refactor: 遥测与版本管理拆分为独立微服务 (telemetry-service + admin-service)

架构重构: 将遥测(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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-12 18:30:39 -08:00
parent 4da8a373f2
commit e20c321d12
56 changed files with 1130 additions and 166 deletions

View File

@ -118,6 +118,7 @@ services:
mc mb --ignore-existing genex/sar-reports; mc mb --ignore-existing genex/sar-reports;
mc mb --ignore-existing genex/avatars; mc mb --ignore-existing genex/avatars;
mc mb --ignore-existing genex/exports; 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/coupon-images;
mc anonymous set download genex/avatars; mc anonymous set download genex/avatars;
echo 'MinIO buckets initialized'; echo 'MinIO buckets initialized';
@ -313,6 +314,74 @@ services:
networks: networks:
- genex-network - 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) # Go Services (3)
# ============================================================ # ============================================================

View File

@ -43,6 +43,11 @@ services:
paths: paths:
- /api/v1/admin/system - /api/v1/admin/system
strip_path: false strip_path: false
# --- telemetry-service (NestJS :3011) ---
- name: telemetry-service
url: http://telemetry-service:3011
routes:
- name: telemetry-routes - name: telemetry-routes
paths: paths:
- /api/v1/telemetry - /api/v1/telemetry
@ -51,6 +56,11 @@ services:
paths: paths:
- /api/v1/admin/telemetry - /api/v1/admin/telemetry
strip_path: false strip_path: false
# --- admin-service (NestJS :3012) - App version management ---
- name: admin-service
url: http://admin-service:3012
routes:
- name: app-version-routes - name: app-version-routes
paths: paths:
- /api/v1/app/version - /api/v1/app/version

View File

@ -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"]

View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from '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() @Injectable()
export class AppVersionService { export class AppVersionService {

View File

@ -2,11 +2,7 @@ import {
Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn,
UpdateDateColumn, VersionColumn, Index, UpdateDateColumn, VersionColumn, Index,
} from 'typeorm'; } from 'typeorm';
import { Platform } from '../enums/platform.enum';
export enum Platform {
ANDROID = 'ANDROID',
IOS = 'IOS',
}
@Entity('app_versions') @Entity('app_versions')
@Index('idx_app_versions_platform', ['platform', 'isEnabled']) @Index('idx_app_versions_platform', ['platform', 'isEnabled'])

View File

@ -0,0 +1,4 @@
export enum Platform {
ANDROID = 'ANDROID',
IOS = 'IOS',
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -32,7 +32,7 @@ export class PackageParserService {
minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(), minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(),
platform: 'ANDROID', platform: 'ANDROID',
}; };
} catch (err) { } catch (err: any) {
this.logger.warn(`APK parse failed, using fallback: ${err.message}`); this.logger.warn(`APK parse failed, using fallback: ${err.message}`);
return { return {
packageName: 'unknown', packageName: 'unknown',
@ -48,7 +48,7 @@ export class PackageParserService {
const unzipper = await import('unzipper'); const unzipper = await import('unzipper');
const bplistParser = await import('bplist-parser'); const bplistParser = await import('bplist-parser');
const directory = await unzipper.Open.buffer(buffer); 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'); if (!plistEntry) throw new Error('Info.plist not found in IPA');
const plistBuffer = await plistEntry.buffer(); const plistBuffer = await plistEntry.buffer();
@ -62,7 +62,7 @@ export class PackageParserService {
minSdkVersion: info.MinimumOSVersion, minSdkVersion: info.MinimumOSVersion,
platform: 'IOS', platform: 'IOS',
}; };
} catch (err) { } catch (err: any) {
this.logger.warn(`IPA parse failed, using fallback: ${err.message}`); this.logger.warn(`IPA parse failed, using fallback: ${err.message}`);
return { return {
packageName: 'unknown', packageName: 'unknown',

View File

@ -8,9 +8,9 @@ import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
import { AppVersionService } from '../../../application/services/app-version.service'; import { AppVersionService } from '../../../application/services/app-version.service';
import { FileStorageService } from '../../../application/services/file-storage.service'; import { FileStorageService } from '../../../application/services/file-storage.service';
import { PackageParserService } from '../../../infrastructure/parsers/package-parser.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') @Controller('admin/versions')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)

View File

@ -3,9 +3,9 @@ import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { AppVersionService } from '../../../application/services/app-version.service'; import { AppVersionService } from '../../../application/services/app-version.service';
import { FileStorageService } from '../../../application/services/file-storage.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') @Controller('app/version')
export class AppVersionController { export class AppVersionController {
constructor( constructor(

View File

@ -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(),
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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/**/*"]
}

View File

@ -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"]

View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View File

@ -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"
}
}

View File

@ -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(),
};
}
}

