c7bad83496
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>
335 lines
9.4 KiB
Go
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"
|
|
}
|