package app import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "net/http" "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: &http.Client{ Timeout: 25 * time.Second, }, 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 UpdateEnterpriseSiteConnectionRequest struct { Name *string `json:"name"` SiteURL *string `json:"site_url"` AppID *string `json:"appid"` Secret *string `json:"secret"` DefaultAcode *string `json:"default_acode"` DefaultMcode *string `json:"default_mcode"` DefaultScode *string `json:"default_scode"` Status *string `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)(]*\s(?:src|data-src|_src)=["'])([^"']+)(["'])`) var enterpriseSiteMarkdownHeadingPattern = regexp.MustCompile(`^#{1,2}\s+(.+?)\s*#*\s*$`) var enterpriseSiteMalformedEditorImagePattern = regexp.MustCompile(`(?i)(]*?)\s+(> '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 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", "failed to list enterprise sites") } 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", "failed to parse enterprise site") } 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", "failed to iterate enterprise sites") } 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", "workspace context is required") } normalized, err := normalizeCreateEnterpriseSiteConnectionRequest(req) if 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", "failed to store enterprise site credential") } 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 { return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists") } 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", "workspace context is required") } current, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id) if err != nil { return nil, err } if current.Status == "disabled" && req.Status == nil { return nil, response.ErrConflict(40942, "enterprise_site_disabled", "enterprise site connection is disabled") } next := *current if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "site name is required") } next.Name = name } if req.SiteURL != nil { siteURL, normalizeErr := normalizeEnterpriseSiteURL(*req.SiteURL) if normalizeErr != nil { return nil, normalizeErr } next.SiteURL = siteURL } if req.AppID != nil { appid := strings.TrimSpace(*req.AppID) if appid == "" { return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required") } next.AppID = appid } if req.DefaultAcode != nil { next.DefaultAcode = normalizeOptionalEnterpriseCode(*req.DefaultAcode) } if req.DefaultMcode != nil { next.DefaultMcode = normalizeOptionalEnterpriseCode(*req.DefaultMcode) } if req.DefaultScode != nil { next.DefaultScode = normalizeOptionalEnterpriseCode(*req.DefaultScode) } if req.Status != nil { status := strings.TrimSpace(*req.Status) if !validEnterpriseSiteStatus(status) { return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "status is invalid") } next.Status = status } ciphertext := next.CredentialCiphertext if req.Secret != nil && strings.TrimSpace(*req.Secret) != "" { encrypted, encryptErr := encryptEnterpriseSiteCredential(*req.Secret, s.credentialKeyMaterial()) if encryptErr != nil { return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "failed to store enterprise site credential") } ciphertext = encrypted } if _, 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, last_error = CASE WHEN $8 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, next.DefaultAcode, next.DefaultMcode, next.DefaultScode, next.Status, id, actor.TenantID, workspaceID, ); err != nil { return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists") } return s.Get(ctx, id) } 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", "failed to delete enterprise site") } if command.RowsAffected() == 0 { return response.ErrNotFound(40441, "enterprise_site_not_found", "enterprise site connection 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", "failed to update enterprise site ping") } _ = 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 did not return any publishable categories") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50224, "enterprise_site_category_sync_begin_failed", "failed to sync enterprise site categories") } 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", "failed to sync enterprise site categories") } for _, item := range items { rawJSON, marshalErr := json.Marshal(item.Raw) if marshalErr != nil { return nil, response.ErrInternal(50224, "enterprise_site_category_payload_failed", "failed to sync enterprise site categories") } 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", "failed to sync enterprise site categories") } } 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", "failed to update enterprise site sync state") } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50224, "enterprise_site_category_sync_commit_failed", "failed to sync enterprise site categories") } 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", "failed to list enterprise site categories") } 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", "failed to parse enterprise site category") } 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", "failed to iterate enterprise site categories") } 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", "workspace context is required") } 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", "enterprise site connection is 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", "category_id is 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", "failed to render article content") } } contentHTML = enterpriseSiteStripLeadingTitleHeadingHTML(contentHTML, article.Title) if strings.TrimSpace(contentHTML) == "" { return nil, response.ErrBadRequest(40048, "enterprise_site_article_empty", "article content is 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", "content compliance blocked this publish request"), gate) case sharedcompliance.GateDecisionNeedsAck: return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), 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", "failed to create publish record") } 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", "failed to persist CMS response") } 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", "failed to create publish record") } 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", "failed to create publish record") } 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", "failed to create publish record") } 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", "failed to update article publish status") } 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", "failed to create publish record") } 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", "ack record cannot be consumed without a matching compliance result") } 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", "failed to consume compliance acknowledgement") } if !consumed { return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is invalid or expired") } 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", "failed to update publish record") } 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 `, status, externalID, externalURL, nullableJSON(responsePayload), publishedAt, errorMessage, publishRecordID, tenantID); err != nil { return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "failed to update publish record") } 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", "failed to update publish batch") } 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", "failed to update article publish status") } 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", "failed to update enterprise site") } } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50228, "enterprise_site_publish_finish_commit_failed", "failed to update publish record") } 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", "failed to load article") } 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", "failed to reconstruct article version") } 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", "failed to load article version") } 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", "failed to load article version") } 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", "failed to validate enterprise site category") } if !exists { return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "category is not synced for this site") } 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", "enterprise site connection not found") } return nil, response.ErrInternal(50220, "enterprise_site_lookup_failed", "failed to load enterprise site") } 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", "failed to read enterprise site credential") } 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) default: return newPBootCMSPublisher(s.httpClient) } } 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 cmsType != pbootCMSPlatformID { return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "only PBootCMS is supported in this version") } appid := strings.TrimSpace(req.AppID) if appid == "" { return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required") } secret := strings.TrimSpace(req.Secret) if secret == "" { return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_secret_required", "secret is required") } 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", "site_url is 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", "site_url is invalid") } scheme := strings.ToLower(parsed.Scheme) if scheme != "https" && scheme != "http" { return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "site_url must be http or https") } parsed.Scheme = scheme parsed.Path = strings.TrimRight(parsed.Path, "/") parsed.RawQuery = "" parsed.Fragment = "" return strings.TrimRight(parsed.String(), "/"), nil } func normalizeEnterpriseCMSType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } 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), "$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, " 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), " 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), "]*>(.*?)\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 request failed" } for _, marker := range []string{"api_secret", "secret", "signature", "appid="} { if strings.Contains(strings.ToLower(message), strings.ToLower(marker)) { return "CMS request failed; please verify site credentials and plugin installation" } } 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 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 }