client.go 9.9 KB

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