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
+80 -52
View File
@@ -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, &currentVersionID, &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, &currentVersionID, &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
}