diff --git a/server/cmd/scheduler/main.go b/server/cmd/scheduler/main.go index 974dd2c..e990a3a 100644 --- a/server/cmd/scheduler/main.go +++ b/server/cmd/scheduler/main.go @@ -48,7 +48,7 @@ func main() { app.GenerationStreams, generationCfg, app.Config.LLM.MaxOutputTokens, - ) + ).WithCache(app.Cache) monitoringCallbackService := tenantapp.NewMonitoringCallbackService( app.DB, @@ -61,7 +61,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - internalscheduler.NewScheduleDispatchWorker(app.DB, app.RabbitMQ, promptRuleSvc, app.Logger, app.Config.Scheduler).Start(ctx) + internalscheduler.NewScheduleDispatchWorker(app.DB, app.RabbitMQ, promptRuleSvc, app.Cache, app.Logger, app.Config.Scheduler).Start(ctx) internalscheduler.NewMonitoringResultRecoveryWorker(app.MonitoringDB, monitoringCallbackService, app.Logger).Start(ctx) internalscheduler.NewMonitoringLeaseRecoveryWorker(app.MonitoringDB, app.Logger).Start(ctx) internalscheduler.NewMonitoringReceivedInspectionWorker(app.MonitoringDB, app.Logger).Start(ctx) diff --git a/server/cmd/worker-generate/main.go b/server/cmd/worker-generate/main.go index 7b97649..7e58449 100644 --- a/server/cmd/worker-generate/main.go +++ b/server/cmd/worker-generate/main.go @@ -53,7 +53,7 @@ func main() { app.GenerationStreams, generationCfg, app.Config.LLM.MaxOutputTokens, - ) + ).WithCache(app.Cache) promptRuleSvc := tenantapp.NewPromptRuleGenerationService( app.DB, @@ -63,7 +63,7 @@ func main() { app.GenerationStreams, generationCfg, app.Config.LLM.MaxOutputTokens, - ) + ).WithCache(app.Cache) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/server/internal/scheduler/schedule_dispatch_worker.go b/server/internal/scheduler/schedule_dispatch_worker.go index f647b85..f256c95 100644 --- a/server/internal/scheduler/schedule_dispatch_worker.go +++ b/server/internal/scheduler/schedule_dispatch_worker.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "go.uber.org/zap" + 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/messaging/rabbitmq" sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule" @@ -19,12 +20,14 @@ const ( defaultScheduleDispatchInterval = 15 * time.Second defaultScheduleDispatchTimeout = 30 * time.Second defaultScheduleDispatchBatch = 20 + scheduleCacheCleanupTimeout = 30 * time.Second ) type ScheduleDispatchWorker struct { pool *pgxpool.Pool rabbitMQ *rabbitmq.Client promptRuleService *tenantapp.PromptRuleGenerationService + cache sharedcache.Cache logger *zap.Logger interval time.Duration timeout time.Duration @@ -49,10 +52,16 @@ type dueScheduleTask struct { ScheduledFor time.Time } +type scheduleTaskCacheUpdate struct { + id int64 + tenantID int64 +} + func NewScheduleDispatchWorker( pool *pgxpool.Pool, rabbitMQClient *rabbitmq.Client, promptRuleService *tenantapp.PromptRuleGenerationService, + cache sharedcache.Cache, logger *zap.Logger, cfg config.SchedulerConfig, ) *ScheduleDispatchWorker { @@ -60,6 +69,7 @@ func NewScheduleDispatchWorker( pool: pool, rabbitMQ: rabbitMQClient, promptRuleService: promptRuleService, + cache: cache, logger: logger, interval: schedulerDispatchInterval(cfg), timeout: schedulerDispatchTimeout(cfg), @@ -138,7 +148,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in defer tx.Rollback(ctx) rows, err := tx.Query(ctx, ` - SELECT id, cron_expr, start_at, end_at, status + SELECT id, tenant_id, cron_expr, start_at, end_at, status FROM schedule_tasks WHERE deleted_at IS NULL AND status = 'enabled' @@ -154,18 +164,19 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in now := time.Now() count := 0 updates := make([]struct { - id int64 - nextRunAt *time.Time + cacheUpdate scheduleTaskCacheUpdate + nextRunAt *time.Time }, 0, w.batchSize) for rows.Next() { var ( id int64 + tenantID int64 cronExpr string status string startAt pgtype.Timestamptz endAt pgtype.Timestamptz ) - if err := rows.Scan(&id, &cronExpr, &startAt, &endAt, &status); err != nil { + if err := rows.Scan(&id, &tenantID, &cronExpr, &startAt, &endAt, &status); err != nil { rows.Close() return 0, err } @@ -183,10 +194,13 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in } updates = append(updates, struct { - id int64 - nextRunAt *time.Time + cacheUpdate scheduleTaskCacheUpdate + nextRunAt *time.Time }{ - id: id, + cacheUpdate: scheduleTaskCacheUpdate{ + id: id, + tenantID: tenantID, + }, nextRunAt: nextRunAt, }) } @@ -202,7 +216,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in SET next_run_at = $1, updated_at = NOW() WHERE id = $2 - `, update.nextRunAt, update.id); err != nil { + `, update.nextRunAt, update.cacheUpdate.id); err != nil { return 0, err } count++ @@ -211,6 +225,11 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in if err := tx.Commit(ctx); err != nil { return 0, err } + cacheUpdates := make([]scheduleTaskCacheUpdate, 0, len(updates)) + for _, update := range updates { + cacheUpdates = append(cacheUpdates, update.cacheUpdate) + } + w.invalidateScheduleTaskCaches(cacheUpdates) return count, nil } @@ -240,8 +259,8 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc now := time.Now() tasks := make([]dueScheduleTask, 0, w.batchSize) updates := make([]struct { - id int64 - nextRun *time.Time + cacheUpdate scheduleTaskCacheUpdate + nextRun *time.Time }, 0, w.batchSize) for rows.Next() { var ( @@ -293,10 +312,13 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc } updates = append(updates, struct { - id int64 - nextRun *time.Time + cacheUpdate scheduleTaskCacheUpdate + nextRun *time.Time }{ - id: task.ID, + cacheUpdate: scheduleTaskCacheUpdate{ + id: task.ID, + tenantID: task.TenantID, + }, nextRun: nextRun, }) @@ -316,7 +338,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc SET next_run_at = $1, updated_at = NOW() WHERE id = $2 - `, update.nextRun, update.id); err != nil { + `, update.nextRun, update.cacheUpdate.id); err != nil { return nil, err } } @@ -324,6 +346,11 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc if err := tx.Commit(ctx); err != nil { return nil, err } + cacheUpdates := make([]scheduleTaskCacheUpdate, 0, len(updates)) + for _, update := range updates { + cacheUpdates = append(cacheUpdates, update.cacheUpdate) + } + w.invalidateScheduleTaskCaches(cacheUpdates) return tasks, nil } @@ -450,6 +477,20 @@ func schedulerDispatchConcurrency(cfg config.SchedulerConfig) int { return 1 } +func (w *ScheduleDispatchWorker) invalidateScheduleTaskCaches(updates []scheduleTaskCacheUpdate) { + if w == nil || w.cache == nil || len(updates) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), scheduleCacheCleanupTimeout) + defer cancel() + + for _, update := range updates { + taskID := update.id + tenantapp.InvalidateScheduleTaskCaches(ctx, w.cache, update.tenantID, &taskID) + } +} + func timePtrFromTimestamp(value pgtype.Timestamptz) *time.Time { if !value.Valid { return nil diff --git a/server/internal/shared/cache/cache.go b/server/internal/shared/cache/cache.go index 52603b1..1d9712f 100644 --- a/server/internal/shared/cache/cache.go +++ b/server/internal/shared/cache/cache.go @@ -12,4 +12,5 @@ type Cache interface { Get(ctx context.Context, key string) ([]byte, error) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error Delete(ctx context.Context, key string) error + DeletePrefix(ctx context.Context, prefix string) error } diff --git a/server/internal/shared/cache/memory.go b/server/internal/shared/cache/memory.go index 7c6c03b..e3ea05f 100644 --- a/server/internal/shared/cache/memory.go +++ b/server/internal/shared/cache/memory.go @@ -58,6 +58,21 @@ func (c *memoryCache) Delete(_ context.Context, key string) error { return nil } +func (c *memoryCache) DeletePrefix(_ context.Context, prefix string) error { + if prefix == "" { + return nil + } + + c.mu.Lock() + for key := range c.entries { + if len(key) >= len(prefix) && key[:len(prefix)] == prefix { + delete(c.entries, key) + } + } + c.mu.Unlock() + return nil +} + func (c *memoryCache) evictLoop() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() diff --git a/server/internal/shared/cache/readthrough.go b/server/internal/shared/cache/readthrough.go new file mode 100644 index 0000000..43453bf --- /dev/null +++ b/server/internal/shared/cache/readthrough.go @@ -0,0 +1,128 @@ +package cache + +import ( + "context" + "encoding/json" + "math/rand" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + defaultJitterRatio = 0.1 +) + +var ( + jitterMu sync.Mutex + jitterRand = rand.New(rand.NewSource(time.Now().UnixNano())) +) + +type payloadEnvelope[T any] struct { + Empty bool `json:"empty,omitempty"` + Value T `json:"value,omitempty"` +} + +func LoadJSON[T any]( + ctx context.Context, + c Cache, + group *singleflight.Group, + key string, + ttl time.Duration, + loader func(context.Context) (T, error), +) (T, error) { + value, _, err := loadJSONInternal(ctx, c, group, key, ttl, 0, func(loadCtx context.Context) (T, bool, error) { + value, err := loader(loadCtx) + return value, true, err + }) + return value, err +} + +func LoadJSONWithEmpty[T any]( + ctx context.Context, + c Cache, + group *singleflight.Group, + key string, + ttl time.Duration, + emptyTTL time.Duration, + loader func(context.Context) (T, bool, error), +) (T, bool, error) { + return loadJSONInternal(ctx, c, group, key, ttl, emptyTTL, loader) +} + +func loadJSONInternal[T any]( + ctx context.Context, + c Cache, + group *singleflight.Group, + key string, + ttl time.Duration, + emptyTTL time.Duration, + loader func(context.Context) (T, bool, error), +) (T, bool, error) { + var zero T + if c == nil { + return loader(ctx) + } + + if raw, err := c.Get(ctx, key); err == nil { + var envelope payloadEnvelope[T] + if unmarshalErr := json.Unmarshal(raw, &envelope); unmarshalErr == nil { + if envelope.Empty { + return zero, false, nil + } + return envelope.Value, true, nil + } + } + + result, err, _ := group.Do(key, func() (interface{}, error) { + value, found, loadErr := loader(ctx) + if loadErr != nil { + return nil, loadErr + } + + envelope := payloadEnvelope[T]{ + Empty: !found, + Value: value, + } + if raw, marshalErr := json.Marshal(envelope); marshalErr == nil { + cacheTTL := ttl + if !found { + cacheTTL = emptyTTL + } + if cacheTTL > 0 { + _ = c.Set(ctx, key, raw, ApplyJitter(cacheTTL)) + } + } + + return envelope, nil + }) + if err != nil { + return zero, false, err + } + + envelope, ok := result.(payloadEnvelope[T]) + if !ok { + return zero, false, nil + } + if envelope.Empty { + return zero, false, nil + } + return envelope.Value, true, nil +} + +func ApplyJitter(ttl time.Duration) time.Duration { + if ttl <= 0 { + return ttl + } + + maxJitter := int64(float64(ttl) * defaultJitterRatio) + if maxJitter <= 0 { + return ttl + } + + jitterMu.Lock() + defer jitterMu.Unlock() + + return ttl + time.Duration(jitterRand.Int63n(maxJitter+1)) +} diff --git a/server/internal/shared/cache/redis.go b/server/internal/shared/cache/redis.go index cbeeeff..cf82bab 100644 --- a/server/internal/shared/cache/redis.go +++ b/server/internal/shared/cache/redis.go @@ -31,3 +31,27 @@ func (c *redisCache) Set(ctx context.Context, key string, value []byte, ttl time func (c *redisCache) Delete(ctx context.Context, key string) error { return c.client.Del(ctx, key).Err() } + +func (c *redisCache) DeletePrefix(ctx context.Context, prefix string) error { + if prefix == "" { + return nil + } + + pattern := prefix + "*" + var cursor uint64 + for { + keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + return err + } + if len(keys) > 0 { + if err := c.client.Del(ctx, keys...).Err(); err != nil { + return err + } + } + cursor = nextCursor + if cursor == 0 { + return nil + } + } +} diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index 07037cd..7172d2c 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -14,9 +14,11 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "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/contentstats" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" @@ -28,12 +30,19 @@ type ArticleService struct { pool *pgxpool.Pool auditLogs *auditlog.AsyncWriter objectStorage objectstorage.Client + cache sharedcache.Cache + cacheGroup singleflight.Group } func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService { return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage} } +func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService { + s.cache = c + return s +} + const maxArticleImageSizeBytes = 10 * 1024 * 1024 var supportedArticleImageExtensions = map[string]string{ @@ -84,6 +93,47 @@ type ArticleListResponse struct { func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) { actor := auth.MustActor(ctx) + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) { + return s.loadArticles(loadCtx, actor.TenantID, params) + }) +} + +type ArticleDetailResponse struct { + ID int64 `json:"id"` + SourceType string `json:"source_type"` + TemplateID *int64 `json:"template_id"` + TemplateName *string `json:"template_name"` + GenerationMode *string `json:"generation_mode"` + Platforms []string `json:"platforms"` + CoverAssetURL *string `json:"cover_asset_url"` + 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) + record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) { + return s.loadArticleDetail(loadCtx, actor.TenantID, id) + }) + if err != nil { + return nil, err + } + if !found || record == nil { + return nil, response.ErrNotFound(40411, "article_not_found", "article not found") + } + return record, nil +} + +func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) { sourceType := params.SourceType if params.Page < 1 { @@ -94,9 +144,8 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A } offset := (params.Page - 1) * params.PageSize - // Count var total int64 - countArgs := []interface{}{actor.TenantID} + countArgs := []interface{}{tenantID} 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 @@ -160,7 +209,6 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") } - // List 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, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json FROM articles a @@ -175,7 +223,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A LIMIT 1 ) gt ON true WHERE a.tenant_id = $1 AND a.deleted_at IS NULL` - listArgs := []interface{}{actor.TenantID} + listArgs := []interface{}{tenantID} listIdx := 2 if params.GenerateStatus != nil { @@ -233,7 +281,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A } defer rows.Close() - var items []ArticleListItem + items := make([]ArticleListItem, 0) for rows.Next() { var item ArticleListItem var dbSourceType string @@ -248,40 +296,13 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) items = append(items, item) } - if items == nil { - items = []ArticleListItem{} - } - return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil } -type ArticleDetailResponse struct { - ID int64 `json:"id"` - SourceType string `json:"source_type"` - TemplateID *int64 `json:"template_id"` - TemplateName *string `json:"template_name"` - GenerationMode *string `json:"generation_mode"` - Platforms []string `json:"platforms"` - CoverAssetURL *string `json:"cover_asset_url"` - 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 +func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articleID int64) (*ArticleDetailResponse, bool, error) { + var item ArticleDetailResponse var dbSourceType string var currentVersionID *int64 - var wizardStateJSON []byte var inputParamsJSON []byte err := s.pool.QueryRow(ctx, ` @@ -299,29 +320,33 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe LIMIT 1 ) gt ON true WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL - `, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, ¤tVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt, - &d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON) + `, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt, + &item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON) if err != nil { - return nil, response.ErrNotFound(40411, "article_not_found", "article not found") - } - d.SourceType = dbSourceType - if currentVersionID != nil { - content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID) - if err != nil { - return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version") + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil } - d.HTMLContent = content.HTMLContent - d.MarkdownContent = content.MarkdownContent + return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch article") + } + + item.SourceType = dbSourceType + if currentVersionID != nil { + content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID) + if err != nil { + return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version") + } + item.HTMLContent = content.HTMLContent + item.MarkdownContent = content.MarkdownContent } if len(wizardStateJSON) > 0 { - d.WizardState = map[string]interface{}{} - _ = json.Unmarshal(wizardStateJSON, &d.WizardState) + item.WizardState = map[string]interface{}{} + _ = json.Unmarshal(wizardStateJSON, &item.WizardState) } - d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) - d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) - d.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON) - d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount) - return &d, nil + item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) + item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) + item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON) + item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount) + return &item, true, nil } type CreateArticleRequest struct { @@ -374,6 +399,7 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) ( return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation") } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) return s.Detail(ctx, articleID) } @@ -473,6 +499,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update") } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id) return s.Detail(ctx, id) } @@ -598,6 +625,7 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error { Result: &result, }) + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id) return nil } diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go index 5f0b531..a0c6904 100644 --- a/server/internal/tenant/app/brand_service.go +++ b/server/internal/tenant/app/brand_service.go @@ -3,13 +3,17 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "strings" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "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/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" ) @@ -18,12 +22,19 @@ type BrandService struct { pool *pgxpool.Pool monitoringPool *pgxpool.Pool auditLogs *auditlog.AsyncWriter + cache sharedcache.Cache + cacheGroup singleflight.Group } func NewBrandService(pool, monitoringPool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *BrandService { return &BrandService{pool: pool, monitoringPool: monitoringPool, auditLogs: auditLogs} } +func (s *BrandService) WithCache(c sharedcache.Cache) *BrandService { + s.cache = c + return s +} + // --- Brand CRUD --- type BrandRequest struct { @@ -44,30 +55,9 @@ type BrandResponse struct { func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) { actor := auth.MustActor(ctx) - rows, err := s.pool.Query(ctx, ` - SELECT id, name, website, description, status, created_at, updated_at - FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC - `, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to list brands") - } - defer rows.Close() - - var brands []BrandResponse - for rows.Next() { - var b BrandResponse - var ca, ua interface{} - if err := rows.Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua); err != nil { - return nil, response.ErrInternal(50010, "scan_failed", err.Error()) - } - b.CreatedAt = fmt.Sprintf("%v", ca) - b.UpdatedAt = fmt.Sprintf("%v", ua) - brands = append(brands, b) - } - if brands == nil { - brands = []BrandResponse{} - } - return brands, nil + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]BrandResponse, error) { + return s.loadBrands(loadCtx, actor.TenantID) + }) } func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) { @@ -102,6 +92,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp Result: &result, }) + invalidateBrandCaches(ctx, s.cache, actor.TenantID, id) return &BrandResponse{ ID: id, Name: req.Name, @@ -114,18 +105,16 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) { actor := auth.MustActor(ctx) - var b BrandResponse - var ca, ua interface{} - err := s.pool.QueryRow(ctx, ` - SELECT id, name, website, description, status, created_at, updated_at - FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - `, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua) + record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, brandDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*BrandResponse, bool, error) { + return s.loadBrandDetail(loadCtx, actor.TenantID, id) + }) if err != nil { + return nil, err + } + if !found || record == nil { return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found") } - b.CreatedAt = fmt.Sprintf("%v", ca) - b.UpdatedAt = fmt.Sprintf("%v", ua) - return &b, nil + return record, nil } func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) error { @@ -141,6 +130,7 @@ func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) e if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40420, "brand_not_found", "brand not found") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, id) return nil } @@ -199,6 +189,7 @@ func (s *BrandService) Delete(ctx context.Context, id int64) error { Result: &result, }) + invalidateBrandCaches(ctx, s.cache, actor.TenantID, id) return nil } @@ -218,29 +209,9 @@ type KeywordResponse struct { func (s *BrandService) ListKeywords(ctx context.Context, brandID int64) ([]KeywordResponse, error) { actor := auth.MustActor(ctx) - rows, err := s.pool.Query(ctx, ` - SELECT id, brand_id, name, status, created_at FROM brand_keywords - WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC - `, brandID, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords") - } - defer rows.Close() - - var keywords []KeywordResponse - for rows.Next() { - var k KeywordResponse - var ca interface{} - if err := rows.Scan(&k.ID, &k.BrandID, &k.Name, &k.Status, &ca); err != nil { - return nil, response.ErrInternal(50010, "scan_failed", err.Error()) - } - k.CreatedAt = fmt.Sprintf("%v", ca) - keywords = append(keywords, k) - } - if keywords == nil { - keywords = []KeywordResponse{} - } - return keywords, nil + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandKeywordsCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]KeywordResponse, error) { + return s.loadBrandKeywords(loadCtx, actor.TenantID, brandID) + }) } func (s *BrandService) CreateKeyword(ctx context.Context, brandID int64, req KeywordRequest) (*KeywordResponse, error) { @@ -253,6 +224,7 @@ func (s *BrandService) CreateKeyword(ctx context.Context, brandID int64, req Key if err != nil { return nil, response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return &KeywordResponse{ID: id, BrandID: brandID, Name: req.Name, Status: "active", CreatedAt: fmt.Sprintf("%v", ca)}, nil } @@ -265,6 +237,7 @@ func (s *BrandService) UpdateKeyword(ctx context.Context, brandID, keywordID int if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40421, "keyword_not_found", "keyword not found") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } @@ -329,6 +302,7 @@ func (s *BrandService) DeleteKeyword(ctx context.Context, brandID, keywordID int return err } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } @@ -350,38 +324,9 @@ type QuestionResponse struct { func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) { actor := auth.MustActor(ctx) - query := ` - SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at - FROM brand_questions q - WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL` - args := []interface{}{brandID, actor.TenantID} - - if keywordID != nil { - query += ` AND q.keyword_id = $3` - args = append(args, *keywordID) - } - query += ` ORDER BY q.created_at DESC` - - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to list questions") - } - defer rows.Close() - - var questions []QuestionResponse - for rows.Next() { - var q QuestionResponse - var ca interface{} - if err := rows.Scan(&q.ID, &q.BrandID, &q.KeywordID, &q.QuestionText, &q.Status, &ca); err != nil { - return nil, response.ErrInternal(50010, "scan_failed", err.Error()) - } - q.CreatedAt = fmt.Sprintf("%v", ca) - questions = append(questions, q) - } - if questions == nil { - questions = []QuestionResponse{} - } - return questions, nil + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) { + return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID) + }) } func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req QuestionRequest) (*QuestionResponse, error) { @@ -402,6 +347,7 @@ func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req Qu return nil, response.ErrInternal(50010, "create_failed", "failed to create question") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return &QuestionResponse{ ID: questionID, BrandID: brandID, @@ -430,6 +376,7 @@ func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID i if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40422, "question_not_found", "question not found") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } @@ -459,6 +406,7 @@ func (s *BrandService) DeleteQuestion(ctx context.Context, brandID, questionID i return err } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } @@ -483,34 +431,9 @@ type CompetitorResponse struct { func (s *BrandService) ListCompetitors(ctx context.Context, brandID int64) ([]CompetitorResponse, error) { actor := auth.MustActor(ctx) - rows, err := s.pool.Query(ctx, ` - SELECT id, brand_id, name, website, description, product_lines_json, created_at - FROM competitors WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC - `, brandID, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to list competitors") - } - defer rows.Close() - - var comps []CompetitorResponse - for rows.Next() { - var c CompetitorResponse - var ca interface{} - var plJSON []byte - if err := rows.Scan(&c.ID, &c.BrandID, &c.Name, &c.Website, &c.Description, &plJSON, &ca); err != nil { - return nil, response.ErrInternal(50010, "scan_failed", err.Error()) - } - c.CreatedAt = fmt.Sprintf("%v", ca) - if plJSON != nil { - raw := json.RawMessage(plJSON) - c.ProductLinesJSON = &raw - } - comps = append(comps, c) - } - if comps == nil { - comps = []CompetitorResponse{} - } - return comps, nil + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandCompetitorsCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]CompetitorResponse, error) { + return s.loadBrandCompetitors(loadCtx, actor.TenantID, brandID) + }) } func (s *BrandService) CreateCompetitor(ctx context.Context, brandID int64, req CompetitorRequest) (*CompetitorResponse, error) { @@ -529,6 +452,7 @@ func (s *BrandService) CreateCompetitor(ctx context.Context, brandID int64, req if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create competitor") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return &CompetitorResponse{ID: id, BrandID: brandID, Name: req.Name, Website: req.Website, Description: req.Description, ProductLinesJSON: req.ProductLinesJSON, CreatedAt: fmt.Sprintf("%v", ca)}, nil } @@ -545,6 +469,7 @@ func (s *BrandService) UpdateCompetitor(ctx context.Context, brandID, competitor if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40423, "competitor_not_found", "competitor not found") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } @@ -557,5 +482,133 @@ func (s *BrandService) DeleteCompetitor(ctx context.Context, brandID, competitor if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40423, "competitor_not_found", "competitor not found") } + invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return nil } + +func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandResponse, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, website, description, status, created_at, updated_at + FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC + `, tenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list brands") + } + defer rows.Close() + + brands := make([]BrandResponse, 0) + for rows.Next() { + var item BrandResponse + var createdAt interface{} + var updatedAt interface{} + if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &createdAt, &updatedAt); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + item.UpdatedAt = fmt.Sprintf("%v", updatedAt) + brands = append(brands, item) + } + return brands, nil +} + +func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID int64) (*BrandResponse, bool, error) { + var item BrandResponse + var createdAt interface{} + var updatedAt interface{} + err := s.pool.QueryRow(ctx, ` + SELECT id, name, website, description, status, created_at, updated_at + FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch brand") + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + item.UpdatedAt = fmt.Sprintf("%v", updatedAt) + return &item, true, nil +} + +func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID int64) ([]KeywordResponse, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, brand_id, name, status, created_at FROM brand_keywords + WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC + `, brandID, tenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords") + } + defer rows.Close() + + items := make([]KeywordResponse, 0) + for rows.Next() { + var item KeywordResponse + var createdAt interface{} + if err := rows.Scan(&item.ID, &item.BrandID, &item.Name, &item.Status, &createdAt); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + items = append(items, item) + } + return items, nil +} + +func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) { + query := ` + SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at + FROM brand_questions q + WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL` + args := []interface{}{brandID, tenantID} + + if keywordID != nil { + query += ` AND q.keyword_id = $3` + args = append(args, *keywordID) + } + query += ` ORDER BY q.created_at DESC` + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list questions") + } + defer rows.Close() + + items := make([]QuestionResponse, 0) + for rows.Next() { + var item QuestionResponse + var createdAt interface{} + if err := rows.Scan(&item.ID, &item.BrandID, &item.KeywordID, &item.QuestionText, &item.Status, &createdAt); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + items = append(items, item) + } + return items, nil +} + +func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, brand_id, name, website, description, product_lines_json, created_at + FROM competitors WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC + `, brandID, tenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list competitors") + } + defer rows.Close() + + items := make([]CompetitorResponse, 0) + for rows.Next() { + var item CompetitorResponse + var createdAt interface{} + var productLinesJSON []byte + if err := rows.Scan(&item.ID, &item.BrandID, &item.Name, &item.Website, &item.Description, &productLinesJSON, &createdAt); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + if productLinesJSON != nil { + raw := json.RawMessage(productLinesJSON) + item.ProductLinesJSON = &raw + } + items = append(items, item) + } + return items, nil +} diff --git a/server/internal/tenant/app/cache_support.go b/server/internal/tenant/app/cache_support.go new file mode 100644 index 0000000..bca4c32 --- /dev/null +++ b/server/internal/tenant/app/cache_support.go @@ -0,0 +1,212 @@ +package app + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" +) + +const ( + defaultReadCacheTTL = 5 * time.Minute + defaultReadCacheEmptyTTL = 1 * time.Minute +) + +func defaultCacheTTL() time.Duration { + return defaultReadCacheTTL +} + +func defaultCacheEmptyTTL() time.Duration { + return defaultReadCacheEmptyTTL +} + +func digestCacheKey(value interface{}) string { + raw, err := json.Marshal(value) + if err != nil { + return "fallback" + } + + sum := sha1.Sum(raw) + return hex.EncodeToString(sum[:8]) +} + +func deleteCacheKey(ctx context.Context, c sharedcache.Cache, key string) { + if c == nil || key == "" { + return + } + _ = c.Delete(ctx, key) +} + +func deleteCachePrefix(ctx context.Context, c sharedcache.Cache, prefix string) { + if c == nil || prefix == "" { + return + } + _ = c.DeletePrefix(ctx, prefix) +} + +func workspaceOverviewCacheKey(tenantID int64) string { + return fmt.Sprintf("workspace:overview:%d", tenantID) +} + +func workspaceRecentArticlesCacheKey(tenantID int64) string { + return fmt.Sprintf("workspace:recent_articles:%d", tenantID) +} + +func workspaceQuotaSummaryCacheKey(tenantID int64) string { + return fmt.Sprintf("workspace:quota_summary:%d", tenantID) +} + +func workspaceTemplateCardsCacheKey(tenantID int64) string { + return fmt.Sprintf("workspace:template_cards:%d", tenantID) +} + +func invalidateWorkspaceCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) { + deleteCacheKey(ctx, c, workspaceOverviewCacheKey(tenantID)) + deleteCacheKey(ctx, c, workspaceRecentArticlesCacheKey(tenantID)) + deleteCacheKey(ctx, c, workspaceQuotaSummaryCacheKey(tenantID)) + deleteCacheKey(ctx, c, workspaceTemplateCardsCacheKey(tenantID)) +} + +func brandListCacheKey(tenantID int64) string { + return fmt.Sprintf("brand:list:%d", tenantID) +} + +func brandDetailCacheKey(tenantID, brandID int64) string { + return fmt.Sprintf("brand:detail:%d:%d", tenantID, brandID) +} + +func brandKeywordsCachePrefix(tenantID, brandID int64) string { + return fmt.Sprintf("brand:keywords:%d:%d:", tenantID, brandID) +} + +func brandKeywordsCacheKey(tenantID, brandID int64) string { + return brandKeywordsCachePrefix(tenantID, brandID) + "all" +} + +func brandQuestionsCachePrefix(tenantID, brandID int64) string { + return fmt.Sprintf("brand:questions:%d:%d:", tenantID, brandID) +} + +func brandQuestionsCacheKey(tenantID, brandID int64, keywordID *int64) string { + return fmt.Sprintf("%s%s", brandQuestionsCachePrefix(tenantID, brandID), digestCacheKey(struct { + KeywordID *int64 `json:"keyword_id,omitempty"` + }{ + KeywordID: keywordID, + })) +} + +func brandCompetitorsCachePrefix(tenantID, brandID int64) string { + return fmt.Sprintf("brand:competitors:%d:%d:", tenantID, brandID) +} + +func brandCompetitorsCacheKey(tenantID, brandID int64) string { + return brandCompetitorsCachePrefix(tenantID, brandID) + "all" +} + +func scheduleTaskListCachePrefix(tenantID int64) string { + return fmt.Sprintf("schedule_task:list:%d:", tenantID) +} + +func scheduleTaskDetailCachePrefix(tenantID int64) string { + return fmt.Sprintf("schedule_task:detail:%d:", tenantID) +} + +func invalidateBrandCaches(ctx context.Context, c sharedcache.Cache, tenantID, brandID int64) { + deleteCacheKey(ctx, c, brandDetailCacheKey(tenantID, brandID)) + deleteCachePrefix(ctx, c, brandKeywordsCachePrefix(tenantID, brandID)) + deleteCachePrefix(ctx, c, brandQuestionsCachePrefix(tenantID, brandID)) + deleteCachePrefix(ctx, c, brandCompetitorsCachePrefix(tenantID, brandID)) + deleteCacheKey(ctx, c, brandListCacheKey(tenantID)) + deleteCachePrefix(ctx, c, scheduleTaskListCachePrefix(tenantID)) + deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID)) + invalidateWorkspaceCaches(ctx, c, tenantID) +} + +func promptRuleGroupsCacheKey(tenantID int64) string { + return fmt.Sprintf("prompt_rule:groups:%d", tenantID) +} + +func promptRuleSimpleCacheKey(tenantID int64) string { + return fmt.Sprintf("prompt_rule:simple:%d", tenantID) +} + +func promptRuleDetailCachePrefix(tenantID int64) string { + return fmt.Sprintf("prompt_rule:detail:%d:", tenantID) +} + +func promptRuleDetailCacheKey(tenantID, ruleID int64) string { + return fmt.Sprintf("%s%d", promptRuleDetailCachePrefix(tenantID), ruleID) +} + +func promptRuleListCachePrefix(tenantID int64) string { + return fmt.Sprintf("prompt_rule:list:%d:", tenantID) +} + +func promptRuleListCacheKey(tenantID int64, params interface{}) string { + return promptRuleListCachePrefix(tenantID) + digestCacheKey(params) +} + +func articleListCachePrefix(tenantID int64) string { + return fmt.Sprintf("article:list:%d:", tenantID) +} + +func invalidatePromptRuleCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, ruleID *int64) { + deleteCacheKey(ctx, c, promptRuleGroupsCacheKey(tenantID)) + deleteCacheKey(ctx, c, promptRuleSimpleCacheKey(tenantID)) + deleteCachePrefix(ctx, c, promptRuleListCachePrefix(tenantID)) + if ruleID != nil && *ruleID > 0 { + deleteCacheKey(ctx, c, promptRuleDetailCacheKey(tenantID, *ruleID)) + } else { + deleteCachePrefix(ctx, c, promptRuleDetailCachePrefix(tenantID)) + } + deleteCachePrefix(ctx, c, scheduleTaskListCachePrefix(tenantID)) + deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID)) + deleteCachePrefix(ctx, c, articleListCachePrefix(tenantID)) +} + +func scheduleTaskDetailCacheKey(tenantID, taskID int64) string { + return fmt.Sprintf("%s%d", scheduleTaskDetailCachePrefix(tenantID), taskID) +} + +func scheduleTaskListCacheKey(tenantID int64, params interface{}) string { + return scheduleTaskListCachePrefix(tenantID) + digestCacheKey(params) +} + +func invalidateScheduleTaskCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, taskID *int64) { + deleteCachePrefix(ctx, c, scheduleTaskListCachePrefix(tenantID)) + if taskID != nil && *taskID > 0 { + deleteCacheKey(ctx, c, scheduleTaskDetailCacheKey(tenantID, *taskID)) + } else { + deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID)) + } +} + +func InvalidateScheduleTaskCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, taskID *int64) { + invalidateScheduleTaskCaches(ctx, c, tenantID, taskID) +} + +func articleDetailCachePrefix(tenantID int64) string { + return fmt.Sprintf("article:detail:%d:", tenantID) +} + +func articleDetailCacheKey(tenantID, articleID int64) string { + return fmt.Sprintf("%s%d", articleDetailCachePrefix(tenantID), articleID) +} + +func articleListCacheKey(tenantID int64, params interface{}) string { + return articleListCachePrefix(tenantID) + digestCacheKey(params) +} + +func invalidateArticleCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, articleID *int64) { + deleteCachePrefix(ctx, c, articleListCachePrefix(tenantID)) + if articleID != nil && *articleID > 0 { + deleteCacheKey(ctx, c, articleDetailCacheKey(tenantID, *articleID)) + } else { + deleteCachePrefix(ctx, c, articleDetailCachePrefix(tenantID)) + } + invalidateWorkspaceCaches(ctx, c, tenantID) +} diff --git a/server/internal/tenant/app/generation_task_runtime.go b/server/internal/tenant/app/generation_task_runtime.go index ce286ef..806f5ae 100644 --- a/server/internal/tenant/app/generation_task_runtime.go +++ b/server/internal/tenant/app/generation_task_runtime.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/llm" @@ -22,6 +23,23 @@ type ClaimedGenerationTask struct { InputParams map[string]interface{} } +const generationCleanupTimeout = 30 * time.Second + +func newGenerationCleanupContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), generationCleanupTimeout) +} + +func rollbackGenerationTx(tx pgx.Tx) { + if tx == nil { + return + } + + ctx, cancel := newGenerationCleanupContext() + defer cancel() + + _ = tx.Rollback(ctx) +} + func HandleClaimedGenerationTaskFailure( ctx context.Context, pool *pgxpool.Pool, @@ -30,6 +48,9 @@ func HandleClaimedGenerationTaskFailure( task ClaimedGenerationTask, taskErr error, ) error { + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + errMsg := formatGenerationFailure("task_load", taskErr) now := time.Now() @@ -37,7 +58,7 @@ func HandleClaimedGenerationTaskFailure( articleRepo := repository.NewArticleRepository(pool) quotaRepo := repository.NewQuotaRepository(pool) - _ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ + _ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{ ID: task.ID, TenantID: task.TenantID, Status: "failed", @@ -45,16 +66,16 @@ func HandleClaimedGenerationTaskFailure( CompletedAt: &now, }) if task.ArticleID > 0 { - _ = articleRepo.UpdateArticleGenerateStatus(ctx, task.ArticleID, task.TenantID, "failed") + _ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, task.ArticleID, task.TenantID, "failed") } if task.QuotaReservationID > 0 { - balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, task.TenantID, "article_generation") + balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, task.TenantID, "article_generation") if balanceErr == nil && task.OperatorID > 0 { reason := "generation_refund" referenceType := "generation_task" taskReferenceID := task.ID - _, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{ + _, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{ TenantID: task.TenantID, OperatorID: task.OperatorID, QuotaType: "article_generation", @@ -66,7 +87,16 @@ func HandleClaimedGenerationTaskFailure( }) } - _ = quotaRepo.RefundReservation(ctx, task.QuotaReservationID, task.TenantID) + _ = quotaRepo.RefundReservation(cleanupCtx, task.QuotaReservationID, task.TenantID) + } + + if task.ArticleID > 0 { + switch { + case templateService != nil && templateService.cache != nil: + invalidateArticleCaches(cleanupCtx, templateService.cache, task.TenantID, &task.ArticleID) + case promptRuleService != nil && promptRuleService.cache != nil: + invalidateArticleCaches(cleanupCtx, promptRuleService.cache, task.TenantID, &task.ArticleID) + } } title := resolveArticleTitle(task.InputParams, "") diff --git a/server/internal/tenant/app/media_service.go b/server/internal/tenant/app/media_service.go index 42beea7..4ba1316 100644 --- a/server/internal/tenant/app/media_service.go +++ b/server/internal/tenant/app/media_service.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "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/response" ) @@ -22,12 +23,18 @@ const minBrowserExtensionVersion = "0.1.0" type MediaService struct { pool *pgxpool.Pool logger *zap.Logger + cache sharedcache.Cache } func NewMediaService(pool *pgxpool.Pool, logger *zap.Logger) *MediaService { return &MediaService{pool: pool, logger: logger} } +func (s *MediaService) WithCache(c sharedcache.Cache) *MediaService { + s.cache = c + return s +} + type MediaPlatformResponse struct { ID int64 `json:"id"` PlatformID string `json:"platform_id"` @@ -543,6 +550,7 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64, if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50042, "publish_batch_commit_failed", "failed to commit publish batch") } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) return &CreatePublishBatchResponse{ PublishBatchID: publishBatchID, @@ -829,6 +837,7 @@ func (s *MediaService) HandlePublishCallback(ctx context.Context, req PluginPubl if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50056, "publish_callback_commit_failed", "failed to commit publish callback") } + invalidateArticleCaches(ctx, s.cache, session.TenantID, &articleID) return &PluginPublishCallbackResponse{ PublishRecordID: req.PublishRecordID, diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go index 7cbccb0..c417876 100644 --- a/server/internal/tenant/app/prompt_generate_service.go +++ b/server/internal/tenant/app/prompt_generate_service.go @@ -11,6 +11,7 @@ import ( "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" @@ -29,6 +30,7 @@ type PromptRuleGenerationService struct { streamEnabled bool articleTimeout time.Duration maxOutputTokens int64 + cache sharedcache.Cache } type promptRuleGenerationJob struct { @@ -131,6 +133,11 @@ func NewPromptRuleGenerationService( return svc } +func (s *PromptRuleGenerationService) WithCache(c sharedcache.Cache) *PromptRuleGenerationService { + s.cache = c + return s +} + func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) { actor := auth.MustActor(ctx) extra := clonePromptRuleExtraParams(req.ExtraParams) @@ -369,6 +376,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task") } + invalidateArticleCaches(ctx, s.cache, input.TenantID, &articleID) job := promptRuleGenerationJob{ OperatorID: operatorID, @@ -488,9 +496,12 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job s.failGeneration(ctx, job, title, "persist_begin_tx", err) return } - defer func() { - _ = tx.Rollback(ctx) - }() + defer rollbackGenerationTx(tx) + + failWithRollback := func(stage string, err error) { + rollbackGenerationTx(tx) + s.failGeneration(ctx, job, title, stage, err) + } articleRepo := repository.NewArticleRepository(tx) quotaRepo := repository.NewQuotaRepository(tx) @@ -506,16 +517,16 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job SourceLabel: result.Model, }) if err != nil { - s.failGeneration(ctx, job, title, "persist_create_version", err) + failWithRollback("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) + failWithRollback("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) + failWithRollback("persist_article_status", err) return } @@ -527,18 +538,21 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job StartedAt: &now, CompletedAt: &completed, }); err != nil { - s.failGeneration(ctx, job, title, "persist_task_status", err) + failWithRollback("persist_task_status", err) return } if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil { - s.failGeneration(ctx, job, title, "confirm_reservation", err) + failWithRollback("confirm_reservation", err) return } if err := tx.Commit(ctx); err != nil { - s.failGeneration(ctx, job, title, "persist_commit", err) + failWithRollback("persist_commit", err) return } + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) if s.streamEnabled { s.streams.Complete(job.ArticleID, job.TaskID, title, content) @@ -546,6 +560,9 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job } func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job promptRuleGenerationJob, title, stage string, genErr error) { + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + errMsg := formatGenerationFailure(stage, genErr) now := time.Now() failureTitle := strings.TrimSpace(extractString(job.InputParams, "task_name")) @@ -557,16 +574,16 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr articleRepo := repository.NewArticleRepository(s.pool) quotaRepo := repository.NewQuotaRepository(s.pool) - _ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ + _ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{ ID: job.TaskID, TenantID: job.TenantID, Status: "failed", ErrorMessage: &errMsg, CompletedAt: &now, }) - _ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "failed") + _ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed") if failureTitle != "" { - _, _ = s.pool.Exec(ctx, ` + _, _ = 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() @@ -574,12 +591,12 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr `, failureTitle, job.ArticleID, job.TenantID) } - balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, job.TenantID, "article_generation") + balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation") if balanceErr == nil { reason := "generation_refund" referenceType := "generation_task" taskReferenceID := job.TaskID - _, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{ + _, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{ TenantID: job.TenantID, OperatorID: job.OperatorID, QuotaType: "article_generation", @@ -591,7 +608,8 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr }) } - _ = quotaRepo.RefundReservation(ctx, job.ReservationID, job.TenantID) + _ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID) + invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) if s.streamEnabled { s.streams.Fail(job.ArticleID, job.TaskID, failureTitle, errMsg) diff --git a/server/internal/tenant/app/prompt_rule_service.go b/server/internal/tenant/app/prompt_rule_service.go index 6e3e3f9..42056e9 100644 --- a/server/internal/tenant/app/prompt_rule_service.go +++ b/server/internal/tenant/app/prompt_rule_service.go @@ -3,26 +3,36 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "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/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" ) type PromptRuleService struct { - pool *pgxpool.Pool - auditLogs *auditlog.AsyncWriter + pool *pgxpool.Pool + auditLogs *auditlog.AsyncWriter + cache sharedcache.Cache + cacheGroup singleflight.Group } func NewPromptRuleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *PromptRuleService { return &PromptRuleService{pool: pool, auditLogs: auditLogs} } +func (s *PromptRuleService) WithCache(c sharedcache.Cache) *PromptRuleService { + s.cache = c + return s +} + // --- Group types --- type PromptRuleGroupRequest struct { @@ -97,30 +107,9 @@ type PromptRuleSimple struct { 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 + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleGroupsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleGroupResponse, error) { + return s.loadPromptRuleGroups(loadCtx, actor.TenantID) + }) } func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroupRequest) (*PromptRuleGroupResponse, error) { @@ -155,6 +144,7 @@ func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroup Result: &result, }) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) return &PromptRuleGroupResponse{ID: id, Name: req.Name, SortOrder: sortOrder, CreatedAt: fmt.Sprintf("%v", ca)}, nil } @@ -171,6 +161,7 @@ func (s *PromptRuleService) UpdateGroup(ctx context.Context, id int64, req Promp if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") } + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) return nil } @@ -206,6 +197,7 @@ func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { AfterJSON: afterJSON, Result: &result, }) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) return nil } @@ -213,148 +205,23 @@ func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { 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 + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*PromptRuleListResponse, error) { + return s.loadPromptRules(loadCtx, actor.TenantID, params) }) - 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) - r.KnowledgeGroupIDs = []int64{} - r.KnowledgeGroups = []PromptRuleKnowledgeGroup{} - items = append(items, r) - } - if items == nil { - items = []PromptRuleResponse{} - } - - groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, collectPromptRuleIDs(items)) - if err != nil { - return nil, err - } - for index := range items { - items[index].KnowledgeGroups = groupMap[items[index].ID] - items[index].KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(items[index].KnowledgeGroups) - } - - 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) + record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, promptRuleDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*PromptRuleResponse, bool, error) { + return s.loadPromptRuleDetail(loadCtx, actor.TenantID, id) + }) if err != nil { + return nil, err + } + if !found || record == 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 - - groupMap, groupErr := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, []int64{id}) - if groupErr != nil { - return nil, groupErr - } - r.KnowledgeGroups = groupMap[id] - r.KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(r.KnowledgeGroups) - return &r, nil + return record, nil } func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) { @@ -403,6 +270,7 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) ( Result: &result, }) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) return &PromptRuleResponse{ ID: id, GroupID: req.GroupID, @@ -447,6 +315,7 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit prompt rule") } + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) return nil } @@ -475,6 +344,7 @@ func (s *PromptRuleService) Delete(ctx context.Context, id int64) error { AfterJSON: afterJSON, Result: &result, }) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) return nil } @@ -487,31 +357,197 @@ func (s *PromptRuleService) ToggleStatus(ctx context.Context, id int64, req Prom if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) return nil } func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple, error) { actor := auth.MustActor(ctx) + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleSimpleCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleSimple, error) { + return s.loadPromptRuleSimple(loadCtx, actor.TenantID) + }) +} + +func (s *PromptRuleService) loadPromptRuleGroups(ctx context.Context, tenantID int64) ([]PromptRuleGroupResponse, error) { + 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 + `, tenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list groups") + } + defer rows.Close() + + items := make([]PromptRuleGroupResponse, 0) + for rows.Next() { + var item PromptRuleGroupResponse + var createdAt interface{} + if err := rows.Scan(&item.ID, &item.Name, &item.SortOrder, &createdAt); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + items = append(items, item) + } + return items, nil +} + +func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, params PromptRuleListParams) (*PromptRuleListResponse, error) { + 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() } + + if params.Ungrouped { + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM prompt_rules + WHERE tenant_id = $1 AND deleted_at IS NULL AND group_id IS NULL + `, tenantID).Scan(&total); 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 + `, tenantID, params.PageSize, offset) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") + } + rows = r + } else { + if 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 || '%') + `, tenantID, params.GroupID, params.Status, params.Keyword).Scan(&total); 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 + `, 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 + } + + pgxRows := rows.(interface { + Close() + Next() bool + Scan(dest ...interface{}) error + Err() error + }) + defer pgxRows.Close() + + items := make([]PromptRuleResponse, 0) + for pgxRows.Next() { + var item PromptRuleResponse + var createdAt interface{} + var updatedAt interface{} + if err := pgxRows.Scan(&item.ID, &item.GroupID, &item.Name, &item.PromptContent, + &item.Scene, &item.DefaultTone, &item.DefaultWordCount, &item.Status, + &createdAt, &updatedAt, &item.ArticleCount); err != nil { + return nil, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + item.UpdatedAt = fmt.Sprintf("%v", updatedAt) + item.KnowledgeGroupIDs = []int64{} + item.KnowledgeGroups = []PromptRuleKnowledgeGroup{} + items = append(items, item) + } + groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, collectPromptRuleIDs(items)) + if err != nil { + return nil, err + } + for idx := range items { + items[idx].KnowledgeGroups = groupMap[items[idx].ID] + items[idx].KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(items[idx].KnowledgeGroups) + } + + return &PromptRuleListResponse{Items: items, Total: total}, nil +} + +func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID, ruleID int64) (*PromptRuleResponse, bool, error) { + var item PromptRuleResponse + var createdAt interface{} + var updatedAt 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 + `, ruleID, tenantID).Scan(&item.ID, &item.GroupID, &item.Name, &item.PromptContent, + &item.Scene, &item.DefaultTone, &item.DefaultWordCount, &item.Status, + &createdAt, &updatedAt, &articleCount) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch prompt rule") + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + item.UpdatedAt = fmt.Sprintf("%v", updatedAt) + item.ArticleCount = articleCount + + groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, []int64{ruleID}) + if err != nil { + return nil, false, err + } + item.KnowledgeGroups = groupMap[ruleID] + item.KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(item.KnowledgeGroups) + return &item, true, nil +} + +func (s *PromptRuleService) loadPromptRuleSimple(ctx context.Context, tenantID int64) ([]PromptRuleSimple, error) { 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) + `, tenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } defer rows.Close() - var items []PromptRuleSimple + items := make([]PromptRuleSimple, 0) for rows.Next() { - var r PromptRuleSimple - if err := rows.Scan(&r.ID, &r.Name); err != nil { + var item PromptRuleSimple + if err := rows.Scan(&item.ID, &item.Name); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } - items = append(items, r) - } - if items == nil { - items = []PromptRuleSimple{} + items = append(items, item) } return items, nil } diff --git a/server/internal/tenant/app/schedule_task_service.go b/server/internal/tenant/app/schedule_task_service.go index 36e832e..60fabf2 100644 --- a/server/internal/tenant/app/schedule_task_service.go +++ b/server/internal/tenant/app/schedule_task_service.go @@ -3,28 +3,39 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "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/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule" ) type ScheduleTaskService struct { - pool *pgxpool.Pool - auditLogs *auditlog.AsyncWriter + pool *pgxpool.Pool + auditLogs *auditlog.AsyncWriter + cache sharedcache.Cache + cacheGroup singleflight.Group } func NewScheduleTaskService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ScheduleTaskService { return &ScheduleTaskService{pool: pool, auditLogs: auditLogs} } +func (s *ScheduleTaskService) WithCache(c sharedcache.Cache) *ScheduleTaskService { + s.cache = c + return s +} + type ScheduleTaskRequest struct { PromptRuleID int64 `json:"prompt_rule_id" binding:"required"` BrandID *int64 `json:"brand_id"` @@ -77,122 +88,23 @@ type ScheduleTaskStatusRequest struct { 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) - AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') - AND ($5::timestamptz IS NULL OR created_at >= $5) - AND ($6::timestamptz IS NULL OR created_at <= $6) - `, actor.TenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo).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.enable_web_search, st.generate_count, 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) - AND ($4::text IS NULL OR st.name ILIKE '%' || $4 || '%') - AND ($5::timestamptz IS NULL OR st.created_at >= $5) - AND ($6::timestamptz IS NULL OR st.created_at <= $6) - ORDER BY st.created_at DESC - LIMIT $7 OFFSET $8 - `, actor.TenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, 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, &r.EnableWebSearch, &r.GenerateCount, &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 + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, scheduleTaskListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ScheduleTaskListResponse, error) { + return s.loadScheduleTasks(loadCtx, actor.TenantID, params) + }) } 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.enable_web_search, st.generate_count, 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, &r.EnableWebSearch, &r.GenerateCount, &startAt, &endAt, - &nextRunAt, &r.Status, &ca, &ua, - &r.PromptRuleName, &r.BrandName) + record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, scheduleTaskDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ScheduleTaskResponse, bool, error) { + return s.loadScheduleTaskDetail(loadCtx, actor.TenantID, id) + }) if err != nil { + return nil, err + } + if !found || record == 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 + return record, nil } func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskRequest) (*ScheduleTaskResponse, error) { @@ -274,6 +186,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques Result: &result, }) + invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return &ScheduleTaskResponse{ ID: id, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID, Name: req.Name, CronExpr: req.CronExpr, TargetPlatform: req.TargetPlatform, @@ -335,6 +248,7 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit schedule task") } + invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } @@ -363,6 +277,7 @@ func (s *ScheduleTaskService) Delete(ctx context.Context, id int64) error { AfterJSON: afterJSON, Result: &result, }) + invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } @@ -405,9 +320,123 @@ func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req Sc if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit schedule task") } + invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } +func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID int64, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) { + 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 + if 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) + AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') + AND ($5::timestamptz IS NULL OR created_at >= $5) + AND ($6::timestamptz IS NULL OR created_at <= $6) + `, tenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo).Scan(&total); 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.enable_web_search, st.generate_count, 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) + AND ($4::text IS NULL OR st.name ILIKE '%' || $4 || '%') + AND ($5::timestamptz IS NULL OR st.created_at >= $5) + AND ($6::timestamptz IS NULL OR st.created_at <= $6) + ORDER BY st.created_at DESC + LIMIT $7 OFFSET $8 + `, tenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to list schedule tasks") + } + defer rows.Close() + + items := make([]ScheduleTaskResponse, 0) + for rows.Next() { + item, err := scanScheduleTaskResponse(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return &ScheduleTaskListResponse{Items: items, Total: total}, nil +} + +func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenantID, taskID int64) (*ScheduleTaskResponse, bool, error) { + row := s.pool.QueryRow(ctx, ` + SELECT st.id, st.prompt_rule_id, st.brand_id, st.name, + st.cron_expr, st.target_platform, st.enable_web_search, st.generate_count, 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 + `, taskID, tenantID) + item, err := scanScheduleTaskResponse(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + return &item, true, nil +} + +func scanScheduleTaskResponse(scanner interface { + Scan(dest ...interface{}) error +}) (ScheduleTaskResponse, error) { + var item ScheduleTaskResponse + var createdAt interface{} + var updatedAt interface{} + var startAt interface{} + var endAt interface{} + var nextRunAt interface{} + if err := scanner.Scan(&item.ID, &item.PromptRuleID, &item.BrandID, &item.Name, + &item.CronExpr, &item.TargetPlatform, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt, + &nextRunAt, &item.Status, &createdAt, &updatedAt, + &item.PromptRuleName, &item.BrandName); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ScheduleTaskResponse{}, pgx.ErrNoRows + } + return ScheduleTaskResponse{}, response.ErrInternal(50010, "scan_failed", err.Error()) + } + item.CreatedAt = fmt.Sprintf("%v", createdAt) + item.UpdatedAt = fmt.Sprintf("%v", updatedAt) + if startAt != nil { + value := fmt.Sprintf("%v", startAt) + item.StartAt = &value + } + if endAt != nil { + value := fmt.Sprintf("%v", endAt) + item.EndAt = &value + } + if nextRunAt != nil { + value := fmt.Sprintf("%v", nextRunAt) + item.NextRunAt = &value + } + return item, nil +} + func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenantID, promptRuleID int64) error { var exists bool _ = s.pool.QueryRow(ctx, ` diff --git a/server/internal/tenant/app/template_assist.go b/server/internal/tenant/app/template_assist.go index 59249c1..c122b4e 100644 --- a/server/internal/tenant/app/template_assist.go +++ b/server/internal/tenant/app/template_assist.go @@ -127,7 +127,7 @@ func (s *TemplateService) CreateAnalyzeTask(ctx context.Context, templateID int6 templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { - return nil, response.ErrNotFound(40410, "template_not_found", "template not found") + return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) @@ -208,7 +208,7 @@ func (s *TemplateService) CreateTitleTask(ctx context.Context, templateID int64, templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { - return nil, response.ErrNotFound(40410, "template_not_found", "template not found") + return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) @@ -289,7 +289,7 @@ func (s *TemplateService) CreateOutlineTask(ctx context.Context, templateID int6 templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { - return nil, response.ErrNotFound(40410, "template_not_found", "template not found") + return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) diff --git a/server/internal/tenant/app/template_service.go b/server/internal/tenant/app/template_service.go index 9e8db80..ae9d91a 100644 --- a/server/internal/tenant/app/template_service.go +++ b/server/internal/tenant/app/template_service.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -11,6 +12,7 @@ import ( "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" @@ -29,6 +31,7 @@ type TemplateService struct { streamEnabled bool articleTimeout time.Duration maxOutputTokens int64 + cache sharedcache.Cache } type generationJob struct { @@ -75,6 +78,11 @@ func NewTemplateService( return svc } +func (s *TemplateService) WithCache(c sharedcache.Cache) *TemplateService { + s.cache = c + return s +} + type TemplateListItem struct { ID int64 `json:"id"` Scope string `json:"scope"` @@ -125,7 +133,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail 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") + return nil, mapTemplateLookupError(err) } detail := &TemplateDetail{ @@ -158,6 +166,13 @@ func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int6 return *record.TenantID == actorTenantID } +func mapTemplateLookupError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return response.ErrNotFound(40410, "template_not_found", "template not found") + } + return err +} + func effectivePromptVisibility(record *repository.TemplateRecord, actorTenantID int64) string { if canViewPromptTemplate(record, actorTenantID) { return "visible" @@ -192,7 +207,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S 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") + return nil, mapTemplateLookupError(err) } stateJSON, err := json.Marshal(normalizeWizardState(req.WizardState, req.CurrentStep)) @@ -210,6 +225,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S if err != nil { return nil, response.ErrInternal(50012, "draft_create_failed", "failed to create article draft") } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) return &SaveDraftResponse{ArticleID: articleID}, nil } @@ -232,6 +248,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable") } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) return &SaveDraftResponse{ArticleID: articleID}, nil } @@ -240,7 +257,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { - return nil, response.ErrNotFound(40410, "template_not_found", "template not found") + return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) @@ -358,6 +375,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit: %w", err) } + invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID) job := generationJob{ OperatorID: actor.UserID, @@ -481,9 +499,12 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ s.failGeneration(ctx, job, title, "persist_begin_tx", err) return } - defer func() { - _ = tx.Rollback(ctx) - }() + defer rollbackGenerationTx(tx) + + failWithRollback := func(stage string, err error) { + rollbackGenerationTx(tx) + s.failGeneration(ctx, job, title, stage, err) + } articleRepo := repository.NewArticleRepository(tx) quotaRepo := repository.NewQuotaRepository(tx) @@ -499,16 +520,16 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ SourceLabel: result.Model, }) if err != nil { - s.failGeneration(ctx, job, title, "persist_create_version", err) + failWithRollback("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) + failWithRollback("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) + failWithRollback("persist_article_status", err) return } @@ -520,18 +541,21 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ StartedAt: &now, CompletedAt: &completed, }); err != nil { - s.failGeneration(ctx, job, title, "persist_task_status", err) + failWithRollback("persist_task_status", err) return } if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil { - s.failGeneration(ctx, job, title, "confirm_reservation", err) + failWithRollback("confirm_reservation", err) return } if err := tx.Commit(ctx); err != nil { - s.failGeneration(ctx, job, title, "persist_commit", err) + failWithRollback("persist_commit", err) return } + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) if s.streamEnabled { s.streams.Complete(job.ArticleID, job.TaskID, title, content) @@ -539,6 +563,9 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ } func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, title, stage string, genErr error) { + cleanupCtx, cancel := newGenerationCleanupContext() + defer cancel() + errMsg := formatGenerationFailure(stage, genErr) now := time.Now() @@ -546,21 +573,21 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, articleRepo := repository.NewArticleRepository(s.pool) quotaRepo := repository.NewQuotaRepository(s.pool) - _ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{ + _ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{ ID: job.TaskID, TenantID: job.TenantID, Status: "failed", ErrorMessage: &errMsg, CompletedAt: &now, }) - _ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "failed") + _ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed") - balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, job.TenantID, "article_generation") + balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation") if balanceErr == nil { reason := "generation_refund" referenceType := "generation_task" taskReferenceID := job.TaskID - _, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{ + _, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{ TenantID: job.TenantID, OperatorID: job.OperatorID, QuotaType: "article_generation", @@ -572,7 +599,8 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, }) } - _ = quotaRepo.RefundReservation(ctx, job.ReservationID, job.TenantID) + _ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID) + invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) if s.streamEnabled { s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg) diff --git a/server/internal/tenant/app/workspace_service.go b/server/internal/tenant/app/workspace_service.go index 6a65a9b..e203492 100644 --- a/server/internal/tenant/app/workspace_service.go +++ b/server/internal/tenant/app/workspace_service.go @@ -6,8 +6,10 @@ import ( "errors" "github.com/jackc/pgx/v5" + "golang.org/x/sync/singleflight" "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/response" "github.com/geo-platform/tenant-api/internal/tenant/domain" "github.com/geo-platform/tenant-api/internal/tenant/repository" @@ -17,88 +19,99 @@ type WorkspaceService struct { repo repository.WorkspaceRepository templates repository.TemplateRepository supportedPlatformCount int + cache sharedcache.Cache + cacheGroup singleflight.Group } func NewWorkspaceService(repo repository.WorkspaceRepository, templates repository.TemplateRepository) *WorkspaceService { return &WorkspaceService{repo: repo, templates: templates, supportedPlatformCount: 5} } +func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService { + s.cache = c + return s +} + func (s *WorkspaceService) Overview(ctx context.Context) (*domain.WorkspaceOverview, error) { actor := auth.MustActor(ctx) - articleCount, err := s.repo.CountArticlesByTenant(ctx, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") - } - publishedCount, err := s.repo.CountPublishedArticlesByTenant(ctx, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to count published articles") - } - brandCount, err := s.repo.CountBrandsByTenant(ctx, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to count brands") - } + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceOverviewCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.WorkspaceOverview, error) { + articleCount, err := s.repo.CountArticlesByTenant(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") + } + publishedCount, err := s.repo.CountPublishedArticlesByTenant(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to count published articles") + } + brandCount, err := s.repo.CountBrandsByTenant(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to count brands") + } - return &domain.WorkspaceOverview{ - ArticleCount: articleCount, - PublishedCount: publishedCount, - BrandCount: brandCount, - SupportedPlatformCount: s.supportedPlatformCount, - }, nil + return &domain.WorkspaceOverview{ + ArticleCount: articleCount, + PublishedCount: publishedCount, + BrandCount: brandCount, + SupportedPlatformCount: s.supportedPlatformCount, + }, nil + }) } func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentArticle, error) { actor := auth.MustActor(ctx) + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceRecentArticlesCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]domain.RecentArticle, error) { + rows, err := s.repo.GetRecentArticles(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to fetch recent articles") + } - rows, err := s.repo.GetRecentArticles(ctx, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to fetch recent articles") - } - - articles := make([]domain.RecentArticle, 0, len(rows)) - for _, row := range rows { - articles = append(articles, domain.RecentArticle{ - ID: row.ID, - GenerateStatus: row.GenerateStatus, - PublishStatus: row.PublishStatus, - SourceType: row.SourceType, - GenerationMode: row.GenerationMode, - CreatedAt: row.CreatedAt, - Title: row.Title, - WordCount: row.WordCount, - SourceLabel: row.SourceLabel, - TemplateName: row.TemplateName, - }) - } - return articles, nil + articles := make([]domain.RecentArticle, 0, len(rows)) + for _, row := range rows { + articles = append(articles, domain.RecentArticle{ + ID: row.ID, + GenerateStatus: row.GenerateStatus, + PublishStatus: row.PublishStatus, + SourceType: row.SourceType, + GenerationMode: row.GenerationMode, + CreatedAt: row.CreatedAt, + Title: row.Title, + WordCount: row.WordCount, + SourceLabel: row.SourceLabel, + TemplateName: row.TemplateName, + }) + } + return articles, nil + }) } func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) { actor := auth.MustActor(ctx) - - balance, err := s.repo.GetQuotaSummary(ctx, actor.TenantID) - if err != nil { - return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance") - } - - plan, err := s.repo.GetActivePlanForTenant(ctx, actor.TenantID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return &domain.QuotaSummary{Balance: balance}, nil + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) { + balance, err := s.repo.GetQuotaSummary(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance") } - return nil, response.ErrInternal(50010, "query_failed", "failed to get active plan") - } - var policy map[string]int - _ = json.Unmarshal(plan.QuotaPolicyJSON, &policy) - total := policy["article_generation"] + plan, err := s.repo.GetActivePlanForTenant(loadCtx, actor.TenantID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return &domain.QuotaSummary{Balance: balance}, nil + } + return nil, response.ErrInternal(50010, "query_failed", "failed to get active plan") + } - return &domain.QuotaSummary{ - PlanCode: plan.PlanCode, - PlanName: plan.PlanName, - TotalQuota: total, - UsedQuota: total - balance, - Balance: balance, - }, nil + var policy map[string]int + _ = json.Unmarshal(plan.QuotaPolicyJSON, &policy) + total := policy["article_generation"] + + return &domain.QuotaSummary{ + PlanCode: plan.PlanCode, + PlanName: plan.PlanName, + TotalQuota: total, + UsedQuota: total - balance, + Balance: balance, + }, nil + }) } type TemplateCard struct { @@ -113,24 +126,25 @@ type TemplateCard struct { func (s *WorkspaceService) TemplateCards(ctx context.Context) ([]TemplateCard, 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 fetch templates") - } - - cards := make([]TemplateCard, 0, len(rows)) - for _, row := range rows { - card := TemplateCard{ - ID: row.ID, - Scope: row.Scope, - TemplateKey: row.TemplateKey, - TemplateName: row.TemplateName, - OriginType: row.OriginType, - PromptVisibility: effectivePromptVisibility(&row, actor.TenantID), + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceTemplateCardsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]TemplateCard, error) { + rows, err := s.templates.ListTemplates(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to fetch templates") } - _ = json.Unmarshal(row.CardConfigJSON, &card.CardConfigJSON) - cards = append(cards, card) - } - return cards, nil + + cards := make([]TemplateCard, 0, len(rows)) + for _, row := range rows { + card := TemplateCard{ + ID: row.ID, + Scope: row.Scope, + TemplateKey: row.TemplateKey, + TemplateName: row.TemplateName, + OriginType: row.OriginType, + PromptVisibility: effectivePromptVisibility(&row, actor.TenantID), + } + _ = json.Unmarshal(row.CardConfigJSON, &card.CardConfigJSON) + cards = append(cards, card) + } + return cards, nil + }) } diff --git a/server/internal/tenant/repository/article_repo.go b/server/internal/tenant/repository/article_repo.go index c4167a2..285210a 100644 --- a/server/internal/tenant/repository/article_repo.go +++ b/server/internal/tenant/repository/article_repo.go @@ -4,6 +4,7 @@ import ( "context" "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" + "github.com/jackc/pgx/v5" ) type CreateArticleInput struct { @@ -86,17 +87,37 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea } func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error { - return r.q.UpdateArticleCurrentVersion(ctx, generated.UpdateArticleCurrentVersionParams{ - VersionID: pgInt8(&versionID), - ID: articleID, - TenantID: tenantID, - }) + tag, err := r.db.Exec(ctx, ` + UPDATE articles + SET current_version_id = $1, + updated_at = NOW() + WHERE id = $2 + AND tenant_id = $3 + AND deleted_at IS NULL + `, pgInt8(&versionID), articleID, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil } func (r *articleRepository) UpdateArticleGenerateStatus(ctx context.Context, articleID, tenantID int64, status string) error { - return r.q.UpdateArticleGenerateStatus(ctx, generated.UpdateArticleGenerateStatusParams{ - Status: status, - ID: articleID, - TenantID: tenantID, - }) + tag, err := r.db.Exec(ctx, ` + UPDATE articles + SET generate_status = $1, + updated_at = NOW() + WHERE id = $2 + AND tenant_id = $3 + AND deleted_at IS NULL + `, status, articleID, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil } diff --git a/server/internal/tenant/repository/template_cache_repo.go b/server/internal/tenant/repository/template_cache_repo.go index 6e3620d..7625965 100644 --- a/server/internal/tenant/repository/template_cache_repo.go +++ b/server/internal/tenant/repository/template_cache_repo.go @@ -2,18 +2,24 @@ package repository import ( "context" - "encoding/json" + "errors" "fmt" "time" "github.com/geo-platform/tenant-api/internal/shared/cache" + "github.com/jackc/pgx/v5" + "golang.org/x/sync/singleflight" ) -const templateCacheTTL = 5 * time.Minute +const ( + templateCacheTTL = 5 * time.Minute + templateCacheEmptyTTL = 1 * time.Minute +) type cachedTemplateRepository struct { inner TemplateRepository cache cache.Cache + group singleflight.Group } func NewCachedTemplateRepository(inner TemplateRepository, c cache.Cache) TemplateRepository { @@ -23,24 +29,16 @@ func NewCachedTemplateRepository(inner TemplateRepository, c cache.Cache) Templa func (r *cachedTemplateRepository) ListTemplates(ctx context.Context, tenantID int64) ([]TemplateRecord, error) { key := fmt.Sprintf("tmpl_list:%d", tenantID) - if data, err := r.cache.Get(ctx, key); err == nil { - var records []TemplateRecord - if err := json.Unmarshal(data, &records); err == nil { - for i := range records { - applyPlatformTemplatePromptOverrides(&records[i]) - } - return records, nil + records, _, err := cache.LoadJSONWithEmpty(ctx, r.cache, &r.group, key, templateCacheTTL, templateCacheEmptyTTL, func(loadCtx context.Context) ([]TemplateRecord, bool, error) { + records, err := r.inner.ListTemplates(loadCtx, tenantID) + if err != nil { + return nil, false, err } - } - - records, err := r.inner.ListTemplates(ctx, tenantID) + return records, true, nil + }) if err != nil { return nil, err } - - if data, err := json.Marshal(records); err == nil { - _ = r.cache.Set(ctx, key, data, templateCacheTTL) - } for i := range records { applyPlatformTemplatePromptOverrides(&records[i]) } @@ -50,21 +48,21 @@ func (r *cachedTemplateRepository) ListTemplates(ctx context.Context, tenantID i func (r *cachedTemplateRepository) GetTemplateByID(ctx context.Context, id, tenantID int64) (*TemplateRecord, error) { key := fmt.Sprintf("tmpl:%d:%d", tenantID, id) - if data, err := r.cache.Get(ctx, key); err == nil { - var record TemplateRecord - if err := json.Unmarshal(data, &record); err == nil { - applyPlatformTemplatePromptOverrides(&record) - return &record, nil + record, found, err := cache.LoadJSONWithEmpty(ctx, r.cache, &r.group, key, templateCacheTTL, templateCacheEmptyTTL, func(loadCtx context.Context) (*TemplateRecord, bool, error) { + record, err := r.inner.GetTemplateByID(loadCtx, id, tenantID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, err } - } - - record, err := r.inner.GetTemplateByID(ctx, id, tenantID) + return record, true, nil + }) if err != nil { return nil, err } - - if data, err := json.Marshal(record); err == nil { - _ = r.cache.Set(ctx, key, data, templateCacheTTL) + if !found || record == nil { + return nil, pgx.ErrNoRows } applyPlatformTemplatePromptOverrides(record) return record, nil diff --git a/server/internal/tenant/transport/article_handler.go b/server/internal/tenant/transport/article_handler.go index 59172c2..0fbbaf6 100644 --- a/server/internal/tenant/transport/article_handler.go +++ b/server/internal/tenant/transport/article_handler.go @@ -42,7 +42,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { ) return &ArticleHandler{ - svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage), + svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).WithCache(a.Cache), selectionOptimize: app.NewArticleSelectionOptimizeService( a.DB, a.LLM, @@ -56,7 +56,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { a.GenerationStreams, a.Config.Generation, a.Config.LLM.MaxOutputTokens, - ), + ).WithCache(a.Cache), streams: a.GenerationStreams, streamEnabled: a.Config.Generation.StreamEnabled, assets: NewAssetHandler(a), diff --git a/server/internal/tenant/transport/brand_handler.go b/server/internal/tenant/transport/brand_handler.go index a4bf186..62d24bc 100644 --- a/server/internal/tenant/transport/brand_handler.go +++ b/server/internal/tenant/transport/brand_handler.go @@ -15,7 +15,7 @@ type BrandHandler struct { } func NewBrandHandler(a *bootstrap.App) *BrandHandler { - return &BrandHandler{svc: app.NewBrandService(a.DB, a.MonitoringDB, a.AuditLogs)} + return &BrandHandler{svc: app.NewBrandService(a.DB, a.MonitoringDB, a.AuditLogs).WithCache(a.Cache)} } func (h *BrandHandler) List(c *gin.Context) { diff --git a/server/internal/tenant/transport/media_handler.go b/server/internal/tenant/transport/media_handler.go index fce76ca..fb222c2 100644 --- a/server/internal/tenant/transport/media_handler.go +++ b/server/internal/tenant/transport/media_handler.go @@ -18,7 +18,7 @@ type MediaHandler struct { func NewMediaHandler(a *bootstrap.App) *MediaHandler { return &MediaHandler{ - svc: app.NewMediaService(a.DB, a.Logger), + svc: app.NewMediaService(a.DB, a.Logger).WithCache(a.Cache), logger: a.Logger, } } diff --git a/server/internal/tenant/transport/prompt_rule_handler.go b/server/internal/tenant/transport/prompt_rule_handler.go index 3ddee2b..a88848d 100644 --- a/server/internal/tenant/transport/prompt_rule_handler.go +++ b/server/internal/tenant/transport/prompt_rule_handler.go @@ -15,7 +15,7 @@ type PromptRuleHandler struct { } func NewPromptRuleHandler(a *bootstrap.App) *PromptRuleHandler { - return &PromptRuleHandler{svc: app.NewPromptRuleService(a.DB, a.AuditLogs)} + return &PromptRuleHandler{svc: app.NewPromptRuleService(a.DB, a.AuditLogs).WithCache(a.Cache)} } // --- Groups --- diff --git a/server/internal/tenant/transport/schedule_task_handler.go b/server/internal/tenant/transport/schedule_task_handler.go index 85d020b..14ad66d 100644 --- a/server/internal/tenant/transport/schedule_task_handler.go +++ b/server/internal/tenant/transport/schedule_task_handler.go @@ -16,7 +16,7 @@ type ScheduleTaskHandler struct { } func NewScheduleTaskHandler(a *bootstrap.App) *ScheduleTaskHandler { - return &ScheduleTaskHandler{svc: app.NewScheduleTaskService(a.DB, a.AuditLogs)} + return &ScheduleTaskHandler{svc: app.NewScheduleTaskService(a.DB, a.AuditLogs).WithCache(a.Cache)} } func (h *ScheduleTaskHandler) List(c *gin.Context) { diff --git a/server/internal/tenant/transport/template_handler.go b/server/internal/tenant/transport/template_handler.go index 9ab3f3b..20d1eaf 100644 --- a/server/internal/tenant/transport/template_handler.go +++ b/server/internal/tenant/transport/template_handler.go @@ -42,7 +42,7 @@ func NewTemplateHandler(a *bootstrap.App) *TemplateHandler { a.GenerationStreams, a.Config.Generation, a.Config.LLM.MaxOutputTokens, - ), + ).WithCache(a.Cache), } } diff --git a/server/internal/tenant/transport/workspace_handler.go b/server/internal/tenant/transport/workspace_handler.go index 063895c..1c8ffff 100644 --- a/server/internal/tenant/transport/workspace_handler.go +++ b/server/internal/tenant/transport/workspace_handler.go @@ -21,7 +21,7 @@ func NewWorkspaceHandler(a *bootstrap.App) *WorkspaceHandler { repository.NewTemplateRepository(a.DB), a.Cache, ), - ), + ).WithCache(a.Cache), } } diff --git a/媒体发布手工自测.md b/媒体发布手工自测.md index 3c3a394..a1f41fd 100644 --- a/媒体发布手工自测.md +++ b/媒体发布手工自测.md @@ -21,7 +21,4 @@ 没有问题的: 头条号:toutiaohao.ts - -简书:jianshu.ts - -知乎 \ No newline at end of file +知乎: zhihu.ts \ No newline at end of file