From c45ed8a5754c9855c3ace52c9bbbeaa93ba1554c Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 2 Dec 2025 11:22:30 -0800 Subject: [PATCH] =?UTF-8?q?feat(admin-service):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=BA=94=E7=94=A8=E7=89=88=E6=9C=AC=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDD+Hexagonal架构 - Domain层: AppVersion实体, Platform枚举, Repository接口 - Infrastructure层: Prisma集成, Repository实现 - Application层: CheckUpdate查询(供移动端), CreateVersion命令(管理员) - API层: VersionController, DTOs (request/response) - 数据库: app_versions表设计(支持Android/iOS) - 功能: 版本检查、强制更新、文件SHA-256校验 - 部署: Dockerfile, docker-compose.yml, deploy.sh脚本 - 数据库迁移: Prisma migration初始化 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/services/admin-service/.env.example | 21 +++ backend/services/admin-service/.gitignore | 44 ++++++ backend/services/admin-service/Dockerfile | 70 +++++++++- backend/services/admin-service/deploy.sh | 125 ++++++++++++++++++ backend/services/admin-service/nest-cli.json | 8 ++ backend/services/admin-service/package.json | 60 +++++++++ .../20250102100000_init/migration.sql | 31 +++++ .../prisma/migrations/migration_lock.toml | 3 + .../admin-service/prisma/schema.prisma | 45 +++++++ .../src/api/controllers/health.controller.ts | 16 +++ .../src/api/controllers/version.controller.ts | 78 +++++++++++ .../src/api/dto/request/check-update.dto.ts | 14 ++ .../src/api/dto/request/create-version.dto.ts | 52 ++++++++ .../src/api/dto/response/version.dto.ts | 69 ++++++++++ .../services/admin-service/src/app.module.ts | 30 +++++ .../create-version/create-version.command.ts | 18 +++ .../create-version/create-version.handler.ts | 43 ++++++ .../check-update/check-update.handler.ts | 57 ++++++++ .../check-update/check-update.query.ts | 8 ++ .../admin-service/src/config/index.ts | 23 ++++ .../src/domain/entities/app-version.entity.ts | 66 +++++++++ .../src/domain/enums/platform.enum.ts | 4 + .../repositories/app-version.repository.ts | 46 +++++++ .../persistence/prisma/prisma.service.ts | 22 +++ .../app-version.repository.impl.ts | 118 +++++++++++++++++ backend/services/admin-service/src/main.ts | 49 +++++++ backend/services/admin-service/tsconfig.json | 24 ++++ backend/services/docker-compose.yml | 63 +++++---- 28 files changed, 1170 insertions(+), 37 deletions(-) create mode 100644 backend/services/admin-service/.env.example create mode 100644 backend/services/admin-service/.gitignore create mode 100644 backend/services/admin-service/deploy.sh create mode 100644 backend/services/admin-service/nest-cli.json create mode 100644 backend/services/admin-service/prisma/migrations/20250102100000_init/migration.sql create mode 100644 backend/services/admin-service/prisma/migrations/migration_lock.toml create mode 100644 backend/services/admin-service/prisma/schema.prisma create mode 100644 backend/services/admin-service/src/api/controllers/health.controller.ts create mode 100644 backend/services/admin-service/src/api/controllers/version.controller.ts create mode 100644 backend/services/admin-service/src/api/dto/request/check-update.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/request/create-version.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/response/version.dto.ts create mode 100644 backend/services/admin-service/src/app.module.ts create mode 100644 backend/services/admin-service/src/application/commands/create-version/create-version.command.ts create mode 100644 backend/services/admin-service/src/application/commands/create-version/create-version.handler.ts create mode 100644 backend/services/admin-service/src/application/queries/check-update/check-update.handler.ts create mode 100644 backend/services/admin-service/src/application/queries/check-update/check-update.query.ts create mode 100644 backend/services/admin-service/src/config/index.ts create mode 100644 backend/services/admin-service/src/domain/entities/app-version.entity.ts create mode 100644 backend/services/admin-service/src/domain/enums/platform.enum.ts create mode 100644 backend/services/admin-service/src/domain/repositories/app-version.repository.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/prisma/prisma.service.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts create mode 100644 backend/services/admin-service/src/main.ts diff --git a/backend/services/admin-service/.env.example b/backend/services/admin-service/.env.example new file mode 100644 index 00000000..c8d55a4a --- /dev/null +++ b/backend/services/admin-service/.env.example @@ -0,0 +1,21 @@ +# ============================================================================= +# Admin Service - Environment Variables Example +# ============================================================================= + +# Application +NODE_ENV=production +APP_PORT=3010 +API_PREFIX=api/v1 + +# Database +DATABASE_URL=postgresql://rwa_user:rwa_secure_password@postgres:5432/rwa_admin?schema=public + +# JWT +JWT_SECRET=your-jwt-secret-here +JWT_EXPIRES_IN=7d + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=9 diff --git a/backend/services/admin-service/.gitignore b/backend/services/admin-service/.gitignore new file mode 100644 index 00000000..e1f948ce --- /dev/null +++ b/backend/services/admin-service/.gitignore @@ -0,0 +1,44 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Environment +.env +.env.local +.env.*.local + +# Prisma +/prisma/*.db +/prisma/*.db-journal diff --git a/backend/services/admin-service/Dockerfile b/backend/services/admin-service/Dockerfile index 920c8104..2a08ef4d 100644 --- a/backend/services/admin-service/Dockerfile +++ b/backend/services/admin-service/Dockerfile @@ -1,10 +1,70 @@ # ============================================================================= # Admin Service Dockerfile # ============================================================================= -# NOTE: This service is not yet implemented. Placeholder only. -# ============================================================================= -FROM node:20-alpine +# Build stage +FROM node:20-alpine AS builder + WORKDIR /app -RUN echo "Admin service not yet implemented" > /app/README.txt -CMD ["cat", "/app/README.txt"] + +# Copy package files +COPY package*.json ./ +COPY tsconfig*.json ./ +COPY nest-cli.json ./ + +# Copy Prisma schema +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Generate Prisma client (dummy DATABASE_URL for build time only) +RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate + +# Copy source code +COPY src ./src + +# Build TypeScript +RUN npm run build + +# Verify build output exists +RUN ls -la dist/ && test -f dist/main.js + +# Production stage - use Debian slim for OpenSSL compatibility +FROM node:20-slim + +WORKDIR /app + +# Install OpenSSL and curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install production dependencies only +COPY package*.json ./ +RUN npm ci --only=production + +# Copy Prisma schema and generate client (dummy DATABASE_URL for build time only) +COPY prisma ./prisma/ +RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN groupadd -g 1001 nodejs && \ + useradd -u 1001 -g nodejs nestjs + +# Switch to non-root user +USER nestjs + +# Expose port +EXPOSE 3010 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:3010/api/v1/health || exit 1 + +# Start service +CMD ["node", "dist/main.js"] diff --git a/backend/services/admin-service/deploy.sh b/backend/services/admin-service/deploy.sh new file mode 100644 index 00000000..d4331427 --- /dev/null +++ b/backend/services/admin-service/deploy.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# ============================================================================= +# Admin Service - Individual Deployment Script +# ============================================================================= + +set -e + +SERVICE_NAME="admin-service" +CONTAINER_NAME="rwa-admin-service" +IMAGE_NAME="services-admin-service" +PORT=3010 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICES_DIR="$(dirname "$SCRIPT_DIR")" + +# Load environment +if [ -f "$SERVICES_DIR/.env" ]; then + export $(cat "$SERVICES_DIR/.env" | grep -v '^#' | xargs) +fi + +case "$1" in + build) + log_info "Building $SERVICE_NAME..." + docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" + log_success "$SERVICE_NAME built successfully" + ;; + + build-no-cache) + log_info "Building $SERVICE_NAME (no cache)..." + docker build --no-cache -t "$IMAGE_NAME" "$SCRIPT_DIR" + log_success "$SERVICE_NAME built successfully" + ;; + + start) + log_info "Starting $SERVICE_NAME..." + cd "$SERVICES_DIR" + docker compose up -d "$SERVICE_NAME" + log_success "$SERVICE_NAME started" + ;; + + stop) + log_info "Stopping $SERVICE_NAME..." + docker stop "$CONTAINER_NAME" 2>/dev/null || true + docker rm "$CONTAINER_NAME" 2>/dev/null || true + log_success "$SERVICE_NAME stopped" + ;; + + restart) + $0 stop + $0 start + ;; + + logs) + docker logs -f "$CONTAINER_NAME" + ;; + + logs-tail) + docker logs --tail 100 "$CONTAINER_NAME" + ;; + + status) + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_success "$SERVICE_NAME is running" + docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Status}}\t{{.Ports}}" + else + log_warn "$SERVICE_NAME is not running" + fi + ;; + + shell) + log_info "Entering $SERVICE_NAME container shell..." + docker exec -it "$CONTAINER_NAME" sh + ;; + + migrate) + log_info "Running database migrations for $SERVICE_NAME..." + docker exec "$CONTAINER_NAME" npx prisma migrate deploy + log_success "Migrations completed" + ;; + + migrate-dev) + log_info "Running dev migrations for $SERVICE_NAME..." + docker exec -it "$CONTAINER_NAME" npx prisma migrate dev + log_success "Dev migrations completed" + ;; + + clean) + log_warn "Cleaning $SERVICE_NAME (removing container and volumes)..." + $0 stop + docker volume rm "${SERVICE_NAME}_node_modules" 2>/dev/null || true + log_success "$SERVICE_NAME cleaned" + ;; + + *) + echo "Usage: $0 {build|build-no-cache|start|stop|restart|logs|logs-tail|status|shell|migrate|migrate-dev|clean}" + echo "" + echo "Commands:" + echo " build - Build Docker image" + echo " build-no-cache - Build Docker image without cache" + echo " start - Start service" + echo " stop - Stop service" + echo " restart - Restart service" + echo " logs - Follow logs" + echo " logs-tail - Show last 100 lines of logs" + echo " status - Check service status" + echo " shell - Enter container shell" + echo " migrate - Run database migrations (production)" + echo " migrate-dev - Run database migrations (development)" + echo " clean - Remove container and volumes" + exit 1 + ;; +esac diff --git a/backend/services/admin-service/nest-cli.json b/backend/services/admin-service/nest-cli.json new file mode 100644 index 00000000..f9aa683b --- /dev/null +++ b/backend/services/admin-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json index e69de29b..555996df 100644 --- a/backend/services/admin-service/package.json +++ b/backend/services/admin-service/package.json @@ -0,0 +1,60 @@ +{ + "name": "admin-service", + "version": "1.0.0", + "description": "RWA Admin Service - Mobile App Version Management", + "author": "RWA Team", + "private": true, + "license": "UNLICENSED", + "prisma": { + "schema": "prisma/schema.prisma" + }, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:prod": "prisma migrate deploy", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.17", + "@prisma/client": "^5.7.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "ioredis": "^5.3.2", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "prettier": "^3.0.0", + "prisma": "^5.7.0", + "source-map-support": "^0.5.21", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + } +} diff --git a/backend/services/admin-service/prisma/migrations/20250102100000_init/migration.sql b/backend/services/admin-service/prisma/migrations/20250102100000_init/migration.sql new file mode 100644 index 00000000..f94fb1f8 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20250102100000_init/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "Platform" AS ENUM ('ANDROID', 'IOS'); + +-- CreateTable +CREATE TABLE "app_versions" ( + "id" TEXT NOT NULL, + "platform" "Platform" NOT NULL, + "versionCode" INTEGER NOT NULL, + "versionName" TEXT NOT NULL, + "buildNumber" TEXT NOT NULL, + "downloadUrl" TEXT NOT NULL, + "fileSize" BIGINT NOT NULL, + "fileSha256" TEXT NOT NULL, + "minOsVersion" TEXT, + "changelog" TEXT NOT NULL, + "isForceUpdate" BOOLEAN NOT NULL DEFAULT false, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "releaseDate" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdBy" TEXT NOT NULL, + "updatedBy" TEXT, + + CONSTRAINT "app_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "app_versions_platform_isEnabled_idx" ON "app_versions"("platform", "isEnabled"); + +-- CreateIndex +CREATE INDEX "app_versions_platform_versionCode_idx" ON "app_versions"("platform", "versionCode"); diff --git a/backend/services/admin-service/prisma/migrations/migration_lock.toml b/backend/services/admin-service/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..99e4f200 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma new file mode 100644 index 00000000..5a87bcf5 --- /dev/null +++ b/backend/services/admin-service/prisma/schema.prisma @@ -0,0 +1,45 @@ +// ============================================================================= +// Admin Service - Prisma Schema +// ============================================================================= + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================================================= +// App Version Management +// ============================================================================= + +model AppVersion { + id String @id @default(uuid()) + platform Platform + versionCode Int // Android: versionCode, iOS: CFBundleVersion + versionName String // 用户可见版本号,如 "1.2.3" + buildNumber String // 构建号 + downloadUrl String // APK/IPA 下载地址 + fileSize BigInt // 文件大小(字节) + fileSha256 String // 文件 SHA-256 校验和 + minOsVersion String? // 最低操作系统版本要求 + changelog String // 更新日志 + isForceUpdate Boolean @default(false) // 是否强制更新 + isEnabled Boolean @default(true) // 是否启用 + releaseDate DateTime? // 发布日期 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String // 创建人ID + updatedBy String? // 更新人ID + + @@index([platform, isEnabled]) + @@index([platform, versionCode]) + @@map("app_versions") +} + +enum Platform { + ANDROID + IOS +} diff --git a/backend/services/admin-service/src/api/controllers/health.controller.ts b/backend/services/admin-service/src/api/controllers/health.controller.ts new file mode 100644 index 00000000..17f4f37b --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common' +import { ApiTags, ApiOperation } from '@nestjs/swagger' + +@ApiTags('Health') +@Controller('health') +export class HealthController { + @Get() + @ApiOperation({ summary: '健康检查' }) + check() { + return { + status: 'ok', + service: 'admin-service', + timestamp: new Date().toISOString(), + } + } +} diff --git a/backend/services/admin-service/src/api/controllers/version.controller.ts b/backend/services/admin-service/src/api/controllers/version.controller.ts new file mode 100644 index 00000000..31b48abd --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/version.controller.ts @@ -0,0 +1,78 @@ +import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common' +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger' +import { CheckUpdateDto } from '../dto/request/check-update.dto' +import { CreateVersionDto } from '../dto/request/create-version.dto' +import { UpdateCheckResultDto, VersionDto } from '../dto/response/version.dto' +import { CheckUpdateHandler } from '@/application/queries/check-update/check-update.handler' +import { CheckUpdateQuery } from '@/application/queries/check-update/check-update.query' +import { CreateVersionHandler } from '@/application/commands/create-version/create-version.handler' +import { CreateVersionCommand } from '@/application/commands/create-version/create-version.command' + +@ApiTags('Version Management') +@Controller('versions') +export class VersionController { + constructor( + private readonly checkUpdateHandler: CheckUpdateHandler, + private readonly createVersionHandler: CreateVersionHandler, + ) {} + + @Get('check-update') + @ApiOperation({ summary: '检查更新 (供移动端调用)' }) + @ApiResponse({ status: 200, type: UpdateCheckResultDto }) + async checkUpdate(@Query() dto: CheckUpdateDto): Promise { + const query = new CheckUpdateQuery(dto.platform, dto.currentVersionCode) + const result = await this.checkUpdateHandler.execute(query) + + return { + hasUpdate: result.hasUpdate, + isForceUpdate: result.isForceUpdate, + latestVersion: result.latestVersion + ? { + ...result.latestVersion, + fileSize: result.latestVersion.fileSize.toString(), + } + : null, + } + } + + @Post() + @ApiOperation({ summary: '创建新版本 (管理员)' }) + @ApiBearerAuth() + @ApiResponse({ status: 201, type: VersionDto }) + async createVersion(@Body() dto: CreateVersionDto): Promise { + const command = new CreateVersionCommand( + dto.platform, + dto.versionCode, + dto.versionName, + dto.buildNumber, + dto.downloadUrl, + BigInt(dto.fileSize), + dto.fileSha256, + dto.changelog, + dto.isForceUpdate, + dto.minOsVersion ?? null, + dto.releaseDate ? new Date(dto.releaseDate) : null, + 'admin', // TODO: Get from JWT token + ) + + const version = await this.createVersionHandler.execute(command) + + return { + id: version.id, + platform: version.platform, + versionCode: version.versionCode, + versionName: version.versionName, + buildNumber: version.buildNumber, + downloadUrl: version.downloadUrl, + fileSize: version.fileSize.toString(), + fileSha256: version.fileSha256, + changelog: version.changelog, + isForceUpdate: version.isForceUpdate, + isEnabled: version.isEnabled, + minOsVersion: version.minOsVersion, + releaseDate: version.releaseDate, + createdAt: version.createdAt, + updatedAt: version.updatedAt, + } + } +} diff --git a/backend/services/admin-service/src/api/dto/request/check-update.dto.ts b/backend/services/admin-service/src/api/dto/request/check-update.dto.ts new file mode 100644 index 00000000..1b819062 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/check-update.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsEnum, IsInt, Min } from 'class-validator' +import { Platform } from '@/domain/enums/platform.enum' + +export class CheckUpdateDto { + @ApiProperty({ enum: Platform, description: '平台类型' }) + @IsEnum(Platform) + platform: Platform + + @ApiProperty({ description: '当前版本号', example: 100 }) + @IsInt() + @Min(1) + currentVersionCode: number +} diff --git a/backend/services/admin-service/src/api/dto/request/create-version.dto.ts b/backend/services/admin-service/src/api/dto/request/create-version.dto.ts new file mode 100644 index 00000000..285d222d --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/create-version.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsEnum, IsInt, IsString, IsBoolean, IsUrl, Min, IsOptional, IsDateString } from 'class-validator' +import { Platform } from '@/domain/enums/platform.enum' + +export class CreateVersionDto { + @ApiProperty({ enum: Platform, description: '平台类型' }) + @IsEnum(Platform) + platform: Platform + + @ApiProperty({ description: '版本号', example: 101 }) + @IsInt() + @Min(1) + versionCode: number + + @ApiProperty({ description: '版本名称', example: '1.0.1' }) + @IsString() + versionName: string + + @ApiProperty({ description: '构建号', example: '202512021200' }) + @IsString() + buildNumber: string + + @ApiProperty({ description: 'APK/IPA下载地址', example: 'https://example.com/app-v1.0.1.apk' }) + @IsUrl() + downloadUrl: string + + @ApiProperty({ description: '文件大小(字节)', example: 52428800 }) + @IsString() + fileSize: string + + @ApiProperty({ description: '文件SHA-256校验和' }) + @IsString() + fileSha256: string + + @ApiProperty({ description: '更新日志' }) + @IsString() + changelog: string + + @ApiProperty({ description: '是否强制更新', default: false }) + @IsBoolean() + isForceUpdate: boolean + + @ApiPropertyOptional({ description: '最低操作系统版本', example: '10.0' }) + @IsOptional() + @IsString() + minOsVersion?: string + + @ApiPropertyOptional({ description: '发布日期', example: '2025-12-02T10:00:00Z' }) + @IsOptional() + @IsDateString() + releaseDate?: string +} diff --git a/backend/services/admin-service/src/api/dto/response/version.dto.ts b/backend/services/admin-service/src/api/dto/response/version.dto.ts new file mode 100644 index 00000000..fd5b158e --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/version.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { Platform } from '@/domain/enums/platform.enum' + +export class VersionDto { + @ApiProperty() + id: string + + @ApiProperty({ enum: Platform }) + platform: Platform + + @ApiProperty() + versionCode: number + + @ApiProperty() + versionName: string + + @ApiProperty() + buildNumber: string + + @ApiProperty() + downloadUrl: string + + @ApiProperty() + fileSize: string + + @ApiProperty() + fileSha256: string + + @ApiProperty() + changelog: string + + @ApiProperty() + isForceUpdate: boolean + + @ApiProperty() + isEnabled: boolean + + @ApiPropertyOptional() + minOsVersion: string | null + + @ApiPropertyOptional() + releaseDate: Date | null + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date +} + +export class UpdateCheckResultDto { + @ApiProperty({ description: '是否有更新' }) + hasUpdate: boolean + + @ApiProperty({ description: '是否强制更新' }) + isForceUpdate: boolean + + @ApiPropertyOptional({ description: '最新版本信息' }) + latestVersion: { + versionCode: number + versionName: string + downloadUrl: string + fileSize: string + fileSha256: string + changelog: string + minOsVersion: string | null + releaseDate: Date | null + } | null +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts new file mode 100644 index 00000000..48a9fdcd --- /dev/null +++ b/backend/services/admin-service/src/app.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { configurations } from './config' +import { PrismaService } from './infrastructure/persistence/prisma/prisma.service' +import { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl' +import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository' +import { CheckUpdateHandler } from './application/queries/check-update/check-update.handler' +import { CreateVersionHandler } from './application/commands/create-version/create-version.handler' +import { VersionController } from './api/controllers/version.controller' +import { HealthController } from './api/controllers/health.controller' + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: configurations, + }), + ], + controllers: [VersionController, HealthController], + providers: [ + PrismaService, + { + provide: APP_VERSION_REPOSITORY, + useClass: AppVersionRepositoryImpl, + }, + CheckUpdateHandler, + CreateVersionHandler, + ], +}) +export class AppModule {} diff --git a/backend/services/admin-service/src/application/commands/create-version/create-version.command.ts b/backend/services/admin-service/src/application/commands/create-version/create-version.command.ts new file mode 100644 index 00000000..f67a2591 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/create-version/create-version.command.ts @@ -0,0 +1,18 @@ +import { Platform } from '@/domain/enums/platform.enum' + +export class CreateVersionCommand { + constructor( + public readonly platform: Platform, + public readonly versionCode: number, + public readonly versionName: string, + public readonly buildNumber: string, + public readonly downloadUrl: string, + public readonly fileSize: bigint, + public readonly fileSha256: string, + public readonly changelog: string, + public readonly isForceUpdate: boolean, + public readonly minOsVersion: string | null, + public readonly releaseDate: Date | null, + public readonly createdBy: string, + ) {} +} diff --git a/backend/services/admin-service/src/application/commands/create-version/create-version.handler.ts b/backend/services/admin-service/src/application/commands/create-version/create-version.handler.ts new file mode 100644 index 00000000..733674f6 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/create-version/create-version.handler.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, ConflictException } from '@nestjs/common' +import { CreateVersionCommand } from './create-version.command' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' + +@Injectable() +export class CreateVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(command: CreateVersionCommand): Promise { + // Check if version already exists + const existing = await this.appVersionRepository.findByPlatformAndVersionCode( + command.platform, + command.versionCode, + ) + + if (existing) { + throw new ConflictException( + `Version ${command.versionCode} already exists for platform ${command.platform}`, + ) + } + + const appVersion = AppVersion.create({ + platform: command.platform, + versionCode: command.versionCode, + versionName: command.versionName, + buildNumber: command.buildNumber, + downloadUrl: command.downloadUrl, + fileSize: command.fileSize, + fileSha256: command.fileSha256, + changelog: command.changelog, + isForceUpdate: command.isForceUpdate, + minOsVersion: command.minOsVersion, + releaseDate: command.releaseDate, + createdBy: command.createdBy, + }) + + return await this.appVersionRepository.save(appVersion) + } +} diff --git a/backend/services/admin-service/src/application/queries/check-update/check-update.handler.ts b/backend/services/admin-service/src/application/queries/check-update/check-update.handler.ts new file mode 100644 index 00000000..dbb248a0 --- /dev/null +++ b/backend/services/admin-service/src/application/queries/check-update/check-update.handler.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common' +import { CheckUpdateQuery } from './check-update.query' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' + +export interface UpdateCheckResult { + hasUpdate: boolean + isForceUpdate: boolean + latestVersion: { + versionCode: number + versionName: string + downloadUrl: string + fileSize: bigint + fileSha256: string + changelog: string + minOsVersion: string | null + releaseDate: Date | null + } | null +} + +@Injectable() +export class CheckUpdateHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(query: CheckUpdateQuery): Promise { + const latestVersion = await this.appVersionRepository.findLatestByPlatform(query.platform) + + if (!latestVersion) { + return { + hasUpdate: false, + isForceUpdate: false, + latestVersion: null, + } + } + + const hasUpdate = latestVersion.isNewerThan(query.currentVersionCode) + + return { + hasUpdate, + isForceUpdate: hasUpdate && latestVersion.shouldForceUpdate(), + latestVersion: hasUpdate + ? { + versionCode: latestVersion.versionCode, + versionName: latestVersion.versionName, + downloadUrl: latestVersion.downloadUrl, + fileSize: latestVersion.fileSize, + fileSha256: latestVersion.fileSha256, + changelog: latestVersion.changelog, + minOsVersion: latestVersion.minOsVersion, + releaseDate: latestVersion.releaseDate, + } + : null, + } + } +} diff --git a/backend/services/admin-service/src/application/queries/check-update/check-update.query.ts b/backend/services/admin-service/src/application/queries/check-update/check-update.query.ts new file mode 100644 index 00000000..14836da3 --- /dev/null +++ b/backend/services/admin-service/src/application/queries/check-update/check-update.query.ts @@ -0,0 +1,8 @@ +import { Platform } from '@/domain/enums/platform.enum' + +export class CheckUpdateQuery { + constructor( + public readonly platform: Platform, + public readonly currentVersionCode: number, + ) {} +} diff --git a/backend/services/admin-service/src/config/index.ts b/backend/services/admin-service/src/config/index.ts new file mode 100644 index 00000000..523b4c87 --- /dev/null +++ b/backend/services/admin-service/src/config/index.ts @@ -0,0 +1,23 @@ +export const appConfig = () => ({ + port: parseInt(process.env.APP_PORT || '3010', 10), + env: process.env.NODE_ENV || 'development', + apiPrefix: process.env.API_PREFIX || 'api/v1', +}) + +export const databaseConfig = () => ({ + url: process.env.DATABASE_URL, +}) + +export const jwtConfig = () => ({ + secret: process.env.JWT_SECRET || 'default-jwt-secret-change-in-production', + expiresIn: process.env.JWT_EXPIRES_IN || '7d', +}) + +export const redisConfig = () => ({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + db: parseInt(process.env.REDIS_DB || '9', 10), +}) + +export const configurations = [appConfig, databaseConfig, jwtConfig, redisConfig] diff --git a/backend/services/admin-service/src/domain/entities/app-version.entity.ts b/backend/services/admin-service/src/domain/entities/app-version.entity.ts new file mode 100644 index 00000000..e1ad9edd --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/app-version.entity.ts @@ -0,0 +1,66 @@ +import { Platform } from '../enums/platform.enum' + +export class AppVersion { + constructor( + public readonly id: string, + public readonly platform: Platform, + public readonly versionCode: number, + public readonly versionName: string, + public readonly buildNumber: string, + public readonly downloadUrl: string, + public readonly fileSize: bigint, + public readonly fileSha256: string, + public readonly changelog: string, + public readonly isForceUpdate: boolean, + public readonly isEnabled: boolean, + public readonly minOsVersion: string | null, + public readonly releaseDate: Date | null, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly createdBy: string, + public readonly updatedBy: string | null, + ) {} + + static create(params: { + platform: Platform + versionCode: number + versionName: string + buildNumber: string + downloadUrl: string + fileSize: bigint + fileSha256: string + changelog: string + isForceUpdate: boolean + minOsVersion?: string | null + releaseDate?: Date | null + createdBy: string + }): AppVersion { + return new AppVersion( + crypto.randomUUID(), + params.platform, + params.versionCode, + params.versionName, + params.buildNumber, + params.downloadUrl, + params.fileSize, + params.fileSha256, + params.changelog, + params.isForceUpdate, + true, // isEnabled = true by default + params.minOsVersion ?? null, + params.releaseDate ?? null, + new Date(), + new Date(), + params.createdBy, + null, + ) + } + + isNewerThan(currentVersionCode: number): boolean { + return this.versionCode > currentVersionCode + } + + shouldForceUpdate(): boolean { + return this.isForceUpdate && this.isEnabled + } +} diff --git a/backend/services/admin-service/src/domain/enums/platform.enum.ts b/backend/services/admin-service/src/domain/enums/platform.enum.ts new file mode 100644 index 00000000..4054586a --- /dev/null +++ b/backend/services/admin-service/src/domain/enums/platform.enum.ts @@ -0,0 +1,4 @@ +export enum Platform { + ANDROID = 'ANDROID', + IOS = 'IOS', +} diff --git a/backend/services/admin-service/src/domain/repositories/app-version.repository.ts b/backend/services/admin-service/src/domain/repositories/app-version.repository.ts new file mode 100644 index 00000000..173c68da --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/app-version.repository.ts @@ -0,0 +1,46 @@ +import { AppVersion } from '../entities/app-version.entity' +import { Platform } from '../enums/platform.enum' + +export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY') + +export interface AppVersionRepository { + /** + * 保存新版本 + */ + save(appVersion: AppVersion): Promise + + /** + * 根据ID查找版本 + */ + findById(id: string): Promise + + /** + * 获取指定平台的最新版本 + */ + findLatestByPlatform(platform: Platform): Promise + + /** + * 获取指定平台所有启用的版本列表 + */ + findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise + + /** + * 根据平台和版本号查找 + */ + findByPlatformAndVersionCode(platform: Platform, versionCode: number): Promise + + /** + * 更新版本信息 + */ + update(id: string, updates: Partial): Promise + + /** + * 禁用/启用版本 + */ + toggleEnabled(id: string, isEnabled: boolean): Promise + + /** + * 删除版本 + */ + delete(id: string): Promise +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/prisma/prisma.service.ts b/backend/services/admin-service/src/infrastructure/persistence/prisma/prisma.service.ts new file mode 100644 index 00000000..3966dc7d --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/prisma/prisma.service.ts @@ -0,0 +1,22 @@ +import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name) + + async onModuleInit() { + try { + await this.$connect() + this.logger.log('Successfully connected to database') + } catch (error) { + this.logger.error('Failed to connect to database', error) + throw error + } + } + + async onModuleDestroy() { + await this.$disconnect() + this.logger.log('Disconnected from database') + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts new file mode 100644 index 00000000..355ce269 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common' +import { AppVersionRepository } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' +import { Platform } from '@/domain/enums/platform.enum' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class AppVersionRepositoryImpl implements AppVersionRepository { + constructor(private readonly prisma: PrismaService) {} + + async save(appVersion: AppVersion): Promise { + const created = await this.prisma.appVersion.create({ + data: { + id: appVersion.id, + platform: appVersion.platform, + versionCode: appVersion.versionCode, + versionName: appVersion.versionName, + buildNumber: appVersion.buildNumber, + downloadUrl: appVersion.downloadUrl, + fileSize: appVersion.fileSize, + fileSha256: appVersion.fileSha256, + changelog: appVersion.changelog, + isForceUpdate: appVersion.isForceUpdate, + isEnabled: appVersion.isEnabled, + minOsVersion: appVersion.minOsVersion, + releaseDate: appVersion.releaseDate, + createdBy: appVersion.createdBy, + updatedBy: appVersion.updatedBy, + }, + }) + + return this.toDomain(created) + } + + async findById(id: string): Promise { + const found = await this.prisma.appVersion.findUnique({ where: { id } }) + return found ? this.toDomain(found) : null + } + + async findLatestByPlatform(platform: Platform): Promise { + const found = await this.prisma.appVersion.findFirst({ + where: { platform, isEnabled: true }, + orderBy: { versionCode: 'desc' }, + }) + return found ? this.toDomain(found) : null + } + + async findAllByPlatform(platform: Platform, includeDisabled = false): Promise { + const results = await this.prisma.appVersion.findMany({ + where: includeDisabled ? { platform } : { platform, isEnabled: true }, + orderBy: { versionCode: 'desc' }, + }) + return results.map((r) => this.toDomain(r)) + } + + async findByPlatformAndVersionCode( + platform: Platform, + versionCode: number, + ): Promise { + const found = await this.prisma.appVersion.findFirst({ + where: { platform, versionCode }, + }) + return found ? this.toDomain(found) : null + } + + async update(id: string, updates: Partial): Promise { + const updated = await this.prisma.appVersion.update({ + where: { id }, + data: { + versionName: updates.versionName, + buildNumber: updates.buildNumber, + downloadUrl: updates.downloadUrl, + fileSize: updates.fileSize, + fileSha256: updates.fileSha256, + changelog: updates.changelog, + isForceUpdate: updates.isForceUpdate, + minOsVersion: updates.minOsVersion, + releaseDate: updates.releaseDate, + updatedBy: updates.updatedBy, + updatedAt: new Date(), + }, + }) + return this.toDomain(updated) + } + + async toggleEnabled(id: string, isEnabled: boolean): Promise { + await this.prisma.appVersion.update({ + where: { id }, + data: { isEnabled, updatedAt: new Date() }, + }) + } + + async delete(id: string): Promise { + await this.prisma.appVersion.delete({ where: { id } }) + } + + private toDomain(model: any): AppVersion { + return new AppVersion( + model.id, + model.platform as Platform, + model.versionCode, + model.versionName, + model.buildNumber, + model.downloadUrl, + model.fileSize, + model.fileSha256, + model.changelog, + model.isForceUpdate, + model.isEnabled, + model.minOsVersion, + model.releaseDate, + model.createdAt, + model.updatedAt, + model.createdBy, + model.updatedBy, + ) + } +} diff --git a/backend/services/admin-service/src/main.ts b/backend/services/admin-service/src/main.ts new file mode 100644 index 00000000..c3fa02dc --- /dev/null +++ b/backend/services/admin-service/src/main.ts @@ -0,0 +1,49 @@ +import { NestFactory } from '@nestjs/core' +import { ValidationPipe, Logger } from '@nestjs/common' +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' +import { AppModule } from './app.module' + +async function bootstrap() { + const logger = new Logger('Bootstrap') + const app = await NestFactory.create(AppModule) + + // Global prefix + app.setGlobalPrefix('api/v1') + + // Validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ) + + // CORS + app.enableCors({ + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, + }) + + // Swagger + const config = new DocumentBuilder() + .setTitle('Admin Service API') + .setDescription('RWA管理服务API - 移动应用版本管理') + .setVersion('1.0.0') + .addBearerAuth() + .addTag('Version Management', '版本管理') + .addTag('Health', '健康检查') + .build() + const document = SwaggerModule.createDocument(app, config) + SwaggerModule.setup('api/docs', app, document) + + const port = parseInt(process.env.APP_PORT || '3010', 10) + await app.listen(port) + + logger.log(`Admin Service is running on port ${port}`) + logger.log(`Swagger docs: http://localhost:${port}/api/docs`) +} + +bootstrap() diff --git a/backend/services/admin-service/tsconfig.json b/backend/services/admin-service/tsconfig.json index e69de29b..bd3c3946 100644 --- a/backend/services/admin-service/tsconfig.json +++ b/backend/services/admin-service/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/backend/services/docker-compose.yml b/backend/services/docker-compose.yml index 38bf381f..98eb9fc5 100644 --- a/backend/services/docker-compose.yml +++ b/backend/services/docker-compose.yml @@ -19,7 +19,7 @@ services: environment: POSTGRES_USER: ${POSTGRES_USER:-rwa_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rwa_secure_password} - POSTGRES_MULTIPLE_DATABASES: rwa_identity,rwa_wallet,rwa_mpc,rwa_backup,rwa_planting,rwa_referral,rwa_reward,rwa_leaderboard,rwa_reporting,rwa_authorization + POSTGRES_MULTIPLE_DATABASES: rwa_identity,rwa_wallet,rwa_mpc,rwa_backup,rwa_planting,rwa_referral,rwa_reward,rwa_leaderboard,rwa_reporting,rwa_authorization,rwa_admin ports: - "5432:5432" volumes: @@ -462,37 +462,36 @@ services: networks: - rwa-network - # admin-service: - # # NOTE: Service not yet implemented - uncomment when ready - # build: - # context: ./admin-service - # dockerfile: Dockerfile - # container_name: rwa-admin-service - # ports: - # - "3010:3010" - # environment: - # - NODE_ENV=production - # - APP_PORT=3010 - # - DATABASE_URL=postgresql://${POSTGRES_USER:-rwa_user}:${POSTGRES_PASSWORD:-rwa_secure_password}@postgres:5432/rwa_identity?schema=public - # - JWT_SECRET=${JWT_SECRET} - # - REDIS_HOST=redis - # - REDIS_PORT=6379 - # - REDIS_PASSWORD=${REDIS_PASSWORD:-} - # - REDIS_DB=9 - # depends_on: - # postgres: - # condition: service_healthy - # redis: - # condition: service_healthy - # healthcheck: - # test: ["CMD", "wget", "-q", "--spider", "http://localhost:3010/health"] - # interval: 30s - # timeout: 10s - # retries: 3 - # start_period: 40s - # restart: unless-stopped - # networks: - # - rwa-network + admin-service: + build: + context: ./admin-service + dockerfile: Dockerfile + container_name: rwa-admin-service + ports: + - "3010:3010" + environment: + - NODE_ENV=production + - APP_PORT=3010 + - DATABASE_URL=postgresql://${POSTGRES_USER:-rwa_user}:${POSTGRES_PASSWORD:-rwa_secure_password}@postgres:5432/rwa_admin?schema=public + - JWT_SECRET=${JWT_SECRET} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=9 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3010/api/v1/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 40s + restart: unless-stopped + networks: + - rwa-network # =========================================================================== # Volumes