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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user