View File

@ -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<string, any>,
) {
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(),
};
}
}

View File

@ -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';
}
}

View File

@ -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)}`;
}
}

View File

@ -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`;
}
}

View File

@ -9,7 +9,7 @@ export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy {
constructor() { constructor() {
this.kafka = new Kafka({ this.kafka = new Kafka({
clientId: 'user-service-telemetry', clientId: 'telemetry-service',
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
}); });
this.producer = this.kafka.producer(); this.producer = this.kafka.producer();
@ -46,7 +46,7 @@ export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy {
installId: data.installId, installId: data.installId,
timestamp: data.timestamp, timestamp: data.timestamp,
properties: data.properties || {}, properties: data.properties || {},
source: 'user-service', source: 'telemetry-service',
}), }),
}], }],
}); });

View File

@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TelemetryEvent } from '../../../domain/entities/telemetry-event.entity'; import { TelemetryEvent } from '../../../domain/entities/telemetry-event.entity';
import { PresenceRedisService } from '../../../infrastructure/redis/presence-redis.service'; import { PresenceRedisService } from '../../../infrastructure/redis/presence-redis.service';
import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto';
@ApiTags('admin-telemetry') @ApiTags('admin-telemetry')
@Controller('admin/telemetry') @Controller('admin/telemetry')
@ -21,29 +22,27 @@ export class AdminTelemetryController {
@Get('dau') @Get('dau')
@ApiOperation({ summary: 'Query DAU statistics' }) @ApiOperation({ summary: 'Query DAU statistics' })
async getDauStats(@Query('startDate') startDate: string, @Query('endDate') endDate: string) { async getDauStats(@Query() query: QueryDauDto) {
const result = await this.telemetryService.getDauStats(startDate, endDate); const result = await this.telemetryService.getDauStats(query.startDate, query.endDate);
return { code: 0, data: result }; return { code: 0, data: result };
} }
@Get('events') @Get('events')
@ApiOperation({ summary: 'Query telemetry events' }) @ApiOperation({ summary: 'Query telemetry events' })
async listEvents( async listEvents(@Query() query: QueryEventsDto) {
@Query('page') page = 1, const page = query.page || 1;
@Query('limit') limit = 20, const limit = query.limit || 20;
@Query('eventName') eventName?: string,
@Query('userId') userId?: string,
) {
const qb = this.eventRepo.createQueryBuilder('e'); const qb = this.eventRepo.createQueryBuilder('e');
if (eventName) qb.andWhere('e.event_name = :eventName', { eventName }); if (query.eventName) qb.andWhere('e.event_name = :eventName', { eventName: query.eventName });
if (userId) qb.andWhere('e.user_id = :userId', { userId }); if (query.userId) qb.andWhere('e.user_id = :userId', { userId: query.userId });
qb.orderBy('e.event_time', 'DESC') qb.orderBy('e.event_time', 'DESC')
.skip((+page - 1) * +limit) .skip((page - 1) * limit)
.take(+limit); .take(limit);
const [items, total] = await qb.getManyAndCount(); 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') @Get('realtime')

View File

@ -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(),
};
}
}

