diff --git a/backend/services/user-service/package.json b/backend/services/user-service/package.json index 6dd2804..fdb4f14 100644 --- a/backend/services/user-service/package.json +++ b/backend/services/user-service/package.json @@ -13,37 +13,38 @@ "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/platform-express": "^10.3.0", "@nestjs/schedule": "^4.0.0", + "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.0", - "typeorm": "^0.3.19", - "pg": "^8.11.3", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", + "@nestjs/typeorm": "^10.0.1", "bcryptjs": "^2.4.3", - "class-validator": "^0.14.0", "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" + "rxjs": "^7.8.1", + "typeorm": "^0.3.19" }, "devDependencies": { "@nestjs/cli": "^10.3.0", "@nestjs/testing": "^10.3.0", + "@types/bcryptjs": "^2.4.6", + "@types/jest": "^29.5.0", + "@types/multer": "^1.4.11", "@types/node": "^20.11.0", "@types/passport-jwt": "^4.0.1", - "@types/bcryptjs": "^2.4.6", - "@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" + "ts-node": "^10.9.0", + "typescript": "^5.3.0" } } diff --git a/backend/services/user-service/src/application/services/telemetry-scheduler.service.ts b/backend/services/user-service/src/application/services/telemetry-scheduler.service.ts index 271228c..ce7c415 100644 --- a/backend/services/user-service/src/application/services/telemetry-scheduler.service.ts +++ b/backend/services/user-service/src/application/services/telemetry-scheduler.service.ts @@ -6,6 +6,7 @@ import { OnlineSnapshot } from '../../domain/entities/online-snapshot.entity'; import { DailyActiveStats } from '../../domain/entities/daily-active-stats.entity'; import { TelemetryEvent } from '../../domain/entities/telemetry-event.entity'; import { PresenceRedisService } from '../../infrastructure/redis/presence-redis.service'; +import { TelemetryMetricsService } from '../../infrastructure/metrics/telemetry-metrics.service'; @Injectable() export class TelemetrySchedulerService { @@ -16,6 +17,7 @@ export class TelemetrySchedulerService { @InjectRepository(DailyActiveStats) private readonly dauRepo: Repository, @InjectRepository(TelemetryEvent) private readonly eventRepo: Repository, private readonly presenceRedis: PresenceRedisService, + private readonly metrics: TelemetryMetricsService, ) {} /** Record online snapshot every minute */ @@ -23,6 +25,7 @@ export class TelemetrySchedulerService { async recordOnlineSnapshot() { try { const count = await this.presenceRedis.countOnline(); + this.metrics.onlineUsers.set(count); const snapshot = this.snapshotRepo.create({ ts: new Date(), onlineCount: count, @@ -124,16 +127,20 @@ export class TelemetrySchedulerService { if (r.region) dauByRegion[r.region] = parseInt(r.count, 10); } + const dauCount = parseInt(result.dauCount, 10) || 0; + // Upsert await this.dauRepo.upsert( { day: dayStr, - dauCount: parseInt(result.dauCount, 10) || 0, + dauCount, dauByPlatform, dauByRegion, calculatedAt: new Date(), }, ['day'], ); + + this.metrics.dau.set({ date: dayStr }, dauCount); } } diff --git a/backend/services/user-service/src/application/services/telemetry.service.ts b/backend/services/user-service/src/application/services/telemetry.service.ts index cea8c18..8e019c2 100644 --- a/backend/services/user-service/src/application/services/telemetry.service.ts +++ b/backend/services/user-service/src/application/services/telemetry.service.ts @@ -5,6 +5,8 @@ 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 { PresenceRedisService } from '../../infrastructure/redis/presence-redis.service'; +import { TelemetryMetricsService } from '../../infrastructure/metrics/telemetry-metrics.service'; +import { TelemetryProducerService } from '../../infrastructure/kafka/telemetry-producer.service'; @Injectable() export class TelemetryService { @@ -15,6 +17,8 @@ export class TelemetryService { @InjectRepository(OnlineSnapshot) private readonly snapshotRepo: Repository, @InjectRepository(DailyActiveStats) private readonly dauRepo: Repository, private readonly presenceRedis: PresenceRedisService, + private readonly metrics: TelemetryMetricsService, + private readonly kafkaProducer: TelemetryProducerService, ) {} /** Batch insert telemetry events */ @@ -25,6 +29,8 @@ export class TelemetryService { clientTs: number; properties?: Record; }>): Promise<{ recorded: number }> { + const timer = this.metrics.eventBatchDuration.startTimer(); + const entities = events.map((e) => { const event = new TelemetryEvent(); event.userId = e.userId || null; @@ -37,26 +43,43 @@ export class TelemetryService { await this.eventRepo.save(entities); + // Increment event counters + for (const e of events) { + this.metrics.eventsTotal.inc({ event_name: e.eventName }); + } + // Update HyperLogLog for DAU on session_start events const today = new Date().toISOString().slice(0, 10); for (const e of events) { if (e.eventName === 'app_session_start') { const identifier = e.userId || e.installId; await this.presenceRedis.addDauIdentifier(today, identifier); + + // Publish session started event to Kafka + await this.kafkaProducer.publishSessionStarted({ + userId: e.userId, + installId: e.installId, + timestamp: e.clientTs, + properties: e.properties, + }); } } + timer(); return { recorded: entities.length }; } /** Record heartbeat */ async recordHeartbeat(userId: string, installId: string, appVersion: string): Promise { await this.presenceRedis.updatePresence(userId); + this.metrics.heartbeatTotal.inc({ app_version: appVersion }); + await this.kafkaProducer.publishHeartbeat(userId, installId, appVersion); } /** Get current online count */ async getOnlineCount(): Promise<{ count: number; windowSeconds: number; queriedAt: string }> { const count = await this.presenceRedis.countOnline(); + this.metrics.onlineUsers.set(count); return { count, windowSeconds: this.presenceRedis.getWindowSeconds(), diff --git a/backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts b/backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts new file mode 100644 index 0000000..cebfdd6 --- /dev/null +++ b/backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Kafka, Producer } from 'kafkajs'; + +@Injectable() +export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(TelemetryProducerService.name); + private readonly kafka: Kafka; + private producer: Producer; + + constructor() { + this.kafka = new Kafka({ + clientId: 'user-service-telemetry', + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + try { + await this.producer.connect(); + this.logger.log('Kafka telemetry producer connected'); + } catch (err) { + this.logger.warn(`Kafka producer connection failed (non-fatal): ${err.message}`); + } + } + + async onModuleDestroy() { + try { + await this.producer.disconnect(); + } catch (_) {} + } + + async publishSessionStarted(data: { + userId?: string; + installId: string; + timestamp: number; + properties?: Record; + }) { + try { + await this.producer.send({ + topic: 'telemetry.session.started', + messages: [{ + key: data.userId || data.installId, + value: JSON.stringify({ + userId: data.userId, + installId: data.installId, + timestamp: data.timestamp, + properties: data.properties || {}, + source: 'user-service', + }), + }], + }); + } catch (err) { + this.logger.warn(`Failed to publish session event: ${err.message}`); + } + } + + async publishHeartbeat(userId: string, installId: string, appVersion: string) { + try { + await this.producer.send({ + topic: 'telemetry.heartbeat', + messages: [{ + key: userId, + value: JSON.stringify({ userId, installId, appVersion, ts: Date.now() }), + }], + }); + } catch (err) { + // Heartbeat kafka publish is optional, just log + this.logger.debug(`Heartbeat publish skipped: ${err.message}`); + } + } +} diff --git a/backend/services/user-service/src/infrastructure/metrics/telemetry-metrics.service.ts b/backend/services/user-service/src/infrastructure/metrics/telemetry-metrics.service.ts new file mode 100644 index 0000000..92bda8f --- /dev/null +++ b/backend/services/user-service/src/infrastructure/metrics/telemetry-metrics.service.ts @@ -0,0 +1,54 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class TelemetryMetricsService implements OnModuleInit { + readonly onlineUsers: client.Gauge; + readonly dau: client.Gauge; + readonly heartbeatTotal: client.Counter; + readonly eventsTotal: client.Counter; + readonly eventBatchDuration: client.Histogram; + + constructor() { + this.onlineUsers = new client.Gauge({ + name: 'genex_online_users', + help: 'Current online user count', + }); + + this.dau = new client.Gauge({ + name: 'genex_dau', + help: 'Daily active users', + labelNames: ['date'], + }); + + this.heartbeatTotal = new client.Counter({ + name: 'genex_heartbeat_total', + help: 'Total heartbeat count', + labelNames: ['app_version'], + }); + + this.eventsTotal = new client.Counter({ + name: 'genex_events_total', + help: 'Total telemetry events', + labelNames: ['event_name'], + }); + + this.eventBatchDuration = new client.Histogram({ + name: 'genex_event_batch_duration', + help: 'Batch event processing duration in seconds', + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5], + }); + } + + onModuleInit() { + client.collectDefaultMetrics(); + } + + async getMetrics(): Promise { + return client.register.metrics(); + } + + getContentType(): string { + return client.register.contentType; + } +} diff --git a/backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts b/backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts new file mode 100644 index 0000000..4aa4bcf --- /dev/null +++ b/backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; + +interface ParsedPackageInfo { + packageName: string; + versionCode: number; + versionName: string; + minSdkVersion?: string; + platform: 'ANDROID' | 'IOS'; +} + +@Injectable() +export class PackageParserService { + private readonly logger = new Logger(PackageParserService.name); + + async parse(buffer: Buffer, filename: string): Promise { + const ext = filename.split('.').pop()?.toLowerCase(); + if (ext === 'apk') return this.parseApk(buffer); + if (ext === 'ipa') return this.parseIpa(buffer); + throw new BadRequestException('Unsupported file format. Only .apk and .ipa are supported.'); + } + + private async parseApk(buffer: Buffer): Promise { + try { + // Dynamic import to handle missing dependency gracefully + const ApkReader = await import('adbkit-apkreader').then(m => m.default || m); + const reader = await ApkReader.open(buffer); + const manifest = await reader.readManifest(); + return { + packageName: manifest.package || 'unknown', + versionCode: manifest.versionCode || 0, + versionName: manifest.versionName || '0.0.0', + minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(), + platform: 'ANDROID', + }; + } catch (err) { + this.logger.warn(`APK parse failed, using fallback: ${err.message}`); + return { + packageName: 'unknown', + versionCode: 0, + versionName: '0.0.0', + platform: 'ANDROID', + }; + } + } + + private async parseIpa(buffer: Buffer): Promise { + try { + 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)); + if (!plistEntry) throw new Error('Info.plist not found in IPA'); + + const plistBuffer = await plistEntry.buffer(); + const parsed = bplistParser.parseBuffer(plistBuffer); + const info = parsed[0] || {}; + + return { + packageName: info.CFBundleIdentifier || 'unknown', + versionCode: parseInt(info.CFBundleVersion || '0', 10), + versionName: info.CFBundleShortVersionString || '0.0.0', + minSdkVersion: info.MinimumOSVersion, + platform: 'IOS', + }; + } catch (err) { + this.logger.warn(`IPA parse failed, using fallback: ${err.message}`); + return { + packageName: 'unknown', + versionCode: 0, + versionName: '0.0.0', + platform: 'IOS', + }; + } + } +} diff --git a/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts b/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts index 501f6f2..e7a14be 100644 --- a/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts +++ b/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts @@ -7,6 +7,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagg 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'; @ApiTags('Admin - App Versions') @@ -18,6 +19,7 @@ export class AdminVersionController { constructor( private readonly versionService: AppVersionService, private readonly fileStorage: FileStorageService, + private readonly packageParser: PackageParserService, ) {} @Get() @@ -88,11 +90,17 @@ export class AdminVersionController { }, @Req() req: any, ) { - const platform = body.platform.toUpperCase() as Platform; + // Parse package to extract metadata (auto-fill when not provided) + const parsedInfo = await this.packageParser.parse(file.buffer, file.originalname); + + const platform = body.platform + ? (body.platform.toUpperCase() as Platform) + : parsedInfo.platform; const versionCode = body.versionCode ? parseInt(body.versionCode, 10) - : Date.now(); - const versionName = body.versionName || '1.0.0'; + : parsedInfo.versionCode || Date.now(); + const versionName = body.versionName || parsedInfo.versionName || '1.0.0'; + const buildNumber = body.buildNumber || versionCode.toString(); // Upload to MinIO const uploadResult = await this.fileStorage.uploadFile( @@ -106,13 +114,13 @@ export class AdminVersionController { platform, versionCode, versionName, - buildNumber: body.buildNumber || versionCode.toString(), + buildNumber, downloadUrl: uploadResult.downloadUrl, fileSize: uploadResult.fileSize, fileSha256: uploadResult.sha256, changelog: body.changelog || '', isForceUpdate: body.isForceUpdate === 'true', - minOsVersion: body.minOsVersion, + minOsVersion: body.minOsVersion || parsedInfo.minSdkVersion, releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined, createdBy: req.user?.sub, }); @@ -120,6 +128,15 @@ export class AdminVersionController { return { code: 0, data: version }; } + @Post('parse') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Parse APK/IPA without saving (preview metadata)' }) + async parsePackage(@UploadedFile() file: Express.Multer.File) { + const info = await this.packageParser.parse(file.buffer, file.originalname); + return { code: 0, data: info }; + } + @Put(':id') @ApiOperation({ summary: 'Update version' }) async updateVersion( diff --git a/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts b/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts index 651e1ed..689bb15 100644 --- a/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts +++ b/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts @@ -1,12 +1,17 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Param, Query, Res } from '@nestjs/common'; 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'; @ApiTags('App Version') @Controller('app/version') export class AppVersionController { - constructor(private readonly versionService: AppVersionService) {} + constructor( + private readonly versionService: AppVersionService, + private readonly fileStorage: FileStorageService, + ) {} @Get('check') @ApiOperation({ summary: 'Check for app update (mobile client)' }) @@ -23,4 +28,12 @@ export class AppVersionController { ); return { code: 0, data: result }; } + + @Get('download/:id') + @ApiOperation({ summary: 'Download app package' }) + async downloadVersion(@Param('id') id: string, @Res() res: Response) { + const version = await this.versionService.getVersion(id); + // Redirect to the download URL (presigned MinIO URL or external URL) + return res.redirect(302, version.downloadUrl); + } } diff --git a/backend/services/user-service/src/interface/http/controllers/metrics.controller.ts b/backend/services/user-service/src/interface/http/controllers/metrics.controller.ts new file mode 100644 index 0000000..0e91986 --- /dev/null +++ b/backend/services/user-service/src/interface/http/controllers/metrics.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Response } from 'express'; +import { TelemetryMetricsService } from '../../../infrastructure/metrics/telemetry-metrics.service'; + +@ApiExcludeController() +@Controller('metrics') +export class MetricsController { + constructor(private readonly metricsService: TelemetryMetricsService) {} + + @Get() + async getMetrics(@Res() res: Response) { + const metrics = await this.metricsService.getMetrics(); + res.set('Content-Type', this.metricsService.getContentType()); + res.send(metrics); + } +} diff --git a/backend/services/user-service/src/interface/http/dto/batch-events.dto.ts b/backend/services/user-service/src/interface/http/dto/batch-events.dto.ts new file mode 100644 index 0000000..c0b737a --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/batch-events.dto.ts @@ -0,0 +1,28 @@ +import { + IsString, + IsOptional, + IsNumber, + IsUUID, + IsObject, + MaxLength, + ValidateNested, + ArrayMaxSize, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TelemetryEventItem { + @ApiProperty({ example: 'page_view', maxLength: 64 }) @IsString() @MaxLength(64) eventName: string; + @ApiProperty({ example: 'inst_abc123', maxLength: 128 }) @IsString() @MaxLength(128) installId: string; + @ApiPropertyOptional() @IsOptional() @IsUUID() userId?: string; + @ApiProperty({ example: 1700000000000 }) @IsNumber() clientTs: number; + @ApiPropertyOptional({ type: 'object' }) @IsOptional() @IsObject() properties?: Record; +} + +export class BatchEventsDto { + @ApiProperty({ type: [TelemetryEventItem] }) + @ValidateNested({ each: true }) + @Type(() => TelemetryEventItem) + @ArrayMaxSize(500) + events: TelemetryEventItem[]; +} diff --git a/backend/services/user-service/src/interface/http/dto/check-update.dto.ts b/backend/services/user-service/src/interface/http/dto/check-update.dto.ts new file mode 100644 index 0000000..02db4a7 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/check-update.dto.ts @@ -0,0 +1,15 @@ +import { IsIn, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CheckUpdateDto { + @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) + @IsIn(['android', 'ios', 'ANDROID', 'IOS']) + platform: string; + + @ApiProperty({ example: 10, minimum: 1 }) + @Type(() => Number) + @IsInt() + @Min(1) + current_version_code: number; +} diff --git a/backend/services/user-service/src/interface/http/dto/create-version.dto.ts b/backend/services/user-service/src/interface/http/dto/create-version.dto.ts new file mode 100644 index 0000000..c50334b --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/create-version.dto.ts @@ -0,0 +1,37 @@ +import { + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + IsUrl, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateVersionDto { + @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) @IsIn(['android', 'ios', 'ANDROID', 'IOS']) platform: string; + @ApiProperty({ example: 10 }) @IsInt() versionCode: number; + @ApiProperty({ example: '1.2.0', maxLength: 32 }) @IsString() @MaxLength(32) versionName: string; + @ApiProperty({ example: '20250101.1', maxLength: 64 }) @IsString() @MaxLength(64) buildNumber: string; + @ApiProperty({ example: 'https://cdn.example.com/app-1.2.0.apk' }) @IsUrl() downloadUrl: string; + @ApiProperty({ example: '52428800' }) @IsString() fileSize: string; + @ApiProperty({ example: 'a1b2c3d4...', maxLength: 64 }) @IsString() @MaxLength(64) fileSha256: string; + @ApiProperty({ example: 'Bug fixes and performance improvements.' }) @IsString() changelog: string; + @ApiProperty({ example: false }) @IsBoolean() isForceUpdate: boolean; + @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; + @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; +} + +export class UpdateVersionDto { + @ApiPropertyOptional({ example: '1.2.1', maxLength: 32 }) @IsOptional() @IsString() @MaxLength(32) versionName?: string; + @ApiPropertyOptional({ example: '20250102.1', maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) buildNumber?: string; + @ApiPropertyOptional({ example: 'https://cdn.example.com/app-1.2.1.apk' }) @IsOptional() @IsUrl() downloadUrl?: string; + @ApiPropertyOptional({ example: '52428800' }) @IsOptional() @IsString() fileSize?: string; + @ApiPropertyOptional({ example: 'a1b2c3d4...', maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) fileSha256?: string; + @ApiPropertyOptional({ example: 'Updated changelog.' }) @IsOptional() @IsString() changelog?: string; + @ApiPropertyOptional({ example: false }) @IsOptional() @IsBoolean() isForceUpdate?: boolean; + @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; + @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; +} diff --git a/backend/services/user-service/src/interface/http/dto/heartbeat.dto.ts b/backend/services/user-service/src/interface/http/dto/heartbeat.dto.ts new file mode 100644 index 0000000..4b89fae --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/heartbeat.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class HeartbeatDto { + @ApiProperty({ example: 'inst_abc123', maxLength: 128 }) @IsString() @MaxLength(128) installId: string; + @ApiProperty({ example: '1.2.0', maxLength: 32 }) @IsString() @MaxLength(32) appVersion: string; +} diff --git a/backend/services/user-service/src/interface/http/dto/query-dau.dto.ts b/backend/services/user-service/src/interface/http/dto/query-dau.dto.ts new file mode 100644 index 0000000..40f6cf5 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/query-dau.dto.ts @@ -0,0 +1,15 @@ +import { IsDateString, IsOptional, IsString, IsUUID, Min, Max, IsInt } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class QueryDauDto { + @ApiProperty({ example: '2025-01-01' }) @IsDateString() startDate: string; + @ApiProperty({ example: '2025-01-31' }) @IsDateString() endDate: string; +} + +export class QueryEventsDto { + @ApiPropertyOptional({ minimum: 1, default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number; + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit?: number; + @ApiPropertyOptional() @IsOptional() @IsString() eventName?: string; + @ApiPropertyOptional() @IsOptional() @IsUUID() userId?: string; +} diff --git a/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts b/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts new file mode 100644 index 0000000..1013f31 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts @@ -0,0 +1,13 @@ +import { IsDateString, IsIn, IsNumberString, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UploadVersionDto { + @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) @IsIn(['android', 'ios', 'ANDROID', 'IOS']) platform: string; + @ApiPropertyOptional() @IsOptional() @IsNumberString() versionCode?: string; + @ApiPropertyOptional({ maxLength: 32 }) @IsOptional() @IsString() @MaxLength(32) versionName?: string; + @ApiPropertyOptional({ maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) buildNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() changelog?: string; + @ApiPropertyOptional({ enum: ['true', 'false'] }) @IsOptional() @IsIn(['true', 'false']) isForceUpdate?: string; + @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; + @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; +} diff --git a/backend/services/user-service/src/user.module.ts b/backend/services/user-service/src/user.module.ts index 6a39bd6..4639d65 100644 --- a/backend/services/user-service/src/user.module.ts +++ b/backend/services/user-service/src/user.module.ts @@ -21,6 +21,9 @@ import { TransactionRepository } from './infrastructure/persistence/transaction. 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'; @@ -47,6 +50,7 @@ import { TelemetryController } from './interface/http/controllers/telemetry.cont 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: [ @@ -65,6 +69,7 @@ import { AdminVersionController } from './interface/http/controllers/admin-versi AdminDashboardController, AdminUserController, AdminSystemController, AdminAnalyticsController, TelemetryController, AdminTelemetryController, AppVersionController, AdminVersionController, + MetricsController, ], providers: [ UserRepository, KycRepository, WalletRepository, TransactionRepository, MessageRepository, @@ -72,7 +77,8 @@ import { AdminVersionController } from './interface/http/controllers/admin-versi UserProfileService, KycService, WalletService, MessageService, AdminDashboardService, AdminUserService, AdminSystemService, AdminAnalyticsService, TelemetryService, TelemetrySchedulerService, - AppVersionService, FileStorageService, + AppVersionService, FileStorageService, PackageParserService, + TelemetryMetricsService, TelemetryProducerService, ], exports: [UserProfileService, WalletService, MessageService], }) diff --git a/docs/guides/00-UI设计需求.md b/docs/guides/00-UI设计需求.md index 17cf1dd..5b3eff3 100644 --- a/docs/guides/00-UI设计需求.md +++ b/docs/guides/00-UI设计需求.md @@ -460,7 +460,7 @@ AI Agent可代用户执行的操作(均需用户确认): ## I. Utility Track / Securities Track UI隔离设计 -> SRS 1.6要求:Utility Track和Securities Track使用不同的交易市场界面(前端隔离)。MVP阶段仅开放Utility Track。 +> SRS 1.6要求:Utility Track和Securities Track使用不同的交易市场界面(前端隔离)。当前仅开放Utility Track,Securities Track待牌照后启用。 ### I1. 双轨UI隔离原则 @@ -474,12 +474,12 @@ AI Agent可代用户执行的操作(均需用户确认): | **合规标识** | "消费券"标签(绿色) | "投资券"标签(橙色)+ 风险警示 | | **KYC要求** | 交易前提示升级KYC L1 | 交易前提示升级KYC L2+ | -### I2. MVP阶段处理 +### I2. 当前阶段处理 ``` -MVP阶段: -- 只展示Utility Track市场 -- Securities Track入口隐藏(代码预留,配置开关控制) +当前阶段(Utility Track): +- 默认展示Utility Track市场 +- Securities Track入口通过配置开关控制(待牌照后启用) - 所有挂单价格自动校验 ≤ 面值 - 券详情页不显示"投资收益"相关信息 ``` diff --git a/docs/guides/05-后端开发指南.md b/docs/guides/05-后端开发指南.md index 4ecd5b3..cf18535 100644 --- a/docs/guides/05-后端开发指南.md +++ b/docs/guides/05-后端开发指南.md @@ -1426,7 +1426,7 @@ export class ReconciliationService { | 阶段 | 用户规模 | 日交易量 | 基础设施 | |------|---------|---------|---------| -| Phase 1 (MVP) | 10万 | 50万笔 | 单区域K8s(3节点) | +| Phase 1 (基础平台) | 10万 | 50万笔 | 单区域K8s(3节点) | | Phase 2 (商业化) | 100万 | 500万笔 | 双区域K8s + 热备(5+节点) | | Phase 3 (金融化) | 1,000万 | 5,000万笔 | 多区域集群 + GCFN节点 | diff --git a/docs/guides/06-区块链开发指南.md b/docs/guides/06-区块链开发指南.md index 010a199..e904f86 100644 --- a/docs/guides/06-区块链开发指南.md +++ b/docs/guides/06-区块链开发指南.md @@ -189,8 +189,8 @@ contract CouponFactory is Initializable, AccessControlUpgradeable { uint256 quantity, CouponConfig calldata config ) external onlyRole(MINTER_ROLE) returns (uint256[] memory tokenIds) { - // MVP阶段只允许Utility类型 - require(config.couponType == CouponType.Utility, "Only Utility Track in MVP"); + // 当前仅开放Utility类型,Securities Track需牌照后启用 + require(config.couponType == CouponType.Utility, "Only Utility Track enabled"); // Utility Track强制:价格上限 = 面值 if (config.couponType == CouponType.Utility) { @@ -558,16 +558,16 @@ forge verify-contract \ ## 12. GNX原生代币经济模型 -### 12.1 代币用途(MVP阶段) +### 12.1 代币用途 -| 用途 | 说明 | MVP状态 | -|------|------|---------| -| **Gas消耗** | 支付交易费用(平台全额补贴) | 用户不接触 | -| **治理投票** | 参与链参数决策 | 仅平台内部 | -| **质押收益** | 验证节点质押获奖励 | 暂不开放 | -| **二级市场交易** | 交易所买卖GNX | 暂不开放 | +| 用途 | 说明 | 状态 | +|------|------|------| +| **Gas消耗** | 支付交易费用(平台全额补贴) | 已上线 | +| **治理投票** | 参与链参数决策 | 平台内部运行 | +| **质押收益** | 验证节点质押获奖励 | 待法律意见书后开放 | +| **二级市场交易** | 交易所买卖GNX | 待合规审批后开放 | -> MVP阶段GNX仅用于Gas(平台补贴),不上交易所,回避SEC证券风险。质押开放需取得法律意见书。 +> GNX当前用于Gas(平台补贴),不上交易所,回避SEC证券风险。质押和二级市场交易需取得法律意见书后开放。 ### 12.2 代币分配(预留设计) @@ -584,7 +584,7 @@ forge verify-contract \ ```go // genex-chain/x/evm/keeper/gas.go -// MVP阶段:Gas Price = 0,平台全额补贴 +// 当前阶段:Gas Price = 0,平台全额补贴 // 后期可通过Governance合约调整Gas参数 func (k Keeper) GetBaseFee(ctx sdk.Context) *big.Int { @@ -926,7 +926,7 @@ contract ExchangeRateOracle is Initializable { // 仅在Securities Track + Broker-Dealer牌照后启用 /** - * @notice 预留接口定义,不在MVP实现 + * @notice 预留接口定义,待Securities Track启用后实现 * - 券收益流打包 * - 信用评级接口 * - 收益曲线计算 diff --git a/docs/guides/07-遥测与版本管理开发指南.md b/docs/guides/07-遥测与版本管理开发指南.md index 0820d04..e45c55a 100644 --- a/docs/guides/07-遥测与版本管理开发指南.md +++ b/docs/guides/07-遥测与版本管理开发指南.md @@ -39,17 +39,15 @@ Genex 项目的适配方案: | 功能 | 归属服务 | 理由 | |------|---------|------| -| **遥测 (Telemetry)** | **user-service (扩展)** | Genex 用户量 MVP 阶段较小,无需独立服务;遥测与用户强关联 | -| **版本管理 (App Version)** | **user-service (扩展)** | 版本管理 API 量少,admin 端已在 user-service 中 | - -> 后续用户量增长可拆分为独立 presence-service +| **遥测 (Telemetry)** | **user-service (扩展)** | 遥测与用户强关联,共享用户表和 Redis 连接,减少跨服务调用 | +| **版本管理 (App Version)** | **user-service (扩展)** | 版本管理与用户设备信息紧密耦合,统一管理降低运维复杂度 | ### 2.2 与参考项目的差异 | 维度 | rwadurian | Genex 适配 | |------|-----------|-----------| | ORM | Prisma | TypeORM (与现有一致) | -| 架构 | 独立服务 + CQRS | 扩展 user-service,标准 Service/Controller | +| 架构 | 独立服务 + CQRS | 扩展 user-service,DDD 四层架构 | | 文件存储 | 本地 `./uploads` | **MinIO** (已有基础设施) | | 事件总线 | Kafka | **Kafka** (已有 @genex/kafka-client) | | 缓存 | Redis | **Redis** (已有) | @@ -371,7 +369,7 @@ Flutter/Taro 客户端需要: --- -## 七、Prometheus 指标 (可选) +## 七、Prometheus 指标 | 指标名 | 类型 | 标签 | 说明 | |--------|------|------|------|