feat(membership): enforce tenant subscription plans with blocked UI

Add configurable membership plans (free/plus/pro) synced to DB on boot, a
subscription guard middleware that blocks tenant endpoints on expired or
missing plans, and a MembershipBlockedView that surfaces the reason so
the admin can contact the tenant owner. Quota and brand-library reads now
honor the active plan's policy JSON and expired subscriptions.
This commit is contained in:
2026-04-18 20:56:05 +08:00
parent 9ec9314aea
commit 63667ed2d1
28 changed files with 1738 additions and 80 deletions
+16
View File
@@ -95,6 +95,22 @@ const enUS = {
defaultTestAccount: "Default account",
defaultTestPassword: "Default password",
},
membershipBlocked: {
eyebrow: "Access Paused",
title: "This tenant plan is unavailable",
reasonTrialExpired: "The free trial's 3-day page access window has ended. All business pages are now locked. Please contact an administrator to reactivate trial access.",
reasonRequired: "This tenant does not have an active plan. All business pages are now locked. Please contact an administrator.",
reasonInactive: "This tenant plan has expired or been disabled. All business pages are now locked. Please contact an administrator to restore access.",
plan: "Current Plan",
expiredAt: "Expired At",
articleLimit: "Article Limit",
companyLimit: "Company Limit",
contactAdmin: "Once the administrator reactivates access, refresh this page to continue.",
logout: "Log out",
refresh: "Check again",
refreshing: "Checking…",
stillBlocked: "Access is still blocked. Please contact your administrator before checking again.",
},
shell: {
localeLabel: "Language",
quotaTitle: "Plan & Quota",
+16
View File
@@ -95,6 +95,22 @@ const zhCN = {
defaultTestAccount: "默认测试账号",
defaultTestPassword: "默认测试密码",
},
membershipBlocked: {
eyebrow: "访问已暂停",
title: "当前租户套餐不可用",
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
plan: "当前套餐",
expiredAt: "到期时间",
articleLimit: "文章额度",
companyLimit: "公司数量",
contactAdmin: "管理员重新开通后,刷新页面即可恢复访问。",
logout: "退出登录",
refresh: "重新检查",
refreshing: "检查中…",
stillBlocked: "账户仍处于受限状态,请先联系管理员后再重试。",
},
shell: {
localeLabel: "语言",
quotaTitle: "套餐与额度",
+3
View File
@@ -12,6 +12,9 @@ const errorMessageMap: Record<string, string> = {
tenant_scope_missing: "租户上下文缺失",
query_failed: "数据读取失败",
quota_insufficient: "生成额度不足",
trial_plan_expired: "免费试用已到期,请联系管理员开通",
subscription_required: "当前租户尚未开通有效套餐,请联系管理员",
subscription_inactive: "当前租户套餐已失效,请联系管理员",
llm_unavailable: "生成服务不可用",
analyze_context_required: "请先填写品牌或模板上下文信息",
title_context_required: "请先确认品牌、关键词或竞品信息",
+19
View File
@@ -15,6 +15,14 @@ const router = createRouter({
descriptionKey: "route.login.description",
},
},
{
path: "/membership-blocked",
name: "membership-blocked",
component: () => import("@/views/MembershipBlockedView.vue"),
meta: {
requiresAuth: true,
},
},
{
path: "/",
component: () => import("@/layouts/AppShell.vue"),
@@ -223,11 +231,22 @@ router.beforeEach(async (to) => {
};
}
if (authStore.isAuthenticated && authStore.isMembershipBlocked && to.name !== "membership-blocked") {
return { name: "membership-blocked" };
}
if (to.name === "membership-blocked" && authStore.isAuthenticated && !authStore.isMembershipBlocked) {
return { name: "workspace" };
}
if (to.meta.requiresKol && !authStore.isActiveKol) {
return { name: "workspace" };
}
if (to.name === "login" && authStore.isAuthenticated) {
if (authStore.isMembershipBlocked) {
return { name: "membership-blocked" };
}
return { name: "workspace" };
}
+4
View File
@@ -65,6 +65,8 @@ export const useAuthStore = defineStore("auth", () => {
}
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value));
const membership = computed(() => user.value?.membership ?? null);
const isMembershipBlocked = computed(() => Boolean(membership.value?.blocked));
const kolProfile = computed(() => user.value?.kol_profile ?? null);
const isActiveKol = computed(() => kolProfile.value?.status === "active");
@@ -74,6 +76,8 @@ export const useAuthStore = defineStore("auth", () => {
user,
initialized,
isAuthenticated,
membership,
isMembershipBlocked,
kolProfile,
isActiveKol,
bootstrap,
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { LockOutlined, LogoutOutlined, ReloadOutlined } from "@ant-design/icons-vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useAuthStore } from "@/stores/auth";
const authStore = useAuthStore();
const router = useRouter();
const { t, locale } = useI18n();
const membership = computed(() => authStore.membership);
const isRefreshing = ref(false);
const stillBlocked = ref(false);
const blockedReasonText = computed(() => {
switch (membership.value?.blocked_reason) {
case "trial_plan_expired":
return t("membershipBlocked.reasonTrialExpired");
case "subscription_required":
return t("membershipBlocked.reasonRequired");
default:
return t("membershipBlocked.reasonInactive");
}
});
const planName = computed(() => membership.value?.plan_name || membership.value?.plan_code || "--");
const articleLimit = computed(() => membership.value?.article_generation_limit ?? 0);
const companyLimit = computed(() => membership.value?.company_limit ?? 0);
const expiryText = computed(() => {
const value = membership.value?.end_at;
if (!value) {
return "--";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(locale.value, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
});
async function handleLogout(): Promise<void> {
await authStore.logout();
await router.replace("/login");
}
async function handleRefresh(): Promise<void> {
if (isRefreshing.value) {
return;
}
isRefreshing.value = true;
stillBlocked.value = false;
try {
await authStore.bootstrap();
if (!authStore.isMembershipBlocked) {
await router.replace("/workspace");
} else {
stillBlocked.value = true;
}
} finally {
isRefreshing.value = false;
}
}
</script>
<template>
<main class="membership-blocked-page">
<section
class="membership-blocked-card"
role="alert"
aria-labelledby="membership-blocked-title"
aria-describedby="membership-blocked-description"
>
<div class="membership-blocked-badge" aria-hidden="true">
<LockOutlined />
</div>
<header class="membership-blocked-copy">
<p class="membership-blocked-eyebrow">{{ t("membershipBlocked.eyebrow") }}</p>
<h1 id="membership-blocked-title">{{ t("membershipBlocked.title") }}</h1>
<p id="membership-blocked-description" class="membership-blocked-description">
{{ blockedReasonText }}
</p>
</header>
<dl class="membership-blocked-grid">
<div class="membership-blocked-item">
<dt>{{ t("membershipBlocked.plan") }}</dt>
<dd>{{ planName }}</dd>
</div>
<div class="membership-blocked-item">
<dt>{{ t("membershipBlocked.expiredAt") }}</dt>
<dd>{{ expiryText }}</dd>
</div>
<div class="membership-blocked-item">
<dt>{{ t("membershipBlocked.articleLimit") }}</dt>
<dd>{{ articleLimit }}</dd>
</div>
<div class="membership-blocked-item">
<dt>{{ t("membershipBlocked.companyLimit") }}</dt>
<dd>{{ companyLimit }}</dd>
</div>
</dl>
<p class="membership-blocked-note">
{{ t("membershipBlocked.contactAdmin") }}
</p>
<p
v-if="stillBlocked"
class="membership-blocked-still"
role="status"
aria-live="polite"
>
{{ t("membershipBlocked.stillBlocked") }}
</p>
<div class="membership-blocked-actions">
<button class="secondary" type="button" @click="handleLogout">
<LogoutOutlined aria-hidden="true" />
{{ t("membershipBlocked.logout") }}
</button>
<button
class="primary"
type="button"
:disabled="isRefreshing"
:aria-busy="isRefreshing"
@click="handleRefresh"
>
<ReloadOutlined aria-hidden="true" :spin="isRefreshing" />
{{ isRefreshing ? t("membershipBlocked.refreshing") : t("membershipBlocked.refresh") }}
</button>
</div>
</section>
</main>
</template>
<style scoped>
.membership-blocked-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
background:
radial-gradient(circle at top left, rgba(255, 205, 120, 0.28), transparent 30%),
radial-gradient(circle at bottom right, rgba(19, 94, 204, 0.14), transparent 34%),
linear-gradient(180deg, #fffaf0 0%, #f6f8fb 100%);
}
.membership-blocked-card {
width: min(720px, 100%);
padding: 36px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(25, 45, 80, 0.08);
box-shadow: 0 24px 70px rgba(15, 32, 64, 0.12);
backdrop-filter: blur(18px);
}
.membership-blocked-badge {
width: 56px;
height: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: linear-gradient(135deg, #ffb347 0%, #ff7f50 100%);
color: #fff;
font-size: 22px;
box-shadow: 0 14px 28px rgba(255, 127, 80, 0.25);
}
.membership-blocked-copy {
margin-top: 18px;
}
.membership-blocked-eyebrow {
margin: 0 0 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #c7642c;
}
.membership-blocked-copy h1 {
margin: 0;
font-size: clamp(30px, 5vw, 40px);
line-height: 1.08;
color: #172033;
}
.membership-blocked-description {
margin: 14px 0 0;
font-size: 16px;
line-height: 1.7;
color: #4f596d;
}
.membership-blocked-grid {
margin: 28px 0 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.membership-blocked-item {
margin: 0;
padding: 16px 18px;
border-radius: 18px;
background: #f7f9fc;
border: 1px solid rgba(23, 32, 51, 0.06);
}
.membership-blocked-item dt {
display: block;
margin: 0 0 8px;
font-size: 12px;
color: #6b7384;
}
.membership-blocked-item dd {
margin: 0;
color: #172033;
font-size: 18px;
font-weight: 700;
}
.membership-blocked-still {
margin: 16px 0 0;
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 127, 80, 0.12);
color: #b14a1a;
font-size: 14px;
}
.membership-blocked-actions button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.membership-blocked-actions button[disabled]:hover {
transform: none;
}
.membership-blocked-note {
margin-top: 22px;
padding: 16px 18px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(23, 92, 255, 0.08), rgba(255, 179, 71, 0.12));
color: #1f3558;
line-height: 1.7;
}
.membership-blocked-actions {
margin-top: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.membership-blocked-actions button {
height: 46px;
border-radius: 14px;
border: none;
padding: 0 18px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.membership-blocked-actions button:hover {
transform: translateY(-1px);
}
.membership-blocked-actions .primary {
color: #fff;
background: linear-gradient(135deg, #135ecc 0%, #0f7ad8 100%);
box-shadow: 0 14px 28px rgba(19, 94, 204, 0.2);
}
.membership-blocked-actions .secondary {
color: #27334a;
background: #edf1f7;
}
@media (max-width: 640px) {
.membership-blocked-card {
padding: 24px;
border-radius: 22px;
}
.membership-blocked-grid {
grid-template-columns: 1fr;
}
.membership-blocked-actions {
flex-direction: column-reverse;
}
.membership-blocked-actions button {
width: 100%;
justify-content: center;
}
}
</style>
+26
View File
@@ -38,6 +38,32 @@ monitoring_workers:
result_ingest_concurrency: 4
projection_rebuild_concurrency: 2
membership:
default_plan_code: free
plans:
- code: free
name: Free
article_generation: 3
article_quota_cycle: lifetime
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
contact_admin_on_expiry: true
- code: plus
name: Plus
article_generation: 200
article_quota_cycle: monthly
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
- code: pro
name: Pro
article_generation: 400
article_quota_cycle: monthly
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
redis:
addr: redis:6379
db: 0
+16
View File
@@ -30,9 +30,25 @@ export interface UserInfo {
tenant_id: number;
tenant_role: string;
permissions: string[];
membership: MembershipInfo | null;
kol_profile: KolProfileBrief | null;
}
export interface MembershipInfo {
plan_code: string;
plan_name: string;
status: string;
blocked: boolean;
blocked_reason: string | null;
start_at: string | null;
end_at: string | null;
article_generation_limit: number;
article_quota_cycle: string;
company_limit: number;
image_storage_bytes: number;
contact_admin_on_expiry: boolean;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
+44 -26
View File
@@ -18,6 +18,7 @@ import (
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type seedState struct {
@@ -119,7 +120,7 @@ func main() {
log.Fatalf("ping monitoring db: %v", err)
}
state, err := seedBusinessData(ctx, businessPool)
state, err := seedBusinessData(ctx, businessPool, cfg.Membership)
if err != nil {
log.Fatalf("seed business data: %v", err)
}
@@ -140,7 +141,7 @@ func main() {
fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts")
}
func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, error) {
func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg config.MembershipConfig) (*seedState, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
@@ -156,6 +157,18 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro
state := &seedState{}
if err := repository.NewTenantPlanRepository(tx).UpsertConfiguredPlans(ctx, membershipCfg); err != nil {
return nil, fmt.Errorf("sync membership plans: %w", err)
}
defaultPlan, ok := membershipCfg.FindPlan(membershipCfg.DefaultPlanCode)
if !ok {
defaultPlan, _ = membershipCfg.FindPlan("free")
}
if defaultPlan.Code == "" && len(membershipCfg.Plans) > 0 {
defaultPlan = membershipCfg.Plans[0]
}
state.TenantID, err = ensureTenant(ctx, tx)
if err != nil {
return nil, err
@@ -167,20 +180,20 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro
if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil {
return nil, err
}
state.PlanID, err = ensurePlan(ctx, tx)
state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code)
if err != nil {
return nil, err
}
if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID); err != nil {
if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID, defaultPlan.SubscriptionDuration); err != nil {
return nil, err
}
if err := ensureQuotaLedger(ctx, tx, state.TenantID); err != nil {
if err := ensureQuotaLedger(ctx, tx, state.TenantID, defaultPlan.ArticleGeneration); err != nil {
return nil, err
}
if err := ensureTemplates(ctx, tx); err != nil {
return nil, err
}
state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID)
state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID, defaultPlan)
if err != nil {
return nil, err
}
@@ -342,7 +355,12 @@ func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID i
return wrapSeedErr("insert membership", err)
}
func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (int64, int64, error) {
func ensureSecondaryTenantUser(
ctx context.Context,
tx pgx.Tx,
planID int64,
defaultPlan config.MembershipPlanConfig,
) (int64, int64, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost)
if err != nil {
return 0, 0, fmt.Errorf("hash secondary user password: %w", err)
@@ -361,52 +379,52 @@ func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (in
if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil {
return 0, 0, err
}
if err := ensureSubscription(ctx, tx, tenantID, planID); err != nil {
if err := ensureSubscription(ctx, tx, tenantID, planID, defaultPlan.SubscriptionDuration); err != nil {
return 0, 0, err
}
if err := ensureQuotaLedger(ctx, tx, tenantID); err != nil {
if err := ensureQuotaLedger(ctx, tx, tenantID, defaultPlan.ArticleGeneration); err != nil {
return 0, 0, err
}
return tenantID, userID, nil
}
func ensurePlan(ctx context.Context, tx pgx.Tx) (int64, error) {
func ensurePlan(ctx context.Context, tx pgx.Tx, planCode string) (int64, error) {
var planID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO plans (plan_code, name, quota_policy_json, status)
VALUES ('free', 'Free Plan', '{"article_generation": 100}', 'active')
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id FROM plans WHERE plan_code = 'free'
SELECT id
FROM plans
WHERE plan_code = $1
AND deleted_at IS NULL
LIMIT 1
`).Scan(&planID)
`, planCode).Scan(&planID)
return planID, wrapSeedErr("insert plan", err)
}
func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64) error {
func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64, duration time.Duration) error {
if duration <= 0 {
duration = 72 * time.Hour
}
_, err := tx.Exec(ctx, `
INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status)
SELECT $1, $2, NOW(), NOW() + INTERVAL '1 year', 'active'
SELECT $1, $2, NOW(), NOW() + $3::interval, 'active'
WHERE NOT EXISTS (
SELECT 1
FROM tenant_plan_subscriptions
WHERE tenant_id = $1
AND plan_id = $2
AND status = 'active'
AND deleted_at IS NULL
AND end_at > NOW()
)
`, tenantID, planID)
`, tenantID, planID, duration.String())
return wrapSeedErr("insert subscription", err)
}
func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64) error {
func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64, total int) error {
_, err := tx.Exec(ctx, `
INSERT INTO tenant_quota_ledgers (tenant_id, quota_type, delta, balance_after, reason)
SELECT $1, 'article_generation', 100, 100, 'initial_allocation'
SELECT $1, 'article_generation', $2, $2, 'initial_allocation'
WHERE NOT EXISTS (
SELECT 1
FROM tenant_quota_ledgers
@@ -414,7 +432,7 @@ func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64) error {
AND quota_type = 'article_generation'
AND reason = 'initial_allocation'
)
`, tenantID)
`, tenantID, total)
return wrapSeedErr("insert quota", err)
}
+26
View File
@@ -59,6 +59,32 @@ monitoring_workers:
result_ingest_concurrency: 4
projection_rebuild_concurrency: 2
membership:
default_plan_code: free
plans:
- code: free
name: Free
article_generation: 3
article_quota_cycle: lifetime
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
contact_admin_on_expiry: true
- code: plus
name: Plus
article_generation: 200
article_quota_cycle: monthly
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
- code: pro
name: Pro
article_generation: 400
article_quota_cycle: monthly
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
+26
View File
@@ -69,6 +69,32 @@ monitoring_workers:
result_ingest_concurrency: 4
projection_rebuild_concurrency: 2
membership:
default_plan_code: free
plans:
- code: free
name: Free
article_generation: 3
article_quota_cycle: lifetime
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
contact_admin_on_expiry: true
- code: plus
name: Plus
article_generation: 200
article_quota_cycle: monthly
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
- code: pro
name: Pro
article_generation: 400
article_quota_cycle: monthly
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
+23
View File
@@ -75,6 +75,11 @@ func New(configPath string) (*App, error) {
return nil, fmt.Errorf("init postgres: %w", err)
}
if err := syncMembershipPlans(ctx, pool, cfg.Membership); err != nil {
pool.Close()
return nil, fmt.Errorf("sync membership plans: %w", err)
}
monitoringPool, err := postgres.NewPool(ctx, cfg.MonitoringDatabase)
if err != nil {
pool.Close()
@@ -175,6 +180,24 @@ func New(configPath string) (*App, error) {
}, nil
}
// syncMembershipPlans upserts all configured plans in a single transaction so
// that a partial failure (or concurrent startup) never leaves the `plans` table
// in a half-synced state.
func syncMembershipPlans(ctx context.Context, pool *pgxpool.Pool, membership config.MembershipConfig) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if err := repository.NewTenantPlanRepository(tx).UpsertConfiguredPlans(ctx, membership); err != nil {
return err
}
return tx.Commit(ctx)
}
func (a *App) Close() {
if a.AuditLogs != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+255 -4
View File
@@ -1,6 +1,7 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
@@ -16,6 +17,7 @@ type Config struct {
RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"`
Membership MembershipConfig `mapstructure:"membership"`
BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"`
Redis RedisConfig `mapstructure:"redis"`
Qdrant QdrantConfig `mapstructure:"qdrant"`
@@ -110,6 +112,64 @@ type MonitoringConfig struct {
ProjectionRebuildConcurrency int `mapstructure:"projection_rebuild_concurrency"`
}
const (
ArticleQuotaCycleLifetime = "lifetime"
ArticleQuotaCycleMonthly = "monthly"
)
type MembershipConfig struct {
DefaultPlanCode string `mapstructure:"default_plan_code"`
Plans []MembershipPlanConfig `mapstructure:"plans"`
}
type MembershipPlanConfig struct {
Code string `mapstructure:"code"`
Name string `mapstructure:"name"`
ArticleGeneration int `mapstructure:"article_generation"`
ArticleQuotaCycle string `mapstructure:"article_quota_cycle"`
ImageStorageBytes int64 `mapstructure:"image_storage_bytes"`
CompanyLimit int `mapstructure:"company_limit"`
SubscriptionDuration time.Duration `mapstructure:"subscription_duration"`
ContactAdminOnExpiry bool `mapstructure:"contact_admin_on_expiry"`
}
type PlanQuotaPolicy struct {
ArticleGeneration int `json:"article_generation"`
ArticleQuotaCycle string `json:"article_quota_cycle"`
ImageStorageBytes int64 `json:"image_storage_bytes"`
BrandLimit int `json:"brand_limit"`
SubscriptionDurationSecs int64 `json:"subscription_duration_seconds,omitempty"`
ContactAdminOnExpiry bool `json:"contact_admin_on_expiry,omitempty"`
}
func (c MembershipConfig) FindPlan(code string) (MembershipPlanConfig, bool) {
normalizedCode := strings.ToLower(strings.TrimSpace(code))
for _, plan := range c.Plans {
if strings.EqualFold(strings.TrimSpace(plan.Code), normalizedCode) {
return plan, true
}
}
return MembershipPlanConfig{}, false
}
func (p MembershipPlanConfig) QuotaPolicy() PlanQuotaPolicy {
policy := PlanQuotaPolicy{
ArticleGeneration: maxInt(p.ArticleGeneration, 0),
ArticleQuotaCycle: normalizeArticleQuotaCycle(p.ArticleQuotaCycle, p.Code),
ImageStorageBytes: maxInt64(p.ImageStorageBytes, 0),
BrandLimit: maxInt(p.CompanyLimit, 0),
ContactAdminOnExpiry: p.ContactAdminOnExpiry,
}
if p.SubscriptionDuration > 0 {
policy.SubscriptionDurationSecs = int64(p.SubscriptionDuration / time.Second)
}
return policy
}
func (p MembershipPlanConfig) QuotaPolicyJSON() ([]byte, error) {
return json.Marshal(p.QuotaPolicy())
}
type BrandLibraryConfig struct {
FreeBrandLimit int `mapstructure:"free_brand_limit"`
PaidBrandLimit int `mapstructure:"paid_brand_limit"`
@@ -196,17 +256,15 @@ type GenerationConfig struct {
func Load(configPath string) (*Config, error) {
v := viper.New()
v.SetConfigFile(configPath)
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if err := v.ReadInConfig(); err != nil {
if err := readConfigWithFallback(v, configPath); err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
// Allow local overrides
v.SetConfigFile(strings.Replace(configPath, ".yaml", ".local.yaml", 1))
_ = v.MergeInConfig()
_ = mergeLocalOverrideWithFallback(v, configPath)
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
@@ -217,11 +275,77 @@ func Load(configPath string) (*Config, error) {
normalizeRabbitMQConfig(&cfg.RabbitMQ)
normalizeSchedulerConfig(&cfg.Scheduler)
normalizeMonitoringConfig(&cfg.MonitoringWorkers)
normalizeMembershipConfig(&cfg.Membership)
normalizeBrandLibraryConfig(&cfg.BrandLibrary)
return &cfg, nil
}
func readConfigWithFallback(v *viper.Viper, configPath string) error {
var lastErr error
for _, candidate := range candidateConfigPaths(configPath, false) {
v.SetConfigFile(candidate)
if err := v.ReadInConfig(); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
func mergeLocalOverrideWithFallback(v *viper.Viper, configPath string) error {
for _, candidate := range candidateConfigPaths(configPath, true) {
v.SetConfigFile(candidate)
if err := v.MergeInConfig(); err == nil {
return nil
}
}
return nil
}
func candidateConfigPaths(configPath string, local bool) []string {
trimmed := strings.TrimSpace(configPath)
if trimmed == "" {
return nil
}
paths := make([]string, 0, 2)
switch {
case strings.HasSuffix(trimmed, ".yaml"):
if local {
paths = append(paths, strings.TrimSuffix(trimmed, ".yaml")+".local.yaml")
} else {
paths = append(paths, trimmed)
}
alt := strings.TrimSuffix(trimmed, ".yaml")
if local {
paths = append(paths, alt+".local.yml")
} else {
paths = append(paths, alt+".yml")
}
case strings.HasSuffix(trimmed, ".yml"):
if local {
paths = append(paths, strings.TrimSuffix(trimmed, ".yml")+".local.yml")
} else {
paths = append(paths, trimmed)
}
alt := strings.TrimSuffix(trimmed, ".yml")
if local {
paths = append(paths, alt+".local.yaml")
} else {
paths = append(paths, alt+".yaml")
}
default:
if local {
paths = append(paths, trimmed+".local.yaml", trimmed+".local.yml")
} else {
paths = append(paths, trimmed, trimmed+".yaml", trimmed+".yml")
}
}
return paths
}
func applyEnvOverrides(cfg *Config) {
if cfg == nil {
return
@@ -457,6 +581,119 @@ func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) {
}
}
func normalizeMembershipConfig(cfg *MembershipConfig) {
if cfg == nil {
return
}
if strings.TrimSpace(cfg.DefaultPlanCode) == "" {
cfg.DefaultPlanCode = "free"
}
if len(cfg.Plans) == 0 {
cfg.Plans = defaultMembershipPlans()
}
normalized := make([]MembershipPlanConfig, 0, len(cfg.Plans))
seen := make(map[string]struct{}, len(cfg.Plans))
for _, plan := range cfg.Plans {
plan.Code = strings.ToLower(strings.TrimSpace(plan.Code))
if plan.Code == "" {
continue
}
if _, exists := seen[plan.Code]; exists {
continue
}
if strings.TrimSpace(plan.Name) == "" {
plan.Name = defaultPlanName(plan.Code)
}
plan.ArticleQuotaCycle = normalizeArticleQuotaCycle(plan.ArticleQuotaCycle, plan.Code)
if plan.ArticleGeneration < 0 {
plan.ArticleGeneration = 0
}
if plan.ImageStorageBytes < 0 {
plan.ImageStorageBytes = 0
}
if plan.CompanyLimit < 0 {
plan.CompanyLimit = 0
}
if plan.SubscriptionDuration < 0 {
plan.SubscriptionDuration = 0
}
normalized = append(normalized, plan)
seen[plan.Code] = struct{}{}
}
if len(normalized) == 0 {
normalized = defaultMembershipPlans()
}
cfg.Plans = normalized
if _, ok := cfg.FindPlan(cfg.DefaultPlanCode); !ok {
cfg.DefaultPlanCode = cfg.Plans[0].Code
}
}
func defaultMembershipPlans() []MembershipPlanConfig {
return []MembershipPlanConfig{
{
Code: "free",
Name: "Free",
ArticleGeneration: 3,
ArticleQuotaCycle: ArticleQuotaCycleLifetime,
ImageStorageBytes: 10 * 1024 * 1024,
CompanyLimit: 1,
SubscriptionDuration: 72 * time.Hour,
ContactAdminOnExpiry: true,
},
{
Code: "plus",
Name: "Plus",
ArticleGeneration: 200,
ArticleQuotaCycle: ArticleQuotaCycleMonthly,
ImageStorageBytes: 500 * 1024 * 1024,
CompanyLimit: 1,
SubscriptionDuration: 720 * time.Hour,
},
{
Code: "pro",
Name: "Pro",
ArticleGeneration: 400,
ArticleQuotaCycle: ArticleQuotaCycleMonthly,
ImageStorageBytes: 1024 * 1024 * 1024,
CompanyLimit: 2,
SubscriptionDuration: 720 * time.Hour,
},
}
}
func normalizeArticleQuotaCycle(value, planCode string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case ArticleQuotaCycleLifetime:
return ArticleQuotaCycleLifetime
case ArticleQuotaCycleMonthly:
return ArticleQuotaCycleMonthly
default:
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
return ArticleQuotaCycleLifetime
}
return ArticleQuotaCycleMonthly
}
}
func defaultPlanName(code string) string {
switch strings.ToLower(strings.TrimSpace(code)) {
case "free":
return "Free"
case "plus":
return "Plus"
case "pro":
return "Pro"
default:
return strings.ToUpper(strings.TrimSpace(code))
}
}
func lookupFirstNonEmptyEnv(keys ...string) (string, bool) {
for _, key := range keys {
if value, ok := lookupNonEmptyEnv(key); ok {
@@ -495,3 +732,17 @@ func lookupBoolEnv(key string) (bool, bool) {
return false, false
}
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback
}
return value
}
func maxInt64(value, fallback int64) int64 {
if value < fallback {
return fallback
}
return value
}
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadPrefersEnvLLMAPIKey(t *testing.T) {
@@ -113,6 +114,55 @@ brand_library: {}
}
}
func TestLoadAppliesMembershipDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
membership: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Membership.DefaultPlanCode != "free" {
t.Fatalf("expected default plan code free, got %q", cfg.Membership.DefaultPlanCode)
}
if len(cfg.Membership.Plans) != 3 {
t.Fatalf("expected 3 default plans, got %d", len(cfg.Membership.Plans))
}
free, ok := cfg.Membership.FindPlan("free")
if !ok {
t.Fatalf("expected free plan to exist")
}
if free.ArticleGeneration != 3 {
t.Fatalf("expected free article generation 3, got %d", free.ArticleGeneration)
}
if free.ImageStorageBytes != 10*1024*1024 {
t.Fatalf("expected free image storage 10MB, got %d", free.ImageStorageBytes)
}
if free.CompanyLimit != 1 {
t.Fatalf("expected free company limit 1, got %d", free.CompanyLimit)
}
if free.SubscriptionDuration != 72*time.Hour {
t.Fatalf("expected free subscription duration 72h, got %s", free.SubscriptionDuration)
}
if !free.ContactAdminOnExpiry {
t.Fatalf("expected free plan to require admin contact on expiry")
}
pro, ok := cfg.Membership.FindPlan("pro")
if !ok {
t.Fatalf("expected pro plan to exist")
}
if pro.ArticleGeneration != 400 {
t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration)
}
if pro.CompanyLimit != 2 {
t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit)
}
}
func writeTestConfig(t *testing.T, body string) string {
t.Helper()
@@ -14,6 +14,7 @@ import (
type AuthService struct {
repo repository.AuthRepository
plans repository.TenantPlanRepository
kolProfiles repository.KolProfileRepository
jwt *auth.Manager
sessions *auth.SessionStore
@@ -21,12 +22,14 @@ type AuthService struct {
func NewAuthService(
repo repository.AuthRepository,
plans repository.TenantPlanRepository,
kolProfiles repository.KolProfileRepository,
jwt *auth.Manager,
sessions *auth.SessionStore,
) *AuthService {
return &AuthService{
repo: repo,
plans: plans,
kolProfiles: kolProfiles,
jwt: jwt,
sessions: sessions,
@@ -53,9 +56,25 @@ type UserInfo struct {
TenantID int64 `json:"tenant_id"`
TenantRole string `json:"tenant_role"`
Permissions []string `json:"permissions"`
Membership *MembershipInfo `json:"membership"`
KolProfile *KolProfileBrief `json:"kol_profile"`
}
type MembershipInfo struct {
PlanCode string `json:"plan_code"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
Blocked bool `json:"blocked"`
BlockedReason *string `json:"blocked_reason"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
ArticleGenerationLimit int `json:"article_generation_limit"`
ArticleQuotaCycle string `json:"article_quota_cycle"`
CompanyLimit int `json:"company_limit"`
ImageStorageBytes int64 `json:"image_storage_bytes"`
ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"`
}
type KolProfileBrief struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
@@ -92,6 +111,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
if err != nil {
return nil, err
}
memberInfo, err := s.loadMembershipInfo(ctx, membership.TenantID)
if err != nil {
return nil, err
}
return &LoginResponse{
AccessToken: pair.AccessToken,
@@ -105,6 +128,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
TenantID: membership.TenantID,
TenantRole: membership.TenantRole,
Permissions: auth.PermissionsForRole(membership.TenantRole),
Membership: memberInfo,
KolProfile: kolProfile,
},
}, nil
@@ -166,6 +190,10 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
if err != nil {
return nil, err
}
memberInfo, err := s.loadMembershipInfo(ctx, actor.TenantID)
if err != nil {
return nil, err
}
return &UserInfo{
ID: user.ID,
@@ -175,6 +203,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
TenantID: actor.TenantID,
TenantRole: actor.Role,
Permissions: auth.PermissionsForRole(actor.Role),
Membership: memberInfo,
KolProfile: kolProfile,
}, nil
}
@@ -200,6 +229,59 @@ func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID
}, nil
}
func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (*MembershipInfo, error) {
if s.plans == nil {
return nil, nil
}
now := time.Now().UTC()
access, err := s.plans.GetTenantPlanAccess(ctx, tenantID, now)
if err != nil {
return nil, err
}
if access == nil {
reason := "subscription_required"
return &MembershipInfo{
Status: "inactive",
Blocked: true,
BlockedReason: &reason,
}, nil
}
status := "active"
if !access.HasActiveAccess(now) {
status = "expired"
}
blockedReason := access.BlockedReason(now)
var blockedReasonPtr *string
if blockedReason != "" {
blockedReasonPtr = &blockedReason
}
return &MembershipInfo{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Status: status,
Blocked: !access.HasActiveAccess(now),
BlockedReason: blockedReasonPtr,
StartAt: formatTimeValuePtr(access.StartAt),
EndAt: formatTimeValuePtr(access.EndAt),
ArticleGenerationLimit: access.ArticleGenerationLimit(),
ArticleQuotaCycle: access.ArticleQuotaCycle(),
CompanyLimit: access.BrandLimit(),
ImageStorageBytes: access.ImageStorageBytes(),
ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry,
}, nil
}
func formatTimeValuePtr(value time.Time) *string {
if value.IsZero() {
return nil
}
formatted := value.UTC().Format(time.RFC3339)
return &formatted
}
func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
claims, err := s.jwt.Parse(accessToken)
if err != nil {
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -787,8 +788,9 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
}
type brandLibraryPlan struct {
PlanCode string
PlanName string
PlanCode string
PlanName string
MaxBrands int
}
type brandLibraryUsage struct {
@@ -808,6 +810,9 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
}
maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode)
if plan.MaxBrands > 0 {
maxBrands = plan.MaxBrands
}
maxKeywords := s.limits.MaxKeywords
return &BrandLibrarySummaryResponse{
@@ -825,26 +830,39 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
plan := &brandLibraryPlan{
PlanCode: "free",
PlanName: "",
PlanCode: "free",
PlanName: "",
MaxBrands: s.limits.BrandLimitForPlan("free"),
}
var quotaPolicyJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT p.plan_code, p.name
SELECT p.plan_code, p.name, p.quota_policy_json
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
AND p.status = 'active'
AND s.end_at > $2
ORDER BY s.start_at DESC
LIMIT 1
`, tenantID).Scan(&plan.PlanCode, &plan.PlanName)
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, &quotaPolicyJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return plan, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
}
var quotaPolicy struct {
BrandLimit int `json:"brand_limit"`
}
if len(quotaPolicyJSON) > 0 {
_ = json.Unmarshal(quotaPolicyJSON, &quotaPolicy)
}
if quotaPolicy.BrandLimit > 0 {
plan.MaxBrands = quotaPolicy.BrandLimit
}
return plan, nil
}
@@ -771,6 +771,7 @@ JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1
`, tenantID, defaultImageQuotaBytes).Scan(&quotaBytes)
if err != nil {
+23 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/sync/singleflight"
@@ -18,13 +19,23 @@ import (
type WorkspaceService struct {
repo repository.WorkspaceRepository
templates repository.TemplateRepository
quota repository.QuotaRepository
supportedPlatformCount int
cache sharedcache.Cache
cacheGroup singleflight.Group
}
func NewWorkspaceService(repo repository.WorkspaceRepository, templates repository.TemplateRepository) *WorkspaceService {
return &WorkspaceService{repo: repo, templates: templates, supportedPlatformCount: 5}
func NewWorkspaceService(
repo repository.WorkspaceRepository,
templates repository.TemplateRepository,
quota repository.QuotaRepository,
) *WorkspaceService {
return &WorkspaceService{
repo: repo,
templates: templates,
quota: quota,
supportedPlatformCount: 5,
}
}
func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService {
@@ -87,29 +98,21 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA
func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) {
actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) {
balance, err := s.repo.GetQuotaSummary(loadCtx, actor.TenantID)
status, err := s.quota.GetArticleQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &domain.QuotaSummary{Balance: 0}, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance")
}
plan, err := s.repo.GetActivePlanForTenant(loadCtx, actor.TenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &domain.QuotaSummary{Balance: balance}, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to get active plan")
}
var policy map[string]int
_ = json.Unmarshal(plan.QuotaPolicyJSON, &policy)
total := policy["article_generation"]
return &domain.QuotaSummary{
PlanCode: plan.PlanCode,
PlanName: plan.PlanName,
TotalQuota: total,
UsedQuota: total - balance,
Balance: balance,
PlanCode: status.PlanCode,
PlanName: status.PlanName,
TotalQuota: status.Total,
UsedQuota: status.Used,
Balance: status.Balance,
ResetAt: status.ResetAt,
}, nil
})
}
@@ -53,6 +53,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json,
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
AND s.end_at > NOW()
LIMIT 1
`
@@ -65,6 +65,7 @@ WITH plan AS (
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1
)
UPDATE tenant_image_storage_usage u
@@ -45,6 +45,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json,
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.status = 'active' AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1;
-- name: ListKolCardsForTenant :many
@@ -2,11 +2,23 @@ package repository
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ArticleQuotaStatus struct {
PlanCode string
PlanName string
Total int
Used int
Balance int
ResetAt *time.Time
}
type QuotaLedgerInput struct {
TenantID int64
OperatorID int64
@@ -30,6 +42,7 @@ type QuotaReservationInput struct {
type QuotaRepository interface {
GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error)
GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error)
InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error)
CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error)
UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error
@@ -38,22 +51,90 @@ type QuotaRepository interface {
}
type quotaRepository struct {
q generated.Querier
q generated.Querier
db generated.DBTX
}
func NewQuotaRepository(db generated.DBTX) QuotaRepository {
return &quotaRepository{q: newQuerier(db)}
return &quotaRepository{q: newQuerier(db), db: db}
}
func (r *quotaRepository) GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) {
balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{
TenantID: tenantID,
QuotaType: quotaType,
})
if quotaType != "article_generation" {
balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{
TenantID: tenantID,
QuotaType: quotaType,
})
if err != nil {
return 0, err
}
return int(balance), nil
}
status, err := r.GetArticleQuotaStatus(ctx, tenantID, time.Now().UTC())
if err != nil {
return 0, err
}
return int(balance), nil
return status.Balance, nil
}
func (r *quotaRepository) GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error) {
access, err := loadTenantPlanAccess(ctx, r.db, tenantID, now)
if err != nil {
return nil, err
}
if access == nil {
return nil, pgx.ErrNoRows
}
// Non-HTTP callers (scheduler, workers) bypass subscription_guard, so any
// code path that consults article quota must itself enforce that the
// subscription is currently active. Return a zero-balance status when the
// plan is expired/inactive/not-yet-active so the caller short-circuits on
// insufficient balance instead of dispatching generation.
if !access.HasActiveAccess(now) {
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: 0,
Used: 0,
Balance: 0,
}, nil
}
total := access.ArticleGenerationLimit()
if total <= 0 {
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: 0,
Used: 0,
Balance: 0,
}, nil
}
windowStart, windowEnd, resetAt := access.ArticleQuotaWindow(now)
if windowEnd.IsZero() {
return nil, errors.New("article quota window end is missing")
}
used, err := CountEffectiveQuotaReservations(ctx, r.db, tenantID, "article_generation", windowStart, windowEnd)
if err != nil {
return nil, err
}
balance := total - used
if balance < 0 {
balance = 0
}
return &ArticleQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: total,
Used: used,
Balance: balance,
ResetAt: resetAt,
}, nil
}
func (r *quotaRepository) InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error) {
@@ -0,0 +1,326 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
const (
tenantPlanBlockedReasonRequired = "subscription_required"
tenantPlanBlockedReasonExpired = "trial_plan_expired"
tenantPlanBlockedReasonInactive = "subscription_inactive"
defaultArticleQuotaFallback = 0
)
type TenantPlanAccess struct {
SubscriptionID int64
PlanID int64
PlanCode string
PlanName string
PlanStatus string
SubscriptionStatus string
StartAt time.Time
EndAt time.Time
Policy sharedconfig.PlanQuotaPolicy
}
func (a *TenantPlanAccess) HasActiveAccess(now time.Time) bool {
if a == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(a.PlanStatus), "active") {
return false
}
if !strings.EqualFold(strings.TrimSpace(a.SubscriptionStatus), "active") {
return false
}
nowUTC := now.UTC()
if !a.StartAt.IsZero() && a.StartAt.After(nowUTC) {
return false
}
if a.EndAt.IsZero() {
return true
}
return a.EndAt.After(nowUTC)
}
func (a *TenantPlanAccess) BlockedReason(now time.Time) string {
if a == nil {
return tenantPlanBlockedReasonRequired
}
if a.HasActiveAccess(now) {
return ""
}
if strings.EqualFold(strings.TrimSpace(a.PlanCode), "free") && !a.EndAt.IsZero() && !a.EndAt.After(now.UTC()) {
return tenantPlanBlockedReasonExpired
}
return tenantPlanBlockedReasonInactive
}
func (a *TenantPlanAccess) ArticleGenerationLimit() int {
if a == nil {
return defaultArticleQuotaFallback
}
return maxInt(a.Policy.ArticleGeneration, 0)
}
func (a *TenantPlanAccess) BrandLimit() int {
if a == nil {
return 0
}
return maxInt(a.Policy.BrandLimit, 0)
}
func (a *TenantPlanAccess) ImageStorageBytes() int64 {
if a == nil {
return 0
}
return maxInt64(a.Policy.ImageStorageBytes, 0)
}
func (a *TenantPlanAccess) ArticleQuotaCycle() string {
if a == nil {
return sharedconfig.ArticleQuotaCycleLifetime
}
switch strings.ToLower(strings.TrimSpace(a.Policy.ArticleQuotaCycle)) {
case sharedconfig.ArticleQuotaCycleMonthly:
return sharedconfig.ArticleQuotaCycleMonthly
default:
return sharedconfig.ArticleQuotaCycleLifetime
}
}
func (a *TenantPlanAccess) ArticleQuotaWindow(now time.Time) (time.Time, time.Time, *time.Time) {
if a == nil {
return time.Time{}, time.Time{}, nil
}
windowStart := a.StartAt.UTC()
windowEnd := a.EndAt.UTC()
if a.ArticleQuotaCycle() != sharedconfig.ArticleQuotaCycleMonthly {
return windowStart, windowEnd, nil
}
cycleStart, cycleEnd := monthlyCycleBounds(windowStart, now.UTC())
if cycleStart.Before(windowStart) {
cycleStart = windowStart
}
if !windowEnd.IsZero() && cycleEnd.After(windowEnd) {
cycleEnd = windowEnd
}
resetAt := cycleEnd
return cycleStart, cycleEnd, &resetAt
}
type TenantPlanRepository interface {
UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error
GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error)
}
type tenantPlanRepository struct {
db generated.DBTX
}
func NewTenantPlanRepository(db generated.DBTX) TenantPlanRepository {
return &tenantPlanRepository{db: db}
}
func (r *tenantPlanRepository) UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error {
for _, plan := range membership.Plans {
policyJSON, err := plan.QuotaPolicyJSON()
if err != nil {
return err
}
if _, err := r.db.Exec(ctx, `
INSERT INTO plans (plan_code, name, quota_policy_json, status)
VALUES ($1, $2, $3::jsonb, 'active')
ON CONFLICT (plan_code) WHERE deleted_at IS NULL
DO UPDATE SET
name = EXCLUDED.name,
quota_policy_json = EXCLUDED.quota_policy_json,
status = 'active',
updated_at = NOW()
`, plan.Code, plan.Name, policyJSON); err != nil {
return err
}
}
return nil
}
func (r *tenantPlanRepository) GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error) {
return loadTenantPlanAccess(ctx, r.db, tenantID, now)
}
func loadTenantPlanAccess(ctx context.Context, db generated.DBTX, tenantID int64, now time.Time) (*TenantPlanAccess, error) {
row := db.QueryRow(ctx, `
SELECT
s.id,
s.plan_id,
p.plan_code,
p.name,
p.status,
s.status,
s.start_at,
s.end_at,
p.quota_policy_json
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.deleted_at IS NULL
AND p.deleted_at IS NULL
ORDER BY
CASE
WHEN s.status = 'active' AND p.status = 'active' AND s.start_at <= $2 AND s.end_at > $2 THEN 0
WHEN s.status = 'active' AND s.end_at <= $2 THEN 1
ELSE 2
END,
s.start_at DESC,
s.id DESC
LIMIT 1
`, tenantID, now.UTC())
var access TenantPlanAccess
var rawPolicy []byte
if err := row.Scan(
&access.SubscriptionID,
&access.PlanID,
&access.PlanCode,
&access.PlanName,
&access.PlanStatus,
&access.SubscriptionStatus,
&access.StartAt,
&access.EndAt,
&rawPolicy,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
access.StartAt = access.StartAt.UTC()
access.EndAt = access.EndAt.UTC()
access.Policy = sharedconfig.PlanQuotaPolicy{}
if len(rawPolicy) > 0 {
if err := json.Unmarshal(rawPolicy, &access.Policy); err != nil {
return nil, err
}
}
access.Policy.ArticleQuotaCycle = normalizeQuotaCycle(access.Policy.ArticleQuotaCycle, access.PlanCode)
return &access, nil
}
func CountEffectiveQuotaReservations(
ctx context.Context,
db generated.DBTX,
tenantID int64,
quotaType string,
startAt, endAt time.Time,
) (int, error) {
row := db.QueryRow(ctx, `
SELECT COALESCE(
SUM(
CASE
WHEN status IN ('pending', 'confirmed') THEN reserved_amount
ELSE 0
END
),
0
)::INT
FROM quota_reservations
WHERE tenant_id = $1
AND quota_type = $2
AND created_at >= $3
AND created_at < $4
`, tenantID, quotaType, startAt.UTC(), endAt.UTC())
var used int
if err := row.Scan(&used); err != nil {
return 0, err
}
return used, nil
}
func monthlyCycleBounds(anchor, now time.Time) (time.Time, time.Time) {
start := anchor.UTC()
current := now.UTC()
if current.Before(start) {
return start, addMonthsPreserveAnchor(start, 1)
}
months := (current.Year()-start.Year())*12 + int(current.Month()-start.Month())
cycleStart := addMonthsPreserveAnchor(start, months)
for cycleStart.After(current) {
months--
cycleStart = addMonthsPreserveAnchor(start, months)
}
cycleEnd := addMonthsPreserveAnchor(start, months+1)
for !cycleEnd.After(current) {
months++
cycleStart = addMonthsPreserveAnchor(start, months)
cycleEnd = addMonthsPreserveAnchor(start, months+1)
}
return cycleStart, cycleEnd
}
// addMonthsPreserveAnchor advances anchor by n months while clamping the
// resulting day to the target month's last day. This avoids Go's time.AddDate
// drift where e.g. Jan 31 + 1 month becomes Mar 3 instead of Feb 28.
func addMonthsPreserveAnchor(anchor time.Time, n int) time.Time {
if n == 0 {
return anchor
}
y := anchor.Year()
m := int(anchor.Month()) + n
for m > 12 {
y++
m -= 12
}
for m < 1 {
y--
m += 12
}
// Last day of the target month: day 0 of the following month.
lastDay := time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, anchor.Location()).Day()
day := anchor.Day()
if day > lastDay {
day = lastDay
}
return time.Date(y, time.Month(m), day, anchor.Hour(), anchor.Minute(), anchor.Second(), anchor.Nanosecond(), anchor.Location())
}
func normalizeQuotaCycle(value, planCode string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case sharedconfig.ArticleQuotaCycleMonthly:
return sharedconfig.ArticleQuotaCycleMonthly
default:
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
return sharedconfig.ArticleQuotaCycleLifetime
}
return sharedconfig.ArticleQuotaCycleMonthly
}
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback
}
return value
}
func maxInt64(value, fallback int64) int64 {
if value < fallback {
return fallback
}
return value
}
@@ -0,0 +1,263 @@
package repository
import (
"testing"
"time"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
)
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed.UTC()
}
func TestHasActiveAccess(t *testing.T) {
now := mustParseTime(t, "2026-04-18T12:00:00Z")
cases := []struct {
name string
access *TenantPlanAccess
want bool
}{
{name: "nil access", access: nil, want: false},
{
name: "plan inactive",
access: &TenantPlanAccess{
PlanStatus: "inactive",
SubscriptionStatus: "active",
StartAt: now.Add(-time.Hour),
EndAt: now.Add(time.Hour),
},
want: false,
},
{
name: "subscription inactive",
access: &TenantPlanAccess{
PlanStatus: "active",
SubscriptionStatus: "cancelled",
StartAt: now.Add(-time.Hour),
EndAt: now.Add(time.Hour),
},
want: false,
},
{
name: "future start_at is blocked",
access: &TenantPlanAccess{
PlanStatus: "active",
SubscriptionStatus: "active",
StartAt: now.Add(time.Hour),
EndAt: now.Add(24 * time.Hour),
},
want: false,
},
{
name: "expired end_at",
access: &TenantPlanAccess{
PlanStatus: "active",
SubscriptionStatus: "active",
StartAt: now.Add(-48 * time.Hour),
EndAt: now.Add(-time.Hour),
},
want: false,
},
{
name: "within window",
access: &TenantPlanAccess{
PlanStatus: "active",
SubscriptionStatus: "active",
StartAt: now.Add(-time.Hour),
EndAt: now.Add(time.Hour),
},
want: true,
},
{
name: "zero end_at is perpetual",
access: &TenantPlanAccess{
PlanStatus: "active",
SubscriptionStatus: "active",
StartAt: now.Add(-time.Hour),
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.access.HasActiveAccess(now); got != tc.want {
t.Fatalf("HasActiveAccess = %v, want %v", got, tc.want)
}
})
}
}
func TestBlockedReason(t *testing.T) {
now := mustParseTime(t, "2026-04-18T12:00:00Z")
cases := []struct {
name string
access *TenantPlanAccess
want string
}{
{name: "nil -> required", access: nil, want: "subscription_required"},
{
name: "active access -> empty",
access: &TenantPlanAccess{
PlanStatus: "active", SubscriptionStatus: "active",
StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour),
},
want: "",
},
{
name: "free expired -> trial_plan_expired",
access: &TenantPlanAccess{
PlanCode: "free", PlanStatus: "active", SubscriptionStatus: "active",
StartAt: now.Add(-96 * time.Hour), EndAt: now.Add(-time.Hour),
},
want: "trial_plan_expired",
},
{
name: "paid expired -> inactive",
access: &TenantPlanAccess{
PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active",
StartAt: now.Add(-30 * 24 * time.Hour), EndAt: now.Add(-time.Hour),
},
want: "subscription_inactive",
},
{
name: "future start_at -> inactive (not expired)",
access: &TenantPlanAccess{
PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active",
StartAt: now.Add(time.Hour), EndAt: now.Add(48 * time.Hour),
},
want: "subscription_inactive",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.access.BlockedReason(now); got != tc.want {
t.Fatalf("BlockedReason = %q, want %q", got, tc.want)
}
})
}
}
func TestAddMonthsPreserveAnchor(t *testing.T) {
cases := []struct {
name string
anchor string
n int
want string
}{
{name: "jan 31 + 1 month clamps to feb 28 (non-leap)", anchor: "2026-01-31T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"},
{name: "jan 31 + 1 month clamps to feb 29 (leap)", anchor: "2024-01-31T09:00:00Z", n: 1, want: "2024-02-29T09:00:00Z"},
{name: "jan 31 + 2 months -> mar 31", anchor: "2026-01-31T09:00:00Z", n: 2, want: "2026-03-31T09:00:00Z"},
{name: "jan 30 + 1 month clamps to feb 28", anchor: "2026-01-30T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"},
{name: "mar 31 + 1 month clamps to apr 30", anchor: "2026-03-31T09:00:00Z", n: 1, want: "2026-04-30T09:00:00Z"},
{name: "cross year", anchor: "2026-12-15T00:00:00Z", n: 2, want: "2027-02-15T00:00:00Z"},
{name: "n=0 returns anchor", anchor: "2026-04-18T12:00:00Z", n: 0, want: "2026-04-18T12:00:00Z"},
{name: "negative step backwards", anchor: "2026-03-31T09:00:00Z", n: -1, want: "2026-02-28T09:00:00Z"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
anchor := mustParseTime(t, tc.anchor)
want := mustParseTime(t, tc.want)
got := addMonthsPreserveAnchor(anchor, tc.n)
if !got.Equal(want) {
t.Fatalf("addMonthsPreserveAnchor(%s, %d) = %s, want %s", tc.anchor, tc.n, got.Format(time.RFC3339), want.Format(time.RFC3339))
}
})
}
}
func TestMonthlyCycleBoundsMonthEndAnchor(t *testing.T) {
// Subscription anchored on Jan 31; current time inside Feb. Legacy
// AddDate-based implementation drifted into March (Feb 31 -> Mar 3).
anchor := mustParseTime(t, "2026-01-31T09:00:00Z")
now := mustParseTime(t, "2026-02-15T00:00:00Z")
start, end := monthlyCycleBounds(anchor, now)
wantStart := mustParseTime(t, "2026-01-31T09:00:00Z")
wantEnd := mustParseTime(t, "2026-02-28T09:00:00Z")
if !start.Equal(wantStart) {
t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339))
}
if !end.Equal(wantEnd) {
t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), wantEnd.Format(time.RFC3339))
}
}
func TestMonthlyCycleBoundsContainsNowAcrossLeap(t *testing.T) {
anchor := mustParseTime(t, "2024-01-31T00:00:00Z")
cases := []struct {
name string
now string
wantStart string
wantEnd string
}{
{name: "late jan", now: "2024-01-31T12:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"},
{name: "mid feb leap", now: "2024-02-15T00:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"},
{name: "early mar", now: "2024-03-01T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"},
{name: "mid mar", now: "2024-03-15T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"},
{name: "early apr clamps to 30", now: "2024-04-15T00:00:00Z", wantStart: "2024-03-31T00:00:00Z", wantEnd: "2024-04-30T00:00:00Z"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
start, end := monthlyCycleBounds(anchor, mustParseTime(t, tc.now))
if !start.Equal(mustParseTime(t, tc.wantStart)) {
t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), tc.wantStart)
}
if !end.Equal(mustParseTime(t, tc.wantEnd)) {
t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), tc.wantEnd)
}
// Invariant: now must fall within [start, end)
now := mustParseTime(t, tc.now)
if now.Before(start) || !now.Before(end) {
t.Fatalf("now %s not within [%s, %s)", tc.now, start.Format(time.RFC3339), end.Format(time.RFC3339))
}
})
}
}
func TestArticleQuotaWindowMonthlyRespectsSubscriptionBounds(t *testing.T) {
anchor := mustParseTime(t, "2026-01-31T00:00:00Z")
end := mustParseTime(t, "2027-01-31T00:00:00Z")
access := &TenantPlanAccess{
PlanCode: "pro",
PlanStatus: "active",
SubscriptionStatus: "active",
StartAt: anchor,
EndAt: end,
Policy: sharedconfig.PlanQuotaPolicy{
ArticleGeneration: 400,
ArticleQuotaCycle: sharedconfig.ArticleQuotaCycleMonthly,
BrandLimit: 2,
ImageStorageBytes: 1024 * 1024 * 1024,
},
}
now := mustParseTime(t, "2026-02-15T00:00:00Z")
start, cycleEnd, reset := access.ArticleQuotaWindow(now)
wantStart := mustParseTime(t, "2026-01-31T00:00:00Z")
wantEnd := mustParseTime(t, "2026-02-28T00:00:00Z")
if !start.Equal(wantStart) {
t.Fatalf("window start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339))
}
if !cycleEnd.Equal(wantEnd) {
t.Fatalf("window end = %s, want %s", cycleEnd.Format(time.RFC3339), wantEnd.Format(time.RFC3339))
}
if reset == nil || !reset.Equal(wantEnd) {
t.Fatalf("reset = %v, want %s", reset, wantEnd.Format(time.RFC3339))
}
}
@@ -20,6 +20,7 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
return &AuthHandler{
svc: app.NewAuthService(
repository.NewAuthRepository(a.DB),
repository.NewTenantPlanRepository(a.DB),
a.KolProfiles,
a.JWT,
a.Sessions,
+21 -17
View File
@@ -4,6 +4,7 @@ import (
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func RegisterRoutes(app *bootstrap.App) {
@@ -35,7 +36,10 @@ func RegisterRoutes(app *bootstrap.App) {
protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/logout", authHandler.Logout)
workspace := protected.Group("/tenant/workspace")
tenantProtected := protected.Group("/tenant")
tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB)))
workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app)
workspace.GET("/overview", wsHandler.Overview)
workspace.GET("/recent-articles", wsHandler.RecentArticles)
@@ -43,7 +47,7 @@ func RegisterRoutes(app *bootstrap.App) {
workspace.GET("/template-cards", wsHandler.TemplateCards)
workspace.GET("/kol-cards", wsHandler.KolCards)
templates := protected.Group("/tenant/templates")
templates := tenantProtected.Group("/templates")
tplHandler := NewTemplateHandler(app)
templates.GET("", tplHandler.List)
templates.GET("/:id", tplHandler.Detail)
@@ -56,7 +60,7 @@ func RegisterRoutes(app *bootstrap.App) {
templates.GET("/:id/gen_outline_task_result", tplHandler.GetOutlineTaskResult)
templates.POST("/:id/generate", tplHandler.Generate)
kolManage := protected.Group("/tenant/kol/manage")
kolManage := tenantProtected.Group("/kol/manage")
kolManageHandler := NewKolManageHandler(app, publicAssets)
kolManage.GET("/profile", kolManageHandler.GetProfile)
kolManage.PUT("/profile", kolManageHandler.UpdateProfile)
@@ -80,19 +84,19 @@ func RegisterRoutes(app *bootstrap.App) {
kolManage.POST("/assist/stream", kolManageHandler.AssistStream)
kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
kolDash := protected.Group("/tenant/kol/dashboard")
kolDash := tenantProtected.Group("/kol/dashboard")
kolDashboardHandler := NewKolDashboardHandler(app)
kolDash.GET("/overview", kolDashboardHandler.Overview)
kolDash.GET("/packages", kolDashboardHandler.Packages)
kolDash.GET("/trend", kolDashboardHandler.Trend)
kolMarketplace := protected.Group("/tenant/kol/marketplace")
kolMarketplace := tenantProtected.Group("/kol/marketplace")
kolMarketplaceHandler := NewKolMarketplaceHandler(app)
kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages)
kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage)
kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe)
kolSubscriptions := protected.Group("/tenant/kol")
kolSubscriptions := tenantProtected.Group("/kol")
kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions)
kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts)
kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema)
@@ -100,13 +104,13 @@ func RegisterRoutes(app *bootstrap.App) {
// V1 compromise: these admin operations live on tenant-api and are gated by
// tenant_role=tenant_admin until the platform-api/module is built.
kolAdmin := protected.Group("/tenant/kol/admin")
kolAdmin := tenantProtected.Group("/kol/admin")
kolAdminHandler := NewKolAdminHandler(app)
kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription)
kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind)
kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription)
articles := protected.Group("/tenant/articles")
articles := tenantProtected.Group("/articles")
artHandler := NewArticleHandler(app)
articles.GET("", artHandler.List)
articles.POST("", artHandler.Create)
@@ -121,14 +125,14 @@ func RegisterRoutes(app *bootstrap.App) {
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.DELETE("/:id", artHandler.Delete)
media := protected.Group("/tenant/media")
media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms)
media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts)
media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation)
media.POST("/plugin-sessions", pluginHandler.CreatePluginSession)
media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount)
brands := protected.Group("/tenant/brands")
brands := tenantProtected.Group("/brands")
brandHandler := NewBrandHandler(app)
brands.GET("", brandHandler.List)
brands.GET("/library-summary", brandHandler.Summary)
@@ -149,13 +153,13 @@ func RegisterRoutes(app *bootstrap.App) {
brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor)
brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor)
monitoring := protected.Group("/tenant/monitoring")
monitoring := tenantProtected.Group("/monitoring")
monitoringHandler := NewMonitoringHandler(app)
monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite)
monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail)
monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow)
knowledge := protected.Group("/tenant/knowledge")
knowledge := tenantProtected.Group("/knowledge")
knowledgeHandler := NewKnowledgeHandler(app)
knowledge.GET("/groups", knowledgeHandler.ListGroups)
knowledge.POST("/groups", knowledgeHandler.CreateGroup)
@@ -169,7 +173,7 @@ func RegisterRoutes(app *bootstrap.App) {
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
// Prompt Rules
promptRules := protected.Group("/tenant/prompt-rules")
promptRules := tenantProtected.Group("/prompt-rules")
prHandler := NewPromptRuleHandler(app)
promptRules.GET("", prHandler.List)
promptRules.GET("/simple", prHandler.ListSimple)
@@ -185,7 +189,7 @@ func RegisterRoutes(app *bootstrap.App) {
promptRules.DELETE("/groups/:gid", prHandler.DeleteGroup)
// Schedule Tasks
schedules := protected.Group("/tenant/schedules")
schedules := tenantProtected.Group("/schedules")
schHandler := NewScheduleTaskHandler(app)
schedules.GET("", schHandler.List)
schedules.POST("", schHandler.Create)
@@ -194,11 +198,11 @@ func RegisterRoutes(app *bootstrap.App) {
schedules.DELETE("/:id", schHandler.Delete)
schedules.PUT("/:id/status", schHandler.ToggleStatus)
instantTasks := protected.Group("/tenant/instant-tasks")
instantTasks := tenantProtected.Group("/instant-tasks")
instHandler := NewInstantTaskHandler(app)
instantTasks.GET("", instHandler.List)
images := protected.Group("/tenant/images")
images := tenantProtected.Group("/images")
imgHandler := NewImageHandler(app)
images.GET("", imgHandler.ListImages)
images.POST("", imgHandler.UploadImage)
@@ -207,7 +211,7 @@ func RegisterRoutes(app *bootstrap.App) {
images.DELETE("/:id", imgHandler.DeleteImage)
images.GET("/storage-usage", imgHandler.StorageUsage)
imageFolders := protected.Group("/tenant/images/folders")
imageFolders := tenantProtected.Group("/images/folders")
imageFolders.GET("", imgHandler.ListFolders)
imageFolders.POST("", imgHandler.CreateFolder)
imageFolders.PUT("/:id", imgHandler.UpdateFolder)
@@ -0,0 +1,63 @@
package transport
import (
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin.HandlerFunc {
return func(c *gin.Context) {
actor, ok := auth.ActorFromCtx(c.Request.Context())
if !ok {
response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated"))
c.Abort()
return
}
now := time.Now().UTC()
access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now)
if err != nil {
response.Error(c, response.ErrInternal(50091, "subscription_lookup_failed", "failed to load tenant subscription"))
c.Abort()
return
}
if access != nil && access.HasActiveAccess(now) {
c.Next()
return
}
reason := ""
if access != nil {
reason = access.BlockedReason(now)
} else {
reason = "subscription_required"
}
switch reason {
case "trial_plan_expired":
response.Error(c, response.ErrForbidden(
40382,
"trial_plan_expired",
"free trial has expired, please contact administrator to reactivate access",
))
case "subscription_required":
response.Error(c, response.ErrForbidden(
40381,
"subscription_required",
"subscription is required, please contact administrator",
))
default:
response.Error(c, response.ErrForbidden(
40383,
"subscription_inactive",
"subscription is inactive, please contact administrator",
))
}
c.Abort()
}
}
@@ -21,6 +21,7 @@ func NewWorkspaceHandler(a *bootstrap.App) *WorkspaceHandler {
repository.NewTemplateRepository(a.DB),
a.Cache,
),
repository.NewQuotaRepository(a.DB),
).WithCache(a.Cache),
}
}