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
View File
@@ -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)