Files
geo/server/internal/tenant/app/enterprise_site_service.go
T
root 6e0519a232
Frontend CI / Frontend (push) Successful in 3m23s
Backend CI / Backend (push) Failing after 6m46s
feat: add brand asset cleanup worker and related functionality
- Implemented a new BrandAssetCleanupWorker to handle the cleanup of brand-related assets after a brand is deleted.
- Added SQL queries for cleaning up articles, keywords, questions, competitors, and monitoring data associated with a brand.
- Introduced a new API endpoint to delete publish records.
- Updated the router to include the new delete publish record endpoint.
- Added tests for the BrandAssetCleanupWorker to ensure proper functionality.
- Created migration scripts to support soft deletion of publish records and to add the brand asset cleanup scheduler job.
2026-06-19 11:45:13 +08:00

2685 lines
88 KiB
Go

package app
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
goldhtml "github.com/yuin/goldmark/renderer/html"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type EnterpriseSiteService struct {
pool *pgxpool.Pool
cfg config.Provider
httpClient *http.Client
cache sharedcache.Cache
compliance *tenantcompliance.Service
storage objectstorage.Client
}
func NewEnterpriseSiteService(pool *pgxpool.Pool, cfg config.Provider) *EnterpriseSiteService {
return &EnterpriseSiteService{
pool: pool,
cfg: cfg,
httpClient: newEnterpriseSiteHTTPClient(cfg),
compliance: tenantcompliance.NewService(pool, cfg, nil),
}
}
func (s *EnterpriseSiteService) WithCache(c sharedcache.Cache) *EnterpriseSiteService {
s.cache = c
return s
}
func (s *EnterpriseSiteService) WithObjectStorage(storage objectstorage.Client) *EnterpriseSiteService {
s.storage = storage
return s
}
type EnterpriseSiteConnectionResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
SiteURL string `json:"site_url"`
CMSType string `json:"cms_type"`
AuthType string `json:"auth_type"`
AppID string `json:"appid"`
DefaultAcode *string `json:"default_acode"`
DefaultMcode *string `json:"default_mcode"`
DefaultScode *string `json:"default_scode"`
Status string `json:"status"`
PluginVersion *string `json:"plugin_version"`
SiteName *string `json:"site_name"`
LastPingAt *time.Time `json:"last_ping_at"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastPublishAt *time.Time `json:"last_publish_at"`
LastError *string `json:"last_error"`
CategoryCount int `json:"category_count"`
LatestRecord *EnterpriseSiteLatestPublishRecord `json:"latest_record,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type EnterpriseSiteLatestPublishRecord struct {
ID int64 `json:"id"`
ArticleID int64 `json:"article_id"`
ArticleTitle *string `json:"article_title"`
Status string `json:"status"`
ExternalArticleID *string `json:"external_article_id"`
ExternalArticleURL *string `json:"external_article_url"`
ErrorMessage *string `json:"error_message"`
PublishedAt *time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type EnterpriseSiteCategoryResponse struct {
ID int64 `json:"id"`
RemoteID string `json:"remote_id"`
ParentRemoteID *string `json:"parent_remote_id"`
Name string `json:"name"`
Slug *string `json:"slug"`
Raw map[string]any `json:"raw,omitempty"`
SyncedAt time.Time `json:"synced_at"`
}
type EnterpriseSiteCategoryItem struct {
RemoteID string
ParentRemoteID *string
Name string
Slug *string
Raw map[string]any
}
type EnterpriseSiteCapability struct {
CMSType string `json:"cms_type"`
PluginVersion *string `json:"plugin_version"`
SiteURL string `json:"site_url"`
SiteName *string `json:"site_name"`
APIAuth bool `json:"api_auth"`
}
type CreateEnterpriseSiteConnectionRequest struct {
Name string `json:"name"`
SiteURL string `json:"site_url" binding:"required"`
CMSType string `json:"cms_type" binding:"required"`
AppID string `json:"appid" binding:"required"`
Secret string `json:"secret" binding:"required"`
DefaultAcode *string `json:"default_acode"`
DefaultMcode *string `json:"default_mcode"`
DefaultScode *string `json:"default_scode"`
}
type EnterpriseSitePatchString struct {
Set bool
Value *string
}
func (v *EnterpriseSitePatchString) UnmarshalJSON(data []byte) error {
v.Set = true
v.Value = nil
trimmed := bytes.TrimSpace(data)
if bytes.Equal(trimmed, []byte("null")) {
return nil
}
var value string
if err := json.Unmarshal(trimmed, &value); err != nil {
return err
}
v.Value = &value
return nil
}
type UpdateEnterpriseSiteConnectionRequest struct {
Name EnterpriseSitePatchString `json:"name"`
SiteURL EnterpriseSitePatchString `json:"site_url"`
AppID EnterpriseSitePatchString `json:"appid"`
Secret EnterpriseSitePatchString `json:"secret"`
DefaultAcode EnterpriseSitePatchString `json:"default_acode"`
DefaultMcode EnterpriseSitePatchString `json:"default_mcode"`
DefaultScode EnterpriseSitePatchString `json:"default_scode"`
Status EnterpriseSitePatchString `json:"status"`
}
type PublishEnterpriseSiteArticleRequest struct {
ArticleID int64 `json:"article_id" binding:"required"`
CategoryID string `json:"category_id"`
PublishType string `json:"publish_type"`
Author *string `json:"author"`
Source *string `json:"source"`
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
AckRecordID *int64 `json:"ack_record_id,omitempty"`
CoverAssetURL *string `json:"cover_asset_url,omitempty"`
CoverImageAssetID *int64 `json:"cover_image_asset_id,omitempty"`
}
type EnterpriseSitePublishResponse struct {
PublishBatchID int64 `json:"publish_batch_id"`
PublishRecordID int64 `json:"publish_record_id"`
ConnectionID int64 `json:"connection_id"`
ArticleID int64 `json:"article_id"`
Status string `json:"status"`
ExternalArticleID *string `json:"external_article_id"`
ExternalArticleURL *string `json:"external_article_url"`
ErrorMessage *string `json:"error_message"`
PublishedAt *time.Time `json:"published_at"`
}
type enterpriseSiteConnectionRecord struct {
ID int64
TenantID int64
WorkspaceID int64
Name string
SiteURL string
CMSType string
AuthType string
AppID string
CredentialCiphertext string
DefaultAcode *string
DefaultMcode *string
DefaultScode *string
Status string
PluginVersion *string
SiteName *string
LastPingAt *time.Time
LastSyncAt *time.Time
LastPublishAt *time.Time
LastError *string
CategoryCount int
CreatedAt time.Time
UpdatedAt time.Time
}
type enterpriseSitePublishArticle struct {
ID int64
BrandID int64
CurrentVersionID *int64
Title string
HTMLContent string
MarkdownContent string
WordCount int
SourceLabel *string
CoverAssetURL *string
CoverImageAssetID *int64
}
var enterpriseSiteSummaryTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
var enterpriseSiteImageAttrPattern = regexp.MustCompile(`(?i)(<img\b[^>]*\s(?:src|data-src|_src)=["'])([^"']+)(["'])`)
var enterpriseSiteMarkdownHeadingPattern = regexp.MustCompile(`^#{1,2}\s+(.+?)\s*#*\s*$`)
var enterpriseSiteMalformedEditorImagePattern = regexp.MustCompile(`(?i)(<p\b[^<>]*?)\s+(<img\b)`)
func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConnectionResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
rows, err := s.pool.Query(ctx, `
WITH category_counts AS (
SELECT connection_id, COUNT(*)::int AS category_count
FROM enterprise_site_categories
WHERE tenant_id = $1 AND workspace_id = $2
GROUP BY connection_id
),
latest_records AS (
SELECT DISTINCT ON (pr.target_connection_id)
pr.target_connection_id,
pr.id,
pr.article_id,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', '')) AS article_title,
pr.status,
pr.external_article_id,
pr.external_article_url,
pr.error_message,
pr.published_at,
pr.created_at,
pr.updated_at
FROM publish_records pr
LEFT JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN article_versions av ON av.id = a.current_version_id
WHERE pr.tenant_id = $1
AND pr.target_type = 'enterprise_site'
AND pr.target_connection_id IS NOT NULL
AND pr.deleted_at IS NULL
ORDER BY pr.target_connection_id, pr.created_at DESC, pr.id DESC
)
SELECT
c.id,
c.tenant_id,
c.workspace_id,
c.name,
c.site_url,
c.cms_type,
c.auth_type,
c.appid,
c.credential_ciphertext,
c.default_acode,
c.default_mcode,
c.default_scode,
c.status,
c.plugin_version,
c.site_name,
c.last_ping_at,
c.last_sync_at,
c.last_publish_at,
c.last_error,
COALESCE(cc.category_count, 0),
c.created_at,
c.updated_at,
lr.id,
lr.article_id,
lr.article_title,
lr.status,
lr.external_article_id,
lr.external_article_url,
lr.error_message,
lr.published_at,
lr.created_at,
lr.updated_at
FROM enterprise_site_connections c
LEFT JOIN category_counts cc ON cc.connection_id = c.id
LEFT JOIN latest_records lr ON lr.target_connection_id = c.id
WHERE c.tenant_id = $1
AND c.workspace_id = $2
AND c.deleted_at IS NULL
ORDER BY c.created_at DESC, c.id DESC
`, actor.TenantID, workspaceID)
if err != nil {
return nil, response.ErrInternal(50220, "enterprise_site_query_failed", "企业站点列表读取失败")
}
defer rows.Close()
items := make([]EnterpriseSiteConnectionResponse, 0)
for rows.Next() {
record, latest, scanErr := scanEnterpriseSiteConnectionRow(rows)
if scanErr != nil {
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "企业站点数据解析失败")
}
item := enterpriseSiteConnectionResponse(record)
item.LatestRecord = latest
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "企业站点数据解析失败")
}
return items, nil
}
func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterpriseSiteConnectionRequest) (*EnterpriseSiteConnectionResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
normalized, err := normalizeCreateEnterpriseSiteConnectionRequest(req)
if err != nil {
return nil, err
}
if err := validateEnterpriseSiteCMSURL(normalized.SiteURL, normalized.CMSType); err != nil {
return nil, err
}
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
if err != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
}
var id int64
if err := s.pool.QueryRow(ctx, `
INSERT INTO enterprise_site_connections (
tenant_id, workspace_id, created_by_user_id, name, site_url, cms_type, auth_type,
appid, credential_ciphertext, default_acode, default_mcode, default_scode, status
)
VALUES ($1, $2, $3, $4, $5, $6, 'native_api', $7, $8, $9, $10, $11, 'draft')
RETURNING id
`,
actor.TenantID,
workspaceID,
actor.UserID,
normalized.Name,
normalized.SiteURL,
normalized.CMSType,
normalized.AppID,
ciphertext,
normalized.DefaultAcode,
normalized.DefaultMcode,
normalized.DefaultScode,
).Scan(&id); err != nil {
if isUniqueConstraintError(err, "uk_enterprise_site_connections_active_site") {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
return nil, response.ErrInternal(50232, "enterprise_site_create_failed", "企业站点创建失败")
}
return s.Get(ctx, id)
}
func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req UpdateEnterpriseSiteConnectionRequest) (*EnterpriseSiteConnectionResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
current, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
if err != nil {
return nil, err
}
if current.Status == "disabled" && !req.Status.Set {
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
}
next := *current
if req.Name.Set {
if req.Name.Value == nil {
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "请输入站点名称")
}
name := strings.TrimSpace(*req.Name.Value)
if name == "" {
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "请输入站点名称")
}
next.Name = name
}
if req.SiteURL.Set {
if req.SiteURL.Value == nil {
return nil, response.ErrBadRequest(40041, "enterprise_site_url_required", "请填写站点域名")
}
siteURL, normalizeErr := normalizeEnterpriseSiteURL(*req.SiteURL.Value)
if normalizeErr != nil {
return nil, normalizeErr
}
next.SiteURL = siteURL
}
if req.AppID.Set {
if req.AppID.Value == nil {
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
appid := strings.TrimSpace(*req.AppID.Value)
if appid == "" {
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
next.AppID = appid
}
if req.DefaultAcode.Set {
next.DefaultAcode = nil
if req.DefaultAcode.Value != nil {
next.DefaultAcode = normalizeOptionalEnterpriseCode(*req.DefaultAcode.Value)
}
}
if req.DefaultMcode.Set {
next.DefaultMcode = nil
if req.DefaultMcode.Value != nil {
next.DefaultMcode = normalizeOptionalEnterpriseCode(*req.DefaultMcode.Value)
}
}
if req.DefaultScode.Set {
next.DefaultScode = nil
if req.DefaultScode.Value != nil {
next.DefaultScode = normalizeOptionalEnterpriseCode(*req.DefaultScode.Value)
}
}
if req.Status.Set {
if req.Status.Value == nil {
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "状态参数不正确")
}
status := strings.TrimSpace(*req.Status.Value)
if !validEnterpriseSiteStatus(status) {
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "状态参数不正确")
}
next.Status = status
}
ciphertext := next.CredentialCiphertext
if req.Secret.Set && req.Secret.Value != nil && strings.TrimSpace(*req.Secret.Value) != "" {
encrypted, encryptErr := encryptEnterpriseSiteCredential(*req.Secret.Value, s.credentialKeyMaterial())
if encryptErr != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
}
ciphertext = encrypted
}
if !validEnterpriseSiteStatus(next.Status) {
next.Status = "draft"
}
if err := validateEnterpriseSiteCMSURL(next.SiteURL, next.CMSType); err != nil {
return nil, err
}
if !strings.EqualFold(next.SiteURL, current.SiteURL) {
if err := s.ensureEnterpriseSiteURLAvailable(ctx, actor.TenantID, workspaceID, id, next.SiteURL, next.CMSType); err != nil {
return nil, err
}
}
command, err := s.pool.Exec(ctx, `
UPDATE enterprise_site_connections
SET name = $1,
site_url = $2,
appid = $3,
credential_ciphertext = $4,
default_acode = $5,
default_mcode = $6,
default_scode = $7,
status = $8::varchar,
last_error = CASE WHEN $8::text IN ('draft', 'connected') THEN NULL ELSE last_error END,
updated_at = NOW()
WHERE id = $9
AND tenant_id = $10
AND workspace_id = $11
AND deleted_at IS NULL
`,
next.Name,
next.SiteURL,
next.AppID,
ciphertext,
enterpriseSiteNullableText(next.DefaultAcode),
enterpriseSiteNullableText(next.DefaultMcode),
enterpriseSiteNullableText(next.DefaultScode),
next.Status,
id,
actor.TenantID,
workspaceID,
)
if err != nil {
if isUniqueConstraintError(err, "uk_enterprise_site_connections_active_site") {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
appErr := response.ErrInternal(50233, "enterprise_site_update_failed", "企业站点更新失败,请检查站点信息后重试")
appErr.Cause = err
return nil, appErr
}
if command.RowsAffected() == 0 {
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return s.Get(ctx, id)
}
func (s *EnterpriseSiteService) ensureEnterpriseSiteURLAvailable(ctx context.Context, tenantID, workspaceID, currentID int64, siteURL, cmsType string) error {
var existingID int64
err := s.pool.QueryRow(ctx, `
SELECT id
FROM enterprise_site_connections
WHERE tenant_id = $1
AND workspace_id = $2
AND lower(site_url) = lower($3)
AND cms_type = $4
AND deleted_at IS NULL
AND id <> $5
LIMIT 1
`, tenantID, workspaceID, siteURL, cmsType, currentID).Scan(&existingID)
if err == nil {
return response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return response.ErrInternal(50233, "enterprise_site_update_failed", "企业站点连接重复校验失败")
}
func (s *EnterpriseSiteService) Get(ctx context.Context, id int64) (*EnterpriseSiteConnectionResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
record, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
if err != nil {
return nil, err
}
item := enterpriseSiteConnectionResponse(record)
return &item, nil
}
func (s *EnterpriseSiteService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
command, err := s.pool.Exec(ctx, `
UPDATE enterprise_site_connections
SET deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND workspace_id = $3
AND deleted_at IS NULL
`, id, actor.TenantID, workspaceID)
if err != nil {
return response.ErrInternal(50222, "enterprise_site_delete_failed", "企业站点删除失败")
}
if command.RowsAffected() == 0 {
return response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return nil
}
func (s *EnterpriseSiteService) Ping(ctx context.Context, id int64) (*EnterpriseSiteCapability, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
if err != nil {
return nil, err
}
credential, err := s.connectionCredential(connection)
if err != nil {
return nil, err
}
publisher := s.publisherFor(connection.CMSType)
capability, raw, err := publisher.Ping(ctx, credential)
if err != nil {
_ = s.markConnectionError(ctx, connection.ID, actor.TenantID, workspaceID, err)
return nil, response.ErrBadRequest(40044, "enterprise_site_ping_failed", sanitizeEnterpriseSiteError(err))
}
pluginVersion := capability.PluginVersion
siteName := capability.SiteName
if _, err := s.pool.Exec(ctx, `
UPDATE enterprise_site_connections
SET status = 'connected',
plugin_version = $1,
site_name = $2,
last_ping_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND workspace_id = $5 AND deleted_at IS NULL
`, pluginVersion, siteName, connection.ID, actor.TenantID, workspaceID); err != nil {
return nil, response.ErrInternal(50223, "enterprise_site_ping_update_failed", "企业站点连接状态更新失败")
}
_ = raw
return capability, nil
}
func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([]EnterpriseSiteCategoryResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
if err != nil {
return nil, err
}
credential, err := s.connectionCredential(connection)
if err != nil {
return nil, err
}
items, _, err := s.publisherFor(connection.CMSType).Categories(ctx, credential)
if err != nil {
return nil, response.ErrBadRequest(40045, "enterprise_site_category_sync_failed", sanitizeEnterpriseSiteError(err))
}
if len(items) == 0 {
return nil, response.ErrBadRequest(40046, "enterprise_site_categories_empty", "CMS 未返回可发布栏目")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_begin_failed", "企业站点栏目同步失败")
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
DELETE FROM enterprise_site_categories
WHERE tenant_id = $1 AND workspace_id = $2 AND connection_id = $3
`, actor.TenantID, workspaceID, connection.ID); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_clear_failed", "企业站点栏目同步失败")
}
for _, item := range items {
rawJSON, marshalErr := json.Marshal(item.Raw)
if marshalErr != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_payload_failed", "企业站点栏目同步失败")
}
if _, err := tx.Exec(ctx, `
INSERT INTO enterprise_site_categories (
tenant_id, workspace_id, connection_id, remote_id, parent_remote_id, name, slug, raw_payload_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`,
actor.TenantID,
workspaceID,
connection.ID,
item.RemoteID,
item.ParentRemoteID,
item.Name,
item.Slug,
rawJSON,
); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_insert_failed", "企业站点栏目同步失败")
}
}
if _, err := tx.Exec(ctx, `
UPDATE enterprise_site_connections
SET status = 'connected',
last_sync_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
`, connection.ID, actor.TenantID, workspaceID); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_sync_update_failed", "企业站点同步状态更新失败")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_commit_failed", "企业站点栏目同步失败")
}
return s.Categories(ctx, id)
}
func (s *EnterpriseSiteService) Categories(ctx context.Context, id int64) ([]EnterpriseSiteCategoryResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if _, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, remote_id, parent_remote_id, name, slug, raw_payload_json, synced_at
FROM enterprise_site_categories
WHERE tenant_id = $1
AND workspace_id = $2
AND connection_id = $3
ORDER BY COALESCE(parent_remote_id, ''), name ASC, id ASC
`, actor.TenantID, workspaceID, id)
if err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_query_failed", "企业站点栏目读取失败")
}
defer rows.Close()
items := make([]EnterpriseSiteCategoryResponse, 0)
for rows.Next() {
var item EnterpriseSiteCategoryResponse
var raw []byte
if err := rows.Scan(&item.ID, &item.RemoteID, &item.ParentRemoteID, &item.Name, &item.Slug, &raw, &item.SyncedAt); err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "企业站点栏目解析失败")
}
if len(raw) > 0 {
item.Raw = map[string]any{}
_ = json.Unmarshal(raw, &item.Raw)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "企业站点栏目解析失败")
}
return items, nil
}
func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID int64, req PublishEnterpriseSiteArticleRequest) (*EnterpriseSitePublishResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, connectionID)
if err != nil {
return nil, err
}
if connection.Status == "disabled" {
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
}
article, err := s.loadPublishArticle(ctx, actor.TenantID, brandID, req.ArticleID, req.ArticleVersionID)
if err != nil {
return nil, err
}
categoryID := strings.TrimSpace(req.CategoryID)
if categoryID == "" {
categoryID = strings.TrimSpace(enterpriseSiteStringPointerValue(connection.DefaultScode))
}
if categoryID == "" {
return nil, response.ErrBadRequest(40047, "enterprise_site_category_required", "请选择企业站点栏目")
}
if err := s.ensureCategoryExists(ctx, actor.TenantID, workspaceID, connection.ID, categoryID); err != nil {
return nil, err
}
contentHTML := enterpriseSiteNormalizeStoredContent(article.HTMLContent)
markdownContent := enterpriseSiteNormalizeStoredContent(article.MarkdownContent)
if contentHTML != "" && !enterpriseSiteContentLooksLikeHTML(contentHTML) {
if markdownContent == "" {
markdownContent = contentHTML
}
contentHTML = ""
}
markdownContent = enterpriseSiteStripLeadingTitleHeadingMarkdown(markdownContent, article.Title)
if contentHTML == "" {
contentHTML, err = markdownToEnterpriseSiteHTML(markdownContent)
if err != nil {
return nil, response.ErrInternal(50226, "enterprise_site_markdown_render_failed", "文章内容渲染失败")
}
}
contentHTML = enterpriseSiteStripLeadingTitleHeadingHTML(contentHTML, article.Title)
if strings.TrimSpace(contentHTML) == "" {
return nil, response.ErrBadRequest(40048, "enterprise_site_article_empty", "文章内容为空")
}
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, article.ID, article.CurrentVersionID)
if err != nil {
return nil, err
}
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
TenantID: actor.TenantID,
ArticleID: article.ID,
ArticleVersionID: effectiveVersionID,
ActorID: actor.UserID,
TargetPlatforms: []string{connection.CMSType},
AckRecordID: req.AckRecordID,
TriggerSource: "publish_gate",
})
if err != nil {
return nil, compliancePublishError(err, gate)
}
if gate != nil {
switch gate.Decision {
case sharedcompliance.GateDecisionBlock:
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "内容合规检查未通过,无法发布"), gate)
case sharedcompliance.GateDecisionNeedsAck:
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "发布前需要先确认合规提示"), gate)
}
}
publishType := normalizeEnterprisePublishType(req.PublishType)
coverURLInput, coverImageAssetID := enterpriseSitePublishCoverInput(article, req)
contentHTML, coverURL := s.prepareEnterpriseSitePublishAssets(
ctx,
actor.TenantID,
contentHTML,
coverURLInput,
coverImageAssetID,
)
requestPayload := cmsPublishRequest{
ArticleID: article.ID,
Title: article.Title,
ContentHTML: contentHTML,
CategoryID: categoryID,
Description: enterpriseSiteDescription(contentHTML, markdownContent),
Author: strings.TrimSpace(enterpriseSiteStringPointerValue(req.Author)),
Source: strings.TrimSpace(enterpriseSiteStringPointerValue(req.Source)),
CoverURL: coverURL,
PublishType: publishType,
}
if requestPayload.Author == "" {
requestPayload.Author = "Geo SaaS"
}
if requestPayload.Source == "" {
requestPayload.Source = "Geo SaaS"
}
credential, err := s.connectionCredential(connection)
if err != nil {
return nil, err
}
requestJSON, err := json.Marshal(map[string]any{
"connection_id": connection.ID,
"cms_type": connection.CMSType,
"category_id": categoryID,
"publish_type": publishType,
"article_id": article.ID,
"article_version_id": effectiveVersionID,
"requested_article_version_id": req.ArticleVersionID,
"cover_image_asset_id": coverImageAssetID,
})
if err != nil {
return nil, response.ErrInternal(50227, "enterprise_site_publish_payload_failed", "企业站点发布记录创建失败")
}
publishBatchID, publishRecordID, err := s.createEnterprisePublishRecord(ctx, actor, article, connection, publishType, requestJSON, effectiveVersionID, req.AckRecordID, gate)
if err != nil {
return nil, err
}
result, raw, publishErr := s.publisherFor(connection.CMSType).Publish(ctx, credential, requestPayload)
if publishErr != nil {
errorMessage := sanitizeEnterpriseSiteError(publishErr)
if updateErr := s.finishEnterprisePublishRecord(ctx, actor.TenantID, workspaceID, publishBatchID, publishRecordID, article.ID, connection.ID, "failed", nil, nil, nil, nil, &errorMessage); updateErr != nil {
return nil, updateErr
}
return &EnterpriseSitePublishResponse{
PublishBatchID: publishBatchID,
PublishRecordID: publishRecordID,
ConnectionID: connection.ID,
ArticleID: article.ID,
Status: "failed",
ErrorMessage: &errorMessage,
}, nil
}
rawJSON, err := json.Marshal(raw)
if err != nil {
return nil, response.ErrInternal(50228, "enterprise_site_publish_response_failed", "CMS 响应保存失败")
}
externalID := normalizeStringPointer(&result.RemoteID)
externalURL := normalizeStringPointer(&result.URL)
publishedAt := time.Now().UTC()
status := "success"
if publishType == "draft" {
status = "success"
}
if err := s.finishEnterprisePublishRecord(ctx, actor.TenantID, workspaceID, publishBatchID, publishRecordID, article.ID, connection.ID, status, externalID, externalURL, &rawJSON, &publishedAt, nil); err != nil {
return nil, err
}
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &article.ID)
return &EnterpriseSitePublishResponse{
PublishBatchID: publishBatchID,
PublishRecordID: publishRecordID,
ConnectionID: connection.ID,
ArticleID: article.ID,
Status: status,
ExternalArticleID: externalID,
ExternalArticleURL: externalURL,
PublishedAt: &publishedAt,
}, nil
}
func (s *EnterpriseSiteService) createEnterprisePublishRecord(
ctx context.Context,
actor auth.Actor,
article enterpriseSitePublishArticle,
connection *enterpriseSiteConnectionRecord,
publishType string,
requestJSON []byte,
articleVersionID int64,
ackRecordID *int64,
gate *tenantcompliance.GateOutcome,
) (int64, int64, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_begin_failed", "企业站点发布记录创建失败")
}
defer tx.Rollback(ctx)
var publishBatchID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_batches (tenant_id, article_id, initiator_user_id, status, publish_type, cover_asset_url)
VALUES ($1, $2, $3, 'publishing', $4, $5)
RETURNING id
`, actor.TenantID, article.ID, actor.UserID, publishType, normalizeStringPointer(article.CoverAssetURL)).Scan(&publishBatchID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_batch_failed", "企业站点发布记录创建失败")
}
var publishRecordID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_records (
tenant_id, publish_batch_id, article_id, platform_account_id, platform_id, status,
request_payload_json, target_type, target_connection_id
)
VALUES ($1, $2, $3, NULL, $4, 'publishing', $5, 'enterprise_site', $6)
RETURNING id
`, actor.TenantID, publishBatchID, article.ID, connection.CMSType, requestJSON, connection.ID).Scan(&publishRecordID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_record_failed", "企业站点发布记录创建失败")
}
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = 'publishing', updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, article.ID, actor.TenantID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_article_publish_status_failed", "文章发布状态更新失败")
}
if err := s.consumeEnterprisePublishAckTx(ctx, tx, ackRecordID, gate, actor.TenantID, article.ID, articleVersionID); err != nil {
return 0, 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_commit_failed", "企业站点发布记录创建失败")
}
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &article.ID)
return publishBatchID, publishRecordID, nil
}
func (s *EnterpriseSiteService) consumeEnterprisePublishAckTx(
ctx context.Context,
tx pgx.Tx,
ackRecordID *int64,
gate *tenantcompliance.GateOutcome,
tenantID int64,
articleID int64,
articleVersionID int64,
) error {
if ackRecordID == nil {
return nil
}
if gate == nil || gate.Result == nil {
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "合规确认记录与当前文章不匹配")
}
consumed, err := s.compliance.ConsumeAckTx(ctx, tx, *ackRecordID, tenantcompliance.ConsumeAckQuery{
TenantID: tenantID,
ArticleID: articleID,
ArticleVersionID: articleVersionID,
CheckRecordID: gate.Result.RecordID,
ContentHash: gate.Result.ContentHash,
PolicyFingerprint: gate.Result.PolicyFingerprint,
ConsumedByJobID: nil,
})
if err != nil {
return response.ErrInternal(50227, "enterprise_site_publish_ack_failed", "合规确认处理失败")
}
if !consumed {
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "合规确认记录无效或已过期")
}
return nil
}
func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
ctx context.Context,
tenantID int64,
workspaceID int64,
publishBatchID int64,
publishRecordID int64,
articleID int64,
connectionID int64,
status string,
externalID *string,
externalURL *string,
responseJSON *[]byte,
publishedAt *time.Time,
errorMessage *string,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_finish_begin_failed", "企业站点发布结果更新失败")
}
defer tx.Rollback(ctx)
var responsePayload []byte
if responseJSON != nil {
responsePayload = *responseJSON
}
if _, err := tx.Exec(ctx, `
UPDATE publish_records
SET status = $1,
external_article_id = $2,
external_article_url = $3,
external_manage_url = $3,
response_payload_json = $4,
published_at = $5,
error_message = $6,
updated_at = NOW()
WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL
`, status, externalID, externalURL, nullableJSON(responsePayload), publishedAt, errorMessage, publishRecordID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "企业站点发布结果更新失败")
}
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE publish_batches
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, batchStatus, publishBatchID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_batch_update_failed", "企业站点发布批次更新失败")
}
articleStatus := publishBatchStatusToArticleStatus(batchStatus)
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, articleStatus, articleID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_article_publish_update_failed", "文章发布状态更新失败")
}
if status == "success" {
if _, err := tx.Exec(ctx, `
UPDATE enterprise_site_connections
SET status = 'connected',
last_publish_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
`, connectionID, tenantID, workspaceID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_connection_update_failed", "企业站点连接状态更新失败")
}
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_finish_commit_failed", "企业站点发布结果更新失败")
}
invalidateArticleCaches(ctx, s.cache, tenantID, &articleID)
return nil
}
func (s *EnterpriseSiteService) loadPublishArticle(ctx context.Context, tenantID, brandID, articleID int64, requestedVersionID *int64) (enterpriseSitePublishArticle, error) {
var article enterpriseSitePublishArticle
var wizardStateJSON []byte
if err := s.pool.QueryRow(ctx, `
SELECT
a.id,
a.brand_id,
a.current_version_id,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '未命名文章'),
COALESCE(av.word_count, 0),
av.source_label,
a.wizard_state_json
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
WHERE a.id = $1
AND a.tenant_id = $2
AND a.brand_id = $3
AND a.deleted_at IS NULL
AND a.generate_status = 'completed'
`, articleID, tenantID, brandID).Scan(
&article.ID,
&article.BrandID,
&article.CurrentVersionID,
&article.Title,
&article.WordCount,
&article.SourceLabel,
&wizardStateJSON,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return article, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return article, response.ErrInternal(50229, "enterprise_site_article_lookup_failed", "文章读取失败")
}
if err := s.populatePublishArticleContent(ctx, &article, wizardStateJSON, requestedVersionID); err != nil {
return article, err
}
article.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
article.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
article.Title = strings.TrimSpace(article.Title)
if article.Title == "" {
article.Title = "未命名文章"
}
return article, nil
}
func (s *EnterpriseSiteService) populatePublishArticleContent(ctx context.Context, article *enterpriseSitePublishArticle, wizardStateJSON []byte, requestedVersionID *int64) error {
if article == nil {
return nil
}
if requestedVersionID != nil && *requestedVersionID > 0 {
if err := s.loadPublishArticleVersionContent(ctx, article, *requestedVersionID, true); err != nil {
return err
}
article.CurrentVersionID = requestedVersionID
if enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
if err := s.loadPublishArticleVersionSnapshotContent(ctx, article, *requestedVersionID, true); err != nil {
return err
}
}
if enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
return s.populatePublishArticleWizardFallback(article, wizardStateJSON)
}
return nil
}
if article.CurrentVersionID != nil && *article.CurrentVersionID > 0 {
if err := s.loadPublishArticleVersionContent(ctx, article, *article.CurrentVersionID, false); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
}
if !enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
return nil
}
if err := s.loadLatestPublishArticleVersionContent(ctx, article); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if article.CurrentVersionID != nil && *article.CurrentVersionID > 0 && enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
if err := s.loadPublishArticleVersionSnapshotContent(ctx, article, *article.CurrentVersionID, false); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
}
return s.populatePublishArticleWizardFallback(article, wizardStateJSON)
}
func (s *EnterpriseSiteService) loadPublishArticleVersionContent(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64, strict bool) error {
content, err := repository.LoadArticleVersionContent(ctx, s.pool, article.ID, versionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if !strict {
return pgx.ErrNoRows
}
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_reconstruct_failed", "文章版本内容恢复失败")
}
if content != nil {
article.HTMLContent = strings.TrimSpace(enterpriseSiteStringPointerValue(content.HTMLContent))
article.MarkdownContent = strings.TrimSpace(enterpriseSiteStringPointerValue(content.MarkdownContent))
}
if err := s.loadPublishArticleVersionMetadata(ctx, article, versionID); err != nil {
return err
}
return nil
}
func (s *EnterpriseSiteService) loadPublishArticleVersionMetadata(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64) error {
var title string
var sourceLabel *string
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(title, ''), COALESCE(word_count, 0), source_label
FROM article_versions
WHERE id = $1 AND article_id = $2
`, versionID, article.ID).Scan(&title, &article.WordCount, &sourceLabel); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_lookup_failed", "文章版本读取失败")
}
if strings.TrimSpace(title) != "" {
article.Title = strings.TrimSpace(title)
}
if sourceLabel != nil {
article.SourceLabel = sourceLabel
}
return nil
}
func (s *EnterpriseSiteService) loadPublishArticleVersionSnapshotContent(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64, strict bool) error {
if article == nil {
return nil
}
var snapshot string
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(plaintext_snapshot, '')
FROM article_versions
WHERE id = $1 AND article_id = $2
`, versionID, article.ID).Scan(&snapshot); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if !strict {
return pgx.ErrNoRows
}
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_snapshot_lookup_failed", "文章版本读取失败")
}
snapshot = enterpriseSiteNormalizeStoredContent(snapshot)
if strings.TrimSpace(snapshot) != "" {
article.MarkdownContent = snapshot
}
return nil
}
func (s *EnterpriseSiteService) loadLatestPublishArticleVersionContent(ctx context.Context, article *enterpriseSitePublishArticle) error {
var versionID int64
var title string
var sourceLabel *string
err := s.pool.QueryRow(ctx, `
SELECT id, COALESCE(title, ''), COALESCE(word_count, 0), source_label
FROM article_versions
WHERE article_id = $1
ORDER BY version_no DESC, id DESC
LIMIT 1
`, article.ID).Scan(&versionID, &title, &article.WordCount, &sourceLabel)
if err != nil {
return err
}
article.CurrentVersionID = &versionID
if strings.TrimSpace(article.Title) == "" && strings.TrimSpace(title) != "" {
article.Title = strings.TrimSpace(title)
}
if article.SourceLabel == nil && sourceLabel != nil {
article.SourceLabel = sourceLabel
}
return s.loadPublishArticleVersionContent(ctx, article, versionID, false)
}
func (s *EnterpriseSiteService) populatePublishArticleWizardFallback(article *enterpriseSitePublishArticle, wizardStateJSON []byte) error {
if !enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
return nil
}
htmlContent, markdownContent := enterpriseSiteContentFromWizardState(wizardStateJSON)
if strings.TrimSpace(htmlContent) != "" {
article.HTMLContent = strings.TrimSpace(htmlContent)
}
if strings.TrimSpace(markdownContent) != "" {
article.MarkdownContent = strings.TrimSpace(markdownContent)
}
return nil
}
func (s *EnterpriseSiteService) ensureCategoryExists(ctx context.Context, tenantID, workspaceID, connectionID int64, categoryID string) error {
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM enterprise_site_categories
WHERE tenant_id = $1
AND workspace_id = $2
AND connection_id = $3
AND remote_id = $4
)
`, tenantID, workspaceID, connectionID, categoryID).Scan(&exists); err != nil {
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "企业站点栏目校验失败")
}
if !exists {
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "企业站点栏目不存在,请先同步栏目")
}
return nil
}
func (s *EnterpriseSiteService) loadConnection(ctx context.Context, tenantID, workspaceID, id int64) (*enterpriseSiteConnectionRecord, error) {
var item enterpriseSiteConnectionRecord
if err := s.pool.QueryRow(ctx, `
SELECT
c.id,
c.tenant_id,
c.workspace_id,
c.name,
c.site_url,
c.cms_type,
c.auth_type,
c.appid,
c.credential_ciphertext,
c.default_acode,
c.default_mcode,
c.default_scode,
c.status,
c.plugin_version,
c.site_name,
c.last_ping_at,
c.last_sync_at,
c.last_publish_at,
c.last_error,
(
SELECT COUNT(*)::int
FROM enterprise_site_categories ec
WHERE ec.connection_id = c.id
AND ec.tenant_id = c.tenant_id
AND ec.workspace_id = c.workspace_id
) AS category_count,
c.created_at,
c.updated_at
FROM enterprise_site_connections c
WHERE c.id = $1
AND c.tenant_id = $2
AND c.workspace_id = $3
AND c.deleted_at IS NULL
`, id, tenantID, workspaceID).Scan(
&item.ID,
&item.TenantID,
&item.WorkspaceID,
&item.Name,
&item.SiteURL,
&item.CMSType,
&item.AuthType,
&item.AppID,
&item.CredentialCiphertext,
&item.DefaultAcode,
&item.DefaultMcode,
&item.DefaultScode,
&item.Status,
&item.PluginVersion,
&item.SiteName,
&item.LastPingAt,
&item.LastSyncAt,
&item.LastPublishAt,
&item.LastError,
&item.CategoryCount,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return nil, response.ErrInternal(50220, "enterprise_site_lookup_failed", "企业站点连接读取失败")
}
return &item, nil
}
func (s *EnterpriseSiteService) connectionCredential(connection *enterpriseSiteConnectionRecord) (enterpriseSiteCredential, error) {
secret, err := decryptEnterpriseSiteCredential(connection.CredentialCiphertext, s.credentialKeyMaterial())
if err != nil {
return enterpriseSiteCredential{}, response.ErrInternal(50231, "enterprise_site_secret_decrypt_failed", "企业站点凭证读取失败")
}
return enterpriseSiteCredential{
SiteURL: connection.SiteURL,
AppID: connection.AppID,
Secret: secret,
DefaultAcode: enterpriseSiteStringPointerValue(connection.DefaultAcode),
DefaultMcode: enterpriseSiteStringPointerValue(connection.DefaultMcode),
DefaultScode: enterpriseSiteStringPointerValue(connection.DefaultScode),
}, nil
}
func (s *EnterpriseSiteService) publisherFor(cmsType string) cmsPublisher {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID:
return newPBootCMSPublisher(s.httpClient)
case wordpressPlatformID:
return newWordPressPublisher(s.httpClient)
default:
return unsupportedCMSPublisher{cmsType: cmsType}
}
}
func (s *EnterpriseSiteService) credentialKeyMaterial() string {
if s != nil && s.cfg != nil && s.cfg.Current() != nil {
if secret := strings.TrimSpace(s.cfg.Current().JWT.Secret); secret != "" {
return secret
}
}
return ""
}
func (s *EnterpriseSiteService) markConnectionError(ctx context.Context, connectionID, tenantID, workspaceID int64, err error) error {
message := sanitizeEnterpriseSiteError(err)
_, updateErr := s.pool.Exec(ctx, `
UPDATE enterprise_site_connections
SET status = 'error',
last_error = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND workspace_id = $4 AND deleted_at IS NULL
`, message, connectionID, tenantID, workspaceID)
return updateErr
}
func scanEnterpriseSiteConnectionRow(rows pgx.Rows) (*enterpriseSiteConnectionRecord, *EnterpriseSiteLatestPublishRecord, error) {
var item enterpriseSiteConnectionRecord
var latestID pgtype.Int8
var latestArticleID pgtype.Int8
var latestArticleTitle pgtype.Text
var latestStatus pgtype.Text
var latestExternalID pgtype.Text
var latestExternalURL pgtype.Text
var latestError pgtype.Text
var latestPublishedAt pgtype.Timestamptz
var latestCreatedAt pgtype.Timestamptz
var latestUpdatedAt pgtype.Timestamptz
err := rows.Scan(
&item.ID,
&item.TenantID,
&item.WorkspaceID,
&item.Name,
&item.SiteURL,
&item.CMSType,
&item.AuthType,
&item.AppID,
&item.CredentialCiphertext,
&item.DefaultAcode,
&item.DefaultMcode,
&item.DefaultScode,
&item.Status,
&item.PluginVersion,
&item.SiteName,
&item.LastPingAt,
&item.LastSyncAt,
&item.LastPublishAt,
&item.LastError,
&item.CategoryCount,
&item.CreatedAt,
&item.UpdatedAt,
&latestID,
&latestArticleID,
&latestArticleTitle,
&latestStatus,
&latestExternalID,
&latestExternalURL,
&latestError,
&latestPublishedAt,
&latestCreatedAt,
&latestUpdatedAt,
)
if err != nil {
return nil, nil, err
}
var latest *EnterpriseSiteLatestPublishRecord
if latestID.Valid && latestArticleID.Valid && latestStatus.Valid && latestCreatedAt.Valid && latestUpdatedAt.Valid {
latest = &EnterpriseSiteLatestPublishRecord{
ID: latestID.Int64,
ArticleID: latestArticleID.Int64,
ArticleTitle: enterpriseSiteTextPointer(latestArticleTitle),
Status: latestStatus.String,
ExternalArticleID: enterpriseSiteTextPointer(latestExternalID),
ExternalArticleURL: enterpriseSiteTextPointer(latestExternalURL),
ErrorMessage: enterpriseSiteTextPointer(latestError),
PublishedAt: enterpriseSiteTimePointer(latestPublishedAt),
CreatedAt: latestCreatedAt.Time,
UpdatedAt: latestUpdatedAt.Time,
}
}
return &item, latest, nil
}
func enterpriseSiteConnectionResponse(item *enterpriseSiteConnectionRecord) EnterpriseSiteConnectionResponse {
return EnterpriseSiteConnectionResponse{
ID: item.ID,
Name: item.Name,
SiteURL: item.SiteURL,
CMSType: item.CMSType,
AuthType: item.AuthType,
AppID: item.AppID,
DefaultAcode: item.DefaultAcode,
DefaultMcode: item.DefaultMcode,
DefaultScode: item.DefaultScode,
Status: item.Status,
PluginVersion: item.PluginVersion,
SiteName: item.SiteName,
LastPingAt: item.LastPingAt,
LastSyncAt: item.LastSyncAt,
LastPublishAt: item.LastPublishAt,
LastError: item.LastError,
CategoryCount: item.CategoryCount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func enterpriseSiteNormalizeLegacyPublishErrorState(item *EnterpriseSiteConnectionResponse) {
if item == nil || item.Status != "error" || item.LatestRecord == nil {
return
}
switch strings.TrimSpace(item.LatestRecord.Status) {
case "failed", "publish_failed":
default:
return
}
lastError := strings.TrimSpace(enterpriseSiteStringPointerValue(item.LastError))
recordError := strings.TrimSpace(enterpriseSiteStringPointerValue(item.LatestRecord.ErrorMessage))
if lastError == "" || recordError == "" || lastError != recordError {
return
}
item.Status = "connected"
item.LastError = nil
}
type normalizedEnterpriseSiteConnectionRequest struct {
Name string
SiteURL string
CMSType string
AppID string
Secret string
DefaultAcode *string
DefaultMcode *string
DefaultScode *string
}
func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConnectionRequest) (normalizedEnterpriseSiteConnectionRequest, error) {
siteURL, err := normalizeEnterpriseSiteURL(req.SiteURL)
if err != nil {
return normalizedEnterpriseSiteConnectionRequest{}, err
}
cmsType := normalizeEnterpriseCMSType(req.CMSType)
if !validEnterpriseCMSType(cmsType) {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "当前版本暂不支持该 CMS 类型")
}
appid := strings.TrimSpace(req.AppID)
if appid == "" {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
secret := strings.TrimSpace(req.Secret)
if secret == "" {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_secret_required", "请输入 Secret")
}
name := strings.TrimSpace(req.Name)
if name == "" {
if parsed, err := url.Parse(siteURL); err == nil {
name = parsed.Hostname()
}
}
if name == "" {
name = "企业站点"
}
return normalizedEnterpriseSiteConnectionRequest{
Name: name,
SiteURL: siteURL,
CMSType: cmsType,
AppID: appid,
Secret: secret,
DefaultAcode: normalizeOptionalEnterpriseCodePtr(req.DefaultAcode),
DefaultMcode: normalizeOptionalEnterpriseCodePtr(req.DefaultMcode),
DefaultScode: normalizeOptionalEnterpriseCodePtr(req.DefaultScode),
}, nil
}
func normalizeEnterpriseSiteURL(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", response.ErrBadRequest(40041, "enterprise_site_url_required", "请填写站点域名")
}
if !strings.Contains(trimmed, "://") {
trimmed = "https://" + trimmed
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && scheme != "http" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名必须使用 http 或 https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if isEnterpriseSiteBlockedHost(host) {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名不可使用内网或系统保留地址")
}
parsed.Scheme = scheme
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func validateEnterpriseSiteCMSURL(siteURL, cmsType string) error {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if strings.EqualFold(strings.TrimSpace(cmsType), wordpressPlatformID) &&
strings.EqualFold(parsed.Scheme, "http") &&
!isEnterpriseSiteLoopbackHost(parsed.Hostname()) {
return response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
}
return nil
}
func isEnterpriseSiteBlockedHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "" {
return true
}
if isEnterpriseSiteLoopbackHost(host) || host == "localhost" {
return false
}
if addr, err := netip.ParseAddr(host); err == nil {
return isEnterpriseSiteBlockedAddr(addr)
}
return false
}
func isEnterpriseSiteLoopbackHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "localhost" {
return true
}
addr, err := netip.ParseAddr(host)
return err == nil && addr.IsLoopback()
}
func isEnterpriseSiteBlockedAddr(addr netip.Addr) bool {
if !addr.IsValid() {
return true
}
if addr.Is4In6() {
addr = addr.Unmap()
}
if addr.IsLoopback() {
return false
}
if addr.IsPrivate() ||
addr.IsLinkLocalUnicast() ||
addr.IsLinkLocalMulticast() ||
addr.IsMulticast() ||
addr.IsUnspecified() ||
addr.IsInterfaceLocalMulticast() {
return true
}
return enterpriseSiteBlockedAddrPrefix(addr)
}
func enterpriseSiteBlockedAddrPrefix(addr netip.Addr) bool {
blockedPrefixes := []netip.Prefix{
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("ff00::/8"),
}
for _, prefix := range blockedPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func newEnterpriseSiteHTTPClient(config.Provider) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
if isEnterpriseSiteBlockedHost(host) {
return nil, fmt.Errorf("enterprise site target host is blocked")
}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
remoteAddr := conn.RemoteAddr()
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
if addr, ok := netip.AddrFromSlice(tcpAddr.IP); ok && isEnterpriseSiteBlockedAddr(addr) {
_ = conn.Close()
return nil, fmt.Errorf("enterprise site target address is blocked")
}
}
return conn, nil
}
return &http.Client{
Timeout: 25 * time.Second,
Transport: transport,
}
}
func normalizeEnterpriseCMSType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validEnterpriseCMSType(cmsType string) bool {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID, wordpressPlatformID:
return true
default:
return false
}
}
func normalizeOptionalEnterpriseCodePtr(value *string) *string {
if value == nil {
return nil
}
return normalizeOptionalEnterpriseCode(*value)
}
func normalizeOptionalEnterpriseCode(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func validEnterpriseSiteStatus(status string) bool {
switch strings.TrimSpace(status) {
case "draft", "connected", "error", "disabled":
return true
default:
return false
}
}
func normalizeEnterprisePublishType(value string) string {
if strings.TrimSpace(value) == "draft" {
return "draft"
}
return "publish"
}
func enterpriseSitePublishArticleContentInvalid(htmlContent, markdownContent string) bool {
return enterpriseSiteStoredContentInvalid(htmlContent) && enterpriseSiteStoredContentInvalid(markdownContent)
}
func enterpriseSiteStoredContentInvalid(value string) bool {
return strings.TrimSpace(enterpriseSiteNormalizeStoredContent(value)) == ""
}
func enterpriseSiteNormalizeStoredContent(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if !enterpriseSiteLooksLikeDiffPatch(value) {
return enterpriseSiteRepairMalformedEditorImageHTML(value)
}
if restored, ok := enterpriseSiteRestoreDiffPatchContent(value); ok {
return strings.TrimSpace(enterpriseSiteRepairMalformedEditorImageHTML(restored))
}
return ""
}
func enterpriseSiteRepairMalformedEditorImageHTML(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.Contains(strings.ToLower(value), "<img") {
return value
}
return enterpriseSiteMalformedEditorImagePattern.ReplaceAllString(value, `$1>$2`)
}
func enterpriseSiteLooksLikeDiffPatch(value string) bool {
value = strings.TrimSpace(value)
return strings.HasPrefix(value, "@@ -") && strings.Contains(value, " @@")
}
func enterpriseSiteRestoreDiffPatchContent(value string) (string, bool) {
dmp := diffmatchpatch.New()
patches, err := dmp.PatchFromText(strings.TrimSpace(value))
if err != nil || len(patches) == 0 {
return "", false
}
restored, applied := dmp.PatchApply(patches, "")
if len(applied) == 0 {
return "", false
}
for _, ok := range applied {
if !ok {
return "", false
}
}
return restored, true
}
func enterpriseSiteContentLooksLikeHTML(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return false
}
return strings.Contains(value, "<p") ||
strings.Contains(value, "<div") ||
strings.Contains(value, "<br") ||
strings.Contains(value, "<h1") ||
strings.Contains(value, "<h2") ||
strings.Contains(value, "<h3") ||
strings.Contains(value, "<ul") ||
strings.Contains(value, "<ol") ||
strings.Contains(value, "<li") ||
strings.Contains(value, "<img") ||
strings.Contains(value, "<table") ||
strings.Contains(value, "<blockquote")
}
func enterpriseSiteContentFromWizardState(raw []byte) (string, string) {
if len(raw) == 0 {
return "", ""
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return "", ""
}
htmlKeys := []string{"html_content", "htmlContent", "content_html", "article_html", "body_html"}
markdownKeys := []string{"markdown_content", "markdownContent", "content", "article_content", "body", "text"}
var htmlContent string
for _, key := range htmlKeys {
if value := strings.TrimSpace(enterpriseSiteWizardString(payload[key])); value != "" && !enterpriseSiteStoredContentInvalid(value) {
htmlContent = value
break
}
}
var markdownContent string
for _, key := range markdownKeys {
if value := strings.TrimSpace(enterpriseSiteWizardString(payload[key])); value != "" && !enterpriseSiteStoredContentInvalid(value) {
markdownContent = value
break
}
}
return htmlContent, markdownContent
}
func enterpriseSiteWizardString(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
default:
return ""
}
}
func enterpriseSitePublishCoverInput(article enterpriseSitePublishArticle, req PublishEnterpriseSiteArticleRequest) (string, *int64) {
coverURL := enterpriseSiteStringPointerValue(article.CoverAssetURL)
if req.CoverAssetURL != nil {
coverURL = strings.TrimSpace(*req.CoverAssetURL)
}
coverImageAssetID := article.CoverImageAssetID
if req.CoverImageAssetID != nil {
coverImageAssetID = req.CoverImageAssetID
}
return coverURL, coverImageAssetID
}
func (s *EnterpriseSiteService) prepareEnterpriseSitePublishAssets(ctx context.Context, tenantID int64, contentHTML, coverURL string, coverImageAssetID *int64) (string, string) {
base := s.enterpriseSitePublicBaseURL()
preparedHTML := s.prepareEnterpriseSiteImageURLs(ctx, tenantID, contentHTML, base)
preparedCover := s.prepareEnterpriseSiteCoverURL(ctx, tenantID, coverURL, coverImageAssetID, base)
return preparedHTML, preparedCover
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteCoverURL(ctx context.Context, tenantID int64, coverURL string, coverImageAssetID *int64, baseURL string) string {
if coverImageAssetID != nil && *coverImageAssetID > 0 {
if dataURI := s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, *coverImageAssetID); dataURI != "" {
return dataURI
}
}
return s.prepareEnterpriseSiteAssetURL(ctx, tenantID, coverURL, baseURL)
}
func (s *EnterpriseSiteService) enterpriseSitePublicBaseURL() string {
if s == nil || s.cfg == nil || s.cfg.Current() == nil {
return ""
}
cfg := s.cfg.Current()
if value := normalizeEnterpriseSiteAssetBaseURL(cfg.Server.PublicBaseURL); value != "" {
return value
}
for _, origin := range cfg.Server.AllowedOrigins {
if value := normalizeEnterpriseSiteAssetBaseURL(origin); value != "" {
return value
}
}
port := cfg.Server.Port
if port <= 0 {
port = 8080
}
return "http://127.0.0.1:" + strconv.Itoa(port)
}
func normalizeEnterpriseSiteAssetBaseURL(value string) string {
value = strings.TrimSpace(value)
if value == "" || value == "*" {
return ""
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ""
}
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/")
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLs(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
return fragment
}
contextNode := &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
}
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
if err != nil {
return s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL)
}
for _, node := range nodes {
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, node, baseURL)
}
nodes = enterpriseSitePruneEmptyImageWrapperNodes(nodes)
return enterpriseSiteRenderHTMLNodes(nodes, s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL))
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageNodeURLs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) {
if node == nil {
return
}
if node.Type == html.ElementNode && strings.EqualFold(node.Data, "img") {
if !s.prepareEnterpriseSiteImageAttrs(ctx, tenantID, node, baseURL) {
enterpriseSiteRemoveHTMLNode(node)
return
}
}
for child := node.FirstChild; child != nil; {
next := child.NextSibling
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, child, baseURL)
child = next
}
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageAttrs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) bool {
if node == nil {
return true
}
dataURI := s.enterpriseSiteDataURIFromImageNode(ctx, tenantID, node)
hasImageURL := false
for idx := range node.Attr {
key := strings.ToLower(strings.TrimSpace(node.Attr[idx].Key))
if key != "src" && key != "data-src" && key != "_src" {
continue
}
hasImageURL = true
if dataURI != "" {
node.Attr[idx].Val = dataURI
continue
}
node.Attr[idx].Val = s.prepareEnterpriseSiteAssetURL(ctx, tenantID, node.Attr[idx].Val, baseURL)
if strings.TrimSpace(node.Attr[idx].Val) == "" {
return false
}
}
node.Attr = enterpriseSiteStripSaaSImageAttrs(node.Attr)
return hasImageURL
}
func enterpriseSiteStripSaaSImageAttrs(attrs []html.Attribute) []html.Attribute {
if len(attrs) == 0 {
return attrs
}
cleaned := make([]html.Attribute, 0, len(attrs))
for _, attr := range attrs {
key := strings.ToLower(strings.TrimSpace(attr.Key))
if key == "data-asset-id" || key == "asset-id" {
continue
}
cleaned = append(cleaned, attr)
}
return cleaned
}
func enterpriseSiteRemoveHTMLNode(node *html.Node) {
if node == nil || node.Parent == nil {
return
}
parent := node.Parent
parent.RemoveChild(node)
if enterpriseSiteHTMLNodeIsEmptyImageWrapper(parent) {
enterpriseSiteRemoveHTMLNode(parent)
}
}
func enterpriseSiteHTMLNodeIsEmptyImageWrapper(node *html.Node) bool {
if node == nil || node.Type != html.ElementNode || !strings.EqualFold(node.Data, "p") {
return false
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.TextNode && strings.TrimSpace(child.Data) == "" {
continue
}
return false
}
if len(node.Attr) == 0 {
return true
}
for _, attr := range node.Attr {
key := strings.ToLower(strings.TrimSpace(attr.Key))
value := strings.ToLower(strings.TrimSpace(attr.Val))
if key == "class" && strings.Contains(value, "article-editor-image") {
return true
}
if key == "align" && value == "center" {
continue
}
}
return false
}
func enterpriseSitePruneEmptyImageWrapperNodes(nodes []*html.Node) []*html.Node {
if len(nodes) == 0 {
return nodes
}
pruned := make([]*html.Node, 0, len(nodes))
for _, node := range nodes {
if enterpriseSiteHTMLNodeIsEmptyImageWrapper(node) {
continue
}
pruned = append(pruned, node)
}
return pruned
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteAssetURL(ctx context.Context, tenantID int64, value string, baseURL string) string {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(strings.ToLower(value), "data:") {
return value
}
if dataURI := s.enterpriseSiteDataURIFromAssetURL(ctx, tenantID, value); dataURI != "" {
return dataURI
}
if enterpriseSiteLooksLikeSaaSAssetURL(value) {
return ""
}
return enterpriseSiteAbsoluteAssetURL(value, baseURL)
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLsFallback(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
return enterpriseSiteImageAttrPattern.ReplaceAllStringFunc(fragment, func(match string) string {
parts := enterpriseSiteImageAttrPattern.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
return parts[1] + s.prepareEnterpriseSiteAssetURL(ctx, tenantID, parts[2], baseURL) + parts[3]
})
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromAssetURL(ctx context.Context, tenantID int64, value string) string {
objectKey, ok := s.enterpriseSiteObjectKeyFromAssetURL(ctx, tenantID, value)
if !ok || s == nil || s.storage == nil {
return ""
}
return s.enterpriseSiteDataURIFromObjectKey(ctx, objectKey)
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageNode(ctx context.Context, tenantID int64, node *html.Node) string {
assetID := enterpriseSiteImageNodeAssetID(node)
if assetID <= 0 {
return ""
}
return s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, assetID)
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageAssetID(ctx context.Context, tenantID, assetID int64) string {
objectKey, ok := s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
if !ok || s == nil || s.storage == nil {
return ""
}
return s.enterpriseSiteDataURIFromObjectKey(ctx, objectKey)
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromObjectKey(ctx context.Context, objectKey string) string {
content, err := s.storage.GetBytes(ctx, objectKey)
if err != nil || len(content) == 0 {
return ""
}
contentType := enterpriseSiteImageContentType(content, objectKey)
if contentType == "" {
return ""
}
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(content)
}
func enterpriseSiteImageContentType(content []byte, objectKey string) string {
contentType := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
if strings.HasPrefix(contentType, "image/") {
return contentType
}
lowerKey := strings.ToLower(strings.TrimSpace(objectKey))
switch {
case strings.HasSuffix(lowerKey, ".webp"):
return "image/webp"
case strings.HasSuffix(lowerKey, ".png"):
return "image/png"
case strings.HasSuffix(lowerKey, ".jpg"), strings.HasSuffix(lowerKey, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lowerKey, ".gif"):
return "image/gif"
default:
return ""
}
}
func enterpriseSiteImageNodeAssetID(node *html.Node) int64 {
if node == nil {
return 0
}
for _, attr := range node.Attr {
key := strings.ToLower(strings.TrimSpace(attr.Key))
if key != "data-asset-id" && key != "asset-id" {
continue
}
id, err := strconv.ParseInt(strings.TrimSpace(attr.Val), 10, 64)
if err == nil && id > 0 {
return id
}
}
return 0
}
func (s *EnterpriseSiteService) enterpriseSiteObjectKeyFromAssetURL(ctx context.Context, tenantID int64, value string) (string, bool) {
if objectKey, ok := enterpriseSiteObjectKeyFromPublicAssetURL(value, tenantID, s.enterpriseSiteAssetTokenSecret()); ok {
return objectKey, true
}
assetID, ok := enterpriseSiteAssetIDFromURL(value)
if !ok {
return "", false
}
return s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
}
func (s *EnterpriseSiteService) loadEnterpriseSiteImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, bool) {
if s == nil || s.pool == nil || tenantID <= 0 || assetID <= 0 {
return "", false
}
var objectKey string
if err := s.pool.QueryRow(ctx, `
SELECT object_key
FROM image_assets
WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL
`, assetID, tenantID).Scan(&objectKey); err != nil {
return "", false
}
if !enterpriseSiteObjectKeyAllowedForTenant(objectKey, tenantID) {
return "", false
}
return objectKey, true
}
func (s *EnterpriseSiteService) enterpriseSiteAssetTokenSecret() string {
if s == nil || s.cfg == nil || s.cfg.Current() == nil {
return ""
}
return strings.TrimSpace(s.cfg.Current().JWT.Secret)
}
func enterpriseSiteObjectKeyFromPublicAssetURL(raw string, tenantID int64, secret string) (string, bool) {
token, ok := enterpriseSitePublicAssetTokenFromURL(raw)
if !ok {
return "", false
}
objectKey, valid := publicasset.ParseObjectKeyToken(token, secret)
if !valid {
objectKey, valid = publicasset.ObjectKeyFromToken(token)
}
if !valid || !enterpriseSiteObjectKeyAllowedForTenant(objectKey, tenantID) {
return "", false
}
return objectKey, true
}
func enterpriseSiteLooksLikeSaaSAssetURL(raw string) bool {
_, ok := enterpriseSitePublicAssetTokenFromURL(raw)
return ok
}
func enterpriseSitePublicAssetTokenFromURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
if token, ok := enterpriseSiteAssetTokenFromPath(raw); ok {
return token, true
}
parsed, err := url.Parse(raw)
if err != nil {
return "", false
}
if token, ok := enterpriseSiteAssetTokenFromPath(parsed.Path); ok {
return token, true
}
return "", false
}
func enterpriseSiteAssetTokenFromPath(pathValue string) (string, bool) {
pathValue = strings.TrimSpace(pathValue)
if pathValue == "" {
return "", false
}
pathValue = strings.TrimLeft(pathValue, "/")
for _, prefix := range []string{"api/public/assets/", "api/desktop/content/assets/", "public/assets/", "assets/"} {
if strings.HasPrefix(pathValue, prefix) {
token := strings.TrimPrefix(pathValue, prefix)
if slash := strings.Index(token, "/"); slash >= 0 {
token = token[:slash]
}
if idx := strings.IndexAny(token, "?#"); idx >= 0 {
token = token[:idx]
}
token, _ = url.PathUnescape(token)
return strings.TrimSpace(token), strings.TrimSpace(token) != ""
}
}
return "", false
}
func enterpriseSiteAssetIDFromURL(raw string) (int64, bool) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return 0, false
}
value := strings.TrimSpace(parsed.Query().Get("data-asset-id"))
if value == "" {
value = strings.TrimSpace(parsed.Query().Get("asset_id"))
}
if value == "" {
return 0, false
}
id, err := strconv.ParseInt(value, 10, 64)
return id, err == nil && id > 0
}
func enterpriseSiteObjectKeyAllowedForTenant(objectKey string, tenantID int64) bool {
if tenantID <= 0 {
return false
}
tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/"
return strings.HasPrefix(objectKey, tenantPrefix+"images/") ||
strings.HasPrefix(objectKey, tenantPrefix+"articles/")
}
func enterpriseSiteAbsoluteImageURLs(fragment string, baseURL string) string {
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
return fragment
}
contextNode := &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
}
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
if err != nil {
return enterpriseSiteAbsoluteImageURLsFallback(fragment, baseURL)
}
for _, node := range nodes {
enterpriseSiteRewriteImageNodeURLs(node, baseURL)
}
var builder strings.Builder
for _, node := range nodes {
if err := html.Render(&builder, node); err != nil {
return enterpriseSiteAbsoluteImageURLsFallback(fragment, baseURL)
}
}
return builder.String()
}
func enterpriseSiteRewriteImageNodeURLs(node *html.Node, baseURL string) {
if node == nil {
return
}
if node.Type == html.ElementNode && strings.EqualFold(node.Data, "img") {
for idx := range node.Attr {
key := strings.ToLower(strings.TrimSpace(node.Attr[idx].Key))
if key != "src" && key != "data-src" && key != "_src" {
continue
}
node.Attr[idx].Val = enterpriseSiteAbsoluteAssetURL(node.Attr[idx].Val, baseURL)
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
enterpriseSiteRewriteImageNodeURLs(child, baseURL)
}
}
func enterpriseSiteAbsoluteImageURLsFallback(fragment string, baseURL string) string {
return enterpriseSiteImageAttrPattern.ReplaceAllStringFunc(fragment, func(match string) string {
parts := enterpriseSiteImageAttrPattern.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
return parts[1] + enterpriseSiteAbsoluteAssetURL(parts[2], baseURL) + parts[3]
})
}
func enterpriseSiteAbsoluteAssetURL(value string, baseURL string) string {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(strings.ToLower(value), "data:") {
return value
}
parsed, err := url.Parse(value)
if err == nil && parsed.Scheme != "" {
return value
}
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return value
}
if strings.HasPrefix(value, "//") {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" {
return value
}
return base.Scheme + ":" + value
}
if strings.HasPrefix(value, "/") {
return baseURL + value
}
return baseURL + "/" + strings.TrimLeft(value, "/")
}
func enterpriseSiteStripLeadingTitleHeadingHTML(fragment string, title string) string {
normalizedTitle := enterpriseSiteNormalizeHeadingText(title)
if normalizedTitle == "" || strings.TrimSpace(fragment) == "" {
return fragment
}
contextNode := &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
}
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
if err != nil {
return enterpriseSiteStripLeadingTitleHeadingHTMLFallback(fragment, normalizedTitle)
}
first := enterpriseSiteFirstMeaningfulHTMLNode(nodes)
if enterpriseSiteHTMLNodeIsTitleHeading(first, normalizedTitle) {
return enterpriseSiteRenderHTMLNodes(enterpriseSiteWithoutTopLevelHTMLNode(nodes, first), fragment)
}
if enterpriseSiteHTMLNodeIsContentWrapper(first) {
child := enterpriseSiteFirstMeaningfulHTMLChild(first)
if enterpriseSiteHTMLNodeIsTitleHeading(child, normalizedTitle) {
first.RemoveChild(child)
if enterpriseSiteHTMLNodeIsSkippable(first) {
nodes = enterpriseSiteWithoutTopLevelHTMLNode(nodes, first)
}
return enterpriseSiteRenderHTMLNodes(nodes, fragment)
}
}
return fragment
}
func enterpriseSiteStripLeadingTitleHeadingHTMLFallback(fragment string, normalizedTitle string) string {
match := regexp.MustCompile(`(?is)^\s*<h[12]\b[^>]*>(.*?)</h[12]>\s*`).FindStringSubmatchIndex(fragment)
if len(match) != 4 {
return fragment
}
heading := enterpriseSiteSummaryTagPattern.ReplaceAllString(fragment[match[2]:match[3]], " ")
if enterpriseSiteNormalizeHeadingText(heading) != normalizedTitle {
return fragment
}
return strings.TrimSpace(fragment[match[1]:])
}
func enterpriseSiteStripLeadingTitleHeadingMarkdown(markdownContent string, title string) string {
normalizedTitle := enterpriseSiteNormalizeHeadingText(title)
if normalizedTitle == "" || strings.TrimSpace(markdownContent) == "" {
return markdownContent
}
normalizedMarkdown := strings.TrimPrefix(markdownContent, "\uFEFF")
lineEnd := strings.IndexByte(normalizedMarkdown, '\n')
firstLine := normalizedMarkdown
rest := ""
if lineEnd >= 0 {
firstLine = strings.TrimSuffix(normalizedMarkdown[:lineEnd], "\r")
rest = normalizedMarkdown[lineEnd+1:]
}
match := enterpriseSiteMarkdownHeadingPattern.FindStringSubmatch(strings.TrimSpace(firstLine))
if len(match) == 2 && enterpriseSiteNormalizeHeadingText(match[1]) == normalizedTitle {
return strings.TrimSpace(rest)
}
if enterpriseSiteNormalizeHeadingText(firstLine) == normalizedTitle {
return strings.TrimSpace(rest)
}
return markdownContent
}
func enterpriseSiteNormalizeHeadingText(value string) string {
value = strings.TrimPrefix(strings.TrimSpace(value), "\uFEFF")
return strings.Join(strings.Fields(value), " ")
}
func enterpriseSiteFirstMeaningfulHTMLNode(nodes []*html.Node) *html.Node {
for _, node := range nodes {
if !enterpriseSiteHTMLNodeIsSkippable(node) {
return node
}
}
return nil
}
func enterpriseSiteFirstMeaningfulHTMLChild(node *html.Node) *html.Node {
if node == nil {
return nil
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if !enterpriseSiteHTMLNodeIsSkippable(child) {
return child
}
}
return nil
}
func enterpriseSiteHTMLNodeIsTitleHeading(node *html.Node, normalizedTitle string) bool {
if node == nil || node.Type != html.ElementNode {
return false
}
tagName := strings.ToLower(strings.TrimSpace(node.Data))
if tagName != "h1" && tagName != "h2" {
return false
}
return enterpriseSiteNormalizeHeadingText(enterpriseSiteHTMLNodeText(node)) == normalizedTitle
}
func enterpriseSiteHTMLNodeText(node *html.Node) string {
if node == nil {
return ""
}
var builder strings.Builder
enterpriseSiteAppendHTMLNodeText(&builder, node)
return builder.String()
}
func enterpriseSiteAppendHTMLNodeText(builder *strings.Builder, node *html.Node) {
if node == nil {
return
}
if node.Type == html.TextNode {
builder.WriteString(node.Data)
return
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
enterpriseSiteAppendHTMLNodeText(builder, child)
}
}
func enterpriseSiteHTMLNodeIsContentWrapper(node *html.Node) bool {
if node == nil || node.Type != html.ElementNode {
return false
}
switch strings.ToLower(strings.TrimSpace(node.Data)) {
case "article", "div", "main", "section":
return true
default:
return false
}
}
func enterpriseSiteHTMLNodeIsSkippable(node *html.Node) bool {
if node == nil {
return true
}
switch node.Type {
case html.CommentNode:
return true
case html.TextNode:
return enterpriseSiteNormalizeHeadingText(node.Data) == ""
case html.ElementNode:
if strings.EqualFold(node.Data, "br") {
return true
}
return enterpriseSiteNormalizeHeadingText(enterpriseSiteHTMLNodeText(node)) == "" && !enterpriseSiteHTMLNodeHasNonTextContent(node)
default:
return false
}
}
func enterpriseSiteHTMLNodeHasNonTextContent(node *html.Node) bool {
if node == nil {
return false
}
if node.Type == html.ElementNode {
switch strings.ToLower(strings.TrimSpace(node.Data)) {
case "audio", "canvas", "embed", "iframe", "img", "object", "svg", "table", "video":
return true
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if enterpriseSiteHTMLNodeHasNonTextContent(child) {
return true
}
}
return false
}
func enterpriseSiteWithoutTopLevelHTMLNode(nodes []*html.Node, remove *html.Node) []*html.Node {
if remove == nil {
return nodes
}
filtered := make([]*html.Node, 0, len(nodes))
for _, node := range nodes {
if node != remove {
filtered = append(filtered, node)
}
}
return filtered
}
func enterpriseSiteRenderHTMLNodes(nodes []*html.Node, fallback string) string {
var builder strings.Builder
for _, node := range nodes {
if err := html.Render(&builder, node); err != nil {
return fallback
}
}
return strings.TrimSpace(builder.String())
}
func markdownToEnterpriseSiteHTML(markdown string) (string, error) {
renderer := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)
var out bytes.Buffer
if err := renderer.Convert([]byte(strings.TrimSpace(markdown)), &out); err != nil {
return "", err
}
return out.String(), nil
}
func enterpriseSiteDescription(htmlContent, markdownContent string) string {
text := strings.TrimSpace(enterpriseSiteSummaryTagPattern.ReplaceAllString(htmlContent, ""))
if text == "" {
text = enterpriseSitePlainTextFromMarkdown(markdownContent)
}
text = strings.Join(strings.Fields(text), " ")
if text == "" {
return ""
}
if len([]rune(text)) <= 150 {
return text
}
runes := []rune(text)
return string(runes[:150])
}
func enterpriseSitePlainTextFromMarkdown(markdownContent string) string {
lines := strings.Split(strings.TrimSpace(markdownContent), "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = strings.TrimLeft(line, "#")
line = strings.TrimSpace(line)
line = strings.TrimLeft(line, "-*")
line = strings.TrimSpace(line)
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, " ")
}
func sanitizeEnterpriseSiteError(err error) string {
if err == nil {
return ""
}
message := strings.TrimSpace(err.Error())
if message == "" {
return "CMS 请求失败"
}
lower := strings.ToLower(message)
for _, marker := range []string{"api_secret", "secret", "signature", "appid="} {
if strings.Contains(lower, strings.ToLower(marker)) {
return "CMS 请求失败,请检查站点凭证和接口安装状态"
}
}
if strings.HasPrefix(lower, "request pbootcms ") {
return "CMS 请求失败,请检查站点地址和网络连通性"
}
if strings.HasPrefix(lower, "parse pbootcms response") {
return "CMS 响应解析失败,请确认接口返回 JSON 格式"
}
if strings.HasPrefix(lower, "parse pbootcms data") {
return "CMS 数据解析失败,请确认接口返回数据格式"
}
if strings.HasPrefix(lower, "invalid pbootcms site url") {
return "站点域名格式不正确"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " returned http ") {
return "CMS 接口请求失败,请检查站点接口是否可访问"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " failed with code ") {
return "CMS 接口返回失败,请检查站点接口配置"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " endpoint is unavailable") {
return "CMS 接口不可用,请确认接口安装路径"
}
if strings.HasPrefix(lower, "request wordpress ") {
return "WordPress 请求失败,请检查站点地址和网络连通性"
}
if strings.HasPrefix(lower, "parse wordpress response") {
return "WordPress 响应解析失败,请确认 REST API 返回 JSON 格式"
}
if strings.Contains(lower, "rest_not_logged_in") || strings.Contains(lower, "incorrect_password") || strings.Contains(lower, "invalid_username") {
return "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
}
if strings.HasPrefix(lower, "invalid wordpress site url") {
return "站点域名格式不正确"
}
if strings.Contains(lower, "invalid wordpress category id") {
return "WordPress 栏目 ID 不正确,请先同步栏目后再发布"
}
if strings.HasPrefix(lower, "wordpress ") && strings.Contains(lower, " returned http ") {
return "WordPress REST API 请求失败,请检查站点权限和接口状态"
}
if strings.Contains(lower, "unsupported cms type") {
return "当前版本暂不支持该 CMS 类型"
}
if len([]rune(message)) > 300 {
runes := []rune(message)
return string(runes[:300])
}
return message
}
func enterpriseSiteStringPointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func enterpriseSiteNullableText(value *string) pgtype.Text {
if value == nil {
return pgtype.Text{}
}
return pgtype.Text{String: *value, Valid: true}
}
func enterpriseSiteTextPointer(value pgtype.Text) *string {
if !value.Valid {
return nil
}
trimmed := strings.TrimSpace(value.String)
if trimmed == "" {
return nil
}
return &trimmed
}
func enterpriseSiteTimePointer(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
return &value.Time
}