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>
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user