From 590c219a6491764251bbbca22113d50de5320f42 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 24 Jun 2026 23:39:12 +0800 Subject: [PATCH] chore: update app generation workflow --- .../public/logos/icon-with-title.svg | 2 +- apps/desktop-client/electron-builder.yml | 4 + apps/desktop-client/package.json | 2 +- .../desktop-client/public/icon-with-title.svg | 2 +- .../tenant/app/article_imitation_service.go | 24 ++++ .../tenant/app/generation_task_runtime.go | 125 ++++++++++++++++++ .../tenant/app/prompt_generate_service.go | 24 ++++ .../internal/tenant/app/template_service.go | 24 ++++ .../tenant/repository/generated/audit.sql.go | 6 +- .../tenant/repository/queries/audit.sql | 6 +- .../worker/generate/kol_generation_worker.go | 22 +++ 11 files changed, 232 insertions(+), 9 deletions(-) diff --git a/apps/admin-web/public/logos/icon-with-title.svg b/apps/admin-web/public/logos/icon-with-title.svg index fd84798..8d1020a 100644 --- a/apps/admin-web/public/logos/icon-with-title.svg +++ b/apps/admin-web/public/logos/icon-with-title.svg @@ -57,7 +57,7 @@ - + diff --git a/apps/desktop-client/electron-builder.yml b/apps/desktop-client/electron-builder.yml index 25f9be9..72c38a3 100644 --- a/apps/desktop-client/electron-builder.yml +++ b/apps/desktop-client/electron-builder.yml @@ -43,6 +43,10 @@ mac: gatekeeperAssess: false entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.inherit.plist + # Playwright 只作为 CDP client 使用;lib/bin 里的字体、图片、脚本不是可执行代码。 + # 跳过这些资源的单独签名,避免 timestamp 服务抖动被放大成打包失败。 + signIgnore: + - /node_modules/playwright-core/(lib|bin)/ electronLanguages: - en - zh_CN diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index b6d3b0f..46a9d78 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@geo/desktop-client", - "version": "0.1.3", + "version": "0.1.6", "private": true, "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "author": { diff --git a/apps/desktop-client/public/icon-with-title.svg b/apps/desktop-client/public/icon-with-title.svg index fd84798..8d1020a 100644 --- a/apps/desktop-client/public/icon-with-title.svg +++ b/apps/desktop-client/public/icon-with-title.svg @@ -57,7 +57,7 @@ - + diff --git a/server/internal/tenant/app/article_imitation_service.go b/server/internal/tenant/app/article_imitation_service.go index f537800..5c6c598 100644 --- a/server/internal/tenant/app/article_imitation_service.go +++ b/server/internal/tenant/app/article_imitation_service.go @@ -409,6 +409,30 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art quotaRepo := repository.NewQuotaRepository(tx) auditTx := repository.NewAuditRepository(tx) + if err := EnsureGenerationArticleWritable(ctx, tx, job.ArticleID, job.TenantID); err != nil { + if IsGenerationArticleUnavailable(err) { + rollbackGenerationTx(tx) + if cleanupErr := HandleGenerationTaskArticleUnavailable(ctx, s.pool, s.cache, ClaimedGenerationTask{ + ID: job.TaskID, + TenantID: job.TenantID, + OperatorID: job.OperatorID, + ArticleID: job.ArticleID, + QuotaReservationID: job.ReservationID, + TaskType: articleImitationTaskType, + InputParams: cloneInputParams(job.InputParams), + }, articleImitationTaskType, false); cleanupErr != nil && s.logger != nil { + s.logger.Warn("imitation generation article unavailable cleanup failed", + zap.Error(cleanupErr), + zap.Int64("task_id", job.TaskID), + zap.Int64("article_id", job.ArticleID), + ) + } + return + } + failWithRollback("persist_article_status", err) + return + } + versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{ ArticleID: job.ArticleID, Title: title, diff --git a/server/internal/tenant/app/generation_task_runtime.go b/server/internal/tenant/app/generation_task_runtime.go index 887da8a..e428623 100644 --- a/server/internal/tenant/app/generation_task_runtime.go +++ b/server/internal/tenant/app/generation_task_runtime.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/llm" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) @@ -26,6 +27,12 @@ type ClaimedGenerationTask struct { const generationCleanupTimeout = 30 * time.Second +var errGenerationArticleUnavailable = errors.New("generation article is unavailable") + +type generationDBTX interface { + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + type promptRuleTaskContext struct { PromptRuleID int64 BrandID int64 @@ -46,6 +53,124 @@ func rollbackGenerationTx(tx pgx.Tx) { _ = tx.Rollback(ctx) } +func generationStringPtr(value string) *string { + return &value +} + +func EnsureGenerationArticleWritable(ctx context.Context, db generationDBTX, articleID, tenantID int64) error { + if articleID <= 0 || tenantID <= 0 { + return errGenerationArticleUnavailable + } + + var lockedArticleID int64 + if err := db.QueryRow(ctx, ` + SELECT id + FROM articles + WHERE id = $1 + AND tenant_id = $2 + AND deleted_at IS NULL + FOR UPDATE + `, articleID, tenantID).Scan(&lockedArticleID); err != nil { + return err + } + return nil +} + +func IsGenerationArticleUnavailable(err error) bool { + return errors.Is(err, errGenerationArticleUnavailable) || errors.Is(err, pgx.ErrNoRows) +} + +func HandleGenerationTaskArticleUnavailable( + ctx context.Context, + pool *pgxpool.Pool, + cache sharedcache.Cache, + task ClaimedGenerationTask, + taskType string, + updateKolTaskRecord bool, +) error { + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + + const errMsg = "文章已删除,生成结果已丢弃" + now := time.Now() + auditRepo := repository.NewAuditRepository(pool) + quotaRepo := repository.NewQuotaRepository(pool) + usageRepo := repository.NewKolUsageRepository(pool) + + var cleanupErr error + if task.ID > 0 { + if err := auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{ + ID: task.ID, + TenantID: task.TenantID, + Status: "cancelled", + ErrorMessage: generationStringPtr(errMsg), + CompletedAt: &now, + }); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("cancel generation task: %w", err)) + } + if updateKolTaskRecord { + _ = auditRepo.UpdateTaskRecordStatus(cleanupCtx, repository.TaskRecordInput{ + TenantID: task.TenantID, + TaskType: kolGenerationTaskRecordType, + ResourceType: kolGenerationTaskRecordScope, + ResourceID: task.ID, + Status: "cancelled", + ErrorMessage: generationStringPtr(errMsg), + }) + _ = usageRepo.MarkFailedByTask(cleanupCtx, task.TenantID, task.ID, errMsg) + } + } + + if task.QuotaReservationID > 0 { + var balance int + balanceErr := error(nil) + if task.OperatorID > 0 { + balance, balanceErr = quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation") + } + refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, task.QuotaReservationID, task.TenantID) + if err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err)) + } else if refunded { + if balanceErr != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr)) + } else if task.OperatorID > 0 { + reason := "generation_cancel_refund" + referenceType := "generation_task" + taskReferenceID := task.ID + if _, err := quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{ + TenantID: task.TenantID, + OperatorID: task.OperatorID, + QuotaType: "article_generation", + Delta: 1, + BalanceAfter: balance + 1, + Reason: &reason, + ReferenceType: &referenceType, + ReferenceID: &taskReferenceID, + }); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation cancel refund ledger: %w", err)) + } + } + } + } + + if task.ArticleID > 0 { + invalidateArticleCaches(cleanupCtx, cache, task.TenantID, &task.ArticleID) + } + + RecordGenerationTaskEvent(GenerationTaskEventTransition, GenerationTaskLogContext{ + TaskID: task.ID, + TenantID: task.TenantID, + OperatorID: task.OperatorID, + ArticleID: task.ArticleID, + ReservationID: task.QuotaReservationID, + TaskType: taskType, + Stage: "article_deleted", + StatusAfter: "cancelled", + Result: GenerationTaskResultSkipped, + }) + return cleanupErr +} + func HandleClaimedGenerationTaskFailure( ctx context.Context, pool *pgxpool.Pool, diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go index 995489a..eafbf6f 100644 --- a/server/internal/tenant/app/prompt_generate_service.go +++ b/server/internal/tenant/app/prompt_generate_service.go @@ -593,6 +593,30 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job quotaRepo := repository.NewQuotaRepository(tx) auditTx := repository.NewAuditRepository(tx) + if err := EnsureGenerationArticleWritable(ctx, tx, job.ArticleID, job.TenantID); err != nil { + if IsGenerationArticleUnavailable(err) { + rollbackGenerationTx(tx) + if cleanupErr := HandleGenerationTaskArticleUnavailable(ctx, s.pool, s.cache, ClaimedGenerationTask{ + ID: job.TaskID, + TenantID: job.TenantID, + OperatorID: job.OperatorID, + ArticleID: job.ArticleID, + QuotaReservationID: job.ReservationID, + TaskType: "custom_generation", + InputParams: cloneInputParams(job.InputParams), + }, "custom_generation", false); cleanupErr != nil && s.logger != nil { + s.logger.Warn("prompt rule generation article unavailable cleanup failed", + zap.Error(cleanupErr), + zap.Int64("task_id", job.TaskID), + zap.Int64("article_id", job.ArticleID), + ) + } + return + } + failWithRollback("persist_article_status", err) + return + } + versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{ ArticleID: job.ArticleID, Title: title, diff --git a/server/internal/tenant/app/template_service.go b/server/internal/tenant/app/template_service.go index 5d635ef..0746b6c 100644 --- a/server/internal/tenant/app/template_service.go +++ b/server/internal/tenant/app/template_service.go @@ -545,6 +545,30 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ quotaRepo := repository.NewQuotaRepository(tx) auditTx := repository.NewAuditRepository(tx) + if err := EnsureGenerationArticleWritable(ctx, tx, job.ArticleID, job.TenantID); err != nil { + if IsGenerationArticleUnavailable(err) { + rollbackGenerationTx(tx) + if cleanupErr := HandleGenerationTaskArticleUnavailable(ctx, s.pool, s.cache, ClaimedGenerationTask{ + ID: job.TaskID, + TenantID: job.TenantID, + OperatorID: job.OperatorID, + ArticleID: job.ArticleID, + QuotaReservationID: job.ReservationID, + TaskType: "template", + InputParams: cloneInputParams(job.Params), + }, "template", false); cleanupErr != nil && s.logger != nil { + s.logger.Warn("template generation article unavailable cleanup failed", + zap.Error(cleanupErr), + zap.Int64("task_id", job.TaskID), + zap.Int64("article_id", job.ArticleID), + ) + } + return + } + failWithRollback("persist_article_status", err) + return + } + versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{ ArticleID: job.ArticleID, Title: title, diff --git a/server/internal/tenant/repository/generated/audit.sql.go b/server/internal/tenant/repository/generated/audit.sql.go index a6f0ce5..e3a1c82 100644 --- a/server/internal/tenant/repository/generated/audit.sql.go +++ b/server/internal/tenant/repository/generated/audit.sql.go @@ -129,9 +129,9 @@ const updateGenerationTaskStatus = `-- name: UpdateGenerationTaskStatus :exec UPDATE generation_tasks SET status = $1::text, error_message = $2, started_at = COALESCE($3, started_at), completed_at = COALESCE($4, completed_at), - lease_token = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_token END, - lease_owner = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END, - lease_expires_at = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END, + lease_token = CASE WHEN $1::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_token END, + lease_owner = CASE WHEN $1::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_owner END, + lease_expires_at = CASE WHEN $1::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_expires_at END, updated_at = NOW() WHERE id = $5 AND tenant_id = $6 ` diff --git a/server/internal/tenant/repository/queries/audit.sql b/server/internal/tenant/repository/queries/audit.sql index e1df39e..ce6fa55 100644 --- a/server/internal/tenant/repository/queries/audit.sql +++ b/server/internal/tenant/repository/queries/audit.sql @@ -42,8 +42,8 @@ WHERE tenant_id = sqlc.arg(tenant_id) UPDATE generation_tasks SET status = sqlc.arg(status)::text, error_message = sqlc.arg(error_message), started_at = COALESCE(sqlc.arg(started_at), started_at), completed_at = COALESCE(sqlc.arg(completed_at), completed_at), - lease_token = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_token END, - lease_owner = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END, - lease_expires_at = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END, + lease_token = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_token END, + lease_owner = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_owner END, + lease_expires_at = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed', 'cancelled', 'canceled', 'aborted') THEN NULL ELSE lease_expires_at END, updated_at = NOW() WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); diff --git a/server/internal/worker/generate/kol_generation_worker.go b/server/internal/worker/generate/kol_generation_worker.go index 2833758..5a9a76c 100644 --- a/server/internal/worker/generate/kol_generation_worker.go +++ b/server/internal/worker/generate/kol_generation_worker.go @@ -179,6 +179,15 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime auditTx := repository.NewAuditRepository(tx) usageRepo := repository.NewKolUsageRepository(tx) + if err := tenantapp.EnsureGenerationArticleWritable(ctx, tx, task.ArticleID, task.TenantID); err != nil { + if tenantapp.IsGenerationArticleUnavailable(err) { + w.rollbackAndCancelUnavailableArticle(ctx, tx, task) + return + } + w.rollbackAndFail(ctx, tx, task, "persist_article_status", err) + return + } + versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{ ArticleID: task.ArticleID, Title: title, @@ -270,6 +279,19 @@ func (w *KolGenerationWorker) rollbackAndFail(ctx context.Context, tx pgx.Tx, ta w.HandleTaskFailure(ctx, task, stage, taskErr) } +func (w *KolGenerationWorker) rollbackAndCancelUnavailableArticle(ctx context.Context, tx pgx.Tx, task tenantapp.ClaimedGenerationTask) { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = tx.Rollback(cleanupCtx) + if err := tenantapp.HandleGenerationTaskArticleUnavailable(ctx, w.pool, w.cache, task, kolGenerationTaskType, true); err != nil && w.logger != nil { + w.logger.Warn("kol generation article unavailable cleanup failed", + zap.Error(err), + zap.Int64("task_id", task.ID), + zap.Int64("article_id", task.ArticleID), + ) + } +} + var ( errMissingRenderedPrompt = errors.New("kol generation task missing rendered prompt") errEmptyGeneratedContent = errors.New("kol generation returned empty content")