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", defaultTestAccount: "Default account",
defaultTestPassword: "Default password", 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: { shell: {
localeLabel: "Language", localeLabel: "Language",
quotaTitle: "Plan & Quota", quotaTitle: "Plan & Quota",
+16
View File
@@ -95,6 +95,22 @@ const zhCN = {
defaultTestAccount: "默认测试账号", defaultTestAccount: "默认测试账号",
defaultTestPassword: "默认测试密码", defaultTestPassword: "默认测试密码",
}, },
membershipBlocked: {
eyebrow: "访问已暂停",
title: "当前租户套餐不可用",
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
plan: "当前套餐",
expiredAt: "到期时间",
articleLimit: "文章额度",
companyLimit: "公司数量",
contactAdmin: "管理员重新开通后,刷新页面即可恢复访问。",
logout: "退出登录",
refresh: "重新检查",
refreshing: "检查中…",
stillBlocked: "账户仍处于受限状态,请先联系管理员后再重试。",
},
shell: { shell: {
localeLabel: "语言", localeLabel: "语言",
quotaTitle: "套餐与额度", quotaTitle: "套餐与额度",
+3
View File
@@ -12,6 +12,9 @@ const errorMessageMap: Record<string, string> = {
tenant_scope_missing: "租户上下文缺失", tenant_scope_missing: "租户上下文缺失",
query_failed: "数据读取失败", query_failed: "数据读取失败",
quota_insufficient: "生成额度不足", quota_insufficient: "生成额度不足",
trial_plan_expired: "免费试用已到期,请联系管理员开通",
subscription_required: "当前租户尚未开通有效套餐,请联系管理员",
subscription_inactive: "当前租户套餐已失效,请联系管理员",
llm_unavailable: "生成服务不可用", llm_unavailable: "生成服务不可用",
analyze_context_required: "请先填写品牌或模板上下文信息", analyze_context_required: "请先填写品牌或模板上下文信息",
title_context_required: "请先确认品牌、关键词或竞品信息", title_context_required: "请先确认品牌、关键词或竞品信息",
+19
View File
@@ -15,6 +15,14 @@ const router = createRouter({
descriptionKey: "route.login.description", descriptionKey: "route.login.description",
}, },
}, },
{
path: "/membership-blocked",
name: "membership-blocked",
component: () => import("@/views/MembershipBlockedView.vue"),
meta: {
requiresAuth: true,
},
},
{ {
path: "/", path: "/",
component: () => import("@/layouts/AppShell.vue"), 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) { if (to.meta.requiresKol && !authStore.isActiveKol) {
return { name: "workspace" }; return { name: "workspace" };
} }
if (to.name === "login" && authStore.isAuthenticated) { if (to.name === "login" && authStore.isAuthenticated) {
if (authStore.isMembershipBlocked) {
return { name: "membership-blocked" };
}
return { name: "workspace" }; return { name: "workspace" };
} }
+4
View File
@@ -65,6 +65,8 @@ export const useAuthStore = defineStore("auth", () => {
} }
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value)); 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 kolProfile = computed(() => user.value?.kol_profile ?? null);
const isActiveKol = computed(() => kolProfile.value?.status === "active"); const isActiveKol = computed(() => kolProfile.value?.status === "active");
@@ -74,6 +76,8 @@ export const useAuthStore = defineStore("auth", () => {
user, user,
initialized, initialized,
isAuthenticated, isAuthenticated,
membership,
isMembershipBlocked,
kolProfile, kolProfile,
isActiveKol, isActiveKol,
bootstrap, 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 result_ingest_concurrency: 4
projection_rebuild_concurrency: 2 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: redis:
addr: redis:6379 addr: redis:6379
db: 0 db: 0
+16
View File
@@ -30,9 +30,25 @@ export interface UserInfo {
tenant_id: number; tenant_id: number;
tenant_role: string; tenant_role: string;
permissions: string[]; permissions: string[];
membership: MembershipInfo | null;
kol_profile: KolProfileBrief | 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 { export interface LoginResponse {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
+44 -26
View File
@@ -18,6 +18,7 @@ import (
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" 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/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/prompts" "github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
type seedState struct { type seedState struct {
@@ -119,7 +120,7 @@ func main() {
log.Fatalf("ping monitoring db: %v", err) log.Fatalf("ping monitoring db: %v", err)
} }
state, err := seedBusinessData(ctx, businessPool) state, err := seedBusinessData(ctx, businessPool, cfg.Membership)
if err != nil { if err != nil {
log.Fatalf("seed business data: %v", err) log.Fatalf("seed business data: %v", err)
} }
@@ -140,7 +141,7 @@ func main() {
fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts") 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) hash, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, fmt.Errorf("hash password: %w", err) return nil, fmt.Errorf("hash password: %w", err)
@@ -156,6 +157,18 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro
state := &seedState{} 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) state.TenantID, err = ensureTenant(ctx, tx)
if err != nil { if err != nil {
return nil, err 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 { if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil {
return nil, err return nil, err
} }
state.PlanID, err = ensurePlan(ctx, tx) state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code)
if err != nil { if err != nil {
return nil, err 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 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 return nil, err
} }
if err := ensureTemplates(ctx, tx); err != nil { if err := ensureTemplates(ctx, tx); err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -342,7 +355,12 @@ func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID i
return wrapSeedErr("insert membership", err) 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) hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("hash secondary user password: %w", err) 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 { if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil {
return 0, 0, err 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 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 0, 0, err
} }
return tenantID, userID, nil 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 var planID int64
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
WITH ensured AS ( SELECT id
INSERT INTO plans (plan_code, name, quota_policy_json, status) FROM plans
VALUES ('free', 'Free Plan', '{"article_generation": 100}', 'active') WHERE plan_code = $1
ON CONFLICT DO NOTHING AND deleted_at IS NULL
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id FROM plans WHERE plan_code = 'free'
LIMIT 1 LIMIT 1
`).Scan(&planID) `, planCode).Scan(&planID)
return planID, wrapSeedErr("insert plan", err) 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, ` _, err := tx.Exec(ctx, `
INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status) 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 ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM tenant_plan_subscriptions FROM tenant_plan_subscriptions
WHERE tenant_id = $1 WHERE tenant_id = $1
AND plan_id = $2 AND plan_id = $2
AND status = 'active' AND status = 'active'
AND deleted_at IS NULL
AND end_at > NOW()
) )
`, tenantID, planID) `, tenantID, planID, duration.String())
return wrapSeedErr("insert subscription", err) 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, ` _, err := tx.Exec(ctx, `
INSERT INTO tenant_quota_ledgers (tenant_id, quota_type, delta, balance_after, reason) 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 ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM tenant_quota_ledgers 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 quota_type = 'article_generation'
AND reason = 'initial_allocation' AND reason = 'initial_allocation'
) )
`, tenantID) `, tenantID, total)
return wrapSeedErr("insert quota", err) return wrapSeedErr("insert quota", err)
} }
+26
View File
@@ -59,6 +59,32 @@ monitoring_workers:
result_ingest_concurrency: 4 result_ingest_concurrency: 4
projection_rebuild_concurrency: 2 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: brand_library:
free_brand_limit: 1 free_brand_limit: 1
paid_brand_limit: 2 paid_brand_limit: 2
+26
View File
@@ -69,6 +69,32 @@ monitoring_workers:
result_ingest_concurrency: 4 result_ingest_concurrency: 4
projection_rebuild_concurrency: 2 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: brand_library:
free_brand_limit: 1 free_brand_limit: 1
paid_brand_limit: 2 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) 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) monitoringPool, err := postgres.NewPool(ctx, cfg.MonitoringDatabase)
if err != nil { if err != nil {
pool.Close() pool.Close()
@@ -175,6 +180,24 @@ func New(configPath string) (*App, error) {
}, nil }, 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() { func (a *App) Close() {
if a.AuditLogs != nil { if a.AuditLogs != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+255 -4
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -16,6 +17,7 @@ type Config struct {
RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"` RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"`
Scheduler SchedulerConfig `mapstructure:"scheduler"` Scheduler SchedulerConfig `mapstructure:"scheduler"`
MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"` MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"`
Membership MembershipConfig `mapstructure:"membership"`
BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"` BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"`
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
Qdrant QdrantConfig `mapstructure:"qdrant"` Qdrant QdrantConfig `mapstructure:"qdrant"`
@@ -110,6 +112,64 @@ type MonitoringConfig struct {
ProjectionRebuildConcurrency int `mapstructure:"projection_rebuild_concurrency"` 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 { type BrandLibraryConfig struct {
FreeBrandLimit int `mapstructure:"free_brand_limit"` FreeBrandLimit int `mapstructure:"free_brand_limit"`
PaidBrandLimit int `mapstructure:"paid_brand_limit"` PaidBrandLimit int `mapstructure:"paid_brand_limit"`
@@ -196,17 +256,15 @@ type GenerationConfig struct {
func Load(configPath string) (*Config, error) { func Load(configPath string) (*Config, error) {
v := viper.New() v := viper.New()
v.SetConfigFile(configPath)
v.AutomaticEnv() v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 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) return nil, fmt.Errorf("read config: %w", err)
} }
// Allow local overrides // Allow local overrides
v.SetConfigFile(strings.Replace(configPath, ".yaml", ".local.yaml", 1)) _ = mergeLocalOverrideWithFallback(v, configPath)
_ = v.MergeInConfig()
var cfg Config var cfg Config
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
@@ -217,11 +275,77 @@ func Load(configPath string) (*Config, error) {
normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeRabbitMQConfig(&cfg.RabbitMQ)
normalizeSchedulerConfig(&cfg.Scheduler) normalizeSchedulerConfig(&cfg.Scheduler)
normalizeMonitoringConfig(&cfg.MonitoringWorkers) normalizeMonitoringConfig(&cfg.MonitoringWorkers)
normalizeMembershipConfig(&cfg.Membership)
normalizeBrandLibraryConfig(&cfg.BrandLibrary) normalizeBrandLibraryConfig(&cfg.BrandLibrary)
return &cfg, nil 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) { func applyEnvOverrides(cfg *Config) {
if cfg == nil { if cfg == nil {
return 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) { func lookupFirstNonEmptyEnv(keys ...string) (string, bool) {
for _, key := range keys { for _, key := range keys {
if value, ok := lookupNonEmptyEnv(key); ok { if value, ok := lookupNonEmptyEnv(key); ok {
@@ -495,3 +732,17 @@ func lookupBoolEnv(key string) (bool, bool) {
return false, false 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" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestLoadPrefersEnvLLMAPIKey(t *testing.T) { 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 { func writeTestConfig(t *testing.T, body string) string {
t.Helper() t.Helper()
@@ -14,6 +14,7 @@ import (
type AuthService struct { type AuthService struct {
repo repository.AuthRepository repo repository.AuthRepository
plans repository.TenantPlanRepository
kolProfiles repository.KolProfileRepository kolProfiles repository.KolProfileRepository
jwt *auth.Manager jwt *auth.Manager
sessions *auth.SessionStore sessions *auth.SessionStore
@@ -21,12 +22,14 @@ type AuthService struct {
func NewAuthService( func NewAuthService(
repo repository.AuthRepository, repo repository.AuthRepository,
plans repository.TenantPlanRepository,
kolProfiles repository.KolProfileRepository, kolProfiles repository.KolProfileRepository,
jwt *auth.Manager, jwt *auth.Manager,
sessions *auth.SessionStore, sessions *auth.SessionStore,
) *AuthService { ) *AuthService {
return &AuthService{ return &AuthService{
repo: repo, repo: repo,
plans: plans,
kolProfiles: kolProfiles, kolProfiles: kolProfiles,
jwt: jwt, jwt: jwt,
sessions: sessions, sessions: sessions,
@@ -53,9 +56,25 @@ type UserInfo struct {
TenantID int64 `json:"tenant_id"` TenantID int64 `json:"tenant_id"`
TenantRole string `json:"tenant_role"` TenantRole string `json:"tenant_role"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Membership *MembershipInfo `json:"membership"`
KolProfile *KolProfileBrief `json:"kol_profile"` 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 { type KolProfileBrief struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
@@ -92,6 +111,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
if err != nil { if err != nil {
return nil, err return nil, err
} }
memberInfo, err := s.loadMembershipInfo(ctx, membership.TenantID)
if err != nil {
return nil, err
}
return &LoginResponse{ return &LoginResponse{
AccessToken: pair.AccessToken, AccessToken: pair.AccessToken,
@@ -105,6 +128,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
TenantID: membership.TenantID, TenantID: membership.TenantID,
TenantRole: membership.TenantRole, TenantRole: membership.TenantRole,
Permissions: auth.PermissionsForRole(membership.TenantRole), Permissions: auth.PermissionsForRole(membership.TenantRole),
Membership: memberInfo,
KolProfile: kolProfile, KolProfile: kolProfile,
}, },
}, nil }, nil
@@ -166,6 +190,10 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
memberInfo, err := s.loadMembershipInfo(ctx, actor.TenantID)
if err != nil {
return nil, err
}
return &UserInfo{ return &UserInfo{
ID: user.ID, ID: user.ID,
@@ -175,6 +203,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
TenantID: actor.TenantID, TenantID: actor.TenantID,
TenantRole: actor.Role, TenantRole: actor.Role,
Permissions: auth.PermissionsForRole(actor.Role), Permissions: auth.PermissionsForRole(actor.Role),
Membership: memberInfo,
KolProfile: kolProfile, KolProfile: kolProfile,
}, nil }, nil
} }
@@ -200,6 +229,59 @@ func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID
}, nil }, 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 { func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
claims, err := s.jwt.Parse(accessToken) claims, err := s.jwt.Parse(accessToken)
if err != nil { if err != nil {
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -787,8 +788,9 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
} }
type brandLibraryPlan struct { type brandLibraryPlan struct {
PlanCode string PlanCode string
PlanName string PlanName string
MaxBrands int
} }
type brandLibraryUsage struct { type brandLibraryUsage struct {
@@ -808,6 +810,9 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
} }
maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode) maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode)
if plan.MaxBrands > 0 {
maxBrands = plan.MaxBrands
}
maxKeywords := s.limits.MaxKeywords maxKeywords := s.limits.MaxKeywords
return &BrandLibrarySummaryResponse{ 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) { func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
plan := &brandLibraryPlan{ plan := &brandLibraryPlan{
PlanCode: "free", PlanCode: "free",
PlanName: "", PlanName: "",
MaxBrands: s.limits.BrandLimitForPlan("free"),
} }
var quotaPolicyJSON []byte
err := s.pool.QueryRow(ctx, ` 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 FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1 WHERE s.tenant_id = $1
AND s.status = 'active' AND s.status = 'active'
AND s.deleted_at IS NULL AND s.deleted_at IS NULL
AND p.status = 'active'
AND s.end_at > $2
ORDER BY s.start_at DESC ORDER BY s.start_at DESC
LIMIT 1 LIMIT 1
`, tenantID).Scan(&plan.PlanCode, &plan.PlanName) `, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, &quotaPolicyJSON)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return plan, nil return plan, nil
} }
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan") 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 return plan, nil
} }
@@ -771,6 +771,7 @@ JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1 WHERE s.tenant_id = $1
AND s.status = 'active' AND s.status = 'active'
AND s.deleted_at IS NULL AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1 LIMIT 1
`, tenantID, defaultImageQuotaBytes).Scan(&quotaBytes) `, tenantID, defaultImageQuotaBytes).Scan(&quotaBytes)
if err != nil { if err != nil {
+23 -20
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
@@ -18,13 +19,23 @@ import (
type WorkspaceService struct { type WorkspaceService struct {
repo repository.WorkspaceRepository repo repository.WorkspaceRepository
templates repository.TemplateRepository templates repository.TemplateRepository
quota repository.QuotaRepository
supportedPlatformCount int supportedPlatformCount int
cache sharedcache.Cache cache sharedcache.Cache
cacheGroup singleflight.Group cacheGroup singleflight.Group
} }
func NewWorkspaceService(repo repository.WorkspaceRepository, templates repository.TemplateRepository) *WorkspaceService { func NewWorkspaceService(
return &WorkspaceService{repo: repo, templates: templates, supportedPlatformCount: 5} 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 { 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) { func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) { 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 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") 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{ return &domain.QuotaSummary{
PlanCode: plan.PlanCode, PlanCode: status.PlanCode,
PlanName: plan.PlanName, PlanName: status.PlanName,
TotalQuota: total, TotalQuota: status.Total,
UsedQuota: total - balance, UsedQuota: status.Used,
Balance: balance, Balance: status.Balance,
ResetAt: status.ResetAt,
}, nil }, nil
}) })
} }
@@ -53,6 +53,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json,
FROM tenant_plan_subscriptions s FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1 AND s.status = 'active' AND s.deleted_at IS NULL WHERE s.tenant_id = $1 AND s.status = 'active' AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1 LIMIT 1
` `
@@ -65,6 +65,7 @@ WITH plan AS (
WHERE s.tenant_id = $1 WHERE s.tenant_id = $1
AND s.status = 'active' AND s.status = 'active'
AND s.deleted_at IS NULL AND s.deleted_at IS NULL
AND s.end_at > NOW()
LIMIT 1 LIMIT 1
) )
UPDATE tenant_image_storage_usage u 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 FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id 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 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; LIMIT 1;
-- name: ListKolCardsForTenant :many -- name: ListKolCardsForTenant :many
@@ -2,11 +2,23 @@ package repository
import ( import (
"context" "context"
"errors"
"time" "time"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated" "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 { type QuotaLedgerInput struct {
TenantID int64 TenantID int64
OperatorID int64 OperatorID int64
@@ -30,6 +42,7 @@ type QuotaReservationInput struct {
type QuotaRepository interface { type QuotaRepository interface {
GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) 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) InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error)
CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error) CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error)
UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error
@@ -38,22 +51,90 @@ type QuotaRepository interface {
} }
type quotaRepository struct { type quotaRepository struct {
q generated.Querier q generated.Querier
db generated.DBTX
} }
func NewQuotaRepository(db generated.DBTX) QuotaRepository { 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) { func (r *quotaRepository) GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) {
balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{ if quotaType != "article_generation" {
TenantID: tenantID, balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{
QuotaType: quotaType, 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 { if err != nil {
return 0, err 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) { 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{ return &AuthHandler{
svc: app.NewAuthService( svc: app.NewAuthService(
repository.NewAuthRepository(a.DB), repository.NewAuthRepository(a.DB),
repository.NewTenantPlanRepository(a.DB),
a.KolProfiles, a.KolProfiles,
a.JWT, a.JWT,
a.Sessions, 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/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth" "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/shared/middleware"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
func RegisterRoutes(app *bootstrap.App) { func RegisterRoutes(app *bootstrap.App) {
@@ -35,7 +36,10 @@ func RegisterRoutes(app *bootstrap.App) {
protected.GET("/auth/me", authHandler.Me) protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/logout", authHandler.Logout) 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) wsHandler := NewWorkspaceHandler(app)
workspace.GET("/overview", wsHandler.Overview) workspace.GET("/overview", wsHandler.Overview)
workspace.GET("/recent-articles", wsHandler.RecentArticles) workspace.GET("/recent-articles", wsHandler.RecentArticles)
@@ -43,7 +47,7 @@ func RegisterRoutes(app *bootstrap.App) {
workspace.GET("/template-cards", wsHandler.TemplateCards) workspace.GET("/template-cards", wsHandler.TemplateCards)
workspace.GET("/kol-cards", wsHandler.KolCards) workspace.GET("/kol-cards", wsHandler.KolCards)
templates := protected.Group("/tenant/templates") templates := tenantProtected.Group("/templates")
tplHandler := NewTemplateHandler(app) tplHandler := NewTemplateHandler(app)
templates.GET("", tplHandler.List) templates.GET("", tplHandler.List)
templates.GET("/:id", tplHandler.Detail) templates.GET("/:id", tplHandler.Detail)
@@ -56,7 +60,7 @@ func RegisterRoutes(app *bootstrap.App) {
templates.GET("/:id/gen_outline_task_result", tplHandler.GetOutlineTaskResult) templates.GET("/:id/gen_outline_task_result", tplHandler.GetOutlineTaskResult)
templates.POST("/:id/generate", tplHandler.Generate) templates.POST("/:id/generate", tplHandler.Generate)
kolManage := protected.Group("/tenant/kol/manage") kolManage := tenantProtected.Group("/kol/manage")
kolManageHandler := NewKolManageHandler(app, publicAssets) kolManageHandler := NewKolManageHandler(app, publicAssets)
kolManage.GET("/profile", kolManageHandler.GetProfile) kolManage.GET("/profile", kolManageHandler.GetProfile)
kolManage.PUT("/profile", kolManageHandler.UpdateProfile) kolManage.PUT("/profile", kolManageHandler.UpdateProfile)
@@ -80,19 +84,19 @@ func RegisterRoutes(app *bootstrap.App) {
kolManage.POST("/assist/stream", kolManageHandler.AssistStream) kolManage.POST("/assist/stream", kolManageHandler.AssistStream)
kolManage.GET("/assist/:id", kolManageHandler.AssistGet) kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
kolDash := protected.Group("/tenant/kol/dashboard") kolDash := tenantProtected.Group("/kol/dashboard")
kolDashboardHandler := NewKolDashboardHandler(app) kolDashboardHandler := NewKolDashboardHandler(app)
kolDash.GET("/overview", kolDashboardHandler.Overview) kolDash.GET("/overview", kolDashboardHandler.Overview)
kolDash.GET("/packages", kolDashboardHandler.Packages) kolDash.GET("/packages", kolDashboardHandler.Packages)
kolDash.GET("/trend", kolDashboardHandler.Trend) kolDash.GET("/trend", kolDashboardHandler.Trend)
kolMarketplace := protected.Group("/tenant/kol/marketplace") kolMarketplace := tenantProtected.Group("/kol/marketplace")
kolMarketplaceHandler := NewKolMarketplaceHandler(app) kolMarketplaceHandler := NewKolMarketplaceHandler(app)
kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages) kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages)
kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage) kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage)
kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe) kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe)
kolSubscriptions := protected.Group("/tenant/kol") kolSubscriptions := tenantProtected.Group("/kol")
kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions) kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions)
kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts) kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts)
kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema) 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 // V1 compromise: these admin operations live on tenant-api and are gated by
// tenant_role=tenant_admin until the platform-api/module is built. // 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) kolAdminHandler := NewKolAdminHandler(app)
kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription) kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription)
kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind) kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind)
kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription) kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription)
articles := protected.Group("/tenant/articles") articles := tenantProtected.Group("/articles")
artHandler := NewArticleHandler(app) artHandler := NewArticleHandler(app)
articles.GET("", artHandler.List) articles.GET("", artHandler.List)
articles.POST("", artHandler.Create) articles.POST("", artHandler.Create)
@@ -121,14 +125,14 @@ func RegisterRoutes(app *bootstrap.App) {
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch) articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.DELETE("/:id", artHandler.Delete) articles.DELETE("/:id", artHandler.Delete)
media := protected.Group("/tenant/media") media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms) media.GET("/platforms", pluginHandler.ListPlatforms)
media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts) media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts)
media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation) media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation)
media.POST("/plugin-sessions", pluginHandler.CreatePluginSession) media.POST("/plugin-sessions", pluginHandler.CreatePluginSession)
media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount) media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount)
brands := protected.Group("/tenant/brands") brands := tenantProtected.Group("/brands")
brandHandler := NewBrandHandler(app) brandHandler := NewBrandHandler(app)
brands.GET("", brandHandler.List) brands.GET("", brandHandler.List)
brands.GET("/library-summary", brandHandler.Summary) brands.GET("/library-summary", brandHandler.Summary)
@@ -149,13 +153,13 @@ func RegisterRoutes(app *bootstrap.App) {
brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor) brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor)
brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor) brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor)
monitoring := protected.Group("/tenant/monitoring") monitoring := tenantProtected.Group("/monitoring")
monitoringHandler := NewMonitoringHandler(app) monitoringHandler := NewMonitoringHandler(app)
monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite) monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite)
monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail) monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail)
monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow) monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow)
knowledge := protected.Group("/tenant/knowledge") knowledge := tenantProtected.Group("/knowledge")
knowledgeHandler := NewKnowledgeHandler(app) knowledgeHandler := NewKnowledgeHandler(app)
knowledge.GET("/groups", knowledgeHandler.ListGroups) knowledge.GET("/groups", knowledgeHandler.ListGroups)
knowledge.POST("/groups", knowledgeHandler.CreateGroup) knowledge.POST("/groups", knowledgeHandler.CreateGroup)
@@ -169,7 +173,7 @@ func RegisterRoutes(app *bootstrap.App) {
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem) knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
// Prompt Rules // Prompt Rules
promptRules := protected.Group("/tenant/prompt-rules") promptRules := tenantProtected.Group("/prompt-rules")
prHandler := NewPromptRuleHandler(app) prHandler := NewPromptRuleHandler(app)
promptRules.GET("", prHandler.List) promptRules.GET("", prHandler.List)
promptRules.GET("/simple", prHandler.ListSimple) promptRules.GET("/simple", prHandler.ListSimple)
@@ -185,7 +189,7 @@ func RegisterRoutes(app *bootstrap.App) {
promptRules.DELETE("/groups/:gid", prHandler.DeleteGroup) promptRules.DELETE("/groups/:gid", prHandler.DeleteGroup)
// Schedule Tasks // Schedule Tasks
schedules := protected.Group("/tenant/schedules") schedules := tenantProtected.Group("/schedules")
schHandler := NewScheduleTaskHandler(app) schHandler := NewScheduleTaskHandler(app)
schedules.GET("", schHandler.List) schedules.GET("", schHandler.List)
schedules.POST("", schHandler.Create) schedules.POST("", schHandler.Create)
@@ -194,11 +198,11 @@ func RegisterRoutes(app *bootstrap.App) {
schedules.DELETE("/:id", schHandler.Delete) schedules.DELETE("/:id", schHandler.Delete)
schedules.PUT("/:id/status", schHandler.ToggleStatus) schedules.PUT("/:id/status", schHandler.ToggleStatus)
instantTasks := protected.Group("/tenant/instant-tasks") instantTasks := tenantProtected.Group("/instant-tasks")
instHandler := NewInstantTaskHandler(app) instHandler := NewInstantTaskHandler(app)
instantTasks.GET("", instHandler.List) instantTasks.GET("", instHandler.List)
images := protected.Group("/tenant/images") images := tenantProtected.Group("/images")
imgHandler := NewImageHandler(app) imgHandler := NewImageHandler(app)
images.GET("", imgHandler.ListImages) images.GET("", imgHandler.ListImages)
images.POST("", imgHandler.UploadImage) images.POST("", imgHandler.UploadImage)
@@ -207,7 +211,7 @@ func RegisterRoutes(app *bootstrap.App) {
images.DELETE("/:id", imgHandler.DeleteImage) images.DELETE("/:id", imgHandler.DeleteImage)
images.GET("/storage-usage", imgHandler.StorageUsage) images.GET("/storage-usage", imgHandler.StorageUsage)
imageFolders := protected.Group("/tenant/images/folders") imageFolders := tenantProtected.Group("/images/folders")
imageFolders.GET("", imgHandler.ListFolders) imageFolders.GET("", imgHandler.ListFolders)
imageFolders.POST("", imgHandler.CreateFolder) imageFolders.POST("", imgHandler.CreateFolder)
imageFolders.PUT("/:id", imgHandler.UpdateFolder) 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), repository.NewTemplateRepository(a.DB),
a.Cache, a.Cache,
), ),
repository.NewQuotaRepository(a.DB),
).WithCache(a.Cache), ).WithCache(a.Cache),
} }
} }