View File

@ -2,6 +2,8 @@ import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/comm
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@genex/common'; import { JwtAuthGuard } from '@genex/common';
import { TelemetryService } from '../../../application/services/telemetry.service'; import { TelemetryService } from '../../../application/services/telemetry.service';
import { BatchEventsDto } from '../dto/batch-events.dto';
import { HeartbeatDto } from '../dto/heartbeat.dto';
@ApiTags('telemetry') @ApiTags('telemetry')
@Controller('telemetry') @Controller('telemetry')
@ -10,15 +12,7 @@ export class TelemetryController {
@Post('events') @Post('events')
@ApiOperation({ summary: 'Batch report telemetry events (no auth required)' }) @ApiOperation({ summary: 'Batch report telemetry events (no auth required)' })
async batchEvents(@Body() body: { async batchEvents(@Body() body: BatchEventsDto) {
events: Array<{
eventName: string;
userId?: string;
installId: string;
clientTs: number;
properties?: Record<string, any>;
}>;
}) {
const result = await this.telemetryService.recordEvents(body.events); const result = await this.telemetryService.recordEvents(body.events);
return { code: 0, data: result }; return { code: 0, data: result };
} }
@ -27,7 +21,7 @@ export class TelemetryController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Report heartbeat for online detection' }) @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); await this.telemetryService.recordHeartbeat(req.user.sub, body.installId, body.appVersion);
return { code: 0, data: { success: true } }; return { code: 0, data: { success: true } };
} }

View File

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

View File

@ -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 {}

View File

@ -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/**/*"]
}

View File

@ -16,7 +16,6 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.2.0", "@nestjs/swagger": "^7.2.0",
"@nestjs/throttler": "^5.1.0", "@nestjs/throttler": "^5.1.0",
"@nestjs/typeorm": "^10.0.1", "@nestjs/typeorm": "^10.0.1",
@ -24,12 +23,9 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"minio": "^8.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.19" "typeorm": "^0.3.19"

View File

@ -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;
}

View File

@ -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;
}

View File

@ -2,17 +2,12 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { ScheduleModule } from '@nestjs/schedule';
import { User } from './domain/entities/user.entity'; import { User } from './domain/entities/user.entity';
import { KycSubmission } from './domain/entities/kyc-submission.entity'; import { KycSubmission } from './domain/entities/kyc-submission.entity';
import { Wallet } from './domain/entities/wallet.entity'; import { Wallet } from './domain/entities/wallet.entity';
import { Transaction } from './domain/entities/transaction.entity'; import { Transaction } from './domain/entities/transaction.entity';
import { Message } from './domain/entities/message.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 { UserRepository } from './infrastructure/persistence/user.repository';
import { KycRepository } from './infrastructure/persistence/kyc.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 { TransactionRepository } from './infrastructure/persistence/transaction.repository';
import { MessageRepository } from './infrastructure/persistence/message.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 { UserProfileService } from './application/services/user-profile.service';
import { KycService } from './application/services/kyc.service'; import { KycService } from './application/services/kyc.service';
import { WalletService } from './application/services/wallet.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 { AdminUserService } from './application/services/admin-user.service';
import { AdminSystemService } from './application/services/admin-system.service'; import { AdminSystemService } from './application/services/admin-system.service';
import { AdminAnalyticsService } from './application/services/admin-analytics.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 { UserController } from './interface/http/controllers/user.controller';
import { KycController } from './interface/http/controllers/kyc.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 { AdminUserController } from './interface/http/controllers/admin-user.controller';
import { AdminSystemController } from './interface/http/controllers/admin-system.controller'; import { AdminSystemController } from './interface/http/controllers/admin-system.controller';
import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.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({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
User, KycSubmission, Wallet, Transaction, Message, AppVersion, User, KycSubmission, Wallet, Transaction, Message,
TelemetryEvent, OnlineSnapshot, DailyActiveStats,
]), ]),
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret', secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
}), }),
ScheduleModule.forRoot(),
], ],
controllers: [ controllers: [
UserController, KycController, WalletController, MessageController, UserController, KycController, WalletController, MessageController,
AdminDashboardController, AdminUserController, AdminSystemController, AdminAnalyticsController, AdminDashboardController, AdminUserController, AdminSystemController, AdminAnalyticsController,
TelemetryController, AdminTelemetryController,
AppVersionController, AdminVersionController,
MetricsController,
], ],
providers: [ providers: [
UserRepository, KycRepository, WalletRepository, TransactionRepository, MessageRepository, UserRepository, KycRepository, WalletRepository, TransactionRepository, MessageRepository,
PresenceRedisService,
UserProfileService, KycService, WalletService, MessageService, UserProfileService, KycService, WalletService, MessageService,
AdminDashboardService, AdminUserService, AdminSystemService, AdminAnalyticsService, AdminDashboardService, AdminUserService, AdminSystemService, AdminAnalyticsService,
TelemetryService, TelemetrySchedulerService,
AppVersionService, FileStorageService, PackageParserService,
TelemetryMetricsService, TelemetryProducerService,
], ],
exports: [UserProfileService, WalletService, MessageService], exports: [UserProfileService, WalletService, MessageService],
}) })

