| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- 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
- })
- }
|