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:
parent
4da8a373f2
commit
e20c321d12
|
|
@ -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)
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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'])
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum Platform {
|
||||
ANDROID = 'ANDROID',
|
||||
IOS = 'IOS',
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
}),
|
||||
}],
|
||||
});
|
||||
|
|
@ -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')
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
}>;
|
||||
}) {
|
||||
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 } };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,36 +228,56 @@ 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/清理)
|
||||
├── 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 文件上传/下载
|
||||
|
|
@ -267,8 +286,9 @@ services/user-service/src/
|
|||
│ └── 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 新增路由
|
||||
# telemetry-service (:3011)
|
||||
- name: telemetry-service
|
||||
url: http://telemetry-service:3011
|
||||
routes:
|
||||
- name: telemetry-routes
|
||||
paths:
|
||||
- /api/v1/telemetry
|
||||
strip_path: false
|
||||
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
|
||||
strip_path: false
|
||||
paths: [/api/v1/app/version]
|
||||
- name: admin-version-routes
|
||||
paths:
|
||||
- /api/v1/admin/versions
|
||||
strip_path: false
|
||||
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]
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in New Issue