From 41f3060e00abc107f81791551aa237ce0d07ca55 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 16 Apr 2026 21:01:40 +0800 Subject: [PATCH] feat: add brand library management features - Introduced BrandLibrarySummary type to encapsulate brand library limits and usage. - Updated Brand interface to include keyword_count and question_count fields. - Implemented brand library limits in the backend, including max brands, keywords, and questions per keyword. - Added API endpoint to retrieve brand library summary. - Enhanced BrandsView.vue to display brand library usage and limits. - Implemented computed properties to manage brand, keyword, and question limits in the UI. - Updated mutation handlers to invalidate relevant queries upon creating/updating brands, keywords, and questions. - Added visual indicators for brand, keyword, and question limits in the UI. - Enhanced error handling for exceeding brand and keyword limits during creation. --- apps/admin-web/src/i18n/messages/en-US.ts | 20 + apps/admin-web/src/i18n/messages/zh-CN.ts | 20 + apps/admin-web/src/lib/api.ts | 4 + apps/admin-web/src/lib/errors.ts | 7 + apps/admin-web/src/views/BrandsView.vue | 255 +++++++++++- packages/shared-types/src/index.ts | 14 + server/configs/config.local.yaml.example | 6 + server/configs/config.yaml | 6 + server/internal/shared/config/config.go | 35 ++ server/internal/shared/config/config_test.go | 24 ++ server/internal/tenant/app/brand_service.go | 366 ++++++++++++++++-- server/internal/tenant/app/cache_support.go | 5 + .../tenant/transport/brand_handler.go | 11 +- server/internal/tenant/transport/router.go | 1 + .../internal/tenant/transport/router_test.go | 1 + 15 files changed, 731 insertions(+), 44 deletions(-) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index df95dfd..8e8c323 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -811,6 +811,23 @@ const enUS = { keywords: "Keywords", competitors: "Competitors", }, + quota: { + plan: "Current plan", + planHint: "Brand library limits are enforced from the active plan.", + brands: "Brand companies", + brandsHint: "Free defaults to 1 and paid defaults to 2.", + keywords: "Total keywords", + keywordsHint: "The total number of keywords this account can bind.", + questions: "Questions per keyword", + questionsHint: "The maximum number of questions allowed under one keyword.", + }, + meta: { + keywordCount: "{count} keywords", + questionCount: "{count} questions", + brandKeywordCount: "{count} keywords under this brand", + globalKeywordUsage: "Total keywords used {used} / {total}", + keywordQuestionUsage: "Questions {used} / {total}", + }, sections: { keywords: "Keywords", questions: "Question set", @@ -855,6 +872,9 @@ const enUS = { deleteCompetitor: "Competitor deleted.", chooseBrand: "Please choose a brand first.", chooseKeyword: "Please choose a keyword first.", + brandLimitReached: "Your current plan allows up to {limit} brand companies.", + keywordLimitReached: "This account allows up to {limit} keywords.", + questionLimitReached: "This keyword allows up to {limit} questions.", }, }, custom: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 12511bf..dbb3d4a 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -818,6 +818,23 @@ const zhCN = { keywords: "关键词", competitors: "竞品库", }, + quota: { + plan: "当前套餐", + planHint: "品牌词库额度会按套餐实时生效。", + brands: "品牌公司", + brandsHint: "免费版默认 1 个,付费版默认 2 个。", + keywords: "关键词总量", + keywordsHint: "当前用户可绑定的关键词总数。", + questions: "每个关键词问题数", + questionsHint: "单个关键词下最多可维护的问题数。", + }, + meta: { + keywordCount: "{count} 个关键词", + questionCount: "{count} 个问题", + brandKeywordCount: "当前品牌已绑定 {count} 个关键词", + globalKeywordUsage: "总关键词已用 {used} / {total}", + keywordQuestionUsage: "问题 {used} / {total}", + }, sections: { keywords: "关键词", questions: "问题集", @@ -862,6 +879,9 @@ const zhCN = { deleteCompetitor: "竞品已删除", chooseBrand: "请先选择品牌", chooseKeyword: "请先选择关键词", + brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司", + keywordLimitReached: "当前账号最多可绑定 {limit} 个关键词", + questionLimitReached: "当前关键词下最多可维护 {limit} 个问题", }, }, custom: { diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 1ca86a1..a14be1e 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -9,6 +9,7 @@ import type { ArticleVersion, AuthTokens, Brand, + BrandLibrarySummary, BrandRequest, Competitor, CompetitorRequest, @@ -540,6 +541,9 @@ export const brandsApi = { list() { return apiClient.get("/api/tenant/brands"); }, + getLibrarySummary() { + return apiClient.get("/api/tenant/brands/library-summary"); + }, create(payload: BrandRequest) { return apiClient.post("/api/tenant/brands", payload); }, diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 12df400..fc23f16 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -39,6 +39,13 @@ const errorMessageMap: Record = { object_storage_unavailable: "对象存储未配置或当前不可用", invalid_payload: "请求参数不合法", update_failed: "保存文章失败", + brand_exists: "品牌名称已存在", + brand_limit_reached: "品牌公司数量已达当前套餐上限", + keyword_exists: "关键词已存在", + keyword_limit_reached: "关键词数量已达当前套餐上限", + question_limit_reached: "当前关键词下的问题数量已达上限", + brand_not_found: "品牌不存在或已删除", + keyword_not_found: "关键词不存在或已删除", publish_cover_required: "已选择百家号,请先上传封面图", publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试", publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试", diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index 7913fe9..0f6806b 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -6,7 +6,7 @@ import { } from "@ant-design/icons-vue"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { message } from "ant-design-vue"; -import type { Brand, Competitor, Keyword, Question } from "@geo/shared-types"; +import type { Brand, BrandLibrarySummary, Competitor, Keyword, Question } from "@geo/shared-types"; import type { TableColumnsType } from "ant-design-vue"; import { computed, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; @@ -58,6 +58,11 @@ const brandListQuery = useQuery({ queryFn: () => brandsApi.list(), }); +const brandLibrarySummaryQuery = useQuery({ + queryKey: ["brands", "library-summary"], + queryFn: () => brandsApi.getLibrarySummary(), +}); + const keywordsQuery = useQuery({ queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]), enabled: computed(() => Boolean(selectedBrandId.value)), @@ -82,6 +87,49 @@ const competitorsQuery = useQuery({ queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number), }); +const brandLibrarySummary = computed(() => brandLibrarySummaryQuery.data.value ?? null); + +const selectedBrand = computed(() => + brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null, +); + +const questionCountByKeyword = computed(() => { + const counts = new Map(); + for (const item of allQuestionsQuery.data.value ?? []) { + counts.set(item.keyword_id, (counts.get(item.keyword_id) ?? 0) + 1); + } + return counts; +}); + +const selectedBrandKeywordCount = computed(() => selectedBrand.value?.keyword_count ?? 0); +const selectedKeywordQuestionCount = computed(() => { + if (!selectedKeywordId.value) { + return 0; + } + return questionCountByKeyword.value.get(selectedKeywordId.value) ?? 0; +}); + +const brandLimitReached = computed(() => { + if (!brandLibrarySummary.value) { + return false; + } + return brandLibrarySummary.value.remaining_brands <= 0; +}); + +const keywordLimitReached = computed(() => { + if (!brandLibrarySummary.value) { + return false; + } + return brandLibrarySummary.value.remaining_keywords <= 0; +}); + +const questionLimitReached = computed(() => { + if (!brandLibrarySummary.value || !selectedKeywordId.value) { + return false; + } + return selectedKeywordQuestionCount.value >= brandLibrarySummary.value.max_questions_per_keyword; +}); + const brandMutations = { create: useMutation({ mutationFn: () => @@ -141,7 +189,10 @@ const keywordMutations = { onSuccess: async () => { message.success(t("brands.messages.createKeyword")); keywordModalOpen.value = false; - await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["brands"] }), + queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }), + ]); }, onError: (error) => message.error(formatError(error)), }), @@ -153,7 +204,10 @@ const keywordMutations = { onSuccess: async () => { message.success(t("brands.messages.updateKeyword")); keywordModalOpen.value = false; - await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["brands"] }), + queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }), + ]); }, onError: (error) => message.error(formatError(error)), }), @@ -166,6 +220,7 @@ const keywordMutations = { selectedKeywordId.value = null; } await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["brands"] }), queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }), queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }), ]); @@ -184,7 +239,10 @@ const questionMutations = { onSuccess: async () => { message.success(t("brands.messages.createQuestion")); questionModalOpen.value = false; - await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["brands"] }), + queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }), + ]); }, onError: (error) => message.error(formatError(error)), }), @@ -205,7 +263,10 @@ const questionMutations = { brandsApi.removeQuestion(selectedBrandId.value as number, questionId), onSuccess: async () => { message.success(t("brands.messages.deleteQuestion")); - await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["brands"] }), + queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }), + ]); }, onError: (error) => message.error(formatError(error)), }), @@ -319,6 +380,14 @@ function stringifyProductLines(value: unknown): string { } function openBrandModal(brand?: Brand): void { + if (!brand && brandLimitReached.value) { + message.warning( + t("brands.messages.brandLimitReached", { + limit: brandLibrarySummary.value?.max_brands ?? 0, + }), + ); + return; + } editingBrandId.value = brand?.id ?? null; brandForm.name = brand?.name ?? ""; brandForm.website = brand?.website ?? ""; @@ -331,6 +400,14 @@ function openKeywordModal(keyword?: Keyword): void { message.warning(t("brands.messages.chooseBrand")); return; } + if (!keyword && keywordLimitReached.value) { + message.warning( + t("brands.messages.keywordLimitReached", { + limit: brandLibrarySummary.value?.max_keywords ?? 0, + }), + ); + return; + } editingKeywordId.value = keyword?.id ?? null; keywordForm.name = keyword?.name ?? ""; keywordModalOpen.value = true; @@ -341,6 +418,14 @@ function openQuestionModal(question?: Question): void { message.warning(t("brands.messages.chooseBrand")); return; } + if (!question && questionLimitReached.value) { + message.warning( + t("brands.messages.questionLimitReached", { + limit: brandLibrarySummary.value?.max_questions_per_keyword ?? 0, + }), + ); + return; + } editingQuestionId.value = question?.id ?? null; questionForm.keyword_id = question?.keyword_id ?? selectedKeywordId.value ?? undefined; questionForm.question_text = question?.question_text ?? ""; @@ -419,12 +504,39 @@ async function submitCompetitor(): Promise {

{{ t('brands.description') }}

- + {{ t("brands.newBrand") }}
+ +
+
+ {{ t("brands.quota.plan") }} + {{ brandLibrarySummary?.plan_name || t("shell.planFallback") }} +

{{ t("brands.quota.planHint") }}

+
+
+ {{ t("brands.quota.brands") }} + + {{ brandLibrarySummary?.used_brands ?? 0 }} / {{ brandLibrarySummary?.max_brands ?? "--" }} + +

{{ t("brands.quota.brandsHint") }}

+
+
+ {{ t("brands.quota.keywords") }} + + {{ brandLibrarySummary?.used_keywords ?? 0 }} / {{ brandLibrarySummary?.max_keywords ?? "--" }} + +

{{ t("brands.quota.keywordsHint") }}

+
+
+ {{ t("brands.quota.questions") }} + {{ brandLibrarySummary?.max_questions_per_keyword ?? "--" }} +

{{ t("brands.quota.questionsHint") }}

+
+
@@ -476,6 +588,11 @@ async function submitCompetitor(): Promise { + +
+ {{ t("brands.meta.keywordCount", { count: brand.keyword_count }) }} + {{ t("brands.meta.questionCount", { count: brand.question_count }) }} +
@@ -489,8 +606,21 @@ async function submitCompetitor(): Promise {

{{ t("brands.sections.keywords") }}

+

+ {{ t("brands.meta.brandKeywordCount", { count: selectedBrandKeywordCount }) }} + · + {{ t("brands.meta.globalKeywordUsage", { + used: brandLibrarySummary?.used_keywords ?? 0, + total: brandLibrarySummary?.max_keywords ?? 0, + }) }} +

- + {{ t("brands.actions.newKeyword") }}
@@ -504,7 +634,15 @@ async function submitCompetitor(): Promise { :class="{ 'brands-view__keyword-item--active': keyword.id === selectedKeywordId }" @click="selectedKeywordId = keyword.id" > - {{ keyword.name }} +
+ {{ keyword.name }} + + {{ t("brands.meta.keywordQuestionUsage", { + used: questionCountByKeyword.get(keyword.id) ?? 0, + total: brandLibrarySummary?.max_questions_per_keyword ?? 0, + }) }} + +
{

{{ t("brands.sections.questions") }}

{{ selectedKeyword?.name || t("brands.sections.questions") }}

+

+ {{ t("brands.meta.keywordQuestionUsage", { + used: selectedKeywordQuestionCount, + total: brandLibrarySummary?.max_questions_per_keyword ?? 0, + }) }} +

- + {{ t("brands.actions.newQuestion") }}
@@ -775,6 +924,49 @@ async function submitCompetitor(): Promise { padding: 24px; } +.brands-view__quota-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 0 24px 24px; + border-top: 1px solid #eef3f8; +} + +.brands-view__quota-card { + padding: 16px 18px; + border: 1px solid #e6edf5; + border-radius: 12px; + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); +} + +.brands-view__quota-card--plan { + background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%); + border-color: #cfe0ff; +} + +.brands-view__quota-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + letter-spacing: 0.04em; + color: #6b7280; + text-transform: uppercase; +} + +.brands-view__quota-card strong { + display: block; + font-size: 22px; + line-height: 1.2; + color: #111827; +} + +.brands-view__quota-card p { + margin: 8px 0 0; + color: #6b7280; + font-size: 13px; + line-height: 1.5; +} + .brands-view__header-title h2 { margin: 0; font-size: 20px; @@ -830,6 +1022,17 @@ async function submitCompetitor(): Promise { height: 16px; } +.brands-view__section-hint { + margin: 8px 0 0; + color: #6b7280; + font-size: 13px; +} + +.brands-view__dot { + margin: 0 8px; + color: #c0cad8; +} + .brands-view__brand-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -893,6 +1096,21 @@ async function submitCompetitor(): Promise { line-height: 1.6; } +.brands-view__brand-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.brands-view__brand-meta span { + padding: 6px 10px; + border-radius: 999px; + background: #edf4ff; + color: #2458d3; + font-size: 12px; + font-weight: 600; +} + .brands-view__brand-card-actions, .brands-view__keyword-actions, .brands-view__inline-actions { @@ -927,6 +1145,23 @@ async function submitCompetitor(): Promise { transition: all 0.2s; } +.brands-view__keyword-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.brands-view__keyword-copy span { + font-weight: 600; + color: #111827; +} + +.brands-view__keyword-copy small { + color: #6b7280; + font-size: 12px; +} + .brands-view__keyword-item:hover { border-color: #bfd7ff; background: #f0f7ff; @@ -992,6 +1227,7 @@ async function submitCompetitor(): Promise { } @media (max-width: 1080px) { + .brands-view__quota-strip, .brands-view__brand-grid, .brands-view__keyword-layout, .brands-view__competitor-grid { @@ -1000,6 +1236,7 @@ async function submitCompetitor(): Promise { } @media (max-width: 720px) { + .brands-view__header, .brands-view__section-head { flex-direction: column; align-items: stretch; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 7df3dc3..c008d18 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -725,6 +725,8 @@ export interface Brand { website: string | null; description: string | null; status: string; + keyword_count: number; + question_count: number; created_at: string; updated_at?: string; } @@ -759,6 +761,18 @@ export interface Question { created_at: string; } +export interface BrandLibrarySummary { + plan_code: string; + plan_name: string; + max_brands: number; + used_brands: number; + remaining_brands: number; + max_keywords: number; + used_keywords: number; + remaining_keywords: number; + max_questions_per_keyword: number; +} + export interface CompetitorRequest { name: string; website?: string | null; diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index 76a6330..23de1dc 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -59,6 +59,12 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +brand_library: + free_brand_limit: 1 + paid_brand_limit: 2 + max_keywords: 5 + max_questions_per_keyword: 5 + redis: addr: localhost:6379 diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 91928f2..e2d7a1a 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -63,6 +63,12 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +brand_library: + free_brand_limit: 1 + paid_brand_limit: 2 + max_keywords: 5 + max_questions_per_keyword: 5 + redis: addr: localhost:6379 db: 0 diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 3f42ae2..5d1704d 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -16,6 +16,7 @@ type Config struct { RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"` Scheduler SchedulerConfig `mapstructure:"scheduler"` MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"` + BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"` Redis RedisConfig `mapstructure:"redis"` Qdrant QdrantConfig `mapstructure:"qdrant"` ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"` @@ -103,6 +104,20 @@ type MonitoringConfig struct { ProjectionRebuildConcurrency int `mapstructure:"projection_rebuild_concurrency"` } +type BrandLibraryConfig struct { + FreeBrandLimit int `mapstructure:"free_brand_limit"` + PaidBrandLimit int `mapstructure:"paid_brand_limit"` + MaxKeywords int `mapstructure:"max_keywords"` + MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"` +} + +func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int { + if strings.EqualFold(strings.TrimSpace(planCode), "free") { + return c.FreeBrandLimit + } + return c.PaidBrandLimit +} + type QdrantConfig struct { URL string `mapstructure:"url"` APIKey string `mapstructure:"api_key"` @@ -196,6 +211,7 @@ func Load(configPath string) (*Config, error) { normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeSchedulerConfig(&cfg.Scheduler) normalizeMonitoringConfig(&cfg.MonitoringWorkers) + normalizeBrandLibraryConfig(&cfg.BrandLibrary) return &cfg, nil } @@ -398,6 +414,25 @@ func normalizeMonitoringConfig(cfg *MonitoringConfig) { } } +func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) { + if cfg == nil { + return + } + + if cfg.FreeBrandLimit <= 0 { + cfg.FreeBrandLimit = 1 + } + if cfg.PaidBrandLimit <= 0 { + cfg.PaidBrandLimit = 2 + } + if cfg.MaxKeywords <= 0 { + cfg.MaxKeywords = 5 + } + if cfg.MaxQuestionsPerKeyword <= 0 { + cfg.MaxQuestionsPerKeyword = 5 + } +} + func lookupFirstNonEmptyEnv(keys ...string) (string, bool) { for _, key := range keys { if value, ok := lookupNonEmptyEnv(key); ok { diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index 7ad7f63..f50d46e 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -89,6 +89,30 @@ retrieval: } } +func TestLoadAppliesBrandLibraryDefaults(t *testing.T) { + configPath := writeTestConfig(t, ` +brand_library: {} +`) + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.BrandLibrary.FreeBrandLimit != 1 { + t.Fatalf("expected default free brand limit 1, got %d", cfg.BrandLibrary.FreeBrandLimit) + } + if cfg.BrandLibrary.PaidBrandLimit != 2 { + t.Fatalf("expected default paid brand limit 2, got %d", cfg.BrandLibrary.PaidBrandLimit) + } + if cfg.BrandLibrary.MaxKeywords != 5 { + t.Fatalf("expected default max keywords 5, got %d", cfg.BrandLibrary.MaxKeywords) + } + if cfg.BrandLibrary.MaxQuestionsPerKeyword != 5 { + t.Fatalf("expected default max questions per keyword 5, got %d", cfg.BrandLibrary.MaxQuestionsPerKeyword) + } +} + func writeTestConfig(t *testing.T, body string) string { t.Helper() diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go index a0c6904..ef8eb45 100644 --- a/server/internal/tenant/app/brand_service.go +++ b/server/internal/tenant/app/brand_service.go @@ -8,12 +8,14 @@ import ( "strings" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "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" + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" ) @@ -21,13 +23,14 @@ import ( type BrandService struct { pool *pgxpool.Pool monitoringPool *pgxpool.Pool + limits sharedconfig.BrandLibraryConfig 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 NewBrandService(pool, monitoringPool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, limits sharedconfig.BrandLibraryConfig) *BrandService { + return &BrandService{pool: pool, monitoringPool: monitoringPool, limits: limits, auditLogs: auditLogs} } func (s *BrandService) WithCache(c sharedcache.Cache) *BrandService { @@ -44,13 +47,27 @@ type BrandRequest struct { } type BrandResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Website *string `json:"website"` - Description *string `json:"description"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Website *string `json:"website"` + Description *string `json:"description"` + Status string `json:"status"` + KeywordCount int `json:"keyword_count"` + QuestionCount int `json:"question_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type BrandLibrarySummaryResponse struct { + PlanCode string `json:"plan_code"` + PlanName string `json:"plan_name"` + MaxBrands int `json:"max_brands"` + UsedBrands int `json:"used_brands"` + RemainingBrands int `json:"remaining_brands"` + MaxKeywords int `json:"max_keywords"` + UsedKeywords int `json:"used_keywords"` + RemainingKeywords int `json:"remaining_keywords"` + MaxQuestionsPerKeyword int `json:"max_questions_per_keyword"` } func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) { @@ -60,20 +77,47 @@ func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) { }) } +func (s *BrandService) Summary(ctx context.Context) (*BrandLibrarySummaryResponse, error) { + actor := auth.MustActor(ctx) + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandLibrarySummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*BrandLibrarySummaryResponse, error) { + return s.loadBrandLibrarySummary(loadCtx, actor.TenantID) + }) +} + func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) { actor := auth.MustActor(ctx) normalizeBrandRequest(&req) if req.Name == "" { return nil, response.ErrBadRequest(40001, "invalid_params", "name is required") } + + summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID) + if err != nil { + return nil, err + } + var id int64 var ca interface{} - err := s.pool.QueryRow(ctx, ` + err = s.pool.QueryRow(ctx, ` + WITH usage AS ( + SELECT COUNT(*)::INT AS used_brands + FROM brands + WHERE tenant_id = $1 AND deleted_at IS NULL + ) INSERT INTO brands (tenant_id, name, website, description, status) - VALUES ($1, $2, $3, $4, 'active') RETURNING id, created_at - `, actor.TenantID, req.Name, req.Website, req.Description).Scan(&id, &ca) + SELECT $1, $2, $3, $4, 'active' + FROM usage + WHERE usage.used_brands < $5 + RETURNING id, created_at + `, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &ca) if err != nil { - return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists") + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40904, "brand_limit_reached", fmt.Sprintf("current plan allows up to %d brand companies", summary.MaxBrands)) + } + if isUniqueConstraintError(err, "uk_brand_tenant_name_active") { + return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists") + } + return nil, response.ErrInternal(50010, "create_failed", "failed to create brand") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name}) @@ -94,12 +138,14 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp invalidateBrandCaches(ctx, s.cache, actor.TenantID, id) return &BrandResponse{ - ID: id, - Name: req.Name, - Website: req.Website, - Description: req.Description, - Status: "active", - CreatedAt: fmt.Sprintf("%v", ca), + ID: id, + Name: req.Name, + Website: req.Website, + Description: req.Description, + Status: "active", + KeywordCount: 0, + QuestionCount: 0, + CreatedAt: fmt.Sprintf("%v", ca), }, nil } @@ -127,7 +173,13 @@ func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) e UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW() WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL `, req.Name, req.Website, req.Description, id, actor.TenantID) - if err != nil || tag.RowsAffected() == 0 { + if err != nil { + if isUniqueConstraintError(err, "uk_brand_tenant_name_active") { + return response.ErrConflict(40901, "brand_exists", "brand with this name already exists") + } + return response.ErrInternal(50010, "update_failed", "failed to update brand") + } + if tag.RowsAffected() == 0 { return response.ErrNotFound(40420, "brand_not_found", "brand not found") } invalidateBrandCaches(ctx, s.cache, actor.TenantID, id) @@ -216,13 +268,48 @@ func (s *BrandService) ListKeywords(ctx context.Context, brandID int64) ([]Keywo func (s *BrandService) CreateKeyword(ctx context.Context, brandID int64, req KeywordRequest) (*KeywordResponse, error) { actor := auth.MustActor(ctx) + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + return nil, response.ErrBadRequest(40001, "invalid_params", "name is required") + } + exists, err := s.brandExists(ctx, actor.TenantID, brandID) + if err != nil { + return nil, err + } + if !exists { + return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found") + } + + summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID) + if err != nil { + return nil, err + } + var id int64 var ca interface{} - err := s.pool.QueryRow(ctx, ` - INSERT INTO brand_keywords (tenant_id, brand_id, name, status) VALUES ($1, $2, $3, 'active') RETURNING id, created_at - `, actor.TenantID, brandID, req.Name).Scan(&id, &ca) + err = s.pool.QueryRow(ctx, ` + WITH usage AS ( + SELECT COUNT(*)::INT AS used_keywords + FROM brand_keywords + WHERE tenant_id = $1 AND deleted_at IS NULL + ) + INSERT INTO brand_keywords (tenant_id, brand_id, name, status) + SELECT $1, $2, $3, 'active' + FROM usage + WHERE usage.used_keywords < $4 + RETURNING id, created_at + `, actor.TenantID, brandID, req.Name, summary.MaxKeywords).Scan(&id, &ca) if err != nil { - return nil, response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand") + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40905, "keyword_limit_reached", fmt.Sprintf("current plan allows up to %d keywords", summary.MaxKeywords)) + } + if isUniqueConstraintError(err, "uk_brand_keyword_name_active") { + return nil, response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand") + } + if isForeignKeyConstraintError(err) { + return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found") + } + return nil, response.ErrInternal(50010, "create_failed", "failed to create keyword") } invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) return &KeywordResponse{ID: id, BrandID: brandID, Name: req.Name, Status: "active", CreatedAt: fmt.Sprintf("%v", ca)}, nil @@ -230,11 +317,21 @@ func (s *BrandService) CreateKeyword(ctx context.Context, brandID int64, req Key func (s *BrandService) UpdateKeyword(ctx context.Context, brandID, keywordID int64, req KeywordRequest) error { actor := auth.MustActor(ctx) + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + return response.ErrBadRequest(40001, "invalid_params", "name is required") + } tag, err := s.pool.Exec(ctx, ` UPDATE brand_keywords SET name = $1, updated_at = NOW() WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL `, req.Name, keywordID, brandID, actor.TenantID) - if err != nil || tag.RowsAffected() == 0 { + if err != nil { + if isUniqueConstraintError(err, "uk_brand_keyword_name_active") { + return response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand") + } + return response.ErrInternal(50010, "update_failed", "failed to update keyword") + } + if tag.RowsAffected() == 0 { return response.ErrNotFound(40421, "keyword_not_found", "keyword not found") } invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID) @@ -331,19 +428,48 @@ func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keyword func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req QuestionRequest) (*QuestionResponse, error) { actor := auth.MustActor(ctx) + if req.KeywordID <= 0 { + return nil, response.ErrBadRequest(40001, "invalid_params", "keyword_id is required") + } req.QuestionText = strings.TrimSpace(req.QuestionText) if req.QuestionText == "" { return nil, response.ErrBadRequest(40001, "invalid_params", "question_text is required") } + exists, err := s.keywordExistsForBrand(ctx, actor.TenantID, brandID, req.KeywordID) + if err != nil { + return nil, err + } + if !exists { + return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found") + } + + summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID) + if err != nil { + return nil, err + } + var questionID int64 var ca interface{} - err := s.pool.QueryRow(ctx, ` + err = s.pool.QueryRow(ctx, ` + WITH usage AS ( + SELECT COUNT(*)::INT AS used_questions + FROM brand_questions + WHERE tenant_id = $1 AND keyword_id = $3 AND deleted_at IS NULL + ) INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status) - VALUES ($1, $2, $3, $4, 'active') + SELECT $1, $2, $3, $4, 'active' + FROM usage + WHERE usage.used_questions < $5 RETURNING id, created_at - `, actor.TenantID, brandID, req.KeywordID, req.QuestionText).Scan(&questionID, &ca) + `, actor.TenantID, brandID, req.KeywordID, req.QuestionText, summary.MaxQuestionsPerKeyword).Scan(&questionID, &ca) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40906, "question_limit_reached", fmt.Sprintf("each keyword allows up to %d questions", summary.MaxQuestionsPerKeyword)) + } + if isForeignKeyConstraintError(err) { + return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found") + } return nil, response.ErrInternal(50010, "create_failed", "failed to create question") } @@ -488,8 +614,32 @@ func (s *BrandService) DeleteCompetitor(ctx context.Context, brandID, competitor 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 + SELECT + b.id, + b.name, + b.website, + b.description, + b.status, + COALESCE(stats.keyword_count, 0) AS keyword_count, + COALESCE(stats.question_count, 0) AS question_count, + b.created_at, + b.updated_at + FROM brands b + LEFT JOIN LATERAL ( + SELECT + COUNT(DISTINCT k.id)::INT AS keyword_count, + COUNT(q.id)::INT AS question_count + FROM brand_keywords k + LEFT JOIN brand_questions q + ON q.keyword_id = k.id + AND q.tenant_id = b.tenant_id + AND q.deleted_at IS NULL + WHERE k.brand_id = b.id + AND k.tenant_id = b.tenant_id + AND k.deleted_at IS NULL + ) stats ON true + WHERE b.tenant_id = $1 AND b.deleted_at IS NULL + ORDER BY b.created_at DESC `, tenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list brands") @@ -501,7 +651,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR 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 { + if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } item.CreatedAt = fmt.Sprintf("%v", createdAt) @@ -516,9 +666,32 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in 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) + SELECT + b.id, + b.name, + b.website, + b.description, + b.status, + COALESCE(stats.keyword_count, 0) AS keyword_count, + COALESCE(stats.question_count, 0) AS question_count, + b.created_at, + b.updated_at + FROM brands b + LEFT JOIN LATERAL ( + SELECT + COUNT(DISTINCT k.id)::INT AS keyword_count, + COUNT(q.id)::INT AS question_count + FROM brand_keywords k + LEFT JOIN brand_questions q + ON q.keyword_id = k.id + AND q.tenant_id = b.tenant_id + AND q.deleted_at IS NULL + WHERE k.brand_id = b.id + AND k.tenant_id = b.tenant_id + AND k.deleted_at IS NULL + ) stats ON true + WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL + `, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil @@ -612,3 +785,128 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand } return items, nil } + +type brandLibraryPlan struct { + PlanCode string + PlanName string +} + +type brandLibraryUsage struct { + BrandCount int + KeywordCount int +} + +func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int64) (*BrandLibrarySummaryResponse, error) { + plan, err := s.loadBrandLibraryPlan(ctx, tenantID) + if err != nil { + return nil, err + } + + usage, err := s.loadBrandLibraryUsage(ctx, tenantID) + if err != nil { + return nil, err + } + + maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode) + maxKeywords := s.limits.MaxKeywords + + return &BrandLibrarySummaryResponse{ + PlanCode: plan.PlanCode, + PlanName: plan.PlanName, + MaxBrands: maxBrands, + UsedBrands: usage.BrandCount, + RemainingBrands: maxInt(maxBrands-usage.BrandCount, 0), + MaxKeywords: maxKeywords, + UsedKeywords: usage.KeywordCount, + RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0), + MaxQuestionsPerKeyword: s.limits.MaxQuestionsPerKeyword, + }, nil +} + +func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) { + plan := &brandLibraryPlan{ + PlanCode: "free", + PlanName: "", + } + + err := s.pool.QueryRow(ctx, ` + SELECT p.plan_code, p.name + FROM tenant_plan_subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL + ORDER BY s.start_at DESC + LIMIT 1 + `, tenantID).Scan(&plan.PlanCode, &plan.PlanName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return plan, nil + } + return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan") + } + return plan, nil +} + +func (s *BrandService) loadBrandLibraryUsage(ctx context.Context, tenantID int64) (*brandLibraryUsage, error) { + usage := &brandLibraryUsage{} + err := s.pool.QueryRow(ctx, ` + SELECT + (SELECT COUNT(*)::INT FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL) AS brand_count, + (SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL) AS keyword_count + `, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to load brand library usage") + } + return usage, nil +} + +func (s *BrandService) brandExists(ctx context.Context, tenantID, brandID int64) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM brands + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + ) + `, brandID, tenantID).Scan(&exists) + if err != nil { + return false, response.ErrInternal(50010, "query_failed", "failed to load brand") + } + return exists, nil +} + +func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM brand_keywords + WHERE id = $1 + AND brand_id = $2 + AND tenant_id = $3 + AND deleted_at IS NULL + ) + `, keywordID, brandID, tenantID).Scan(&exists) + if err != nil { + return false, response.ErrInternal(50010, "query_failed", "failed to load keyword") + } + return exists, nil +} + +func isUniqueConstraintError(err error, constraint string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint +} + +func isForeignKeyConstraintError(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} + +func maxInt(value, floor int) int { + if value < floor { + return floor + } + return value +} diff --git a/server/internal/tenant/app/cache_support.go b/server/internal/tenant/app/cache_support.go index bca4c32..70d8018 100644 --- a/server/internal/tenant/app/cache_support.go +++ b/server/internal/tenant/app/cache_support.go @@ -75,6 +75,10 @@ func brandListCacheKey(tenantID int64) string { return fmt.Sprintf("brand:list:%d", tenantID) } +func brandLibrarySummaryCacheKey(tenantID int64) string { + return fmt.Sprintf("brand:library_summary:%d", tenantID) +} + func brandDetailCacheKey(tenantID, brandID int64) string { return fmt.Sprintf("brand:detail:%d:%d", tenantID, brandID) } @@ -121,6 +125,7 @@ func invalidateBrandCaches(ctx context.Context, c sharedcache.Cache, tenantID, b deleteCachePrefix(ctx, c, brandQuestionsCachePrefix(tenantID, brandID)) deleteCachePrefix(ctx, c, brandCompetitorsCachePrefix(tenantID, brandID)) deleteCacheKey(ctx, c, brandListCacheKey(tenantID)) + deleteCacheKey(ctx, c, brandLibrarySummaryCacheKey(tenantID)) deleteCachePrefix(ctx, c, scheduleTaskListCachePrefix(tenantID)) deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID)) invalidateWorkspaceCaches(ctx, c, tenantID) diff --git a/server/internal/tenant/transport/brand_handler.go b/server/internal/tenant/transport/brand_handler.go index 62d24bc..5e2017e 100644 --- a/server/internal/tenant/transport/brand_handler.go +++ b/server/internal/tenant/transport/brand_handler.go @@ -15,7 +15,7 @@ type BrandHandler struct { } func NewBrandHandler(a *bootstrap.App) *BrandHandler { - return &BrandHandler{svc: app.NewBrandService(a.DB, a.MonitoringDB, a.AuditLogs).WithCache(a.Cache)} + return &BrandHandler{svc: app.NewBrandService(a.DB, a.MonitoringDB, a.AuditLogs, a.Config.BrandLibrary).WithCache(a.Cache)} } func (h *BrandHandler) List(c *gin.Context) { @@ -27,6 +27,15 @@ func (h *BrandHandler) List(c *gin.Context) { response.Success(c, data) } +func (h *BrandHandler) Summary(c *gin.Context) { + data, err := h.svc.Summary(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + func (h *BrandHandler) Create(c *gin.Context) { var req app.BrandRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index f5e7a81..0ceef0c 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -80,6 +80,7 @@ func RegisterRoutes(app *bootstrap.App) { brands := protected.Group("/tenant/brands") brandHandler := NewBrandHandler(app) brands.GET("", brandHandler.List) + brands.GET("/library-summary", brandHandler.Summary) brands.POST("", brandHandler.Create) brands.GET("/:id", brandHandler.Detail) brands.PUT("/:id", brandHandler.Update) diff --git a/server/internal/tenant/transport/router_test.go b/server/internal/tenant/transport/router_test.go index a5fd709..e146d00 100644 --- a/server/internal/tenant/transport/router_test.go +++ b/server/internal/tenant/transport/router_test.go @@ -29,6 +29,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) { {http.MethodGet, "/api/tenant/templates"}, {http.MethodGet, "/api/tenant/articles"}, {http.MethodGet, "/api/tenant/brands"}, + {http.MethodGet, "/api/tenant/brands/library-summary"}, {http.MethodGet, "/api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail"}, {http.MethodPost, "/api/tenant/brands"}, }