package plane_v2 import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "strings" "sync" "time" "go.uber.org/zap" ) const clientTimeout = 60 * time.Second // Response HTTP 响应 type Response struct { StatusCode int Status string Body []byte } // PlaneClient Plane Web API 客户端(通讯层) type PlaneClient struct { baseURL string email string password string csrfToken string httpClient *http.Client loginMu sync.Mutex } // 全局单例 var globalClient *PlaneClient // InitPlaneClient 初始化全局 PlaneClient(启动时调用一次,失败直接 panic) func InitPlaneClient(host, email, password string) { if globalClient != nil { zap.L().Warn("[PlaneClient] 重复调用 InitPlaneClient,已忽略") return } client, err := newPlaneClient(host, email, password) if err != nil { panic(fmt.Sprintf("[PlaneClient] 初始化失败: %v", err)) } globalClient = client } // newPlaneClient 创建 Plane 客户端并执行登录 func newPlaneClient(host, email, password string) (*PlaneClient, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("创建CookieJar失败: %w", err) } c := &PlaneClient{ baseURL: strings.TrimRight(host, "/"), email: email, password: password, httpClient: &http.Client{ Timeout: clientTimeout, Jar: jar, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, }, } if err := c.login(); err != nil { return nil, fmt.Errorf("初始化登录失败: %w", err) } return c, nil } // IsSuccess 判断 HTTP 状态码是否成功 func IsSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 } // ============ 公开 HTTP 方法 ============ // Get 发送 GET 请求 func (c *PlaneClient) Get(relativeURL string) (*Response, error) { return c.ensureAuth(func() (*Response, error) { return c.doRequest(http.MethodGet, relativeURL, nil) }) } // Post 发送 POST 请求(JSON) func (c *PlaneClient) Post(relativeURL string, body any) (*Response, error) { return c.ensureAuth(func() (*Response, error) { return c.doJSONRequest(http.MethodPost, relativeURL, body) }) } // Patch 发送 PATCH 请求(JSON) func (c *PlaneClient) Patch(relativeURL string, body any) (*Response, error) { return c.ensureAuth(func() (*Response, error) { return c.doJSONRequest(http.MethodPatch, relativeURL, body) }) } // Delete 发送 DELETE 请求 func (c *PlaneClient) Delete(relativeURL string) (*Response, error) { return c.ensureAuth(func() (*Response, error) { return c.doRequest(http.MethodDelete, relativeURL, nil) }) } // PostForm 发送 multipart/form-data POST 请求 func (c *PlaneClient) PostForm(relativeURL string, writeForm func(w *multipart.Writer) error) (*Response, error) { return c.ensureAuth(func() (*Response, error) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) if err := writeForm(writer); err != nil { return nil, fmt.Errorf("写入表单数据失败: %w", err) } writer.Close() reqURL := c.baseURL + relativeURL 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", c.baseURL) req.Header.Set("X-CSRFToken", c.csrfToken) return c.executeRequest(req) }) } // PostS3 上传文件到 S3(预签名 POST,不走认证) func (c *PlaneClient) PostS3(uploadURL string, fields map[string]string, fileData []byte, fileName string) error { var buf bytes.Buffer writer := multipart.NewWriter(&buf) for k, v := range fields { if err := writer.WriteField(k, v); err != nil { return fmt.Errorf("写入 field %s 失败: %w", k, err) } } part, err := writer.CreateFormFile("file", fileName) if err != nil { return fmt.Errorf("创建 form file 失败: %w", err) } if _, err := part.Write(fileData); err != nil { return fmt.Errorf("写入文件数据失败: %w", err) } writer.Close() req, err := http.NewRequest(http.MethodPost, uploadURL, &buf) if err != nil { return fmt.Errorf("创建上传请求失败: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("文件上传失败: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("文件上传失败: %s, body=%s", resp.Status, string(respBody)) } return nil } // GetRedirectLocation 发送 GET 请求并从 302 响应中提取 Location func (c *PlaneClient) GetRedirectLocation(relativeURL string) (string, error) { reqURL := c.baseURL + relativeURL req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return "", fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", c.baseURL) resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently { location := resp.Header.Get("Location") if location != "" { return location, nil } } if IsSuccess(resp.StatusCode) { return reqURL, nil } return "", fmt.Errorf("获取重定向URL失败: status=%d", resp.StatusCode) } // GetBaseURL 获取基础 URL func (c *PlaneClient) GetBaseURL() string { return c.baseURL } // ============ 内部方法 ============ // ensureAuth 确保已认证,401/403 时自动重新登录重试 func (c *PlaneClient) ensureAuth(doRequest func() (*Response, error)) (*Response, error) { resp, err := doRequest() if err != nil { return nil, err } // 401 Unauthorized = session 过期,重新登录后重试 if resp.StatusCode == http.StatusUnauthorized { zap.L().Warn("[PlaneClient] Session 过期,重新登录") if loginErr := c.login(); loginErr != nil { return nil, fmt.Errorf("重新登录失败: %w", loginErr) } return doRequest() } // 403 Forbidden = 权限不足(不是认证问题),直接返回 return resp, nil } // login 执行登录流程 func (c *PlaneClient) login() error { c.loginMu.Lock() defer c.loginMu.Unlock() zap.L().Info("[PlaneClient] 开始登录") // Step 1: 获取 CSRF Token csrfURL := c.baseURL + "/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", c.baseURL) resp, err := c.httpClient.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", resp.StatusCode) } var csrfResp struct { CsrfToken string `json:"csrf_token"` } if err := json.Unmarshal(body, &csrfResp); err != nil { return fmt.Errorf("解析CSRF Token失败: %w", err) } c.csrfToken = csrfResp.CsrfToken // Step 2: 登录 signInURL := c.baseURL + "/auth/sign-in/" formData := url.Values{} formData.Set("email", c.email) formData.Set("password", c.password) 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", c.baseURL) req.Header.Set("X-CSRFToken", c.csrfToken) req.AddCookie(&http.Cookie{Name: "csrftoken", Value: c.csrfToken}) resp, err = c.httpClient.Do(req) if err != nil { return fmt.Errorf("登录请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { loginBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("登录失败, status=%d, body=%s", resp.StatusCode, string(loginBody)) } // 验证 session cookie parsedURL, _ := url.Parse(c.baseURL) cookies := c.httpClient.Jar.Cookies(parsedURL) for _, cookie := range cookies { if cookie.Name == "session-id" { zap.L().Info("[PlaneClient] 登录成功") return nil } } return fmt.Errorf("登录后未获取到session-id cookie") } // doRequest 发送不带 body 的请求 func (c *PlaneClient) doRequest(method, relativeURL string, _ any) (*Response, error) { reqURL := c.baseURL + relativeURL req, err := http.NewRequest(method, reqURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", c.baseURL) if method != http.MethodGet { req.Header.Set("X-CSRFToken", c.csrfToken) } return c.executeRequest(req) } // doJSONRequest 发送 JSON body 的请求 func (c *PlaneClient) doJSONRequest(method, relativeURL string, body any) (*Response, error) { reqURL := c.baseURL + relativeURL jsonData, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("序列化请求体失败: %w", err) } req, err := http.NewRequest(method, 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", c.baseURL) req.Header.Set("X-CSRFToken", c.csrfToken) return c.executeRequest(req) } // executeRequest 执行请求并返回响应 func (c *PlaneClient) executeRequest(req *http.Request) (*Response, error) { resp, err := c.httpClient.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 &Response{ StatusCode: resp.StatusCode, Status: resp.Status, Body: body, }, nil }