refactor(admin-service): 完全按DDD架构重构,添加值对象层

值对象层 (Value Objects):
- VersionCode: 整数版本号,支持比较操作
- VersionName: 语义化版本格式 (major.minor.patch)
- BuildNumber: 构建号验证 (字母数字+点/下划线/连字符)
- DownloadUrl: HTTP/HTTPS URL 格式验证
- FileSha256: 64字符十六进制字符串验证
- FileSize: BigInt类型,2GB上限,支持人类可读格式转换
- Changelog: 更新日志 (10-5000字符)
- MinOsVersion: 最低操作系统版本格式验证

领域层重构:
- AppVersion Entity: 从贫血模型重构为充血模型
  - 私有字段 + getter 封装
  - 业务方法: disable(), enable(), setForceUpdate(), setReleaseDate()
  - 工厂方法: create() (新建), reconstitute() (重建)
  - 使用值对象替代所有原始类型

基础设施层:
- AppVersionMapper: 领域对象与持久化模型转换
- AppVersionRepositoryImpl: 使用 Mapper 进行数据转换
- 更新方法签名使用值对象类型

应用层:
- CreateVersionHandler: 创建值对象后构建领域实体
- CheckUpdateHandler: 从值对象提取值用于响应

共享层:
- DomainException: 领域异常基类

架构改进:
- 完整的 DDD 分层架构
- 值对象封装验证逻辑和业务规则
- 领域实体包含业务行为
- 清晰的领域-持久化边界

🤖 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 17:33:32 -08:00
parent 0be3fe619e
commit 3385997b86
16 changed files with 793 additions and 191 deletions

View File

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

View File

@ -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<AppVersion> {
// 创建值对象
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PrismaAppVersion, 'createdAt' | 'updatedAt'> {
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,
};
}
}

View File

@ -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<AppVersion> {
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<AppVersion | null> {
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<AppVersion | null> {
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<AppVersion[]> {
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<AppVersion | null> {
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<AppVersion>): Promise<AppVersion> {
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<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,
)
await this.prisma.appVersion.delete({ where: { id } });
}
}

View File

@ -0,0 +1,6 @@
export class DomainException extends Error {
constructor(message: string) {
super(message);
this.name = 'DomainException';
}
}