From 84f5b12ccc95bb4c483e8b3e21b9423a9b75c235 Mon Sep 17 00:00:00 2001 From: LinXin Date: Fri, 21 Nov 2025 15:16:48 +0800 Subject: [PATCH] feat: B3 Core CRUD and Drag-and-Drop API --- backend/README_dev.md | 12 +- backend/docs/API.md | 812 +++++++++++++++++++++++++++++ backend/handlers/board/board.go | 169 ++++++ backend/handlers/column/column.go | 167 ++++++ backend/handlers/task/task.go | 240 +++++++++ backend/routes/routes.go | 32 +- backend/services/board_service.go | 92 ++++ backend/services/column_service.go | 115 ++++ backend/services/errors.go | 3 + backend/services/task_service.go | 171 ++++++ 10 files changed, 1811 insertions(+), 2 deletions(-) create mode 100644 backend/docs/API.md create mode 100644 backend/handlers/board/board.go create mode 100644 backend/handlers/column/column.go create mode 100644 backend/handlers/task/task.go create mode 100644 backend/services/board_service.go create mode 100644 backend/services/column_service.go create mode 100644 backend/services/task_service.go diff --git a/backend/README_dev.md b/backend/README_dev.md index 94c0db6..e6d7f69 100644 --- a/backend/README_dev.md +++ b/backend/README_dev.md @@ -86,7 +86,17 @@ go build ./ ## API 接口文档 -参考Postman +详细的API接口文档请参考:[API.md](./docs/API.md) + +文档包含: +- 认证相关接口(注册、登录) +- 用户相关接口 +- 看板相关接口(CRUD) +- 列相关接口(CRUD) +- 任务相关接口(CRUD) +- 任务拖拽排序接口 + +所有接口遵循RESTful规范,支持完整的增删改查操作。 ## 开发规范 diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..614fc1f --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,812 @@ +# Progress Wall API 接口文档 + +## 基础信息 + +- **Base URL**: `http://localhost:8080/api` +- **认证方式**: Bearer Token (JWT) +- **Content-Type**: `application/json` + +## 认证 + +大部分接口需要认证,请求头中需要包含: + +``` +Authorization: Bearer +``` + +获取 token 的方式:通过 `/api/auth/login` 接口登录后获取。 + +--- + +## 认证相关接口 + +### 1. 用户注册 + +**POST** `/api/auth/register` + +**请求体**: +```json +{ + "username": "string (必填, 3-20个字符, 只能包含字母、数字、下划线)", + "email": "string (必填, 有效邮箱格式)", + "password": "string (必填, 至少6位, 包含字母和数字)", + "nickname": "string (可选)" +} +``` + +**响应** (201 Created): +```json +{ + "user": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "nickname": "测试用户", + "created_at": "2025-11-20T10:00:00Z" + } +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误 +- `409 Conflict`: 用户名或邮箱已存在 + +--- + +### 2. 用户登录 + +**POST** `/api/auth/login` + +**请求体**: +```json +{ + "username": "string (必填, 支持用户名或邮箱)", + "password": "string (必填)" +} +``` + +**响应** (200 OK): +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "nickname": "测试用户" + } +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误 +- `401 Unauthorized`: 用户名或密码错误 +- `403 Forbidden`: 账户已被禁用 + +--- + +## 用户相关接口 + +### 3. 获取当前用户信息 + +**GET** `/api/user/profile` + +**需要认证**: 是 + +**响应** (200 OK): +```json +{ + "user": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "nickname": "测试用户", + "avatar": "", + "phone": "", + "status": 1, + "created_at": "2025-11-20T10:00:00Z" + } +} +``` + +**错误响应**: +- `401 Unauthorized`: 未认证或token无效 +- `404 Not Found`: 用户不存在 + +--- + +## 看板相关接口 + +### 4. 获取用户的所有看板 + +**GET** `/api/boards` + +**需要认证**: 是 + +**响应** (200 OK): +```json +{ + "boards": [ + { + "id": 1, + "name": "项目看板", + "description": "项目描述", + "color": "#3498db", + "status": 1, + "project_id": 1, + "owner_id": 1, + "position": 0, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" + } + ] +} +``` + +--- + +### 5. 创建看板 + +**POST** `/api/boards` + +**需要认证**: 是 + +**请求体**: +```json +{ + "name": "string (必填)", + "description": "string (可选)", + "color": "string (可选, 十六进制颜色, 默认#3498db)", + "project_id": "number (必填)" +} +``` + +**响应** (201 Created): +```json +{ + "id": 1, + "name": "项目看板", + "description": "项目描述", + "color": "#3498db", + "status": 1, + "project_id": 1, + "owner_id": 1, + "position": 0, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" +} +``` + +--- + +### 6. 获取单个看板(包含嵌套的列和任务) + +**GET** `/api/boards/:boardId` + +**需要认证**: 是 + +**路径参数**: +- `boardId`: 看板ID (number) + +**响应** (200 OK): +```json +{ + "id": 1, + "name": "项目看板", + "description": "项目描述", + "color": "#3498db", + "status": 1, + "project_id": 1, + "owner_id": 1, + "position": 0, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z", + "owner": { + "id": 1, + "username": "testuser", + "email": "test@example.com" + }, + "columns": [ + { + "id": 1, + "name": "待办", + "description": "待处理的任务", + "color": "#95a5a6", + "position": 0, + "board_id": 1, + "status": 1, + "created_at": "2025-11-20T10:00:00Z", + "tasks": [ + { + "id": 1, + "title": "任务标题", + "description": "任务描述", + "priority": 2, + "status": 1, + "position": 0, + "column_id": 1, + "creator_id": 1, + "assignee_id": 2, + "project_id": 1, + "created_at": "2025-11-20T10:00:00Z", + "assignee": { + "id": 2, + "username": "assignee", + "email": "assignee@example.com" + }, + "creator": { + "id": 1, + "username": "testuser", + "email": "test@example.com" + } + } + ] + } + ] +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的看板ID +- `404 Not Found`: 看板不存在 + +--- + +### 7. 更新看板 + +**PUT** `/api/boards/:boardId` + +**需要认证**: 是 + +**路径参数**: +- `boardId`: 看板ID (number) + +**请求体** (所有字段可选): +```json +{ + "name": "string (可选)", + "description": "string (可选)", + "color": "string (可选)", + "status": "number (可选, 1=活跃, 2=归档)", + "position": "number (可选)" +} +``` + +**响应** (200 OK): +```json +{ + "message": "更新成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误 +- `404 Not Found`: 看板不存在 + +--- + +### 8. 删除看板 + +**DELETE** `/api/boards/:boardId` + +**需要认证**: 是 + +**路径参数**: +- `boardId`: 看板ID (number) + +**响应** (200 OK): +```json +{ + "message": "删除成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的看板ID +- `404 Not Found`: 看板不存在 + +--- + +## 列相关接口 + +### 9. 获取看板的所有列 + +**GET** `/api/boards/:boardId/columns` + +**需要认证**: 是 + +**路径参数**: +- `boardId`: 看板ID (number) + +**响应** (200 OK): +```json +{ + "columns": [ + { + "id": 1, + "name": "待办", + "description": "待处理的任务", + "color": "#95a5a6", + "position": 0, + "board_id": 1, + "status": 1, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" + } + ] +} +``` + +--- + +### 10. 创建列 + +**POST** `/api/boards/:boardId/columns` + +**需要认证**: 是 + +**路径参数**: +- `boardId`: 看板ID (number) + +**请求体**: +```json +{ + "name": "string (必填)", + "description": "string (可选)", + "color": "string (可选, 十六进制颜色, 默认#95a5a6)" +} +``` + +**响应** (201 Created): +```json +{ + "id": 1, + "name": "待办", + "description": "待处理的任务", + "color": "#95a5a6", + "position": 0, + "board_id": 1, + "status": 1, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" +} +``` + +**说明**: 新创建的列会自动设置 `position` 为当前看板中列的最大位置+1 + +--- + +### 11. 获取单个列 + +**GET** `/api/columns/:columnId` + +**需要认证**: 是 + +**路径参数**: +- `columnId`: 列ID (number) + +**响应** (200 OK): +```json +{ + "id": 1, + "name": "待办", + "description": "待处理的任务", + "color": "#95a5a6", + "position": 0, + "board_id": 1, + "status": 1, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z", + "tasks": [ + { + "id": 1, + "title": "任务标题", + "description": "任务描述", + "priority": 2, + "status": 1, + "position": 0, + "column_id": 1, + "created_at": "2025-11-20T10:00:00Z" + } + ] +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的列ID +- `404 Not Found`: 列不存在 + +--- + +### 12. 更新列 + +**PUT** `/api/columns/:columnId` + +**需要认证**: 是 + +**路径参数**: +- `columnId`: 列ID (number) + +**请求体** (所有字段可选): +```json +{ + "name": "string (可选)", + "description": "string (可选)", + "color": "string (可选)", + "status": "number (可选, 1=正常, 2=禁用)", + "position": "number (可选)" +} +``` + +**响应** (200 OK): +```json +{ + "message": "更新成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误 +- `404 Not Found`: 列不存在 + +--- + +### 13. 删除列 + +**DELETE** `/api/columns/:columnId` + +**需要认证**: 是 + +**路径参数**: +- `columnId`: 列ID (number) + +**响应** (200 OK): +```json +{ + "message": "删除成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的列ID +- `404 Not Found`: 列不存在 + +--- + +## 任务相关接口 + +### 14. 获取列的所有任务 + +**GET** `/api/columns/:columnId/tasks` + +**需要认证**: 是 + +**路径参数**: +- `columnId`: 列ID (number) + +**响应** (200 OK): +```json +{ + "tasks": [ + { + "id": 1, + "title": "任务标题", + "description": "任务描述", + "priority": 2, + "status": 1, + "position": 0, + "column_id": 1, + "creator_id": 1, + "assignee_id": 2, + "project_id": 1, + "due_date": "2025-12-01T00:00:00Z", + "start_date": null, + "end_date": null, + "estimated_hours": 8.0, + "actual_hours": null, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" + } + ] +} +``` + +--- + +### 15. 创建任务 + +**POST** `/api/columns/:columnId/tasks` + +**需要认证**: 是 + +**路径参数**: +- `columnId`: 列ID (number) + +**请求体**: +```json +{ + "title": "string (必填)", + "description": "string (可选)", + "priority": "number (可选, 1=低, 2=中, 3=高, 4=紧急, 默认2)", + "due_date": "string (可选, ISO 8601格式)", + "start_date": "string (可选, ISO 8601格式)", + "estimated_hours": "number (可选)", + "assignee_id": "number (可选)", + "project_id": "number (必填)" +} +``` + +**响应** (201 Created): +```json +{ + "id": 1, + "title": "任务标题", + "description": "任务描述", + "priority": 2, + "status": 1, + "position": 0, + "column_id": 1, + "creator_id": 1, + "assignee_id": 2, + "project_id": 1, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" +} +``` + +**说明**: +- 新创建的任务会自动设置 `position` 为当前列中任务的最大位置+1 +- `status` 默认为 1 (待办) +- `creator_id` 自动设置为当前登录用户ID + +--- + +### 16. 获取单个任务 + +**GET** `/api/tasks/:taskId` + +**需要认证**: 是 + +**路径参数**: +- `taskId`: 任务ID (number) + +**响应** (200 OK): +```json +{ + "id": 1, + "title": "任务标题", + "description": "任务描述", + "priority": 2, + "status": 1, + "position": 0, + "column_id": 1, + "creator_id": 1, + "assignee_id": 2, + "project_id": 1, + "due_date": "2025-12-01T00:00:00Z", + "start_date": null, + "end_date": null, + "estimated_hours": 8.0, + "actual_hours": null, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z", + "assignee": { + "id": 2, + "username": "assignee", + "email": "assignee@example.com" + }, + "creator": { + "id": 1, + "username": "testuser", + "email": "test@example.com" + }, + "column": { + "id": 1, + "name": "待办", + "board_id": 1 + } +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的任务ID +- `404 Not Found`: 任务不存在 + +--- + +### 17. 更新任务 + +**PUT** `/api/tasks/:taskId` + +**需要认证**: 是 + +**路径参数**: +- `taskId`: 任务ID (number) + +**请求体** (所有字段可选): +```json +{ + "title": "string (可选)", + "description": "string (可选)", + "priority": "number (可选, 1=低, 2=中, 3=高, 4=紧急)", + "status": "number (可选, 1=待办, 2=进行中, 3=已完成, 4=已取消)", + "due_date": "string (可选, ISO 8601格式)", + "start_date": "string (可选, ISO 8601格式)", + "end_date": "string (可选, ISO 8601格式)", + "estimated_hours": "number (可选)", + "actual_hours": "number (可选)", + "assignee_id": "number (可选, null表示取消分配)" +} +``` + +**响应** (200 OK): +```json +{ + "message": "更新成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误 +- `404 Not Found`: 任务不存在 + +--- + +### 18. 删除任务 + +**DELETE** `/api/tasks/:taskId` + +**需要认证**: 是 + +**路径参数**: +- `taskId`: 任务ID (number) + +**响应** (200 OK): +```json +{ + "message": "删除成功" +} +``` + +**错误响应**: +- `400 Bad Request`: 无效的任务ID +- `404 Not Found`: 任务不存在 + +--- + +### 19. 移动任务(拖拽排序) + +**PATCH** `/api/tasks/:taskId/move` + +**需要认证**: 是 + +**路径参数**: +- `taskId`: 任务ID (number) + +**请求体**: +```json +{ + "newColumnId": "number (必填, 目标列ID)", + "newOrder": "number (必填, 在目标列中的新位置索引, 从0开始)" +} +``` + +**响应** (200 OK): +```json +{ + "message": "移动成功" +} +``` + +**功能说明**: +- 支持跨列移动:将任务从一个列移动到另一个列 +- 支持同列内移动:在同一列内调整任务顺序 +- 自动更新相关任务的 `position` 值,保证排序正确 +- 使用数据库事务保证操作的一致性 + +**示例**: +```json +// 将任务移动到列2的第0个位置 +{ + "newColumnId": 2, + "newOrder": 0 +} + +// 在同一列内将任务移动到第3个位置 +{ + "newColumnId": 1, + "newOrder": 3 +} +``` + +**错误响应**: +- `400 Bad Request`: 请求参数错误(缺少 newColumnId 或 newOrder) +- `404 Not Found`: 任务不存在 + +--- + +## 数据模型说明 + +### Board (看板) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | number | 看板ID | +| name | string | 看板名称 | +| description | string | 看板描述 | +| color | string | 看板颜色(十六进制) | +| status | number | 看板状态(1=活跃, 2=归档) | +| project_id | number | 所属项目ID | +| owner_id | number | 所有者用户ID | +| position | number | 在看板列表中的排序位置 | +| created_at | string | 创建时间 | +| updated_at | string | 更新时间 | + +### Column (列) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | number | 列ID | +| name | string | 列名称 | +| description | string | 列描述 | +| color | string | 列颜色(十六进制) | +| position | number | 在看板中的排序位置 | +| board_id | number | 所属看板ID | +| status | number | 列状态(1=正常, 2=禁用) | +| created_at | string | 创建时间 | +| updated_at | string | 更新时间 | + +### Task (任务) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | number | 任务ID | +| title | string | 任务标题 | +| description | string | 任务描述 | +| priority | number | 优先级(1=低, 2=中, 3=高, 4=紧急) | +| status | number | 任务状态(1=待办, 2=进行中, 3=已完成, 4=已取消) | +| position | number | 在列中的排序位置 | +| column_id | number | 所属列ID | +| creator_id | number | 创建者用户ID | +| assignee_id | number | 分配给的用户ID(可选) | +| project_id | number | 所属项目ID | +| due_date | string | 截止日期(ISO 8601格式) | +| start_date | string | 开始日期(ISO 8601格式) | +| end_date | string | 结束日期(ISO 8601格式) | +| estimated_hours | number | 预估工时(小时) | +| actual_hours | number | 实际工时(小时) | +| created_at | string | 创建时间 | +| updated_at | string | 更新时间 | + +--- + +## 错误码说明 + +| HTTP状态码 | 说明 | +|-----------|------| +| 200 OK | 请求成功 | +| 201 Created | 资源创建成功 | +| 400 Bad Request | 请求参数错误 | +| 401 Unauthorized | 未认证或认证失败 | +| 403 Forbidden | 无权限访问 | +| 404 Not Found | 资源不存在 | +| 409 Conflict | 资源冲突(如用户名已存在) | +| 500 Internal Server Error | 服务器内部错误 | + +--- + +## 注意事项 + +1. **认证**: 除登录和注册接口外,所有接口都需要在请求头中携带有效的 JWT token +2. **时间格式**: 所有日期时间字段使用 ISO 8601 格式(例如: `2025-11-20T10:00:00Z`) +3. **排序**: 列和任务默认按 `position` 字段升序排列 +4. **软删除**: 删除操作使用软删除,数据不会真正从数据库中删除 +5. **嵌套结构**: `GET /api/boards/:boardId` 返回的看板对象包含完整的嵌套结构(列和任务) +6. **拖拽排序**: 使用 `PATCH /api/tasks/:taskId/move` 接口进行任务拖拽排序,系统会自动处理位置更新 + +--- + +## 更新日志 + +- **2025-11-20**: 初始版本,包含看板、列、任务的完整CRUD接口和拖拽排序功能 + diff --git a/backend/handlers/board/board.go b/backend/handlers/board/board.go new file mode 100644 index 0000000..b43fcde --- /dev/null +++ b/backend/handlers/board/board.go @@ -0,0 +1,169 @@ +package board + +import ( + "net/http" + "strconv" + + "progress-wall-backend/models" + "progress-wall-backend/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// BoardHandler 看板处理器 +type BoardHandler struct { + boardService *services.BoardService +} + +// NewBoardHandler 创建看板处理器 +func NewBoardHandler(db *gorm.DB) *BoardHandler { + return &BoardHandler{ + boardService: services.NewBoardService(db), + } +} + +// GetBoard 获取单个看板(包含嵌套的列和任务) +func (h *BoardHandler) GetBoard(c *gin.Context) { + boardID, err := strconv.ParseUint(c.Param("boardId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + board, err := h.boardService.GetBoardByID(uint(boardID)) + if err != nil { + if err == services.ErrBoardNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, board) +} + +// GetBoards 获取用户的所有看板 +func (h *BoardHandler) GetBoards(c *gin.Context) { + userID := c.GetUint("user_id") + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无法获取用户信息"}) + return + } + + boards, err := h.boardService.GetBoardsByUserID(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"boards": boards}) +} + +// CreateBoard 创建看板 +func (h *BoardHandler) CreateBoard(c *gin.Context) { + userID := c.GetUint("user_id") + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无法获取用户信息"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Color string `json:"color"` + ProjectID uint `json:"project_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + board := &models.Board{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + ProjectID: req.ProjectID, + OwnerID: userID, + Status: models.BoardStatusActive, + } + + if err := h.boardService.CreateBoard(board); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, board) +} + +// UpdateBoard 更新看板 +func (h *BoardHandler) UpdateBoard(c *gin.Context) { + boardID, err := strconv.ParseUint(c.Param("boardId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + Color *string `json:"color"` + Status *models.BoardStatus `json:"status"` + Position *int `json:"position"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Color != nil { + updates["color"] = *req.Color + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Position != nil { + updates["position"] = *req.Position + } + + if err := h.boardService.UpdateBoard(uint(boardID), updates); err != nil { + if err == services.ErrBoardNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} + +// DeleteBoard 删除看板 +func (h *BoardHandler) DeleteBoard(c *gin.Context) { + boardID, err := strconv.ParseUint(c.Param("boardId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + if err := h.boardService.DeleteBoard(uint(boardID)); err != nil { + if err == services.ErrBoardNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/backend/handlers/column/column.go b/backend/handlers/column/column.go new file mode 100644 index 0000000..7e56699 --- /dev/null +++ b/backend/handlers/column/column.go @@ -0,0 +1,167 @@ +package column + +import ( + "net/http" + "strconv" + + "progress-wall-backend/models" + "progress-wall-backend/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ColumnHandler 列处理器 +type ColumnHandler struct { + columnService *services.ColumnService +} + +// NewColumnHandler 创建列处理器 +func NewColumnHandler(db *gorm.DB) *ColumnHandler { + return &ColumnHandler{ + columnService: services.NewColumnService(db), + } +} + +// GetColumn 获取单个列 +func (h *ColumnHandler) GetColumn(c *gin.Context) { + columnID, err := strconv.ParseUint(c.Param("columnId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的列ID"}) + return + } + + column, err := h.columnService.GetColumnByID(uint(columnID)) + if err != nil { + if err == services.ErrColumnNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, column) +} + +// GetColumns 获取看板的所有列 +func (h *ColumnHandler) GetColumns(c *gin.Context) { + boardID, err := strconv.ParseUint(c.Param("boardId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + columns, err := h.columnService.GetColumnsByBoardID(uint(boardID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"columns": columns}) +} + +// CreateColumn 创建列 +func (h *ColumnHandler) CreateColumn(c *gin.Context) { + boardID, err := strconv.ParseUint(c.Param("boardId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的看板ID"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Color string `json:"color"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + column := &models.Column{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + BoardID: uint(boardID), + Status: models.ColumnStatusActive, + } + + if err := h.columnService.CreateColumn(column); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, column) +} + +// UpdateColumn 更新列 +func (h *ColumnHandler) UpdateColumn(c *gin.Context) { + columnID, err := strconv.ParseUint(c.Param("columnId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的列ID"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + Color *string `json:"color"` + Status *models.ColumnStatus `json:"status"` + Position *int `json:"position"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Color != nil { + updates["color"] = *req.Color + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Position != nil { + updates["position"] = *req.Position + } + + if err := h.columnService.UpdateColumn(uint(columnID), updates); err != nil { + if err == services.ErrColumnNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} + +// DeleteColumn 删除列 +func (h *ColumnHandler) DeleteColumn(c *gin.Context) { + columnID, err := strconv.ParseUint(c.Param("columnId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的列ID"}) + return + } + + if err := h.columnService.DeleteColumn(uint(columnID)); err != nil { + if err == services.ErrColumnNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/backend/handlers/task/task.go b/backend/handlers/task/task.go new file mode 100644 index 0000000..9beb03e --- /dev/null +++ b/backend/handlers/task/task.go @@ -0,0 +1,240 @@ +package task + +import ( + "net/http" + "strconv" + "time" + + "progress-wall-backend/models" + "progress-wall-backend/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// TaskHandler 任务处理器 +type TaskHandler struct { + taskService *services.TaskService +} + +// NewTaskHandler 创建任务处理器 +func NewTaskHandler(db *gorm.DB) *TaskHandler { + return &TaskHandler{ + taskService: services.NewTaskService(db), + } +} + +// GetTask 获取单个任务 +func (h *TaskHandler) GetTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的任务ID"}) + return + } + + task, err := h.taskService.GetTaskByID(uint(taskID)) + if err != nil { + if err == services.ErrTaskNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, task) +} + +// GetTasks 获取列的所有任务 +func (h *TaskHandler) GetTasks(c *gin.Context) { + columnID, err := strconv.ParseUint(c.Param("columnId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的列ID"}) + return + } + + tasks, err := h.taskService.GetTasksByColumnID(uint(columnID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"tasks": tasks}) +} + +// CreateTask 创建任务 +func (h *TaskHandler) CreateTask(c *gin.Context) { + userID := c.GetUint("user_id") + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无法获取用户信息"}) + return + } + + columnID, err := strconv.ParseUint(c.Param("columnId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的列ID"}) + return + } + + var req struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Priority *models.TaskPriority `json:"priority"` + DueDate *time.Time `json:"due_date"` + StartDate *time.Time `json:"start_date"` + EstimatedHours *float64 `json:"estimated_hours"` + AssigneeID *uint `json:"assignee_id"` + ProjectID uint `json:"project_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + priority := models.TaskPriorityMedium + if req.Priority != nil { + priority = *req.Priority + } + + task := &models.Task{ + Title: req.Title, + Description: req.Description, + Priority: priority, + Status: models.TaskStatusTodo, + ColumnID: uint(columnID), + CreatorID: userID, + AssigneeID: req.AssigneeID, + ProjectID: req.ProjectID, + DueDate: req.DueDate, + StartDate: req.StartDate, + EstimatedHours: req.EstimatedHours, + } + + if err := h.taskService.CreateTask(task); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, task) +} + +// UpdateTask 更新任务 +func (h *TaskHandler) UpdateTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的任务ID"}) + return + } + + var req struct { + Title *string `json:"title"` + Description *string `json:"description"` + Priority *models.TaskPriority `json:"priority"` + Status *models.TaskStatus `json:"status"` + DueDate *time.Time `json:"due_date"` + StartDate *time.Time `json:"start_date"` + EndDate *time.Time `json:"end_date"` + EstimatedHours *float64 `json:"estimated_hours"` + ActualHours *float64 `json:"actual_hours"` + AssigneeID *uint `json:"assignee_id"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + updates := make(map[string]interface{}) + if req.Title != nil { + updates["title"] = *req.Title + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Priority != nil { + updates["priority"] = *req.Priority + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.DueDate != nil { + updates["due_date"] = *req.DueDate + } + if req.StartDate != nil { + updates["start_date"] = *req.StartDate + } + if req.EndDate != nil { + updates["end_date"] = *req.EndDate + } + if req.EstimatedHours != nil { + updates["estimated_hours"] = *req.EstimatedHours + } + if req.ActualHours != nil { + updates["actual_hours"] = *req.ActualHours + } + if req.AssigneeID != nil { + updates["assignee_id"] = *req.AssigneeID + } + + if err := h.taskService.UpdateTask(uint(taskID), updates); err != nil { + if err == services.ErrTaskNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} + +// DeleteTask 删除任务 +func (h *TaskHandler) DeleteTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的任务ID"}) + return + } + + if err := h.taskService.DeleteTask(uint(taskID)); err != nil { + if err == services.ErrTaskNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +// MoveTask 移动任务(拖拽排序) +func (h *TaskHandler) MoveTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的任务ID"}) + return + } + + var req struct { + NewColumnID uint `json:"newColumnId" binding:"required"` + NewOrder int `json:"newOrder" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误:需要 newColumnId 和 newOrder"}) + return + } + + if err := h.taskService.MoveTask(uint(taskID), req.NewColumnID, req.NewOrder); err != nil { + if err == services.ErrTaskNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "移动成功"}) +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index fdd9939..a430eaf 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -1,11 +1,15 @@ package routes import ( + "strings" + "progress-wall-backend/config" "progress-wall-backend/handlers/auth" + "progress-wall-backend/handlers/board" + "progress-wall-backend/handlers/column" + "progress-wall-backend/handlers/task" "progress-wall-backend/handlers/user" "progress-wall-backend/middleware" - "strings" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -33,6 +37,9 @@ func SetupRoutes(db *gorm.DB, cfg *config.Config) *gin.Engine { loginHandler := auth.NewLoginHandler(db, cfg) registerHandler := auth.NewRegisterHandler(db, cfg) profileHandler := user.NewProfileHandler(db) + boardHandler := board.NewBoardHandler(db) + columnHandler := column.NewColumnHandler(db) + taskHandler := task.NewTaskHandler(db) // 公开路由(不需要认证) api := r.Group("/api") @@ -48,7 +55,30 @@ func SetupRoutes(db *gorm.DB, cfg *config.Config) *gin.Engine { protected := api.Group("") protected.Use(middleware.AuthMiddleware(cfg)) { + // 用户相关 protected.GET("/user/profile", profileHandler.GetProfile) + + // 看板相关 + protected.GET("/boards", boardHandler.GetBoards) + protected.POST("/boards", boardHandler.CreateBoard) + protected.GET("/boards/:boardId", boardHandler.GetBoard) + protected.PUT("/boards/:boardId", boardHandler.UpdateBoard) + protected.DELETE("/boards/:boardId", boardHandler.DeleteBoard) + + // 列相关 + protected.GET("/boards/:boardId/columns", columnHandler.GetColumns) + protected.POST("/boards/:boardId/columns", columnHandler.CreateColumn) + protected.GET("/columns/:columnId", columnHandler.GetColumn) + protected.PUT("/columns/:columnId", columnHandler.UpdateColumn) + protected.DELETE("/columns/:columnId", columnHandler.DeleteColumn) + + // 任务相关 + protected.GET("/columns/:columnId/tasks", taskHandler.GetTasks) + protected.POST("/columns/:columnId/tasks", taskHandler.CreateTask) + protected.GET("/tasks/:taskId", taskHandler.GetTask) + protected.PUT("/tasks/:taskId", taskHandler.UpdateTask) + protected.DELETE("/tasks/:taskId", taskHandler.DeleteTask) + protected.PATCH("/tasks/:taskId/move", taskHandler.MoveTask) } return r diff --git a/backend/services/board_service.go b/backend/services/board_service.go new file mode 100644 index 0000000..acde8f4 --- /dev/null +++ b/backend/services/board_service.go @@ -0,0 +1,92 @@ +package services + +import ( + "errors" + "progress-wall-backend/models" + + "gorm.io/gorm" +) + +// BoardService 看板服务 +type BoardService struct { + db *gorm.DB +} + +// NewBoardService 创建看板服务 +func NewBoardService(db *gorm.DB) *BoardService { + return &BoardService{ + db: db, + } +} + +// GetBoardByID 根据ID获取看板(包含嵌套的列和任务) +func (s *BoardService) GetBoardByID(boardID uint) (*models.Board, error) { + var board models.Board + result := s.db. + Preload("Columns", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") + }). + Preload("Columns.Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") + }). + Preload("Columns.Tasks.Assignee"). + Preload("Columns.Tasks.Creator"). + Preload("Owner"). + First(&board, boardID) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrBoardNotFound + } + return nil, errors.New("查询看板失败") + } + + return &board, nil +} + +// GetBoardsByUserID 获取用户的所有看板 +func (s *BoardService) GetBoardsByUserID(userID uint) ([]models.Board, error) { + var boards []models.Board + result := s.db. + Where("owner_id = ?", userID). + Order("position ASC"). + Find(&boards) + + if result.Error != nil { + return nil, errors.New("查询看板列表失败") + } + + return boards, nil +} + +// CreateBoard 创建看板 +func (s *BoardService) CreateBoard(board *models.Board) error { + if err := s.db.Create(board).Error; err != nil { + return errors.New("创建看板失败") + } + return nil +} + +// UpdateBoard 更新看板 +func (s *BoardService) UpdateBoard(boardID uint, updates map[string]interface{}) error { + result := s.db.Model(&models.Board{}).Where("id = ?", boardID).Updates(updates) + if result.Error != nil { + return errors.New("更新看板失败") + } + if result.RowsAffected == 0 { + return ErrBoardNotFound + } + return nil +} + +// DeleteBoard 删除看板(软删除) +func (s *BoardService) DeleteBoard(boardID uint) error { + result := s.db.Delete(&models.Board{}, boardID) + if result.Error != nil { + return errors.New("删除看板失败") + } + if result.RowsAffected == 0 { + return ErrBoardNotFound + } + return nil +} diff --git a/backend/services/column_service.go b/backend/services/column_service.go new file mode 100644 index 0000000..51cbbae --- /dev/null +++ b/backend/services/column_service.go @@ -0,0 +1,115 @@ +package services + +import ( + "errors" + "progress-wall-backend/models" + + "gorm.io/gorm" +) + +// ColumnService 列服务 +type ColumnService struct { + db *gorm.DB +} + +// NewColumnService 创建列服务 +func NewColumnService(db *gorm.DB) *ColumnService { + return &ColumnService{ + db: db, + } +} + +// GetColumnByID 根据ID获取列 +func (s *ColumnService) GetColumnByID(columnID uint) (*models.Column, error) { + var column models.Column + result := s.db. + Preload("Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") + }). + First(&column, columnID) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrColumnNotFound + } + return nil, errors.New("查询列失败") + } + + return &column, nil +} + +// GetColumnsByBoardID 获取看板的所有列 +func (s *ColumnService) GetColumnsByBoardID(boardID uint) ([]models.Column, error) { + var columns []models.Column + result := s.db. + Where("board_id = ?", boardID). + Order("position ASC"). + Find(&columns) + + if result.Error != nil { + return nil, errors.New("查询列列表失败") + } + + return columns, nil +} + +// CreateColumn 创建列 +func (s *ColumnService) CreateColumn(column *models.Column) error { + // 获取当前看板的最大position + var maxPosition int + s.db.Model(&models.Column{}). + Where("board_id = ?", column.BoardID). + Select("COALESCE(MAX(position), -1)"). + Scan(&maxPosition) + column.Position = maxPosition + 1 + + if err := s.db.Create(column).Error; err != nil { + return errors.New("创建列失败") + } + return nil +} + +// UpdateColumn 更新列 +func (s *ColumnService) UpdateColumn(columnID uint, updates map[string]interface{}) error { + result := s.db.Model(&models.Column{}).Where("id = ?", columnID).Updates(updates) + if result.Error != nil { + return errors.New("更新列失败") + } + if result.RowsAffected == 0 { + return ErrColumnNotFound + } + return nil +} + +// DeleteColumn 删除列(软删除) +func (s *ColumnService) DeleteColumn(columnID uint) error { + result := s.db.Delete(&models.Column{}, columnID) + if result.Error != nil { + return errors.New("删除列失败") + } + if result.RowsAffected == 0 { + return ErrColumnNotFound + } + return nil +} + +// ReorderColumns 重新排序列 +func (s *ColumnService) ReorderColumns(boardID uint, columnIDs []uint) error { + tx := s.db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + for i, columnID := range columnIDs { + if err := tx.Model(&models.Column{}). + Where("id = ? AND board_id = ?", columnID, boardID). + Update("position", i).Error; err != nil { + tx.Rollback() + return errors.New("重新排序列失败") + } + } + + return tx.Commit().Error +} diff --git a/backend/services/errors.go b/backend/services/errors.go index efbe94c..047e9bf 100644 --- a/backend/services/errors.go +++ b/backend/services/errors.go @@ -11,4 +11,7 @@ var ( ErrUpdateLoginTime = errors.New("更新登录时间失败") ErrUserExists = errors.New("用户名或邮箱已存在") ErrInvalidPassword = errors.New("密码格式不正确") + ErrBoardNotFound = errors.New("看板不存在") + ErrColumnNotFound = errors.New("列不存在") + ErrTaskNotFound = errors.New("任务不存在") ) diff --git a/backend/services/task_service.go b/backend/services/task_service.go new file mode 100644 index 0000000..f9f19cb --- /dev/null +++ b/backend/services/task_service.go @@ -0,0 +1,171 @@ +package services + +import ( + "errors" + "progress-wall-backend/models" + + "gorm.io/gorm" +) + +// TaskService 任务服务 +type TaskService struct { + db *gorm.DB +} + +// NewTaskService 创建任务服务 +func NewTaskService(db *gorm.DB) *TaskService { + return &TaskService{ + db: db, + } +} + +// GetTaskByID 根据ID获取任务 +func (s *TaskService) GetTaskByID(taskID uint) (*models.Task, error) { + var task models.Task + result := s.db. + Preload("Assignee"). + Preload("Creator"). + Preload("Column"). + Preload("Labels"). + First(&task, taskID) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrTaskNotFound + } + return nil, errors.New("查询任务失败") + } + + return &task, nil +} + +// GetTasksByColumnID 获取列的所有任务 +func (s *TaskService) GetTasksByColumnID(columnID uint) ([]models.Task, error) { + var tasks []models.Task + result := s.db. + Where("column_id = ?", columnID). + Order("position ASC"). + Find(&tasks) + + if result.Error != nil { + return nil, errors.New("查询任务列表失败") + } + + return tasks, nil +} + +// CreateTask 创建任务 +func (s *TaskService) CreateTask(task *models.Task) error { + // 获取当前列的最大position + var maxPosition int + s.db.Model(&models.Task{}). + Where("column_id = ?", task.ColumnID). + Select("COALESCE(MAX(position), -1)"). + Scan(&maxPosition) + task.Position = maxPosition + 1 + + if err := s.db.Create(task).Error; err != nil { + return errors.New("创建任务失败") + } + return nil +} + +// UpdateTask 更新任务 +func (s *TaskService) UpdateTask(taskID uint, updates map[string]interface{}) error { + result := s.db.Model(&models.Task{}).Where("id = ?", taskID).Updates(updates) + if result.Error != nil { + return errors.New("更新任务失败") + } + if result.RowsAffected == 0 { + return ErrTaskNotFound + } + return nil +} + +// DeleteTask 删除任务(软删除) +func (s *TaskService) DeleteTask(taskID uint) error { + result := s.db.Delete(&models.Task{}, taskID) + if result.Error != nil { + return errors.New("删除任务失败") + } + if result.RowsAffected == 0 { + return ErrTaskNotFound + } + return nil +} + +// MoveTask 移动任务到新列和新位置 +func (s *TaskService) MoveTask(taskID uint, newColumnID uint, newOrder int) error { + tx := s.db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 获取任务 + var task models.Task + if err := tx.First(&task, taskID).Error; err != nil { + tx.Rollback() + return ErrTaskNotFound + } + + oldColumnID := task.ColumnID + + // 如果移动到不同列,需要更新两个列中的任务位置 + if oldColumnID != newColumnID { + // 从旧列中移除:将旧列中位置大于当前任务位置的所有任务位置减1 + if err := tx.Model(&models.Task{}). + Where("column_id = ? AND position > ?", oldColumnID, task.Position). + Update("position", gorm.Expr("position - 1")).Error; err != nil { + tx.Rollback() + return errors.New("更新旧列任务位置失败") + } + + // 在新列中插入:将新列中位置大于等于newOrder的所有任务位置加1 + if err := tx.Model(&models.Task{}). + Where("column_id = ? AND position >= ?", newColumnID, newOrder). + Update("position", gorm.Expr("position + 1")).Error; err != nil { + tx.Rollback() + return errors.New("更新新列任务位置失败") + } + + // 更新任务的列ID和位置 + if err := tx.Model(&task). + Updates(map[string]interface{}{ + "column_id": newColumnID, + "position": newOrder, + }).Error; err != nil { + tx.Rollback() + return errors.New("更新任务位置失败") + } + } else { + // 同一列内移动 + oldPosition := task.Position + if oldPosition < newOrder { + // 向后移动:将位置在 (oldPosition, newOrder] 之间的任务位置减1 + if err := tx.Model(&models.Task{}). + Where("column_id = ? AND position > ? AND position <= ?", newColumnID, oldPosition, newOrder). + Update("position", gorm.Expr("position - 1")).Error; err != nil { + tx.Rollback() + return errors.New("更新任务位置失败") + } + } else if oldPosition > newOrder { + // 向前移动:将位置在 [newOrder, oldPosition) 之间的任务位置加1 + if err := tx.Model(&models.Task{}). + Where("column_id = ? AND position >= ? AND position < ?", newColumnID, newOrder, oldPosition). + Update("position", gorm.Expr("position + 1")).Error; err != nil { + tx.Rollback() + return errors.New("更新任务位置失败") + } + } + + // 更新任务位置 + if err := tx.Model(&task).Update("position", newOrder).Error; err != nil { + tx.Rollback() + return errors.New("更新任务位置失败") + } + } + + return tx.Commit().Error +}