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:
parent
0be3fe619e
commit
3385997b86
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export class DomainException extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'DomainException';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue