package plane_v2 import ( "bytes" "encoding/json" "fmt" "fwh-toolbox-server/config" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "strings" "sync" "time" "go.uber.org/zap" ) // Plane v2 Web API客户端 - 使用Session+CSRF认证(Django SessionAuthentication) const ( webTimeout time.Duration = 60 * time.Second // 请求超时60s ) var ( webBaseURL string webEmail string webPassword string webWorkspaceSlug string webProjectId string webLabelBugId string webLabelFeatureId string webLabelFeedbackId string webCsrfToken string webClient *http.Client webLoginMu sync.Mutex ) // planeWebResponse 封装Plane Web HTTP响应 type planeWebResponse struct { StatusCode int Status string Body []byte } // PlaneWebHttpInit 初始化Plane Web HTTP客户端配置 func PlaneWebHttpInit(cfg *config.PlaneWebApiConfig) { webBaseURL = strings.TrimRight(cfg.Host, "/") webEmail = cfg.Email webPassword = cfg.Password webWorkspaceSlug = cfg.WorkspaceSlug webProjectId = cfg.ProjectId webLabelBugId = cfg.LabelBugId webLabelFeatureId = cfg.LabelFeatureId webLabelFeedbackId = cfg.LabelFeedbackId jar, err := cookiejar.New(nil) if err != nil { zap.L().Fatal("[PlaneWeb] 创建CookieJar失败", zap.Error(err)) } webClient = &http.Client{ Timeout: webTimeout, Jar: jar, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } // 初始化时执行登录 if err := webLogin(); err != nil { zap.L().Error("[PlaneWeb] 初始化登录失败", zap.Error(err)) } } // GetWorkspaceSlug 获取WorkspaceSlug func GetWorkspaceSlug() string { return webWorkspaceSlug } // GetProjectId 获取ProjectId func GetProjectId() string { return webProjectId } // GetLabelBugId 获取Bug标签ID func GetWebLabelBugId() string { return webLabelBugId } // GetLabelFeatureId 获取Feature标签ID func GetWebLabelFeatureId() string { return webLabelFeatureId } // GetLabelFeedbackId 获取Feedback标签ID func GetWebLabelFeedbackId() string { return webLabelFeedbackId } // isWebSuccess 判断HTTP状态码是否表示成功(2xx或3xx重定向) func isWebSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 } // webLogin 执行登录流程:获取CSRF Token → 登录获取Session func webLogin() error { webLoginMu.Lock() defer webLoginMu.Unlock() zap.L().Info("[PlaneWeb] 开始登录流程") // Step 1: GET /auth/get-csrf-token/ 获取CSRF Token csrfURL := webBaseURL + "/auth/get-csrf-token/" req, err := http.NewRequest(http.MethodGet, csrfURL, nil) if err != nil { return fmt.Errorf("创建CSRF请求失败: %w", err) } req.Header.Set("Referer", webBaseURL) resp, err := webClient.Do(req) if err != nil { return fmt.Errorf("获取CSRF Token请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("读取CSRF响应失败: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("获取CSRF Token失败, status=%d, body=%s", resp.StatusCode, string(body)) } // 解析CSRF Token var csrfResp struct { CsrfToken string `json:"csrf_token"` } if err := json.Unmarshal(body, &csrfResp); err != nil { return fmt.Errorf("解析CSRF Token响应失败: %w", err) } webCsrfToken = csrfResp.CsrfToken zap.L().Debug("[PlaneWeb] 获取CSRF Token成功", zap.String("token", webCsrfToken[:8]+"...")) // Step 2: POST /auth/sign-in/ 登录 signInURL := webBaseURL + "/auth/sign-in/" formData := url.Values{} formData.Set("email", webEmail) formData.Set("password", webPassword) req, err = http.NewRequest(http.MethodPost, signInURL, strings.NewReader(formData.Encode())) if err != nil { return fmt.Errorf("创建登录请求失败: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", webBaseURL) req.Header.Set("X-CSRFToken", webCsrfToken) req.AddCookie(&http.Cookie{Name: "csrftoken", Value: webCsrfToken}) resp, err = webClient.Do(req) if err != nil { return fmt.Errorf("登录请求失败: %w", err) } defer resp.Body.Close() // 登录成功返回302 if resp.StatusCode != http.StatusFound { loginBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("登录失败, status=%d, body=%s", resp.StatusCode, string(loginBody)) } // 验证session-id cookie是否已设置 parsedURL, _ := url.Parse(webBaseURL) cookies := webClient.Jar.Cookies(parsedURL) hasSession := false for _, c := range cookies { if c.Name == "session-id" { hasSession = true break } } if !hasSession { return fmt.Errorf("登录后未获取到session-id cookie") } zap.L().Info("[PlaneWeb] 登录成功") return nil } // webEnsureAuth 确保已认证,如果收到401/403则重新登录并重试 func webEnsureAuth(doRequest func() (*planeWebResponse, error)) (*planeWebResponse, error) { resp, err := doRequest() if err != nil { return nil, err } // 如果是401或403,尝试重新登录后重试 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { zap.L().Warn("[PlaneWeb] 收到认证错误,尝试重新登录", zap.Int("statusCode", resp.StatusCode)) if loginErr := webLogin(); loginErr != nil { return nil, fmt.Errorf("重新登录失败: %w", loginErr) } // 重试请求 return doRequest() } return resp, nil } // planeWebGet 发送GET请求到Plane Web API func planeWebGet(relativeURL string) (*planeWebResponse, error) { return webEnsureAuth(func() (*planeWebResponse, error) { reqURL := webBaseURL + relativeURL zap.L().Debug("[PlaneWeb] HttpGet", zap.String("url", reqURL)) req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) resp, err := webClient.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return &planeWebResponse{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }) } // planeWebPost 发送POST请求到Plane Web API(JSON) func planeWebPost(relativeURL string, reqBody any) (*planeWebResponse, error) { return webEnsureAuth(func() (*planeWebResponse, error) { reqURL := webBaseURL + relativeURL zap.L().Debug("[PlaneWeb] HttpPost", zap.String("url", reqURL)) jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("序列化请求体失败: %w", err) } req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) req.Header.Set("X-CSRFToken", webCsrfToken) resp, err := webClient.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return &planeWebResponse{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }) } // planeWebPatch 发送PATCH请求到Plane Web API(JSON) func planeWebPatch(relativeURL string, reqBody any) (*planeWebResponse, error) { return webEnsureAuth(func() (*planeWebResponse, error) { reqURL := webBaseURL + relativeURL zap.L().Debug("[PlaneWeb] HttpPatch", zap.String("url", reqURL)) jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("序列化请求体失败: %w", err) } req, err := http.NewRequest(http.MethodPatch, reqURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) req.Header.Set("X-CSRFToken", webCsrfToken) resp, err := webClient.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return &planeWebResponse{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }) } // planeWebDelete 发送DELETE请求到Plane Web API func planeWebDelete(relativeURL string) (*planeWebResponse, error) { return webEnsureAuth(func() (*planeWebResponse, error) { reqURL := webBaseURL + relativeURL zap.L().Debug("[PlaneWeb] HttpDelete", zap.String("url", reqURL)) req, err := http.NewRequest(http.MethodDelete, reqURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) req.Header.Set("X-CSRFToken", webCsrfToken) resp, err := webClient.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return &planeWebResponse{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }) } // planeWebPostForm 发送multipart/form-data POST请求到Plane Web API func planeWebPostForm(relativeURL string, writeForm func(w *multipart.Writer) error) (*planeWebResponse, error) { return webEnsureAuth(func() (*planeWebResponse, error) { reqURL := webBaseURL + relativeURL zap.L().Debug("[PlaneWeb] HttpPostForm", zap.String("url", reqURL)) var buf bytes.Buffer writer := multipart.NewWriter(&buf) if err := writeForm(writer); err != nil { return nil, fmt.Errorf("写入表单数据失败: %w", err) } writer.Close() req, err := http.NewRequest(http.MethodPost, reqURL, &buf) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) req.Header.Set("X-CSRFToken", webCsrfToken) resp, err := webClient.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return &planeWebResponse{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }) }