feat: 项目的增删改查
This commit is contained in:
239
apps/server/README-RESTful-API.md
Normal file
239
apps/server/README-RESTful-API.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# RESTful API 设计规范
|
||||
|
||||
## 概述
|
||||
|
||||
本项目采用 RESTful API 设计规范,提供统一、直观的 HTTP 接口设计。
|
||||
|
||||
## API 设计原则
|
||||
|
||||
### 1. 资源命名规范
|
||||
- 使用名词复数形式作为资源名称
|
||||
- 示例:`/api/projects`、`/api/users`
|
||||
|
||||
### 2. HTTP 方法语义
|
||||
- `GET`: 获取资源
|
||||
- `POST`: 创建资源
|
||||
- `PUT`: 更新整个资源
|
||||
- `PATCH`: 部分更新资源
|
||||
- `DELETE`: 删除资源
|
||||
|
||||
### 3. 状态码规范
|
||||
- `200 OK`: 成功获取或更新资源
|
||||
- `201 Created`: 成功创建资源
|
||||
- `204 No Content`: 成功删除资源
|
||||
- `400 Bad Request`: 请求参数错误
|
||||
- `404 Not Found`: 资源不存在
|
||||
- `500 Internal Server Error`: 服务器内部错误
|
||||
|
||||
## 项目 API 接口
|
||||
|
||||
### 项目资源 (Projects)
|
||||
|
||||
#### 1. 获取项目列表
|
||||
```
|
||||
GET /api/projects
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
- `page` (可选): 页码,默认为 1
|
||||
- `limit` (可选): 每页数量,默认为 10,最大 100
|
||||
- `name` (可选): 项目名称搜索
|
||||
|
||||
**响应格式:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取列表成功,共N条记录",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "项目名称",
|
||||
"description": "项目描述",
|
||||
"repository": "https://github.com/user/repo",
|
||||
"valid": 1,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "system",
|
||||
"updatedBy": "system"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"total": 100,
|
||||
"totalPages": 10
|
||||
}
|
||||
},
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 获取单个项目
|
||||
```
|
||||
GET /api/projects/:id
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
- `id`: 项目ID(整数)
|
||||
|
||||
**响应格式:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取数据成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "项目名称",
|
||||
"description": "项目描述",
|
||||
"repository": "https://github.com/user/repo",
|
||||
"valid": 1,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "system",
|
||||
"updatedBy": "system"
|
||||
},
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 创建项目
|
||||
```
|
||||
POST /api/projects
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"name": "项目名称",
|
||||
"description": "项目描述(可选)",
|
||||
"repository": "https://github.com/user/repo"
|
||||
}
|
||||
```
|
||||
|
||||
**验证规则:**
|
||||
- `name`: 必填,2-50个字符
|
||||
- `description`: 可选,最多200个字符
|
||||
- `repository`: 必填,有效的URL格式
|
||||
|
||||
**响应格式:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取数据成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "项目名称",
|
||||
"description": "项目描述",
|
||||
"repository": "https://github.com/user/repo",
|
||||
"valid": 1,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "system",
|
||||
"updatedBy": "system"
|
||||
},
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 更新项目
|
||||
```
|
||||
PUT /api/projects/:id
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
- `id`: 项目ID(整数)
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"name": "新项目名称(可选)",
|
||||
"description": "新项目描述(可选)",
|
||||
"repository": "https://github.com/user/newrepo(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
**验证规则:**
|
||||
- `name`: 可选,2-50个字符
|
||||
- `description`: 可选,最多200个字符
|
||||
- `repository`: 可选,有效的URL格式
|
||||
|
||||
**响应格式:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取数据成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "新项目名称",
|
||||
"description": "新项目描述",
|
||||
"repository": "https://github.com/user/newrepo",
|
||||
"valid": 1,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T12:00:00Z",
|
||||
"createdBy": "system",
|
||||
"updatedBy": "system"
|
||||
},
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 删除项目
|
||||
```
|
||||
DELETE /api/projects/:id
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
- `id`: 项目ID(整数)
|
||||
|
||||
**响应:**
|
||||
- HTTP 状态码: 204 No Content
|
||||
- 响应体: 空
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 验证错误 (400 Bad Request)
|
||||
```json
|
||||
{
|
||||
"code": 1003,
|
||||
"message": "项目名称至少2个字符",
|
||||
"data": {
|
||||
"field": "name",
|
||||
"validationErrors": [
|
||||
{
|
||||
"field": "name",
|
||||
"message": "项目名称至少2个字符",
|
||||
"code": "too_small"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
### 资源不存在 (404 Not Found)
|
||||
```json
|
||||
{
|
||||
"code": 1002,
|
||||
"message": "项目不存在",
|
||||
"data": null,
|
||||
"timestamp": 1700000000000
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 统一响应格式
|
||||
所有 API 都使用统一的响应格式,包含 `code`、`message`、`data`、`timestamp` 字段。
|
||||
|
||||
### 2. 参数验证
|
||||
使用 Zod 进行严格的参数验证,确保数据的完整性和安全性。
|
||||
|
||||
### 3. 错误处理
|
||||
全局异常处理中间件统一处理各种错误,提供一致的错误响应格式。
|
||||
|
||||
### 4. 分页支持
|
||||
列表接口支持分页功能,返回分页信息方便前端处理。
|
||||
|
||||
### 5. 软删除
|
||||
删除操作采用软删除方式,将 `valid` 字段设置为 0,保留数据历史。
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Context } from 'koa';
|
||||
import { Controller, Get, Post } from '../decorators/route.ts';
|
||||
import prisma from '../libs/db.ts';
|
||||
import { log } from '../libs/logger.ts';
|
||||
import { gitea } from '../libs/gitea.ts';
|
||||
import { Controller, Get, Post } from '../../decorators/route.ts';
|
||||
import prisma from '../../libs/db.ts';
|
||||
import { log } from '../../libs/logger.ts';
|
||||
import { gitea } from '../../libs/gitea.ts';
|
||||
|
||||
@Controller('/auth')
|
||||
export class AuthController {
|
||||
4
apps/server/controllers/index.ts
Normal file
4
apps/server/controllers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 控制器统一导出
|
||||
export { ProjectController } from './project/index.ts';
|
||||
export { UserController } from './user/index.ts';
|
||||
export { AuthController } from './auth/index.ts';
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Context } from 'koa';
|
||||
import prisma from '../libs/db.ts';
|
||||
import { log } from '../libs/logger.ts';
|
||||
import { BusinessError } from '../middlewares/exception.ts';
|
||||
import { Controller, Get } from '../decorators/route.ts';
|
||||
|
||||
@Controller('/project')
|
||||
export class ProjectController {
|
||||
@Get('/list')
|
||||
async list(ctx: Context) {
|
||||
log.debug('app', 'session %o', ctx.session);
|
||||
try {
|
||||
const list = await prisma.application.findMany({
|
||||
where: {
|
||||
valid: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 直接返回数据,由路由中间件统一包装成响应格式
|
||||
return list;
|
||||
} catch (error) {
|
||||
// 抛出业务异常,由全局异常处理中间件捕获
|
||||
throw new BusinessError('获取应用列表失败', 1001, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/detail/:id')
|
||||
async detail(ctx: Context) {
|
||||
const { id } = ctx.params;
|
||||
const app = await prisma.application.findUnique({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new BusinessError('应用不存在', 1002, 404);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
167
apps/server/controllers/project/index.ts
Normal file
167
apps/server/controllers/project/index.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Context } from 'koa';
|
||||
import prisma from '../../libs/db.ts';
|
||||
import { log } from '../../libs/logger.ts';
|
||||
import { BusinessError } from '../../middlewares/exception.ts';
|
||||
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
listProjectQuerySchema,
|
||||
projectIdSchema,
|
||||
} from './schema.ts';
|
||||
|
||||
@Controller('/projects')
|
||||
export class ProjectController {
|
||||
// GET /api/projects - 获取项目列表
|
||||
@Get('')
|
||||
async list(ctx: Context) {
|
||||
const query = listProjectQuerySchema.parse(ctx.query);
|
||||
|
||||
const whereCondition: any = {
|
||||
valid: 1,
|
||||
};
|
||||
|
||||
// 如果提供了名称搜索参数
|
||||
if (query?.name) {
|
||||
whereCondition.name = {
|
||||
contains: query.name,
|
||||
};
|
||||
}
|
||||
|
||||
const [total, projects] = await Promise.all([
|
||||
prisma.project.count({ where: whereCondition }),
|
||||
prisma.project.findMany({
|
||||
where: whereCondition,
|
||||
skip: query ? (query.page - 1) * query.limit : 0,
|
||||
take: query?.limit,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
data: projects,
|
||||
pagination: {
|
||||
page: query?.page || 1,
|
||||
limit: query?.limit || 10,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (query?.limit || 10)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/projects/:id - 获取单个项目
|
||||
@Get(':id')
|
||||
async show(ctx: Context) {
|
||||
const { id } = projectIdSchema.parse(ctx.params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
id,
|
||||
valid: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new BusinessError('项目不存在', 1002, 404);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
// POST /api/projects - 创建项目
|
||||
@Post('')
|
||||
async create(ctx: Context) {
|
||||
const validatedData = createProjectSchema.parse(ctx.request.body);
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
description: validatedData.description || '',
|
||||
repository: validatedData.repository,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
valid: 1,
|
||||
},
|
||||
});
|
||||
|
||||
log.info('project', 'Created new project: %s', project.name);
|
||||
return project;
|
||||
}
|
||||
|
||||
// PUT /api/projects/:id - 更新项目
|
||||
@Put(':id')
|
||||
async update(ctx: Context) {
|
||||
const { id } = projectIdSchema.parse(ctx.params);
|
||||
const validatedData = updateProjectSchema.parse(ctx.request.body);
|
||||
|
||||
// 检查项目是否存在
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: {
|
||||
id,
|
||||
valid: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
throw new BusinessError('项目不存在', 1002, 404);
|
||||
}
|
||||
|
||||
// 只更新提供的字段
|
||||
const updateData: any = {
|
||||
updatedBy: 'system',
|
||||
};
|
||||
|
||||
if (validatedData.name !== undefined) {
|
||||
updateData.name = validatedData.name;
|
||||
}
|
||||
if (validatedData.description !== undefined) {
|
||||
updateData.description = validatedData.description;
|
||||
}
|
||||
if (validatedData.repository !== undefined) {
|
||||
updateData.repository = validatedData.repository;
|
||||
}
|
||||
|
||||
const project = await prisma.project.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
log.info('project', 'Updated project: %s', project.name);
|
||||
return project;
|
||||
}
|
||||
|
||||
// DELETE /api/projects/:id - 删除项目(软删除)
|
||||
@Delete(':id')
|
||||
async destroy(ctx: Context) {
|
||||
const { id } = projectIdSchema.parse(ctx.params);
|
||||
|
||||
// 检查项目是否存在
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: {
|
||||
id,
|
||||
valid: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
throw new BusinessError('项目不存在', 1002, 404);
|
||||
}
|
||||
|
||||
// 软删除:将 valid 设置为 0
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
valid: 0,
|
||||
updatedBy: 'system',
|
||||
},
|
||||
});
|
||||
|
||||
log.info('project', 'Deleted project: %s', existingProject.name);
|
||||
|
||||
// RESTful 删除成功返回 204 No Content
|
||||
ctx.status = 204;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
apps/server/controllers/project/schema.ts
Normal file
57
apps/server/controllers/project/schema.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 创建项目验证架构
|
||||
*/
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string({
|
||||
message: '项目名称必须是字符串',
|
||||
}).min(2, { message: '项目名称至少2个字符' }).max(50, { message: '项目名称不能超过50个字符' }),
|
||||
|
||||
description: z.string({
|
||||
message: '项目描述必须是字符串',
|
||||
}).max(200, { message: '项目描述不能超过200个字符' }).optional(),
|
||||
|
||||
repository: z.string({
|
||||
message: '仓库地址必须是字符串',
|
||||
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新项目验证架构
|
||||
*/
|
||||
export const updateProjectSchema = z.object({
|
||||
name: z.string({
|
||||
message: '项目名称必须是字符串',
|
||||
}).min(2, { message: '项目名称至少2个字符' }).max(50, { message: '项目名称不能超过50个字符' }).optional(),
|
||||
|
||||
description: z.string({
|
||||
message: '项目描述必须是字符串',
|
||||
}).max(200, { message: '项目描述不能超过200个字符' }).optional(),
|
||||
|
||||
repository: z.string({
|
||||
message: '仓库地址必须是字符串',
|
||||
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 项目列表查询参数验证架构
|
||||
*/
|
||||
export const listProjectQuerySchema = z.object({
|
||||
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),
|
||||
name: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
/**
|
||||
* 项目ID验证架构
|
||||
*/
|
||||
export const projectIdSchema = z.object({
|
||||
id: z.coerce.number().int().positive({ message: '项目 ID 必须是正整数' }),
|
||||
});
|
||||
|
||||
// TypeScript 类型导出
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||
export type ListProjectQuery = z.infer<typeof listProjectQuerySchema>;
|
||||
export type ProjectIdParams = z.infer<typeof projectIdSchema>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Context } from 'koa';
|
||||
import { Controller, Get, Post, Put, Delete } from '../decorators/route.ts';
|
||||
import { BusinessError } from '../middlewares/exception.ts';
|
||||
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||
import { BusinessError } from '../../middlewares/exception.ts';
|
||||
|
||||
/**
|
||||
* 用户控制器
|
||||
@@ -51,10 +51,10 @@ function createMethodDecorator(method: HttpMethod) {
|
||||
// 在类初始化时执行
|
||||
context.addInitializer(function () {
|
||||
// 使用 this.constructor 时需要类型断言
|
||||
const constructor = (this as any).constructor;
|
||||
const ctor = (this as any).constructor;
|
||||
|
||||
// 获取现有的路由元数据
|
||||
const existingRoutes: RouteMetadata[] = getMetadata(ROUTE_METADATA_KEY, constructor) || [];
|
||||
const existingRoutes: RouteMetadata[] = getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||
|
||||
// 添加新的路由元数据
|
||||
const newRoute: RouteMetadata = {
|
||||
@@ -66,7 +66,7 @@ function createMethodDecorator(method: HttpMethod) {
|
||||
existingRoutes.push(newRoute);
|
||||
|
||||
// 保存路由元数据到类的构造函数上
|
||||
setMetadata(ROUTE_METADATA_KEY, existingRoutes, constructor);
|
||||
setMetadata(ROUTE_METADATA_KEY, existingRoutes, ctor);
|
||||
});
|
||||
|
||||
return target;
|
||||
@@ -125,13 +125,13 @@ export function Controller(prefix: string = '') {
|
||||
/**
|
||||
* 获取控制器的路由元数据
|
||||
*/
|
||||
export function getRouteMetadata(constructor: any): RouteMetadata[] {
|
||||
return getMetadata(ROUTE_METADATA_KEY, constructor) || [];
|
||||
export function getRouteMetadata(ctor: any): RouteMetadata[] {
|
||||
return getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取控制器的路由前缀
|
||||
*/
|
||||
export function getControllerPrefix(constructor: any): string {
|
||||
return getMetadata('prefix', constructor) || '';
|
||||
export function getControllerPrefix(ctor: any): string {
|
||||
return getMetadata('prefix', ctor) || '';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type Koa from 'koa';
|
||||
import { z } from 'zod';
|
||||
import type { Middleware } from './types.ts';
|
||||
import { log } from '../libs/logger.ts';
|
||||
|
||||
@@ -51,7 +52,22 @@ export class Exception implements Middleware {
|
||||
* 处理错误
|
||||
*/
|
||||
private handleError(ctx: Koa.Context, error: any): void {
|
||||
if (error instanceof BusinessError) {
|
||||
if (error instanceof z.ZodError) {
|
||||
// Zod 参数验证错误
|
||||
const firstError = error.issues[0];
|
||||
const errorMessage = firstError?.message || '参数验证失败';
|
||||
const fieldPath = firstError?.path?.join('.') || 'unknown';
|
||||
|
||||
log.info('Exception', 'Zod validation failed: %s at %s', errorMessage, fieldPath);
|
||||
this.sendResponse(ctx, 1003, errorMessage, {
|
||||
field: fieldPath,
|
||||
validationErrors: error.issues.map(issue => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}))
|
||||
}, 400);
|
||||
} else if (error instanceof BusinessError) {
|
||||
// 业务异常
|
||||
this.sendResponse(ctx, error.code, error.message, null, error.httpStatus);
|
||||
} else if (error.status) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import KoaRouter from '@koa/router';
|
||||
import type Koa from 'koa';
|
||||
import type { Middleware } from './types.ts';
|
||||
import { RouteScanner } from '../libs/route-scanner.ts';
|
||||
import { ProjectController } from '../controllers/project.ts';
|
||||
import { UserController } from '../controllers/user.ts';
|
||||
import { AuthController } from '../controllers/auth.ts';
|
||||
import {
|
||||
ProjectController,
|
||||
UserController,
|
||||
AuthController,
|
||||
} from '../controllers/index.ts';
|
||||
import { log } from '../libs/logger.ts';
|
||||
|
||||
export class Router implements Middleware {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-session": "^7.0.2",
|
||||
"pino": "^9.9.1",
|
||||
"pino-pretty": "^13.1.1"
|
||||
"pino-pretty": "^13.1.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node-ts": "^23.6.1",
|
||||
|
||||
Binary file not shown.
@@ -11,7 +11,7 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Application {
|
||||
model Project {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
|
||||
Reference in New Issue
Block a user