mcpField
S
saas-platforms

saas-platforms

github.com/miles990开发26362 次安装40 周前更新
代码仓库

关于 saas-platforms

面向 SaaS 开发者的实践指南,涵盖多租户、订阅计费与用户管理。内容包含租户隔离、Stripe 订阅和功能权益等实现示例。

主要功能

  1. 01多租户数据库策略
  2. 02租户数据隔离
  3. 03Stripe 订阅管理
  4. 04按用量计费
  5. 05功能标志与权益
  6. 06用户引导流程

适用场景

  1. 01设计多租户 SaaS 架构
  2. 02实现订阅与用量计费
  3. 03配置套餐功能和限额
  4. 04追踪新用户引导进度

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/miles990/claude-software-skills --skill saas-platforms

安装后在 Claude Code 中输入/saas-platforms确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

templates/
billing-config.ts
README.md
tenant-schema.prisma

详细介绍

SaaS 平台开发

概述

构建具备多租户、订阅计费和用户管理功能的软件即服务应用。


多租户

数据库策略
// Strategy 1: Shared database with tenant_id column
interface TenantEntity {
  tenantId: string;
  // ... other fields
}

// Middleware to inject tenant context
function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;

  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant ID required' });
  }

  req.tenantId = tenantId;
  next();
}

// Prisma middleware for automatic tenant filtering
prisma.$use(async (params, next) => {
  const tenantId = getCurrentTenantId();

  if (params.model && hasTenantId(params.model)) {
    // Add tenant filter to queries
    if (params.action === 'findMany' || params.action === 'findFirst') {
      params.args.where = {
        ...params.args.where,
        tenantId,
      };
    }

    // Add tenant ID to creates
    if (params.action === 'create') {
      params.args.data.tenantId = tenantId;
    }
  }

  return next(params);
});

// Strategy 2: Schema per tenant (PostgreSQL)
async function createTenantSchema(tenantId: string) {
  await prisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${tenantId}`;

  // Run migrations for new schema
  await runMigrations(tenantId);
}

function getTenantConnection(tenantId: string) {
  return new PrismaClient({
    datasources: {
      db: {
        url: `${process.env.DATABASE_URL}?schema=${tenantId}`,
      },
    },
  });
}

// Strategy 3: Database per tenant
async function createTenantDatabase(tenantId: string) {
  const dbName = `tenant_${tenantId}`;
  await adminDb.$executeRaw`CREATE DATABASE ${dbName}`;

  return new PrismaClient({
    datasources: {
      db: {
        url: `postgresql://user:pass@host:5432/${dbName}`,
      },
    },
  });
}
租户隔离
// Row-level security with Prisma
const prisma = new PrismaClient().$extends({
  query: {
    $allModels: {
      async findMany({ model, operation, args, query }) {
        const tenantId = getCurrentTenantId();
        args.where = { ...args.where, tenantId };
        return query(args);
      },
      async create({ model, operation, args, query }) {
        const tenantId = getCurrentTenantId();
        args.data = { ...args.data, tenantId };
        return query(args);
      },
    },
  },
});

// PostgreSQL Row Level Security
/*
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
*/

// Set tenant context for RLS
async function withTenantContext<T>(
  tenantId: string,
  fn: () => Promise<T>
): Promise<T> {
  await prisma.$executeRaw`SET app.tenant_id = ${tenantId}`;
  try {
    return await fn();
  } finally {
    await prisma.$executeRaw`RESET app.tenant_id`;
  }
}

订阅管理

Stripe 订阅
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Create subscription
async function createSubscription(
  customerId: string,
  priceId: string,
  trialDays?: number
) {
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    trial_period_days: trialDays,
    payment_behavior: 'default_incomplete',
    payment_settings: { save_default_payment_method: 'on_subscription' },
    expand: ['latest_invoice.payment_intent'],
  });

  return subscription;
}

// Update subscription
async function updateSubscription(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  return stripe.subscriptions.update(subscriptionId, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: 'create_prorations',
  });
}

// Cancel subscription
async function cancelSubscription(subscriptionId: string, immediate = false) {
  if (immediate) {
    return stripe.subscriptions.cancel(subscriptionId);
  }

  return stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: true,
  });
}

// Handle subscription webhooks
async function handleSubscriptionWebhook(event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await deactivateSubscription(subscription.id);
      break;
    }

    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice;
      await recordPayment(invoice);
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      await handleFailedPayment(invoice);
      break;
    }
  }
}

