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
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -787,8 +788,9 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
}
type brandLibraryPlan struct {
PlanCode string
PlanName string
PlanCode string
PlanName string
MaxBrands int
}
type brandLibraryUsage struct {
@@ -808,6 +810,9 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
}
maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode)
if plan.MaxBrands > 0 {
maxBrands = plan.MaxBrands
}
maxKeywords := s.limits.MaxKeywords
return &BrandLibrarySummaryResponse{
@@ -825,26 +830,39 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
plan := &brandLibraryPlan{
PlanCode: "free",
PlanName: "",
PlanCode: "free",
PlanName: "",
MaxBrands: s.limits.BrandLimitForPlan("free"),
}
var quotaPolicyJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT p.plan_code, p.name
SELECT p.plan_code, p.name, p.quota_policy_json
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
AND p.status = 'active'
AND s.end_at > $2
ORDER BY s.start_at DESC
LIMIT 1
`, tenantID).Scan(&plan.PlanCode, &plan.PlanName)
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, &quotaPolicyJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return plan, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
}
var quotaPolicy struct {
BrandLimit int `json:"brand_limit"`
}
if len(quotaPolicyJSON) > 0 {
_ = json.Unmarshal(quotaPolicyJSON, &quotaPolicy)
}
if quotaPolicy.BrandLimit > 0 {
plan.MaxBrands = quotaPolicy.BrandLimit
}
return plan, nil
}