fix: add image asset ID extraction and filtering functions for markdown content

This commit is contained in:
2026-04-16 21:12:35 +08:00
parent 41f3060e00
commit ed10fd82e6
2 changed files with 231 additions and 25 deletions
+164 -25
View File
@@ -12,6 +12,7 @@ import (
_ "image/png"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -20,8 +21,8 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/singleflight"
_ "golang.org/x/image/webp"
"golang.org/x/sync/singleflight"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
"github.com/geo-platform/tenant-api/internal/shared/auth"
@@ -59,6 +60,9 @@ var supportedArticleImageExtensions = map[string]string{
"image/webp": "webp",
}
var articleMarkdownImageAssetIDPattern = regexp.MustCompile(`data-asset-id=(?:"|')(\d+)(?:"|')`)
var articleMarkdownImageAssetAttributePattern = regexp.MustCompile(`\s*data-asset-id=(?:"|')(\d+)(?:"|')`)
type ArticleListParams struct {
Page int
PageSize int
@@ -354,6 +358,29 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
markdownValue := ""
if item.MarkdownContent != nil {
markdownValue = *item.MarkdownContent
}
sanitizedMarkdown, _, sanitizedCoverImageAssetID, err := s.resolveArticleImageMetadata(
ctx,
s.pool,
tenantID,
markdownValue,
item.CoverImageAssetID,
)
if err != nil {
return nil, false, response.ErrInternal(50012, "query_failed", "failed to resolve article image metadata")
}
if item.MarkdownContent != nil {
item.MarkdownContent = &sanitizedMarkdown
}
item.CoverImageAssetID = sanitizedCoverImageAssetID
if item.WizardState != nil && sanitizedCoverImageAssetID == nil {
delete(item.WizardState, "cover_image_asset_id")
}
}
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
return &item, true, nil
}
@@ -437,10 +464,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
}
markdown := strings.TrimSpace(req.MarkdownContent)
referencedImageAssetIDs, err := normalizeImageAssetIDs(req.ReferencedImageAssetIDs)
if err != nil {
return nil, err
}
var referencedImageAssetIDs []int64
coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID)
if err != nil {
return nil, err
@@ -468,8 +492,14 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
}
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
if err := s.ensureActiveImageAssets(ctx, tx, actor.TenantID, imageAssetIDsToValidate); err != nil {
markdown, referencedImageAssetIDs, coverImageAssetID, err = s.resolveArticleImageMetadata(
ctx,
tx,
actor.TenantID,
markdown,
coverImageAssetID,
)
if err != nil {
return nil, err
}
@@ -484,7 +514,20 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
wordCount := countArticleWords(markdown)
sourceLabel := "手动编辑"
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL, req.CoverImageAssetID)
resolvedCoverImageAssetInput := req.CoverImageAssetID
if req.CoverImageAssetID.Set {
resolvedCoverImageAssetInput = NullableInt64Input{
Set: true,
Value: coverImageAssetID,
}
}
nextWizardStateJSON, err := mergeArticleWizardState(
wizardStateJSON,
title,
req.Platforms,
req.CoverAssetURL,
resolvedCoverImageAssetInput,
)
if err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
}
@@ -520,15 +563,13 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
}
imageRefRepo := repository.NewImageReferenceRepository(tx)
if req.ReferencedImageAssetIDs != nil {
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil {
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
}
for _, imageAssetID := range referencedImageAssetIDs {
if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
}
for _, imageAssetID := range referencedImageAssetIDs {
if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
}
}
}
if req.CoverImageAssetID.Set {
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_cover"); err != nil {
@@ -721,6 +762,10 @@ func countArticleWords(input string) int {
return contentstats.CountWords(input)
}
type imageAssetLookupQueryer interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
func normalizeImageAssetIDs(ids []int64) ([]int64, error) {
if ids == nil {
return nil, nil
@@ -777,12 +822,109 @@ func collectImageAssetIDsForValidation(bodyImageIDs []int64, coverImageAssetID *
return result
}
func (s *ArticleService) ensureActiveImageAssets(ctx context.Context, tx pgx.Tx, tenantID int64, imageAssetIDs []int64) error {
if len(imageAssetIDs) == 0 {
func extractReferencedImageAssetIDsFromMarkdown(markdown string) []int64 {
if strings.TrimSpace(markdown) == "" {
return nil
}
rows, err := tx.Query(ctx, `
matches := articleMarkdownImageAssetIDPattern.FindAllStringSubmatch(markdown, -1)
if len(matches) == 0 {
return nil
}
ids := make([]int64, 0, len(matches))
for _, match := range matches {
id, err := strconv.ParseInt(match[1], 10, 64)
if err != nil || id <= 0 {
continue
}
ids = append(ids, id)
}
normalized, err := normalizeImageAssetIDs(ids)
if err != nil {
return nil
}
return normalized
}
func filterActiveImageAssetIDs(ids []int64, activeIDs map[int64]struct{}) []int64 {
if len(ids) == 0 {
return nil
}
filtered := make([]int64, 0, len(ids))
for _, id := range ids {
if _, ok := activeIDs[id]; ok {
filtered = append(filtered, id)
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func stripInactiveImageAssetIDsFromMarkdown(markdown string, activeIDs map[int64]struct{}) string {
if markdown == "" {
return markdown
}
return articleMarkdownImageAssetAttributePattern.ReplaceAllStringFunc(markdown, func(match string) string {
parts := articleMarkdownImageAssetAttributePattern.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
id, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return ""
}
if _, ok := activeIDs[id]; ok {
return match
}
return ""
})
}
func (s *ArticleService) resolveArticleImageMetadata(
ctx context.Context,
q imageAssetLookupQueryer,
tenantID int64,
markdown string,
coverImageAssetID *int64,
) (string, []int64, *int64, error) {
referencedImageAssetIDs := extractReferencedImageAssetIDsFromMarkdown(markdown)
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
activeImageAssetIDs, err := s.loadActiveImageAssetIDSet(ctx, q, tenantID, imageAssetIDsToValidate)
if err != nil {
return "", nil, nil, err
}
resolvedCoverImageAssetID := coverImageAssetID
if coverImageAssetID != nil {
if _, ok := activeImageAssetIDs[*coverImageAssetID]; !ok {
resolvedCoverImageAssetID = nil
}
}
return stripInactiveImageAssetIDsFromMarkdown(markdown, activeImageAssetIDs),
filterActiveImageAssetIDs(referencedImageAssetIDs, activeImageAssetIDs),
resolvedCoverImageAssetID,
nil
}
func (s *ArticleService) loadActiveImageAssetIDSet(
ctx context.Context,
q imageAssetLookupQueryer,
tenantID int64,
imageAssetIDs []int64,
) (map[int64]struct{}, error) {
if len(imageAssetIDs) == 0 {
return map[int64]struct{}{}, nil
}
rows, err := q.Query(ctx, `
SELECT id
FROM image_assets
WHERE tenant_id = $1
@@ -791,7 +933,7 @@ WHERE tenant_id = $1
AND deleted_at IS NULL
`, tenantID, imageAssetIDs)
if err != nil {
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
}
defer rows.Close()
@@ -799,17 +941,14 @@ WHERE tenant_id = $1
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
}
validIDs[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
}
if len(validIDs) != len(imageAssetIDs) {
return response.ErrBadRequest(40021, "invalid_image_asset_ids", "one or more image assets are invalid or inactive")
}
return nil
return validIDs, nil
}
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
@@ -0,0 +1,67 @@
package app
import (
"strings"
"testing"
)
func TestExtractReferencedImageAssetIDsFromMarkdown_DeduplicatesInOrder(t *testing.T) {
t.Parallel()
markdown := strings.Join([]string{
`before`,
`<p><img src="https://example.com/1.webp" data-asset-id="42" /></p>`,
`<p><img src="https://example.com/2.webp" data-asset-id='7' /></p>`,
`<p><img src="https://example.com/3.webp" data-asset-id="42" /></p>`,
}, "\n")
got := extractReferencedImageAssetIDsFromMarkdown(markdown)
want := []int64{42, 7}
if len(got) != len(want) {
t.Fatalf("expected %d ids, got %d (%v)", len(want), len(got), got)
}
for idx := range want {
if got[idx] != want[idx] {
t.Fatalf("expected ids %v, got %v", want, got)
}
}
}
func TestStripInactiveImageAssetIDsFromMarkdown_RemovesOnlyInactiveAttrs(t *testing.T) {
t.Parallel()
markdown := strings.Join([]string{
`<p class="article-editor-image"><img src="https://example.com/active.webp" data-asset-id="12" /></p>`,
`<p class="article-editor-image"><img src="https://example.com/inactive.webp" data-asset-id="34" /></p>`,
}, "\n")
got := stripInactiveImageAssetIDsFromMarkdown(markdown, map[int64]struct{}{
12: {},
})
if !strings.Contains(got, `data-asset-id="12"`) {
t.Fatalf("expected active asset id to remain, got %q", got)
}
if strings.Contains(got, `data-asset-id="34"`) {
t.Fatalf("expected inactive asset id to be removed, got %q", got)
}
if !strings.Contains(got, `inactive.webp`) {
t.Fatalf("expected image markup to remain after stripping asset id, got %q", got)
}
}
func TestStripInactiveImageAssetIDsFromMarkdown_RemovesAllAttrsWhenNoAssetsAreActive(t *testing.T) {
t.Parallel()
markdown := `<p><img src="https://example.com/cover.webp" data-asset-id="88" /></p>`
got := stripInactiveImageAssetIDsFromMarkdown(markdown, map[int64]struct{}{})
if strings.Contains(got, `data-asset-id="88"`) {
t.Fatalf("expected inactive asset id to be removed, got %q", got)
}
if !strings.Contains(got, `cover.webp`) {
t.Fatalf("expected image src to remain, got %q", got)
}
}