6e0519a232
- 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.
2685 lines
88 KiB
Go
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
|
|
}
|