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
+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")
}