feat: implement article version content storage and reconstruction logic; enhance article repository with version management
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type ArticleService struct {
|
||||
@@ -161,7 +162,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
|
||||
// List
|
||||
listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
LEFT JOIN article_templates t ON t.id = a.template_id
|
||||
@@ -236,17 +237,15 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
for rows.Next() {
|
||||
var item ArticleListItem
|
||||
var dbSourceType string
|
||||
var markdownContent *string
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.GenerationErrorMessage, &markdownContent, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
|
||||
&item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
item.SourceType = dbSourceType
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.WordCount = resolveWordCount(markdownContent, item.WordCount)
|
||||
items = append(items, item)
|
||||
}
|
||||
if items == nil {
|
||||
@@ -281,12 +280,13 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
actor := auth.MustActor(ctx)
|
||||
var d ArticleDetailResponse
|
||||
var dbSourceType string
|
||||
var currentVersionID *int64
|
||||
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT a.id, a.source_type, a.template_id, a.generate_status, a.publish_status, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), av.html_content, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, av.version_no,
|
||||
SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, a.publish_status, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), COALESCE(av.word_count, 0), av.source_label, av.version_no,
|
||||
t.template_name, gt.error_message, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
@@ -299,12 +299,20 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
||||
&d.Title, &d.HTMLContent, &d.MarkdownContent, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, ¤tVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
||||
&d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
d.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
}
|
||||
d.HTMLContent = content.HTMLContent
|
||||
d.MarkdownContent = content.MarkdownContent
|
||||
}
|
||||
if len(wizardStateJSON) > 0 {
|
||||
d.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
|
||||
@@ -341,12 +349,17 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
}
|
||||
|
||||
sourceLabel := "自由创作"
|
||||
var versionID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label)
|
||||
VALUES ($1, 1, $2, NULL, '', 0, $3)
|
||||
RETURNING id
|
||||
`, articleID, title, sourceLabel).Scan(&versionID); err != nil {
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: articleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: "",
|
||||
WordCount: 0,
|
||||
SourceLabel: sourceLabel,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to create article version")
|
||||
}
|
||||
|
||||
@@ -426,12 +439,17 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
||||
}
|
||||
|
||||
var versionID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label)
|
||||
VALUES ($1, $2, $3, NULL, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, id, nextVersionNo, title, markdown, wordCount, sourceLabel).Scan(&versionID); err != nil {
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: id,
|
||||
VersionNo: nextVersionNo,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: markdown,
|
||||
WordCount: wordCount,
|
||||
SourceLabel: sourceLabel,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to create article version")
|
||||
}
|
||||
|
||||
@@ -527,7 +545,7 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, version_no, title, markdown_content, word_count, source_label, created_at
|
||||
SELECT id, version_no, title, word_count, source_label, created_at
|
||||
FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC
|
||||
`, articleID)
|
||||
if err != nil {
|
||||
@@ -538,11 +556,9 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
|
||||
var versions []VersionItem
|
||||
for rows.Next() {
|
||||
var v VersionItem
|
||||
var markdownContent *string
|
||||
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &markdownContent, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
v.WordCount = resolveWordCount(markdownContent, v.WordCount)
|
||||
versions = append(versions, v)
|
||||
}
|
||||
if versions == nil {
|
||||
|
||||
@@ -30,11 +30,12 @@ type ArticleRepository interface {
|
||||
}
|
||||
|
||||
type articleRepository struct {
|
||||
q generated.Querier
|
||||
db generated.DBTX
|
||||
q generated.Querier
|
||||
}
|
||||
|
||||
func NewArticleRepository(db generated.DBTX) ArticleRepository {
|
||||
return &articleRepository{q: newQuerier(db)}
|
||||
return &articleRepository{db: db, q: newQuerier(db)}
|
||||
}
|
||||
|
||||
func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArticleInput) (int64, error) {
|
||||
@@ -46,15 +47,42 @@ func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArtic
|
||||
}
|
||||
|
||||
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
|
||||
return r.q.CreateArticleVersion(ctx, generated.CreateArticleVersionParams{
|
||||
ArticleID: input.ArticleID,
|
||||
VersionNo: int32(input.VersionNo),
|
||||
Title: pgText(&input.Title),
|
||||
HtmlContent: pgText(&input.HTMLContent),
|
||||
MarkdownContent: pgText(&input.MarkdownContent),
|
||||
WordCount: int32(input.WordCount),
|
||||
SourceLabel: pgText(&input.SourceLabel),
|
||||
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
storedHTML, storedMarkdown := buildArticleVersionDiffStorage(*previous, ArticleVersionContent{
|
||||
HTMLContent: stringPtr(input.HTMLContent),
|
||||
MarkdownContent: stringPtr(input.MarkdownContent),
|
||||
})
|
||||
|
||||
var versionID int64
|
||||
err = r.db.QueryRow(ctx, `
|
||||
INSERT INTO article_versions (
|
||||
article_id,
|
||||
version_no,
|
||||
title,
|
||||
html_content,
|
||||
markdown_content,
|
||||
word_count,
|
||||
source_label
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
input.ArticleID,
|
||||
input.VersionNo,
|
||||
pgText(&input.Title),
|
||||
pgText(storedHTML),
|
||||
pgText(storedMarkdown),
|
||||
input.WordCount,
|
||||
pgText(&input.SourceLabel),
|
||||
).Scan(&versionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return versionID, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||
)
|
||||
|
||||
type ArticleVersionContent struct {
|
||||
HTMLContent *string
|
||||
MarkdownContent *string
|
||||
}
|
||||
|
||||
type storedArticleVersionContent struct {
|
||||
VersionNo int
|
||||
HTMLContent *string
|
||||
MarkdownContent *string
|
||||
}
|
||||
|
||||
func LoadArticleVersionContent(
|
||||
ctx context.Context,
|
||||
db generated.DBTX,
|
||||
articleID int64,
|
||||
versionID int64,
|
||||
) (*ArticleVersionContent, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT version_no, html_content, markdown_content
|
||||
FROM article_versions
|
||||
WHERE article_id = $1
|
||||
AND version_no <= (
|
||||
SELECT version_no
|
||||
FROM article_versions
|
||||
WHERE id = $2 AND article_id = $1
|
||||
)
|
||||
ORDER BY version_no ASC
|
||||
`, articleID, versionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
versions, err := collectStoredArticleVersionContents(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
return reconstructStoredArticleVersionContents(versions)
|
||||
}
|
||||
|
||||
func loadLatestArticleVersionContent(
|
||||
ctx context.Context,
|
||||
db generated.DBTX,
|
||||
articleID int64,
|
||||
) (*ArticleVersionContent, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT version_no, html_content, markdown_content
|
||||
FROM article_versions
|
||||
WHERE article_id = $1
|
||||
ORDER BY version_no ASC
|
||||
`, articleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
versions, err := collectStoredArticleVersionContents(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return &ArticleVersionContent{}, nil
|
||||
}
|
||||
|
||||
return reconstructStoredArticleVersionContents(versions)
|
||||
}
|
||||
|
||||
func collectStoredArticleVersionContents(rows pgx.Rows) ([]storedArticleVersionContent, error) {
|
||||
versions := make([]storedArticleVersionContent, 0)
|
||||
for rows.Next() {
|
||||
var version storedArticleVersionContent
|
||||
if err := rows.Scan(&version.VersionNo, &version.HTMLContent, &version.MarkdownContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions = append(versions, version)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
func buildArticleVersionDiffStorage(previous, current ArticleVersionContent) (*string, *string) {
|
||||
return stringPtr(articleVersionPatchText(textValue(previous.HTMLContent), textValue(current.HTMLContent))),
|
||||
stringPtr(articleVersionPatchText(textValue(previous.MarkdownContent), textValue(current.MarkdownContent)))
|
||||
}
|
||||
|
||||
func reconstructStoredArticleVersionContents(versions []storedArticleVersionContent) (*ArticleVersionContent, error) {
|
||||
var html string
|
||||
var markdown string
|
||||
|
||||
for _, version := range versions {
|
||||
nextHTML, err := applyArticleVersionPatch(html, version.HTMLContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply html patch for version %d: %w", version.VersionNo, err)
|
||||
}
|
||||
nextMarkdown, err := applyArticleVersionPatch(markdown, version.MarkdownContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply markdown patch for version %d: %w", version.VersionNo, err)
|
||||
}
|
||||
html = nextHTML
|
||||
markdown = nextMarkdown
|
||||
}
|
||||
|
||||
result := &ArticleVersionContent{}
|
||||
result.HTMLContent = stringPtr(html)
|
||||
result.MarkdownContent = stringPtr(markdown)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func articleVersionPatchText(previous, current string) string {
|
||||
dmp := diffmatchpatch.New()
|
||||
patches := dmp.PatchMake(previous, current)
|
||||
return dmp.PatchToText(patches)
|
||||
}
|
||||
|
||||
func applyArticleVersionPatch(base string, patchText *string) (string, error) {
|
||||
if patchText == nil || *patchText == "" {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
patches, err := dmp.PatchFromText(*patchText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
next, applied := dmp.PatchApply(patches, base)
|
||||
for _, ok := range applied {
|
||||
if !ok {
|
||||
return "", fmt.Errorf("patch apply failed")
|
||||
}
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func textValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
copied := value
|
||||
return &copied
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildArticleVersionDiffStorage_ReconstructsFromEmptyBase(t *testing.T) {
|
||||
current := ArticleVersionContent{
|
||||
HTMLContent: stringPtr(""),
|
||||
MarkdownContent: stringPtr("# First version\n\nHello world"),
|
||||
}
|
||||
|
||||
storedHTML, storedMarkdown := buildArticleVersionDiffStorage(ArticleVersionContent{}, current)
|
||||
|
||||
reconstructed, err := reconstructStoredArticleVersionContents([]storedArticleVersionContent{
|
||||
{
|
||||
VersionNo: 1,
|
||||
HTMLContent: storedHTML,
|
||||
MarkdownContent: storedMarkdown,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct stored content: %v", err)
|
||||
}
|
||||
|
||||
if textValue(reconstructed.HTMLContent) != textValue(current.HTMLContent) {
|
||||
t.Fatalf("unexpected html content: %q", textValue(reconstructed.HTMLContent))
|
||||
}
|
||||
if textValue(reconstructed.MarkdownContent) != textValue(current.MarkdownContent) {
|
||||
t.Fatalf("unexpected markdown content: %q", textValue(reconstructed.MarkdownContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructStoredArticleVersionContents_DiffChain(t *testing.T) {
|
||||
_, markdownV1 := buildArticleVersionDiffStorage(
|
||||
ArticleVersionContent{},
|
||||
ArticleVersionContent{MarkdownContent: stringPtr("alpha")},
|
||||
)
|
||||
_, markdownV2 := buildArticleVersionDiffStorage(
|
||||
ArticleVersionContent{MarkdownContent: stringPtr("alpha")},
|
||||
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta")},
|
||||
)
|
||||
_, markdownV3 := buildArticleVersionDiffStorage(
|
||||
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta")},
|
||||
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta gamma")},
|
||||
)
|
||||
|
||||
reconstructed, err := reconstructStoredArticleVersionContents([]storedArticleVersionContent{
|
||||
{
|
||||
VersionNo: 1,
|
||||
MarkdownContent: markdownV1,
|
||||
},
|
||||
{
|
||||
VersionNo: 2,
|
||||
MarkdownContent: markdownV2,
|
||||
},
|
||||
{
|
||||
VersionNo: 3,
|
||||
MarkdownContent: markdownV3,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct stored content: %v", err)
|
||||
}
|
||||
|
||||
if got := textValue(reconstructed.MarkdownContent); got != "alpha beta gamma" {
|
||||
t.Fatalf("unexpected markdown content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||
)
|
||||
|
||||
@@ -76,7 +73,7 @@ func (r *workspaceRepository) GetRecentArticles(ctx context.Context, tenantID in
|
||||
GenerationMode: nullableText(row.GenerationMode),
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
Title: nullableText(row.Title),
|
||||
WordCount: resolveWorkspaceWordCount(row.MarkdownContent, intFromInt4(row.WordCount)),
|
||||
WordCount: intFromInt4(row.WordCount),
|
||||
SourceLabel: nullableText(row.SourceLabel),
|
||||
TemplateName: nullableText(row.TemplateName),
|
||||
})
|
||||
@@ -107,12 +104,3 @@ func (r *workspaceRepository) GetActivePlanForTenant(ctx context.Context, tenant
|
||||
EndAt: timeFromTimestamp(row.EndAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceWordCount(markdown pgtype.Text, stored int) int {
|
||||
if markdown.Valid {
|
||||
if counted := contentstats.CountWords(markdown.String); counted > 0 {
|
||||
return counted
|
||||
}
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user