feat(enterprise-site): add PbootCMS enterprise site publisher
Introduce a new enterprise-site publishing channel that lets tenants push articles to self-hosted PbootCMS sites alongside the existing media supply flow. Backend (server/internal/tenant): - enterprise_site_service: CRUD, ping, category sync, and article publish - enterprise_site_pbootcms: PbootCMS API client integration - enterprise_site_crypto: encrypt/decrypt stored site credentials - enterprise_site_handler + routes under /enterprise-sites - migrations for the enterprise site publisher tables - config: add SERVER_PUBLIC_BASE_URL (Server.PublicBaseURL) for callbacks - article/media services adjusted to support the publish flow Frontend (apps/admin-web): - PublishArticleModal & ArticlePublishStatus: enterprise-site publish UI - MediaView: manage enterprise sites and categories - api + shared-types: enterprise site endpoints and types - http-client: add PATCH method support Integrations: - pbootcms GeoPublisher controller plugin + install guide - docs/enterprise-site-publisher-v1.md design doc
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (err *pbootCMSRequestError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user