# Plane Web API 完整使用文档 > 本文档覆盖 Plane v1.3.1 内部 Web API 的**所有**主要端点,包括认证、工作空间、项目、工作项、迭代、模块、状态、标签、成员、页面、收件箱、资源上传等,并为每个接口提供 Go 语言代码示例。 --- ## 目录 1. [认证与客户端基础](#1-认证与客户端基础) 2. [工作空间 (Workspaces)](#2-工作空间-workspaces) 3. [项目 (Projects)](#3-项目-projects) 4. [工作项 (Issues)](#4-工作项-issues) 5. [工作项评论 (Comments)](#5-工作项评论-comments) 6. [工作项链接 (Links)](#6-工作项链接-links) 7. [工作项关系 (Relations)](#7-工作项关系-relations) 8. [迭代 (Cycles)](#8-迭代-cycles) 9. [模块 (Modules)](#9-模块-modules) 10. [状态 (States)](#10-状态-states) 11. [标签 (Labels)](#11-标签-labels) 12. [成员 (Members)](#12-成员-members) 13. [收件箱/Intake](#13-收件箱intake) 14. [资源上传 (Assets v2)](#14-资源上传-assets-v2) 15. [页面 (Pages)](#15-页面-pages) 16. [便签 (Stickies)](#16-便签-stickies) 17. [附录](#17-附录) --- ## 1. 认证与客户端基础 ### 1.1 认证机制 | 项目 | 值 | |------|-----| | 认证方式 | Django SessionAuthentication (Cookie) | | Session Cookie 名 | `session-id` | | Session 有效期 | 7 天 (604800秒) | | CSRF Cookie 名 | `csrftoken` | | CSRF Header | `X-CSRFToken` | | 写操作要求 | Session Cookie + CSRF Token | | 读操作要求 | Session Cookie | ### 1.2 登录流程 **步骤 1**: 获取 CSRF Token ``` GET /auth/get-csrf-token/ 响应: {"csrf_token": "abc123..."} Set-Cookie: csrftoken=abc123... ``` **步骤 2**: 邮箱密码登录 ``` POST /auth/sign-in/ Content-Type: application/x-www-form-urlencoded Cookie: csrftoken=abc123... X-CSRFToken: abc123... Body: email=user@example.com&password=secret 响应: 302 (成功) Set-Cookie: session-id=xyz789... ``` ### 1.3 Go 客户端基础代码 ```go package plane import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "os" "path/filepath" "strings" ) // Client Plane Web API 客户端 type Client struct { BaseURL string httpClient *http.Client csrfToken string } // NewClient 创建客户端实例 func NewClient(baseURL string) (*Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ Jar: jar, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, }, }, nil } // request 执行 HTTP 请求 func (c *Client) request(method, path string, body io.Reader, contentType string) (*http.Response, error) { req, err := http.NewRequest(method, c.BaseURL+path, body) if err != nil { return nil, err } if contentType != "" { req.Header.Set("Content-Type", contentType) } if method != http.MethodGet && c.csrfToken != "" { req.Header.Set("X-CSRFToken", c.csrfToken) } req.Header.Set("Referer", c.BaseURL) return c.httpClient.Do(req) } // jsonRequest 执行 JSON 请求并返回响应 func (c *Client) jsonRequest(method, path string, payload interface{}) ([]byte, int, error) { var body io.Reader if payload != nil { data, err := json.Marshal(payload) if err != nil { return nil, 0, err } body = bytes.NewReader(data) } resp, err := c.request(method, path, body, "application/json") if err != nil { return nil, 0, err } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) return respBody, resp.StatusCode, nil } // Login 登录获取 Session func (c *Client) Login(email, password string) error { // 获取 CSRF Token resp, err := c.request(http.MethodGet, "/auth/get-csrf-token/", nil, "") if err != nil { return fmt.Errorf("获取 CSRF 失败: %w", err) } defer resp.Body.Close() var csrf struct { CSRFToken string `json:"csrf_token"` } json.NewDecoder(resp.Body).Decode(&csrf) c.csrfToken = csrf.CSRFToken // 登录 form := url.Values{"email": {email}, "password": {password}} loginResp, err := c.request(http.MethodPost, "/auth/sign-in/", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") if err != nil { return fmt.Errorf("登录失败: %w", err) } defer loginResp.Body.Close() if loginResp.StatusCode != http.StatusFound { return fmt.Errorf("登录失败, 状态码: %d", loginResp.StatusCode) } return nil } ``` ### 1.4 登录示例 ```go func main() { client, _ := NewClient("http://localhost:8000") err := client.Login("admin@example.com", "password123") if err != nil { panic(err) } fmt.Println("登录成功") } ``` --- ## 2. 工作空间 (Workspaces) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/` | 列出当前用户的工作空间 | | POST | `/api/workspaces/` | 创建工作空间 | | GET | `/api/workspaces/{slug}/` | 获取工作空间详情 | | PATCH | `/api/workspaces/{slug}/` | 更新工作空间 | | DELETE | `/api/workspaces/{slug}/` | 删除工作空间 | ### 创建工作空间参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 工作空间名称 | | `slug` | string | 是 | URL 标识符(全局唯一) | | `organization_size` | string | 否 | 组织规模 | ### Go 示例 ```go // ListWorkspaces 列出工作空间 func (c *Client) ListWorkspaces() ([]byte, error) { body, status, err := c.jsonRequest(http.MethodGet, "/api/workspaces/", nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, err: %v", status, err) } return body, nil } // CreateWorkspace 创建工作空间 func (c *Client) CreateWorkspace(name, slug string) ([]byte, error) { payload := map[string]interface{}{ "name": name, "slug": slug, } body, status, err := c.jsonRequest(http.MethodPost, "/api/workspaces/", payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // GetWorkspace 获取工作空间详情 func (c *Client) GetWorkspace(slug string) ([]byte, error) { body, status, err := c.jsonRequest(http.MethodGet, fmt.Sprintf("/api/workspaces/%s/", slug), nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateWorkspace 更新工作空间 func (c *Client) UpdateWorkspace(slug string, data map[string]interface{}) ([]byte, error) { body, status, err := c.jsonRequest(http.MethodPatch, fmt.Sprintf("/api/workspaces/%s/", slug), data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } ``` --- ## 3. 项目 (Projects) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/` | 列出项目 | | POST | `/api/workspaces/{slug}/projects/` | 创建项目 | | GET | `/api/workspaces/{slug}/projects/{id}/` | 获取项目详情 | | PATCH | `/api/workspaces/{slug}/projects/{id}/` | 更新项目 | | DELETE | `/api/workspaces/{slug}/projects/{id}/` | 删除项目 | | POST | `/api/workspaces/{slug}/projects/{id}/archive/` | 归档项目 | | DELETE | `/api/workspaces/{slug}/projects/{id}/archive/` | 取消归档 | ### 创建项目参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 项目名称(最大 255 字符) | | `identifier` | string | 是 | 项目标识符(最大 12 字符,自动转大写) | | `description` | string | 否 | 描述 | | `network` | integer | 否 | `0`=私密, `2`=公开(默认 2) | | `project_lead` | uuid | 否 | 负责人 ID | | `default_assignee` | uuid | 否 | 默认指派人 | | `module_view` | boolean | 否 | 启用模块(默认 false) | | `cycle_view` | boolean | 否 | 启用迭代(默认 false) | | `page_view` | boolean | 否 | 启用页面(默认 true) | | `intake_view` | boolean | 否 | 启用收件箱(默认 false) | | `timezone` | string | 否 | 时区(默认 UTC) | ### Go 示例 ```go // ListProjects 列出项目 func (c *Client) ListProjects(slug string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/", slug) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateProject 创建项目 func (c *Client) CreateProject(slug string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/", slug) body, status, err := c.jsonRequest(http.MethodPost, path, data) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // GetProject 获取项目详情 func (c *Client) GetProject(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateProject 更新项目 func (c *Client) UpdateProject(slug, projectID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/", slug, projectID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteProject 删除项目 func (c *Client) DeleteProject(slug, projectID string) error { _, status, err := c.jsonRequest(http.MethodDelete, fmt.Sprintf("/api/workspaces/%s/projects/%s/", slug, projectID), nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // ArchiveProject 归档项目 func (c *Client) ArchiveProject(slug, projectID string) error { _, status, err := c.jsonRequest(http.MethodPost, fmt.Sprintf("/api/workspaces/%s/projects/%s/archive/", slug, projectID), map[string]interface{}{}) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 4. 工作项 (Issues) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/issues/` | 列出工作项 | | POST | `/api/workspaces/{slug}/projects/{pid}/issues/` | 创建工作项 | | GET | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/` | 获取详情 | | PATCH | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/` | 更新工作项 | | DELETE | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/` | 删除工作项 | | GET | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/sub-issues/` | 列出子工作项 | | POST | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/archive/` | 归档 | | DELETE | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/archive/` | 取消归档 | | GET | `/api/workspaces/{slug}/projects/{pid}/issues/{id}/history/` | 活动历史 | ### 创建工作项参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 名称(最大 255 字符) | | `description_html` | string | 否 | HTML 描述 | | `priority` | string | 否 | `urgent`/`high`/`medium`/`low`/`none`(默认 `none`) | | `state_id` | uuid | 否 | 状态 ID | | `parent_id` | uuid | 否 | 父工作项 ID | | `assignee_ids` | array[uuid] | 否 | 指派人 ID 列表 | | `label_ids` | array[uuid] | 否 | 标签 ID 列表 | | `start_date` | date | 否 | 开始日期 (YYYY-MM-DD) | | `target_date` | date | 否 | 目标日期 (YYYY-MM-DD) | | `estimate_point` | uuid | 否 | 估算点 ID | ### Go 示例 ```go // ListIssues 列出工作项 func (c *Client) ListIssues(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateIssue 创建工作项 func (c *Client) CreateIssue(slug, projectID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/", slug, projectID) body, status, err := c.jsonRequest(http.MethodPost, path, data) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // GetIssue 获取工作项详情 func (c *Client) GetIssue(slug, projectID, issueID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/", slug, projectID, issueID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateIssue 更新工作项 func (c *Client) UpdateIssue(slug, projectID, issueID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/", slug, projectID, issueID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteIssue 删除工作项 func (c *Client) DeleteIssue(slug, projectID, issueID string) error { _, status, err := c.jsonRequest(http.MethodDelete, fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/", slug, projectID, issueID), nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // ArchiveIssue 归档工作项 func (c *Client) ArchiveIssue(slug, projectID, issueID string) error { _, status, err := c.jsonRequest(http.MethodPost, fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/archive/", slug, projectID, issueID), map[string]interface{}{}) if err != nil || status != 200 { return fmt.Errorf("status: %d", status) } return nil } // GetIssueActivity 获取工作项活动历史 func (c *Client) GetIssueActivity(slug, projectID, issueID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/history/", slug, projectID, issueID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // GetSubIssues 获取子工作项 func (c *Client) GetSubIssues(slug, projectID, issueID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/sub-issues/", slug, projectID, issueID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } ``` --- ## 5. 工作项评论 (Comments) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `.../issues/{issue_id}/comments/` | 列出评论 | | POST | `.../issues/{issue_id}/comments/` | 创建评论 | | GET | `.../issues/{issue_id}/comments/{id}/` | 获取评论 | | PATCH | `.../issues/{issue_id}/comments/{id}/` | 更新评论 | | DELETE | `.../issues/{issue_id}/comments/{id}/` | 删除评论 | ### 创建评论参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `comment_html` | string | 是 | 评论 HTML 内容 | | `comment_json` | object | 否 | 评论 JSON 结构 | | `access` | string | 否 | `INTERNAL`(默认) / `EXTERNAL` | ### Go 示例 ```go // ListComments 列出评论 func (c *Client) ListComments(slug, projectID, issueID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/comments/", slug, projectID, issueID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateComment 创建评论 func (c *Client) CreateComment(slug, projectID, issueID, commentHTML string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/comments/", slug, projectID, issueID) payload := map[string]interface{}{ "comment_html": commentHTML, } body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // UpdateComment 更新评论 func (c *Client) UpdateComment(slug, projectID, issueID, commentID, commentHTML string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/comments/%s/", slug, projectID, issueID, commentID) payload := map[string]interface{}{"comment_html": commentHTML} body, status, err := c.jsonRequest(http.MethodPatch, path, payload) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteComment 删除评论 func (c *Client) DeleteComment(slug, projectID, issueID, commentID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/comments/%s/", slug, projectID, issueID, commentID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 6. 工作项链接 (Links) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `.../issues/{issue_id}/issue-links/` | 列出链接 | | POST | `.../issues/{issue_id}/issue-links/` | 创建链接 | | PATCH | `.../issues/{issue_id}/issue-links/{id}/` | 更新链接 | | DELETE | `.../issues/{issue_id}/issue-links/{id}/` | 删除链接 | ### 创建链接参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `url` | string | 是 | 链接 URL (http/https) | | `title` | string | 否 | 链接标题 | ### Go 示例 ```go // CreateIssueLink 创建链接 func (c *Client) CreateIssueLink(slug, projectID, issueID, linkURL, title string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/issue-links/", slug, projectID, issueID) payload := map[string]interface{}{"url": linkURL, "title": title} body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteIssueLink 删除链接 func (c *Client) DeleteIssueLink(slug, projectID, issueID, linkID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/issue-links/%s/", slug, projectID, issueID, linkID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 7. 工作项关系 (Relations) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `.../issues/{issue_id}/issue-relation/` | 列出关系 | | POST | `.../issues/{issue_id}/issue-relation/` | 创建关系 | | POST | `.../issues/{issue_id}/remove-relation/` | 移除关系 | ### 创建关系参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `relation_type` | string | 是 | `blocking`/`blocked_by`/`duplicate`/`relates_to`/`start_before`/`start_after`/`finish_before`/`finish_after` | | `related_list` | array[uuid] | 是 | 关联工作项 ID 列表 | ### Go 示例 ```go // CreateIssueRelation 创建工作项关系 func (c *Client) CreateIssueRelation(slug, projectID, issueID, relationType string, relatedIDs []string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/issue-relation/", slug, projectID, issueID) payload := map[string]interface{}{ "relation_type": relationType, "related_list": relatedIDs, } body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // RemoveIssueRelation 移除关系 func (c *Client) RemoveIssueRelation(slug, projectID, issueID, relationType, relatedID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issues/%s/remove-relation/", slug, projectID, issueID) payload := map[string]interface{}{ "relation_type": relationType, "related_list": []string{relatedID}, } _, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 8. 迭代 (Cycles) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/cycles/` | 列出迭代 | | POST | `/api/workspaces/{slug}/projects/{pid}/cycles/` | 创建迭代 | | GET | `/api/workspaces/{slug}/projects/{pid}/cycles/{id}/` | 获取迭代详情 | | PATCH | `/api/workspaces/{slug}/projects/{pid}/cycles/{id}/` | 更新迭代 | | DELETE | `/api/workspaces/{slug}/projects/{pid}/cycles/{id}/` | 删除迭代 | | GET | `.../cycles/{id}/cycle-issues/` | 列出迭代中的工作项 | | POST | `.../cycles/{id}/cycle-issues/` | 添加工作项到迭代 | | DELETE | `.../cycles/{id}/cycle-issues/{issue_id}/` | 从迭代移除工作项 | | POST | `.../cycles/{id}/transfer-issues/` | 转移未完成工作项到另一迭代 | | POST | `.../cycles/{id}/archive/` | 归档迭代 | | GET | `.../archived-cycles/` | 列出已归档迭代 | | DELETE | `.../archived-cycles/{id}/` | 取消归档 | | GET | `.../cycles/{id}/progress/` | 获取迭代进度 | | GET | `.../cycles/{id}/analytics/` | 获取迭代分析 | ### 创建迭代参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 迭代名称 | | `description` | string | 否 | 描述 | | `start_date` | datetime | 否 | 开始时间(不可晚于 end_date) | | `end_date` | datetime | 否 | 结束时间 | | `owned_by` | uuid | 否 | 负责人 ID(默认当前用户) | ### 添加工作项到迭代参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `issues` | array[uuid] | 是 | 工作项 ID 列表 | ### 转移工作项参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `new_cycle_id` | uuid | 是 | 目标迭代 ID | ### Go 示例 ```go // ListCycles 列出迭代 func (c *Client) ListCycles(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateCycle 创建迭代 func (c *Client) CreateCycle(slug, projectID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/", slug, projectID) body, status, err := c.jsonRequest(http.MethodPost, path, data) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // GetCycle 获取迭代详情 func (c *Client) GetCycle(slug, projectID, cycleID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/", slug, projectID, cycleID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateCycle 更新迭代 func (c *Client) UpdateCycle(slug, projectID, cycleID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/", slug, projectID, cycleID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteCycle 删除迭代 func (c *Client) DeleteCycle(slug, projectID, cycleID string) error { _, status, err := c.jsonRequest(http.MethodDelete, fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/", slug, projectID, cycleID), nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // AddIssuesToCycle 添加工作项到迭代 func (c *Client) AddIssuesToCycle(slug, projectID, cycleID string, issueIDs []string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/cycle-issues/", slug, projectID, cycleID) payload := map[string]interface{}{"issues": issueIDs} body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // RemoveIssueFromCycle 从迭代移除工作项 func (c *Client) RemoveIssueFromCycle(slug, projectID, cycleID, issueID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/cycle-issues/%s/", slug, projectID, cycleID, issueID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // TransferCycleIssues 转移迭代工作项 func (c *Client) TransferCycleIssues(slug, projectID, cycleID, newCycleID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/transfer-issues/", slug, projectID, cycleID) payload := map[string]interface{}{"new_cycle_id": newCycleID} body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // ArchiveCycle 归档迭代 func (c *Client) ArchiveCycle(slug, projectID, cycleID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/archive/", slug, projectID, cycleID) _, status, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || status != 200 { return fmt.Errorf("status: %d", status) } return nil } // GetCycleProgress 获取迭代进度 func (c *Client) GetCycleProgress(slug, projectID, cycleID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/cycles/%s/progress/", slug, projectID, cycleID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } ``` --- ## 9. 模块 (Modules) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/modules/` | 列出模块 | | POST | `/api/workspaces/{slug}/projects/{pid}/modules/` | 创建模块 | | GET | `.../modules/{id}/` | 获取模块详情 | | PATCH | `.../modules/{id}/` | 更新模块 | | DELETE | `.../modules/{id}/` | 删除模块 | | GET | `.../modules/{id}/issues/` | 列出模块中的工作项 | | POST | `.../modules/{id}/issues/` | 添加工作项到模块 | | DELETE | `.../modules/{id}/issues/{issue_id}/` | 从模块移除工作项 | | GET | `.../modules/{id}/module-links/` | 列出模块链接 | | POST | `.../modules/{id}/module-links/` | 创建模块链接 | | PATCH | `.../modules/{id}/module-links/{link_id}/` | 更新模块链接 | | DELETE | `.../modules/{id}/module-links/{link_id}/` | 删除模块链接 | | POST | `.../modules/{id}/archive/` | 归档模块 | | GET | `.../archived-modules/` | 列出已归档模块 | | DELETE | `.../archived-modules/{id}/` | 取消归档 | ### 创建模块参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 模块名称(同项目内不可重复) | | `description` | string | 否 | 描述 | | `start_date` | date | 否 | 开始日期 (YYYY-MM-DD) | | `target_date` | date | 否 | 目标日期 | | `status` | string | 否 | `backlog`/`planned`(默认)/`in-progress`/`paused`/`completed`/`cancelled` | | `lead` | uuid | 否 | 负责人 ID | | `members` | array[uuid] | 否 | 成员 ID 列表 | ### 添加工作项到模块参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `issues` | array[uuid] | 是 | 工作项 ID 列表 | ### Go 示例 ```go // ListModules 列出模块 func (c *Client) ListModules(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateModule 创建模块 func (c *Client) CreateModule(slug, projectID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/", slug, projectID) body, status, err := c.jsonRequest(http.MethodPost, path, data) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // GetModule 获取模块详情 func (c *Client) GetModule(slug, projectID, moduleID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/", slug, projectID, moduleID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateModule 更新模块 func (c *Client) UpdateModule(slug, projectID, moduleID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/", slug, projectID, moduleID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteModule 删除模块 func (c *Client) DeleteModule(slug, projectID, moduleID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/", slug, projectID, moduleID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // AddIssuesToModule 添加工作项到模块 func (c *Client) AddIssuesToModule(slug, projectID, moduleID string, issueIDs []string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/issues/", slug, projectID, moduleID) payload := map[string]interface{}{"issues": issueIDs} body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // RemoveIssueFromModule 从模块移除工作项 func (c *Client) RemoveIssueFromModule(slug, projectID, moduleID, issueID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/issues/%s/", slug, projectID, moduleID, issueID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 200 { return fmt.Errorf("status: %d", status) } return nil } // ArchiveModule 归档模块 func (c *Client) ArchiveModule(slug, projectID, moduleID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/modules/%s/archive/", slug, projectID, moduleID) _, status, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || status != 200 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 10. 状态 (States) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/states/` | 列出状态 | | POST | `/api/workspaces/{slug}/projects/{pid}/states/` | 创建状态 | | GET | `.../states/{id}/` | 获取状态详情 | | PATCH | `.../states/{id}/` | 更新状态 | | DELETE | `.../states/{id}/` | 删除状态 | | POST | `.../states/{id}/mark-default/` | 设为默认状态 | ### 创建状态参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 状态名称(项目内唯一) | | `color` | string | 是 | 颜色值(如 `#46A758`) | | `group` | string | 是 | 分组:`backlog`/`unstarted`/`started`/`completed`/`cancelled` | | `description` | string | 否 | 描述 | | `sequence` | float | 否 | 排序序号 | | `default` | boolean | 否 | 是否默认(设 true 会重置其他状态) | ### Go 示例 ```go // ListStates 列出状态 func (c *Client) ListStates(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/states/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateState 创建状态 func (c *Client) CreateState(slug, projectID, name, color, group string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/states/", slug, projectID) payload := map[string]interface{}{ "name": name, "color": color, "group": group, } body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // UpdateState 更新状态 func (c *Client) UpdateState(slug, projectID, stateID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/states/%s/", slug, projectID, stateID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteState 删除状态 func (c *Client) DeleteState(slug, projectID, stateID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/states/%s/", slug, projectID, stateID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // MarkStateDefault 设为默认状态 func (c *Client) MarkStateDefault(slug, projectID, stateID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/states/%s/mark-default/", slug, projectID, stateID) _, status, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || status != 200 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 11. 标签 (Labels) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/issue-labels/` | 列出标签 | | POST | `/api/workspaces/{slug}/projects/{pid}/issue-labels/` | 创建标签 | | GET | `.../issue-labels/{id}/` | 获取标签详情 | | PATCH | `.../issue-labels/{id}/` | 更新标签 | | DELETE | `.../issue-labels/{id}/` | 删除标签 | ### 创建标签参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 标签名称(项目内唯一) | | `color` | string | 否 | 颜色值 | | `description` | string | 否 | 描述 | | `parent` | uuid | 否 | 父标签 ID | ### Go 示例 ```go // ListLabels 列出标签 func (c *Client) ListLabels(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issue-labels/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // CreateLabel 创建标签 func (c *Client) CreateLabel(slug, projectID, name, color string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issue-labels/", slug, projectID) payload := map[string]interface{}{"name": name, "color": color} body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // UpdateLabel 更新标签 func (c *Client) UpdateLabel(slug, projectID, labelID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issue-labels/%s/", slug, projectID, labelID) body, status, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // DeleteLabel 删除标签 func (c *Client) DeleteLabel(slug, projectID, labelID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/issue-labels/%s/", slug, projectID, labelID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } ``` --- ## 12. 成员 (Members) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/members/` | 列出工作空间成员 | | GET | `/api/workspaces/{slug}/members/{id}/` | 获取成员详情 | | PATCH | `/api/workspaces/{slug}/members/{id}/` | 更新成员角色 | | DELETE | `/api/workspaces/{slug}/members/{id}/` | 移除成员 | | GET | `/api/workspaces/{slug}/projects/{pid}/members/` | 列出项目成员 | | POST | `/api/workspaces/{slug}/projects/{pid}/members/` | 添加项目成员 | | GET | `.../projects/{pid}/members/{id}/` | 获取项目成员详情 | | PATCH | `.../projects/{pid}/members/{id}/` | 更新项目成员角色 | | DELETE | `.../projects/{pid}/members/{id}/` | 移除项目成员 | | GET | `/api/users/me/` | 获取当前用户信息 | | PATCH | `/api/users/me/` | 更新当前用户信息 | ### 角色值 | 值 | 名称 | |----|------| | 20 | Admin | | 15 | Member | | 5 | Guest | ### 添加项目成员参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `members` | array | 是 | 成员列表 | | `members[].member_id` | uuid | 是 | 用户 ID(须为工作空间成员) | | `members[].role` | integer | 是 | 角色:20/15/5 | ### Go 示例 ```go // ListWorkspaceMembers 列出工作空间成员 func (c *Client) ListWorkspaceMembers(slug string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/members/", slug) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // ListProjectMembers 列出项目成员 func (c *Client) ListProjectMembers(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/members/", slug, projectID) body, status, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // AddProjectMember 添加项目成员 func (c *Client) AddProjectMember(slug, projectID, memberID string, role int) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/members/", slug, projectID) payload := map[string]interface{}{ "members": []map[string]interface{}{ {"member_id": memberID, "role": role}, }, } body, status, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || status != 201 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // UpdateProjectMemberRole 更新项目成员角色 func (c *Client) UpdateProjectMemberRole(slug, projectID, memberID string, role int) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/members/%s/", slug, projectID, memberID) payload := map[string]interface{}{"role": role} body, status, err := c.jsonRequest(http.MethodPatch, path, payload) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } // RemoveProjectMember 移除项目成员 func (c *Client) RemoveProjectMember(slug, projectID, memberID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/members/%s/", slug, projectID, memberID) _, status, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || status != 204 { return fmt.Errorf("status: %d", status) } return nil } // GetCurrentUser 获取当前用户信息 func (c *Client) GetCurrentUser() ([]byte, error) { body, status, err := c.jsonRequest(http.MethodGet, "/api/users/me/", nil) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d", status) } return body, nil } // UpdateCurrentUser 更新当前用户信息 func (c *Client) UpdateCurrentUser(data map[string]interface{}) ([]byte, error) { body, status, err := c.jsonRequest(http.MethodPatch, "/api/users/me/", data) if err != nil || status != 200 { return nil, fmt.Errorf("status: %d, body: %s", status, string(body)) } return body, nil } ``` --- ## 13. 收件箱/Intake ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/intake-issues/` | 列出收件箱工作项 | | POST | `/api/workspaces/{slug}/projects/{pid}/intake-issues/` | 创建收件箱工作项 | | GET | `.../intake-issues/{issue_id}/` | 获取收件箱工作项详情 | | PATCH | `.../intake-issues/{issue_id}/` | 更新收件箱工作项 | | DELETE | `.../intake-issues/{issue_id}/` | 删除收件箱工作项 | ### 创建收件箱工作项参数 请求体为嵌套 JSON,外层包含 `issue` 对象: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `issue.name` | string | 是 | 工作项名称 | | `issue.description_html` | string | 否 | HTML 描述(支持内联图片) | | `issue.priority` | string | 否 | `urgent`/`high`/`medium`/`low`/`none`(默认 `none`) | | `issue.assignee_ids` | array[uuid] | 否 | 指派人列表 | | `issue.label_ids` | array[uuid] | 否 | 标签列表 | | `issue.start_date` | date | 否 | 开始日期 | | `issue.target_date` | date | 否 | 目标日期 | | `issue.parent_id` | uuid | 否 | 父工作项 ID | > 注意:`state_id` 会被强制设为 Triage 状态,无需传入。 ### 更新收件箱工作项参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `status` | integer | 否 | `-2`=待处理, `-1`=拒绝, `0`=暂缓, `1`=接受, `2`=标记重复 | | `snoozed_till` | datetime | 否 | 暂缓截止时间 | | `duplicate_to` | uuid | 否 | 重复目标工作项 ID | | `issue` | object | 否 | 更新工作项内容(字段同创建) | ### 列表查询参数 | 参数 | 类型 | 说明 | |------|------|------| | `status` | string | 按状态过滤(逗号分隔,如 `-2,-1`) | | `order_by` | string | 排序(默认 `-issue__created_at`) | ### Go 示例 ```go // ListIntakeIssues 列出收件箱工作项 func (c *Client) ListIntakeIssues(slug, projectID string, status string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/intake-issues/", slug, projectID) if status != "" { path += "?status=" + status } body, code, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d", code) } return body, nil } // CreateIntakeIssue 创建收件箱工作项 func (c *Client) CreateIntakeIssue(slug, projectID string, issue map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/intake-issues/", slug, projectID) payload := map[string]interface{}{"issue": issue} body, code, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // GetIntakeIssue 获取收件箱工作项详情 func (c *Client) GetIntakeIssue(slug, projectID, issueID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/intake-issues/%s/", slug, projectID, issueID) body, code, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d", code) } return body, nil } // UpdateIntakeIssue 更新收件箱工作项(修改状态或内容) func (c *Client) UpdateIntakeIssue(slug, projectID, issueID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/intake-issues/%s/", slug, projectID, issueID) body, code, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // AcceptIntakeIssue 接受收件箱工作项 func (c *Client) AcceptIntakeIssue(slug, projectID, issueID string) ([]byte, error) { return c.UpdateIntakeIssue(slug, projectID, issueID, map[string]interface{}{ "status": 1, }) } // RejectIntakeIssue 拒绝收件箱工作项 func (c *Client) RejectIntakeIssue(slug, projectID, issueID string) ([]byte, error) { return c.UpdateIntakeIssue(slug, projectID, issueID, map[string]interface{}{ "status": -1, }) } // DeleteIntakeIssue 删除收件箱工作项 func (c *Client) DeleteIntakeIssue(slug, projectID, issueID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/intake-issues/%s/", slug, projectID, issueID) _, code, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } ``` --- ## 14. 资源上传 (Assets v2) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/assets/v2/workspaces/{slug}/projects/{pid}/` | 创建项目资源(获取预签名 URL) | | PATCH | `/api/assets/v2/workspaces/{slug}/projects/{pid}/{asset_id}/` | 确认上传完成 | | GET | `/api/assets/v2/workspaces/{slug}/projects/{pid}/{asset_id}/` | 下载资源(302 重定向) | | DELETE | `/api/assets/v2/workspaces/{slug}/projects/{pid}/{asset_id}/` | 删除资源 | | POST | `/api/assets/v2/workspaces/{slug}/projects/{pid}/{entity_id}/bulk/` | 批量绑定资源到实体 | | POST | `/api/assets/v2/workspaces/{slug}/` | 创建工作空间资源 | | PATCH | `/api/assets/v2/workspaces/{slug}/{asset_id}/` | 确认工作空间资源上传 | | POST | `/api/assets/v2/user-assets/` | 创建用户资源 | | PATCH | `/api/assets/v2/user-assets/{asset_id}/` | 确认用户资源上传 | ### 创建项目资源参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 文件名 | | `type` | string | 是 | MIME 类型:`image/jpeg`/`image/png`/`image/webp`/`image/jpg`/`image/gif` | | `size` | integer | 是 | 文件大小(字节),上限 5MB | | `entity_type` | string | 是 | 资源类型(见下表) | | `entity_identifier` | string | 否 | 关联实体 ID(新建时传 `""` 空字符串) | ### entity_type 值 | 值 | 说明 | |----|------| | `ISSUE_DESCRIPTION` | 工作项描述内联资源 | | `ISSUE_ATTACHMENT` | 工作项附件 | | `COMMENT_DESCRIPTION` | 评论内联资源 | | `PAGE_DESCRIPTION` | 页面内联资源 | | `DRAFT_ISSUE_DESCRIPTION` | 草稿工作项描述 | | `PROJECT_COVER` | 项目封面 | | `USER_AVATAR` | 用户头像 | | `USER_COVER` | 用户封面 | | `WORKSPACE_LOGO` | 工作空间 Logo | ### 创建资源响应 | 字段 | 类型 | 说明 | |------|------|------| | `upload_data.url` | string | S3 上传目标 URL | | `upload_data.fields` | object | 上传表单字段 | | `asset_id` | uuid | 资源 ID | | `asset_url` | string | 资源访问路径(嵌入 HTML 用) | ### 批量绑定参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `asset_ids` | array[uuid] | 是 | 资源 ID 列表 | ### 上传流程 1. `POST .../projects/{pid}/` → 获取 `upload_data` + `asset_id` 2. `POST {upload_data.url}` (multipart/form-data) → 上传文件到 S3 3. `PATCH .../projects/{pid}/{asset_id}/` → 确认上传 4. 将 `asset_url` 嵌入 `description_html` 5. 创建工作项后 `POST .../{issue_id}/bulk/` → 绑定资源 ### Go 示例 ```go // AssetUploadResult 资源上传结果 type AssetUploadResult struct { UploadData struct { URL string `json:"url"` Fields map[string]string `json:"fields"` } `json:"upload_data"` AssetID string `json:"asset_id"` AssetURL string `json:"asset_url"` } // CreateProjectAsset 创建项目资源并获取预签名 URL func (c *Client) CreateProjectAsset(slug, projectID, fileName, mimeType string, size int64, entityType string) (*AssetUploadResult, error) { path := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/", slug, projectID) payload := map[string]interface{}{ "name": fileName, "type": mimeType, "size": size, "entity_type": entityType, "entity_identifier": "", } body, code, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } var result AssetUploadResult json.Unmarshal(body, &result) return &result, nil } // UploadToS3 上传文件到 S3(使用预签名 URL) func (c *Client) UploadToS3(uploadURL string, fields map[string]string, filePath string) error { file, err := os.Open(filePath) if err != nil { return err } defer file.Close() var buf bytes.Buffer writer := multipart.NewWriter(&buf) for k, v := range fields { writer.WriteField(k, v) } part, _ := writer.CreateFormFile("file", filepath.Base(filePath)) io.Copy(part, file) writer.Close() req, _ := http.NewRequest(http.MethodPost, uploadURL, &buf) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := (&http.Client{}).Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 && resp.StatusCode != 204 { return fmt.Errorf("S3 upload failed: %d", resp.StatusCode) } return nil } // ConfirmAssetUpload 确认资源上传完成 func (c *Client) ConfirmAssetUpload(slug, projectID, assetID string) error { path := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) _, code, err := c.jsonRequest(http.MethodPatch, path, nil) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } // BindAssetsToEntity 批量绑定资源到实体 func (c *Client) BindAssetsToEntity(slug, projectID, entityID string, assetIDs []string) error { path := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/bulk/", slug, projectID, entityID) payload := map[string]interface{}{"asset_ids": assetIDs} _, code, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } // DeleteAsset 删除资源 func (c *Client) DeleteAsset(slug, projectID, assetID string) error { path := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) _, code, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } // UploadInlineImage 完整上传内联图片流程(组合方法) func (c *Client) UploadInlineImage(slug, projectID, filePath, mimeType string) (assetID, assetURL string, err error) { info, err := os.Stat(filePath) if err != nil { return "", "", err } // 1. 创建资源 result, err := c.CreateProjectAsset(slug, projectID, info.Name(), mimeType, info.Size(), "ISSUE_DESCRIPTION") if err != nil { return "", "", err } // 2. 上传到 S3 if err := c.UploadToS3(result.UploadData.URL, result.UploadData.Fields, filePath); err != nil { return "", "", err } // 3. 确认 if err := c.ConfirmAssetUpload(slug, projectID, result.AssetID); err != nil { return "", "", err } return result.AssetID, result.AssetURL, nil } ``` --- ## 15. 页面 (Pages) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/projects/{pid}/pages/` | 列出页面 | | POST | `/api/workspaces/{slug}/projects/{pid}/pages/` | 创建页面 | | GET | `.../pages/{page_id}/` | 获取页面详情 | | PATCH | `.../pages/{page_id}/` | 更新页面 | | DELETE | `.../pages/{page_id}/` | 删除页面 | | GET | `.../pages/{page_id}/description/` | 获取页面描述 | | PATCH | `.../pages/{page_id}/description/` | 更新页面描述 | | POST | `.../pages/{page_id}/archive/` | 归档页面 | | DELETE | `.../pages/{page_id}/archive/` | 取消归档 | | POST | `.../pages/{page_id}/lock/` | 锁定页面 | | DELETE | `.../pages/{page_id}/lock/` | 解锁页面 | | POST | `.../pages/{page_id}/access/` | 设置访问权限 | | POST | `.../pages/{page_id}/duplicate/` | 复制页面 | ### 创建页面参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 页面标题 | | `description_html` | string | 否 | HTML 内容 | | `access` | integer | 否 | `0`=私密, `1`=公开(默认 0) | | `color` | string | 否 | 页面颜色 | | `label_ids` | array[uuid] | 否 | 标签列表 | ### 设置访问权限参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `access` | integer | 是 | `0`=私密, `1`=公开 | ### Go 示例 ```go // ListPages 列出页面 func (c *Client) ListPages(slug, projectID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/", slug, projectID) body, code, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d", code) } return body, nil } // CreatePage 创建页面 func (c *Client) CreatePage(slug, projectID, name, descriptionHTML string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/", slug, projectID) payload := map[string]interface{}{ "name": name, "description_html": descriptionHTML, } body, code, err := c.jsonRequest(http.MethodPost, path, payload) if err != nil || code != 201 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // GetPage 获取页面详情 func (c *Client) GetPage(slug, projectID, pageID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/", slug, projectID, pageID) body, code, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d", code) } return body, nil } // UpdatePage 更新页面 func (c *Client) UpdatePage(slug, projectID, pageID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/", slug, projectID, pageID) body, code, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // UpdatePageDescription 更新页面描述内容 func (c *Client) UpdatePageDescription(slug, projectID, pageID, descHTML string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/description/", slug, projectID, pageID) payload := map[string]interface{}{"description_html": descHTML} body, code, err := c.jsonRequest(http.MethodPatch, path, payload) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // DeletePage 删除页面 func (c *Client) DeletePage(slug, projectID, pageID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/", slug, projectID, pageID) _, code, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } // ArchivePage 归档页面 func (c *Client) ArchivePage(slug, projectID, pageID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/archive/", slug, projectID, pageID) _, code, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || code != 200 { return fmt.Errorf("status: %d", code) } return nil } // LockPage 锁定页面 func (c *Client) LockPage(slug, projectID, pageID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/lock/", slug, projectID, pageID) _, code, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || code != 200 { return fmt.Errorf("status: %d", code) } return nil } // UnlockPage 解锁页面 func (c *Client) UnlockPage(slug, projectID, pageID string) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/lock/", slug, projectID, pageID) _, code, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || code != 200 { return fmt.Errorf("status: %d", code) } return nil } // SetPageAccess 设置页面访问权限 func (c *Client) SetPageAccess(slug, projectID, pageID string, access int) error { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/access/", slug, projectID, pageID) _, code, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{"access": access}) if err != nil || code != 200 { return fmt.Errorf("status: %d", code) } return nil } // DuplicatePage 复制页面 func (c *Client) DuplicatePage(slug, projectID, pageID string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/projects/%s/pages/%s/duplicate/", slug, projectID, pageID) body, code, err := c.jsonRequest(http.MethodPost, path, map[string]interface{}{}) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } ``` --- ## 16. 便签 (Stickies) ### 端点列表 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/workspaces/{slug}/stickies/` | 列出便签 | | POST | `/api/workspaces/{slug}/stickies/` | 创建便签 | | GET | `/api/workspaces/{slug}/stickies/{id}/` | 获取便签 | | PATCH | `/api/workspaces/{slug}/stickies/{id}/` | 更新便签 | | DELETE | `/api/workspaces/{slug}/stickies/{id}/` | 删除便签 | ### 创建便签参数 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 否 | 标题 | | `description_html` | string | 否 | HTML 内容 | | `color` | string | 否 | 前景色 | | `background_color` | string | 否 | 背景色 | ### Go 示例 ```go // ListStickies 列出便签 func (c *Client) ListStickies(slug string) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/stickies/", slug) body, code, err := c.jsonRequest(http.MethodGet, path, nil) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d", code) } return body, nil } // CreateSticky 创建便签 func (c *Client) CreateSticky(slug string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/stickies/", slug) body, code, err := c.jsonRequest(http.MethodPost, path, data) if err != nil || code != 201 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // UpdateSticky 更新便签 func (c *Client) UpdateSticky(slug, stickyID string, data map[string]interface{}) ([]byte, error) { path := fmt.Sprintf("/api/workspaces/%s/stickies/%s/", slug, stickyID) body, code, err := c.jsonRequest(http.MethodPatch, path, data) if err != nil || code != 200 { return nil, fmt.Errorf("status: %d, body: %s", code, string(body)) } return body, nil } // DeleteSticky 删除便签 func (c *Client) DeleteSticky(slug, stickyID string) error { path := fmt.Sprintf("/api/workspaces/%s/stickies/%s/", slug, stickyID) _, code, err := c.jsonRequest(http.MethodDelete, path, nil) if err != nil || code != 204 { return fmt.Errorf("status: %d", code) } return nil } ``` --- ## 17. 附录 ### A. 完整使用示例:创建带内联图片的 Intake 工作项 ```go package main import ( "fmt" "log" ) func main() { // 初始化客户端 client, err := NewClient("http://localhost:8000") if err != nil { log.Fatal(err) } // 登录 if err := client.Login("admin@example.com", "password123"); err != nil { log.Fatal(err) } fmt.Println("✓ 登录成功") slug := "my-workspace" projectID := "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // 上传内联图片 assetID, assetURL, err := client.UploadInlineImage(slug, projectID, "./bug-screenshot.png", "image/png") if err != nil { log.Fatal(err) } fmt.Printf("✓ 图片上传成功: %s\n", assetID) // 创建 Intake 工作项 descHTML := fmt.Sprintf(`

