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
|
||||
}
|
||||
|
||||
@@ -8,16 +8,19 @@ import (
|
||||
|
||||
"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/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type BrandService struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
}
|
||||
|
||||
func NewBrandService(pool *pgxpool.Pool) *BrandService {
|
||||
return &BrandService{pool: pool}
|
||||
func NewBrandService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *BrandService {
|
||||
return &BrandService{pool: pool, auditLogs: auditLogs}
|
||||
}
|
||||
|
||||
// --- Brand CRUD ---
|
||||
@@ -77,8 +80,20 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||
_, _ = s.pool.Exec(ctx, `INSERT INTO audit_logs (operator_id, tenant_id, module, action, after_json, result) VALUES ($1, $2, 'brand', 'create', $3, 'success')`,
|
||||
actor.UserID, actor.TenantID, afterJSON)
|
||||
result := "success"
|
||||
resourceType := "brand"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "brand",
|
||||
Action: "create",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return &BrandResponse{ID: id, Name: req.Name, Description: req.Description, Status: "active", CreatedAt: fmt.Sprintf("%v", ca)}, nil
|
||||
}
|
||||
@@ -130,10 +145,26 @@ func (s *BrandService) Delete(ctx context.Context, id int64) error {
|
||||
_, _ = tx.Exec(ctx, `UPDATE competitors SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "action": "cascade_soft_delete"})
|
||||
_, _ = tx.Exec(ctx, `INSERT INTO audit_logs (operator_id, tenant_id, module, action, after_json, result) VALUES ($1, $2, 'brand', 'delete', $3, 'success')`,
|
||||
actor.UserID, actor.TenantID, afterJSON)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
result := "success"
|
||||
resourceType := "brand"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "brand",
|
||||
Action: "delete",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Keyword CRUD ---
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type PromptRuleGenerationService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
maxOutputTokens int64
|
||||
jobs chan promptRuleGenerationJob
|
||||
}
|
||||
|
||||
type promptRuleGenerationJob struct {
|
||||
OperatorID int64
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
TaskID int64
|
||||
ReservationID int64
|
||||
Prompt string
|
||||
InputParams map[string]interface{}
|
||||
InitialTitle string
|
||||
WebSearch llm.WebSearchOptions
|
||||
}
|
||||
|
||||
type GenerateFromRuleRequest struct {
|
||||
PromptRuleID int64 `json:"prompt_rule_id" binding:"required"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
TargetPlatform *string `json:"target_platform"`
|
||||
ExtraParams map[string]interface{} `json:"extra_params"`
|
||||
}
|
||||
|
||||
type GenerateFromRuleResponse struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
}
|
||||
|
||||
type promptRuleGenerationRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
PromptContent string
|
||||
Scene *string
|
||||
DefaultTone *string
|
||||
DefaultWordCount *int
|
||||
Status string
|
||||
}
|
||||
|
||||
func NewPromptRuleGenerationService(
|
||||
pool *pgxpool.Pool,
|
||||
llmClient llm.Client,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
) *PromptRuleGenerationService {
|
||||
queueSize := cfg.QueueSize
|
||||
if queueSize <= 0 {
|
||||
queueSize = 128
|
||||
}
|
||||
|
||||
workerCount := cfg.WorkerConcurrency
|
||||
if workerCount <= 0 {
|
||||
workerCount = 1
|
||||
}
|
||||
|
||||
articleTimeout := cfg.ArticleTimeout
|
||||
if articleTimeout <= 0 {
|
||||
articleTimeout = 8 * time.Minute
|
||||
}
|
||||
|
||||
svc := &PromptRuleGenerationService{
|
||||
pool: pool,
|
||||
llm: llmClient,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
articleTimeout: articleTimeout,
|
||||
maxOutputTokens: llmMaxOutputTokens,
|
||||
jobs: make(chan promptRuleGenerationJob, queueSize),
|
||||
}
|
||||
|
||||
for i := 0; i < workerCount; i++ {
|
||||
go svc.runGenerationWorker()
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
}
|
||||
|
||||
rule, err := s.loadPromptRule(ctx, actor.TenantID, req.PromptRuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rule.Status != "enabled" {
|
||||
return nil, response.ErrBadRequest(40017, "prompt_rule_disabled", "prompt rule is disabled")
|
||||
}
|
||||
|
||||
extra := req.ExtraParams
|
||||
if extractGenerateCount(extra) > 1 {
|
||||
return nil, response.ErrBadRequest(40018, "generate_count_not_supported", "prompt rule instant generation currently supports generate_count = 1 only")
|
||||
}
|
||||
|
||||
initialTitle := strings.TrimSpace(extractString(extra, "task_name"))
|
||||
if initialTitle == "" {
|
||||
initialTitle = strings.TrimSpace(rule.Name)
|
||||
}
|
||||
if initialTitle == "" {
|
||||
initialTitle = "Generated Article"
|
||||
}
|
||||
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||
if err != nil || balance < 1 {
|
||||
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||
}
|
||||
|
||||
inputParams := map[string]interface{}{
|
||||
"title": initialTitle,
|
||||
"prompt_rule_id": req.PromptRuleID,
|
||||
"target_platform": strings.TrimSpace(derefString(req.TargetPlatform)),
|
||||
"enable_web_search": extractBool(extra, "enable_web_search"),
|
||||
}
|
||||
platformIDs := parsePlatformIDs(derefString(req.TargetPlatform))
|
||||
if len(platformIDs) > 0 {
|
||||
inputParams["target_platforms"] = platformIDs
|
||||
}
|
||||
if req.BrandID != nil {
|
||||
inputParams["brand_id"] = *req.BrandID
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(inputParams)
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, initialTitle, platformIDs)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to begin generation transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
quotaTx := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
newBalance := balance - 1
|
||||
reason := "generation_reserve"
|
||||
referenceType := "generation_task"
|
||||
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: -1,
|
||||
BalanceAfter: newBalance,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to reserve generation quota")
|
||||
}
|
||||
|
||||
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
ResourceType: "article",
|
||||
ResourceID: nil,
|
||||
ReservedAmount: 1,
|
||||
ExpireAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to create quota reservation")
|
||||
}
|
||||
|
||||
var articleID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO articles (tenant_id, source_type, prompt_rule_id, generate_status, publish_status, wizard_state_json)
|
||||
VALUES ($1, 'prompt_rule', $2, 'generating', 'unpublished', $3)
|
||||
RETURNING id
|
||||
`, actor.TenantID, req.PromptRuleID, wizardStateJSON).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to create prompt rule article")
|
||||
}
|
||||
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to update quota reservation resource")
|
||||
}
|
||||
|
||||
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: &articleID,
|
||||
QuotaReservationID: &reservationID,
|
||||
TaskType: "prompt_rule",
|
||||
InputParamsJSON: inputJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to create generation task")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task")
|
||||
}
|
||||
|
||||
job := promptRuleGenerationJob{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
TaskID: taskID,
|
||||
ReservationID: reservationID,
|
||||
Prompt: buildPromptRuleGenerationPrompt(rule, inputParams),
|
||||
InputParams: inputParams,
|
||||
InitialTitle: initialTitle,
|
||||
WebSearch: llm.WebSearchOptions{
|
||||
Enabled: extractBool(extra, "enable_web_search"),
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.enqueueGeneration(job); err != nil {
|
||||
s.failGeneration(context.Background(), job, job.InitialTitle, "queue_enqueue", err)
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||
}
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Start(articleID, taskID, initialTitle)
|
||||
}
|
||||
|
||||
return &GenerateFromRuleResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) loadPromptRule(ctx context.Context, tenantID, promptRuleID int64) (*promptRuleGenerationRecord, error) {
|
||||
record := &promptRuleGenerationRecord{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, prompt_content, scene, default_tone, default_word_count, status
|
||||
FROM prompt_rules
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, promptRuleID, tenantID).Scan(
|
||||
&record.ID,
|
||||
&record.Name,
|
||||
&record.PromptContent,
|
||||
&record.Scene,
|
||||
&record.DefaultTone,
|
||||
&record.DefaultWordCount,
|
||||
&record.Status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) enqueueGeneration(job promptRuleGenerationJob) error {
|
||||
select {
|
||||
case s.jobs <- job:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("generation queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) runGenerationWorker() {
|
||||
for job := range s.jobs {
|
||||
s.executeGeneration(context.Background(), job)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job promptRuleGenerationJob) {
|
||||
now := time.Now()
|
||||
title := strings.TrimSpace(job.InitialTitle)
|
||||
if title == "" {
|
||||
title = "Generated Article"
|
||||
}
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
req := llm.GenerateRequest{
|
||||
Prompt: job.Prompt,
|
||||
Timeout: s.articleTimeout,
|
||||
MaxOutputTokens: s.maxOutputTokens,
|
||||
}
|
||||
if job.WebSearch.Enabled {
|
||||
req.WebSearch = &job.WebSearch
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(ctx, req, func(delta string) {
|
||||
if s.streamEnabled && delta != "" {
|
||||
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, title, "llm_generate", err)
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
s.failGeneration(ctx, job, title, "llm_generate", fmt.Errorf("模型返回空内容"))
|
||||
return
|
||||
}
|
||||
|
||||
title = resolveArticleTitle(job.InputParams, content)
|
||||
wordCount := estimateWordCount(content)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_begin_tx", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
quotaRepo := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
WordCount: wordCount,
|
||||
SourceLabel: result.Model,
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_create_version", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_current_version", err)
|
||||
return
|
||||
}
|
||||
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_article_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
completed := time.Now()
|
||||
if err := auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "completed",
|
||||
StartedAt: &now,
|
||||
CompletedAt: &completed,
|
||||
}); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_task_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "confirm_reservation", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_commit", err)
|
||||
return
|
||||
}
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job promptRuleGenerationJob, title, stage string, genErr error) {
|
||||
errMsg := formatGenerationFailure(stage, genErr)
|
||||
now := time.Now()
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
articleRepo := repository.NewArticleRepository(s.pool)
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "failed")
|
||||
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, job.TenantID, "article_generation")
|
||||
if balanceErr == nil {
|
||||
reason := "generation_refund"
|
||||
referenceType := "generation_task"
|
||||
taskReferenceID := job.TaskID
|
||||
_, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: job.TenantID,
|
||||
OperatorID: job.OperatorID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: 1,
|
||||
BalanceAfter: balance + 1,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceID: &taskReferenceID,
|
||||
})
|
||||
}
|
||||
|
||||
_ = quotaRepo.RefundReservation(ctx, job.ReservationID, job.TenantID)
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
|
||||
sections := []string{strings.TrimSpace(rule.PromptContent)}
|
||||
|
||||
var supplements []string
|
||||
if title := strings.TrimSpace(extractString(params, "title")); title != "" {
|
||||
supplements = append(supplements, "任务名称:"+title)
|
||||
}
|
||||
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
||||
supplements = append(supplements, "适用场景:"+strings.TrimSpace(*rule.Scene))
|
||||
}
|
||||
if rule.DefaultTone != nil && strings.TrimSpace(*rule.DefaultTone) != "" {
|
||||
supplements = append(supplements, "默认语气:"+strings.TrimSpace(*rule.DefaultTone))
|
||||
}
|
||||
if rule.DefaultWordCount != nil && *rule.DefaultWordCount > 0 {
|
||||
supplements = append(supplements, "建议字数:"+strconv.Itoa(*rule.DefaultWordCount)+" 字左右")
|
||||
}
|
||||
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
|
||||
supplements = append(supplements, "目标发布平台:"+strings.ReplaceAll(target, ",", "、"))
|
||||
}
|
||||
|
||||
if len(supplements) > 0 {
|
||||
sections = append(sections, "补充要求:\n- "+strings.Join(supplements, "\n- "))
|
||||
}
|
||||
|
||||
sections = append(sections, "输出要求:\n1. 直接输出完整可发布的中文 Markdown 文章。\n2. 标题使用一级标题。\n3. 不要输出你的思考过程、解释或致歉。")
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func extractGenerateCount(extra map[string]interface{}) int {
|
||||
value := extractInt(extra, "generate_count")
|
||||
if value <= 0 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func extractBool(extra map[string]interface{}, key string) bool {
|
||||
if len(extra) == 0 {
|
||||
return false
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
normalized := strings.TrimSpace(strings.ToLower(typed))
|
||||
return normalized == "true" || normalized == "1" || normalized == "yes"
|
||||
case float64:
|
||||
return typed != 0
|
||||
case int:
|
||||
return typed != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func extractInt(extra map[string]interface{}, key string) int {
|
||||
if len(extra) == 0 {
|
||||
return 0
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok || raw == nil {
|
||||
return 0
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case string:
|
||||
value, _ := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return value
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func extractString(payload map[string]interface{}, key string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := payload[key]
|
||||
if !ok || raw == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type PromptRuleService struct {
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
}
|
||||
|
||||
func NewPromptRuleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *PromptRuleService {
|
||||
return &PromptRuleService{pool: pool, auditLogs: auditLogs}
|
||||
}
|
||||
|
||||
// --- Group types ---
|
||||
|
||||
type PromptRuleGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type PromptRuleGroupResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// --- Rule types ---
|
||||
|
||||
type PromptRuleRequest struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
PromptContent string `json:"prompt_content" binding:"required"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
}
|
||||
|
||||
type PromptRuleResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
PromptContent string `json:"prompt_content"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
Status string `json:"status"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptRuleListParams struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Status *string `json:"status"`
|
||||
Keyword *string `json:"keyword"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Ungrouped bool `json:"ungrouped"`
|
||||
}
|
||||
|
||||
type PromptRuleListResponse struct {
|
||||
Items []PromptRuleResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type PromptRuleStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=enabled disabled"`
|
||||
}
|
||||
|
||||
type PromptRuleSimple struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// --- Group CRUD ---
|
||||
|
||||
func (s *PromptRuleService) ListGroups(ctx context.Context) ([]PromptRuleGroupResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, sort_order, created_at
|
||||
FROM prompt_rule_groups WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list groups")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []PromptRuleGroupResponse
|
||||
for rows.Next() {
|
||||
var g PromptRuleGroupResponse
|
||||
var ca interface{}
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder, &ca); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
g.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
groups = append(groups, g)
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []PromptRuleGroupResponse{}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroupRequest) (*PromptRuleGroupResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
sortOrder := 0
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = *req.SortOrder
|
||||
}
|
||||
var id int64
|
||||
var ca interface{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO prompt_rule_groups (tenant_id, name, sort_order)
|
||||
VALUES ($1, $2, $3) RETURNING id, created_at
|
||||
`, actor.TenantID, req.Name, sortOrder).Scan(&id, &ca)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create group")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||
result := "success"
|
||||
resourceType := "prompt_rule_group"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "prompt_rule",
|
||||
Action: "create_group",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return &PromptRuleGroupResponse{ID: id, Name: req.Name, SortOrder: sortOrder, CreatedAt: fmt.Sprintf("%v", ca)}, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) UpdateGroup(ctx context.Context, id int64, req PromptRuleGroupRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
sortOrder := 0
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = *req.SortOrder
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rule_groups SET name = $1, sort_order = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL
|
||||
`, req.Name, sortOrder, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
// Move rules in this group to ungrouped
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rules SET group_id = NULL, updated_at = NOW()
|
||||
WHERE group_id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rule_groups SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id})
|
||||
result := "success"
|
||||
resourceType := "prompt_rule_group"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "prompt_rule",
|
||||
Action: "delete_group",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Rule CRUD ---
|
||||
|
||||
func (s *PromptRuleService) List(ctx context.Context, params PromptRuleListParams) (*PromptRuleListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 || params.PageSize > 100 {
|
||||
params.PageSize = 20
|
||||
}
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
var total int64
|
||||
var rows interface{ Close() }
|
||||
var queryErr error
|
||||
|
||||
if params.Ungrouped {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM prompt_rules
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL AND group_id IS NULL
|
||||
`, actor.TenantID).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "count_failed", "failed to count rules")
|
||||
}
|
||||
|
||||
r, err := s.pool.Query(ctx, `
|
||||
SELECT pr.id, pr.group_id, pr.name, pr.prompt_content,
|
||||
pr.scene, pr.default_tone, pr.default_word_count, pr.status,
|
||||
pr.created_at, pr.updated_at,
|
||||
COUNT(a.id)::INT AS article_count
|
||||
FROM prompt_rules pr
|
||||
LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL
|
||||
WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL AND pr.group_id IS NULL
|
||||
GROUP BY pr.id
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, actor.TenantID, params.PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list rules")
|
||||
}
|
||||
rows = r
|
||||
queryErr = err
|
||||
} else {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM prompt_rules
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND ($2::bigint IS NULL OR group_id = $2)
|
||||
AND ($3::text IS NULL OR status = $3)
|
||||
AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%')
|
||||
`, actor.TenantID, params.GroupID, params.Status, params.Keyword).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "count_failed", "failed to count rules")
|
||||
}
|
||||
|
||||
r, err := s.pool.Query(ctx, `
|
||||
SELECT pr.id, pr.group_id, pr.name, pr.prompt_content,
|
||||
pr.scene, pr.default_tone, pr.default_word_count, pr.status,
|
||||
pr.created_at, pr.updated_at,
|
||||
COUNT(a.id)::INT AS article_count
|
||||
FROM prompt_rules pr
|
||||
LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL
|
||||
WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL
|
||||
AND ($2::bigint IS NULL OR pr.group_id = $2)
|
||||
AND ($3::text IS NULL OR pr.status = $3)
|
||||
AND ($4::text IS NULL OR pr.name ILIKE '%' || $4 || '%')
|
||||
GROUP BY pr.id
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT $5 OFFSET $6
|
||||
`, actor.TenantID, params.GroupID, params.Status, params.Keyword, params.PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list rules")
|
||||
}
|
||||
rows = r
|
||||
queryErr = err
|
||||
}
|
||||
|
||||
_ = queryErr
|
||||
|
||||
pgxRows := rows.(interface {
|
||||
Close()
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
})
|
||||
defer pgxRows.Close()
|
||||
|
||||
var items []PromptRuleResponse
|
||||
for pgxRows.Next() {
|
||||
var r PromptRuleResponse
|
||||
var ca, ua interface{}
|
||||
if err := pgxRows.Scan(&r.ID, &r.GroupID, &r.Name, &r.PromptContent,
|
||||
&r.Scene, &r.DefaultTone, &r.DefaultWordCount, &r.Status,
|
||||
&ca, &ua, &r.ArticleCount); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
items = append(items, r)
|
||||
}
|
||||
if items == nil {
|
||||
items = []PromptRuleResponse{}
|
||||
}
|
||||
|
||||
return &PromptRuleListResponse{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var r PromptRuleResponse
|
||||
var ca, ua interface{}
|
||||
var articleCount int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT pr.id, pr.group_id, pr.name, pr.prompt_content,
|
||||
pr.scene, pr.default_tone, pr.default_word_count, pr.status,
|
||||
pr.created_at, pr.updated_at,
|
||||
(SELECT COUNT(*) FROM articles WHERE prompt_rule_id = pr.id AND deleted_at IS NULL)::INT
|
||||
FROM prompt_rules pr
|
||||
WHERE pr.id = $1 AND pr.tenant_id = $2 AND pr.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&r.ID, &r.GroupID, &r.Name, &r.PromptContent,
|
||||
&r.Scene, &r.DefaultTone, &r.DefaultWordCount, &r.Status,
|
||||
&ca, &ua, &articleCount)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
r.ArticleCount = articleCount
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var id int64
|
||||
var ca interface{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at
|
||||
`, actor.TenantID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||
result := "success"
|
||||
resourceType := "prompt_rule"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "prompt_rule",
|
||||
Action: "create",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return &PromptRuleResponse{
|
||||
ID: id, GroupID: req.GroupID, Name: req.Name, PromptContent: req.PromptContent,
|
||||
Scene: req.Scene, DefaultTone: req.DefaultTone, DefaultWordCount: req.DefaultWordCount,
|
||||
Status: "enabled", ArticleCount: 0, CreatedAt: fmt.Sprintf("%v", ca),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rules SET name = $1, group_id = $2, prompt_content = $3,
|
||||
scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW()
|
||||
WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL
|
||||
`, req.Name, req.GroupID, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Delete(ctx context.Context, id int64) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rules SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id})
|
||||
result := "success"
|
||||
resourceType := "prompt_rule"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "prompt_rule",
|
||||
Action: "delete",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) ToggleStatus(ctx context.Context, id int64, req PromptRuleStatusRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE prompt_rules SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||
`, req.Status, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name FROM prompt_rules
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'enabled'
|
||||
ORDER BY name
|
||||
`, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list rules")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []PromptRuleSimple
|
||||
for rows.Next() {
|
||||
var r PromptRuleSimple
|
||||
if err := rows.Scan(&r.ID, &r.Name); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
items = append(items, r)
|
||||
}
|
||||
if items == nil {
|
||||
items = []PromptRuleSimple{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizePlatformIDs(platformIDs []string) []string {
|
||||
if len(platformIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(platformIDs))
|
||||
normalized := make([]string, 0, len(platformIDs))
|
||||
for _, platformID := range platformIDs {
|
||||
next := strings.TrimSpace(platformID)
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[next]; exists {
|
||||
continue
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
normalized = append(normalized, next)
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parsePlatformIDs(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return normalizePlatformIDs(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func serializePlatformIDs(platformIDs []string) string {
|
||||
normalized := normalizePlatformIDs(platformIDs)
|
||||
if len(normalized) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(normalized, ",")
|
||||
}
|
||||
|
||||
func resolveArticlePlatforms(wizardStateJSON, inputParamsJSON []byte) []string {
|
||||
if platforms := extractPlatformsFromJSONPayload(wizardStateJSON); len(platforms) > 0 {
|
||||
return platforms
|
||||
}
|
||||
if platforms := extractPlatformsFromJSONPayload(inputParamsJSON); len(platforms) > 0 {
|
||||
return platforms
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func extractPlatformsFromJSONPayload(raw []byte) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if platforms := normalizePlatformsFromValue(payload["platforms"]); len(platforms) > 0 {
|
||||
return platforms
|
||||
}
|
||||
if platforms := normalizePlatformsFromValue(payload["target_platforms"]); len(platforms) > 0 {
|
||||
return platforms
|
||||
}
|
||||
|
||||
if targetPlatform, ok := payload["target_platform"].(string); ok {
|
||||
return parsePlatformIDs(targetPlatform)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePlatformsFromValue(raw interface{}) []string {
|
||||
switch typed := raw.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
return parsePlatformIDs(typed)
|
||||
case []string:
|
||||
return normalizePlatformIDs(typed)
|
||||
case []interface{}:
|
||||
values := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return normalizePlatformIDs(values)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mergeArticleWizardState(raw []byte, title string, platforms []string) ([]byte, error) {
|
||||
state := make(map[string]interface{})
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &state)
|
||||
}
|
||||
|
||||
trimmedTitle := strings.TrimSpace(title)
|
||||
if trimmedTitle != "" {
|
||||
state["title"] = trimmedTitle
|
||||
}
|
||||
|
||||
if platforms != nil {
|
||||
normalized := normalizePlatformIDs(platforms)
|
||||
if len(normalized) == 0 {
|
||||
delete(state, "platforms")
|
||||
delete(state, "target_platform")
|
||||
} else {
|
||||
state["platforms"] = normalized
|
||||
state["target_platform"] = strings.Join(normalized, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(state)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type ScheduleTaskService struct {
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
}
|
||||
|
||||
func NewScheduleTaskService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ScheduleTaskService {
|
||||
return &ScheduleTaskService{pool: pool, auditLogs: auditLogs}
|
||||
}
|
||||
|
||||
type ScheduleTaskRequest struct {
|
||||
PromptRuleID int64 `json:"prompt_rule_id" binding:"required"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
CronExpr string `json:"cron_expr" binding:"required"`
|
||||
TargetPlatform *string `json:"target_platform"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
}
|
||||
|
||||
type ScheduleTaskResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PromptRuleID int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
TargetPlatform *string `json:"target_platform"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
NextRunAt *string `json:"next_run_at"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScheduleTaskListParams struct {
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
Status *string `json:"status"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type ScheduleTaskListResponse struct {
|
||||
Items []ScheduleTaskResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type ScheduleTaskStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=enabled disabled"`
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) List(ctx context.Context, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 || params.PageSize > 100 {
|
||||
params.PageSize = 20
|
||||
}
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
var total int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM schedule_tasks
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND ($2::bigint IS NULL OR prompt_rule_id = $2)
|
||||
AND ($3::text IS NULL OR status = $3)
|
||||
`, actor.TenantID, params.PromptRuleID, params.Status).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "count_failed", "failed to count schedule tasks")
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT st.id, st.prompt_rule_id, st.brand_id, st.name,
|
||||
st.cron_expr, st.target_platform, st.start_at, st.end_at,
|
||||
st.next_run_at, st.status, st.created_at, st.updated_at,
|
||||
pr.name AS prompt_rule_name,
|
||||
b.name AS brand_name
|
||||
FROM schedule_tasks st
|
||||
LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id
|
||||
LEFT JOIN brands b ON b.id = st.brand_id
|
||||
WHERE st.tenant_id = $1 AND st.deleted_at IS NULL
|
||||
AND ($2::bigint IS NULL OR st.prompt_rule_id = $2)
|
||||
AND ($3::text IS NULL OR st.status = $3)
|
||||
ORDER BY st.created_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`, actor.TenantID, params.PromptRuleID, params.Status, params.PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list schedule tasks")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ScheduleTaskResponse
|
||||
for rows.Next() {
|
||||
var r ScheduleTaskResponse
|
||||
var ca, ua interface{}
|
||||
var startAt, endAt, nextRunAt interface{}
|
||||
if err := rows.Scan(&r.ID, &r.PromptRuleID, &r.BrandID, &r.Name,
|
||||
&r.CronExpr, &r.TargetPlatform, &startAt, &endAt,
|
||||
&nextRunAt, &r.Status, &ca, &ua,
|
||||
&r.PromptRuleName, &r.BrandName); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
if startAt != nil {
|
||||
s := fmt.Sprintf("%v", startAt)
|
||||
r.StartAt = &s
|
||||
}
|
||||
if endAt != nil {
|
||||
s := fmt.Sprintf("%v", endAt)
|
||||
r.EndAt = &s
|
||||
}
|
||||
if nextRunAt != nil {
|
||||
s := fmt.Sprintf("%v", nextRunAt)
|
||||
r.NextRunAt = &s
|
||||
}
|
||||
items = append(items, r)
|
||||
}
|
||||
if items == nil {
|
||||
items = []ScheduleTaskResponse{}
|
||||
}
|
||||
|
||||
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) Detail(ctx context.Context, id int64) (*ScheduleTaskResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var r ScheduleTaskResponse
|
||||
var ca, ua interface{}
|
||||
var startAt, endAt, nextRunAt interface{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT st.id, st.prompt_rule_id, st.brand_id, st.name,
|
||||
st.cron_expr, st.target_platform, st.start_at, st.end_at,
|
||||
st.next_run_at, st.status, st.created_at, st.updated_at,
|
||||
pr.name AS prompt_rule_name,
|
||||
b.name AS brand_name
|
||||
FROM schedule_tasks st
|
||||
LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id
|
||||
LEFT JOIN brands b ON b.id = st.brand_id
|
||||
WHERE st.id = $1 AND st.tenant_id = $2 AND st.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&r.ID, &r.PromptRuleID, &r.BrandID, &r.Name,
|
||||
&r.CronExpr, &r.TargetPlatform, &startAt, &endAt,
|
||||
&nextRunAt, &r.Status, &ca, &ua,
|
||||
&r.PromptRuleName, &r.BrandName)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
|
||||
}
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
if startAt != nil {
|
||||
sv := fmt.Sprintf("%v", startAt)
|
||||
r.StartAt = &sv
|
||||
}
|
||||
if endAt != nil {
|
||||
sv := fmt.Sprintf("%v", endAt)
|
||||
r.EndAt = &sv
|
||||
}
|
||||
if nextRunAt != nil {
|
||||
sv := fmt.Sprintf("%v", nextRunAt)
|
||||
r.NextRunAt = &sv
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskRequest) (*ScheduleTaskResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
// Verify prompt rule belongs to same tenant
|
||||
var exists bool
|
||||
_ = s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM prompt_rules WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
|
||||
`, req.PromptRuleID, actor.TenantID).Scan(&exists)
|
||||
if !exists {
|
||||
return nil, response.ErrBadRequest(40001, "invalid_rule", "prompt rule not found or not active")
|
||||
}
|
||||
|
||||
var id int64
|
||||
var ca interface{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO schedule_tasks (tenant_id, prompt_rule_id, brand_id, name, cron_expr, target_platform, start_at, end_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz) RETURNING id, created_at
|
||||
`, actor.TenantID, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, req.TargetPlatform, req.StartAt, req.EndAt).Scan(&id, &ca)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name, "cron_expr": req.CronExpr})
|
||||
result := "success"
|
||||
resourceType := "schedule_task"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "schedule_task",
|
||||
Action: "create",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
return &ScheduleTaskResponse{
|
||||
ID: id, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
|
||||
Name: req.Name, CronExpr: req.CronExpr, TargetPlatform: req.TargetPlatform,
|
||||
Status: "enabled", CreatedAt: fmt.Sprintf("%v", ca),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req ScheduleTaskRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE schedule_tasks SET prompt_rule_id = $1, brand_id = $2, name = $3,
|
||||
cron_expr = $4, target_platform = $5, start_at = $6::timestamptz, end_at = $7::timestamptz, updated_at = NOW()
|
||||
WHERE id = $8 AND tenant_id = $9 AND deleted_at IS NULL
|
||||
`, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, req.TargetPlatform, req.StartAt, req.EndAt, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) Delete(ctx context.Context, id int64) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE schedule_tasks SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id})
|
||||
result := "success"
|
||||
resourceType := "schedule_task"
|
||||
requestID := middleware.RequestIDFromContext(ctx)
|
||||
s.auditLogs.Log(auditlog.Entry{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: &actor.TenantID,
|
||||
Module: "schedule_task",
|
||||
Action: "delete",
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &id,
|
||||
RequestID: nilIfEmptyString(requestID),
|
||||
AfterJSON: afterJSON,
|
||||
Result: &result,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req ScheduleTaskStatusRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE schedule_tasks SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||
`, req.Status, id, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
)
|
||||
|
||||
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
||||
@@ -13,7 +14,7 @@ var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
||||
func buildGenerationPrompt(templateName string, promptTemplate *string, params map[string]interface{}) string {
|
||||
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
|
||||
if basePrompt == "" {
|
||||
basePrompt = fmt.Sprintf("Write a complete article in markdown for the template %q.", templateName)
|
||||
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
||||
}
|
||||
|
||||
var sections []string
|
||||
@@ -21,20 +22,108 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m
|
||||
|
||||
contextBlock := buildPromptContext(params)
|
||||
if contextBlock != "" {
|
||||
sections = append(sections, "Request context:\n"+contextBlock)
|
||||
sections = append(sections, "当前上下文:\n"+contextBlock)
|
||||
}
|
||||
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"Output requirements:",
|
||||
"- Return only article markdown, no surrounding explanation.",
|
||||
"- Use the provided title when available.",
|
||||
"- Follow the provided outline sections and key points when available.",
|
||||
"- Keep structure clear with headings, short paragraphs, and concrete details.",
|
||||
"写作总要求:",
|
||||
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
|
||||
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
|
||||
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
|
||||
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
|
||||
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
|
||||
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
|
||||
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
|
||||
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
|
||||
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
|
||||
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
|
||||
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
|
||||
}, "\n"))
|
||||
|
||||
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
|
||||
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
|
||||
}
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
||||
sectionCount := estimateTopLevelSectionCount(params)
|
||||
if sectionCount <= 0 {
|
||||
sectionCount = 4
|
||||
}
|
||||
|
||||
locale := strings.TrimSpace(stringValue(params["locale"]))
|
||||
if locale == "en-US" {
|
||||
minWords := 900
|
||||
targetWords := 320 + sectionCount*130
|
||||
if targetWords > minWords {
|
||||
minWords = targetWords
|
||||
}
|
||||
if minWords > 1600 {
|
||||
minWords = 1600
|
||||
}
|
||||
maxWords := minWords + 400
|
||||
if maxWords > 2100 {
|
||||
maxWords = 2100
|
||||
}
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d English words,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
||||
"- Intro and conclusion should stay concise. Most top-level sections only need 1-3 tight paragraphs; expand only the most important sections.",
|
||||
"- If one paragraph can make the point clearly, do not split it into multiple repetitive paragraphs.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
minChars := 1100
|
||||
targetChars := 320 + sectionCount*180
|
||||
if targetChars > minChars {
|
||||
minChars = targetChars
|
||||
}
|
||||
if minChars > 1800 {
|
||||
minChars = 1800
|
||||
}
|
||||
maxChars := minChars + 500
|
||||
if maxChars > 2400 {
|
||||
maxChars = 2400
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
|
||||
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
|
||||
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func estimateTopLevelSectionCount(params map[string]interface{}) int {
|
||||
if params == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch value := params["article_outline"].(type) {
|
||||
case []interface{}:
|
||||
if len(value) > 0 {
|
||||
return len(value)
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
if len(value) > 0 {
|
||||
return len(value)
|
||||
}
|
||||
}
|
||||
|
||||
switch value := params["outline_sections"].(type) {
|
||||
case []interface{}:
|
||||
if len(value) > 0 {
|
||||
return len(value)
|
||||
}
|
||||
case []string:
|
||||
if len(value) > 0 {
|
||||
return len(value)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func renderPromptTemplate(promptTemplate *string, params map[string]interface{}) string {
|
||||
if promptTemplate == nil {
|
||||
return ""
|
||||
@@ -66,12 +155,15 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
"product_name",
|
||||
"subject",
|
||||
"brand_name",
|
||||
"primary_keyword",
|
||||
"brand",
|
||||
"category",
|
||||
"count",
|
||||
"depth",
|
||||
"article_outline",
|
||||
"outline_sections",
|
||||
"key_points",
|
||||
"review_intro_hook",
|
||||
"keywords",
|
||||
"competitors",
|
||||
}
|
||||
@@ -83,7 +175,10 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
formatted := strings.TrimSpace(formatPromptValue(value))
|
||||
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
|
||||
return
|
||||
}
|
||||
formatted := strings.TrimSpace(formatPromptContextValue(key, value))
|
||||
if formatted == "" {
|
||||
return
|
||||
}
|
||||
@@ -105,6 +200,21 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatPromptContextValue(key string, value interface{}) string {
|
||||
switch key {
|
||||
case "keywords":
|
||||
return formatKeywordList(value, 6)
|
||||
case "competitors":
|
||||
return formatCompetitorList(value, 6)
|
||||
case "outline_sections":
|
||||
return formatSectionList(value, 10)
|
||||
case "article_outline":
|
||||
return formatOutlineValue(value)
|
||||
default:
|
||||
return formatPromptValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatPromptValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
@@ -114,6 +224,12 @@ func formatPromptValue(value interface{}) string {
|
||||
case []string:
|
||||
return strings.Join(v, ", ")
|
||||
case []interface{}:
|
||||
if hasOutlineItems(v) {
|
||||
return formatOutlineItems(v, 0)
|
||||
}
|
||||
if hasNamedItems(v) {
|
||||
return formatNamedItems(v, 6)
|
||||
}
|
||||
parts := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
formatted := strings.TrimSpace(formatPromptValue(item))
|
||||
@@ -131,6 +247,156 @@ func formatPromptValue(value interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
func formatKeywordList(value interface{}, limit int) string {
|
||||
items := extractStringList(value, limit)
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func formatSectionList(value interface{}, limit int) string {
|
||||
items := extractStringList(value, limit)
|
||||
return strings.Join(items, " > ")
|
||||
}
|
||||
|
||||
func formatCompetitorList(value interface{}, limit int) string {
|
||||
switch items := value.(type) {
|
||||
case []interface{}:
|
||||
names := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if data, ok := item.(map[string]interface{}); ok {
|
||||
name := strings.TrimSpace(stringValue(data["name"]))
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(stringValue(data["brand_name"]))
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
default:
|
||||
return formatPromptValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatOutlineValue(value interface{}) string {
|
||||
switch items := value.(type) {
|
||||
case []interface{}:
|
||||
return formatOutlineItems(items, 0)
|
||||
default:
|
||||
return formatPromptValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func hasStructuredOutline(value interface{}) bool {
|
||||
switch items := value.(type) {
|
||||
case []interface{}:
|
||||
return hasOutlineItems(items)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasOutlineItems(items []interface{}) bool {
|
||||
for _, item := range items {
|
||||
if data, ok := item.(map[string]interface{}); ok {
|
||||
if strings.TrimSpace(stringValue(data["outline"])) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNamedItems(items []interface{}) bool {
|
||||
for _, item := range items {
|
||||
if data, ok := item.(map[string]interface{}); ok {
|
||||
if strings.TrimSpace(stringValue(data["name"])) != "" || strings.TrimSpace(stringValue(data["brand_name"])) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatNamedItems(items []interface{}, limit int) string {
|
||||
names := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
data, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(stringValue(data["name"]))
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(stringValue(data["brand_name"]))
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
if len(names) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func formatOutlineItems(items []interface{}, level int) string {
|
||||
lines := make([]string, 0)
|
||||
indent := strings.Repeat(" ", level)
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
outline := strings.TrimSpace(stringValue(node["outline"]))
|
||||
if outline == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s- %s", indent, outline))
|
||||
children, ok := node["children"].([]interface{})
|
||||
if ok && len(children) > 0 {
|
||||
childText := formatOutlineItems(children, level+1)
|
||||
if childText != "" {
|
||||
lines = append(lines, childText)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func extractStringList(value interface{}, limit int) []string {
|
||||
items := make([]string, 0)
|
||||
appendItem := func(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
items = append(items, text)
|
||||
}
|
||||
|
||||
switch list := value.(type) {
|
||||
case []string:
|
||||
for _, item := range list {
|
||||
appendItem(item)
|
||||
if limit > 0 && len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range list {
|
||||
appendItem(formatPromptValue(item))
|
||||
if limit > 0 && len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func resolveArticleTitle(params map[string]interface{}, markdown string) string {
|
||||
if title := strings.TrimSpace(stringValue(params["title"])); title != "" {
|
||||
return title
|
||||
@@ -155,17 +421,7 @@ func resolveArticleTitle(params map[string]interface{}, markdown string) string
|
||||
}
|
||||
|
||||
func estimateWordCount(markdown string) int {
|
||||
trimmed := strings.TrimSpace(markdown)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
words := len(strings.Fields(trimmed))
|
||||
if words > 0 {
|
||||
return words
|
||||
}
|
||||
|
||||
return utf8.RuneCountInString(trimmed)
|
||||
return contentstats.CountWords(markdown)
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
@@ -18,15 +19,19 @@ import (
|
||||
)
|
||||
|
||||
type TemplateService struct {
|
||||
pool *pgxpool.Pool
|
||||
templates repository.TemplateRepository
|
||||
llm llm.Client
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
jobs chan generationJob
|
||||
pool *pgxpool.Pool
|
||||
templates repository.TemplateRepository
|
||||
llm llm.Client
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
maxOutputTokens int64
|
||||
jobs chan generationJob
|
||||
assistJobs chan assistJob
|
||||
}
|
||||
|
||||
type generationJob struct {
|
||||
OperatorID int64
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
TaskID int64
|
||||
@@ -35,6 +40,7 @@ type generationJob struct {
|
||||
PromptTemplate *string
|
||||
Params map[string]interface{}
|
||||
InitialTitle string
|
||||
WebSearch llm.WebSearchOptions
|
||||
}
|
||||
|
||||
func NewTemplateService(
|
||||
@@ -43,6 +49,7 @@ func NewTemplateService(
|
||||
llmClient llm.Client,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
) *TemplateService {
|
||||
queueSize := cfg.QueueSize
|
||||
if queueSize <= 0 {
|
||||
@@ -54,17 +61,26 @@ func NewTemplateService(
|
||||
workerCount = 1
|
||||
}
|
||||
|
||||
articleTimeout := cfg.ArticleTimeout
|
||||
if articleTimeout <= 0 {
|
||||
articleTimeout = 8 * time.Minute
|
||||
}
|
||||
|
||||
svc := &TemplateService{
|
||||
pool: pool,
|
||||
templates: templates,
|
||||
llm: llmClient,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
jobs: make(chan generationJob, queueSize),
|
||||
pool: pool,
|
||||
templates: templates,
|
||||
llm: llmClient,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
articleTimeout: articleTimeout,
|
||||
maxOutputTokens: llmMaxOutputTokens,
|
||||
jobs: make(chan generationJob, queueSize),
|
||||
assistJobs: make(chan assistJob, queueSize),
|
||||
}
|
||||
|
||||
for i := 0; i < workerCount; i++ {
|
||||
go svc.runGenerationWorker()
|
||||
go svc.runAssistWorker()
|
||||
}
|
||||
|
||||
return svc
|
||||
@@ -99,7 +115,7 @@ func (s *TemplateService) List(ctx context.Context) ([]TemplateListItem, error)
|
||||
OriginType: row.OriginType,
|
||||
TemplateKey: row.TemplateKey,
|
||||
TemplateName: row.TemplateName,
|
||||
PromptVisibility: row.PromptVisibility,
|
||||
PromptVisibility: effectivePromptVisibility(&row, actor.TenantID),
|
||||
CardConfigJSON: map[string]interface{}{},
|
||||
Status: row.Status,
|
||||
VersionNo: row.VersionNo,
|
||||
@@ -132,7 +148,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
|
||||
OriginType: record.OriginType,
|
||||
TemplateKey: record.TemplateKey,
|
||||
TemplateName: record.TemplateName,
|
||||
PromptVisibility: record.PromptVisibility,
|
||||
PromptVisibility: effectivePromptVisibility(record, actor.TenantID),
|
||||
CardConfigJSON: map[string]interface{}{},
|
||||
Status: record.Status,
|
||||
VersionNo: record.VersionNo,
|
||||
@@ -142,7 +158,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
|
||||
_ = json.Unmarshal(record.SchemaJSON, &detail.SchemaJSON)
|
||||
_ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON)
|
||||
|
||||
if detail.PromptVisibility != "sealed" {
|
||||
if canViewPromptTemplate(record, actor.TenantID) {
|
||||
detail.PromptTemplate = record.PromptTemplate
|
||||
}
|
||||
|
||||
@@ -150,7 +166,8 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
|
||||
}
|
||||
|
||||
func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]interface{}, error) {
|
||||
record, err := s.templates.GetTemplateSchema(ctx, id)
|
||||
actor := auth.MustActor(ctx)
|
||||
record, err := s.templates.GetTemplateByID(ctx, id, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
@@ -160,8 +177,26 @@ func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]inte
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int64) bool {
|
||||
if record == nil || record.TenantID == nil {
|
||||
return false
|
||||
}
|
||||
return *record.TenantID == actorTenantID
|
||||
}
|
||||
|
||||
func effectivePromptVisibility(record *repository.TemplateRecord, actorTenantID int64) string {
|
||||
if canViewPromptTemplate(record, actorTenantID) {
|
||||
return "visible"
|
||||
}
|
||||
return "sealed"
|
||||
}
|
||||
|
||||
type GenerateRequest struct {
|
||||
InputParams map[string]interface{} `json:"input_params" binding:"required"`
|
||||
ArticleID *int64 `json:"article_id"`
|
||||
InputParams map[string]interface{} `json:"input_params" binding:"required"`
|
||||
WizardState map[string]interface{} `json:"wizard_state"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
WebSearchLimit *int32 `json:"web_search_limit"`
|
||||
}
|
||||
|
||||
type GenerateResponse struct {
|
||||
@@ -169,6 +204,63 @@ type GenerateResponse struct {
|
||||
TaskID int64 `json:"task_id"`
|
||||
}
|
||||
|
||||
type SaveDraftRequest struct {
|
||||
ArticleID *int64 `json:"article_id"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
WizardState map[string]interface{} `json:"wizard_state" binding:"required"`
|
||||
}
|
||||
|
||||
type SaveDraftResponse struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
}
|
||||
|
||||
func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req SaveDraftRequest) (*SaveDraftResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
if _, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID); err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
|
||||
stateJSON, err := json.Marshal(normalizeWizardState(req.WizardState, req.CurrentStep))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40016, "invalid_wizard_state", "wizard_state must be valid json")
|
||||
}
|
||||
|
||||
if req.ArticleID == nil {
|
||||
var articleID int64
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status, wizard_state_json)
|
||||
VALUES ($1, 'template', $2, 'draft', 'unpublished', $3)
|
||||
RETURNING id
|
||||
`, actor.TenantID, templateID, stateJSON).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "draft_create_failed", "failed to create article draft")
|
||||
}
|
||||
return &SaveDraftResponse{ArticleID: articleID}, nil
|
||||
}
|
||||
|
||||
var articleID int64
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE articles
|
||||
SET wizard_state_json = $1,
|
||||
generate_status = 'draft',
|
||||
publish_status = 'unpublished',
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
AND tenant_id = $3
|
||||
AND template_id = $4
|
||||
AND source_type = 'template'
|
||||
AND deleted_at IS NULL
|
||||
AND generate_status IN ('draft', 'failed')
|
||||
RETURNING id
|
||||
`, stateJSON, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
|
||||
}
|
||||
|
||||
return &SaveDraftResponse{ArticleID: articleID}, nil
|
||||
}
|
||||
|
||||
func (s *TemplateService) Generate(ctx context.Context, templateID int64, req GenerateRequest) (*GenerateResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
@@ -180,6 +272,11 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
}
|
||||
|
||||
initialTitle := resolveArticleTitle(req.InputParams, "")
|
||||
wizardStateJSON, err := json.Marshal(normalizeWizardState(req.WizardState, wizardStateCurrentStep(req.WizardState)))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40016, "invalid_wizard_state", "wizard_state must be valid json")
|
||||
}
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||
if err != nil || balance < 1 {
|
||||
@@ -205,6 +302,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
referenceType := "generation_task"
|
||||
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: -1,
|
||||
BalanceAfter: newBalance,
|
||||
@@ -217,9 +315,10 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
|
||||
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
ResourceType: "article",
|
||||
ResourceID: 0,
|
||||
ResourceID: nil,
|
||||
ReservedAmount: 1,
|
||||
ExpireAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
@@ -227,16 +326,37 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
return nil, fmt.Errorf("create reservation: %w", err)
|
||||
}
|
||||
|
||||
articleID, err := articleTx.CreateArticle(ctx, repository.CreateArticleInput{
|
||||
TenantID: actor.TenantID,
|
||||
SourceType: "template",
|
||||
TemplateID: &templateID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create article: %w", err)
|
||||
}
|
||||
if err := articleTx.UpdateArticleGenerateStatus(ctx, articleID, actor.TenantID, "generating"); err != nil {
|
||||
return nil, fmt.Errorf("mark article generating: %w", err)
|
||||
var articleID int64
|
||||
if req.ArticleID != nil {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM articles
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND template_id = $3
|
||||
AND source_type = 'template'
|
||||
AND deleted_at IS NULL
|
||||
AND generate_status IN ('draft', 'failed')
|
||||
FOR UPDATE
|
||||
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
|
||||
}
|
||||
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
|
||||
return nil, fmt.Errorf("mark draft generating: %w", err)
|
||||
}
|
||||
} else {
|
||||
articleID, err = articleTx.CreateArticle(ctx, repository.CreateArticleInput{
|
||||
TenantID: actor.TenantID,
|
||||
SourceType: "template",
|
||||
TemplateID: &templateID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create article: %w", err)
|
||||
}
|
||||
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
|
||||
return nil, fmt.Errorf("mark article generating: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
@@ -244,6 +364,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
}
|
||||
|
||||
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: &articleID,
|
||||
QuotaReservationID: &reservationID,
|
||||
@@ -258,8 +379,8 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
initialTitle := resolveArticleTitle(req.InputParams, "")
|
||||
job := generationJob{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
TaskID: taskID,
|
||||
@@ -268,10 +389,16 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
PromptTemplate: templateRecord.PromptTemplate,
|
||||
Params: cloneInputParams(req.InputParams),
|
||||
InitialTitle: initialTitle,
|
||||
WebSearch: llm.WebSearchOptions{
|
||||
Enabled: req.EnableWebSearch,
|
||||
},
|
||||
}
|
||||
if req.WebSearchLimit != nil {
|
||||
job.WebSearch.Limit = *req.WebSearchLimit
|
||||
}
|
||||
|
||||
if err := s.enqueueGeneration(job); err != nil {
|
||||
s.failGeneration(context.Background(), job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, job.InitialTitle, err)
|
||||
s.failGeneration(context.Background(), job, job.InitialTitle, "queue_enqueue", err)
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||
}
|
||||
|
||||
@@ -282,6 +409,34 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
return &GenerateResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
func updateGeneratedArticleState(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
articleID, tenantID int64,
|
||||
title, status string,
|
||||
wizardStateJSON []byte,
|
||||
) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE articles
|
||||
SET wizard_state_json = CASE
|
||||
WHEN $1::jsonb IS NOT NULL THEN jsonb_set($1::jsonb, '{title}', to_jsonb($2::text), true)
|
||||
WHEN NULLIF($2, '') IS NULL THEN COALESCE(wizard_state_json, '{}'::jsonb)
|
||||
ELSE jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($2::text), true)
|
||||
END,
|
||||
generate_status = $3,
|
||||
publish_status = 'unpublished',
|
||||
updated_at = NOW()
|
||||
WHERE id = $4 AND tenant_id = $5
|
||||
`, nullableJSONString(wizardStateJSON), strings.TrimSpace(title), status, articleID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("article not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TemplateService) enqueueGeneration(job generationJob) error {
|
||||
select {
|
||||
case s.jobs <- job:
|
||||
@@ -313,19 +468,27 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
})
|
||||
|
||||
prompt := buildGenerationPrompt(job.TemplateName, job.PromptTemplate, job.Params)
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{Prompt: prompt}, func(delta string) {
|
||||
generateReq := llm.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Timeout: s.articleTimeout,
|
||||
MaxOutputTokens: s.maxOutputTokens,
|
||||
}
|
||||
if job.WebSearch.Enabled {
|
||||
generateReq.WebSearch = &job.WebSearch
|
||||
}
|
||||
result, err := s.llm.Generate(ctx, generateReq, func(delta string) {
|
||||
if s.streamEnabled && delta != "" {
|
||||
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
s.failGeneration(ctx, job, title, "llm_generate", err)
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, fmt.Errorf("empty model output"))
|
||||
s.failGeneration(ctx, job, title, "llm_generate", fmt.Errorf("模型返回空内容"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -334,7 +497,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
s.failGeneration(ctx, job, title, "persist_begin_tx", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
@@ -355,25 +518,37 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
SourceLabel: result.Model,
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
s.failGeneration(ctx, job, title, "persist_create_version", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID)
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed")
|
||||
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_current_version", err)
|
||||
return
|
||||
}
|
||||
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_article_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
completed := time.Now()
|
||||
_ = auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
if err := auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "completed",
|
||||
StartedAt: &now,
|
||||
CompletedAt: &completed,
|
||||
})
|
||||
}); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_task_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID)
|
||||
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "confirm_reservation", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
s.failGeneration(ctx, job, title, "persist_commit", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -382,8 +557,8 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TemplateService) failGeneration(ctx context.Context, tenantID, articleID, taskID, reservationID int64, title string, genErr error) {
|
||||
errMsg := genErr.Error()
|
||||
func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, title, stage string, genErr error) {
|
||||
errMsg := formatGenerationFailure(stage, genErr)
|
||||
now := time.Now()
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
@@ -391,21 +566,22 @@ func (s *TemplateService) failGeneration(ctx context.Context, tenantID, articleI
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: taskID,
|
||||
TenantID: tenantID,
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, articleID, tenantID, "failed")
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "failed")
|
||||
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, tenantID, "article_generation")
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, job.TenantID, "article_generation")
|
||||
if balanceErr == nil {
|
||||
reason := "generation_refund"
|
||||
referenceType := "generation_task"
|
||||
taskReferenceID := taskID
|
||||
taskReferenceID := job.TaskID
|
||||
_, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: tenantID,
|
||||
TenantID: job.TenantID,
|
||||
OperatorID: job.OperatorID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: 1,
|
||||
BalanceAfter: balance + 1,
|
||||
@@ -415,10 +591,48 @@ func (s *TemplateService) failGeneration(ctx context.Context, tenantID, articleI
|
||||
})
|
||||
}
|
||||
|
||||
_ = quotaRepo.RefundReservation(ctx, reservationID, tenantID)
|
||||
_ = quotaRepo.RefundReservation(ctx, job.ReservationID, job.TenantID)
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Fail(articleID, taskID, title, errMsg)
|
||||
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func formatGenerationFailure(stage string, genErr error) string {
|
||||
cause := ""
|
||||
if genErr != nil {
|
||||
cause = strings.TrimSpace(genErr.Error())
|
||||
}
|
||||
|
||||
label := generationFailureStageLabel(stage)
|
||||
if cause == "" {
|
||||
return fmt.Sprintf("%s [%s]", label, stage)
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]: %s", label, stage, cause)
|
||||
}
|
||||
|
||||
func generationFailureStageLabel(stage string) string {
|
||||
switch stage {
|
||||
case "queue_enqueue":
|
||||
return "任务入队失败"
|
||||
case "llm_generate":
|
||||
return "AI 正文生成失败"
|
||||
case "persist_begin_tx":
|
||||
return "写入生成结果事务启动失败"
|
||||
case "persist_create_version":
|
||||
return "保存文章版本失败"
|
||||
case "persist_current_version":
|
||||
return "更新文章当前版本失败"
|
||||
case "persist_article_status":
|
||||
return "更新文章生成状态失败"
|
||||
case "persist_task_status":
|
||||
return "更新生成任务状态失败"
|
||||
case "confirm_reservation":
|
||||
return "确认配额扣减失败"
|
||||
case "persist_commit":
|
||||
return "提交生成结果失败"
|
||||
default:
|
||||
return "文章生成失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,3 +647,53 @@ func cloneInputParams(params map[string]interface{}) map[string]interface{} {
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeWizardState(state map[string]interface{}, currentStep int) map[string]interface{} {
|
||||
if state == nil {
|
||||
state = map[string]interface{}{}
|
||||
}
|
||||
|
||||
cloned := make(map[string]interface{}, len(state)+1)
|
||||
for key, value := range state {
|
||||
cloned[key] = value
|
||||
}
|
||||
|
||||
if currentStep < 0 {
|
||||
currentStep = 0
|
||||
}
|
||||
if currentStep > 2 {
|
||||
currentStep = 2
|
||||
}
|
||||
cloned["current_step"] = currentStep
|
||||
return cloned
|
||||
}
|
||||
|
||||
func wizardStateCurrentStep(state map[string]interface{}) int {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
value, ok := state["current_step"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func nullableJSONString(value []byte) *string {
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
text := string(value)
|
||||
return &text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEstimateWordCountCountsChineseContent(t *testing.T) {
|
||||
input := "# 海翔家居评测\n这是一段中文内容,用来验证生成文章的字数统计。"
|
||||
|
||||
got := estimateWordCount(input)
|
||||
want := countArticleWords(input)
|
||||
if got != want {
|
||||
t.Fatalf("estimateWordCount() = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateWordCountCountsMixedContent(t *testing.T) {
|
||||
input := "2026 海翔家居 review,适合 small apartment 用户。"
|
||||
|
||||
got := estimateWordCount(input)
|
||||
want := countArticleWords(input)
|
||||
if got != want {
|
||||
t.Fatalf("estimateWordCount() = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (s *WorkspaceService) TemplateCards(ctx context.Context) ([]TemplateCard, e
|
||||
TemplateKey: row.TemplateKey,
|
||||
TemplateName: row.TemplateName,
|
||||
OriginType: row.OriginType,
|
||||
PromptVisibility: row.PromptVisibility,
|
||||
PromptVisibility: effectivePromptVisibility(&row, actor.TenantID),
|
||||
}
|
||||
_ = json.Unmarshal(row.CardConfigJSON, &card.CardConfigJSON)
|
||||
cards = append(cards, card)
|
||||
|
||||
Reference in New Issue
Block a user