| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- 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"
- )
- 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(cfg *config.PlaneWebApiConfig) {
- if globalClient != nil {
- zap.L().Warn("[PlaneClient] 重复调用 InitPlaneClient,已忽略")
- return
- }
- client, err := newPlaneClient(cfg.Host, cfg.Email, cfg.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
- }
- if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
- zap.L().Warn("[PlaneClient] 认证失败,尝试重新登录", zap.Int("status", resp.StatusCode))
- if loginErr := c.login(); loginErr != nil {
- return nil, fmt.Errorf("重新登录失败: %w", loginErr)
- }
- return doRequest()
- }
- 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
- }
|