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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
@@ -28,12 +30,19 @@ type ArticleService struct {
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
objectStorage objectstorage.Client
|
||||
cache sharedcache.Cache
|
||||
cacheGroup singleflight.Group
|
||||
}
|
||||
|
||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
||||
return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage}
|
||||
}
|
||||
|
||||
func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
||||
|
||||
var supportedArticleImageExtensions = map[string]string{
|
||||
@@ -84,6 +93,47 @@ type ArticleListResponse struct {
|
||||
|
||||
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) {
|
||||
return s.loadArticles(loadCtx, actor.TenantID, params)
|
||||
})
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) {
|
||||
return s.loadArticleDetail(loadCtx, actor.TenantID, id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || record == nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
sourceType := params.SourceType
|
||||
|
||||
if params.Page < 1 {
|
||||
@@ -94,9 +144,8 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
// Count
|
||||
var total int64
|
||||
countArgs := []interface{}{actor.TenantID}
|
||||
countArgs := []interface{}{tenantID}
|
||||
countQuery := `SELECT COUNT(*) FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
||||
@@ -160,7 +209,6 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
|
||||
}
|
||||
|
||||
// List
|
||||
listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
@@ -175,7 +223,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
listArgs := []interface{}{actor.TenantID}
|
||||
listArgs := []interface{}{tenantID}
|
||||
listIdx := 2
|
||||
|
||||
if params.GenerateStatus != nil {
|
||||
@@ -233,7 +281,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ArticleListItem
|
||||
items := make([]ArticleListItem, 0)
|
||||
for rows.Next() {
|
||||
var item ArticleListItem
|
||||
var dbSourceType string
|
||||
@@ -248,40 +296,13 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
items = append(items, item)
|
||||
}
|
||||
if items == nil {
|
||||
items = []ArticleListItem{}
|
||||
}
|
||||
|
||||
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var d ArticleDetailResponse
|
||||
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articleID int64) (*ArticleDetailResponse, bool, error) {
|
||||
var item ArticleDetailResponse
|
||||
var dbSourceType string
|
||||
var currentVersionID *int64
|
||||
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
@@ -299,29 +320,33 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, ¤tVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
||||
&d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
`, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
d.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
d.HTMLContent = content.HTMLContent
|
||||
d.MarkdownContent = content.MarkdownContent
|
||||
return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch article")
|
||||
}
|
||||
|
||||
item.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
}
|
||||
item.HTMLContent = content.HTMLContent
|
||||
item.MarkdownContent = content.MarkdownContent
|
||||
}
|
||||
if len(wizardStateJSON) > 0 {
|
||||
d.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
|
||||
item.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
||||
}
|
||||
d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
d.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
||||
return &d, nil
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
||||
return &item, true, nil
|
||||
}
|
||||
|
||||
type CreateArticleRequest struct {
|
||||
@@ -374,6 +399,7 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation")
|
||||
}
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
return s.Detail(ctx, articleID)
|
||||
}
|
||||
|
||||
@@ -473,6 +499,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update")
|
||||
}
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
||||
return s.Detail(ctx, id)
|
||||
}
|
||||
|
||||
@@ -598,6 +625,7 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -21,7 +21,4 @@
|
||||
没有问题的:
|
||||
|
||||
头条号:toutiaohao.ts
|
||||
|
||||
简书:jianshu.ts
|
||||
|
||||
知乎
|
||||
知乎: zhihu.ts
|
||||
Reference in New Issue
Block a user