Files
geo/server/internal/tenant/app/enterprise_site_wordpress.go
T
root c7bad83496
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s
feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Add WordPress as an enterprise-site CMS type alongside pbootcms, and let
schedule tasks auto-publish generated articles to enterprise sites.

- WordPress connection/publisher service and tests; register wordpress
  media platform and relax cms_type CHECK constraint
- New publish_enterprise_site_targets JSONB column on schedule_tasks with
  array type constraint; thread targets through scheduler dispatch,
  prompt/KOL generation, and worker
- Admin UI: enterprise-site targets in generate/schedule/publish flows,
  KOL package form, MediaView, and i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:44:24 +08:00

335 lines
9.4 KiB
Go

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"
}