// Sync subscription to database
async function syncSubscription(subscription: Stripe.Subscription) {
  const planMapping: Record<string, string> = {
    price_starter: 'starter',
    price_pro: 'pro',
    price_enterprise: 'enterprise',
  };

  await prisma.organization.update({
    where: { stripeCustomerId: subscription.customer as string },
    data: {
      subscriptionId: subscription.id,
      subscriptionStatus: subscription.status,
      plan: planMapping[subscription.items.data[0].price.id] || 'free',
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}
按使用量计费
// Track usage
async function recordUsage(
  subscriptionItemId: string,
  quantity: number,
  timestamp?: number
) {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: timestamp || Math.floor(Date.now() / 1000),
    action: 'increment',
  });
}

// Usage tracking service
class UsageTracker {
  private buffer: Map<string, number> = new Map();
  private flushInterval: NodeJS.Timeout;

  constructor(private flushIntervalMs = 60000) {
    this.flushInterval = setInterval(() => this.flush(), flushIntervalMs);
  }

  track(orgId: string, metric: string, amount = 1) {
    const key = `${orgId}:${metric}`;
    this.buffer.set(key, (this.buffer.get(key) || 0) + amount);
  }

  async flush() {
    const entries = Array.from(this.buffer.entries());
    this.buffer.clear();

    for (const [key, amount] of entries) {
      const [orgId, metric] = key.split(':');

      // Record to database
      await prisma.usageRecord.create({
        data: {
          organizationId: orgId,
          metric,
          amount,
          timestamp: new Date(),
        },
      });

      // Report to Stripe (for metered billing)
      const org = await prisma.organization.findUnique({
        where: { id: orgId },
        select: { subscriptionItemId: true },
      });

      if (org?.subscriptionItemId) {
        await recordUsage(org.subscriptionItemId, amount);
      }
    }
  }
}

功能开关与权益

interface Plan {
  id: string;
  name: string;
  features: {
    [key: string]: boolean | number;
  };
  limits: {
    [key: string]: number;
  };
}

const plans: Record<string, Plan> = {
  free: {
    id: 'free',
    name: 'Free',
    features: {
      basicAnalytics: true,
      advancedAnalytics: false,
      apiAccess: false,
      customBranding: false,
    },
    limits: {
      projects: 3,
      teamMembers: 1,
      storage: 100, // MB
      apiCalls: 1000,
    },
  },
  pro: {
    id: 'pro',
    name: 'Pro',
    features: {
      basicAnalytics: true,
      advancedAnalytics: true,
      apiAccess: true,
      customBranding: false,
    },
    limits: {
      projects: 20,
      teamMembers: 10,
      storage: 10000, // MB
      apiCalls: 100000,
    },
  },
  enterprise: {
    id: 'enterprise',
    name: 'Enterprise',
    features: {
      basicAnalytics: true,
      advancedAnalytics: true,
      apiAccess: true,
      customBranding: true,
    },
    limits: {
      projects: -1, // Unlimited
      teamMembers: -1,
      storage: -1,
      apiCalls: -1,
    },
  },
};

// Check feature access
function hasFeature(org: Organization, feature: string): boolean {
  const plan = plans[org.plan];
  return plan?.features[feature] ?? false;
}

// Check limit
function checkLimit(org: Organization, resource: string, current: number): boolean {
  const plan = plans[org.plan];
  const limit = plan?.limits[resource] ?? 0;
  return limit === -1 || current < limit;
}

// Middleware for feature gating
function requireFeature(feature: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const org = await getOrganization(req.tenantId);

    if (!hasFeature(org, feature)) {
      return res.status(403).json({
        error: 'Feature not available',
        upgrade: true,
        requiredPlan: getMinimumPlanForFeature(feature),
      });
    }

    next();
  };
}

用户引导

interface OnboardingStep {
  id: string;
  title: string;
  completed: boolean;
  skippable: boolean;
}

async function getOnboardingProgress(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { organization: true },
  });

  const steps: OnboardingStep[] = [
    {
      id: 'profile',
      title: 'Complete your profile',
      completed: !!user.name && !!user.avatar,
      skippable: true,
    },
    {
      id: 'invite_team',
      title: 'Invite team members',
      completed: user.organization.memberCount > 1,
      skippable: true,
    },
    {
      id: 'create_project',
      title: 'Create your first project',
      completed: user.organization.projectCount > 0,
      skippable: false,
    },
    {
      id: 'connect_integration',
      title: 'Connect an integration',
      completed: user.organization.integrationCount > 0,
      skippable: true,
    },
  ];

  const completedCount = steps.filter((s) => s.completed).length;

  return {
    steps,
    progress: Math.round((completedCount / steps.length) * 100),
    isComplete: steps.every((s) => s.completed || s.skippable),
  };
}

相关技能

  • [[system-design]] - SaaS 架构
  • [[security-practices]] - 多租户安全
  • [[database]] - 租户数据隔离

评论

支持 Markdown