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
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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()
@@ -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
+1
View File
@@ -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
}
+15
View File
@@ -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()
+128
View File
@@ -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))
}
+24
View File
@@ -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
}
}
}
+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
}
+169 -116
View File
@@ -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
}
+212
View File
@@ -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)
}
@@ -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, "")
@@ -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,
@@ -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)
+204 -168
View File
@@ -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
}
@@ -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, `
@@ -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())
+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)
+94 -80
View File
@@ -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
})
}
@@ -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
}
@@ -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
@@ -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),
@@ -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) {
@@ -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,
}
}
@@ -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 ---
@@ -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) {
@@ -42,7 +42,7 @@ func NewTemplateHandler(a *bootstrap.App) *TemplateHandler {
a.GenerationStreams,
a.Config.Generation,
a.Config.LLM.MaxOutputTokens,
),
).WithCache(a.Cache),
}
}
@@ -21,7 +21,7 @@ func NewWorkspaceHandler(a *bootstrap.App) *WorkspaceHandler {
repository.NewTemplateRepository(a.DB),
a.Cache,
),
),
).WithCache(a.Cache),
}
}
+1 -4
View File
@@ -21,7 +21,4 @@
没有问题的:
头条号:toutiaohao.ts
简书:jianshu.ts
知乎
知乎: zhihu.ts