Laravel: 项目设计 — ShopMetrics SaaS平台架构

最后更新:2026-08-26

没有设计的编码像没有蓝图的盖楼——盖到三层才发现地基不够深,只能推倒重来。

1. 你将学到


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 选型决策

100%
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策略:

▶ 示例:多租户 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) 核心实体关系

100%
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) 架构总览

100%
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 约束。

📖 小节


📝 作业

  1. 基础题(⭐):根据 ER 图,编写 ShopMetrics 剩余 3 张表(products、order_items、alerts)的迁移文件,包含必要的索引和外键。

  2. 进阶题(⭐⭐):用 OpenAPI 3.0 YAML 格式,编写 ShopMetrics 的 Orders API 文档(GET list / GET detail / POST sync),包含请求参数、响应格式、错误码。

  3. 挑战题(⭐⭐⭐):设计 ShopMetrics 的多租户中间件完整方案——实现 SetTenantContext 中间件 + BelongsToTenant Trait + 路径参数(subdomain)识别租户,编写测试验证租户 A 无法访问租户 B 的数据。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