client.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. package plane_v2
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "mime/multipart"
  8. "net/http"
  9. "net/http/cookiejar"
  10. "net/url"
  11. "strings"
  12. "sync"
  13. "time"
  14. "go.uber.org/zap"
  15. )
  16. const clientTimeout = 60 * time.Second
  17. // Response HTTP 响应
  18. type Response struct {
  19. StatusCode int
  20. Status string
  21. Body []byte
  22. }
  23. // PlaneClient Plane Web API 客户端(通讯层)
  24. type PlaneClient struct {
  25. baseURL string
  26. email string
  27. password string
  28. csrfToken string
  29. httpClient *http.Client
  30. loginMu sync.Mutex
  31. }
  32. // 全局单例
  33. var globalClient *PlaneClient
  34. // InitPlaneClient 初始化全局 PlaneClient(启动时调用一次,失败直接 panic)
  35. func InitPlaneClient(host, email, password string) {
  36. if globalClient != nil {
  37. zap.L().Warn("[PlaneClient] 重复调用 InitPlaneClient,已忽略")
  38. return
  39. }
  40. client, err := newPlaneClient(host, email, password)
  41. if err != nil {
  42. panic(fmt.Sprintf("[PlaneClient] 初始化失败: %v", err))
  43. }
  44. globalClient = client
  45. }
  46. // newPlaneClient 创建 Plane 客户端并执行登录
  47. func newPlaneClient(host, email, password string) (*PlaneClient, error) {
  48. jar, err := cookiejar.New(nil)
  49. if err != nil {
  50. return nil, fmt.Errorf("创建CookieJar失败: %w", err)
  51. }
  52. c := &PlaneClient{
  53. baseURL: strings.TrimRight(host, "/"),
  54. email: email,
  55. password: password,
  56. httpClient: &http.Client{
  57. Timeout: clientTimeout,
  58. Jar: jar,
  59. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  60. return http.ErrUseLastResponse
  61. },
  62. },
  63. }
  64. if err := c.login(); err != nil {
  65. return nil, fmt.Errorf("初始化登录失败: %w", err)
  66. }
  67. return c, nil
  68. }
  69. // IsSuccess 判断 HTTP 状态码是否成功
  70. func IsSuccess(statusCode int) bool {
  71. return statusCode >= 200 && statusCode < 300
  72. }
  73. // ============ 公开 HTTP 方法 ============
  74. // Get 发送 GET 请求
  75. func (c *PlaneClient) Get(relativeURL string) (*Response, error) {
  76. return c.ensureAuth(func() (*Response, error) {
  77. return c.doRequest(http.MethodGet, relativeURL, nil)
  78. })
  79. }
  80. // Post 发送 POST 请求(JSON)
  81. func (c *PlaneClient) Post(relativeURL string, body any) (*Response, error) {
  82. return c.ensureAuth(func() (*Response, error) {
  83. return c.doJSONRequest(http.MethodPost, relativeURL, body)
  84. })
  85. }
  86. // Patch 发送 PATCH 请求(JSON)
  87. func (c *PlaneClient) Patch(relativeURL string, body any) (*Response, error) {
  88. return c.ensureAuth(func() (*Response, error) {
  89. return c.doJSONRequest(http.MethodPatch, relativeURL, body)
  90. })
  91. }
  92. // Delete 发送 DELETE 请求
  93. func (c *PlaneClient) Delete(relativeURL string) (*Response, error) {
  94. return c.ensureAuth(func() (*Response, error) {
  95. return c.doRequest(http.MethodDelete, relativeURL, nil)
  96. })
  97. }
  98. // PostForm 发送 multipart/form-data POST 请求
  99. func (c *PlaneClient) PostForm(relativeURL string, writeForm func(w *multipart.Writer) error) (*Response, error) {
  100. return c.ensureAuth(func() (*Response, error) {
  101. var buf bytes.Buffer
  102. writer := multipart.NewWriter(&buf)
  103. if err := writeForm(writer); err != nil {
  104. return nil, fmt.Errorf("写入表单数据失败: %w", err)
  105. }
  106. writer.Close()
  107. reqURL := c.baseURL + relativeURL
  108. req, err := http.NewRequest(http.MethodPost, reqURL, &buf)
  109. if err != nil {
  110. return nil, fmt.Errorf("创建请求失败: %w", err)
  111. }
  112. req.Header.Set("Content-Type", writer.FormDataContentType())
  113. req.Header.Set("Accept", "application/json")
  114. req.Header.Set("Referer", c.baseURL)
  115. req.Header.Set("X-CSRFToken", c.csrfToken)
  116. return c.executeRequest(req)
  117. })
  118. }
  119. // PostS3 上传文件到 S3(预签名 POST,不走认证)
  120. func (c *PlaneClient) PostS3(uploadURL string, fields map[string]string, fileData []byte, fileName string) error {
  121. var buf bytes.Buffer
  122. writer := multipart.NewWriter(&buf)
  123. for k, v := range fields {
  124. if err := writer.WriteField(k, v); err != nil {
  125. return fmt.Errorf("写入 field %s 失败: %w", k, err)
  126. }
  127. }
  128. part, err := writer.CreateFormFile("file", fileName)
  129. if err != nil {
  130. return fmt.Errorf("创建 form file 失败: %w", err)
  131. }
  132. if _, err := part.Write(fileData); err != nil {
  133. return fmt.Errorf("写入文件数据失败: %w", err)
  134. }
  135. writer.Close()
  136. req, err := http.NewRequest(http.MethodPost, uploadURL, &buf)
  137. if err != nil {
  138. return fmt.Errorf("创建上传请求失败: %w", err)
  139. }
  140. req.Header.Set("Content-Type", writer.FormDataContentType())
  141. client := &http.Client{Timeout: 60 * time.Second}
  142. resp, err := client.Do(req)
  143. if err != nil {
  144. return fmt.Errorf("文件上传失败: %w", err)
  145. }
  146. defer resp.Body.Close()
  147. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  148. respBody, _ := io.ReadAll(resp.Body)
  149. return fmt.Errorf("文件上传失败: %s, body=%s", resp.Status, string(respBody))
  150. }
  151. return nil
  152. }
  153. // GetRedirectLocation 发送 GET 请求并从 302 响应中提取 Location
  154. func (c *PlaneClient) GetRedirectLocation(relativeURL string) (string, error) {
  155. reqURL := c.baseURL + relativeURL
  156. req, err := http.NewRequest(http.MethodGet, reqURL, nil)
  157. if err != nil {
  158. return "", fmt.Errorf("创建请求失败: %w", err)
  159. }
  160. req.Header.Set("Accept", "application/json")
  161. req.Header.Set("Referer", c.baseURL)
  162. resp, err := c.httpClient.Do(req)
  163. if err != nil {
  164. return "", fmt.Errorf("请求失败: %w", err)
  165. }
  166. defer resp.Body.Close()
  167. if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
  168. location := resp.Header.Get("Location")
  169. if location != "" {
  170. return location, nil
  171. }
  172. }
  173. if IsSuccess(resp.StatusCode) {
  174. return reqURL, nil
  175. }
  176. return "", fmt.Errorf("获取重定向URL失败: status=%d", resp.StatusCode)
  177. }
  178. // GetBaseURL 获取基础 URL
  179. func (c *PlaneClient) GetBaseURL() string {
  180. return c.baseURL
  181. }
  182. // ============ 内部方法 ============
  183. // ensureAuth 确保已认证,401/403 时自动重新登录重试
  184. func (c *PlaneClient) ensureAuth(doRequest func() (*Response, error)) (*Response, error) {
  185. resp, err := doRequest()
  186. if err != nil {
  187. return nil, err
  188. }
  189. if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
  190. zap.L().Warn("[PlaneClient] 认证失败,尝试重新登录", zap.Int("status", resp.StatusCode))
  191. if loginErr := c.login(); loginErr != nil {
  192. return nil, fmt.Errorf("重新登录失败: %w", loginErr)
  193. }
  194. return doRequest()
  195. }
  196. return resp, nil
  197. }
  198. // login 执行登录流程
  199. func (c *PlaneClient) login() error {
  200. c.loginMu.Lock()
  201. defer c.loginMu.Unlock()
  202. zap.L().Info("[PlaneClient] 开始登录")
  203. // Step 1: 获取 CSRF Token
  204. csrfURL := c.baseURL + "/auth/get-csrf-token/"
  205. req, err := http.NewRequest(http.MethodGet, csrfURL, nil)
  206. if err != nil {
  207. return fmt.Errorf("创建CSRF请求失败: %w", err)
  208. }
  209. req.Header.Set("Referer", c.baseURL)
  210. resp, err := c.httpClient.Do(req)
  211. if err != nil {
  212. return fmt.Errorf("获取CSRF Token失败: %w", err)
  213. }
  214. defer resp.Body.Close()
  215. body, err := io.ReadAll(resp.Body)
  216. if err != nil {
  217. return fmt.Errorf("读取CSRF响应失败: %w", err)
  218. }
  219. if resp.StatusCode != http.StatusOK {
  220. return fmt.Errorf("获取CSRF Token失败, status=%d", resp.StatusCode)
  221. }
  222. var csrfResp struct {
  223. CsrfToken string `json:"csrf_token"`
  224. }
  225. if err := json.Unmarshal(body, &csrfResp); err != nil {
  226. return fmt.Errorf("解析CSRF Token失败: %w", err)
  227. }
  228. c.csrfToken = csrfResp.CsrfToken
  229. // Step 2: 登录
  230. signInURL := c.baseURL + "/auth/sign-in/"
  231. formData := url.Values{}
  232. formData.Set("email", c.email)
  233. formData.Set("password", c.password)
  234. req, err = http.NewRequest(http.MethodPost, signInURL, strings.NewReader(formData.Encode()))
  235. if err != nil {
  236. return fmt.Errorf("创建登录请求失败: %w", err)
  237. }
  238. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  239. req.Header.Set("Referer", c.baseURL)
  240. req.Header.Set("X-CSRFToken", c.csrfToken)
  241. req.AddCookie(&http.Cookie{Name: "csrftoken", Value: c.csrfToken})
  242. resp, err = c.httpClient.Do(req)
  243. if err != nil {
  244. return fmt.Errorf("登录请求失败: %w", err)
  245. }
  246. defer resp.Body.Close()
  247. if resp.StatusCode != http.StatusFound {
  248. loginBody, _ := io.ReadAll(resp.Body)
  249. return fmt.Errorf("登录失败, status=%d, body=%s", resp.StatusCode, string(loginBody))
  250. }
  251. // 验证 session cookie
  252. parsedURL, _ := url.Parse(c.baseURL)
  253. cookies := c.httpClient.Jar.Cookies(parsedURL)
  254. for _, cookie := range cookies {
  255. if cookie.Name == "session-id" {
  256. zap.L().Info("[PlaneClient] 登录成功")
  257. return nil
  258. }
  259. }
  260. return fmt.Errorf("登录后未获取到session-id cookie")
  261. }
  262. // doRequest 发送不带 body 的请求
  263. func (c *PlaneClient) doRequest(method, relativeURL string, _ any) (*Response, error) {
  264. reqURL := c.baseURL + relativeURL
  265. req, err := http.NewRequest(method, reqURL, nil)
  266. if err != nil {
  267. return nil, fmt.Errorf("创建请求失败: %w", err)
  268. }
  269. req.Header.Set("Accept", "application/json")
  270. req.Header.Set("Referer", c.baseURL)
  271. if method != http.MethodGet {
  272. req.Header.Set("X-CSRFToken", c.csrfToken)
  273. }
  274. return c.executeRequest(req)
  275. }
  276. // doJSONRequest 发送 JSON body 的请求
  277. func (c *PlaneClient) doJSONRequest(method, relativeURL string, body any) (*Response, error) {
  278. reqURL := c.baseURL + relativeURL
  279. jsonData, err := json.Marshal(body)
  280. if err != nil {
  281. return nil, fmt.Errorf("序列化请求体失败: %w", err)
  282. }
  283. req, err := http.NewRequest(method, reqURL, bytes.NewReader(jsonData))
  284. if err != nil {
  285. return nil, fmt.Errorf("创建请求失败: %w", err)
  286. }
  287. req.Header.Set("Content-Type", "application/json")
  288. req.Header.Set("Accept", "application/json")
  289. req.Header.Set("Referer", c.baseURL)
  290. req.Header.Set("X-CSRFToken", c.csrfToken)
  291. return c.executeRequest(req)
  292. }
  293. // executeRequest 执行请求并返回响应
  294. func (c *PlaneClient) executeRequest(req *http.Request) (*Response, error) {
  295. resp, err := c.httpClient.Do(req)
  296. if err != nil {
  297. return nil, fmt.Errorf("请求失败: %w", err)
  298. }
  299. defer resp.Body.Close()
  300. body, err := io.ReadAll(resp.Body)
  301. if err != nil {
  302. return nil, fmt.Errorf("读取响应失败: %w", err)
  303. }
  304. return &Response{
  305. StatusCode: resp.StatusCode,
  306. Status: resp.Status,
  307. Body: body,
  308. }, nil
  309. }