88c37e50b2
Introduce a new enterprise-site publishing channel that lets tenants push articles to self-hosted PbootCMS sites alongside the existing media supply flow. Backend (server/internal/tenant): - enterprise_site_service: CRUD, ping, category sync, and article publish - enterprise_site_pbootcms: PbootCMS API client integration - enterprise_site_crypto: encrypt/decrypt stored site credentials - enterprise_site_handler + routes under /enterprise-sites - migrations for the enterprise site publisher tables - config: add SERVER_PUBLIC_BASE_URL (Server.PublicBaseURL) for callbacks - article/media services adjusted to support the publish flow Frontend (apps/admin-web): - PublishArticleModal & ArticlePublishStatus: enterprise-site publish UI - MediaView: manage enterprise sites and categories - api + shared-types: enterprise site endpoints and types - http-client: add PATCH method support Integrations: - pbootcms GeoPublisher controller plugin + install guide - docs/enterprise-site-publisher-v1.md design doc
437 lines
16 KiB
Go
437 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
|
"github.com/sergi/go-diff/diffmatchpatch"
|
|
)
|
|
|
|
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
|
|
got := enterpriseSiteDescription(
|
|
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
|
|
"## 旧版标题\n旧版正文不应该优先进入摘要",
|
|
)
|
|
if strings.Contains(got, "##") {
|
|
t.Fatalf("description contains markdown heading marker: %q", got)
|
|
}
|
|
if strings.Contains(got, "旧版正文") {
|
|
t.Fatalf("description used markdown while html text was available: %q", got)
|
|
}
|
|
if !strings.Contains(got, "2026年合肥全屋定制行业发展现状") {
|
|
t.Fatalf("description = %q, want rendered html text", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLRemovesDuplicateTitle(t *testing.T) {
|
|
got := enterpriseSiteStripLeadingTitleHeadingHTML(
|
|
"<h1>合肥全屋定制口碑怎么判断 看这里</h1><p>正文第一段。</p>",
|
|
"合肥全屋定制口碑怎么判断 看这里",
|
|
)
|
|
if strings.Contains(got, "<h1>") || strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
|
|
t.Fatalf("duplicate title heading was not removed: %q", got)
|
|
}
|
|
if !strings.Contains(got, "正文第一段") {
|
|
t.Fatalf("body content was lost: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLKeepsDifferentHeading(t *testing.T) {
|
|
input := "<h2>行业现状</h2><p>正文第一段。</p>"
|
|
got := enterpriseSiteStripLeadingTitleHeadingHTML(input, "合肥全屋定制口碑怎么判断 看这里")
|
|
if got != input {
|
|
t.Fatalf("different leading heading changed: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesDuplicateTitle(t *testing.T) {
|
|
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
|
|
"# 合肥全屋定制口碑怎么判断 看这里\n\n正文第一段。",
|
|
"合肥全屋定制口碑怎么判断 看这里",
|
|
)
|
|
if strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
|
|
t.Fatalf("duplicate markdown title was not removed: %q", got)
|
|
}
|
|
if got != "正文第一段。" {
|
|
t.Fatalf("markdown body = %q, want body only", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesPlainTitle(t *testing.T) {
|
|
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
|
|
"装修预算不够?2026安徽6款高性价比家居整理\n\n近年安徽本地全屋定制需求持续上涨。",
|
|
"装修预算不够?2026安徽6款高性价比家居整理",
|
|
)
|
|
if strings.Contains(got, "装修预算不够?2026安徽6款高性价比家居整理") {
|
|
t.Fatalf("plain duplicate title was not removed: %q", got)
|
|
}
|
|
if !strings.HasPrefix(got, "近年安徽本地全屋定制需求持续上涨") {
|
|
t.Fatalf("markdown body = %q, want body only", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteDescriptionAfterTitleStripSkipsDuplicateTitle(t *testing.T) {
|
|
title := "合肥全屋定制口碑怎么判断 看这里"
|
|
html := enterpriseSiteStripLeadingTitleHeadingHTML("<h1>"+title+"</h1><p>正文第一段。</p>", title)
|
|
markdown := enterpriseSiteStripLeadingTitleHeadingMarkdown("# "+title+"\n\n正文第一段。", title)
|
|
got := enterpriseSiteDescription(html, markdown)
|
|
if strings.Contains(got, title) {
|
|
t.Fatalf("description still contains duplicate title: %q", got)
|
|
}
|
|
if !strings.Contains(got, "正文第一段") {
|
|
t.Fatalf("description = %q, want body text", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteDescriptionFallsBackToCleanMarkdown(t *testing.T) {
|
|
got := enterpriseSiteDescription("", "## 2026年合肥全屋定制行业发展现状\n- 消费者需求升级。")
|
|
if strings.Contains(got, "##") || strings.Contains(got, "- ") {
|
|
t.Fatalf("description was not cleaned: %q", got)
|
|
}
|
|
if !strings.Contains(got, "消费者需求升级") {
|
|
t.Fatalf("description = %q, want markdown text", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteNormalizeStoredContentRestoresDiffPatch(t *testing.T) {
|
|
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
|
|
dmp := diffmatchpatch.New()
|
|
patch := dmp.PatchToText(dmp.PatchMake("", source))
|
|
|
|
got := enterpriseSiteNormalizeStoredContent(patch)
|
|
|
|
if got != source {
|
|
t.Fatalf("restored content = %q, want %q", got, source)
|
|
}
|
|
if strings.Contains(got, "@@ -") {
|
|
t.Fatalf("diff patch leaked into normalized content: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteNormalizeStoredContentRepairsMalformedEditorImage(t *testing.T) {
|
|
got := enterpriseSiteNormalizeStoredContent(
|
|
`<p class="article-editor-image article-editor-image--center" align="center" <img src="/api/public/assets/token" alt="" /></p>`,
|
|
)
|
|
if !strings.Contains(got, `align="center"><img`) {
|
|
t.Fatalf("malformed editor image was not repaired: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteHTMLContentPatchFallsBackToMarkdownRendering(t *testing.T) {
|
|
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
|
|
dmp := diffmatchpatch.New()
|
|
htmlContent := enterpriseSiteNormalizeStoredContent(dmp.PatchToText(dmp.PatchMake("", source)))
|
|
markdownContent := ""
|
|
if htmlContent != "" && !enterpriseSiteContentLooksLikeHTML(htmlContent) {
|
|
markdownContent = htmlContent
|
|
htmlContent = ""
|
|
}
|
|
|
|
got, err := markdownToEnterpriseSiteHTML(markdownContent)
|
|
if err != nil {
|
|
t.Fatalf("render markdown: %v", err)
|
|
}
|
|
|
|
if strings.Contains(got, "@@ -") || strings.Contains(got, "%E5") {
|
|
t.Fatalf("rendered HTML still contains patch/url-encoded content: %q", got)
|
|
}
|
|
if !strings.Contains(got, "<h1>2026安徽全屋定制行业</h1>") || !strings.Contains(got, "<p>正文第一段。</p>") {
|
|
t.Fatalf("rendered HTML = %q, want UEditor-ready HTML", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteAbsoluteImageURLsRewritesRelativeAssetPaths(t *testing.T) {
|
|
got := enterpriseSiteAbsoluteImageURLs(
|
|
`<p><img src="/api/public/assets/token" data-src="/api/public/assets/token" /></p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
if strings.Count(got, "http://127.0.0.1:8080/api/public/assets/token") != 2 {
|
|
t.Fatalf("image urls were not absolutized: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImages(t *testing.T) {
|
|
objectKey := "tenants/7/images/body.png"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(
|
|
context.Background(),
|
|
7,
|
|
`<p><img src="/api/public/assets/`+token+`" data-src="/api/public/assets/`+token+`" /></p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
|
}
|
|
if strings.Count(got, `data:image/png;base64,`) != 2 {
|
|
t.Fatalf("image was not embedded in both image attrs: %s", got)
|
|
}
|
|
if strings.Contains(got, "/api/public/assets/") {
|
|
t.Fatalf("public asset url leaked into enterprise site payload: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImagesWithStaleSignature(t *testing.T) {
|
|
objectKey := "tenants/7/images/body.png"
|
|
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "new-secret"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(
|
|
context.Background(),
|
|
7,
|
|
`<p><img src="http://localhost:5178/api/public/assets/`+token+`" /></p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
|
}
|
|
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
|
t.Fatalf("stale signed image was not embedded: %s", got)
|
|
}
|
|
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
|
|
t.Fatalf("saas asset url leaked into enterprise site payload: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteImageURLsStripsSaaSImageAttrs(t *testing.T) {
|
|
objectKey := "tenants/7/images/body.png"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(
|
|
context.Background(),
|
|
7,
|
|
`<p><img src="/api/public/assets/`+token+`" alt="" data-asset-id="35" asset-id="35" /></p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
|
t.Fatalf("image was not embedded: %s", got)
|
|
}
|
|
if strings.Contains(got, "data-asset-id") || strings.Contains(got, "asset-id") {
|
|
t.Fatalf("saas image attrs leaked into enterprise site payload: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteImageURLsDropsUnavailableSaaSAssetImage(t *testing.T) {
|
|
objectKey := "tenants/7/images/deleted.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "new-secret"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(
|
|
context.Background(),
|
|
7,
|
|
`<p>前文</p><p class="article-editor-image article-editor-image--center" align="center"><img src="http://localhost:5178/api/public/assets/`+token+`" alt="" /></p><p>后文</p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
|
|
t.Fatalf("unavailable saas asset url leaked into enterprise site payload: %s", got)
|
|
}
|
|
if strings.Contains(got, "<img") || strings.Contains(got, "article-editor-image") {
|
|
t.Fatalf("unavailable image wrapper was not removed: %s", got)
|
|
}
|
|
if !strings.Contains(got, "前文") || !strings.Contains(got, "后文") {
|
|
t.Fatalf("surrounding content was lost: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteImageURLsEmbedsIncompletePublicAssetPaths(t *testing.T) {
|
|
objectKey := "tenants/7/articles/42/images/body.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(
|
|
context.Background(),
|
|
7,
|
|
`<p><img src="api/public/assets/`+token+`?format=png" /></p>`,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
|
}
|
|
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
|
t.Fatalf("image was not embedded for incomplete asset path: %s", got)
|
|
}
|
|
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
|
|
t.Fatalf("incomplete asset path leaked into enterprise site payload: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownRenderedEnterpriseSiteImageEmbedsIncompletePublicAssetPath(t *testing.T) {
|
|
objectKey := "tenants/7/articles/42/images/body.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
})).WithObjectStorage(storage)
|
|
html, err := markdownToEnterpriseSiteHTML("正文\n\n")
|
|
if err != nil {
|
|
t.Fatalf("render markdown: %v", err)
|
|
}
|
|
|
|
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, html, "http://127.0.0.1:8080")
|
|
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
|
}
|
|
if !strings.Contains(got, `src="data:image/png;base64,`) {
|
|
t.Fatalf("markdown image was not embedded for incomplete asset path: %s", got)
|
|
}
|
|
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
|
|
t.Fatalf("markdown image asset path leaked into enterprise site payload: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSitePublishAssetsEmbedsIncompleteCoverAssetPath(t *testing.T) {
|
|
objectKey := "tenants/7/articles/42/images/cover.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteCoverURL(
|
|
context.Background(),
|
|
7,
|
|
"api/public/assets/"+token+"?format=png",
|
|
nil,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
|
|
}
|
|
if !strings.HasPrefix(got, "data:image/png;base64,") {
|
|
t.Fatalf("cover was not embedded as data URI: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareEnterpriseSiteCoverURLDropsUnavailableSaaSAssetURL(t *testing.T) {
|
|
objectKey := "tenants/7/images/deleted.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "old-secret")
|
|
storage := &enterpriseSiteImageObjectStorage{}
|
|
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "new-secret"},
|
|
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
|
|
})).WithObjectStorage(storage)
|
|
|
|
got := svc.prepareEnterpriseSiteCoverURL(
|
|
context.Background(),
|
|
7,
|
|
"http://localhost:5178/api/public/assets/"+token,
|
|
nil,
|
|
"http://127.0.0.1:8080",
|
|
)
|
|
|
|
if got != "" {
|
|
t.Fatalf("unavailable saas cover should not be sent to pbootcms, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteNormalizeLegacyPublishErrorState(t *testing.T) {
|
|
errorMessage := "图片下载失败"
|
|
item := EnterpriseSiteConnectionResponse{
|
|
Status: "error",
|
|
LastError: &errorMessage,
|
|
LatestRecord: &EnterpriseSiteLatestPublishRecord{
|
|
Status: "failed",
|
|
ErrorMessage: &errorMessage,
|
|
},
|
|
}
|
|
|
|
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
|
|
|
|
if item.Status != "connected" {
|
|
t.Fatalf("status = %q, want connected", item.Status)
|
|
}
|
|
if item.LastError != nil {
|
|
t.Fatalf("last error = %q, want nil", *item.LastError)
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing.T) {
|
|
lastError := "CMS request failed"
|
|
recordError := "图片下载失败"
|
|
item := EnterpriseSiteConnectionResponse{
|
|
Status: "error",
|
|
LastError: &lastError,
|
|
LatestRecord: &EnterpriseSiteLatestPublishRecord{
|
|
Status: "failed",
|
|
ErrorMessage: &recordError,
|
|
},
|
|
}
|
|
|
|
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
|
|
|
|
if item.Status != "error" {
|
|
t.Fatalf("status = %q, want error", item.Status)
|
|
}
|
|
if item.LastError == nil || *item.LastError != lastError {
|
|
t.Fatalf("last error changed: %#v", item.LastError)
|
|
}
|
|
}
|
|
|
|
type enterpriseSiteImageObjectStorage struct {
|
|
content []byte
|
|
gotKey string
|
|
}
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) Validate() error { return nil }
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
|
s.gotKey = objectKey
|
|
return s.content, nil
|
|
}
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) Exists(context.Context, string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) Delete(context.Context, string) error { return nil }
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
|
|
|
|
func (s *enterpriseSiteImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
|
|
|
|
func testEnterpriseSitePNG() []byte {
|
|
return []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
|
|
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
0x42, 0x60, 0x82,
|
|
}
|
|
}
|