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
+23 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/sync/singleflight"
@@ -18,13 +19,23 @@ import (
type WorkspaceService struct {
repo repository.WorkspaceRepository
templates repository.TemplateRepository
quota repository.QuotaRepository
supportedPlatformCount int
cache sharedcache.Cache
cacheGroup singleflight.Group
}
func NewWorkspaceService(repo repository.WorkspaceRepository, templates repository.TemplateRepository) *WorkspaceService {
return &WorkspaceService{repo: repo, templates: templates, supportedPlatformCount: 5}
func NewWorkspaceService(
repo repository.WorkspaceRepository,
templates repository.TemplateRepository,
quota repository.QuotaRepository,
) *WorkspaceService {
return &WorkspaceService{
repo: repo,
templates: templates,
quota: quota,
supportedPlatformCount: 5,
}
}
func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService {
@@ -87,29 +98,21 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA
func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) {
actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) {
balance, err := s.repo.GetQuotaSummary(loadCtx, actor.TenantID)
status, err := s.quota.GetArticleQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &domain.QuotaSummary{Balance: 0}, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance")
}
plan, err := s.repo.GetActivePlanForTenant(loadCtx, actor.TenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &domain.QuotaSummary{Balance: balance}, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to get active plan")
}
var policy map[string]int
_ = json.Unmarshal(plan.QuotaPolicyJSON, &policy)
total := policy["article_generation"]
return &domain.QuotaSummary{
PlanCode: plan.PlanCode,
PlanName: plan.PlanName,
TotalQuota: total,
UsedQuota: total - balance,
Balance: balance,
PlanCode: status.PlanCode,
PlanName: status.PlanName,
TotalQuota: status.Total,
UsedQuota: status.Used,
Balance: status.Balance,
ResetAt: status.ResetAt,
}, nil
})
}