fix: add image asset ID extraction and filtering functions for markdown content
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user