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:
2026-04-02 00:31:28 +08:00
parent de30497f59
commit b31d8d0096
101 changed files with 16671 additions and 721 deletions
+239 -44
View File
@@ -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
}
+39 -8
View File
@@ -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
+276 -20
View File
@@ -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 {
+315 -51
View File
@@ -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)