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:
@@ -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",
|
||||
|
||||
@@ -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: "套餐与额度",
|
||||
|
||||
@@ -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: "请先确认品牌、关键词或竞品信息",
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "aPolicyJSON)
|
||||
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, "aPolicy)
|
||||
}
|
||||
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("aBytes)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 "aRepository{q: newQuerier(db)}
|
||||
return "aRepository{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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user