diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index 96f06a6..b474d02 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -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) { diff --git a/server/internal/tenant/app/article_service_test.go b/server/internal/tenant/app/article_service_test.go new file mode 100644 index 0000000..e9bd319 --- /dev/null +++ b/server/internal/tenant/app/article_service_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "strings" + "testing" +) + +func TestExtractReferencedImageAssetIDsFromMarkdown_DeduplicatesInOrder(t *testing.T) { + t.Parallel() + + markdown := strings.Join([]string{ + `before`, + `

`, + `

`, + `

`, + }, "\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{ + `

`, + `

`, + }, "\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 := `

` + + 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) + } +}