package app import ( "context" "encoding/json" "fmt" "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 TemplateService struct { pool *pgxpool.Pool templates repository.TemplateRepository llm llm.Client streams *stream.GenerationHub streamEnabled bool jobs chan generationJob } type generationJob struct { TenantID int64 ArticleID int64 TaskID int64 ReservationID int64 TemplateName string PromptTemplate *string Params map[string]interface{} InitialTitle string } func NewTemplateService( pool *pgxpool.Pool, templates repository.TemplateRepository, llmClient llm.Client, streams *stream.GenerationHub, cfg config.GenerationConfig, ) *TemplateService { queueSize := cfg.QueueSize if queueSize <= 0 { queueSize = 128 } workerCount := cfg.WorkerConcurrency if workerCount <= 0 { workerCount = 1 } svc := &TemplateService{ pool: pool, templates: templates, llm: llmClient, streams: streams, streamEnabled: cfg.StreamEnabled, jobs: make(chan generationJob, queueSize), } for i := 0; i < workerCount; i++ { go svc.runGenerationWorker() } return svc } type TemplateListItem struct { ID int64 `json:"id"` Scope string `json:"scope"` OriginType string `json:"origin_type"` TemplateKey string `json:"template_key"` TemplateName string `json:"template_name"` SchemaJSON map[string]interface{} `json:"schema_json"` PromptVisibility string `json:"prompt_visibility"` CardConfigJSON map[string]interface{} `json:"card_config"` Status string `json:"status"` VersionNo int `json:"version_no"` CreatedAt time.Time `json:"created_at"` } func (s *TemplateService) List(ctx context.Context) ([]TemplateListItem, error) { actor := auth.MustActor(ctx) rows, err := s.templates.ListTemplates(ctx, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list templates") } items := make([]TemplateListItem, 0, len(rows)) for _, row := range rows { item := TemplateListItem{ ID: row.ID, Scope: row.Scope, OriginType: row.OriginType, TemplateKey: row.TemplateKey, TemplateName: row.TemplateName, PromptVisibility: row.PromptVisibility, CardConfigJSON: map[string]interface{}{}, Status: row.Status, VersionNo: row.VersionNo, CreatedAt: row.CreatedAt, } _ = json.Unmarshal(row.SchemaJSON, &item.SchemaJSON) _ = json.Unmarshal(row.CardConfigJSON, &item.CardConfigJSON) items = append(items, item) } return items, nil } type TemplateDetail struct { TemplateListItem PromptTemplate *string `json:"prompt_template,omitempty"` } func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail, error) { 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") } detail := &TemplateDetail{ TemplateListItem: TemplateListItem{ ID: record.ID, Scope: record.Scope, OriginType: record.OriginType, TemplateKey: record.TemplateKey, TemplateName: record.TemplateName, PromptVisibility: record.PromptVisibility, CardConfigJSON: map[string]interface{}{}, Status: record.Status, VersionNo: record.VersionNo, CreatedAt: record.CreatedAt, }, } _ = json.Unmarshal(record.SchemaJSON, &detail.SchemaJSON) _ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON) if detail.PromptVisibility != "sealed" { detail.PromptTemplate = record.PromptTemplate } return detail, nil } func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]interface{}, error) { record, err := s.templates.GetTemplateSchema(ctx, id) if err != nil { return nil, response.ErrNotFound(40410, "template_not_found", "template not found") } schema := map[string]interface{}{} _ = json.Unmarshal(record.SchemaJSON, &schema) return schema, nil } type GenerateRequest struct { InputParams map[string]interface{} `json:"input_params" binding:"required"` } type GenerateResponse struct { ArticleID int64 `json:"article_id"` TaskID int64 `json:"task_id"` } func (s *TemplateService) Generate(ctx context.Context, templateID int64, req GenerateRequest) (*GenerateResponse, error) { actor := auth.MustActor(ctx) templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { return nil, response.ErrNotFound(40410, "template_not_found", "template not found") } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } 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") } inputJSON, _ := json.Marshal(req.InputParams) tx, err := s.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() quotaTx := repository.NewQuotaRepository(tx) articleTx := repository.NewArticleRepository(tx) auditTx := repository.NewAuditRepository(tx) newBalance := balance - 1 reason := "generation_reserve" referenceType := "generation_task" _, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{ TenantID: actor.TenantID, QuotaType: "article_generation", Delta: -1, BalanceAfter: newBalance, Reason: &reason, ReferenceType: &referenceType, }) if err != nil { return nil, fmt.Errorf("deduct quota: %w", err) } reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{ TenantID: actor.TenantID, QuotaType: "article_generation", ResourceType: "article", ResourceID: 0, ReservedAmount: 1, ExpireAt: time.Now().Add(time.Hour), }) if err != nil { 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) } if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil { return nil, fmt.Errorf("update reservation: %w", err) } taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{ TenantID: actor.TenantID, ArticleID: &articleID, QuotaReservationID: &reservationID, TaskType: "template", InputParamsJSON: inputJSON, }) if err != nil { return nil, fmt.Errorf("create task: %w", err) } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit: %w", err) } initialTitle := resolveArticleTitle(req.InputParams, "") job := generationJob{ TenantID: actor.TenantID, ArticleID: articleID, TaskID: taskID, ReservationID: reservationID, TemplateName: templateRecord.TemplateName, PromptTemplate: templateRecord.PromptTemplate, Params: cloneInputParams(req.InputParams), InitialTitle: initialTitle, } if err := s.enqueueGeneration(job); err != nil { s.failGeneration(context.Background(), job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, job.InitialTitle, 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 &GenerateResponse{ArticleID: articleID, TaskID: taskID}, nil } func (s *TemplateService) enqueueGeneration(job generationJob) error { select { case s.jobs <- job: return nil default: return fmt.Errorf("generation queue is full") } } func (s *TemplateService) runGenerationWorker() { for job := range s.jobs { s.executeGeneration(context.Background(), job) } } func (s *TemplateService) executeGeneration(ctx context.Context, job generationJob) { 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, }) prompt := buildGenerationPrompt(job.TemplateName, job.PromptTemplate, job.Params) result, err := s.llm.Generate(ctx, llm.GenerateRequest{Prompt: prompt}, 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) 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")) return } title = resolveArticleTitle(job.Params, content) wordCount := estimateWordCount(content) tx, err := s.pool.Begin(ctx) if err != nil { s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, 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.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err) return } _ = articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID) _ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed") completed := time.Now() _ = auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ ID: job.TaskID, TenantID: job.TenantID, Status: "completed", StartedAt: &now, CompletedAt: &completed, }) _ = quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID) if err := tx.Commit(ctx); err != nil { s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err) return } if s.streamEnabled { s.streams.Complete(job.ArticleID, job.TaskID, title, content) } } func (s *TemplateService) failGeneration(ctx context.Context, tenantID, articleID, taskID, reservationID int64, title string, genErr error) { errMsg := genErr.Error() now := time.Now() auditRepo := repository.NewAuditRepository(s.pool) articleRepo := repository.NewArticleRepository(s.pool) quotaRepo := repository.NewQuotaRepository(s.pool) _ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ ID: taskID, TenantID: tenantID, Status: "failed", ErrorMessage: &errMsg, CompletedAt: &now, }) _ = articleRepo.UpdateArticleGenerateStatus(ctx, articleID, tenantID, "failed") balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, tenantID, "article_generation") if balanceErr == nil { reason := "generation_refund" referenceType := "generation_task" taskReferenceID := taskID _, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{ TenantID: tenantID, QuotaType: "article_generation", Delta: 1, BalanceAfter: balance + 1, Reason: &reason, ReferenceType: &referenceType, ReferenceID: &taskReferenceID, }) } _ = quotaRepo.RefundReservation(ctx, reservationID, tenantID) if s.streamEnabled { s.streams.Fail(articleID, taskID, title, errMsg) } } func cloneInputParams(params map[string]interface{}) map[string]interface{} { if len(params) == 0 { return map[string]interface{}{} } cloned := make(map[string]interface{}, len(params)) for key, value := range params { cloned[key] = value } return cloned }