feat: scope articles by brand

This commit is contained in:
2026-05-20 15:37:25 +08:00
parent 5fb9d0b0dd
commit dd082e2ed1
72 changed files with 3213 additions and 432 deletions
@@ -122,6 +122,10 @@ func (s *ArticleImitationService) runtimeConfig() generationRuntimeConfig {
func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if err := s.llm.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
}
@@ -141,6 +145,7 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit
}
inputParams := buildImitationInputParams(sourceURL, req)
inputParams["brand_id"] = brandID
inputJSON, _ := json.Marshal(inputParams)
initialTitle := buildImitationInitialTitle(req.SourceTitle)
wizardStateJSON, _ := json.Marshal(map[string]interface{}{
@@ -192,10 +197,10 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit
var articleID int64
err = tx.QueryRow(ctx, `
INSERT INTO articles (tenant_id, source_type, generate_status, publish_status, wizard_state_json)
VALUES ($1, $2, 'generating', 'unpublished', $3)
INSERT INTO articles (tenant_id, brand_id, source_type, generate_status, publish_status, wizard_state_json)
VALUES ($1, $2, $3, 'generating', 'unpublished', $4)
RETURNING id
`, actor.TenantID, articleImitationSourceType, wizardStateJSON).Scan(&articleID)
`, actor.TenantID, brandID, articleImitationSourceType, wizardStateJSON).Scan(&articleID)
if err != nil {
return nil, response.ErrInternal(50012, "create_failed", "failed to create imitation article")
}
@@ -38,6 +38,10 @@ type articleRegenerationRecord struct {
func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*RegenerateArticleResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if s == nil || s.llm == nil || s.rabbitMQ == nil {
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is not configured")
}
@@ -57,7 +61,7 @@ func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*Rege
}
defer rollbackGenerationTx(tx)
record, err := loadArticleForRegeneration(ctx, tx, actor.TenantID, articleID)
record, err := loadArticleForRegeneration(ctx, tx, actor.TenantID, brandID, articleID)
if err != nil {
return nil, err
}
@@ -184,15 +188,15 @@ func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*Rege
return &RegenerateArticleResponse{ArticleID: articleID, TaskID: taskID}, nil
}
func loadArticleForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*articleRegenerationRecord, error) {
func loadArticleForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, brandID, articleID int64) (*articleRegenerationRecord, error) {
record := &articleRegenerationRecord{}
var templateID, kolPromptID, promptRuleID sql.NullInt64
err := tx.QueryRow(ctx, `
SELECT id, source_type, template_id, kol_prompt_id, prompt_rule_id, generate_status, wizard_state_json
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
FOR UPDATE
`, articleID, tenantID).Scan(
`, articleID, tenantID, brandID).Scan(
&record.ArticleID,
&record.SourceType,
&templateID,
@@ -78,7 +78,11 @@ func (s *ArticleSelectionOptimizeService) ValidateRequest(
}
actor := auth.MustActor(ctx)
return s.ensureArticleEditable(ctx, articleID, actor.TenantID)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
return s.ensureArticleEditable(ctx, articleID, actor.TenantID, brandID)
}
func (s *ArticleSelectionOptimizeService) OptimizeSelection(
@@ -154,15 +158,17 @@ func (s *ArticleSelectionOptimizeService) ensureArticleEditable(
ctx context.Context,
articleID int64,
tenantID int64,
brandID int64,
) error {
var generateStatus string
err := s.pool.QueryRow(
ctx,
`SELECT generate_status
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL`,
articleID,
tenantID,
brandID,
).Scan(&generateStatus)
if err != nil {
if err == pgx.ErrNoRows {
+64 -32
View File
@@ -134,6 +134,7 @@ const articleEffectivePublishStatusExpr = `CASE
type ArticleListParams struct {
Page int
PageSize int
BrandID int64
GenerateStatus *string
PublishStatus *string
SourceType *string
@@ -148,6 +149,7 @@ type ArticleListParams struct {
type ArticleListItem struct {
ID int64 `json:"id"`
BrandID *int64 `json:"brand_id"`
SourceType string `json:"source_type"`
TemplateID *int64 `json:"template_id"`
KolPromptID *int64 `json:"kol_prompt_id"`
@@ -176,14 +178,20 @@ type ArticleListResponse struct {
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
params.BrandID = brandID
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, version, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) {
return s.loadArticles(loadCtx, actor.TenantID, params)
return s.loadArticles(loadCtx, actor.TenantID, brandID, params)
})
}
type ArticleDetailResponse struct {
ID int64 `json:"id"`
BrandID *int64 `json:"brand_id"`
SourceType string `json:"source_type"`
TemplateID *int64 `json:"template_id"`
CurrentVersionID *int64 `json:"current_version_id"`
@@ -209,9 +217,13 @@ type ArticleDetailResponse struct {
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id, version), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) {
return s.loadArticleDetail(loadCtx, actor.TenantID, id)
return s.loadArticleDetail(loadCtx, actor.TenantID, brandID, id)
})
if err != nil {
return nil, err
@@ -222,7 +234,7 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
return record, nil
}
func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) {
func (s *ArticleService) loadArticles(ctx context.Context, tenantID, brandID int64, params ArticleListParams) (*ArticleListResponse, error) {
sourceTypes := normalizeArticleSourceTypes(params.SourceType)
if params.Page < 1 {
@@ -234,7 +246,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
offset := (params.Page - 1) * params.PageSize
var total int64
countArgs := []interface{}{tenantID}
countArgs := []interface{}{tenantID, brandID}
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
@@ -249,8 +261,8 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
countQuery += articlePublishStatusAggregateJoin
}
countQuery += `
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
argIdx := 2
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
argIdx := 3
if params.GenerateStatus != nil {
countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx)
@@ -307,7 +319,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
}
listQuery := `SELECT a.id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, ` + articleEffectivePublishStatusExpr + `, a.created_at,
listQuery := `SELECT a.id, a.brand_id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, ` + articleEffectivePublishStatusExpr + `, 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,
COALESCE(t.template_name, NULLIF(gt.input_params_json ->> 'prompt_name', ''), NULLIF(gt.input_params_json ->> 'package_name', '')), pr.name, a.wizard_state_json, gt.input_params_json
FROM articles a
@@ -322,9 +334,9 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
LIMIT 1
) gt ON true
` + articlePublishStatusAggregateJoin + `
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
listArgs := []interface{}{tenantID}
listIdx := 2
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
listArgs := []interface{}{tenantID, brandID}
listIdx := 3
if params.GenerateStatus != nil {
listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx)
@@ -392,7 +404,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
var dbSourceType string
var wizardStateJSON []byte
var inputParamsJSON []byte
if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
if err := rows.Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
&item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
@@ -409,14 +421,14 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
}
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articleID int64) (*ArticleDetailResponse, bool, error) {
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, brandID, 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, `
SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, `+articleEffectivePublishStatusExpr+`, a.created_at,
SELECT a.id, a.brand_id, a.source_type, a.template_id, a.current_version_id, a.generate_status, `+articleEffectivePublishStatusExpr+`, a.created_at,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), COALESCE(av.word_count, 0), av.source_label, av.version_no,
t.template_name, gt.error_message, a.wizard_state_json, gt.input_params_json
FROM articles a
@@ -430,8 +442,8 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
LIMIT 1
) gt ON true
`+articlePublishStatusAggregateJoin+`
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
`, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, &currentVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
WHERE a.id = $1 AND a.tenant_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
`, articleID, tenantID, brandID).Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, &currentVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -496,6 +508,10 @@ type CreateArticleRequest struct {
func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
title := strings.TrimSpace(req.Title)
@@ -507,10 +523,10 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
var articleID int64
if err := tx.QueryRow(ctx, `
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status)
VALUES ($1, 'free_create', NULL, 'completed', 'unpublished')
INSERT INTO articles (tenant_id, brand_id, source_type, template_id, generate_status, publish_status)
VALUES ($1, $2, 'free_create', NULL, 'completed', 'unpublished')
RETURNING id
`, actor.TenantID).Scan(&articleID); err != nil {
`, actor.TenantID, brandID).Scan(&articleID); err != nil {
return nil, response.ErrInternal(50014, "create_failed", "failed to create article")
}
@@ -561,6 +577,10 @@ type ArticleImageUploadResponse struct {
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
title := strings.TrimSpace(req.Title)
if title == "" {
@@ -585,9 +605,9 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
err = tx.QueryRow(ctx, `
SELECT generate_status, wizard_state_json
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
FOR UPDATE
`, id, actor.TenantID).Scan(&generateStatus, &wizardStateJSON)
`, id, actor.TenantID, brandID).Scan(&generateStatus, &wizardStateJSON)
if err != nil {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
@@ -656,8 +676,8 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
publish_status = 'unpublished',
wizard_state_json = $4,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, versionID, id, actor.TenantID, nextWizardStateJSON)
WHERE id = $2 AND tenant_id = $3 AND brand_id = $5 AND deleted_at IS NULL
`, versionID, id, actor.TenantID, nextWizardStateJSON, brandID)
if err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to update article")
}
@@ -700,6 +720,10 @@ func (s *ArticleService) UploadImage(
content []byte,
) (*ArticleImageUploadResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if err := s.objectStorage.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
@@ -730,7 +754,7 @@ func (s *ArticleService) UploadImage(
return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image pixel count exceeds the maximum allowed")
}
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID)
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID, brandID)
if err != nil {
return nil, err
}
@@ -782,11 +806,15 @@ type VersionItem struct {
func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]VersionItem, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
// Verify ownership
var exists bool
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`,
articleID, actor.TenantID).Scan(&exists)
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL)`,
articleID, actor.TenantID, brandID).Scan(&exists)
if !exists {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
@@ -816,11 +844,15 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
func (s *ArticleService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
// Read before_json for audit
var beforeJSON []byte
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
id, actor.TenantID).Scan(&beforeJSON)
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL`,
id, actor.TenantID, brandID).Scan(&beforeJSON)
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -830,8 +862,8 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
tag, err := tx.Exec(ctx, `
UPDATE articles SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID)
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, id, actor.TenantID, brandID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40411, "article_not_found", "article not found")
}
@@ -1054,13 +1086,13 @@ WHERE tenant_id = $1
return validIDs, nil
}
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64, brandID int64) (string, error) {
var generateStatus string
if err := s.pool.QueryRow(ctx, `
SELECT generate_status
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&generateStatus); err != nil {
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, articleID, tenantID, brandID).Scan(&generateStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", response.ErrNotFound(40411, "article_not_found", "article not found")
}
+8 -1
View File
@@ -98,9 +98,16 @@ type BrandLibrarySummaryResponse struct {
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]BrandResponse, error) {
brands, err := sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]BrandResponse, error) {
return s.loadBrands(loadCtx, actor.TenantID)
})
if err != nil {
return nil, err
}
if brands == nil {
return []BrandResponse{}, nil
}
return brands, nil
}
func (s *BrandService) Summary(ctx context.Context) (*BrandLibrarySummaryResponse, error) {
+19 -15
View File
@@ -52,12 +52,20 @@ func deleteCachePrefix(ctx context.Context, c sharedcache.Cache, prefix string)
_ = c.DeletePrefix(ctx, prefix)
}
func workspaceOverviewCacheKey(tenantID int64) string {
return fmt.Sprintf("workspace:overview:%d", tenantID)
func workspaceOverviewCachePrefix(tenantID int64) string {
return fmt.Sprintf("workspace:overview:%d:", tenantID)
}
func workspaceRecentArticlesCacheKey(tenantID int64) string {
return fmt.Sprintf("workspace:recent_articles:%d", tenantID)
func workspaceOverviewCacheKey(tenantID, brandID int64) string {
return fmt.Sprintf("%s%d", workspaceOverviewCachePrefix(tenantID), brandID)
}
func workspaceRecentArticlesCachePrefix(tenantID int64) string {
return fmt.Sprintf("workspace:recent_articles:%d:", tenantID)
}
func workspaceRecentArticlesCacheKey(tenantID, brandID int64) string {
return fmt.Sprintf("%s%d", workspaceRecentArticlesCachePrefix(tenantID), brandID)
}
func workspaceQuotaSummaryCacheKey(tenantID int64) string {
@@ -73,8 +81,8 @@ func workspaceKolCardsCacheKey(tenantID int64) string {
}
func invalidateWorkspaceCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) {
deleteCacheKey(ctx, c, workspaceOverviewCacheKey(tenantID))
deleteCacheKey(ctx, c, workspaceRecentArticlesCacheKey(tenantID))
deleteCachePrefix(ctx, c, workspaceOverviewCachePrefix(tenantID))
deleteCachePrefix(ctx, c, workspaceRecentArticlesCachePrefix(tenantID))
deleteCacheKey(ctx, c, workspaceQuotaSummaryCacheKey(tenantID))
deleteCacheKey(ctx, c, workspaceTemplateCardsCacheKey(tenantID))
deleteCacheKey(ctx, c, workspaceKolCardsCacheKey(tenantID))
@@ -186,21 +194,17 @@ func invalidatePromptRuleCaches(ctx context.Context, c sharedcache.Cache, tenant
deleteCachePrefix(ctx, c, articleListCachePrefix(tenantID))
}
func scheduleTaskDetailCacheKey(tenantID, taskID int64) string {
return fmt.Sprintf("%s%d", scheduleTaskDetailCachePrefix(tenantID), taskID)
func scheduleTaskDetailCacheKey(tenantID, brandID, taskID int64) string {
return fmt.Sprintf("%s%d:%d", scheduleTaskDetailCachePrefix(tenantID), brandID, taskID)
}
func scheduleTaskListCacheKey(tenantID int64, params interface{}) string {
return scheduleTaskListCachePrefix(tenantID) + digestCacheKey(params)
func scheduleTaskListCacheKey(tenantID, brandID int64, params interface{}) string {
return fmt.Sprintf("%s%d:%s", scheduleTaskListCachePrefix(tenantID), brandID, 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))
}
deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID))
}
func InvalidateScheduleTaskCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, taskID *int64) {
@@ -0,0 +1,15 @@
package app
import (
"context"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
func requireCurrentBrandID(ctx context.Context) (int64, error) {
if brandID, ok := auth.CurrentBrandIDFromCtx(ctx); ok {
return brandID, nil
}
return 0, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")
}
@@ -2,12 +2,14 @@ package app
import (
"context"
"errors"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -43,7 +45,11 @@ func (s *DesktopContentService) Article(
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
detail, found, err := s.articles.loadArticleDetail(ctx, client.TenantID, articleID)
brandID, err := s.articleBrandID(ctx, client.TenantID, articleID)
if err != nil {
return nil, err
}
detail, found, err := s.articles.loadArticleDetail(ctx, client.TenantID, brandID, articleID)
if err != nil {
return nil, err
}
@@ -64,3 +70,23 @@ func (s *DesktopContentService) Article(
CoverAssetURL: detail.CoverAssetURL,
}, nil
}
func (s *DesktopContentService) articleBrandID(ctx context.Context, tenantID, articleID int64) (int64, error) {
if s == nil || s.articles == nil || s.articles.pool == nil {
return 0, response.ErrInternal(50012, "article_lookup_unavailable", "article lookup is unavailable")
}
var brandID int64
if err := s.articles.pool.QueryRow(ctx, `
SELECT brand_id
FROM articles
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, articleID, tenantID).Scan(&brandID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return 0, response.ErrInternal(50010, "query_failed", "failed to load article")
}
return brandID, nil
}
@@ -59,6 +59,10 @@ type InstantTaskListResponse struct {
func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListParams) (*InstantTaskListResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if params.Page < 1 {
params.Page = 1
}
@@ -90,12 +94,13 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN prompt_rules pr ON pr.id = COALESCE(a.prompt_rule_id, NULLIF(gt.input_params_json ->> 'prompt_rule_id', '')::bigint)
WHERE gt.tenant_id = $1
AND COALESCE(a.brand_id, NULLIF(gt.input_params_json ->> 'brand_id', '')::bigint) = $2
AND gt.task_type = 'custom_generation'
AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), 'instant') = 'instant'
AND ($2::bigint IS NULL OR pr.id = $2)
AND ($4::text IS NULL OR COALESCE(NULLIF(gt.input_params_json ->> 'task_name', ''), pr.name, '') ILIKE '%' || $4 || '%')
AND ($5::timestamptz IS NULL OR gt.created_at >= $5)
AND ($6::timestamptz IS NULL OR gt.created_at <= $6)
AND ($3::bigint IS NULL OR pr.id = $3)
AND ($5::text IS NULL OR COALESCE(NULLIF(gt.input_params_json ->> 'task_name', ''), pr.name, '') ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR gt.created_at >= $6)
AND ($7::timestamptz IS NULL OR gt.created_at <= $7)
),
keyed AS (
SELECT *,
@@ -150,11 +155,11 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
)`
var total int64
err := s.pool.QueryRow(ctx, instantTaskBatchCTE+`
err = s.pool.QueryRow(ctx, instantTaskBatchCTE+`
SELECT COUNT(*)
FROM aggregated
WHERE ($3::text IS NULL OR status = $3)
`, actor.TenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo).Scan(&total)
WHERE ($4::text IS NULL OR status = $4)
`, actor.TenantID, brandID, 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 instant tasks")
}
@@ -163,10 +168,10 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
SELECT id, article_id, task_batch_id, prompt_rule_id, prompt_rule_name, task_name,
status, execution_time, input_params_json, generate_count, articles, created_at
FROM aggregated
WHERE ($3::text IS NULL OR status = $3)
WHERE ($4::text IS NULL OR status = $4)
ORDER BY created_at DESC, id DESC
LIMIT $7 OFFSET $8
`, actor.TenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset)
LIMIT $8 OFFSET $9
`, actor.TenantID, brandID, 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 instant tasks")
}
@@ -134,6 +134,11 @@ func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor,
}
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, req KolGenerationSubmitRequest) (*KolGenerationSubmitResponse, error) {
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
if err != nil {
return nil, err
@@ -253,6 +258,7 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
articleID, err := articleTx.CreateArticle(ctx, repository.CreateArticleInput{
TenantID: actor.TenantID,
BrandID: brandID,
SourceType: kolGenerationTaskType,
KolPromptID: &row.PromptID,
})
+9 -4
View File
@@ -88,10 +88,14 @@ func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformRespon
func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID int64) ([]PublishRecordResponse, error) {
actor := auth.MustActor(ctx)
return s.listArticlePublishRecords(ctx, actor.TenantID, articleID)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
return s.listArticlePublishRecords(ctx, actor.TenantID, brandID, articleID)
}
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, articleID int64) ([]PublishRecordResponse, error) {
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pr.platform_id,
mp.name, pa.nickname, pr.status, pr.external_article_id, pr.external_article_url,
@@ -99,9 +103,10 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
FROM publish_records pr
JOIN media_platforms mp ON mp.platform_id = pr.platform_id
JOIN platform_accounts pa ON pa.id = pr.platform_account_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
ORDER BY pr.created_at DESC, pr.id DESC
`, tenantID, articleID)
`, tenantID, articleID, brandID)
if err != nil {
return nil, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
}
@@ -167,6 +167,11 @@ func (s *PromptRuleGenerationService) runtimeConfig() generationRuntimeConfig {
func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
req.BrandID = &brandID
extra := clonePromptRuleExtraParams(req.ExtraParams)
if strings.TrimSpace(extractString(extra, "generation_mode")) == "" {
extra["generation_mode"] = "instant"
@@ -258,6 +263,9 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
if err := s.llm.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
}
if input.BrandID == nil || *input.BrandID <= 0 {
return nil, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")
}
rule, err := s.loadPromptRule(ctx, input.TenantID, input.PromptRuleID)
if err != nil {
@@ -408,10 +416,10 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
var articleID int64
err = tx.QueryRow(ctx, `
INSERT INTO articles (tenant_id, source_type, prompt_rule_id, generate_status, publish_status, wizard_state_json)
VALUES ($1, 'custom_generation', $2, 'generating', 'unpublished', $3)
INSERT INTO articles (tenant_id, brand_id, source_type, prompt_rule_id, generate_status, publish_status, wizard_state_json)
VALUES ($1, $2, 'custom_generation', $3, 'generating', 'unpublished', $4)
RETURNING id
`, input.TenantID, input.PromptRuleID, wizardStateJSON).Scan(&articleID)
`, input.TenantID, *input.BrandID, input.PromptRuleID, wizardStateJSON).Scan(&articleID)
if err != nil {
return nil, response.ErrInternal(50012, "create_failed", "failed to create prompt rule article")
}
@@ -666,7 +674,13 @@ func (s *PromptRuleGenerationService) enqueueScheduleAutoPublish(ctx context.Con
publishTitle = "定时生成文章"
}
if _, err := s.publishJobs.Create(ctx, auth.Actor{
brandID := int64(extractInt(job.InputParams, "brand_id"))
publishCtx := ctx
if brandID > 0 {
publishCtx = auth.WithCurrentBrandID(ctx, brandID)
}
if _, err := s.publishJobs.Create(publishCtx, auth.Actor{
TenantID: job.TenantID,
UserID: job.OperatorID,
PrimaryWorkspaceID: workspaceID,
@@ -87,6 +87,10 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if s.pool == nil {
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
@@ -119,7 +123,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
accountRepo := repository.NewDesktopAccountRepository(tx)
taskRepo := repository.NewDesktopTaskRepository(tx)
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, articleID)
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, brandID, articleID)
if err != nil {
return nil, err
}
@@ -45,14 +45,14 @@ type monitoringArticleAliasInput struct {
ExternalManageURL *string
}
func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*publishableArticleMeta, error) {
func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, brandID, articleID int64) (*publishableArticleMeta, error) {
var generateStatus string
var wizardStateJSON []byte
if err := tx.QueryRow(ctx, `
SELECT generate_status, wizard_state_json
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&generateStatus, &wizardStateJSON); err != nil {
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, articleID, tenantID, brandID).Scan(&generateStatus, &wizardStateJSON); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
@@ -101,15 +101,23 @@ type ScheduleTaskStatusRequest struct {
func (s *ScheduleTaskService) List(ctx context.Context, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) {
actor := auth.MustActor(ctx)
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, scheduleTaskListCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ScheduleTaskListResponse, error) {
return s.loadScheduleTasks(loadCtx, actor.TenantID, brandID, params)
})
}
func (s *ScheduleTaskService) Detail(ctx context.Context, id int64) (*ScheduleTaskResponse, error) {
actor := auth.MustActor(ctx)
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, scheduleTaskDetailCacheKey(actor.TenantID, brandID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ScheduleTaskResponse, bool, error) {
return s.loadScheduleTaskDetail(loadCtx, actor.TenantID, brandID, id)
})
if err != nil {
return nil, err
@@ -122,6 +130,11 @@ func (s *ScheduleTaskService) Detail(ctx context.Context, id int64) (*ScheduleTa
func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskRequest) (*ScheduleTaskResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
req.BrandID = &brandID
if err := validateScheduleCronExpr(req.CronExpr); err != nil {
return nil, err
@@ -220,6 +233,12 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req ScheduleTaskRequest) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
req.BrandID = &brandID
if err := validateScheduleCronExpr(req.CronExpr); err != nil {
return err
}
@@ -247,13 +266,13 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
var endAt pgtype.Timestamptz
err = tx.QueryRow(ctx, `
UPDATE schedule_tasks SET prompt_rule_id = $1, brand_id = $2, name = $3,
cron_expr = $4, enable_web_search = $5, generate_count = $6,
workspace_id = $7, auto_publish = $8, publish_account_ids = $9::jsonb,
cover_asset_url = $10, cover_image_asset_id = $11,
start_at = $12::timestamptz, end_at = $13::timestamptz, operator_id = $14, updated_at = NOW()
WHERE id = $15 AND tenant_id = $16 AND deleted_at IS NULL
cron_expr = $4, enable_web_search = $5, generate_count = $6,
workspace_id = $7, auto_publish = $8, publish_account_ids = $9::jsonb,
cover_asset_url = $10, cover_image_asset_id = $11,
start_at = $12::timestamptz, end_at = $13::timestamptz, operator_id = $14, updated_at = NOW()
WHERE id = $15 AND tenant_id = $16 AND brand_id = $17 AND deleted_at IS NULL
RETURNING status, start_at, end_at
`, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID).
`, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
Scan(&status, &startAt, &endAt)
if err != nil {
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
@@ -282,10 +301,14 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
func (s *ScheduleTaskService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
tag, err := s.pool.Exec(ctx, `
UPDATE schedule_tasks SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID)
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, id, actor.TenantID, brandID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
}
@@ -311,6 +334,10 @@ func (s *ScheduleTaskService) Delete(ctx context.Context, id int64) error {
func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req ScheduleTaskStatusRequest) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50010, "update_failed", "failed to begin schedule task transaction")
@@ -323,9 +350,9 @@ func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req Sc
var endAt pgtype.Timestamptz
err = tx.QueryRow(ctx, `
UPDATE schedule_tasks SET status = $1, operator_id = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL
WHERE id = $3 AND tenant_id = $4 AND brand_id = $5 AND deleted_at IS NULL
RETURNING cron_expr, status, start_at, end_at
`, req.Status, actor.UserID, id, actor.TenantID).
`, req.Status, actor.UserID, id, actor.TenantID, brandID).
Scan(&cronExpr, &status, &startAt, &endAt)
if err != nil {
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
@@ -352,7 +379,7 @@ func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req Sc
return nil
}
func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID int64, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) {
func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, brandID int64, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) {
if params.Page < 1 {
params.Page = 1
}
@@ -364,13 +391,13 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
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 {
WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL
AND ($3::bigint IS NULL OR prompt_rule_id = $3)
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR name ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at <= $7)
`, tenantID, brandID, 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")
}
@@ -384,15 +411,15 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
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)
WHERE st.tenant_id = $1 AND st.brand_id = $2 AND st.deleted_at IS NULL
AND ($3::bigint IS NULL OR st.prompt_rule_id = $3)
AND ($4::text IS NULL OR st.status = $4)
AND ($5::text IS NULL OR st.name ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR st.created_at >= $6)
AND ($7::timestamptz IS NULL OR st.created_at <= $7)
ORDER BY st.created_at DESC
LIMIT $7 OFFSET $8
`, tenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset)
LIMIT $8 OFFSET $9
`, tenantID, brandID, 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")
}
@@ -415,7 +442,7 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
}
func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenantID, taskID int64) (*ScheduleTaskResponse, bool, error) {
func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenantID, brandID, taskID int64) (*ScheduleTaskResponse, bool, error) {
row := s.pool.QueryRow(ctx, `
SELECT st.id, st.workspace_id, st.prompt_rule_id, st.brand_id, st.name,
st.cron_expr, st.auto_publish, st.publish_account_ids, st.cover_asset_url,
@@ -426,8 +453,8 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
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)
WHERE st.id = $1 AND st.tenant_id = $2 AND st.brand_id = $3 AND st.deleted_at IS NULL
`, taskID, tenantID, brandID)
item, err := scanScheduleTaskResponse(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
+18 -7
View File
@@ -221,6 +221,10 @@ type SaveDraftResponse struct {
func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req SaveDraftRequest) (*SaveDraftResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if _, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID); err != nil {
return nil, mapTemplateLookupError(err)
@@ -234,10 +238,10 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
if req.ArticleID == nil {
var articleID int64
err = s.pool.QueryRow(ctx, `
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status, wizard_state_json)
VALUES ($1, 'template', $2, 'draft', 'unpublished', $3)
INSERT INTO articles (tenant_id, brand_id, source_type, template_id, generate_status, publish_status, wizard_state_json)
VALUES ($1, $2, 'template', $3, 'draft', 'unpublished', $4)
RETURNING id
`, actor.TenantID, templateID, stateJSON).Scan(&articleID)
`, actor.TenantID, brandID, templateID, stateJSON).Scan(&articleID)
if err != nil {
return nil, response.ErrInternal(50012, "draft_create_failed", "failed to create article draft")
}
@@ -254,12 +258,13 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
updated_at = NOW()
WHERE id = $2
AND tenant_id = $3
AND template_id = $4
AND brand_id = $4
AND template_id = $5
AND source_type = 'template'
AND deleted_at IS NULL
AND generate_status = 'draft'
RETURNING id
`, stateJSON, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
`, stateJSON, *req.ArticleID, actor.TenantID, brandID, templateID).Scan(&articleID)
if err != nil {
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
}
@@ -270,6 +275,10 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
func (s *TemplateService) Generate(ctx context.Context, templateID int64, req GenerateRequest) (*GenerateResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID)
if err != nil {
@@ -346,12 +355,13 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
FROM articles
WHERE id = $1
AND tenant_id = $2
AND template_id = $3
AND brand_id = $3
AND template_id = $4
AND source_type = 'template'
AND deleted_at IS NULL
AND generate_status IN ('draft', 'failed')
FOR UPDATE
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
`, *req.ArticleID, actor.TenantID, brandID, templateID).Scan(&articleID)
if err != nil {
return nil, response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
}
@@ -361,6 +371,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
} else {
articleID, err = articleTx.CreateArticle(ctx, repository.CreateArticleInput{
TenantID: actor.TenantID,
BrandID: brandID,
SourceType: "template",
TemplateID: &templateID,
})
@@ -46,12 +46,16 @@ func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService {
func (s *WorkspaceService) Overview(ctx context.Context) (*domain.WorkspaceOverview, error) {
actor := auth.MustActor(ctx)
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceOverviewCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.WorkspaceOverview, error) {
articleCount, err := s.repo.CountArticlesByTenantAndBrand(loadCtx, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
}
publishedCount, err := s.repo.CountPublishedArticlesByTenant(loadCtx, actor.TenantID)
publishedCount, err := s.repo.CountPublishedArticlesByTenantAndBrand(loadCtx, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count published articles")
}
@@ -69,8 +73,12 @@ func (s *WorkspaceService) Overview(ctx context.Context) (*domain.WorkspaceOverv
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceRecentArticlesCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]domain.RecentArticle, error) {
rows, err := s.repo.GetRecentArticlesByBrand(loadCtx, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to fetch recent articles")
}
@@ -79,6 +87,7 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA
for _, row := range rows {
articles = append(articles, domain.RecentArticle{
ID: row.ID,
BrandID: row.BrandID,
GenerateStatus: row.GenerateStatus,
PublishStatus: row.PublishStatus,
SourceType: row.SourceType,