发现 UI 问题,详见截图:

请优先处理。

`, assetURL) issueData := map[string]interface{}{ "name": "UI Bug - 按钮错位", "description_html": descHTML, "priority": "high", "label_ids": []string{}, "assignee_ids": []string{}, } respBody, err := client.CreateIntakeIssue(slug, projectID, issueData) if err != nil { log.Fatal(err) } // 从响应中提取 issue ID var resp struct { Issue struct { ID string `json:"id"` } `json:"issue"` } json.Unmarshal(respBody, &resp) fmt.Printf("✓ Intake 工作项创建成功: %s\n", resp.Issue.ID) // 绑定资源 if err := client.BindAssetsToEntity(slug, projectID, resp.Issue.ID, []string{assetID}); err != nil { log.Fatal(err) } fmt.Println("✓ 资源绑定成功") } ``` --- ### B. 完整使用示例:项目管理全流程 ```go func projectWorkflow() { client, _ := NewClient("http://localhost:8000") client.Login("admin@example.com", "password123") slug := "my-workspace" // 创建项目 project, _ := client.CreateProject(slug, map[string]interface{}{ "name": "Backend Refactor", "identifier": "BR", "network": 2, "cycle_view": true, "module_view": true, "intake_view": true, }) fmt.Println("项目已创建:", string(project)) projectID := "从响应中解析" // 创建状态 client.CreateState(slug, projectID, "Review", "#9333EA", "started") // 创建标签 client.CreateLabel(slug, projectID, "backend", "#3B82F6") // 创建工作项 issue, _ := client.CreateIssue(slug, projectID, map[string]interface{}{ "name": "重构用户认证模块", "description_html": "

