Laravel: 项目设计 — ShopMetrics SaaS平台架构
最后更新:2026-08-26
没有设计的编码像没有蓝图的盖楼——盖到三层才发现地基不够深,只能推倒重来。
1. 你将学到
- 需求分析与用户故事:Alice(租户管理员)/Bob(平台运营)/Charlie(数据分析师)
- 多租户架构选型:共享数据库 vs 独立数据库策略
- 数据库 ER 设计:tenants/users/plans/subscriptions/shops/orders/analytics
- API 接口文档设计:OpenAPI 3.0 规范与 Postman 集合
- 技术选型决策:Laravel 11 / MySQL / Redis / S3 / WebSocket
2. 一个从零到 SaaS 的真实故事
(1) 痛点:Bob 想做电商分析平台但不知道怎么开始
Bob 运营着一个电商数据咨询公司,有 50 多个客户。每个客户的数据分散在 8 个电商平台,Bob 用 Excel 手动汇总——每天花 4 小时做报表,经常出错。Alice 是其中一个客户的运营经理,她说:"如果能有个仪表盘实时看到所有店铺数据就好了。"Charlie 是 Bob 的数据分析师,他说:"我们需要一个多租户 SaaS 平台,但架构怎么设计?"
(2) 系统化设计的解法
先做需求分析→用户故事→架构选型→数据建模→API 设计→技术选型,每一步都有明确的产出物,编码时照着蓝图走。
TEXT
📖 仅展示
需求分析 → 用户故事 → 架构选型 → ER设计 → API设计 → 技术选型 → 开始编码
(3) 收益
Bob 用 2 周完成设计后,编码效率提高 3 倍——因为每个需求都有明确的接口和数据库设计,不需要边写边改。
3. 需求分析与用户故事
(1) 三类用户画像
| 角色 | 姓名 | 核心需求 | 典型操作 |
|---|---|---|---|
| 租户管理员 | Alice | 管理自己的店铺和团队 | 创建商店、邀请成员、查看仪表盘 |
| 平台运营 | Bob | 管理所有租户和计费 | 审批租户、管理套餐、查看平台统计 |
| 数据分析师 | Charlie | 分析电商数据生成报表 | 创建报表、设置告警、导出数据 |
(2) 用户故事
TEXT
📖 仅展示
As Alice (Tenant Admin), I want to:
- US-01: Add a new e-commerce shop so I can track its metrics
- US-02: Invite team members so Charlie can access analytics
- US-03: View a dashboard showing revenue/orders across all my shops
- US-04: Subscribe to a plan that fits my number of shops
- US-05: Export reports in CSV/Excel format
- US-06: Set up alerts when revenue drops below a threshold
As Bob (Platform Operator), I want to:
- US-07: Manage tenant accounts (create/suspend/delete)
- US-08: Define subscription plans with different feature limits
- US-09: View platform-wide metrics (total tenants/MRR/activations)
- US-10: Process subscription payments via Stripe
- US-11: Send notifications to tenants about expiring subscriptions
As Charlie (Data Analyst), I want to:
- US-12: Build custom analytics reports with date range and filters
- US-13: Compare shop performance side by side
- US-14: Schedule automated report generation (daily/weekly/monthly)
- US-15: Receive real-time notifications when significant events occur
▶ 示例:用户故事拆解为功能模块
PHP
// User stories → Feature modules mapping
return [
'Tenant Management' => [
'US-07: Manage tenant accounts',
'US-01: Add e-commerce shops',
'US-02: Invite team members',
],
'Subscription & Billing' => [
'US-04: Subscribe to a plan',
'US-08: Define subscription plans',
'US-10: Process Stripe payments',
'US-11: Expiring notifications',
],
'Analytics & Reports' => [
'US-03: View revenue dashboard',
'US-05: Export reports',
'US-12: Build custom reports',
'US-13: Compare shop performance',
'US-14: Schedule report generation',
],
'Alerts & Notifications' => [
'US-06: Revenue drop alerts',
'US-15: Real-time event notifications',
],
'Platform Administration' => [
'US-09: Platform-wide metrics',
],
];
输出:
TEXT
📖 仅展示
// 执行成功
4. 多租户架构选型
(1) 三种多租户策略
| 策略 | 隔离级别 | 成本 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 高 | 中 | 金融/医疗合规要求 |
| 共享数据库+独立Schema | 中 | 中 | 中 | 中等规模、部分隔离需求 |
| 共享数据库+共享Schema | 最低 | 低 | 低 | 大多数 SaaS、tenant_id 隔离 |
(2) ShopMetrics 选型决策
flowchart TD
A[Multi-tenant Strategy] --> B{Data Isolation Requirement?}
B -->|Strict compliance| C[Separate DB per Tenant]
B -->|Standard SaaS| D{Tenant Count?}
D -->|< 100| E[Shared DB + Separate Schema]
D -->|> 100| F[Shared DB + Shared Schema]
F --> G[tenant_id on every row]
G --> H[Global Scope auto-filter]
H --> I[ShopMetrics Choice ✅]
ShopMetrics 选择共享数据库+共享Schema策略:
- 预期 500+ 租户,成本敏感
- 无金融合规要求,tenant_id 隔离足够
- Laravel Global Scope 实现自动过滤
▶ 示例:多租户 Global Scope 实现
PHP
// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = Tenant::current()?->id;
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
$tenantId = Tenant::current()?->id;
if ($tenantId && ! $model->isDirty('tenant_id')) {
$model->tenant_id = $tenantId;
}
});
}
}
// app/Models/Tenant.php
class Tenant extends Model
{
protected static Tenant $currentTenant;
public static function setCurrent(self $tenant): void
{
static::$currentTenant = $tenant;
}
public static function current(): ?self
{
return static::$currentTenant ?? null;
}
}
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
Tenant::setCurrent($user->tenant);
}
return $next($request);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
5. 数据库 ER 设计
(1) 核心实体关系
erDiagram
TENANT ||--o{ USER : "has many"
TENANT ||--o{ SHOP : "has many"
TENANT ||--|| SUBSCRIPTION : "has one"
PLAN ||--o{ SUBSCRIPTION : "subscribed to"
SHOP ||--o{ ORDER : "has many"
SHOP ||--o{ PRODUCT : "has many"
ORDER ||--|{ ORDER_ITEM : "contains"
ORDER_ITEM }o--|| PRODUCT : "references"
USER ||--o{ REPORT : "creates"
TENANT ||--o{ ALERT : "configures"
TENANT {
bigint id PK
string name
string slug UK
string domain
string status
timestamp created_at
}
USER {
bigint id PK
bigint tenant_id FK
string name
string email UK
string role
timestamp created_at
}
PLAN {
bigint id PK
string name
string slug UK
int shop_limit
int user_limit
int price_cents
string stripe_price_id
}
SUBSCRIPTION {
bigint id PK
bigint tenant_id FK
bigint plan_id FK
string stripe_id
string status
timestamp trial_ends_at
timestamp ends_at
}
SHOP {
bigint id PK
bigint tenant_id FK
string name
string platform
string external_id
string status
}
ORDER {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string external_id
string customer_email
int total_cents
string status
timestamp ordered_at
}
PRODUCT {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string name
string sku
int price_cents
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
int unit_price_cents
}
REPORT {
bigint id PK
bigint tenant_id FK
bigint user_id FK
string type
string format
string status
string storage_path
timestamp generated_at
}
ALERT {
bigint id PK
bigint tenant_id FK
string type
string condition
string channel
boolean is_active
}
(2) 关键设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 金额存储 | int price_cents |
避免浮点精度问题 |
| 租户隔离 | tenant_id on every table |
共享Schema策略 |
| 订阅状态 | Stripe Webhook 同步 | 单一数据源(Stripe) |
| 外部ID | external_id UK per tenant |
电商平台ID不同格式 |
| 软删除 | 仅 User/Tenant | 订单/商品不删,只改状态 |
▶ 示例:ShopMetrics 核心模型定义
PHP
// app/Models/Tenant.php
class Tenant extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'slug', 'domain', 'status'];
protected static function booted(): void
{
static::creating(function (self $tenant) {
$tenant->slug ??= Str::slug($tenant->name);
$tenant->domain ??= "{$tenant->slug}.shopmetrics.io";
});
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->ofMany([], fn ($q) => $q->orderByDesc('created_at'));
}
public function alerts(): HasMany
{
return $this->hasMany(Alert::class);
}
public function isActive(): bool
{
return $this->status === 'active' &&
$this->subscription?->stripe_status === 'active';
}
public function canAddShop(): bool
{
$limit = $this->subscription?->plan->shop_limit ?? 0;
return $this->shops()->count() < $limit;
}
}
// app/Models/Order.php
class Order extends Model
{
use BelongsToTenant, HasFactory;
protected $fillable = [
'tenant_id', 'shop_id', 'external_id',
'customer_email', 'total_cents', 'status', 'ordered_at',
];
protected $casts = [
'total_cents' => 'integer',
'ordered_at' => 'datetime',
];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getTotalDollarsAttribute(): float
{
return $this->total_cents / 100;
}
}
输出:
TEXT
📖 仅展示
// 执行成功
6. API 接口设计
(1) API 版本与资源规划
| 资源 | 前缀 | 方法 | 说明 |
|---|---|---|---|
| Auth | /api/v1/auth |
POST login/logout/refresh | 认证 |
| Tenants | /api/v1/tenants |
GET/PATCH current | 租户信息 |
| Shops | /api/v1/shops |
CRUD | 店铺管理 |
| Orders | /api/v1/shops/{id}/orders |
GET/POST | 订单查询 |
| Products | /api/v1/shops/{id}/products |
GET/POST | 商品管理 |
| Dashboard | /api/v1/dashboard |
GET overview/top/revenue | 仪表盘 |
| Reports | /api/v1/reports |
POST generate/GET status | 报表 |
| Alerts | /api/v1/alerts |
CRUD | 告警配置 |
| Plans | /api/v1/plans |
GET list | 套餐列表 |
| Subscriptions | /api/v1/subscriptions |
POST/DELETE | 订阅管理 |
(2) API 响应规范
JSON
{
"data": {
"id": 1,
"type": "shop",
"attributes": {
"name": "Alice's Amazon Store",
"platform": "amazon",
"status": "active",
"created_at": "2024-01-15T10:00:00Z"
},
"relationships": {
"tenant": { "data": { "id": 1, "type": "tenant" } }
}
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2024-03-15T14:30:00Z"
}
}
▶ 示例:ShopMetrics API 路由设计
PHP
// routes/api.php
Route::prefix('v1')->group(function () {
// Public: Authentication
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
// Authenticated routes
Route::middleware(['auth:sanctum', 'set-tenant-context'])->group(function () {
// Auth
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
// Tenant (current user's tenant)
Route::get('tenant', [TenantController::class, 'show']);
Route::patch('tenant', [TenantController::class, 'update']);
// Shops
Route::apiResource('shops', ShopController::class);
// Nested resources under shops
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
// Dashboard
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
Route::get('revenue', [DashboardController::class, 'revenueChart']);
});
// Reports
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::post('reports/{report}/download', [ReportController::class, 'download']);
// Alerts
Route::apiResource('alerts', AlertController::class);
// Subscription
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
// Team management (tenant_owner only)
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::get('members', [TeamController::class, 'index']);
Route::post('invite', [TeamController::class, 'invite']);
Route::delete('members/{user}', [TeamController::class, 'remove']);
});
});
// Stripe Webhooks (no auth)
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
输出:
TEXT
📖 仅展示
// 执行成功
7. 技术选型决策
(1) 技术栈对比与选型
| 层级 | 选项 | 选型 | 理由 |
|---|---|---|---|
| 框架 | Laravel/Symfony/Lumen | Laravel 11 | 全功能、生态丰富、SaaS 快速开发 |
| 数据库 | MySQL/PostgreSQL | MySQL 8.0 | 团队熟悉、Laravel 默认、JSON 支持 |
| 缓存 | Redis/Memcached | Redis 7 | 缓存+Session+Queue+广播统一 |
| 存储 | S3/MinIO/本地 | S3 | 可扩展、CDN 集成、开发用 MinIO |
| 队列 | Redis/Database/SQS | Redis | 低延迟、开发/生产统一 |
| 认证 | Sanctum/Passport | Sanctum | SPA+移动端 Token 认证足够 |
| 实时 | Pusher/Soketi | Soketi | 兼容 Pusher 协议、自托管无费用 |
| 前端 | Blade/Inertia/Livewire | Inertia + Vue | SPA 体验+服务端路由 |
(2) 架构总览
flowchart TB
subgraph Client["Client Layer"]
WEB[Web SPA - Inertia/Vue]
MOBILE[Mobile App]
API_CLIENT[API Consumers]
end
subgraph LB["Load Balancer - Nginx"]
direction LR
direction TB
end
subgraph App["Application Layer"]
API[API Server - Laravel]
WS[WebSocket - Soketi]
end
subgraph Worker["Background Processing"]
QUEUE[Queue Workers]
SCHED[Scheduler]
end
subgraph Data["Data Layer"]
DB[(MySQL 8.0)]
CACHE[(Redis 7)]
S3[S3/MinIO]
end
subgraph External["External Services"]
STRIPE[Stripe API]
SHOPS[E-commerce APIs]
MAIL[Mail Service]
end
WEB --> LB
MOBILE --> LB
API_CLIENT --> LB
LB --> API
WEB --> WS
API --> DB
API --> CACHE
API --> S3
API --> STRIPE
API --> SHOPS
API --> QUEUE
API --> WS
QUEUE --> DB
QUEUE --> CACHE
QUEUE --> S3
QUEUE --> MAIL
QUEUE --> STRIPE
SCHED --> QUEUE
▶ 示例:ShopMetrics 技术选型配置文件
PHP
// config/shopmetrics.php
return [
'tenant' => [
'strategy' => env('TENANT_STRATEGY', 'shared_schema'),
'default_plan' => env('DEFAULT_PLAN_SLUG', 'starter'),
'trial_days' => env('TRIAL_DAYS', 14),
],
'limits' => [
'starter' => ['shops' => 3, 'users' => 5, 'reports_per_month' => 10],
'pro' => ['shops' => 25, 'users' => 25, 'reports_per_month' => 100],
'enterprise' => ['shops' => -1, 'users' => -1, 'reports_per_month' => -1],
],
'reports' => [
'max_date_range_days' => 365,
'formats' => ['csv', 'xlsx', 'json'],
'storage_disk' => env('REPORT_DISK', 's3'),
'retention_days' => env('REPORT_RETENTION_DAYS', 90),
],
'sync' => [
'platforms' => ['amazon', 'shopify', 'ebay', 'etsy'],
'sync_interval_minutes' => env('SYNC_INTERVAL', 60),
'batch_size' => env('SYNC_BATCH_SIZE', 500),
],
'alerts' => [
'channels' => ['email', 'slack', 'webhook'],
'check_interval_minutes' => env('ALERT_CHECK_INTERVAL', 15),
],
];
输出:
TEXT
📖 仅展示
// 执行成功
8. 综合示例:ShopMetrics 项目初始化
PHP
// ============================================
// Comprehensive: Project scaffold and initial setup
// Database migrations, models, and config
// ============================================
// database/migrations/2024_01_01_000001_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->unique();
$table->string('status')->default('active'); // active/suspended/cancelled
$table->softDeletes();
$table->timestamps();
$table->index('status');
});
// database/migrations/2024_01_01_000002_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->integer('shop_limit')->default(3);
$table->integer('user_limit')->default(5);
$table->integer('price_cents')->default(0);
$table->string('stripe_price_id')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000003_create_subscriptions_table.php
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'stripe_status']);
});
// database/migrations/2024_01_01_000004_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('platform'); // amazon/shopify/ebay/etsy
$table->string('external_id');
$table->string('status')->default('active');
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'platform', 'external_id']);
$table->index(['tenant_id', 'status']);
});
// database/migrations/2024_01_01_000005_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('shop_id')->constrained()->cascadeOnDelete();
$table->string('external_id');
$table->string('customer_email')->nullable();
$table->unsignedBigInteger('total_cents')->default(0);
$table->string('status')->default('pending');
$table->timestamp('ordered_at');
$table->timestamps();
$table->unique(['tenant_id', 'external_id']);
$table->index(['tenant_id', 'shop_id', 'status', 'ordered_at']);
$table->index(['tenant_id', 'ordered_at']);
});
❓ 常见问题
Q 共享Schema的租户隔离够安全吗?
A 对大多数 SaaS 足够。关键措施:Global Scope 自动过滤 tenant_id、API 层校验当前用户所属租户、避免 raw SQL 绕过 Scope。如需更高隔离(金融/医疗),用独立数据库策略。
Q ER设计什么时候该用多态关联?
A 多态关联适合"一个实体属于多种父实体"的场景(如 Comment 可属于 Post 或 Video)。ShopMetrics 中 Alert 可能属于 Shop 或 Tenant,可考虑多态。但如果关联类型固定,用外键更清晰。
Q API 版本怎么管理?
A URL 前缀版本(
/api/v1/)最简单。v1 和 v2 共存时,v2 路由单独文件,共享 Model 和 Service 层。大版本升级才新增版本号,小改动用向后兼容的方式。Q 技术选型要考虑团队能力吗?
A 必须考虑。最先进的技术栈如果团队不熟悉,反而更慢。优先选团队最熟悉的技术,其次是生态成熟度,最后才是技术先进性。
Q 设计阶段要细化到什么程度?
A 核心实体和关联必须 100% 确定(ER 图、API 端点),实现细节可以在编码时调整。设计目标:编码时不需要"发明"新的表或接口。
Q 多租户数据迁移怎么办?
A 共享Schema简单——直接加 tenant_id 列 + 数据迁移脚本。从单租户迁移到多租户时,用 artisan 命令批量分配 tenant_id,处理完后加 NOT NULL 约束。
📖 小节
- 用户故事从三类角色(Alice/Bob/Charlie)出发,拆解为功能模块
- 共享数据库+共享Schema 是多数 SaaS 的最佳选择
- ER 设计核心:tenant_id 隔离、金额用 cents、外部 ID 唯一约束
- API 设计遵循 RESTful + 版本前缀 + 统一响应格式
- 技术选型优先考虑团队能力和生态成熟度
- 项目初始化从迁移文件开始,奠定数据模型基础
📝 作业
-
基础题(⭐):根据 ER 图,编写 ShopMetrics 剩余 3 张表(products、order_items、alerts)的迁移文件,包含必要的索引和外键。
-
进阶题(⭐⭐):用 OpenAPI 3.0 YAML 格式,编写 ShopMetrics 的 Orders API 文档(GET list / GET detail / POST sync),包含请求参数、响应格式、错误码。
-
挑战题(⭐⭐⭐):设计 ShopMetrics 的多租户中间件完整方案——实现 SetTenantContext 中间件 + BelongsToTenant Trait + 路径参数(subdomain)识别租户,编写测试验证租户 A 无法访问租户 B 的数据。