feat(admin-service): 实现移动应用版本管理服务

- 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 <noreply@anthropic.com>
This commit is contained in:
Developer 2025-12-02 11:22:30 -08:00
parent abe6d02a4c
commit c45ed8a575
28 changed files with 1170 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common'
import { ApiTags, ApiOperation } from '@nestjs/swagger'
@ApiTags('Health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: '健康检查' })
check() {
return {
status: 'ok',
service: 'admin-service',
timestamp: new Date().toISOString(),
}
}
}

View File

@ -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<UpdateCheckResultDto> {
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<VersionDto> {
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,
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AppVersion> {
// 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)
}
}

View File

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

View File

@ -0,0 +1,8 @@
import { Platform } from '@/domain/enums/platform.enum'
export class CheckUpdateQuery {
constructor(
public readonly platform: Platform,
public readonly currentVersionCode: number,
) {}
}

View File

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

View File

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

View File

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

View File

@ -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<AppVersion>
/**
* ID查找版本
*/
findById(id: string): Promise<AppVersion | null>
/**
*
*/
findLatestByPlatform(platform: Platform): Promise<AppVersion | null>
/**
*
*/
findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise<AppVersion[]>
/**
*
*/
findByPlatformAndVersionCode(platform: Platform, versionCode: number): Promise<AppVersion | null>
/**
*
*/
update(id: string, updates: Partial<AppVersion>): Promise<AppVersion>
/**
* /
*/
toggleEnabled(id: string, isEnabled: boolean): Promise<void>
/**
*
*/
delete(id: string): Promise<void>
}

View File

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

View File

@ -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<AppVersion> {
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<AppVersion | null> {
const found = await this.prisma.appVersion.findUnique({ where: { id } })
return found ? this.toDomain(found) : null
}
async findLatestByPlatform(platform: Platform): Promise<AppVersion | null> {
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<AppVersion[]> {
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<AppVersion | null> {
const found = await this.prisma.appVersion.findFirst({
where: { platform, versionCode },
})
return found ? this.toDomain(found) : null
}
async update(id: string, updates: Partial<AppVersion>): Promise<AppVersion> {
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<void> {
await this.prisma.appVersion.update({
where: { id },
data: { isEnabled, updatedAt: new Date() },
})
}
async delete(id: string): Promise<void> {
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,
)
}
}

View File

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

View File

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

View File

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