Files
geo/server/internal/tenant/app/enterprise_site_pbootcms.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

427 lines
12 KiB
Go

package app
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const pbootCMSPlatformID = "pbootcms"
type cmsPublisher interface {
Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error)
Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error)
Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error)
}
type pbootCMSPublisher struct {
client *http.Client
}
type enterpriseSiteCredential struct {
SiteURL string
AppID string
Secret string
DefaultAcode string
DefaultMcode string
DefaultScode string
}
type cmsPublishRequest struct {
ArticleID int64
Title string
ContentHTML string
CategoryID string
Keywords string
Description string
Author string
Source string
CoverURL string
PublishType string
}
type cmsPublishResult struct {
RemoteID string
URL string
Status string
}
type pbootCMSResponse struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
type pbootCMSRequestError struct {
message string
routeUnavailable bool
}
type unsupportedCMSPublisher struct {
cmsType string
}
func (err *pbootCMSRequestError) Error() string {
return err.message
}
func (p unsupportedCMSPublisher) Ping(context.Context, enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) Categories(context.Context, enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) Publish(context.Context, enterpriseSiteCredential, cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) err() error {
cmsType := strings.TrimSpace(p.cmsType)
if cmsType == "" {
cmsType = "CMS"
}
return fmt.Errorf("unsupported cms type: %s", cmsType)
}
func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
return &pbootCMSPublisher{client: client}
}
func (p *pbootCMSPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
var payload map[string]any
if err := p.request(ctx, http.MethodGet, credential, "ping", nil, nil, &payload); err != nil {
return nil, nil, err
}
capability := &EnterpriseSiteCapability{
CMSType: strings.TrimSpace(stringFromMap(payload, "cms_type")),
PluginVersion: stringPointerFromMap(payload, "plugin_version"),
SiteURL: strings.TrimSpace(stringFromMap(payload, "site_url")),
SiteName: stringPointerFromMap(payload, "site_name"),
APIAuth: boolFromMap(payload, "api_auth"),
}
if capability.CMSType == "" {
capability.CMSType = pbootCMSPlatformID
}
if capability.SiteURL == "" {
capability.SiteURL = credential.SiteURL
}
return capability, payload, nil
}
func (p *pbootCMSPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
query := url.Values{}
if strings.TrimSpace(credential.DefaultAcode) != "" {
query.Set("acode", strings.TrimSpace(credential.DefaultAcode))
}
if strings.TrimSpace(credential.DefaultMcode) != "" {
query.Set("mcode", strings.TrimSpace(credential.DefaultMcode))
}
var rawItems []map[string]any
if err := p.request(ctx, http.MethodGet, credential, "categories", query, nil, &rawItems); err != nil {
return nil, nil, err
}
items := make([]EnterpriseSiteCategoryItem, 0, len(rawItems))
for _, raw := range rawItems {
id := firstStringFromMap(raw, "id", "scode", "remote_id")
name := firstStringFromMap(raw, "title", "name")
if strings.TrimSpace(id) == "" || strings.TrimSpace(name) == "" {
continue
}
items = append(items, EnterpriseSiteCategoryItem{
RemoteID: strings.TrimSpace(id),
ParentRemoteID: pbootCMSOptionalString(firstStringFromMap(raw, "parent_id", "pcode")),
Name: strings.TrimSpace(name),
Slug: pbootCMSOptionalString(firstStringFromMap(raw, "slug", "filename")),
Raw: raw,
})
}
return items, rawItems, nil
}
func (p *pbootCMSPublisher) 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)
}
body := map[string]any{
"scode": categoryID,
"title": strings.TrimSpace(req.Title),
"content": strings.TrimSpace(req.ContentHTML),
"keywords": strings.TrimSpace(req.Keywords),
"description": strings.TrimSpace(req.Description),
"author": strings.TrimSpace(req.Author),
"source": strings.TrimSpace(req.Source),
"status": pbootCMSStatusFromPublishType(req.PublishType),
}
if strings.TrimSpace(req.CoverURL) != "" {
body["ico"] = strings.TrimSpace(req.CoverURL)
}
if strings.TrimSpace(credential.DefaultAcode) != "" {
body["acode"] = strings.TrimSpace(credential.DefaultAcode)
}
var payload map[string]any
if err := p.request(ctx, http.MethodPost, credential, "publish", nil, body, &payload); err != nil {
return nil, nil, err
}
remoteID := firstStringFromMap(payload, "id", "remote_id")
result := &cmsPublishResult{
RemoteID: strings.TrimSpace(remoteID),
URL: strings.TrimSpace(firstStringFromMap(payload, "url", "external_article_url")),
Status: strings.TrimSpace(firstStringFromMap(payload, "status")),
}
if result.Status == "" {
result.Status = "published"
}
return result, payload, nil
}
func (p *pbootCMSPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
endpoints, err := pbootCMSEndpoints(credential.SiteURL, action)
if err != nil {
return err
}
var lastErr error
for _, endpoint := range endpoints {
if err := p.requestEndpoint(ctx, method, endpoint, credential, action, query, body, out); err != nil {
lastErr = err
if !pbootCMSRouteUnavailable(err) {
break
}
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("pbootcms %s endpoint is unavailable", action)
}
func (p *pbootCMSPublisher) requestEndpoint(ctx context.Context, method string, endpoint *url.URL, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
requestEndpoint := *endpoint
requestQuery := requestEndpoint.Query()
for key, values := range query {
requestQuery.Del(key)
for _, value := range values {
requestQuery.Add(key, value)
}
}
p.signQuery(credential, requestQuery)
requestEndpoint.RawQuery = requestQuery.Encode()
var reader io.Reader
if body != nil {
raw, marshalErr := json.Marshal(body)
if marshalErr != nil {
return fmt.Errorf("marshal pbootcms request: %w", marshalErr)
}
reader = bytes.NewReader(raw)
}
httpReq, err := http.NewRequestWithContext(ctx, method, requestEndpoint.String(), reader)
if err != nil {
return fmt.Errorf("build pbootcms request: %w", err)
}
httpReq.Header.Set("User-Agent", "GeoRankly-PBootCMS/1.0")
if body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
resp, err := p.client.Do(httpReq)
if err != nil {
return fmt.Errorf("request pbootcms %s: %w", action, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return fmt.Errorf("read pbootcms response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &pbootCMSRequestError{
message: fmt.Sprintf("pbootcms %s returned http %d", action, resp.StatusCode),
routeUnavailable: resp.StatusCode == http.StatusNotFound,
}
}
var envelope pbootCMSResponse
if err := json.Unmarshal(raw, &envelope); err != nil {
rawText := strings.TrimSpace(string(raw))
return &pbootCMSRequestError{
message: fmt.Sprintf("parse pbootcms response: %v", err),
routeUnavailable: pbootCMSLooksLikeMissingRoute(rawText),
}
}
if envelope.Code != 1 {
msg := strings.TrimSpace(envelope.Msg)
if msg == "" {
msg = strings.TrimSpace(string(envelope.Data))
}
if msg == "" {
msg = fmt.Sprintf("pbootcms %s failed with code %d", action, envelope.Code)
}
return &pbootCMSRequestError{
message: msg,
routeUnavailable: strings.Contains(msg, "页面类文件不存在") || strings.Contains(strings.ToLower(msg), "not found"),
}
}
if out == nil {
return nil
}
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("parse pbootcms data: %w", err)
}
return nil
}
func (p *pbootCMSPublisher) signQuery(credential enterpriseSiteCredential, query url.Values) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
appid := strings.TrimSpace(credential.AppID)
secret := strings.TrimSpace(credential.Secret)
query.Set("appid", appid)
query.Set("timestamp", timestamp)
query.Set("signature", pbootCMSSignature(appid, secret, timestamp))
}
func pbootCMSEndpoints(siteURL string, action string) ([]*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid pbootcms site url")
}
basePath := strings.TrimRight(parsed.Path, "/")
cleanAction := strings.TrimLeft(action, "/")
pretty := *parsed
pretty.Path = basePath + "/api/GeoPublisher/" + cleanAction
pretty.RawQuery = ""
pretty.Fragment = ""
queryEntry := *parsed
queryEntry.Path = basePath + "/api.php"
query := url.Values{}
query.Set("p", "/GeoPublisher/"+cleanAction)
queryEntry.RawQuery = query.Encode()
queryEntry.Fragment = ""
return []*url.URL{&pretty, &queryEntry}, nil
}
func pbootCMSSignature(appid, secret, timestamp string) string {
first := md5.Sum([]byte(appid + secret + timestamp))
firstHex := hex.EncodeToString(first[:])
second := md5.Sum([]byte(firstHex))
return hex.EncodeToString(second[:])
}
func pbootCMSStatusFromPublishType(publishType string) int {
if strings.TrimSpace(publishType) == "draft" {
return 0
}
return 1
}
func pbootCMSRouteUnavailable(err error) bool {
requestErr, ok := err.(*pbootCMSRequestError)
return ok && requestErr.routeUnavailable
}
func pbootCMSLooksLikeMissingRoute(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
return strings.Contains(text, "页面类文件不存在") ||
strings.Contains(text, "页面不存在") ||
strings.Contains(text, "404") ||
strings.Contains(text, "not found")
}
func firstStringFromMap(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringFromMap(payload, key); strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func stringFromMap(payload map[string]any, key string) string {
if payload == nil {
return ""
}
value, ok := payload[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", typed)
}
}
func stringPointerFromMap(payload map[string]any, key string) *string {
if value := strings.TrimSpace(stringFromMap(payload, key)); value != "" {
return &value
}
return nil
}
func boolFromMap(payload map[string]any, key string) bool {
if payload == nil {
return false
}
switch value := payload[key].(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1"
case float64:
return value != 0
default:
return false
}
}
func pbootCMSOptionalString(value string) *string {
value = strings.TrimSpace(value)
if value == "" || value == "0" {
return nil
}
return &value
}