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
@@ -20,6 +20,7 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
return &AuthHandler{
svc: app.NewAuthService(
repository.NewAuthRepository(a.DB),
repository.NewTenantPlanRepository(a.DB),
a.KolProfiles,
a.JWT,
a.Sessions,
+21 -17
View File
@@ -4,6 +4,7 @@ import (
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func RegisterRoutes(app *bootstrap.App) {
@@ -35,7 +36,10 @@ func RegisterRoutes(app *bootstrap.App) {
protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/logout", authHandler.Logout)
workspace := protected.Group("/tenant/workspace")
tenantProtected := protected.Group("/tenant")
tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB)))
workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app)
workspace.GET("/overview", wsHandler.Overview)
workspace.GET("/recent-articles", wsHandler.RecentArticles)
@@ -43,7 +47,7 @@ func RegisterRoutes(app *bootstrap.App) {
workspace.GET("/template-cards", wsHandler.TemplateCards)
workspace.GET("/kol-cards", wsHandler.KolCards)
templates := protected.Group("/tenant/templates")
templates := tenantProtected.Group("/templates")
tplHandler := NewTemplateHandler(app)
templates.GET("", tplHandler.List)
templates.GET("/:id", tplHandler.Detail)
@@ -56,7 +60,7 @@ func RegisterRoutes(app *bootstrap.App) {
templates.GET("/:id/gen_outline_task_result", tplHandler.GetOutlineTaskResult)
templates.POST("/:id/generate", tplHandler.Generate)
kolManage := protected.Group("/tenant/kol/manage")
kolManage := tenantProtected.Group("/kol/manage")
kolManageHandler := NewKolManageHandler(app, publicAssets)
kolManage.GET("/profile", kolManageHandler.GetProfile)
kolManage.PUT("/profile", kolManageHandler.UpdateProfile)
@@ -80,19 +84,19 @@ func RegisterRoutes(app *bootstrap.App) {
kolManage.POST("/assist/stream", kolManageHandler.AssistStream)
kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
kolDash := protected.Group("/tenant/kol/dashboard")
kolDash := tenantProtected.Group("/kol/dashboard")
kolDashboardHandler := NewKolDashboardHandler(app)
kolDash.GET("/overview", kolDashboardHandler.Overview)
kolDash.GET("/packages", kolDashboardHandler.Packages)
kolDash.GET("/trend", kolDashboardHandler.Trend)
kolMarketplace := protected.Group("/tenant/kol/marketplace")
kolMarketplace := tenantProtected.Group("/kol/marketplace")
kolMarketplaceHandler := NewKolMarketplaceHandler(app)
kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages)
kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage)
kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe)
kolSubscriptions := protected.Group("/tenant/kol")
kolSubscriptions := tenantProtected.Group("/kol")
kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions)
kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts)
kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema)
@@ -100,13 +104,13 @@ func RegisterRoutes(app *bootstrap.App) {
// V1 compromise: these admin operations live on tenant-api and are gated by
// tenant_role=tenant_admin until the platform-api/module is built.
kolAdmin := protected.Group("/tenant/kol/admin")
kolAdmin := tenantProtected.Group("/kol/admin")
kolAdminHandler := NewKolAdminHandler(app)
kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription)
kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind)
kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription)
articles := protected.Group("/tenant/articles")
articles := tenantProtected.Group("/articles")
artHandler := NewArticleHandler(app)
articles.GET("", artHandler.List)
articles.POST("", artHandler.Create)
@@ -121,14 +125,14 @@ func RegisterRoutes(app *bootstrap.App) {
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.DELETE("/:id", artHandler.Delete)
media := protected.Group("/tenant/media")
media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms)
media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts)
media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation)
media.POST("/plugin-sessions", pluginHandler.CreatePluginSession)
media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount)
brands := protected.Group("/tenant/brands")
brands := tenantProtected.Group("/brands")
brandHandler := NewBrandHandler(app)
brands.GET("", brandHandler.List)
brands.GET("/library-summary", brandHandler.Summary)
@@ -149,13 +153,13 @@ func RegisterRoutes(app *bootstrap.App) {
brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor)
brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor)
monitoring := protected.Group("/tenant/monitoring")
monitoring := tenantProtected.Group("/monitoring")
monitoringHandler := NewMonitoringHandler(app)
monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite)
monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail)
monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow)
knowledge := protected.Group("/tenant/knowledge")
knowledge := tenantProtected.Group("/knowledge")
knowledgeHandler := NewKnowledgeHandler(app)
knowledge.GET("/groups", knowledgeHandler.ListGroups)
knowledge.POST("/groups", knowledgeHandler.CreateGroup)
@@ -169,7 +173,7 @@ func RegisterRoutes(app *bootstrap.App) {
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
// Prompt Rules
promptRules := protected.Group("/tenant/prompt-rules")
promptRules := tenantProtected.Group("/prompt-rules")
prHandler := NewPromptRuleHandler(app)
promptRules.GET("", prHandler.List)
promptRules.GET("/simple", prHandler.ListSimple)
@@ -185,7 +189,7 @@ func RegisterRoutes(app *bootstrap.App) {
promptRules.DELETE("/groups/:gid", prHandler.DeleteGroup)
// Schedule Tasks
schedules := protected.Group("/tenant/schedules")
schedules := tenantProtected.Group("/schedules")
schHandler := NewScheduleTaskHandler(app)
schedules.GET("", schHandler.List)
schedules.POST("", schHandler.Create)
@@ -194,11 +198,11 @@ func RegisterRoutes(app *bootstrap.App) {
schedules.DELETE("/:id", schHandler.Delete)
schedules.PUT("/:id/status", schHandler.ToggleStatus)
instantTasks := protected.Group("/tenant/instant-tasks")
instantTasks := tenantProtected.Group("/instant-tasks")
instHandler := NewInstantTaskHandler(app)
instantTasks.GET("", instHandler.List)
images := protected.Group("/tenant/images")
images := tenantProtected.Group("/images")
imgHandler := NewImageHandler(app)
images.GET("", imgHandler.ListImages)
images.POST("", imgHandler.UploadImage)
@@ -207,7 +211,7 @@ func RegisterRoutes(app *bootstrap.App) {
images.DELETE("/:id", imgHandler.DeleteImage)
images.GET("/storage-usage", imgHandler.StorageUsage)
imageFolders := protected.Group("/tenant/images/folders")
imageFolders := tenantProtected.Group("/images/folders")
imageFolders.GET("", imgHandler.ListFolders)
imageFolders.POST("", imgHandler.CreateFolder)
imageFolders.PUT("/:id", imgHandler.UpdateFolder)
@@ -0,0 +1,63 @@
package transport
import (
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin.HandlerFunc {
return func(c *gin.Context) {
actor, ok := auth.ActorFromCtx(c.Request.Context())
if !ok {
response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated"))
c.Abort()
return
}
now := time.Now().UTC()
access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now)
if err != nil {
response.Error(c, response.ErrInternal(50091, "subscription_lookup_failed", "failed to load tenant subscription"))
c.Abort()
return
}
if access != nil && access.HasActiveAccess(now) {
c.Next()
return
}
reason := ""
if access != nil {
reason = access.BlockedReason(now)
} else {
reason = "subscription_required"
}
switch reason {
case "trial_plan_expired":
response.Error(c, response.ErrForbidden(
40382,
"trial_plan_expired",
"free trial has expired, please contact administrator to reactivate access",
))
case "subscription_required":
response.Error(c, response.ErrForbidden(
40381,
"subscription_required",
"subscription is required, please contact administrator",
))
default:
response.Error(c, response.ErrForbidden(
40383,
"subscription_inactive",
"subscription is inactive, please contact administrator",
))
}
c.Abort()
}
}
@@ -21,6 +21,7 @@ func NewWorkspaceHandler(a *bootstrap.App) *WorkspaceHandler {
repository.NewTemplateRepository(a.DB),
a.Cache,
),
repository.NewQuotaRepository(a.DB),
).WithCache(a.Cache),
}
}