WEB_API_DOCUMENTATION.md 65 KB

Plane Web API 完整使用文档

本文档覆盖 Plane v1.3.1 内部 Web API 的所有主要端点,包括认证、工作空间、项目、工作项、迭代、模块、状态、标签、成员、页面、收件箱、资源上传等,并为每个接口提供 Go 语言代码示例。


目录

  1. 认证与客户端基础
  2. 工作空间 (Workspaces)
  3. 项目 (Projects)
  4. 工作项 (Issues)
  5. 工作项评论 (Comments)
  6. 工作项链接 (Links)
  7. 工作项关系 (Relations)
  8. 迭代 (Cycles)
  9. 模块 (Modules)
  10. 状态 (States)
  11. 标签 (Labels)
  12. 成员 (Members)
  13. 收件箱/Intake
  14. 资源上传 (Assets v2)
  15. 页面 (Pages)
  16. 便签 (Stickies)
  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 客户端基础代码

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 登录示例

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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 示例

// 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 工作项

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(`<p>发现 UI 问题,详见截图:</p><img src="%s" /><p>请优先处理。</p>`, 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. 完整使用示例:项目管理全流程

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": "<p>将认证逻辑抽象为独立服务</p>",
		"priority":         "high",
		"assignee_ids":     []string{},
		"label_ids":        []string{},
	})
	fmt.Println("工作项已创建:", string(issue))
}

C. 完整使用示例:迭代管理

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 管理建议

// 带自动重试的请求包装
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}/