feat(cache): add read-through cache layer across all app services

Introduce a generic read-through caching infrastructure and wire it into
all major tenant app services to reduce database load on hot read paths.

Key changes:
- Add `DeletePrefix` to Cache interface with memory (prefix scan) and
  Redis (SCAN + DEL) implementations
- New `readthrough.go`: generic `LoadJSON` / `LoadJSONWithEmpty` helpers
  backed by singleflight to prevent cache stampedes; supports jittered TTL
- New `cache_support.go`: centralized cache key builders and invalidation
  helpers for all entities (workspace, brand, prompt rules, schedule tasks,
  articles)
- Wire optional cache into ArticleService, BrandService, WorkspaceService,
  PromptRuleService, ScheduleTaskService, TemplateService, MediaService,
  PromptGenerateService via `WithCache()` builder pattern
- ScheduleDispatchWorker invalidates schedule task cache after dispatching
- ArticleService gains a new `Detail` endpoint with empty-result caching
- Update cmd entrypoints and transport handlers to propagate cache
This commit is contained in:
2026-04-15 16:11:05 +08:00
parent 4d06938565
commit 1538a12042
28 changed files with 1316 additions and 634 deletions
@@ -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)