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
@@ -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 {