package app import ( "context" "encoding/json" "fmt" "net/url" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auth" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "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/messaging/rabbitmq" "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/prompts" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) const ( articleImitationTaskType = "imitation" articleImitationSourceType = "imitation" articleImitationFallbackTitle = "仿写文章生成中" maxImitationSourceContentRunes = 24000 defaultImitationWebSearchLimit = int32(5) ) type ArticleImitationService struct { pool *pgxpool.Pool llm llm.Client rabbitMQ *rabbitmq.Client knowledge *KnowledgeService streams *stream.GenerationHub cfg generationRuntimeConfig configProvider runtimeConfigProvider cache sharedcache.Cache } type GenerateImitationRequest struct { SourceURL string `json:"source_url" binding:"required"` SourceTitle string `json:"source_title"` Locale string `json:"locale"` Industry string `json:"industry"` BrandName string `json:"brand_name"` Region string `json:"region"` TargetAudience string `json:"target_audience"` ContentGoal string `json:"content_goal"` Tone string `json:"tone"` LengthGoal string `json:"length_goal"` Keywords []string `json:"keywords"` PreservePoints string `json:"preserve_points"` AvoidPoints string `json:"avoid_points"` ExtraRequirements string `json:"extra_requirements"` EnableWebSearch bool `json:"enable_web_search"` WebSearchLimit *int32 `json:"web_search_limit"` KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"` } type GenerateImitationResponse struct { ArticleID int64 `json:"article_id"` TaskID int64 `json:"task_id"` } type articleImitationJob struct { OperatorID int64 TenantID int64 ArticleID int64 TaskID int64 ReservationID int64 InputParams map[string]interface{} InitialTitle string WebSearch llm.WebSearchOptions } func NewArticleImitationService( pool *pgxpool.Pool, llmClient llm.Client, rabbitMQClient *rabbitmq.Client, knowledge *KnowledgeService, streams *stream.GenerationHub, cfg config.GenerationConfig, llmMaxOutputTokens int64, ) *ArticleImitationService { return &ArticleImitationService{ pool: pool, llm: llmClient, rabbitMQ: rabbitMQClient, knowledge: knowledge, streams: streams, cfg: generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}), } } func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitationService { s.cache = c return s } func (s *ArticleImitationService) WithConfigProvider(provider runtimeConfigProvider) *ArticleImitationService { s.configProvider = provider return s } func (s *ArticleImitationService) runtimeConfig() generationRuntimeConfig { if s != nil && s.configProvider != nil { if cfg := s.configProvider.Current(); cfg != nil { return generationRuntimeFromConfig(cfg.Generation, cfg.LLM) } } if s == nil { return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{}) } return s.cfg } func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) { actor := auth.MustActor(ctx) if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } sourceURL, err := normalizeImitationSourceURL(req.SourceURL) if err != nil { return nil, err } 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 := buildImitationInputParams(sourceURL, req) inputJSON, _ := json.Marshal(inputParams) initialTitle := buildImitationInitialTitle(req.SourceTitle) wizardStateJSON, _ := json.Marshal(map[string]interface{}{ "title": initialTitle, "source_url": sourceURL, "source_title": strings.TrimSpace(req.SourceTitle), "current_step": 0, }) 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, generate_status, publish_status, wizard_state_json) VALUES ($1, $2, 'generating', 'unpublished', $3) RETURNING id `, actor.TenantID, articleImitationSourceType, wizardStateJSON).Scan(&articleID) if err != nil { return nil, response.ErrInternal(50012, "create_failed", "failed to create imitation 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: articleImitationTaskType, 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") } invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) job := articleImitationJob{ OperatorID: actor.UserID, TenantID: actor.TenantID, ArticleID: articleID, TaskID: taskID, ReservationID: reservationID, InputParams: inputParams, InitialTitle: initialTitle, WebSearch: llm.WebSearchOptions{ Enabled: req.EnableWebSearch, }, } if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 { job.WebSearch.Limit = *req.WebSearchLimit } else if req.EnableWebSearch { job.WebSearch.Limit = defaultImitationWebSearchLimit } if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{ TaskID: taskID, TaskType: articleImitationTaskType, TenantID: actor.TenantID, ArticleID: articleID, }); err != nil { s.failGeneration(context.Background(), job, "queue_enqueue", err) return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry") } runtimeCfg := s.runtimeConfig() if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Start(articleID, taskID, initialTitle) } return &GenerateImitationResponse{ArticleID: articleID, TaskID: taskID}, nil } func (s *ArticleImitationService) ProcessQueuedTask(ctx context.Context, task ClaimedGenerationTask) { inputParams := cloneInputParams(task.InputParams) job := articleImitationJob{ OperatorID: task.OperatorID, TenantID: task.TenantID, ArticleID: task.ArticleID, TaskID: task.ID, ReservationID: task.QuotaReservationID, InputParams: inputParams, InitialTitle: buildImitationInitialTitle(extractString(inputParams, "source_title")), WebSearch: llm.WebSearchOptions{ Enabled: extractBool(inputParams, "enable_web_search"), }, } if limit := extractInt(inputParams, "web_search_limit"); limit > 0 { job.WebSearch.Limit = int32(limit) } else if job.WebSearch.Enabled { job.WebSearch.Limit = defaultImitationWebSearchLimit } s.executeGeneration(ctx, job) } func (s *ArticleImitationService) HandleTaskFailure(ctx context.Context, task ClaimedGenerationTask, stage string, taskErr error) { job := articleImitationJob{ OperatorID: task.OperatorID, TenantID: task.TenantID, ArticleID: task.ArticleID, TaskID: task.ID, ReservationID: task.QuotaReservationID, InputParams: cloneInputParams(task.InputParams), InitialTitle: buildImitationInitialTitle(extractString(task.InputParams, "source_title")), } s.failGeneration(ctx, job, stage, taskErr) } func (s *ArticleImitationService) executeGeneration(ctx context.Context, job articleImitationJob) { now := time.Now() title := strings.TrimSpace(job.InitialTitle) if title == "" { title = articleImitationFallbackTitle } auditRepo := repository.NewAuditRepository(s.pool) _ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ ID: job.TaskID, TenantID: job.TenantID, Status: "running", StartedAt: &now, }) sourceURL := strings.TrimSpace(extractString(job.InputParams, "source_url")) if sourceURL == "" { s.failGeneration(ctx, job, "task_load", fmt.Errorf("source_url is required")) return } if s.knowledge == nil { s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("knowledge service is not configured")) return } parsed, err := s.knowledge.parseWebsiteKnowledge(ctx, sourceURL) if err != nil { s.failGeneration(ctx, job, "source_fetch", err) return } sourceContent := truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes) if sourceContent == "" { sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes) } if sourceContent == "" { s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("source article content is empty")) return } knowledgePrompt := "" if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.InputParams["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 { if s.knowledge == nil { s.failGeneration(ctx, job, "knowledge_resolve", fmt.Errorf("knowledge service is not configured")) return } knowledgeContext, resolveErr := s.knowledge.ResolveContext( ctx, job.TenantID, knowledgeGroupIDs, buildImitationKnowledgeQuery(job.InputParams), ) if resolveErr != nil { s.failGeneration(ctx, job, "knowledge_resolve", resolveErr) return } knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext) } prompt := buildImitationGenerationPrompt(job.InputParams, sourceContent, knowledgePrompt) runtimeCfg := s.runtimeConfig() req := llm.GenerateRequest{ Prompt: prompt, Timeout: runtimeCfg.ArticleTimeout, MaxOutputTokens: runtimeCfg.MaxOutputTokens, } if job.WebSearch.Enabled { req.WebSearch = &job.WebSearch } result, err := s.llm.Generate(ctx, req, func(delta string) { if runtimeCfg.StreamEnabled && s.streams != nil && delta != "" { s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta) } }) if err != nil { s.failGeneration(ctx, job, "llm_generate", err) return } content := strings.TrimSpace(result.Content) if content == "" { s.failGeneration(ctx, job, "llm_generate", fmt.Errorf("模型返回空内容")) return } title = resolveGeneratedImitationTitle(content, job.InputParams) wordCount := estimateWordCount(content) tx, err := s.pool.Begin(ctx) if err != nil { s.failGeneration(ctx, job, "persist_begin_tx", err) return } defer rollbackGenerationTx(tx) failWithRollback := func(stage string, err error) { rollbackGenerationTx(tx) s.failGeneration(ctx, job, stage, err) } 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 { failWithRollback("persist_create_version", err) return } if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil { failWithRollback("persist_current_version", err) return } if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil { failWithRollback("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 { failWithRollback("persist_task_status", err) return } if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil { failWithRollback("confirm_reservation", err) return } if err := tx.Commit(ctx); err != nil { failWithRollback("persist_commit", err) return } cleanupCtx, cancel := newGenerationCleanupContext() defer cancel() invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Complete(job.ArticleID, job.TaskID, title, content) } } func (s *ArticleImitationService) failGeneration(ctx context.Context, job articleImitationJob, stage string, genErr error) { cleanupCtx, cancel := newGenerationCleanupContext() defer cancel() errMsg := formatGenerationFailure(stage, genErr) now := time.Now() title := strings.TrimSpace(job.InitialTitle) if title == "" { title = articleImitationFallbackTitle } auditRepo := repository.NewAuditRepository(s.pool) articleRepo := repository.NewArticleRepository(s.pool) quotaRepo := repository.NewQuotaRepository(s.pool) _ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{ ID: job.TaskID, TenantID: job.TenantID, Status: "failed", ErrorMessage: &errMsg, CompletedAt: &now, }) if job.ArticleID > 0 { _ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed") _, _ = s.pool.Exec(cleanupCtx, ` UPDATE articles SET wizard_state_json = jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($1::text), true), updated_at = NOW() WHERE id = $2 AND tenant_id = $3 `, title, job.ArticleID, job.TenantID) } if job.ReservationID > 0 { balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation") if balanceErr == nil { reason := "generation_refund" referenceType := "generation_task" taskReferenceID := job.TaskID _, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{ TenantID: job.TenantID, OperatorID: job.OperatorID, QuotaType: "article_generation", Delta: 1, BalanceAfter: balance + 1, Reason: &reason, ReferenceType: &referenceType, ReferenceID: &taskReferenceID, }) } _ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID) } if job.ArticleID > 0 { invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) } runtimeCfg := s.runtimeConfig() if runtimeCfg.StreamEnabled && s.streams != nil && job.ArticleID > 0 { s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg) } } func normalizeImitationSourceURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "", response.ErrBadRequest(40001, "source_url_required", "source_url is required") } parsed, err := url.Parse(trimmed) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must be a valid URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must use http or https") } return parsed.String(), nil } func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) map[string]interface{} { params := map[string]interface{}{ "source_url": sourceURL, "source_title": strings.TrimSpace(req.SourceTitle), "locale": normalizeImitationLocale(req.Locale), "industry": strings.TrimSpace(req.Industry), "brand_name": strings.TrimSpace(req.BrandName), "region": strings.TrimSpace(req.Region), "target_audience": strings.TrimSpace(req.TargetAudience), "content_goal": strings.TrimSpace(req.ContentGoal), "tone": strings.TrimSpace(req.Tone), "length_goal": strings.TrimSpace(req.LengthGoal), "keywords": normalizeImitationKeywords(req.Keywords), "preserve_points": strings.TrimSpace(req.PreservePoints), "avoid_points": strings.TrimSpace(req.AvoidPoints), "extra_requirements": strings.TrimSpace(req.ExtraRequirements), "enable_web_search": req.EnableWebSearch, "knowledge_group_ids": normalizeInt64IDs(req.KnowledgeGroupIDs), } if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 { params["web_search_limit"] = *req.WebSearchLimit } return params } func buildImitationInitialTitle(sourceTitle string) string { title := strings.TrimSpace(sourceTitle) if title == "" { return articleImitationFallbackTitle } return "仿写:" + title } func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent string, knowledgePrompt string) string { locale := normalizeImitationLocale(extractString(params, "locale")) sourceTitle := strings.TrimSpace(extractString(params, "source_title")) sourceURL := strings.TrimSpace(extractString(params, "source_url")) keywords := extractStringList(params["keywords"], 16) knowledgePrompt = strings.TrimSpace(knowledgePrompt) var sourceMeta strings.Builder if sourceTitle != "" { appendRawPromptLine(&sourceMeta, "标题", sourceTitle) } if sourceURL != "" { appendRawPromptLine(&sourceMeta, "URL", sourceURL) } var settings strings.Builder appendPromptLine(&settings, "industry", extractString(params, "industry")) appendPromptLine(&settings, "brand_name", extractString(params, "brand_name")) appendPromptLine(&settings, "region", extractString(params, "region")) appendPromptLine(&settings, "target_audience", extractString(params, "target_audience")) appendPromptLine(&settings, "content_goal", extractString(params, "content_goal")) appendPromptLine(&settings, "tone", extractString(params, "tone")) appendPromptLine(&settings, "length_goal", extractString(params, "length_goal")) if len(keywords) > 0 { appendPromptLine(&settings, "keywords", strings.Join(keywords, "、")) } appendPromptLine(&settings, "preserve_points", extractString(params, "preserve_points")) appendPromptLine(&settings, "avoid_points", extractString(params, "avoid_points")) appendPromptLine(&settings, "extra_requirements", extractString(params, "extra_requirements")) knowledgeSection := "" if knowledgePrompt != "" { knowledgeSection = "## 知识库参考\n" + knowledgePrompt } return prompts.ArticleImitationPrompt( locale, sourceMeta.String(), settings.String(), knowledgeSection, sourceContent, ) } func buildImitationKnowledgeQuery(params map[string]interface{}) string { if len(params) == 0 { return "" } parts := make([]string, 0, 8) appendValue := func(value string) { value = strings.TrimSpace(value) if value == "" { return } parts = append(parts, value) } appendValue(extractString(params, "source_title")) appendValue(extractString(params, "industry")) appendValue(extractString(params, "brand_name")) appendValue(extractString(params, "region")) appendValue(extractString(params, "target_audience")) appendValue(extractString(params, "content_goal")) appendValue(extractString(params, "tone")) appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n")) appendValue(extractString(params, "extra_requirements")) return strings.Join(parts, "\n") } func appendPromptLine(b *strings.Builder, label, value string) { value = strings.TrimSpace(value) if value == "" { return } b.WriteString("- " + prompts.ArticleImitationSettingLabel(label) + ":" + value + "\n") } func appendRawPromptLine(b *strings.Builder, label, value string) { value = strings.TrimSpace(value) if value == "" { return } b.WriteString("- " + label + ":" + value + "\n") } func resolveGeneratedImitationTitle(markdown string, params map[string]interface{}) string { for _, line := range strings.Split(markdown, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") { title := strings.TrimSpace(strings.TrimLeft(line, "#")) if title != "" { return title } } } return buildImitationInitialTitle(extractString(params, "source_title")) } func normalizeImitationLocale(value string) string { if strings.EqualFold(strings.TrimSpace(value), "en-US") { return "en-US" } return "zh-CN" } func normalizeImitationKeywords(values []string) []string { if len(values) == 0 { return []string{} } seen := make(map[string]struct{}, len(values)) keywords := make([]string, 0, len(values)) for _, item := range values { item = strings.TrimSpace(item) if item == "" { continue } key := strings.ToLower(item) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} keywords = append(keywords, item) if len(keywords) >= 16 { break } } return keywords } func truncateRunes(value string, maxRunes int) string { if maxRunes <= 0 { return value } runes := []rune(value) if len(runes) <= maxRunes { return value } return string(runes[:maxRunes]) }