package app import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/geo-platform/tenant-api/internal/shared/response" ) const wordpressPlatformID = "wordpress" type wordpressPublisher struct { client *http.Client } type wordpressAPIIndex struct { Name string `json:"name"` Description string `json:"description"` URL string `json:"url"` Home string `json:"home"` Namespaces []string `json:"namespaces"` Headers map[string]string `json:"-"` } type wordpressCategory struct { ID int64 `json:"id"` Count int `json:"count"` Name string `json:"name"` Slug string `json:"slug"` Parent int64 `json:"parent"` Link string `json:"link"` } type wordpressPost struct { ID int64 `json:"id"` Link string `json:"link"` Status string `json:"status"` } type wordpressErrorResponse struct { Code string `json:"code"` Message string `json:"message"` } type wordpressRequestError struct { message string status int code string } func (err *wordpressRequestError) Error() string { return err.message } func newWordPressPublisher(client *http.Client) *wordpressPublisher { if client == nil { client = &http.Client{Timeout: 20 * time.Second} } return &wordpressPublisher{client: client} } func (p *wordpressPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) { var payload map[string]any if err := p.request(ctx, http.MethodGet, credential, "", nil, nil, &payload); err != nil { return nil, nil, err } var me map[string]any if err := p.request(ctx, http.MethodGet, credential, "wp/v2/users/me", nil, nil, &me); err != nil { return nil, nil, err } capability := &EnterpriseSiteCapability{ CMSType: wordpressPlatformID, SiteURL: strings.TrimSpace(firstStringFromMap(payload, "home", "url")), SiteName: stringPointerFromMap(payload, "name"), APIAuth: true, } if capability.SiteURL == "" { capability.SiteURL = credential.SiteURL } safeRaw := map[string]any{ "cms_type": wordpressPlatformID, "site_url": capability.SiteURL, "site_name": enterpriseSiteStringPointerValue(capability.SiteName), "api_auth": true, "authenticated": true, } if id := firstStringFromMap(me, "id", "slug", "name"); id != "" { safeRaw["authenticated_user"] = id } return capability, safeRaw, nil } func (p *wordpressPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) { query := url.Values{} query.Set("per_page", "100") query.Set("hide_empty", "false") items := make([]EnterpriseSiteCategoryItem, 0) rawItems := make([]map[string]any, 0) for page := 1; page <= 50; page++ { query.Set("page", strconv.Itoa(page)) var categories []wordpressCategory resp, err := p.requestWithResponse(ctx, http.MethodGet, credential, "wp/v2/categories", query, nil, &categories) if err != nil { return nil, nil, err } for _, category := range categories { if category.ID <= 0 || strings.TrimSpace(category.Name) == "" { continue } raw := map[string]any{ "id": category.ID, "count": category.Count, "name": category.Name, "slug": category.Slug, "parent": category.Parent, "link": category.Link, } parentRemoteID := wordpressParentCategoryID(category.Parent) slug := normalizeStringPointer(&category.Slug) items = append(items, EnterpriseSiteCategoryItem{ RemoteID: strconv.FormatInt(category.ID, 10), ParentRemoteID: parentRemoteID, Name: strings.TrimSpace(category.Name), Slug: slug, Raw: raw, }) rawItems = append(rawItems, raw) } totalPages := wordpressHeaderInt(resp.Header, "X-WP-TotalPages") if totalPages <= 0 || page >= totalPages { break } } return items, rawItems, nil } func (p *wordpressPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) { categoryID := strings.TrimSpace(req.CategoryID) if categoryID == "" { categoryID = strings.TrimSpace(credential.DefaultScode) } categoryNumber, err := strconv.ParseInt(categoryID, 10, 64) if err != nil || categoryNumber <= 0 { return nil, nil, fmt.Errorf("invalid wordpress category id") } body := map[string]any{ "title": strings.TrimSpace(req.Title), "content": strings.TrimSpace(req.ContentHTML), "status": wordpressStatusFromPublishType(req.PublishType), "categories": []int64{categoryNumber}, } if description := strings.TrimSpace(req.Description); description != "" { body["excerpt"] = description } var payload wordpressPost if _, err := p.requestWithResponse(ctx, http.MethodPost, credential, "wp/v2/posts", nil, body, &payload); err != nil { return nil, nil, err } raw := map[string]any{ "id": payload.ID, "link": payload.Link, "status": payload.Status, } result := &cmsPublishResult{ RemoteID: strconv.FormatInt(payload.ID, 10), URL: strings.TrimSpace(payload.Link), Status: strings.TrimSpace(payload.Status), } if result.Status == "" { result.Status = "published" } return result, raw, nil } func (p *wordpressPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) error { _, err := p.requestWithResponse(ctx, method, credential, path, query, body, out) return err } func (p *wordpressPublisher) requestWithResponse(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) (*http.Response, error) { endpoint, err := wordpressEndpoint(credential.SiteURL, path) if err != nil { return nil, err } if strings.EqualFold(endpoint.Scheme, "http") && !isEnterpriseSiteLoopbackHost(endpoint.Hostname()) { return nil, response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS") } if query != nil { requestQuery := endpoint.Query() for key, values := range query { requestQuery.Del(key) for _, value := range values { requestQuery.Add(key, value) } } endpoint.RawQuery = requestQuery.Encode() } var reader io.Reader if body != nil { raw, marshalErr := json.Marshal(body) if marshalErr != nil { return nil, fmt.Errorf("marshal wordpress request: %w", marshalErr) } reader = bytes.NewReader(raw) } httpReq, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) if err != nil { return nil, fmt.Errorf("build wordpress request: %w", err) } httpReq.Header.Set("User-Agent", "GeoRankly-WordPress/1.0") httpReq.SetBasicAuth(strings.TrimSpace(credential.AppID), strings.TrimSpace(credential.Secret)) if body != nil { httpReq.Header.Set("Content-Type", "application/json") } resp, err := p.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("request wordpress %s: %w", path, err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) if err != nil { return resp, fmt.Errorf("read wordpress response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return resp, wordpressHTTPError(path, resp.StatusCode, raw) } if out == nil || len(raw) == 0 || string(raw) == "null" { return resp, nil } if err := json.Unmarshal(raw, out); err != nil { return resp, fmt.Errorf("parse wordpress response: %w", err) } return resp, nil } func wordpressEndpoint(siteURL string, path string) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(siteURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("invalid wordpress site url") } basePath := strings.TrimRight(parsed.Path, "/") cleanPath := strings.Trim(path, "/") restRoute := "/" if cleanPath != "" { restRoute += cleanPath } parsed.Path = basePath + "/index.php" query := url.Values{} query.Set("rest_route", restRoute) parsed.RawQuery = query.Encode() parsed.Fragment = "" return parsed, nil } func wordpressHTTPError(path string, status int, raw []byte) error { var payload wordpressErrorResponse if err := json.Unmarshal(raw, &payload); err == nil { code := strings.TrimSpace(payload.Code) message := strings.TrimSpace(payload.Message) if code != "" || message != "" { detail := message if code != "" && message != "" { detail = code + ": " + message } else if code != "" { detail = code } return &wordpressRequestError{ message: fmt.Sprintf("wordpress %s returned http %d: %s", path, status, detail), status: status, code: code, } } } return &wordpressRequestError{ message: fmt.Sprintf("wordpress %s returned http %d", path, status), status: status, } } func (err *wordpressRequestError) Status() int { if err == nil { return 0 } return err.status } func (err *wordpressRequestError) Code() string { if err == nil { return "" } return err.code } func wordpressHeaderInt(header http.Header, key string) int { value := strings.TrimSpace(header.Get(key)) if value == "" { return 0 } number, err := strconv.Atoi(value) if err != nil { return 0 } return number } func wordpressParentCategoryID(parent int64) *string { if parent <= 0 { return nil } value := strconv.FormatInt(parent, 10) return &value } func wordpressStatusFromPublishType(publishType string) string { if strings.TrimSpace(publishType) == "draft" { return "draft" } return "publish" }