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
+45 -17
View File
@@ -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)