Laravel: Phase 3综合练习—ShopMetrics API与实时通知
最后更新:2026-08-26
Phase 3 综合练习是"交付高级功能"——把认证、API、中间件、事件、存储全部串起来,构建生产级 API。
1. 你将学到
- Sanctum Token API 认证完整流程
- 10+ API Resource 端点:租户/商品/订单/订阅 CRUD
- 自定义中间件:TenantResolver/RateLimiter/CorsHandler
- 订单事件广播:WebSocket 实时推送到 Alice/Bob/Charlie
- S3 商品图片上传与预签名 URL 下载
2. Alice 的 Phase 3 验收故事
(1) 痛点:6 课知识零散,无法组装成完整 API
Alice 学完 Phase 3 后——Sanctum 在课 15、Resources 在课 16、中间件在课 17——但不知道怎么把它们组装成一套完整 API。Bob 让她写个 RESTful API,她发现认证、Resource 转换、中间件、事件广播、文件上传这些功能需要同时协作,单独看每个都会,组合起来就乱了。
(2) 综合练习的解法
本课从零搭建一套完整的 RESTful API——每个端点都经过认证、租户隔离、限流、Resource 转换、事件广播,最终交付一个生产级 API 系统。
BASH
# Phase 3 deliverable: Complete RESTful API with real-time features
php artisan migrate:fresh --seed
php artisan queue:work &
php artisan storage:link
# → 10+ endpoints, auth, rate limiting, broadcasting all working
(3) 收益
Alice 完成后,拥有了一套完整的 ShopMetrics API——从认证到广播端到端打通,可以直接交付给前端团队。
3. API 认证层
(1) Sanctum Token 认证流程
flowchart TD
A[Client] --> B["POST /api/auth/login<br/>(email+password)"]
B --> C["Sanctum creates Token"]
C --> D["Return plainTextToken"]
D --> E["Client stores Token"]
E --> F["API requests with<br/>Authorization: Bearer {token}"]
F --> G["Sanctum middleware<br/>resolves User"]
G --> H[TenantResolve middleware]
H --> I[Controller]
(2) 认证端点
PHP
// routes/api.php
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::post('/forgot-password', [Auth\PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [Auth\PasswordResetController::class, 'reset']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => new UserResource($r->user()));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
Route::apiResource('tokens', Auth\TokenController::class)->only(['store', 'index', 'destroy']);
});
});
| 端点 | 方法 | 认证 | 说明 |
|---|---|---|---|
| /auth/register | POST | ❌ | 注册 |
| /auth/login | POST | ❌ | 获取 Token |
| /auth/user | GET | ✅ | 当前用户 |
| /auth/logout | POST | ✅ | 撤销 Token |
| /auth/tokens | POST | ✅ | 创建新 Token |
| /auth/tokens | GET | ✅ | 列出 Tokens |
| /auth/tokens/{id} | DELETE | ✅ | 删除 Token |
▶ 示例:ShopMetrics Token 认证完整实现
PHP
// app/Http/Controllers/Auth/ApiTokenController.php
class ApiTokenController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'password' => 'required|string',
'device_name' => 'sometimes|string|max:255',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
if (!$user->tenant_id || $user->tenant->status !== 'active') {
throw ValidationException::withMessages([
'email' => ['Account is not active.'],
]);
}
$abilities = match ($user->role) {
'super_admin' => ['*'],
'tenant_owner' => ['read', 'write', 'manage-users'],
'analyst' => ['read'],
default => [],
};
$token = $user->createToken(
$request->device_name ?? 'api-token',
$abilities,
);
return response()->json([
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'abilities' => $abilities,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
4. API Resource 端点
(1) 完整端点清单
| 端点 | 方法 | Resource | 中间件 |
|---|---|---|---|
| /v1/shops | GET | ShopResource::collection | auth,tenant,throttle |
| /v1/shops | POST | ShopResource | auth,tenant,throttle,role:owner |
| /v1/shops/{id} | GET | ShopResource | auth,tenant |
| /v1/shops/{id} | PUT | ShopResource | auth,tenant,role:owner |
| /v1/shops/{id} | DELETE | - | auth,tenant,role:owner |
| /v1/shops/{id}/products | GET | ProductResource::collection | auth,tenant |
| /v1/products | POST | ProductResource | auth,tenant,role:owner |
| /v1/products/{id} | GET | ProductResource | auth,tenant |
| /v1/products/{id} | PUT | ProductResource | auth,tenant,role:owner |
| /v1/orders | GET | OrderResource::collection | auth,tenant |
| /v1/orders/{id} | GET | OrderResource | auth,tenant |
| /v1/orders/{id}/status | PATCH | OrderResource | auth,tenant,role:owner |
| /v1/analytics/overview | GET | AnalyticsResource | auth,tenant,ability:read |
| /v1/reports/generate | POST | ReportResource | auth,tenant,ability:write |
(2) 路由定义
PHP
// routes/api.php
Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api'])
->prefix('v1')->name('api.v1.')->group(function () {
// Shops
Route::apiResource('shops', Api\V1\ShopController::class);
Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class)->name('shops.logo');
// Products
Route::apiResource('products', Api\V1\ProductController::class);
// Orders
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);
// Analytics & Reports
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
Route::post('reports/generate', [Api\V1\ReportController::class, 'generate']);
// Media
Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
});
▶ 示例:ShopMetrics Order API 端点
PHP
// app/Http/Controllers/Api/V1/OrderController.php
class OrderController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Order::where('tenant_id', tenant()->id)
->with(['shop', 'user'])
->withCount('items');
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('shop_id')) {
$query->where('shop_id', $request->shop_id);
}
if ($request->filled('date_from')) {
$query->where('created_at', '>=', $request->date('date_from'));
}
$orders = $query->latest()->paginate($request->integer('per_page', 15));
return OrderResource::collection($orders);
}
public function show(Order $order): JsonResponse
{
$this->authorize('view', $order);
$order->load(['items.product', 'shop', 'user']);
return new OrderResource($order);
}
public function updateStatus(Request $request, Order $order): JsonResponse
{
$this->authorize('update', $order);
$validated = $request->validate([
'status' => 'required|in:processing,completed,cancelled,refunded',
]);
$oldStatus = $order->status;
$order->update(['status' => $validated['status']]);
event(new OrderStatusChanged($order, $oldStatus, $validated['status']));
return new OrderResource($order->fresh());
}
}
输出:
TEXT
📖 仅展示
// 执行成功
5. 自定义中间件层
▶ 示例:ShopMetrics 中间件体系
PHP
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user?->tenant_id) abort(403, 'No tenant.');
$tenant = $user->tenant;
if ($tenant->status !== 'active') abort(403, 'Tenant inactive.');
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
// app/Http/Middleware/CheckAbility.php
class CheckAbility
{
public function handle(Request $request, Closure $next, string $ability): Response
{
if ($request->user()->tokenCan('*') || $request->user()->tokenCan($ability)) {
return $next($request);
}
abort(403, "Missing ability: {$ability}");
}
}
// app/Http/Middleware/EnsureSubscriptionActive.php
class EnsureSubscriptionActive
{
public function handle(Request $request, Closure $next): Response
{
$tenant = app()->make(Tenant::class);
if (!$tenant->subscription?->isActive()) {
abort(402, 'Active subscription required.');
}
return $next($request);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例:ShopMetrics 限流配置
PHP
// app/Providers/AppServiceProvider.php
public function boot(): void
{
RateLimiter::for('tenant-api', function (Request $request) {
$tenant = app()->make(Tenant::class);
$plan = $tenant->plan ?? null;
return Limit::perMinute(match ($plan->slug ?? 'starter') {
'enterprise' => 600,
'pro' => 120,
default => 60,
})->by($tenant->id);
});
}
输出:
TEXT
📖 仅展示
// 执行成功
6. 事件广播集成
(1) 广播事件
PHP
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('tenant.' . $this->order->tenant_id)];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'shop_name' => $this->order->shop->name,
];
}
public function broadcastAs(): string { return 'order.placed'; }
}
(2) 前端 Echo 监听
JAVASCRIPT
// resources/js/app.js
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (e) => {
showNotification(`New order: ${e.order_number} ($${e.total})`);
})
.listen('.order.status_changed', (e) => {
updateOrderRow(e.order_id, e.new_status);
});
7. S3 文件上传集成
▶ 示例:ShopMetrics 图片上传端点
PHP
// app/Http/Controllers/Api/V1/ShopLogoController.php
class ShopLogoController extends Controller
{
public function __invoke(Request $request, Shop $shop): JsonResponse
{
$this->authorize('update', $shop);
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
if ($shop->logo_path) {
Storage::disk('s3')->delete($shop->logo_path);
}
$path = $request->file('logo')->store(
"shops/{$shop->id}/logos",
's3',
);
$shop->update(['logo_path' => $path]);
return response()->json([
'message' => 'Logo uploaded.',
'logo_url' => Storage::disk('s3')->url($path),
]);
}
}
// app/Http/Controllers/Api/V1/MediaController.php
class MediaController extends Controller
{
public function presign(Request $request): JsonResponse
{
$validated = $request->validate([
'filename' => 'required|string',
'mime_type' => 'required|in:image/jpeg,image/png,image/webp',
]);
$path = 'uploads/' . tenant()->id . '/' . Str::uuid() . '/' . $validated['filename'];
$url = Storage::disk('s3')->temporaryUploadUrl($path, now()->addMinutes(30));
return response()->json(['upload_url' => $url, 'path' => $path]);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
8. 综合示例:ShopMetrics Phase 3 完整 API
PHP
// ============================================
// Comprehensive: ShopMetrics Phase 3 Complete API
// Covers: auth, resources, middleware, events, storage
// ============================================
// routes/api.php — Complete API routes
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => new UserResource($r->user()));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
});
});
Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api', 'subscription.active'])
->prefix('v1')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class);
Route::apiResource('products', Api\V1\ProductController::class);
Route::post('products/{product}/images', Api\V1\ProductImageController::class);
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview'])
->middleware('ability:read');
Route::post('reports/generate', [Api\V1\ReportController::class, 'generate'])
->middleware('ability:write');
Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
});
// routes/channels.php
Broadcast::channel('tenant.{tenantId}', fn ($user, $tenantId) => $user->tenant_id === (int) $tenantId);
// Broadcasting configuration
// BROADCAST_CONNECTION=redis
// QUEUE_CONNECTION=redis
// Run: php artisan queue:work
// Run: php artisan reverb:start (Laravel 11 built-in WebSocket server)
❓ 常见问题
Q Phase 3 练习需要多少时间?
A 大约 6-8 小时。认证 1.5h,API Resources + 端点 2h,中间件 1h,事件广播 1.5h,文件上传 1h,联调 1h。建议每完成一个模块就用 Postman 测试。
Q 如何联调前后端 API?
A 用 Postman/Newman 先测试所有端点,记录 Collection。前端开发时用 Postman 的 Mock Server 或直接连后端。确保 CORS 配置正确。
Q WebSocket 服务用 Reverb 还是 Pusher?
A Laravel 11 内置 Reverb(自托管 WebSocket 服务器),开发用 Reverb 免费快速;生产环境根据规模选 Reverb(自托管)或 Pusher(托管)。
Q API 文档怎么生成?
A 推荐用 Scribe(
knuckleswtf/scribe)自动从代码生成 OpenAPI 文档,比手写 Postman 更不容易过时。每个控制器方法加 PHPDoc 注释即可。Q 限流如何测试?
A 用 Postman Runner 或 ab(Apache Bench)发送超过限额的请求,验证返回 429 状态码和 Retry-After 头。也可以写 PHPUnit 测试模拟限流。
Q S3 上传在本地开发怎么测试?
A 用 MinIO(S3 兼容的本地服务)替代真实 S3:
docker run -p 9000:9000 minio/minio server /data。修改 AWS_URL 指向 localhost:9000 即可。📖 小节
- Sanctum Token 认证为 API 提供无状态鉴权
- API Resources 统一 JSON 输出格式,whenLoaded 条件嵌套关联
- 中间件栈:CORS → TenantResolve → Auth → Throttle → Ability
- 事件广播让订单变更实时推送到前端
- S3 文件上传支持直传(预签名 URL)和服务器中转
- Phase 3 完成后拥有一套生产级 RESTful API
📝 作业
-
基础题(⭐):搭建 ShopMetrics API 认证层,实现 register/login/logout 三个端点,用 Postman 测试获取 Token 并访问受保护端点。
-
进阶题(⭐⭐):实现完整的 Shop + Product CRUD API,包含 Resource 转换、TenantResolve 隔离、基于订阅的限流,使用 Postman Collection 测试所有端点。
-
挑战题(⭐⭐⭐):实现订单状态变更的实时广播——后端 PATCH /orders/{id}/status 触发 OrderStatusChanged 事件,前端 Echo 监听更新 UI,同时记录 API 日志到数据库(使用 terminate 中间件)。