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 { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config' import { ConfigModule } from '@nestjs/config';
import { configurations } from './config' import { configurations } from './config';
import { PrismaService } from './infrastructure/persistence/prisma/prisma.service' import { PrismaService } from './infrastructure/persistence/prisma/prisma.service';
import { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl' import { AppVersionMapper } from './infrastructure/persistence/mappers/app-version.mapper';
import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository' import { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl';
import { CheckUpdateHandler } from './application/queries/check-update/check-update.handler' import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository';
import { CreateVersionHandler } from './application/commands/create-version/create-version.handler' import { CheckUpdateHandler } from './application/queries/check-update/check-update.handler';
import { VersionController } from './api/controllers/version.controller' import { CreateVersionHandler } from './application/commands/create-version/create-version.handler';
import { HealthController } from './api/controllers/health.controller' import { VersionController } from './api/controllers/version.controller';
import { HealthController } from './api/controllers/health.controller';
@Module({ @Module({
imports: [ imports: [
@ -19,6 +20,7 @@ import { HealthController } from './api/controllers/health.controller'
controllers: [VersionController, HealthController], controllers: [VersionController, HealthController],
providers: [ providers: [
PrismaService, PrismaService,
AppVersionMapper,
{ {
provide: APP_VERSION_REPOSITORY, provide: APP_VERSION_REPOSITORY,
useClass: AppVersionRepositoryImpl, useClass: AppVersionRepositoryImpl,

View File

@ -1,7 +1,15 @@
import { Inject, Injectable, ConflictException } from '@nestjs/common' import { Inject, Injectable, ConflictException } from '@nestjs/common';
import { CreateVersionCommand } from './create-version.command' import { CreateVersionCommand } from './create-version.command';
import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository';
import { AppVersion } from '@/domain/entities/app-version.entity' 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() @Injectable()
export class CreateVersionHandler { export class CreateVersionHandler {
@ -11,33 +19,44 @@ export class CreateVersionHandler {
) {} ) {}
async execute(command: CreateVersionCommand): Promise<AppVersion> { 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 // Check if version already exists
const existing = await this.appVersionRepository.findByPlatformAndVersionCode( const existing = await this.appVersionRepository.findByPlatformAndVersionCode(
command.platform, command.platform,
command.versionCode, versionCode,
) );
if (existing) { if (existing) {
throw new ConflictException( throw new ConflictException(
`Version ${command.versionCode} already exists for platform ${command.platform}`, `Version ${command.versionCode} already exists for platform ${command.platform}`,
) );
} }
// 创建领域对象
const appVersion = AppVersion.create({ const appVersion = AppVersion.create({
platform: command.platform, platform: command.platform,
versionCode: command.versionCode, versionCode,
versionName: command.versionName, versionName,
buildNumber: command.buildNumber, buildNumber,
downloadUrl: command.downloadUrl, downloadUrl,
fileSize: command.fileSize, fileSize,
fileSha256: command.fileSha256, fileSha256,
changelog: command.changelog, changelog,
isForceUpdate: command.isForceUpdate, isForceUpdate: command.isForceUpdate,
minOsVersion: command.minOsVersion, minOsVersion,
releaseDate: command.releaseDate, releaseDate: command.releaseDate,
createdBy: command.createdBy, 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 { Inject, Injectable } from '@nestjs/common';
import { CheckUpdateQuery } from './check-update.query' import { CheckUpdateQuery } from './check-update.query';
import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository';
import { VersionCode } from '@/domain/value-objects/version-code.vo';
export interface UpdateCheckResult { export interface UpdateCheckResult {
hasUpdate: boolean hasUpdate: boolean;
isForceUpdate: boolean isForceUpdate: boolean;
latestVersion: { latestVersion: {
versionCode: number versionCode: number;
versionName: string versionName: string;
downloadUrl: string downloadUrl: string;
fileSize: bigint fileSize: string;
fileSha256: string fileSha256: string;
changelog: string changelog: string;
minOsVersion: string | null minOsVersion: string | null;
releaseDate: Date | null releaseDate: Date | null;
} | null } | null;
} }
@Injectable() @Injectable()
@ -25,33 +26,34 @@ export class CheckUpdateHandler {
) {} ) {}
async execute(query: CheckUpdateQuery): Promise<UpdateCheckResult> { async execute(query: CheckUpdateQuery): Promise<UpdateCheckResult> {
const latestVersion = await this.appVersionRepository.findLatestByPlatform(query.platform) const latestVersion = await this.appVersionRepository.findLatestByPlatform(query.platform);
if (!latestVersion) { if (!latestVersion) {
return { return {
hasUpdate: false, hasUpdate: false,
isForceUpdate: false, isForceUpdate: false,
latestVersion: null, latestVersion: null,
} };
} }
const hasUpdate = latestVersion.isNewerThan(query.currentVersionCode) const currentVersionCode = VersionCode.create(query.currentVersionCode);
const hasUpdate = latestVersion.isNewerThan(currentVersionCode);
return { return {
hasUpdate, hasUpdate,
isForceUpdate: hasUpdate && latestVersion.shouldForceUpdate(), isForceUpdate: hasUpdate && latestVersion.shouldForceUpdate(),
latestVersion: hasUpdate latestVersion: hasUpdate
? { ? {
versionCode: latestVersion.versionCode, versionCode: latestVersion.versionCode.value,
versionName: latestVersion.versionName, versionName: latestVersion.versionName.value,
downloadUrl: latestVersion.downloadUrl, downloadUrl: latestVersion.downloadUrl.value,
fileSize: latestVersion.fileSize, fileSize: latestVersion.fileSize.bytes.toString(),
fileSha256: latestVersion.fileSha256, fileSha256: latestVersion.fileSha256.value,
changelog: latestVersion.changelog, changelog: latestVersion.changelog.value,
minOsVersion: latestVersion.minOsVersion, minOsVersion: latestVersion.minOsVersion?.value ?? null,
releaseDate: latestVersion.releaseDate, releaseDate: latestVersion.releaseDate,
} }
: null, : 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 { export class AppVersion {
constructor( private _id: string;
public readonly id: string, private _platform: Platform;
public readonly platform: Platform, private _versionCode: VersionCode;
public readonly versionCode: number, private _versionName: VersionName;
public readonly versionName: string, private _buildNumber: BuildNumber;
public readonly buildNumber: string, private _downloadUrl: DownloadUrl;
public readonly downloadUrl: string, private _fileSize: FileSize;
public readonly fileSize: bigint, private _fileSha256: FileSha256;
public readonly fileSha256: string, private _changelog: Changelog;
public readonly changelog: string, private _isForceUpdate: boolean;
public readonly isForceUpdate: boolean, private _isEnabled: boolean;
public readonly isEnabled: boolean, private _minOsVersion: MinOsVersion | null;
public readonly minOsVersion: string | null, private _releaseDate: Date | null;
public readonly releaseDate: Date | null, private _createdAt: Date;
public readonly createdAt: Date, private _updatedAt: Date;
public readonly updatedAt: Date, private _createdBy: string;
public readonly createdBy: string, private _updatedBy: string | null;
public readonly updatedBy: string | null,
) {}
static create(params: { private constructor() {}
platform: Platform
versionCode: number // Getters
versionName: string get id(): string {
buildNumber: string return this._id;
downloadUrl: string
fileSize: bigint
fileSha256: string
changelog: string
isForceUpdate: boolean
minOsVersion?: string | null
releaseDate?: Date | null
createdBy: string
}): AppVersion {
return new AppVersion(
crypto.randomUUID(),
params.platform,
params.versionCode,
params.versionName,
params.buildNumber,
params.downloadUrl,
params.fileSize,
params.fileSha256,
params.changelog,
params.isForceUpdate,
true, // isEnabled = true by default
params.minOsVersion ?? null,
params.releaseDate ?? null,
new Date(),
new Date(),
params.createdBy,
null,
)
} }
isNewerThan(currentVersionCode: number): boolean { get platform(): Platform {
return this.versionCode > currentVersionCode 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 { 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 { AppVersion } from '../entities/app-version.entity';
import { Platform } from '../enums/platform.enum' 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 { export interface AppVersionRepository {
/** /**
* *
*/ */
save(appVersion: AppVersion): Promise<AppVersion> save(appVersion: AppVersion): Promise<AppVersion>;
/** /**
* ID查找版本 * 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 { Injectable } from '@nestjs/common';
import { AppVersionRepository } from '@/domain/repositories/app-version.repository' import { AppVersionRepository } from '@/domain/repositories/app-version.repository';
import { AppVersion } from '@/domain/entities/app-version.entity' import { AppVersion } from '@/domain/entities/app-version.entity';
import { Platform } from '@/domain/enums/platform.enum' import { Platform } from '@/domain/enums/platform.enum';
import { PrismaService } from '../prisma/prisma.service' import { VersionCode } from '@/domain/value-objects/version-code.vo';
import { PrismaService } from '../prisma/prisma.service';
import { AppVersionMapper } from '../mappers/app-version.mapper';
@Injectable() @Injectable()
export class AppVersionRepositoryImpl implements AppVersionRepository { 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> { async save(appVersion: AppVersion): Promise<AppVersion> {
const data = this.mapper.toPersistence(appVersion);
const created = await this.prisma.appVersion.create({ const created = await this.prisma.appVersion.create({
data: { data: {
id: appVersion.id, ...data,
platform: appVersion.platform, createdAt: appVersion.createdAt,
versionCode: appVersion.versionCode, updatedAt: appVersion.updatedAt,
versionName: appVersion.versionName,
buildNumber: appVersion.buildNumber,
downloadUrl: appVersion.downloadUrl,
fileSize: appVersion.fileSize,
fileSha256: appVersion.fileSha256,
changelog: appVersion.changelog,
isForceUpdate: appVersion.isForceUpdate,
isEnabled: appVersion.isEnabled,
minOsVersion: appVersion.minOsVersion,
releaseDate: appVersion.releaseDate,
createdBy: appVersion.createdBy,
updatedBy: appVersion.updatedBy,
}, },
}) });
return this.toDomain(created) return this.mapper.toDomain(created);
} }
async findById(id: string): Promise<AppVersion | null> { async findById(id: string): Promise<AppVersion | null> {
const found = await this.prisma.appVersion.findUnique({ where: { id } }) const found = await this.prisma.appVersion.findUnique({ where: { id } });
return found ? this.toDomain(found) : null return found ? this.mapper.toDomain(found) : null;
} }
async findLatestByPlatform(platform: Platform): Promise<AppVersion | null> { async findLatestByPlatform(platform: Platform): Promise<AppVersion | null> {
const found = await this.prisma.appVersion.findFirst({ const found = await this.prisma.appVersion.findFirst({
where: { platform, isEnabled: true }, where: { platform, isEnabled: true },
orderBy: { versionCode: 'desc' }, orderBy: { versionCode: 'desc' },
}) });
return found ? this.toDomain(found) : null return found ? this.mapper.toDomain(found) : null;
} }
async findAllByPlatform(platform: Platform, includeDisabled = false): Promise<AppVersion[]> { async findAllByPlatform(platform: Platform, includeDisabled = false): Promise<AppVersion[]> {
const results = await this.prisma.appVersion.findMany({ const results = await this.prisma.appVersion.findMany({
where: includeDisabled ? { platform } : { platform, isEnabled: true }, where: includeDisabled ? { platform } : { platform, isEnabled: true },
orderBy: { versionCode: 'desc' }, orderBy: { versionCode: 'desc' },
}) });
return results.map((r) => this.toDomain(r)) return results.map((r) => this.mapper.toDomain(r));
} }
async findByPlatformAndVersionCode( async findByPlatformAndVersionCode(
platform: Platform, platform: Platform,
versionCode: number, versionCode: VersionCode,
): Promise<AppVersion | null> { ): Promise<AppVersion | null> {
const found = await this.prisma.appVersion.findFirst({ const found = await this.prisma.appVersion.findFirst({
where: { platform, versionCode }, where: { platform, versionCode: versionCode.value },
}) });
return found ? this.toDomain(found) : null return found ? this.mapper.toDomain(found) : null;
} }
async update(id: string, updates: Partial<AppVersion>): Promise<AppVersion> { 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({ const updated = await this.prisma.appVersion.update({
where: { id }, where: { id },
data: { data,
versionName: updates.versionName, });
buildNumber: updates.buildNumber,
downloadUrl: updates.downloadUrl, return this.mapper.toDomain(updated);
fileSize: updates.fileSize,
fileSha256: updates.fileSha256,
changelog: updates.changelog,
isForceUpdate: updates.isForceUpdate,
minOsVersion: updates.minOsVersion,
releaseDate: updates.releaseDate,
updatedBy: updates.updatedBy,
updatedAt: new Date(),
},
})
return this.toDomain(updated)
} }
async toggleEnabled(id: string, isEnabled: boolean): Promise<void> { async toggleEnabled(id: string, isEnabled: boolean): Promise<void> {
await this.prisma.appVersion.update({ await this.prisma.appVersion.update({
where: { id }, where: { id },
data: { isEnabled, updatedAt: new Date() }, data: { isEnabled, updatedAt: new Date() },
}) });
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.prisma.appVersion.delete({ where: { id } }) await this.prisma.appVersion.delete({ where: { id } });
}
private toDomain(model: any): AppVersion {
return new AppVersion(
model.id,
model.platform as Platform,
model.versionCode,
model.versionName,
model.buildNumber,
model.downloadUrl,
model.fileSize,
model.fileSha256,
model.changelog,
model.isForceUpdate,
model.isEnabled,
model.minOsVersion,
model.releaseDate,
model.createdAt,
model.updatedAt,
model.createdBy,
model.updatedBy,
)
} }
} }

View File

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