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/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)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 {
|
||||||
|
|
@ -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'])
|
||||||
|
|
@ -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(),
|
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',
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -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() {
|
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',
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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 { 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 } };
|
||||||
}
|
}
|
||||||
|
|
@ -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/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"
|
||||||
|
|
|
||||||
|
|
@ -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 { 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],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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-service,DDD 四层架构 |
|
| 架构 | 独立服务 + 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]
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue