feat: Introduce DTOs for API validation and new deployment features, including a Git controller and UI components.

This commit is contained in:
2025-11-23 12:03:11 +08:00
parent 02b7c3edb2
commit 378070179f
24 changed files with 809 additions and 302 deletions

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const loginSchema = z.object({
code: z.string().min(1, { message: 'Code不能为空' }),
});
export type LoginInput = z.infer<typeof loginSchema>;

View File

@@ -3,6 +3,7 @@ import { Controller, Get, Post } from '../../decorators/route.ts';
import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { gitea } from '../../libs/gitea.ts';
import { loginSchema } from './dto.ts';
@Controller('/auth')
export class AuthController {
@@ -20,7 +21,7 @@ export class AuthController {
if (ctx.session.user) {
return ctx.session.user;
}
const { code } = ctx.request.body as LoginRequestBody;
const { code } = loginSchema.parse(ctx.request.body);
const { access_token, refresh_token, expires_in } =
await gitea.getToken(code);
const giteaAuth = {
@@ -81,7 +82,3 @@ export class AuthController {
return ctx.session?.user;
}
}
interface LoginRequestBody {
code: string;
}

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const listDeploymentsQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
pageSize: z.coerce.number().int().min(1).max(100).optional().default(10),
projectId: z.coerce.number().int().positive().optional(),
});
export const createDeploymentSchema = z.object({
projectId: z.number().int().positive({ message: '项目ID必须是正整数' }),
pipelineId: z.number().int().positive({ message: '流水线ID必须是正整数' }),
branch: z.string().min(1, { message: '分支不能为空' }),
commitHash: z.string().min(1, { message: '提交哈希不能为空' }),
commitMessage: z.string().min(1, { message: '提交信息不能为空' }),
env: z.string().optional(),
});
export type ListDeploymentsQuery = z.infer<typeof listDeploymentsQuerySchema>;
export type CreateDeploymentInput = z.infer<typeof createDeploymentSchema>;

View File

@@ -1,45 +1,61 @@
import { Controller, Get, Post } from '../../decorators/route.ts';
import type { Prisma } from '../../generated/prisma/index.js';
import type { Prisma } from '../../generated/client.ts';
import { prisma } from '../../libs/prisma.ts';
import type { Context } from 'koa';
import { listDeploymentsQuerySchema, createDeploymentSchema } from './dto.ts';
@Controller('/deployments')
export class DeploymentController {
@Get('')
async list(ctx: Context) {
const { page = 1, pageSize = 10 } = ctx.query;
const { page, pageSize, projectId } = listDeploymentsQuerySchema.parse(ctx.query);
const where: Prisma.DeploymentWhereInput = {
valid: 1,
};
if (projectId) {
where.projectId = projectId;
}
const result = await prisma.deployment.findMany({
where: {
valid: 1,
},
take: Number(pageSize),
skip: (Number(page) - 1) * Number(pageSize),
where,
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: {
createdAt: 'desc',
},
});
const total = await prisma.deployment.count();
const total = await prisma.deployment.count({ where });
return {
data: result,
page: Number(page),
pageSize: Number(pageSize),
total: total,
page,
pageSize,
total,
};
}
@Post('')
async create(ctx: Context) {
const body = ctx.request.body as Prisma.DeploymentCreateInput;
const body = createDeploymentSchema.parse(ctx.request.body);
prisma.deployment.create({
const result = await prisma.deployment.create({
data: {
branch: body.branch,
commitHash: body.commitHash,
commitMessage: body.commitMessage,
status: 'pending',
Project: {
connect: { id: body.projectId },
},
pipelineId: body.pipelineId,
env: body.env || 'dev',
buildLog: '',
createdBy: 'system', // TODO: get from user
updatedBy: 'system',
valid: 1,
},
});
return result;
}
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const getCommitsQuerySchema = z.object({
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
branch: z.string().optional(),
});
export const getBranchesQuerySchema = z.object({
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
});
export type GetCommitsQuery = z.infer<typeof getCommitsQuerySchema>;
export type GetBranchesQuery = z.infer<typeof getBranchesQuerySchema>;

View File

@@ -0,0 +1,113 @@
import type { Context } from 'koa';
import { Controller, Get } from '../../decorators/route.ts';
import { prisma } from '../../libs/prisma.ts';
import { gitea } from '../../libs/gitea.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { getCommitsQuerySchema, getBranchesQuerySchema } from './dto.ts';
@Controller('/git')
export class GitController {
@Get('/commits')
async getCommits(ctx: Context) {
const { projectId, branch } = getCommitsQuerySchema.parse(ctx.query);
const project = await prisma.project.findFirst({
where: {
id: projectId,
valid: 1,
},
});
if (!project) {
throw new BusinessError('Project not found', 1002, 404);
}
// Parse repository URL to get owner and repo
// Supports:
// https://gitea.com/owner/repo.git
// http://gitea.com/owner/repo
const { owner, repo } = this.parseRepoUrl(project.repository);
// Get access token from session
const accessToken = ctx.session?.gitea?.access_token;
console.log('Access token present:', !!accessToken);
if (!accessToken) {
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
}
try {
const commits = await gitea.getCommits(owner, repo, accessToken, branch);
return commits;
} catch (error) {
console.error('Failed to fetch commits:', error);
throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500);
}
}
@Get('/branches')
async getBranches(ctx: Context) {
const { projectId } = getBranchesQuerySchema.parse(ctx.query);
const project = await prisma.project.findFirst({
where: {
id: projectId,
valid: 1,
},
});
if (!project) {
throw new BusinessError('Project not found', 1002, 404);
}
const { owner, repo } = this.parseRepoUrl(project.repository);
const accessToken = ctx.session?.gitea?.access_token;
if (!accessToken) {
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
}
try {
const branches = await gitea.getBranches(owner, repo, accessToken);
return branches;
} catch (error) {
console.error('Failed to fetch branches:', error);
throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500);
}
}
private parseRepoUrl(url: string) {
let cleanUrl = url.trim();
if (cleanUrl.endsWith('/')) {
cleanUrl = cleanUrl.slice(0, -1);
}
// Handle SCP-like syntax: git@host:owner/repo.git
if (!cleanUrl.includes('://') && cleanUrl.includes(':')) {
const scpMatch = cleanUrl.match(/:([^\/]+)\/([^\/]+?)(\.git)?$/);
if (scpMatch) {
return { owner: scpMatch[1], repo: scpMatch[2] };
}
}
// Handle HTTP/HTTPS/SSH URLs
try {
const urlObj = new URL(cleanUrl);
const parts = urlObj.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const repo = parts.pop()!.replace(/\.git$/, '');
const owner = parts.pop()!;
return { owner, repo };
}
} catch (e) {
// Fallback to simple regex
const match = cleanUrl.match(/([^\/]+)\/([^\/]+?)(\.git)?$/);
if (match) {
return { owner: match[1], repo: match[2] };
}
}
throw new BusinessError('Invalid repository URL format', 1003, 400);
}
}

View File

@@ -5,3 +5,4 @@ export { AuthController } from './auth/index.ts';
export { DeploymentController } from './deployment/index.ts';
export { PipelineController } from './pipeline/index.ts';
export { StepController } from './step/index.ts'
export { GitController } from './git/index.ts';

View File

@@ -8,7 +8,7 @@ import {
updatePipelineSchema,
pipelineIdSchema,
listPipelinesQuerySchema,
} from './schema.ts';
} from './dto.ts';
@Controller('/pipelines')
export class PipelineController {

View File

@@ -8,7 +8,7 @@ import {
updateProjectSchema,
listProjectQuerySchema,
projectIdSchema,
} from './schema.ts';
} from './dto.ts';
@Controller('/projects')
export class ProjectController {

View File

@@ -0,0 +1,103 @@
import { z } from 'zod';
// 定义验证架构
export const createStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' }),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' }),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' }),
pipelineId: z
.number({
message: '流水线ID必须是数字',
})
.int()
.positive({ message: '流水线ID必须是正整数' }),
});
export const updateStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' })
.optional(),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' })
.optional(),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' })
.optional(),
});
export const stepIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '步骤 ID 必须是正整数' }),
});
export const listStepsQuerySchema = z
.object({
pipelineId: z.coerce
.number()
.int()
.positive({ message: '流水线ID必须是正整数' })
.optional(),
page: z.coerce
.number()
.int()
.min(1, { message: '页码必须大于0' })
.optional()
.default(1),
limit: z.coerce
.number()
.int()
.min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' })
.optional()
.default(10),
})
.optional();
// TypeScript 类型
export type CreateStepInput = z.infer<typeof createStepSchema>;
export type UpdateStepInput = z.infer<typeof updateStepSchema>;
export type StepIdParams = z.infer<typeof stepIdSchema>;
export type ListStepsQuery = z.infer<typeof listStepsQuerySchema>;

View File

