feat(migrations): Harden task audit tracking and optimize article templates
- Added migration to harden task audit tracking by modifying audit_logs and related tables. - Introduced operator_id to several tables for better tracking of actions. - Updated article_templates with new prompt templates for various article types, enhancing content generation. - Created prompt_rules and schedule_tasks tables to manage content generation rules and scheduling. - Added foreign key constraints to articles for better data integrity.
This commit is contained in:
@@ -4,20 +4,25 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type ArticleService struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
}
|
||||
|
||||
func NewArticleService(pool *pgxpool.Pool) *ArticleService {
|
||||
return &ArticleService{pool: pool}
|
||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ArticleService {
|
||||
return &ArticleService{pool: pool, auditLogs: auditLogs}
|
||||
}
|
||||
|
||||
type ArticleListParams struct {
|
||||
@@ -27,19 +32,27 @@ type ArticleListParams struct {
|
||||
PublishStatus *string
|
||||
SourceType *string
|
||||
TemplateID *int64
|
||||
PromptRuleID *int64
|
||||
Keyword *string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
}
|
||||
|
||||
type ArticleListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
Platforms []string `json:"platforms"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ArticleListResponse struct {
|
||||
@@ -63,7 +76,17 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
// Count
|
||||
var total int64
|
||||
countArgs := []interface{}{actor.TenantID}
|
||||
countQuery := `SELECT COUNT(*) FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
countQuery := `SELECT COUNT(*) FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT input_params_json
|
||||
FROM generation_tasks
|
||||
WHERE article_id = a.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
argIdx := 2
|
||||
|
||||
if params.GenerateStatus != nil {
|
||||
@@ -86,9 +109,25 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
countArgs = append(countArgs, *params.TemplateID)
|
||||
argIdx++
|
||||
}
|
||||
if params.PromptRuleID != nil {
|
||||
countQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(argIdx)
|
||||
countArgs = append(countArgs, *params.PromptRuleID)
|
||||
argIdx++
|
||||
}
|
||||
if params.Keyword != nil {
|
||||
countQuery += ` AND av.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) + ` || '%'`
|
||||
countArgs = append(countArgs, *params.Keyword)
|
||||
argIdx++
|
||||
}
|
||||
if params.CreatedFrom != nil {
|
||||
countQuery += ` AND a.created_at >= $` + strconv.Itoa(argIdx)
|
||||
countArgs = append(countArgs, *params.CreatedFrom)
|
||||
argIdx++
|
||||
}
|
||||
if params.CreatedTo != nil {
|
||||
countQuery += ` AND a.created_at <= $` + strconv.Itoa(argIdx)
|
||||
countArgs = append(countArgs, *params.CreatedTo)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
||||
@@ -96,11 +135,19 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
|
||||
// List
|
||||
listQuery := `SELECT a.id, a.source_type, a.generate_status, a.publish_status, a.created_at,
|
||||
av.title, COALESCE(av.word_count, 0), av.source_label, t.template_name
|
||||
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
|
||||
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
|
||||
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT error_message, input_params_json
|
||||
FROM generation_tasks
|
||||
WHERE article_id = a.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
listArgs := []interface{}{actor.TenantID}
|
||||
listIdx := 2
|
||||
@@ -125,11 +172,26 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
listArgs = append(listArgs, *params.TemplateID)
|
||||
listIdx++
|
||||
}
|
||||
if params.PromptRuleID != nil {
|
||||
listQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(listIdx)
|
||||
listArgs = append(listArgs, *params.PromptRuleID)
|
||||
listIdx++
|
||||
}
|
||||
if params.Keyword != nil {
|
||||
listQuery += ` AND av.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) + ` || '%'`
|
||||
listArgs = append(listArgs, *params.Keyword)
|
||||
listIdx++
|
||||
}
|
||||
if params.CreatedFrom != nil {
|
||||
listQuery += ` AND a.created_at >= $` + strconv.Itoa(listIdx)
|
||||
listArgs = append(listArgs, *params.CreatedFrom)
|
||||
listIdx++
|
||||
}
|
||||
if params.CreatedTo != nil {
|
||||
listQuery += ` AND a.created_at <= $` + strconv.Itoa(listIdx)
|
||||
listArgs = append(listArgs, *params.CreatedTo)
|
||||
listIdx++
|
||||
}
|
||||
|
||||
listQuery += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(listIdx) + ` OFFSET $` + strconv.Itoa(listIdx+1)
|
||||
listArgs = append(listArgs, params.PageSize, offset)
|
||||
@@ -143,10 +205,15 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
var items []ArticleListItem
|
||||
for rows.Next() {
|
||||
var item ArticleListItem
|
||||
if err := rows.Scan(&item.ID, &item.SourceType, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.WordCount, &item.SourceLabel, &item.TemplateName); err != nil {
|
||||
var markdownContent *string
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
if err := rows.Scan(&item.ID, &item.SourceType, &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 {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.WordCount = resolveWordCount(markdownContent, item.WordCount)
|
||||
items = append(items, item)
|
||||
}
|
||||
if items == nil {
|
||||
@@ -157,35 +224,39 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
Platforms []string `json:"platforms"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var d ArticleDetailResponse
|
||||
|
||||
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,
|
||||
av.title, av.html_content, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, av.version_no,
|
||||
t.template_name, gt.error_message
|
||||
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,
|
||||
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
|
||||
LEFT JOIN article_templates t ON t.id = a.template_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT error_message
|
||||
SELECT error_message, input_params_json
|
||||
FROM generation_tasks
|
||||
WHERE article_id = a.id
|
||||
ORDER BY created_at DESC
|
||||
@@ -193,13 +264,105 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
) gt ON true
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&d.ID, &d.SourceType, &d.TemplateID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
||||
&d.Title, &d.HTMLContent, &d.MarkdownContent, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage)
|
||||
&d.Title, &d.HTMLContent, &d.MarkdownContent, &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")
|
||||
}
|
||||
if len(wizardStateJSON) > 0 {
|
||||
d.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
|
||||
}
|
||||
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
type UpdateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
Platforms []string `json:"platforms"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
return nil, response.ErrBadRequest(40012, "article_title_required", "article title is required")
|
||||
}
|
||||
|
||||
markdown := strings.TrimSpace(req.MarkdownContent)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to begin article update transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var generateStatus string
|
||||
var wizardStateJSON []byte
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT generate_status, wizard_state_json
|
||||
FROM articles
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
FOR UPDATE
|
||||
`, id, actor.TenantID).Scan(&generateStatus, &wizardStateJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
if generateStatus != "completed" {
|
||||
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
||||
}
|
||||
|
||||
var nextVersionNo int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(version_no), 0) + 1
|
||||
FROM article_versions
|
||||
WHERE article_id = $1
|
||||
`, id).Scan(&nextVersionNo); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to resolve next article version")
|
||||
}
|
||||
|
||||
wordCount := countArticleWords(markdown)
|
||||
sourceLabel := "手动编辑"
|
||||
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to create article version")
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE articles
|
||||
SET current_version_id = $1,
|
||||
generate_status = 'completed',
|
||||
publish_status = 'unpublished',
|
||||
wizard_state_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||
`, versionID, id, actor.TenantID, nextWizardStateJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article")
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update")
|
||||
}
|
||||
|
||||
return s.Detail(ctx, id)
|
||||
}
|
||||
|
||||
type VersionItem struct {
|
||||
ID int64 `json:"id"`
|
||||
VersionNo int `json:"version_no"`
|
||||
@@ -221,7 +384,7 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, version_no, title, word_count, source_label, created_at
|
||||
SELECT id, version_no, title, markdown_content, word_count, source_label, created_at
|
||||
FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC
|
||||
`, articleID)
|
||||
if err != nil {
|
||||
@@ -232,9 +395,11 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
|
||||
var versions []VersionItem
|
||||
for rows.Next() {
|
||||
var v VersionItem
|
||||
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
|
||||
var markdownContent *string
|
||||
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &markdownContent, &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 {
|
||||
@@ -259,11 +424,41 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
// Audit log
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
INSERT INTO audit_logs (operator_id, tenant_id, module, action, before_json, result)
|
||||
VALUES ($1, $2, 'article', 'delete', $3, 'success')
|
||||
`, actor.UserID, actor.TenantID, json.RawMessage(beforeJSON))
|
||||
result := "success"
|
||||
resourceType := "article"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "article",
|
||||
Action: "delete",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
BeforeJSON: json.RawMessage(beforeJSON),
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func countArticleWords(input string) int {
|
||||
return contentstats.CountWords(input)
|
||||
}
|
||||
|
||||
func resolveWordCount(markdown *string, stored int) int {
|
||||
if markdown != nil {
|
||||
if counted := countArticleWords(*markdown); counted > 0 {
|
||||
return counted
|
||||
}
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
func nilIfEmptyString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user