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:
@@ -47,6 +47,7 @@ type Config struct {
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||
SecurityHeaders SecurityHeaders `mapstructure:"security_headers"`
|
||||
@@ -1193,6 +1194,9 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok {
|
||||
cfg.Server.AllowedOrigins = strings.Split(origins, ",")
|
||||
}
|
||||
if publicBaseURL, ok := lookupNonEmptyEnv("SERVER_PUBLIC_BASE_URL"); ok {
|
||||
cfg.Server.PublicBaseURL = publicBaseURL
|
||||
}
|
||||
if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok {
|
||||
cfg.Server.SecurityHeaders.Enabled = enabled
|
||||
}
|
||||
|
||||
@@ -110,7 +110,10 @@ const articlePublishStatusAggregateJoin = `
|
||||
BOOL_OR(latest.status_bucket = 'success') AS has_success,
|
||||
BOOL_OR(latest.status_bucket = 'failed') AS has_failed
|
||||
FROM (
|
||||
SELECT DISTINCT ON (pr.platform_account_id)
|
||||
SELECT DISTINCT ON (
|
||||
COALESCE(pr.target_type, 'platform_account'),
|
||||
COALESCE(pr.platform_account_id, pr.target_connection_id)
|
||||
)
|
||||
CASE
|
||||
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
|
||||
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
|
||||
@@ -119,7 +122,11 @@ const articlePublishStatusAggregateJoin = `
|
||||
FROM publish_records pr
|
||||
WHERE pr.tenant_id = a.tenant_id
|
||||
AND pr.article_id = a.id
|
||||
ORDER BY pr.platform_account_id, pr.created_at DESC, pr.id DESC
|
||||
ORDER BY
|
||||
COALESCE(pr.target_type, 'platform_account'),
|
||||
COALESCE(pr.platform_account_id, pr.target_connection_id),
|
||||
pr.created_at DESC,
|
||||
pr.id DESC
|
||||
) latest
|
||||
) publish_status_summary ON true`
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const enterpriseSiteCredentialPrefix = "v1:"
|
||||
|
||||
func encryptEnterpriseSiteCredential(secret string, keyMaterial string) (string, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("enterprise site credential is empty")
|
||||
}
|
||||
key := enterpriseSiteCredentialKey(keyMaterial)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create enterprise site credential cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create enterprise site credential gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("generate enterprise site credential nonce: %w", err)
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, []byte(secret), nil)
|
||||
packed := append(nonce, ciphertext...)
|
||||
return enterpriseSiteCredentialPrefix + base64.StdEncoding.EncodeToString(packed), nil
|
||||
}
|
||||
|
||||
func decryptEnterpriseSiteCredential(ciphertext string, keyMaterial string) (string, error) {
|
||||
ciphertext = strings.TrimSpace(ciphertext)
|
||||
if ciphertext == "" {
|
||||
return "", fmt.Errorf("enterprise site credential ciphertext is empty")
|
||||
}
|
||||
if !strings.HasPrefix(ciphertext, enterpriseSiteCredentialPrefix) {
|
||||
return "", fmt.Errorf("unsupported enterprise site credential format")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(ciphertext, enterpriseSiteCredentialPrefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode enterprise site credential: %w", err)
|
||||
}
|
||||
key := enterpriseSiteCredentialKey(keyMaterial)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create enterprise site credential cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create enterprise site credential gcm: %w", err)
|
||||
}
|
||||
if len(raw) <= aead.NonceSize() {
|
||||
return "", fmt.Errorf("enterprise site credential payload is invalid")
|
||||
}
|
||||
nonce := raw[:aead.NonceSize()]
|
||||
encrypted := raw[aead.NonceSize():]
|
||||
plaintext, err := aead.Open(nil, nonce, encrypted, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt enterprise site credential: %w", err)
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func enterpriseSiteCredentialKey(keyMaterial string) [32]byte {
|
||||
trimmed := strings.TrimSpace(keyMaterial)
|
||||
if trimmed == "" {
|
||||
trimmed = "geo-rankly-enterprise-site-development-key"
|
||||
}
|
||||
return sha256.Sum256([]byte("enterprise-site-credential:" + trimmed))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
|
||||
got := enterpriseSiteDescription(
|
||||
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
|
||||
"## 旧版标题\n旧版正文不应该优先进入摘要",
|
||||
)
|
||||
if strings.Contains(got, "##") {
|
||||
t.Fatalf("description contains markdown heading marker: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "旧版正文") {
|
||||
t.Fatalf("description used markdown while html text was available: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "2026年合肥全屋定制行业发展现状") {
|
||||
t.Fatalf("description = %q, want rendered html text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLRemovesDuplicateTitle(t *testing.T) {
|
||||
got := enterpriseSiteStripLeadingTitleHeadingHTML(
|
||||
"<h1>合肥全屋定制口碑怎么判断 看这里</h1><p>正文第一段。</p>",
|
||||
"合肥全屋定制口碑怎么判断 看这里",
|
||||
)
|
||||
if strings.Contains(got, "<h1>") || strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
|
||||
t.Fatalf("duplicate title heading was not removed: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "正文第一段") {
|
||||
t.Fatalf("body content was lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLKeepsDifferentHeading(t *testing.T) {
|
||||
input := "<h2>行业现状</h2><p>正文第一段。</p>"
|
||||
got := enterpriseSiteStripLeadingTitleHeadingHTML(input, "合肥全屋定制口碑怎么判断 看这里")
|
||||
if got != input {
|
||||
t.Fatalf("different leading heading changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesDuplicateTitle(t *testing.T) {
|
||||
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
|
||||
"# 合肥全屋定制口碑怎么判断 看这里\n\n正文第一段。",
|
||||
"合肥全屋定制口碑怎么判断 看这里",
|
||||
)
|
||||
if strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
|
||||
t.Fatalf("duplicate markdown title was not removed: %q", got)
|
||||
}
|
||||
if got != "正文第一段。" {
|
||||
t.Fatalf("markdown body = %q, want body only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesPlainTitle(t *testing.T) {
|
||||
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
|
||||
"装修预算不够?2026安徽6款高性价比家居整理\n\n近年安徽本地全屋定制需求持续上涨。",
|
||||
"装修预算不够?2026安徽6款高性价比家居整理",
|
||||
)
|
||||
if strings.Contains(got, "装修预算不够?2026安徽6款高性价比家居整理") {
|
||||
t.Fatalf("plain duplicate title was not removed: %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "近年安徽本地全屋定制需求持续上涨") {
|
||||
t.Fatalf("markdown body = %q, want body only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteDescriptionAfterTitleStripSkipsDuplicateTitle(t *testing.T) {
|
||||
title := "合肥全屋定制口碑怎么判断 看这里"
|
||||
html := enterpriseSiteStripLeadingTitleHeadingHTML("<h1>"+title+"</h1><p>正文第一段。</p>", title)
|
||||
markdown := enterpriseSiteStripLeadingTitleHeadingMarkdown("# "+title+"\n\n正文第一段。", title)
|
||||
got := enterpriseSiteDescription(html, markdown)
|
||||
if strings.Contains(got, title) {
|
||||
t.Fatalf("description still contains duplicate title: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "正文第一段") {
|
||||
t.Fatalf("description = %q, want body text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteDescriptionFallsBackToCleanMarkdown(t *testing.T) {
|
||||
got := enterpriseSiteDescription("", "## 2026年合肥全屋定制行业发展现状\n- 消费者需求升级。")
|
||||
if strings.Contains(got, "##") || strings.Contains(got, "- ") {
|
||||
t.Fatalf("description was not cleaned: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "消费者需求升级") {
|
||||
t.Fatalf("description = %q, want markdown text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteNormalizeStoredContentRestoresDiffPatch(t *testing.T) {
|
||||
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
|
||||
dmp := diffmatchpatch.New()
|
||||
patch := dmp.PatchToText(dmp.PatchMake("", source))
|
||||
|
||||
got := enterpriseSiteNormalizeStoredContent(patch)
|
||||
|
||||
if got != source {
|
||||
t.Fatalf("restored content = %q, want %q", got, source)
|
||||
}
|
||||
if strings.Contains(got, "@@ -") {
|
||||
t.Fatalf("diff patch leaked into normalized content: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteNormalizeStoredContentRepairsMalformedEditorImage(t *testing.T) {
|
||||
got := enterpriseSiteNormalizeStoredContent(
|
||||
`<p class="article-editor-image article-editor-image--center" align="center" <img src="/api/public/assets/token" alt="" /></p>`,
|
||||
)
|
||||
if !strings.Contains(got, `align="center"><img`) {
|
||||
t.Fatalf("malformed editor image was not repaired: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteHTMLContentPatchFallsBackToMarkdownRendering(t *testing.T) {
|
||||
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
|
||||
dmp := diffmatchpatch.New()
|
||||
htmlContent := enterpriseSiteNormalizeStoredContent(dmp.PatchToText(dmp.PatchMake("", source)))
|
||||
markdownContent := ""
|
||||
if htmlContent != "" && !enterpriseSiteContentLooksLikeHTML(htmlContent) {
|
||||
markdownContent = htmlContent
|
||||
htmlContent = ""
|
||||
}
|
||||
|
||||
got, err := markdownToEnterpriseSiteHTML(markdownContent)
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(got, "@@ -") || strings.Contains(got, "%E5") {
|
||||
t.Fatalf("rendered HTML still contains patch/url-encoded content: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<h1>2026安徽全屋定制行业</h1>") || !strings.Contains(got, "<p>正文第一段。</p>") {
|
||||
t.Fatalf("rendered HTML = %q, want UEditor-ready HTML", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteAbsoluteImageURLsRewritesRelativeAssetPaths(t *testing.T) {
|
||||
got := enterpriseSiteAbsoluteImageURLs(
|
||||
`<p><img src="/api/public/assets/token" data-src="/api/public/assets/token" /></p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
if strings.Count(got, "http://127.0.0.1:8080/api/public/assets/token") != 2 {
|
||||
t.Fatalf("image urls were not absolutized: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImages(t *testing.T) {
|
||||
objectKey := "tenants/7/images/body.png"
|
||||
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "test-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(
|
||||
context.Background(),
|
||||
7,
|
||||
`<p><img src="/api/public/assets/`+token+`" data-src="/api/public/assets/`+token+`" /></p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if storage.gotKey != objectKey {
|
||||
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
||||
}
|
||||
if strings.Count(got, `data:image/png;base64,`) != 2 {
|
||||
t.Fatalf("image was not embedded in both image attrs: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "/api/public/assets/") {
|
||||
t.Fatalf("public asset url leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImagesWithStaleSignature(t *testing.T) {
|
||||
objectKey := "tenants/7/images/body.png"
|
||||
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "new-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(
|
||||
context.Background(),
|
||||
7,
|
||||
`<p><img src="http://localhost:5178/api/public/assets/`+token+`" /></p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if storage.gotKey != objectKey {
|
||||
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
||||
}
|
||||
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
||||
t.Fatalf("stale signed image was not embedded: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
|
||||
t.Fatalf("saas asset url leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteImageURLsStripsSaaSImageAttrs(t *testing.T) {
|
||||
objectKey := "tenants/7/images/body.png"
|
||||
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "test-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(
|
||||
context.Background(),
|
||||
7,
|
||||
`<p><img src="/api/public/assets/`+token+`" alt="" data-asset-id="35" asset-id="35" /></p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
||||
t.Fatalf("image was not embedded: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "data-asset-id") || strings.Contains(got, "asset-id") {
|
||||
t.Fatalf("saas image attrs leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteImageURLsDropsUnavailableSaaSAssetImage(t *testing.T) {
|
||||
objectKey := "tenants/7/images/deleted.webp"
|
||||
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "new-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(
|
||||
context.Background(),
|
||||
7,
|
||||
`<p>前文</p><p class="article-editor-image article-editor-image--center" align="center"><img src="http://localhost:5178/api/public/assets/`+token+`" alt="" /></p><p>后文</p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
|
||||
t.Fatalf("unavailable saas asset url leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "<img") || strings.Contains(got, "article-editor-image") {
|
||||
t.Fatalf("unavailable image wrapper was not removed: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "前文") || !strings.Contains(got, "后文") {
|
||||
t.Fatalf("surrounding content was lost: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteImageURLsEmbedsIncompletePublicAssetPaths(t *testing.T) {
|
||||
objectKey := "tenants/7/articles/42/images/body.webp"
|
||||
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "test-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(
|
||||
context.Background(),
|
||||
7,
|
||||
`<p><img src="api/public/assets/`+token+`?format=png" /></p>`,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if storage.gotKey != objectKey {
|
||||
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
||||
}
|
||||
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
||||
t.Fatalf("image was not embedded for incomplete asset path: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
|
||||
t.Fatalf("incomplete asset path leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRenderedEnterpriseSiteImageEmbedsIncompletePublicAssetPath(t *testing.T) {
|
||||
objectKey := "tenants/7/articles/42/images/body.webp"
|
||||
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "test-secret"},
|
||||
})).WithObjectStorage(storage)
|
||||
html, err := markdownToEnterpriseSiteHTML("正文\n\n")
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
|
||||
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, html, "http://127.0.0.1:8080")
|
||||
|
||||
if storage.gotKey != objectKey {
|
||||
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
||||
}
|
||||
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
||||
t.Fatalf("markdown image was not embedded for incomplete asset path: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
|
||||
t.Fatalf("markdown image asset path leaked into enterprise site payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSitePublishAssetsEmbedsIncompleteCoverAssetPath(t *testing.T) {
|
||||
objectKey := "tenants/7/articles/42/images/cover.webp"
|
||||
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "test-secret"},
|
||||
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteCoverURL(
|
||||
context.Background(),
|
||||
7,
|
||||
"api/public/assets/"+token+"?format=png",
|
||||
nil,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if storage.gotKey != objectKey {
|
||||
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
||||
}
|
||||
if !strings.HasPrefix(got, "data:image/png;base64,") {
|
||||
t.Fatalf("cover was not embedded as data URI: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEnterpriseSiteCoverURLDropsUnavailableSaaSAssetURL(t *testing.T) {
|
||||
objectKey := "tenants/7/images/deleted.webp"
|
||||
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
||||
storage := &enterpriseSiteImageObjectStorage{}
|
||||
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
||||
JWT: config.JWTConfig{Secret: "new-secret"},
|
||||
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
|
||||
})).WithObjectStorage(storage)
|
||||
|
||||
got := svc.prepareEnterpriseSiteCoverURL(
|
||||
context.Background(),
|
||||
7,
|
||||
"http://localhost:5178/api/public/assets/"+token,
|
||||
nil,
|
||||
"http://127.0.0.1:8080",
|
||||
)
|
||||
|
||||
if got != "" {
|
||||
t.Fatalf("unavailable saas cover should not be sent to pbootcms, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteNormalizeLegacyPublishErrorState(t *testing.T) {
|
||||
errorMessage := "图片下载失败"
|
||||
item := EnterpriseSiteConnectionResponse{
|
||||
Status: "error",
|
||||
LastError: &errorMessage,
|
||||
LatestRecord: &EnterpriseSiteLatestPublishRecord{
|
||||
Status: "failed",
|
||||
ErrorMessage: &errorMessage,
|
||||
},
|
||||
}
|
||||
|
||||
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
|
||||
|
||||
if item.Status != "connected" {
|
||||
t.Fatalf("status = %q, want connected", item.Status)
|
||||
}
|
||||
if item.LastError != nil {
|
||||
t.Fatalf("last error = %q, want nil", *item.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing.T) {
|
||||
lastError := "CMS request failed"
|
||||
recordError := "图片下载失败"
|
||||
item := EnterpriseSiteConnectionResponse{
|
||||
Status: "error",
|
||||
LastError: &lastError,
|
||||
LatestRecord: &EnterpriseSiteLatestPublishRecord{
|
||||
Status: "failed",
|
||||
ErrorMessage: &recordError,
|
||||
},
|
||||
}
|
||||
|
||||
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
|
||||
|
||||
if item.Status != "error" {
|
||||
t.Fatalf("status = %q, want error", item.Status)
|
||||
}
|
||||
if item.LastError == nil || *item.LastError != lastError {
|
||||
t.Fatalf("last error changed: %#v", item.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
type enterpriseSiteImageObjectStorage struct {
|
||||
content []byte
|
||||
gotKey string
|
||||
}
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) Validate() error { return nil }
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
||||
s.gotKey = objectKey
|
||||
return s.content, nil
|
||||
}
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) Exists(context.Context, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) Delete(context.Context, string) error { return nil }
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
|
||||
|
||||
func (s *enterpriseSiteImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
|
||||
|
||||
func testEnterpriseSitePNG() []byte {
|
||||
return []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,10 @@ type PublishRecordResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PublishBatchID int64 `json:"publish_batch_id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
PlatformAccountID int64 `json:"platform_account_id"`
|
||||
PlatformAccountID *int64 `json:"platform_account_id"`
|
||||
DesktopAccountID *string `json:"desktop_account_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetConnectionID *int64 `json:"target_connection_id"`
|
||||
PlatformID string `json:"platform_id"`
|
||||
PlatformName string `json:"platform_name"`
|
||||
PlatformNickname string `json:"platform_nickname"`
|
||||
@@ -98,12 +100,22 @@ func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID
|
||||
|
||||
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pa.desktop_id::text, pr.platform_id,
|
||||
mp.name, pa.nickname, pr.status, pr.external_article_id, pr.external_article_url,
|
||||
SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pa.desktop_id::text,
|
||||
COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
|
||||
COALESCE(mp.name, esc.site_name, esc.name, pr.platform_id) AS platform_name,
|
||||
CASE
|
||||
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
|
||||
THEN COALESCE(esc.site_name, esc.name, '企业站点')
|
||||
ELSE COALESCE(pa.nickname, '')
|
||||
END AS platform_nickname,
|
||||
pr.status, pr.external_article_id, pr.external_article_url,
|
||||
pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at
|
||||
FROM publish_records pr
|
||||
JOIN media_platforms mp ON mp.platform_id = pr.platform_id
|
||||
JOIN platform_accounts pa ON pa.id = pr.platform_account_id
|
||||
LEFT JOIN media_platforms mp ON mp.platform_id = pr.platform_id
|
||||
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
|
||||
LEFT JOIN enterprise_site_connections esc
|
||||
ON esc.id = pr.target_connection_id
|
||||
AND esc.tenant_id = pr.tenant_id
|
||||
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
|
||||
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
|
||||
ORDER BY pr.created_at DESC, pr.id DESC
|
||||
@@ -116,13 +128,15 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
|
||||
items := make([]PublishRecordResponse, 0)
|
||||
for rows.Next() {
|
||||
var item PublishRecordResponse
|
||||
var desktopAccountID string
|
||||
var desktopAccountID *string
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.PublishBatchID,
|
||||
&item.ArticleID,
|
||||
&item.PlatformAccountID,
|
||||
&desktopAccountID,
|
||||
&item.TargetType,
|
||||
&item.TargetConnectionID,
|
||||
&item.PlatformID,
|
||||
&item.PlatformName,
|
||||
&item.PlatformNickname,
|
||||
@@ -137,8 +151,8 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
|
||||
); err != nil {
|
||||
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
|
||||
}
|
||||
if strings.TrimSpace(desktopAccountID) != "" {
|
||||
item.DesktopAccountID = &desktopAccountID
|
||||
if desktopAccountID != nil && strings.TrimSpace(*desktopAccountID) != "" {
|
||||
item.DesktopAccountID = desktopAccountID
|
||||
}
|
||||
normalizePublishRecordForResponse(&item)
|
||||
items = append(items, item)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
|
||||
type EnterpriseSiteHandler struct {
|
||||
svc *app.EnterpriseSiteService
|
||||
}
|
||||
|
||||
func NewEnterpriseSiteHandler(a *bootstrap.App) *EnterpriseSiteHandler {
|
||||
return &EnterpriseSiteHandler{
|
||||
svc: app.NewEnterpriseSiteService(a.DB, a.ConfigStore).
|
||||
WithCache(a.Cache).
|
||||
WithObjectStorage(a.ObjectStorage),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) List(c *gin.Context) {
|
||||
data, err := h.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) Create(c *gin.Context) {
|
||||
var req app.CreateEnterpriseSiteConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, 201, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) Update(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req app.UpdateEnterpriseSiteConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) Delete(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) Ping(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Ping(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) Categories(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Categories(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) SyncCategories(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.svc.SyncCategories(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *EnterpriseSiteHandler) PublishArticle(c *gin.Context) {
|
||||
id, ok := enterpriseSiteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req app.PublishEnterpriseSiteArticleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.PublishArticle(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func enterpriseSiteIDParam(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "enterprise site id must be a positive number"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -104,6 +104,17 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
media := tenantProtected.Group("/media")
|
||||
media.GET("/platforms", mediaHandler.ListPlatforms)
|
||||
|
||||
enterpriseSiteHandler := NewEnterpriseSiteHandler(app)
|
||||
enterpriseSites := tenantProtected.Group("/enterprise-sites")
|
||||
enterpriseSites.GET("", enterpriseSiteHandler.List)
|
||||
enterpriseSites.POST("", enterpriseSiteHandler.Create)
|
||||
enterpriseSites.PATCH("/:id", enterpriseSiteHandler.Update)
|
||||
enterpriseSites.DELETE("/:id", enterpriseSiteHandler.Delete)
|
||||
enterpriseSites.POST("/:id/ping", enterpriseSiteHandler.Ping)
|
||||
enterpriseSites.GET("/:id/categories", enterpriseSiteHandler.Categories)
|
||||
enterpriseSites.POST("/:id/categories/sync", enterpriseSiteHandler.SyncCategories)
|
||||
enterpriseSites.POST("/:id/publish", middleware.RequireCurrentBrand(), enterpriseSiteHandler.PublishArticle)
|
||||
|
||||
desktopClient := tenantProtected.Group("/desktop-client")
|
||||
desktopClient.GET("/releases/downloadable", desktopReleaseHandler.ListDownloadable)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
DROP INDEX IF EXISTS idx_publish_records_enterprise_site_active;
|
||||
DROP INDEX IF EXISTS uk_publish_records_batch_enterprise_site;
|
||||
|
||||
DELETE FROM publish_records
|
||||
WHERE target_type = 'enterprise_site';
|
||||
|
||||
ALTER TABLE publish_records
|
||||
DROP CONSTRAINT IF EXISTS ck_publish_records_target_ref,
|
||||
DROP CONSTRAINT IF EXISTS ck_publish_records_target_type,
|
||||
DROP COLUMN IF EXISTS target_connection_id,
|
||||
DROP COLUMN IF EXISTS target_type;
|
||||
|
||||
ALTER TABLE publish_records
|
||||
ALTER COLUMN platform_account_id SET NOT NULL;
|
||||
|
||||
DELETE FROM media_platforms
|
||||
WHERE platform_id = 'pbootcms';
|
||||
|
||||
DROP TABLE IF EXISTS enterprise_site_categories;
|
||||
DROP TABLE IF EXISTS enterprise_site_connections;
|
||||
@@ -0,0 +1,96 @@
|
||||
CREATE TABLE enterprise_site_connections (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||||
created_by_user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(160) NOT NULL,
|
||||
site_url TEXT NOT NULL,
|
||||
cms_type VARCHAR(32) NOT NULL,
|
||||
auth_type VARCHAR(32) NOT NULL DEFAULT 'native_api',
|
||||
appid VARCHAR(160) NOT NULL,
|
||||
credential_ciphertext TEXT NOT NULL,
|
||||
default_acode VARCHAR(32),
|
||||
default_mcode VARCHAR(32),
|
||||
default_scode VARCHAR(64),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
plugin_version VARCHAR(40),
|
||||
site_name VARCHAR(160),
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
last_publish_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT ck_enterprise_site_connections_cms_type
|
||||
CHECK (cms_type IN ('pbootcms', 'wordpress', 'dedecms', 'phpcms')),
|
||||
CONSTRAINT ck_enterprise_site_connections_status
|
||||
CHECK (status IN ('draft', 'connected', 'error', 'disabled'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_enterprise_site_connections_active_site
|
||||
ON enterprise_site_connections (tenant_id, workspace_id, lower(site_url), cms_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_enterprise_site_connections_tenant_workspace
|
||||
ON enterprise_site_connections (tenant_id, workspace_id, status, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE enterprise_site_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||||
connection_id BIGINT NOT NULL REFERENCES enterprise_site_connections(id) ON DELETE CASCADE,
|
||||
remote_id VARCHAR(128) NOT NULL,
|
||||
parent_remote_id VARCHAR(128),
|
||||
name VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(160),
|
||||
raw_payload_json JSONB,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uk_enterprise_site_categories_remote
|
||||
UNIQUE (connection_id, remote_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_enterprise_site_categories_connection
|
||||
ON enterprise_site_categories (connection_id, remote_id);
|
||||
|
||||
ALTER TABLE publish_records
|
||||
ALTER COLUMN platform_account_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE publish_records
|
||||
ADD COLUMN target_type VARCHAR(32) NOT NULL DEFAULT 'platform_account',
|
||||
ADD COLUMN target_connection_id BIGINT REFERENCES enterprise_site_connections(id);
|
||||
|
||||
ALTER TABLE publish_records
|
||||
ADD CONSTRAINT ck_publish_records_target_type
|
||||
CHECK (target_type IN ('platform_account', 'enterprise_site'));
|
||||
|
||||
ALTER TABLE publish_records
|
||||
ADD CONSTRAINT ck_publish_records_target_ref
|
||||
CHECK (
|
||||
(target_type = 'platform_account' AND platform_account_id IS NOT NULL AND target_connection_id IS NULL)
|
||||
OR
|
||||
(target_type = 'enterprise_site' AND platform_account_id IS NULL AND target_connection_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_publish_records_batch_enterprise_site
|
||||
ON publish_records (publish_batch_id, target_connection_id)
|
||||
WHERE target_type = 'enterprise_site';
|
||||
|
||||
CREATE INDEX idx_publish_records_enterprise_site_active
|
||||
ON publish_records (tenant_id, article_id, target_connection_id, updated_at DESC, id DESC)
|
||||
WHERE target_type = 'enterprise_site';
|
||||
|
||||
INSERT INTO media_platforms (platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order)
|
||||
VALUES ('pbootcms', 'PBootCMS', 'enterprise', 'P', '#1d4ed8', NULL, NULL, 'active', 900)
|
||||
ON CONFLICT (platform_id)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
category = EXCLUDED.category,
|
||||
short_name = EXCLUDED.short_name,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
status = EXCLUDED.status,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW();
|
||||
Reference in New Issue
Block a user