View File

@ -34,22 +34,21 @@
### 2.1 架构决策 ### 2.1 架构决策
rwadurian 项目将遥测放在独立的 `presence-service`,版本管理放在 `admin-service` 参考 rwadurian 项目的独立服务架构Genex 同样采用独立微服务方案:
Genex 项目的适配方案:
| 功能 | 归属服务 | 理由 | | 功能 | 归属服务 | 端口 | 理由 |
|------|---------|------| |------|---------|------|------|
| **遥测 (Telemetry)** | **user-service (扩展)** | 遥测与用户强关联,共享用户表和 Redis 连接,减少跨服务调用 | | **遥测 (Telemetry)** | **telemetry-service** | :3011 | 独立微服务专注心跳检测、事件采集、DAU 统计、Prometheus 指标 |
| **版本管理 (App Version)** | **user-service (扩展)** | 版本管理与用户设备信息紧密耦合,统一管理降低运维复杂度 | | **版本管理 (App Version)** | **admin-service** | :3012 | 独立微服务,专注 APK/IPA 管理、OTA 更新、MinIO 文件存储 |
### 2.2 与参考项目的差异 ### 2.2 与参考项目的差异
| 维度 | rwadurian | Genex 适配 | | 维度 | rwadurian | Genex |
|------|-----------|-----------| |------|-----------|-------|
| ORM | Prisma | TypeORM (与现有一致) | | ORM | Prisma | TypeORM (与现有一致) |
| 架构 | 独立服务 + CQRS | 扩展 user-serviceDDD 四层架构 | | 架构 | 独立服务 + CQRS | **独立服务 + DDD 四层架构** |
| 文件存储 | 本地 `./uploads` | **MinIO** (已有基础设施) | | 文件存储 | 本地 `./uploads` | **MinIO** (已有基础设施) |
| 事件总线 | Kafka | **Kafka** (已有 @genex/kafka-client) | | 事件总线 | Kafka | **Kafka** (KRaft 模式) |
| 缓存 | Redis | **Redis** (已有) | | 缓存 | Redis | **Redis** (已有) |
| APK 解析 | adbkit-apkreader | 同方案 | | APK 解析 | adbkit-apkreader | 同方案 |
| IPA 解析 | unzipper + bplist-parser | 同方案 | | 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/ services/telemetry-service/src/
├── domain/entities/ ├── domain/
│ ├── telemetry-event.entity.ts # 事件日志实体 │ ├── entities/
│ ├── online-snapshot.entity.ts # 在线快照实体 │ │ ├── telemetry-event.entity.ts # 事件日志实体
│ └── daily-active-stats.entity.ts # DAU 统计实体 │ │ ├── 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/ ├── application/services/
│ ├── telemetry.service.ts # 事件采集 + 心跳 + DAU │ ├── telemetry.service.ts # 事件采集 + 心跳 + DAU
│ └── telemetry-scheduler.service.ts # 定时任务 (快照/DAU/清理) │ └── telemetry-scheduler.service.ts # 定时任务 (快照/DAU/清理)
├── infrastructure/ ├── infrastructure/
│ └── redis/ │ ├── redis/
│ └── presence-redis.service.ts # Redis 在线检测操作 │ │ └── presence-redis.service.ts # Redis 在线检测操作
│ ├── kafka/
│ │ └── telemetry-producer.service.ts # Kafka 事件发布
│ └── metrics/
│ └── telemetry-metrics.service.ts # Prometheus 指标
└── interface/http/ └── interface/http/
├── controllers/ ├── controllers/
│ ├── telemetry.controller.ts # 遥测 API │ ├── telemetry.controller.ts # 遥测 API (心跳/事件/在线)
│ └── admin-telemetry.controller.ts # Admin 遥测分析 API │ ├── admin-telemetry.controller.ts # Admin 遥测分析 API
│ ├── metrics.controller.ts # GET /metrics (Prometheus)
│ └── health.controller.ts # 健康检查
└── dto/ └── dto/
├── batch-events.dto.ts ├── batch-events.dto.ts
├── heartbeat.dto.ts ├── heartbeat.dto.ts
└── query-dau.dto.ts └── query-dau.dto.ts
``` ```
### 4.2 版本管理模块 (在 user-service 中扩展) ### 4.2 版本管理服务 (admin-service :3012)
``` ```
services/user-service/src/ services/admin-service/src/
├── domain/entities/ ├── domain/
│ └── app-version.entity.ts # 版本实体 │ ├── 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/ ├── application/services/
│ ├── app-version.service.ts # 版本 CRUD + 检查更新 │ ├── app-version.service.ts # 版本 CRUD + 检查更新
│ └── file-storage.service.ts # MinIO 文件上传/下载 │ └── file-storage.service.ts # MinIO 文件上传/下载
├── infrastructure/ ├── infrastructure/
│ └── parsers/ │ └── parsers/
│ └── package-parser.service.ts # APK/IPA 解析 │ └── package-parser.service.ts # APK/IPA 解析
└── interface/http/ └── interface/http/
├── controllers/ ├── controllers/
│ ├── app-version.controller.ts # 移动端检查更新 + 下载 │ ├── app-version.controller.ts # 移动端检查更新 + 下载 (无认证)
│ └── admin-version.controller.ts # Admin 版本管理 │ ├── admin-version.controller.ts # Admin 版本 CRUD (JWT+ADMIN)
│ └── health.controller.ts # 健康检查
└── dto/ └── dto/
├── check-update.dto.ts ├── check-update.dto.ts
├── create-version.dto.ts ├── create-version.dto.ts
@ -285,22 +305,40 @@ migrations/
└── 035_create_app_versions.sql └── 035_create_app_versions.sql
``` ```
### 4.4 Kong 路由 (新增) ### 4.4 Kong 路由
```yaml ```yaml
# user-service 新增路由 # telemetry-service (:3011)
- name: telemetry-routes - name: telemetry-service
paths: url: http://telemetry-service:3011
- /api/v1/telemetry routes:
strip_path: false - name: telemetry-routes
- name: app-version-routes paths: [/api/v1/telemetry]
paths: - name: admin-telemetry-routes
- /api/v1/app/version paths: [/api/v1/admin/telemetry]
strip_path: false
- name: admin-version-routes # admin-service (:3012)
paths: - name: admin-service
- /api/v1/admin/versions url: http://admin-service:3012
strip_path: false 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]
``` ```
--- ---