diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 48a9fdcd..3834edd5 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -1,13 +1,14 @@ -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' +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { configurations } from './config'; +import { PrismaService } from './infrastructure/persistence/prisma/prisma.service'; +import { AppVersionMapper } from './infrastructure/persistence/mappers/app-version.mapper'; +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: [ @@ -19,6 +20,7 @@ import { HealthController } from './api/controllers/health.controller' controllers: [VersionController, HealthController], providers: [ PrismaService, + AppVersionMapper, { provide: APP_VERSION_REPOSITORY, useClass: AppVersionRepositoryImpl, 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 index 733674f6..7fbb57a6 100644 --- 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 @@ -1,7 +1,15 @@ -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' +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'; +import { VersionCode } from '@/domain/value-objects/version-code.vo'; +import { VersionName } from '@/domain/value-objects/version-name.vo'; +import { BuildNumber } from '@/domain/value-objects/build-number.vo'; +import { DownloadUrl } from '@/domain/value-objects/download-url.vo'; +import { FileSize } from '@/domain/value-objects/file-size.vo'; +import { FileSha256 } from '@/domain/value-objects/file-sha256.vo'; +import { Changelog } from '@/domain/value-objects/changelog.vo'; +import { MinOsVersion } from '@/domain/value-objects/min-os-version.vo'; @Injectable() export class CreateVersionHandler { @@ -11,33 +19,44 @@ export class CreateVersionHandler { ) {} async execute(command: CreateVersionCommand): Promise { + // 创建值对象 + const versionCode = VersionCode.create(command.versionCode); + const versionName = VersionName.create(command.versionName); + const buildNumber = BuildNumber.create(command.buildNumber); + const downloadUrl = DownloadUrl.create(command.downloadUrl); + const fileSize = FileSize.create(command.fileSize); + const fileSha256 = FileSha256.create(command.fileSha256); + const changelog = Changelog.create(command.changelog); + const minOsVersion = command.minOsVersion ? MinOsVersion.create(command.minOsVersion) : null; + // Check if version already exists const existing = await this.appVersionRepository.findByPlatformAndVersionCode( command.platform, - command.versionCode, - ) + 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, + versionCode, + versionName, + buildNumber, + downloadUrl, + fileSize, + fileSha256, + changelog, isForceUpdate: command.isForceUpdate, - minOsVersion: command.minOsVersion, + minOsVersion, releaseDate: command.releaseDate, createdBy: command.createdBy, - }) + }); - return await this.appVersionRepository.save(appVersion) + 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 index dbb248a0..07512a7e 100644 --- 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 @@ -1,20 +1,21 @@ -import { Inject, Injectable } from '@nestjs/common' -import { CheckUpdateQuery } from './check-update.query' -import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { Inject, Injectable } from '@nestjs/common'; +import { CheckUpdateQuery } from './check-update.query'; +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository'; +import { VersionCode } from '@/domain/value-objects/version-code.vo'; export interface UpdateCheckResult { - hasUpdate: boolean - isForceUpdate: boolean + hasUpdate: boolean; + isForceUpdate: boolean; latestVersion: { - versionCode: number - versionName: string - downloadUrl: string - fileSize: bigint - fileSha256: string - changelog: string - minOsVersion: string | null - releaseDate: Date | null - } | null + versionCode: number; + versionName: string; + downloadUrl: string; + fileSize: string; + fileSha256: string; + changelog: string; + minOsVersion: string | null; + releaseDate: Date | null; + } | null; } @Injectable() @@ -25,33 +26,34 @@ export class CheckUpdateHandler { ) {} async execute(query: CheckUpdateQuery): Promise { - const latestVersion = await this.appVersionRepository.findLatestByPlatform(query.platform) + const latestVersion = await this.appVersionRepository.findLatestByPlatform(query.platform); if (!latestVersion) { return { hasUpdate: false, isForceUpdate: false, latestVersion: null, - } + }; } - const hasUpdate = latestVersion.isNewerThan(query.currentVersionCode) + const currentVersionCode = VersionCode.create(query.currentVersionCode); + const hasUpdate = latestVersion.isNewerThan(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, + versionCode: latestVersion.versionCode.value, + versionName: latestVersion.versionName.value, + downloadUrl: latestVersion.downloadUrl.value, + fileSize: latestVersion.fileSize.bytes.toString(), + fileSha256: latestVersion.fileSha256.value, + changelog: latestVersion.changelog.value, + minOsVersion: latestVersion.minOsVersion?.value ?? null, releaseDate: latestVersion.releaseDate, } : null, - } + }; } } 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 index e1ad9edd..a604c726 100644 --- a/backend/services/admin-service/src/domain/entities/app-version.entity.ts +++ b/backend/services/admin-service/src/domain/entities/app-version.entity.ts @@ -1,66 +1,222 @@ -import { Platform } from '../enums/platform.enum' +import { Platform } from '../enums/platform.enum'; +import { VersionCode } from '../value-objects/version-code.vo'; +import { VersionName } from '../value-objects/version-name.vo'; +import { BuildNumber } from '../value-objects/build-number.vo'; +import { DownloadUrl } from '../value-objects/download-url.vo'; +import { FileSize } from '../value-objects/file-size.vo'; +import { FileSha256 } from '../value-objects/file-sha256.vo'; +import { Changelog } from '../value-objects/changelog.vo'; +import { MinOsVersion } from '../value-objects/min-os-version.vo'; 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, - ) {} + private _id: string; + private _platform: Platform; + private _versionCode: VersionCode; + private _versionName: VersionName; + private _buildNumber: BuildNumber; + private _downloadUrl: DownloadUrl; + private _fileSize: FileSize; + private _fileSha256: FileSha256; + private _changelog: Changelog; + private _isForceUpdate: boolean; + private _isEnabled: boolean; + private _minOsVersion: MinOsVersion | null; + private _releaseDate: Date | null; + private _createdAt: Date; + private _updatedAt: Date; + private _createdBy: string; + private _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, - ) + private constructor() {} + + // Getters + get id(): string { + return this._id; } - isNewerThan(currentVersionCode: number): boolean { - return this.versionCode > currentVersionCode + get platform(): Platform { + return this._platform; + } + + get versionCode(): VersionCode { + return this._versionCode; + } + + get versionName(): VersionName { + return this._versionName; + } + + get buildNumber(): BuildNumber { + return this._buildNumber; + } + + get downloadUrl(): DownloadUrl { + return this._downloadUrl; + } + + get fileSize(): FileSize { + return this._fileSize; + } + + get fileSha256(): FileSha256 { + return this._fileSha256; + } + + get changelog(): Changelog { + return this._changelog; + } + + get isForceUpdate(): boolean { + return this._isForceUpdate; + } + + get isEnabled(): boolean { + return this._isEnabled; + } + + get minOsVersion(): MinOsVersion | null { + return this._minOsVersion; + } + + get releaseDate(): Date | null { + return this._releaseDate; + } + + get createdAt(): Date { + return this._createdAt; + } + + get updatedAt(): Date { + return this._updatedAt; + } + + get createdBy(): string { + return this._createdBy; + } + + get updatedBy(): string | null { + return this._updatedBy; + } + + // 工厂方法 - 创建新版本 + static create(params: { + platform: Platform; + versionCode: VersionCode; + versionName: VersionName; + buildNumber: BuildNumber; + downloadUrl: DownloadUrl; + fileSize: FileSize; + fileSha256: FileSha256; + changelog: Changelog; + isForceUpdate: boolean; + minOsVersion?: MinOsVersion | null; + releaseDate?: Date | null; + createdBy: string; + }): AppVersion { + const version = new AppVersion(); + version._id = crypto.randomUUID(); + version._platform = params.platform; + version._versionCode = params.versionCode; + version._versionName = params.versionName; + version._buildNumber = params.buildNumber; + version._downloadUrl = params.downloadUrl; + version._fileSize = params.fileSize; + version._fileSha256 = params.fileSha256; + version._changelog = params.changelog; + version._isForceUpdate = params.isForceUpdate; + version._isEnabled = true; // 默认启用 + version._minOsVersion = params.minOsVersion ?? null; + version._releaseDate = params.releaseDate ?? null; + version._createdAt = new Date(); + version._updatedAt = new Date(); + version._createdBy = params.createdBy; + version._updatedBy = null; + return version; + } + + // 从持久化恢复 + static reconstitute(params: { + id: string; + platform: Platform; + versionCode: VersionCode; + versionName: VersionName; + buildNumber: BuildNumber; + downloadUrl: DownloadUrl; + fileSize: FileSize; + fileSha256: FileSha256; + changelog: Changelog; + isForceUpdate: boolean; + isEnabled: boolean; + minOsVersion: MinOsVersion | null; + releaseDate: Date | null; + createdAt: Date; + updatedAt: Date; + createdBy: string; + updatedBy: string | null; + }): AppVersion { + const version = new AppVersion(); + version._id = params.id; + version._platform = params.platform; + version._versionCode = params.versionCode; + version._versionName = params.versionName; + version._buildNumber = params.buildNumber; + version._downloadUrl = params.downloadUrl; + version._fileSize = params.fileSize; + version._fileSha256 = params.fileSha256; + version._changelog = params.changelog; + version._isForceUpdate = params.isForceUpdate; + version._isEnabled = params.isEnabled; + version._minOsVersion = params.minOsVersion; + version._releaseDate = params.releaseDate; + version._createdAt = params.createdAt; + version._updatedAt = params.updatedAt; + version._createdBy = params.createdBy; + version._updatedBy = params.updatedBy; + return version; + } + + // 业务方法 + isNewerThan(currentVersionCode: VersionCode): boolean { + return this._versionCode.isNewerThan(currentVersionCode); } shouldForceUpdate(): boolean { - return this.isForceUpdate && this.isEnabled + return this._isForceUpdate && this._isEnabled; + } + + /** + * 禁用版本 + */ + disable(updatedBy: string): void { + this._isEnabled = false; + this._updatedBy = updatedBy; + this._updatedAt = new Date(); + } + + /** + * 启用版本 + */ + enable(updatedBy: string): void { + this._isEnabled = true; + this._updatedBy = updatedBy; + this._updatedAt = new Date(); + } + + /** + * 设置为强制更新 + */ + setForceUpdate(force: boolean, updatedBy: string): void { + this._isForceUpdate = force; + this._updatedBy = updatedBy; + this._updatedAt = new Date(); + } + + /** + * 更新发布日期 + */ + setReleaseDate(date: Date | null, updatedBy: string): void { + this._releaseDate = date; + this._updatedBy = updatedBy; + this._updatedAt = new Date(); } } 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 index 173c68da..27f6bd7e 100644 --- a/backend/services/admin-service/src/domain/repositories/app-version.repository.ts +++ b/backend/services/admin-service/src/domain/repositories/app-version.repository.ts @@ -1,46 +1,47 @@ -import { AppVersion } from '../entities/app-version.entity' -import { Platform } from '../enums/platform.enum' +import { AppVersion } from '../entities/app-version.entity'; +import { Platform } from '../enums/platform.enum'; +import { VersionCode } from '../value-objects/version-code.vo'; -export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY') +export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY'); export interface AppVersionRepository { /** * 保存新版本 */ - save(appVersion: AppVersion): Promise + save(appVersion: AppVersion): Promise; /** * 根据ID查找版本 */ - findById(id: string): Promise + findById(id: string): Promise; /** * 获取指定平台的最新版本 */ - findLatestByPlatform(platform: Platform): Promise + findLatestByPlatform(platform: Platform): Promise; /** * 获取指定平台所有启用的版本列表 */ - findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise + findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise; /** * 根据平台和版本号查找 */ - findByPlatformAndVersionCode(platform: Platform, versionCode: number): Promise + findByPlatformAndVersionCode(platform: Platform, versionCode: VersionCode): Promise; /** * 更新版本信息 */ - update(id: string, updates: Partial): Promise + update(id: string, updates: Partial): Promise; /** * 禁用/启用版本 */ - toggleEnabled(id: string, isEnabled: boolean): Promise + toggleEnabled(id: string, isEnabled: boolean): Promise; /** * 删除版本 */ - delete(id: string): Promise + delete(id: string): Promise; } diff --git a/backend/services/admin-service/src/domain/value-objects/build-number.vo.ts b/backend/services/admin-service/src/domain/value-objects/build-number.vo.ts new file mode 100644 index 00000000..19761915 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/build-number.vo.ts @@ -0,0 +1,43 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 构建号值对象 + * 格式: 自由格式字符串,通常为日期+序号 (e.g., 20250101.1) + */ +export class BuildNumber { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static create(value: string): BuildNumber { + if (!value || value.trim() === '') { + throw new DomainException('BuildNumber cannot be empty'); + } + + const trimmed = value.trim(); + if (trimmed.length > 64) { + throw new DomainException('BuildNumber cannot exceed 64 characters'); + } + + // 允许字母、数字、点、下划线、横线 + if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) { + throw new DomainException('BuildNumber can only contain alphanumeric characters, dots, underscores, and hyphens'); + } + + return new BuildNumber(trimmed); + } + + equals(other: BuildNumber): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/changelog.vo.ts b/backend/services/admin-service/src/domain/value-objects/changelog.vo.ts new file mode 100644 index 00000000..3c428eb6 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/changelog.vo.ts @@ -0,0 +1,46 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 更新日志值对象 + */ +export class Changelog { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static create(value: string): Changelog { + if (!value || value.trim() === '') { + throw new DomainException('Changelog cannot be empty'); + } + + const trimmed = value.trim(); + + if (trimmed.length < 10) { + throw new DomainException('Changelog must be at least 10 characters'); + } + + if (trimmed.length > 5000) { + throw new DomainException('Changelog cannot exceed 5000 characters'); + } + + return new Changelog(trimmed); + } + + static empty(): Changelog { + return new Changelog('No changelog provided'); + } + + equals(other: Changelog): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts b/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts new file mode 100644 index 00000000..c88a8d3e --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts @@ -0,0 +1,45 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 下载链接值对象 + */ +export class DownloadUrl { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static create(value: string): DownloadUrl { + if (!value || value.trim() === '') { + throw new DomainException('DownloadUrl cannot be empty'); + } + + const trimmed = value.trim(); + + // 验证 URL 格式 + try { + const url = new URL(trimmed); + // 只允许 http/https 协议 + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (e) { + throw new DomainException('DownloadUrl must be a valid HTTP/HTTPS URL'); + } + + return new DownloadUrl(trimmed); + } + + equals(other: DownloadUrl): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts b/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts new file mode 100644 index 00000000..c0a353e8 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts @@ -0,0 +1,43 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 文件 SHA256 哈希值对象 + */ +export class FileSha256 { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static create(value: string): FileSha256 { + if (!value || value.trim() === '') { + throw new DomainException('FileSha256 cannot be empty'); + } + + const trimmed = value.trim().toLowerCase(); + + // SHA256 哈希值长度为 64 个十六进制字符 + if (trimmed.length !== 64) { + throw new DomainException('FileSha256 must be 64 characters long'); + } + + if (!/^[a-f0-9]{64}$/.test(trimmed)) { + throw new DomainException('FileSha256 must contain only hexadecimal characters'); + } + + return new FileSha256(trimmed); + } + + equals(other: FileSha256): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/file-size.vo.ts b/backend/services/admin-service/src/domain/value-objects/file-size.vo.ts new file mode 100644 index 00000000..e00f1177 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/file-size.vo.ts @@ -0,0 +1,57 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 文件大小值对象 (字节) + */ +export class FileSize { + private readonly _bytes: bigint; + + private constructor(bytes: bigint) { + this._bytes = bytes; + } + + get bytes(): bigint { + return this._bytes; + } + + static create(bytes: bigint | number): FileSize { + const value = typeof bytes === 'number' ? BigInt(bytes) : bytes; + + if (value < 0n) { + throw new DomainException('FileSize cannot be negative'); + } + + // 最大文件大小: 2GB + const maxSize = BigInt(2) * BigInt(1024) * BigInt(1024) * BigInt(1024); + if (value > maxSize) { + throw new DomainException('FileSize cannot exceed 2GB'); + } + + return new FileSize(value); + } + + /** + * 转换为人类可读格式 + */ + toHumanReadable(): string { + const bytes = Number(this._bytes); + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; + } + + equals(other: FileSize): boolean { + return this._bytes === other._bytes; + } + + toString(): string { + return this._bytes.toString(); + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/min-os-version.vo.ts b/backend/services/admin-service/src/domain/value-objects/min-os-version.vo.ts new file mode 100644 index 00000000..ec06a65a --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/min-os-version.vo.ts @@ -0,0 +1,43 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 最低操作系统版本值对象 + */ +export class MinOsVersion { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static create(value: string): MinOsVersion { + if (!value || value.trim() === '') { + throw new DomainException('MinOsVersion cannot be empty'); + } + + const trimmed = value.trim(); + + // 验证格式: 数字.数字 或 数字.数字.数字 (e.g., "11.0", "14.0", "5.1.1") + if (!/^\d+(\.\d+){1,2}$/.test(trimmed)) { + throw new DomainException('MinOsVersion must be in format like "11.0" or "5.1.1"'); + } + + return new MinOsVersion(trimmed); + } + + static none(): MinOsVersion { + return new MinOsVersion('0.0'); + } + + equals(other: MinOsVersion): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts b/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts new file mode 100644 index 00000000..90c66b3e --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts @@ -0,0 +1,43 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 版本号值对象 + * Android: Integer (递增) + * iOS: Integer (递增) + */ +export class VersionCode { + private readonly _value: number; + + private constructor(value: number) { + this._value = value; + } + + get value(): number { + return this._value; + } + + static create(value: number): VersionCode { + if (!Number.isInteger(value)) { + throw new DomainException('VersionCode must be an integer'); + } + if (value <= 0) { + throw new DomainException('VersionCode must be positive'); + } + if (value > 2147483647) { + throw new DomainException('VersionCode cannot exceed 2147483647'); + } + return new VersionCode(value); + } + + isNewerThan(other: VersionCode): boolean { + return this._value > other._value; + } + + equals(other: VersionCode): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value.toString(); + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts b/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts new file mode 100644 index 00000000..132f4214 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts @@ -0,0 +1,63 @@ +import { DomainException } from '../../shared/exceptions/domain.exception'; + +/** + * 版本名称值对象 + * 格式: major.minor.patch (e.g., 1.0.0, 2.3.1) + */ +export class VersionName { + private readonly _value: string; + private readonly _major: number; + private readonly _minor: number; + private readonly _patch: number; + + private constructor(value: string, major: number, minor: number, patch: number) { + this._value = value; + this._major = major; + this._minor = minor; + this._patch = patch; + } + + get value(): string { + return this._value; + } + + get major(): number { + return this._major; + } + + get minor(): number { + return this._minor; + } + + get patch(): number { + return this._patch; + } + + static create(value: string): VersionName { + if (!value || value.trim() === '') { + throw new DomainException('VersionName cannot be empty'); + } + + const trimmed = value.trim(); + const versionPattern = /^(\d+)\.(\d+)\.(\d+)$/; + const match = trimmed.match(versionPattern); + + if (!match) { + throw new DomainException('VersionName must follow semantic versioning format (e.g., 1.0.0)'); + } + + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3], 10); + + return new VersionName(trimmed, major, minor, patch); + } + + equals(other: VersionName): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts b/backend/services/admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts new file mode 100644 index 00000000..1bb5c6fb --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { AppVersion as PrismaAppVersion } from '@prisma/client'; +import { AppVersion } from '../../../domain/entities/app-version.entity'; +import { Platform } from '../../../domain/enums/platform.enum'; +import { VersionCode } from '../../../domain/value-objects/version-code.vo'; +import { VersionName } from '../../../domain/value-objects/version-name.vo'; +import { BuildNumber } from '../../../domain/value-objects/build-number.vo'; +import { DownloadUrl } from '../../../domain/value-objects/download-url.vo'; +import { FileSize } from '../../../domain/value-objects/file-size.vo'; +import { FileSha256 } from '../../../domain/value-objects/file-sha256.vo'; +import { Changelog } from '../../../domain/value-objects/changelog.vo'; +import { MinOsVersion } from '../../../domain/value-objects/min-os-version.vo'; + +@Injectable() +export class AppVersionMapper { + toDomain(prisma: PrismaAppVersion): AppVersion { + return AppVersion.reconstitute({ + id: prisma.id, + platform: prisma.platform as Platform, + versionCode: VersionCode.create(prisma.versionCode), + versionName: VersionName.create(prisma.versionName), + buildNumber: BuildNumber.create(prisma.buildNumber), + downloadUrl: DownloadUrl.create(prisma.downloadUrl), + fileSize: FileSize.create(prisma.fileSize), + fileSha256: FileSha256.create(prisma.fileSha256), + changelog: Changelog.create(prisma.changelog), + isForceUpdate: prisma.isForceUpdate, + isEnabled: prisma.isEnabled, + minOsVersion: prisma.minOsVersion ? MinOsVersion.create(prisma.minOsVersion) : null, + releaseDate: prisma.releaseDate, + createdAt: prisma.createdAt, + updatedAt: prisma.updatedAt, + createdBy: prisma.createdBy, + updatedBy: prisma.updatedBy, + }); + } + + toPersistence(domain: AppVersion): Omit { + return { + id: domain.id, + platform: domain.platform, + versionCode: domain.versionCode.value, + versionName: domain.versionName.value, + buildNumber: domain.buildNumber.value, + downloadUrl: domain.downloadUrl.value, + fileSize: domain.fileSize.bytes, + fileSha256: domain.fileSha256.value, + changelog: domain.changelog.value, + isForceUpdate: domain.isForceUpdate, + isEnabled: domain.isEnabled, + minOsVersion: domain.minOsVersion?.value ?? null, + releaseDate: domain.releaseDate, + createdBy: domain.createdBy, + updatedBy: domain.updatedBy, + }; + } +} 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 index 355ce269..649cffd2 100644 --- 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 @@ -1,118 +1,94 @@ -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' +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 { VersionCode } from '@/domain/value-objects/version-code.vo'; +import { PrismaService } from '../prisma/prisma.service'; +import { AppVersionMapper } from '../mappers/app-version.mapper'; @Injectable() export class AppVersionRepositoryImpl implements AppVersionRepository { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly mapper: AppVersionMapper, + ) {} async save(appVersion: AppVersion): Promise { + const data = this.mapper.toPersistence(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, + ...data, + createdAt: appVersion.createdAt, + updatedAt: appVersion.updatedAt, }, - }) + }); - return this.toDomain(created) + return this.mapper.toDomain(created); } async findById(id: string): Promise { - const found = await this.prisma.appVersion.findUnique({ where: { id } }) - return found ? this.toDomain(found) : null + const found = await this.prisma.appVersion.findUnique({ where: { id } }); + return found ? this.mapper.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 + }); + return found ? this.mapper.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)) + }); + return results.map((r) => this.mapper.toDomain(r)); } async findByPlatformAndVersionCode( platform: Platform, - versionCode: number, + versionCode: VersionCode, ): Promise { const found = await this.prisma.appVersion.findFirst({ - where: { platform, versionCode }, - }) - return found ? this.toDomain(found) : null + where: { platform, versionCode: versionCode.value }, + }); + return found ? this.mapper.toDomain(found) : null; } async update(id: string, updates: Partial): Promise { + const data: any = {}; + + if (updates.versionName) data.versionName = updates.versionName.value; + if (updates.buildNumber) data.buildNumber = updates.buildNumber.value; + if (updates.downloadUrl) data.downloadUrl = updates.downloadUrl.value; + if (updates.fileSize !== undefined) data.fileSize = updates.fileSize.bytes; + if (updates.fileSha256) data.fileSha256 = updates.fileSha256.value; + if (updates.changelog) data.changelog = updates.changelog.value; + if (updates.isForceUpdate !== undefined) data.isForceUpdate = updates.isForceUpdate; + if (updates.minOsVersion !== undefined) data.minOsVersion = updates.minOsVersion?.value ?? null; + if (updates.releaseDate !== undefined) data.releaseDate = updates.releaseDate; + if (updates.updatedBy !== undefined) data.updatedBy = updates.updatedBy; + + data.updatedAt = new Date(); + 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) + data, + }); + + return this.mapper.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, - ) + await this.prisma.appVersion.delete({ where: { id } }); } } diff --git a/backend/services/admin-service/src/shared/exceptions/domain.exception.ts b/backend/services/admin-service/src/shared/exceptions/domain.exception.ts new file mode 100644 index 00000000..76ead53c --- /dev/null +++ b/backend/services/admin-service/src/shared/exceptions/domain.exception.ts @@ -0,0 +1,6 @@ +export class DomainException extends Error { + constructor(message: string) { + super(message); + this.name = 'DomainException'; + } +}