将认证逻辑抽象为独立服务

", "priority": "high", "assignee_ids": []string{}, "label_ids": []string{}, }) fmt.Println("工作项已创建:", string(issue)) } ``` --- ### C. 完整使用示例:迭代管理 ```go func cycleWorkflow() { client, _ := NewClient("http://localhost:8000") client.Login("admin@example.com", "password123") slug := "my-workspace" projectID := "project-uuid" // 创建迭代 cycle, _ := client.CreateCycle(slug, projectID, map[string]interface{}{ "name": "Sprint 1", "start_date": "2024-01-15T00:00:00Z", "end_date": "2024-01-29T23:59:59Z", }) fmt.Println("迭代已创建:", string(cycle)) cycleID := "从响应解析" issueID := "已有工作项ID" // 添加工作项到迭代 client.AddIssuesToCycle(slug, projectID, cycleID, []string{issueID}) // 查看进度 progress, _ := client.GetCycleProgress(slug, projectID, cycleID) fmt.Println("迭代进度:", string(progress)) } ``` --- ### D. 错误码参考 | 状态码 | 含义 | 处理方式 | |--------|------|----------| | 200 | 成功 | 正常处理 | | 201 | 创建成功 | 正常处理 | | 204 | 无内容(删除/确认成功) | 无需解析 Body | | 302 | 重定向(登录成功/资源下载) | 提取 Cookie 或跟随重定向 | | 400 | 参数错误 | 检查请求体 | | 403 | 权限不足或 CSRF 失败 | 检查角色/重新获取 CSRF | | 404 | 资源不存在 | 检查路径参数 | | 409 | 冲突(名称重复等) | 检查唯一性约束 | --- ### E. 与 API v1 (X-Api-Key) 对比 | 特性 | Web API (本文档) | API v1 | |------|------------------|--------| | 认证 | Session + CSRF | X-Api-Key Header | | Intake 创建 | 完整字段 (assignees, labels, dates) | 仅 name, description, priority | | 描述内联图片 | 支持 | 不支持 | | 资源上传 | Assets v2 全功能 | 仅附件 | | 工作项字段 | `assignee_ids` / `label_ids` / `state_id` | `assignees` / `labels` / `state` | | URL 前缀 | `/api/` | `/api/v1/` | | Labels 路径 | `/issue-labels/` | `/labels/` | | 适用场景 | 后端自动化、脚本、完整功能集成 | 第三方集成、外部 Webhook | --- ### F. Session 管理建议 ```go // 带自动重试的请求包装 func (c *Client) withRetry(email, password string, fn func() error) error { err := fn() if err != nil && (strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "401")) { // Session 过期,重新登录 if loginErr := c.Login(email, password); loginErr != nil { return fmt.Errorf("重新登录失败: %w", loginErr) } return fn() } return err } ``` --- ### G. 所有端点快速参考 | 模块 | 列出 | 创建 | 详情 | 更新 | 删除 | |------|------|------|------|------|------| | 工作空间 | GET /api/workspaces/ | POST /api/workspaces/ | GET /api/workspaces/{slug}/ | PATCH /api/workspaces/{slug}/ | DELETE /api/workspaces/{slug}/ | | 项目 | GET .../projects/ | POST .../projects/ | GET .../projects/{id}/ | PATCH .../projects/{id}/ | DELETE .../projects/{id}/ | | 工作项 | GET .../issues/ | POST .../issues/ | GET .../issues/{id}/ | PATCH .../issues/{id}/ | DELETE .../issues/{id}/ | | 评论 | GET .../comments/ | POST .../comments/ | GET .../comments/{id}/ | PATCH .../comments/{id}/ | DELETE .../comments/{id}/ | | 链接 | GET .../issue-links/ | POST .../issue-links/ | GET .../issue-links/{id}/ | PATCH .../issue-links/{id}/ | DELETE .../issue-links/{id}/ | | 迭代 | GET .../cycles/ | POST .../cycles/ | GET .../cycles/{id}/ | PATCH .../cycles/{id}/ | DELETE .../cycles/{id}/ | | 模块 | GET .../modules/ | POST .../modules/ | GET .../modules/{id}/ | PATCH .../modules/{id}/ | DELETE .../modules/{id}/ | | 状态 | GET .../states/ | POST .../states/ | GET .../states/{id}/ | PATCH .../states/{id}/ | DELETE .../states/{id}/ | | 标签 | GET .../issue-labels/ | POST .../issue-labels/ | GET .../issue-labels/{id}/ | PATCH .../issue-labels/{id}/ | DELETE .../issue-labels/{id}/ | | 成员 | GET .../members/ | POST .../members/ | GET .../members/{id}/ | PATCH .../members/{id}/ | DELETE .../members/{id}/ | | 收件箱 | GET .../intake-issues/ | POST .../intake-issues/ | GET .../intake-issues/{id}/ | PATCH .../intake-issues/{id}/ | DELETE .../intake-issues/{id}/ | | 页面 | GET .../pages/ | POST .../pages/ | GET .../pages/{id}/ | PATCH .../pages/{id}/ | DELETE .../pages/{id}/ | | 便签 | GET .../stickies/ | POST .../stickies/ | GET .../stickies/{id}/ | PATCH .../stickies/{id}/ | DELETE .../stickies/{id}/ |