@@ -3,109 +3,12 @@ import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { z } from 'zod';
// 定义验证架构
const createStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' }),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' }),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' }),
pipelineId: z
.number({
message: '流水线ID必须是数字',
})
.int()
.positive({ message: '流水线ID必须是正整数' }),
});
const updateStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' })
.optional(),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' })
.optional(),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' })
.optional(),
});
const stepIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '步骤 ID 必须是正整数' }),
});
const listStepsQuerySchema = z
.object({
pipelineId: z.coerce
.number()
.int()
.positive({ message: '流水线ID必须是正整数' })
.optional(),
page: z.coerce
.number()
.int()
.min(1, { message: '页码必须大于0' })
.optional()
.default(1),
limit: z.coerce
.number()
.int()
.min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' })
.optional()
.default(10),
})
.optional();
// TypeScript 类型
type CreateStepInput = z.infer<typeof createStepSchema>;
type UpdateStepInput = z.infer<typeof updateStepSchema>;
type StepIdParams = z.infer<typeof stepIdSchema>;
type ListStepsQuery = z.infer<typeof listStepsQuerySchema>;
import {
createStepSchema,
updateStepSchema,
stepIdSchema,
listStepsQuerySchema,
} from './dto.ts';
@Controller('/steps')
export class StepController {
@@ -185,7 +88,6 @@ export class StepController {
const step = await prisma.step.create({
data: {
name: validatedData.name,
description: validatedData.description || '',
order: validatedData.order,
script: validatedData.script,
pipelineId: validatedData.pipelineId,

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
export const userIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '用户ID必须是正整数' }),
});
export const createUserSchema = z.object({
name: z.string().min(1, { message: '用户名不能为空' }),
email: z.string().email({ message: '邮箱格式不正确' }),
status: z.enum(['active', 'inactive']).optional().default('active'),
});
export const updateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export const searchUserQuerySchema = z.object({
keyword: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type SearchUserQuery = z.infer<typeof searchUserQuerySchema>;

View File

@@ -1,6 +1,12 @@
import type { Context } from 'koa';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import {
userIdSchema,
createUserSchema,
updateUserSchema,
searchUserQuerySchema,
} from './dto.ts';
/**
* 用户控制器
@@ -22,18 +28,18 @@ export class UserController {
@Get('/detail/:id')
async detail(ctx: Context) {
const { id } = ctx.params;
const { id } = userIdSchema.parse(ctx.params);
// 模拟根据ID查找用户
const user = {
id: Number(id),
id,
name: 'User ' + id,
email: `user${id}@example.com`,
status: 'active',
createdAt: new Date().toISOString()
};
if (Number(id) > 100) {
if (id > 100) {
throw new BusinessError('用户不存在', 2001, 404);
}
@@ -42,14 +48,14 @@ export class UserController {
@Post('')
async create(ctx: Context) {
const body = (ctx.request as any).body;
const body = createUserSchema.parse(ctx.request.body);
// 模拟创建用户
const newUser = {
id: Date.now(),
...body,
createdAt: new Date().toISOString(),
status: 'active'
status: body.status
};
return newUser;
@@ -57,12 +63,12 @@ export class UserController {
@Put('/:id')
async update(ctx: Context) {
const { id } = ctx.params;
const body = (ctx.request as any).body;
const { id } = userIdSchema.parse(ctx.params);
const body = updateUserSchema.parse(ctx.request.body);
// 模拟更新用户
const updatedUser = {
id: Number(id),
id,
...body,
updatedAt: new Date().toISOString()
};
@@ -72,9 +78,9 @@ export class UserController {
@Delete('/:id')
async delete(ctx: Context) {
const { id } = ctx.params;
const { id } = userIdSchema.parse(ctx.params);
if (Number(id) === 1) {
if (id === 1) {
throw new BusinessError('管理员账户不能删除', 2002, 403);
}
@@ -88,7 +94,7 @@ export class UserController {
@Get('/search')
async search(ctx: Context) {
const { keyword, status } = ctx.query;
const { keyword, status } = searchUserQuerySchema.parse(ctx.query);
// 模拟搜索逻辑
let results = [
@@ -98,8 +104,8 @@ export class UserController {
if (keyword) {
results = results.filter(user =>
user.name.toLowerCase().includes(String(keyword).toLowerCase()) ||
user.email.toLowerCase().includes(String(keyword).toLowerCase())
user.name.toLowerCase().includes(keyword.toLowerCase()) ||
user.email.toLowerCase().includes(keyword.toLowerCase())
);
}