feat(imitation): add article imitation create flow

Introduce 仿写创作: new list and create views, backend imitation service
and prompt templates, worker task routing for imitation jobs, and one-click
rewrite triggers from question citation sources. Surface source article
URL/title on article list/detail, and restrict failed articles to delete-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 21:36:44 +08:00
parent 46d0a7aea0
commit 40d9a6cc63
29 changed files with 2678 additions and 120 deletions
+34 -2
View File
@@ -94,6 +94,8 @@ type ArticleListItem struct {
GenerationErrorMessage *string `json:"generation_error_message"`
WordCount int `json:"word_count"`
SourceLabel *string `json:"source_label"`
SourceURL *string `json:"source_url,omitempty"`
SourceTitle *string `json:"source_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -127,6 +129,8 @@ type ArticleDetailResponse struct {
MarkdownContent *string `json:"markdown_content"`
WordCount int `json:"word_count"`
SourceLabel *string `json:"source_label"`
SourceURL *string `json:"source_url,omitempty"`
SourceTitle *string `json:"source_title,omitempty"`
VersionNo *int `json:"version_no"`
GenerationErrorMessage *string `json:"generation_error_message"`
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
@@ -205,7 +209,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
argIdx++
}
if params.Keyword != nil {
countQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
countQuery += ` AND (
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
)`
countArgs = append(countArgs, *params.Keyword)
argIdx++
}
@@ -274,7 +282,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
listIdx++
}
if params.Keyword != nil {
listQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'`
listQuery += ` AND (
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
)`
listArgs = append(listArgs, *params.Keyword)
listIdx++
}
@@ -311,6 +323,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
item.SourceType = dbSourceType
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
items = append(items, item)
}
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
@@ -361,6 +374,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
}
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
@@ -1080,6 +1094,24 @@ func resolveArticleGenerationMode(sourceType string, inputParamsJSON []byte) *st
return &mode
}
func resolveArticleImitationSourceInfo(sourceType string, wizardStateJSON, inputParamsJSON []byte) (*string, *string) {
if sourceType != articleImitationSourceType {
return nil, nil
}
sourceURL := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_url"))
if sourceURL == "" {
sourceURL = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_url"))
}
sourceTitle := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_title"))
if sourceTitle == "" {
sourceTitle = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_title"))
}
return nilIfEmptyString(sourceURL), nilIfEmptyString(sourceTitle)
}
func nilIfEmptyString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {