feat: 支持发布单 MySQL 持久化
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
/**
|
||||
* 发布单仓储同时支持本地内存模式和 MySQL/Prisma 持久化模式。
|
||||
* 业务层只关心 run/step 状态流转,是否落库由环境配置统一控制。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
DeployRunStatus as PrismaDeployRunStatus,
|
||||
DeployRunTrigger as PrismaDeployRunTrigger,
|
||||
EnvironmentKind,
|
||||
Prisma,
|
||||
RunStepStatus as PrismaRunStepStatus,
|
||||
} from '@prisma/client';
|
||||
import { EnvConfig } from '../config/env.schema';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { seedDeployRuns } from './deploy-run.seed';
|
||||
@@ -29,6 +40,24 @@ type DeployRunStepMemoryPatch = {
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
type DeployRunRecord = Prisma.DeployRunGetPayload<{
|
||||
include: {
|
||||
project: true;
|
||||
environment: true;
|
||||
steps: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
type DeployRunMetadata = Prisma.InputJsonObject & {
|
||||
jenkinsBuildUrl?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type RunStepMetadata = Prisma.InputJsonObject & {
|
||||
message?: string;
|
||||
logExcerpt?: string;
|
||||
};
|
||||
|
||||
const terminalRunStatuses = new Set<DeployRunStatus>([
|
||||
'success',
|
||||
'failed',
|
||||
@@ -40,6 +69,31 @@ const terminalStepStatuses = new Set<DeployRunStepStatus>([
|
||||
'canceled',
|
||||
'skipped',
|
||||
]);
|
||||
const runStatusToPrisma: Record<DeployRunStatus, PrismaDeployRunStatus> = {
|
||||
pending: PrismaDeployRunStatus.PENDING,
|
||||
queued: PrismaDeployRunStatus.QUEUED,
|
||||
running: PrismaDeployRunStatus.RUNNING,
|
||||
success: PrismaDeployRunStatus.SUCCESS,
|
||||
failed: PrismaDeployRunStatus.FAILED,
|
||||
canceled: PrismaDeployRunStatus.CANCELED,
|
||||
};
|
||||
const stepStatusToPrisma: Record<DeployRunStepStatus, PrismaRunStepStatus> = {
|
||||
pending: PrismaRunStepStatus.PENDING,
|
||||
queued: PrismaRunStepStatus.RUNNING,
|
||||
running: PrismaRunStepStatus.RUNNING,
|
||||
success: PrismaRunStepStatus.SUCCESS,
|
||||
failed: PrismaRunStepStatus.FAILED,
|
||||
canceled: PrismaRunStepStatus.CANCELED,
|
||||
skipped: PrismaRunStepStatus.SKIPPED,
|
||||
};
|
||||
const triggerToPrisma: Record<
|
||||
DeployRunSummary['trigger'],
|
||||
PrismaDeployRunTrigger
|
||||
> = {
|
||||
manual: PrismaDeployRunTrigger.MANUAL,
|
||||
gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK,
|
||||
retry: PrismaDeployRunTrigger.RETRY,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DeployRunRepository {
|
||||
@@ -51,7 +105,7 @@ export class DeployRunRepository {
|
||||
) {}
|
||||
|
||||
async findMany(): Promise<DeployRunSummary[]> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
if (!this.useDatabase()) {
|
||||
return [...this.memoryRuns].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
@@ -73,40 +127,28 @@ export class DeployRunRepository {
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return runs.map((run) => ({
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: run.steps.map((step) => ({
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
})),
|
||||
}));
|
||||
return runs.map((run) => this.toSummary(run));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabaseReads()) {
|
||||
if (!this.useDatabase()) {
|
||||
return this.memoryRuns.find((run) => run.id === id) ?? null;
|
||||
}
|
||||
|
||||
const run = await this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
const run = await this.findRecordById(id);
|
||||
|
||||
return run ? this.toSummary(run) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
||||
const idempotencyKey = this.resolveIdempotencyKey(input);
|
||||
|
||||
if (!this.useDatabase()) {
|
||||
return this.createMemoryRun(input, idempotencyKey);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.deployRun.findUnique({
|
||||
where: { idempotencyKey },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
@@ -118,55 +160,284 @@ export class DeployRunRepository {
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
if (existing) {
|
||||
return this.toSummary(existing);
|
||||
}
|
||||
|
||||
const target = await this.findTargetProjectEnvironment(input);
|
||||
const initialRun = this.buildInitialRun(input, idempotencyKey);
|
||||
const metadata = this.inputMetadata(input);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const run = await tx.deployRun.create({
|
||||
data: {
|
||||
projectId: target.project.id,
|
||||
environmentId: target.environment.id,
|
||||
jobId: target.deployJob?.id,
|
||||
status: PrismaDeployRunStatus.PENDING,
|
||||
trigger: triggerToPrisma[input.trigger ?? 'manual'],
|
||||
ref: input.ref,
|
||||
operator: input.operator,
|
||||
idempotencyKey,
|
||||
metadata,
|
||||
steps: {
|
||||
create: initialRun.steps.map((step) => ({
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: stepStatusToPrisma[step.status],
|
||||
order: step.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
});
|
||||
|
||||
return this.toSummary(created);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: DeployRunStatus,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
return this.updateRun(id, { status });
|
||||
}
|
||||
|
||||
async updateRun(
|
||||
id: string,
|
||||
patch: DeployRunMemoryPatch,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabase()) {
|
||||
return this.updateMemoryRun(id, patch);
|
||||
}
|
||||
|
||||
const current = await this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const metadata = this.objectMetadata<DeployRunMetadata>(current.metadata);
|
||||
const data: Prisma.DeployRunUpdateInput = {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
};
|
||||
|
||||
if (patch.status) {
|
||||
data.status = runStatusToPrisma[patch.status];
|
||||
|
||||
if (patch.status !== 'pending' && !current.startedAt) {
|
||||
data.startedAt = this.toDate(patch.startedAt) ?? now;
|
||||
}
|
||||
|
||||
if (terminalRunStatuses.has(patch.status)) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt) ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('jenkinsQueueId' in patch) {
|
||||
data.jenkinsQueueId = patch.jenkinsQueueId ?? null;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildNumber' in patch) {
|
||||
data.jenkinsBuildNumber = patch.jenkinsBuildNumber ?? null;
|
||||
}
|
||||
|
||||
if ('jenkinsBuildUrl' in patch) {
|
||||
if (patch.jenkinsBuildUrl) {
|
||||
metadata.jenkinsBuildUrl = patch.jenkinsBuildUrl;
|
||||
} else {
|
||||
delete metadata.jenkinsBuildUrl;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
if ('failureSummary' in patch) {
|
||||
data.failureSummary = patch.failureSummary ?? null;
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
data.startedAt = this.toDate(patch.startedAt);
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt);
|
||||
}
|
||||
|
||||
await this.prisma.deployRun.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async updateStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
patch: DeployRunStepMemoryPatch,
|
||||
): Promise<DeployRunSummary | null> {
|
||||
if (!this.useDatabase()) {
|
||||
return this.updateMemoryStep(runId, bpmnNodeId, patch);
|
||||
}
|
||||
|
||||
const current = await this.prisma.runStep.findUnique({
|
||||
where: {
|
||||
deployRunId_bpmnNodeId: {
|
||||
deployRunId: runId,
|
||||
bpmnNodeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const metadata = this.objectMetadata<RunStepMetadata>(current.metadata);
|
||||
const data: Prisma.RunStepUpdateInput = {};
|
||||
|
||||
if (patch.status) {
|
||||
data.status = stepStatusToPrisma[patch.status];
|
||||
|
||||
if (patch.status !== 'pending' && !current.startedAt) {
|
||||
data.startedAt = this.toDate(patch.startedAt) ?? now;
|
||||
}
|
||||
|
||||
if (terminalStepStatuses.has(patch.status)) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt) ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
if ('startedAt' in patch) {
|
||||
data.startedAt = this.toDate(patch.startedAt);
|
||||
}
|
||||
|
||||
if ('finishedAt' in patch) {
|
||||
data.finishedAt = this.toDate(patch.finishedAt);
|
||||
}
|
||||
|
||||
if ('message' in patch) {
|
||||
if (patch.message) {
|
||||
metadata.message = patch.message;
|
||||
} else {
|
||||
delete metadata.message;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
if ('errorSummary' in patch) {
|
||||
data.errorSummary = patch.errorSummary ?? null;
|
||||
}
|
||||
|
||||
if ('logExcerpt' in patch) {
|
||||
if (patch.logExcerpt) {
|
||||
metadata.logExcerpt = patch.logExcerpt;
|
||||
} else {
|
||||
delete metadata.logExcerpt;
|
||||
}
|
||||
data.metadata = metadata;
|
||||
}
|
||||
|
||||
await this.prisma.runStep.update({
|
||||
where: {
|
||||
deployRunId_bpmnNodeId: {
|
||||
deployRunId: runId,
|
||||
bpmnNodeId,
|
||||
},
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return this.findById(runId);
|
||||
}
|
||||
|
||||
private async findRecordById(id: string): Promise<DeployRunRecord | null> {
|
||||
return this.prisma.deployRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async findTargetProjectEnvironment(input: CreateDeployRunInput) {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { key: input.projectKey },
|
||||
include: {
|
||||
environments: {
|
||||
include: {
|
||||
deployJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const targetEnvironmentName =
|
||||
input.environment === 'test'
|
||||
? EnvironmentKind.TEST
|
||||
: EnvironmentKind.PRODUCTION;
|
||||
const environment = project?.environments.find(
|
||||
(item) => item.name === targetEnvironmentName,
|
||||
);
|
||||
const deployJob = environment?.deployJobs[0];
|
||||
|
||||
if (!project || !environment) {
|
||||
throw new Error(
|
||||
`Project ${input.projectKey} ${input.environment} is not seeded in database`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: run.steps.map((step) => ({
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
})),
|
||||
project,
|
||||
environment,
|
||||
deployJob,
|
||||
};
|
||||
}
|
||||
|
||||
create(input: CreateDeployRunInput): DeployRunSummary {
|
||||
const idempotencyKey =
|
||||
input.idempotencyKey ??
|
||||
[
|
||||
input.projectKey,
|
||||
input.environment,
|
||||
input.ref,
|
||||
input.operator,
|
||||
new Date().toISOString(),
|
||||
].join(':');
|
||||
private createMemoryRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
): DeployRunSummary {
|
||||
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const run = this.buildInitialRun(input, idempotencyKey);
|
||||
this.memoryRuns.unshift(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
private buildInitialRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
): DeployRunSummary {
|
||||
const now = new Date().toISOString();
|
||||
const run: DeployRunSummary = {
|
||||
|
||||
return {
|
||||
id: idempotencyKey,
|
||||
projectKey: input.projectKey,
|
||||
environment: input.environment,
|
||||
@@ -220,19 +491,9 @@ export class DeployRunRepository {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.memoryRuns.unshift(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStatus(
|
||||
id: string,
|
||||
status: DeployRunStatus,
|
||||
): DeployRunSummary | null {
|
||||
return this.updateMemoryRun(id, { status });
|
||||
}
|
||||
|
||||
updateMemoryRun(
|
||||
private updateMemoryRun(
|
||||
id: string,
|
||||
patch: DeployRunMemoryPatch,
|
||||
): DeployRunSummary | null {
|
||||
@@ -283,7 +544,7 @@ export class DeployRunRepository {
|
||||
return run;
|
||||
}
|
||||
|
||||
updateMemoryStep(
|
||||
private updateMemoryStep(
|
||||
runId: string,
|
||||
bpmnNodeId: string,
|
||||
patch: DeployRunStepMemoryPatch,
|
||||
@@ -332,7 +593,84 @@ export class DeployRunRepository {
|
||||
return run;
|
||||
}
|
||||
|
||||
private useDatabaseReads(): boolean {
|
||||
private toSummary(run: DeployRunRecord): DeployRunSummary {
|
||||
const metadata = this.objectMetadata<DeployRunMetadata>(run.metadata);
|
||||
const sortedSteps = [...run.steps].sort((left, right) => left.order - right.order);
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
projectKey: run.project.key,
|
||||
environment: run.environment.name === 'TEST' ? 'test' : 'production',
|
||||
ref: run.ref,
|
||||
status: run.status.toLowerCase() as DeployRunStatus,
|
||||
trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'],
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId ?? undefined,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined,
|
||||
jenkinsBuildUrl: metadata.jenkinsBuildUrl,
|
||||
failureSummary: run.failureSummary ?? undefined,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString(),
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
steps: sortedSteps.map((step) => {
|
||||
const stepMetadata = this.objectMetadata<RunStepMetadata>(
|
||||
step.metadata,
|
||||
);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: step.status.toLowerCase() as DeployRunStepStatus,
|
||||
order: step.order,
|
||||
startedAt: step.startedAt?.toISOString(),
|
||||
finishedAt: step.finishedAt?.toISOString(),
|
||||
message: stepMetadata.message,
|
||||
errorSummary: step.errorSummary ?? undefined,
|
||||
logExcerpt: stepMetadata.logExcerpt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private objectMetadata<T extends Record<string, unknown>>(
|
||||
value: Prisma.JsonValue | null | undefined,
|
||||
): T {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return { ...(value as Record<string, unknown>) } as T;
|
||||
}
|
||||
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
private inputMetadata(input: CreateDeployRunInput): Prisma.InputJsonObject {
|
||||
const metadata: DeployRunMetadata = {};
|
||||
|
||||
if (input.remark) {
|
||||
metadata.remark = input.remark;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private resolveIdempotencyKey(input: CreateDeployRunInput): string {
|
||||
return (
|
||||
input.idempotencyKey ??
|
||||
[
|
||||
input.projectKey,
|
||||
input.environment,
|
||||
input.ref,
|
||||
input.operator,
|
||||
new Date().toISOString(),
|
||||
].join(':')
|
||||
);
|
||||
}
|
||||
|
||||
private toDate(value: string | undefined): Date | null | undefined {
|
||||
return value ? new Date(value) : undefined;
|
||||
}
|
||||
|
||||
private useDatabase(): boolean {
|
||||
return (
|
||||
this.config.get('USE_DATABASE_READS', { infer: true }) &&
|
||||
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||
|
||||
Reference in New Issue
Block a user