feat(enterprise-site): add PbootCMS enterprise site publisher
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Failing after 6m43s

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:
2026-06-06 13:06:14 +08:00
parent b40434018a
commit 88c37e50b2
20 changed files with 7708 additions and 528 deletions
+4
View File
@@ -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![body](api/public/assets/" + token + "?format=png)")
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,
}
}
+22 -8
View File
@@ -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();