签入版本
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# 4.2 请求处理链路
|
||||
|
||||
:::tip 链路概览
|
||||
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS 中间件 → Auth 中间件(JWT 认证 + RBAC 鉴权)→ Tenant 中间件 → Demo 中间件 → Log 中间件 → 控制器 → 逻辑层 → 模型层 → 数据库 → 响应序列化 → 浏览器
|
||||
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → Proxy 中间件(真实 IP)→ CORS → 强制 JSON → auth.jwt(JWT 认证 + RBAC 鉴权)→ tenant(租户上下文)→ log.operation(操作日志采集)→ FormRequest 校验 → 控制器 → 服务层 → 模型层 → 数据库 → 响应序列化 → 浏览器 →(响应后)操作日志落库
|
||||
:::
|
||||
|
||||
本节以 `POST /api/example/add` 添加案例为例,逐步拆解完整链路。
|
||||
本节以 `POST /api/example/add` 添加案例为例,逐步拆解完整链路(代码摘自项目实际源码)。
|
||||
|
||||
## 第一阶段:网络层
|
||||
|
||||
@@ -22,18 +22,17 @@ export function exampleAdd(data: any) {
|
||||
```
|
||||
|
||||
前端通过 Axios 发起 HTTP POST 请求,携带:
|
||||
|
||||
- `Authorization: Bearer <JWT Token>` 请求头(由请求拦截器自动添加)
|
||||
- `Content-Type: application/json` 请求头
|
||||
- JSON 请求体
|
||||
|
||||
### 2. Nginx 反向代理(生产环境)
|
||||
### 2. Nginx 反向代理(生产环境 / Docker)
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# docker/nginx.conf(本项目实际配置)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -53,112 +52,197 @@ server: {
|
||||
|
||||
## 第二阶段:路由解析
|
||||
|
||||
### 3. ThinkPHP 入口
|
||||
### 3. Laravel 入口
|
||||
|
||||
```text
|
||||
public/index.php → 引导框架 → 路由解析 route/app.php
|
||||
public/index.php → bootstrap/app.php(中间件别名/分组、健康检查)→ 路由解析 routes/api.php
|
||||
```
|
||||
|
||||
### 4. 路由匹配
|
||||
|
||||
```php
|
||||
// route/app.php
|
||||
// routes/api.php
|
||||
|
||||
// 公开接口(无需认证)
|
||||
Route::group('api', function () {
|
||||
Route::post('login', 'LoginController/login');
|
||||
Route::get('captcha', 'LoginController/captcha');
|
||||
// 公开接口(无需认证)——放在中间件组外
|
||||
Route::post('login', [Api\AuthController::class, 'login']);
|
||||
|
||||
// 认证接口
|
||||
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () {
|
||||
// --- 案例演示 ---
|
||||
Route::post('example/add', [Api\ExampleController::class, 'add']); // ← 匹配到此处
|
||||
});
|
||||
```
|
||||
|
||||
// 需认证接口
|
||||
Route::group('api', function () {
|
||||
Route::group('example', function () {
|
||||
Route::post('add', 'ExampleController/add'); // ← 匹配到此处
|
||||
});
|
||||
})->middleware([
|
||||
\app\middleware\AuthMiddleware::class, // JWT 认证 + 权限校验
|
||||
\app\middleware\TenantMiddleware::class, // 租户上下文
|
||||
\app\middleware\DemoMiddleware::class, // 演示环境拦截
|
||||
\app\middleware\LogMiddleware::class, // 操作日志
|
||||
同时,`bootstrap/app.php` 的全局 `api` 中间件组已 prepend 三个前置中间件:
|
||||
|
||||
```php
|
||||
// bootstrap/app.php
|
||||
$middleware->alias([
|
||||
'proxy' => ProxyMiddleware::class,
|
||||
'cors' => Cors::class,
|
||||
'force.json' => ForceJsonResponse::class,
|
||||
'auth.jwt' => Authenticate::class,
|
||||
'tenant' => ResolveTenant::class,
|
||||
'demo' => DemoMiddleware::class,
|
||||
'log.operation' => LogOperation::class,
|
||||
]);
|
||||
|
||||
// API 中间件组(按顺序:Proxy → Cors → ForceJson)
|
||||
$middleware->api(prepend: [
|
||||
ProxyMiddleware::class,
|
||||
Cors::class,
|
||||
ForceJsonResponse::class,
|
||||
]);
|
||||
```
|
||||
|
||||
路由匹配到 `POST api/example/add` → `ExampleController::add()`,同时绑定四个中间件。
|
||||
完整链路:`ProxyMiddleware → Cors → ForceJsonResponse → auth.jwt → tenant → log.operation → 控制器`。
|
||||
|
||||
## 第三阶段:中间件链(按顺序执行)
|
||||
|
||||
### 5. CorsMiddleware(全局中间件)
|
||||
### 5. ProxyMiddleware(全局,真实 IP)
|
||||
|
||||
```php
|
||||
// app/middleware/CorsMiddleware.php
|
||||
// app/Http/Middleware/ProxyMiddleware.php(节选)
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 处理 OPTIONS 预检请求
|
||||
if ($request->isOptions()) {
|
||||
$response = Response::create('', 'html', 204);
|
||||
$remoteAddr = $request->server('REMOTE_ADDR', '');
|
||||
$proxyIps = $this->getTrustedProxies();
|
||||
|
||||
// 当前连接来源不在受信列表中,跳过(防止伪造代理头)
|
||||
if (! $this->isTrustedProxy($remoteAddr, $proxyIps)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 按优先级提取真实 IP(X-Real-IP > X-Forwarded-For 第一个合法值)
|
||||
$realIp = $this->extractRealIp($request);
|
||||
|
||||
if (! empty($realIp) && $realIp !== $remoteAddr) {
|
||||
// 通过 server() 方法替换 REMOTE_ADDR,后续 ip() 即返回真实 IP
|
||||
$server = $request->server->all();
|
||||
$server['REMOTE_ADDR'] = $realIp;
|
||||
$request->server->replace($server);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
```
|
||||
|
||||
- 部署在 CDN / 负载均衡后时保证 `request()->ip()` 正确(日志、限流依赖)
|
||||
- 受信代理由 `.env` 的 `TRUSTED_PROXIES` 配置,未配置默认信任 `127.0.0.1` 与 `::1`
|
||||
|
||||
### 6. Cors(全局,跨域处理)
|
||||
|
||||
```php
|
||||
// app/Http/Middleware/Cors.php(节选)
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 预检请求直接返回,不进入业务逻辑(Authenticate 等中间件不会执行)
|
||||
if ($request->isMethod('OPTIONS')) {
|
||||
$response = response('', 204);
|
||||
} else {
|
||||
$response = $next($request);
|
||||
}
|
||||
|
||||
// 设置跨域响应头
|
||||
$response->header([
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, ...',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);
|
||||
$config = config('cors');
|
||||
$origin = $request->header('Origin', '');
|
||||
$allowOrigins = $config['allow_origin'] ?? ['*'];
|
||||
$allowCredentials = $config['allow_credentials'] ?? true;
|
||||
|
||||
$originAllowed = false;
|
||||
|
||||
if (in_array('*', $allowOrigins)) {
|
||||
if ($allowCredentials && $origin) {
|
||||
// 凭证模式下不能用 *,回显具体 Origin
|
||||
$response->headers->set('Access-Control-Allow-Origin', $origin);
|
||||
$response->headers->set('Vary', 'Origin'); // 防止 CDN 缓存错误的 CORS 响应
|
||||
} else {
|
||||
$response->headers->set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
$originAllowed = true;
|
||||
} elseif ($origin && in_array($origin, $allowOrigins)) {
|
||||
// 白名单匹配:仅回显请求的 Origin
|
||||
$response->headers->set('Access-Control-Allow-Origin', $origin);
|
||||
$response->headers->set('Vary', 'Origin');
|
||||
$originAllowed = true;
|
||||
}
|
||||
|
||||
// 仅在 Origin 允许时设置其余 CORS 头
|
||||
if ($originAllowed) {
|
||||
$response->headers->set('Access-Control-Allow-Methods', $config['allow_methods'] ?? 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
||||
$response->headers->set('Access-Control-Allow-Headers', $config['allow_headers'] ?? 'Content-Type, Authorization');
|
||||
$response->headers->set('Access-Control-Expose-Headers', $config['expose_headers'] ?? 'Authorization');
|
||||
$response->headers->set('Access-Control-Max-Age', $config['max_age'] ?? 3600);
|
||||
|
||||
if ($allowCredentials) {
|
||||
$response->headers->set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
```
|
||||
|
||||
- 每个请求都经过(全局中间件)
|
||||
- OPTIONS 预检请求直接返回 204
|
||||
- 普通请求附加 CORS 响应头
|
||||
|
||||
### 6. AuthMiddleware(JWT 认证 + 权限校验)
|
||||
### 7. ForceJsonResponse(全局,强制 JSON)
|
||||
|
||||
```php
|
||||
// app/middleware/AuthMiddleware.php
|
||||
// app/Http/Middleware/ForceJsonResponse.php
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 1. 检查排除路由(login/captcha 等直接放行)
|
||||
$path = $request->pathinfo();
|
||||
foreach ($exceptList as $except) {
|
||||
if (strpos($path, $except) !== false) {
|
||||
return $next($request);
|
||||
}
|
||||
// 强制声明期望 JSON,确保异常与验证失败均以 JSON 返回
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
```
|
||||
|
||||
使后续 `Handler::render()` 的 `$request->wantsJson()` 恒为 true,框架错误页不会以 HTML 形式返回。
|
||||
|
||||
### 8. Authenticate(auth.jwt,JWT 认证 + 权限校验)
|
||||
|
||||
```php
|
||||
// app/Http/Middleware/Authenticate.php(节选)
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 获取 token:带 Bearer 前缀则剥离,裸 token 直接使用
|
||||
$header = $request->header('Authorization', '');
|
||||
if ($header === '') {
|
||||
return response()->json([
|
||||
'code' => 401, 'ok' => false,
|
||||
'msg' => '未提供认证令牌', 'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 从请求头获取 Token
|
||||
$token = Jwt::getTokenFromHeader();
|
||||
if (empty($token)) {
|
||||
return json(['code' => 401, 'msg' => '未提供认证令牌']);
|
||||
}
|
||||
$prefix = config('jwt.header_prefix', 'Bearer ');
|
||||
$token = str_starts_with($header, $prefix) ? substr($header, strlen($prefix)) : $header;
|
||||
|
||||
// 3. 解析 Token,校验签名和有效期
|
||||
// 解析 token
|
||||
$jwtService = app(JwtService::class);
|
||||
try {
|
||||
$decoded = Jwt::decode($token);
|
||||
} catch (\RuntimeException $e) {
|
||||
return json(['code' => 401, 'msg' => $e->getMessage()]);
|
||||
$decoded = $jwtService->parseToken($token);
|
||||
} catch (\Exception $e) {
|
||||
// 令牌解析失败:body code=401,消息取自异常
|
||||
return response()->json([
|
||||
'code' => 401, 'ok' => false,
|
||||
'msg' => $e->getMessage(), 'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. 校验 token 类型必须为 access
|
||||
if (($decoded->type ?? '') !== 'access') {
|
||||
return json(['code' => 401, 'msg' => '无效的认证令牌']);
|
||||
}
|
||||
// 注入用户信息,供控制器通过 $request->attributes->get('userInfo') 读取
|
||||
$request->attributes->set('userInfo', $decoded);
|
||||
|
||||
// 5. 注入用户信息到 Request
|
||||
$request->userInfo = $decoded;
|
||||
|
||||
// 6. 读取 #[Permission] 注解,校验权限
|
||||
$permission = AttributeService::getPermission($controllerClass, $action);
|
||||
if ($permission !== null) {
|
||||
$hasPermission = $this->checkPermission($decoded->uid, $permission->code);
|
||||
if (!$hasPermission) {
|
||||
return json(['code' => 403, 'msg' => '无访问权限']);
|
||||
// 权限校验:仅当方法标注了 #[Permission] 时才校验
|
||||
$permissionCode = $this->getPermissionCode($request);
|
||||
if ($permissionCode !== null) {
|
||||
$uid = $decoded->uid ?? 0;
|
||||
if (! $this->checkPermission($uid, $permissionCode)) {
|
||||
return response()->json([
|
||||
'code' => 403, 'ok' => false,
|
||||
'msg' => '无访问权限:'.$permissionCode, 'data' => null,
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,57 +255,76 @@ public function handle(Request $request, \Closure $next): Response
|
||||
```text
|
||||
请求进入
|
||||
│
|
||||
├─ 路径在排除列表? → 直接放行
|
||||
├─ 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)
|
||||
│ └─ 头为空 → 401「未提供认证令牌」
|
||||
│
|
||||
├─ 提取 Authorization: Bearer <token>
|
||||
│ └─ 为空 → 返回 401
|
||||
├─ JwtService::parseToken($token) 解析验证
|
||||
│ └─ 失败(过期/签名错误)→ 401(消息取自异常)
|
||||
│
|
||||
├─ Jwt::decode($token) 解析验证
|
||||
│ └─ 失败(过期/签名错误)→ 返回 401
|
||||
├─ 注入 $request->attributes['userInfo'] = {uid, username, ...}
|
||||
│
|
||||
├─ 校验 type === 'access'
|
||||
│ └─ 不是 → 返回 401
|
||||
│
|
||||
├─ 注入 $request->userInfo = {uid, username, type, exp}
|
||||
│
|
||||
├─ 读取 #[Permission] 注解
|
||||
│ ├─ 无注解 → 跳过权限校验
|
||||
│ └─ 有注解 → checkPermission()
|
||||
├─ 反射读取控制器方法的 #[Permission] 注解(ReflectionMethod,微秒级)
|
||||
│ ├─ 无注解 → 跳过权限校验,仅完成认证
|
||||
│ └─ 有注解 → checkPermission(uid, code)
|
||||
│ ├─ uid === 1 → 直接放行(超级管理员)
|
||||
│ └─ 查询 UserRole → RoleMenu → Menu.permission
|
||||
│ ├─ 包含 → 放行
|
||||
│ └─ 不包含 → 返回 403
|
||||
│ ├─ Redis 读 perms:{uid}
|
||||
│ │ ├─ 命中 *:*:* 或含目标编码 → 通过
|
||||
│ │ └─ Redis 不可用 → 降级
|
||||
│ ├─ Laravel Cache 读 perms_fallback:{uid}(300 秒)
|
||||
│ └─ 未命中 → 查库 user_role → role_menu → menu.permission
|
||||
│ ├─ 包含 → 放行(回填二级缓存)
|
||||
│ └─ 不包含 → 403「无访问权限:sys:example:add」
|
||||
│
|
||||
└─ 放行 → 进入下一个中间件
|
||||
```
|
||||
|
||||
### 7. TenantMiddleware(租户上下文)
|
||||
:::tip 三级权限缓存
|
||||
Redis(`perms:{uid}`,一级)→ Laravel Cache(`perms_fallback:{uid}` 5 分钟,二级)→ 数据库(回填)。菜单/角色菜单变更时 Observer 清除相关缓存,保证权限调整即时生效。
|
||||
:::
|
||||
|
||||
### 9. ResolveTenant(tenant,租户上下文)
|
||||
|
||||
```php
|
||||
// app/middleware/TenantMiddleware.php
|
||||
// app/Http/Middleware/ResolveTenant.php(节选)
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$userInfo = $request->userInfo ?? null;
|
||||
$userId = $userInfo->uid ?? 0;
|
||||
$userInfo = $request->attributes->get('userInfo');
|
||||
if (! $userInfo) {
|
||||
// 未认证请求:不注入租户上下文,直接放行
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$uid = $userInfo->uid ?? 0;
|
||||
|
||||
// 超级管理员(uid=1):跳过租户校验,直接注入 tenantId=0
|
||||
if ($uid === 1) {
|
||||
$request->attributes->set('tenantId', 0);
|
||||
$request->attributes->set('isSuperAdmin', true);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 非超管:查库取 tenant_id(JWT 不含 tenant_id,需查库)
|
||||
$user = User::find($uid);
|
||||
if (! $user) {
|
||||
return response()->json([
|
||||
'code' => 401, 'ok' => false,
|
||||
'msg' => '用户不存在,请重新登录', 'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::find($userId);
|
||||
$tenantId = $user->tenant_id ?? 0;
|
||||
$request->attributes->set('tenantId', $tenantId);
|
||||
$request->attributes->set('isSuperAdmin', false);
|
||||
|
||||
// 注入租户上下文
|
||||
$request->tenantId = $tenantId;
|
||||
$request->isSuperAdmin = ($tenantId == 0);
|
||||
|
||||
// 非超级管理员校验租户状态
|
||||
// 平台用户(tenantId=0):跳过租户校验
|
||||
if ($tenantId > 0) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (!$tenant || $tenant->status != 1) {
|
||||
return json(['code' => 403, 'msg' => '租户已被禁用']);
|
||||
}
|
||||
if ($tenant->expire_time && strtotime($tenant->expire_time) < time()) {
|
||||
return json(['code' => 403, 'msg' => '租户已过期']);
|
||||
}
|
||||
$request->tenantInfo = $tenant;
|
||||
|
||||
// 租户不存在 / 被禁用(status != 1)/ 已过期(expire_time->isPast())
|
||||
// → 均返回 403 对应提示
|
||||
$request->attributes->set('tenantInfo', $tenant);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
@@ -229,208 +332,177 @@ public function handle(Request $request, \Closure $next): Response
|
||||
```
|
||||
|
||||
注入内容:
|
||||
- `$request->tenantId` — 租户 ID(0=超级管理员)
|
||||
- `$request->isSuperAdmin` — 是否超级管理员
|
||||
- `$request->tenantInfo` — 租户信息对象
|
||||
|
||||
### 8. DemoMiddleware(演示环境拦截)
|
||||
- `tenantId` — 租户 ID(0=平台用户)
|
||||
- `isSuperAdmin` — 是否超级管理员(uid=1)
|
||||
- `tenantInfo` — 租户模型实例(`tenantId > 0` 时)
|
||||
|
||||
### 10. LogOperation(log.operation,操作日志采集)
|
||||
|
||||
`handle` 阶段**只收集不写入**,`terminate` 阶段在响应完成后落库:
|
||||
|
||||
```php
|
||||
// app/middleware/DemoMiddleware.php
|
||||
// app/Http/Middleware/LogOperation.php(节选)
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 非演示环境直接放行
|
||||
if (!env('app_demo', false)) {
|
||||
return $next($request);
|
||||
$this->logContext = [
|
||||
'source' => 0, // 来源端:0=后台,1=前台
|
||||
'method' => strtoupper($request->method()), // HTTP 方法
|
||||
'request_method' => $request->route()?->getActionMethod() ?? '', // 控制器方法名
|
||||
'url' => $request->fullUrl(), // 完整请求地址
|
||||
'param' => mb_substr(json_encode($request->except(['password', 'token']), JSON_UNESCAPED_UNICODE), 0, 2000), // 排除敏感字段,截断 2000 字
|
||||
'ip' => $request->ip(),
|
||||
];
|
||||
|
||||
$this->logAttr = $this->getLogAttribute($request); // 反射读取 #[OperationLog]
|
||||
$this->shouldLog = $this->determineIfShouldLog($request->method(), $this->logAttr);
|
||||
|
||||
// 预创建请求信息服务(handle 阶段 request 信息完整,terminate 阶段可能已丢失)
|
||||
if ($this->shouldLog) {
|
||||
$this->requestInfo = RequestInfoService::create($request);
|
||||
}
|
||||
|
||||
// GET 请求放行(读操作)
|
||||
if (strtoupper($request->method()) === 'GET') {
|
||||
return $next($request);
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
public function terminate(Request $request, Response $response): void
|
||||
{
|
||||
if (! $this->shouldLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 #[DemoAllow] 注解
|
||||
if (AttributeService::hasDemoAllow($controllerClass, $action)) {
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
// 补充响应内容、IP 归属地、操作系统、浏览器、耗时、状态
|
||||
$this->logContext['result'] = mb_substr($response->getContent() ?? '', 0, 2000);
|
||||
$this->logContext['location'] = $this->requestInfo->getIpLocation();
|
||||
$this->logContext['os'] = $this->requestInfo->getOs();
|
||||
$this->logContext['browser'] = $this->requestInfo->getBrowser();
|
||||
$this->logContext['consume_time'] = defined('LARAVEL_START')
|
||||
? round((microtime(true) - LARAVEL_START) * 1000) : 0;
|
||||
$this->logContext['status'] = $this->detectStatus($response->getContent()); // code=0/200 → 0,其余 → 1
|
||||
$this->logContext['create_user'] = $request->attributes->get('userInfo')->username ?? '';
|
||||
|
||||
// 拦截写操作
|
||||
return json(['code' => 403, 'msg' => '演示环境,禁止操作']);
|
||||
OperationLog::create($this->logContext);
|
||||
} catch (\Exception $e) {
|
||||
// 日志写入失败不影响业务
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. LogMiddleware(操作日志)
|
||||
记录规则(`determineIfShouldLog`):
|
||||
|
||||
```php
|
||||
// app/middleware/LogMiddleware.php
|
||||
- `POST/PUT/DELETE/PATCH` 一律记录
|
||||
- `GET` 仅在注解类型为 `EXPORT / DOWNLOAD / IMPORT` 时记录
|
||||
- 其它方法(OPTIONS/HEAD)不记录
|
||||
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 执行后续中间件和控制器
|
||||
$response = $next($request);
|
||||
|
||||
// 写操作始终记录
|
||||
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'])) {
|
||||
// 读取 #[Log] 注解
|
||||
$logAttr = AttributeService::getLog($controllerClass, $action);
|
||||
|
||||
// 组装日志数据
|
||||
$logData = [
|
||||
'title' => $logAttr?->title ?: $path,
|
||||
'type' => $logAttr?->type ?: $this->guessType($method),
|
||||
'method' => $method,
|
||||
'url' => $request->url(),
|
||||
'param' => json_encode($request->param()),
|
||||
'ip' => $requestInfo->getIp(),
|
||||
'location' => $requestInfo->getIpLocation(),
|
||||
'os' => $requestInfo->getOs(),
|
||||
'browser' => $requestInfo->getBrowser(),
|
||||
'consume_time'=> round((microtime(true) - $startTime) * 1000),
|
||||
'status' => $this->detectStatus($response->getContent()),
|
||||
];
|
||||
|
||||
// 写入数据库(失败不影响主业务)
|
||||
OperationLog::create($logData);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
```
|
||||
:::tip DemoMiddleware 说明
|
||||
演示模式中间件(`demo` 别名)已挂载于认证路由组(`auth.jwt` → `tenant` → `demo` → `log.operation`):非演示模式(`DEMO_MODE=false`)直接放行;演示模式下 GET 放行、写操作需 `#[DemoAllow]` 注解,否则 403。拦截发生在 `LogOperation` 之前,被拦截请求不产生操作日志。
|
||||
:::
|
||||
|
||||
## 第四阶段:控制器层
|
||||
|
||||
### 10. ExampleController::add()
|
||||
### 11. FormRequest 参数校验(进入方法前)
|
||||
|
||||
```php
|
||||
// app/controller/ExampleController.php
|
||||
// 控制器方法签名声明 ExampleRequest,框架在校验通过后才调用方法
|
||||
public function add(ExampleRequest $request): JsonResponse
|
||||
```
|
||||
|
||||
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
|
||||
#[Permission('sys:example:add', '添加案例演示')]
|
||||
public function add(): Json
|
||||
- `rules()` 未通过 → `BaseRequest::failedValidation()` 直接返回
|
||||
`{"code":1,"ok":false,"msg":"参数验证失败","data":{"name":["案例名称不能为空"]}}`
|
||||
- 校验通过 → 控制器方法执行,`$request->validated()` 取已校验数据(键名转换在 Service 写入入口完成)
|
||||
|
||||
### 12. ExampleController::add()
|
||||
|
||||
```php
|
||||
// app/Http/Controllers/Api/ExampleController.php
|
||||
|
||||
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
|
||||
#[Permission('sys:example:add', '添加示例')]
|
||||
public function add(ExampleRequest $request): JsonResponse
|
||||
{
|
||||
// getJsonBody() 获取前端 POST 提交的 JSON 数据
|
||||
// _add() 内部调用 $this->logic->add($data)
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
return $this->_add($this->exampleService, $request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
控制器层职责:
|
||||
- 通过 `#[Permission]` 声明所需权限
|
||||
- 通过 `#[Log]` 声明日志信息
|
||||
- 获取参数,调用 Logic 层,返回响应
|
||||
控制器层职责:注解声明权限与日志 → 参数由 FormRequest 保证 → 调用快捷方法 → 返回统一响应。
|
||||
|
||||
## 第五阶段:逻辑层
|
||||
## 第五阶段:服务层
|
||||
|
||||
### 11. ExampleLogic::add()
|
||||
### 13. ExampleService::add()(HasCrudOperations 主流程)
|
||||
|
||||
```php
|
||||
// app/logic/ExampleLogic.php → 继承 BaseLogic
|
||||
|
||||
// BaseLogic::add() 的完整流程:
|
||||
public function add(array $data): int
|
||||
{
|
||||
// 1. 钩子:新增前(可修改数据,抛异常可拦截)
|
||||
$data = $this->beforeAdd($data);
|
||||
|
||||
// 2. 保留原始数据供 afterAdd 使用
|
||||
$originalData = $data;
|
||||
|
||||
// 3. 入库前转换:camelCase → snake_case
|
||||
$data = array_camel_to_snake($data);
|
||||
|
||||
// 4. 租户ID自动填充
|
||||
$data = $this->processTenantId($data);
|
||||
|
||||
// 5. 文件字段:迁移临时文件,去掉域名
|
||||
$data = $this->processFileFieldsOnSave($data);
|
||||
|
||||
// 6. 富文本字段:迁移临时文件
|
||||
$data = $this->processContentFieldsOnSave($data);
|
||||
|
||||
// 7. 过滤非数据库字段
|
||||
$saveData = $this->filterTableFields($data);
|
||||
|
||||
// 8. 唯一性校验
|
||||
$this->checkUnique($saveData);
|
||||
|
||||
// 9. 写入数据库
|
||||
$model = $this->getModel()->create($saveData);
|
||||
$id = $model->id;
|
||||
|
||||
// 10. 钩子:新增后
|
||||
$this->afterAdd($id, $originalData);
|
||||
|
||||
return $id;
|
||||
}
|
||||
```text
|
||||
$this->_add($service, $data)
|
||||
│
|
||||
▼
|
||||
Service::add($data) ← HasCrudOperations
|
||||
├─ beforeAdd($data) 钩子 ← 可改写数据、抛 BizException 拦截
|
||||
├─ checkUnique($data) ← uniqueFields 唯一性校验,重复抛 BizException
|
||||
├─ DB::transaction { ← 事务包裹(任一异常自动回滚)
|
||||
│ Model::create($data) ← Eloquent 写入
|
||||
│ }
|
||||
├─ afterAdd($id, $data) 钩子 ← 关联处理(抛异常同样回滚)
|
||||
└─ return $id
|
||||
```
|
||||
|
||||
:::tip 异常即控制流
|
||||
`BizException` 在钩子/唯一性校验中抛出 → 中断事务闭包回滚 → 由 `_add()` 捕获转为 `fail(消息, code)`,或由全局 `Handler` 兜底——任何路径前端拿到的结构一致。
|
||||
:::
|
||||
|
||||
## 第六阶段:模型层
|
||||
|
||||
### 12. BaseModel 自动处理
|
||||
### 14. HasAuditColumns 自动填充 + SQL 执行
|
||||
|
||||
```php
|
||||
// app/model/Example.php
|
||||
class Example extends BaseModel
|
||||
{
|
||||
protected $name = 'example';
|
||||
}
|
||||
|
||||
// BaseModel 提供的能力:
|
||||
// 1. 全局查询范围 soft_delete → WHERE is_delete = 0
|
||||
// 2. onBeforeInsert → 自动填充 create_user, create_time
|
||||
// 3. onBeforeUpdate → 自动填充 update_user, update_time
|
||||
// app/Models/Concerns/HasAuditColumns.php(节选)
|
||||
static::creating(function ($model) {
|
||||
$model->create_user = $model->create_user ?? Auth::username();
|
||||
$model->create_time = $model->create_time ?? now();
|
||||
});
|
||||
```
|
||||
|
||||
### 13. 数据库执行
|
||||
|
||||
ThinkPHP ORM 将操作转换为 SQL:
|
||||
Eloquent 将操作转换为 SQL:
|
||||
|
||||
```sql
|
||||
INSERT INTO think_example (name, status, sort, avatar, create_user, create_time)
|
||||
VALUES ('测试案例', 1, 0, '', 'admin', '2026-09-20 12:00:00');
|
||||
INSERT INTO laravel_example (name, status, sort, create_user, create_time)
|
||||
VALUES ('测试案例', 1, 0, 'admin', '2026-10-02 12:00:00');
|
||||
```
|
||||
|
||||
返回自增 ID(MySQL `lastInsertId`;Oracle 走序列 CURRVAL,见 `OracleProcessor`)。
|
||||
|
||||
## 第七阶段:响应返回
|
||||
|
||||
### 14. 响应序列化
|
||||
### 15. 响应序列化
|
||||
|
||||
```php
|
||||
// BaseController::_add()
|
||||
protected function _add(BaseLogic $logic, array $data): Json
|
||||
{
|
||||
$id = $logic->add($data);
|
||||
return $this->success(['id' => $id], '添加成功');
|
||||
}
|
||||
// Controller::_add() 内部
|
||||
$id = $service->add($data);
|
||||
return $this->success(['id' => $id], '添加成功');
|
||||
|
||||
// Result::success()
|
||||
public static function success($data, string $msg = '操作成功'): Json
|
||||
{
|
||||
return json([
|
||||
'code' => 0,
|
||||
'ok' => true,
|
||||
'msg' => $msg,
|
||||
'data' => self::convertData($data), // snake_case → camelCase
|
||||
]);
|
||||
}
|
||||
// Support\Result::success()(节选)
|
||||
return response()->json([
|
||||
'code' => self::SUCCESS, // 0
|
||||
'ok' => true,
|
||||
'msg' => $msg,
|
||||
'data' => convert_to_camel($data), // snake_case → camelCase
|
||||
]);
|
||||
```
|
||||
|
||||
### 15. 浏览器接收响应
|
||||
### 16. 浏览器接收响应 + 日志落库
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "添加成功",
|
||||
"data": {
|
||||
"id": 42
|
||||
}
|
||||
"data": { "id": 42 }
|
||||
}
|
||||
```
|
||||
|
||||
前端 Axios 拦截器统一处理:`code === 0` 时展示成功提示,否则展示错误信息。
|
||||
响应发送后,Laravel 调用 `LogOperation::terminate()` 将本次操作写入 `laravel_operation_log`(耗时、状态、归属地等)。
|
||||
|
||||
前端 Axios 拦截器统一处理:`code === 0` 时展示成功提示,否则展示错误信息;401 触发登出跳转。
|
||||
|
||||
## 完整链路流程图
|
||||
|
||||
@@ -440,93 +512,75 @@ public static function success($data, string $msg = '操作成功'): Json
|
||||
│ Authorization: Bearer <token>
|
||||
▼
|
||||
[Nginx / Vite Proxy] ── 反向代理转发
|
||||
│
|
||||
▼
|
||||
[ThinkPHP 入口] ── public/index.php
|
||||
│
|
||||
[public/index.php] ── bootstrap/app.php 引导
|
||||
▼
|
||||
[路由解析] ── route/app.php 匹配 api/example/add
|
||||
│
|
||||
[路由解析] ── routes/api.php 匹配 api/example/add(挂 auth.jwt + tenant + demo + log.operation)
|
||||
▼
|
||||
[CorsMiddleware] ── 跨域校验通过(全局)
|
||||
│
|
||||
[ProxyMiddleware] ── 受信代理校验,替换真实 REMOTE_ADDR(全局)
|
||||
▼
|
||||
[AuthMiddleware]
|
||||
├─ 提取 Token → Jwt::decode()
|
||||
├─ 校验 type === 'access'
|
||||
├─ 注入 $request->userInfo
|
||||
├─ 读取 #[Permission] 注解
|
||||
├─ checkPermission() 权限校验
|
||||
│ ├─ uid=1 → 跳过(超级管理员)
|
||||
│ └─ UserRole → RoleMenu → Menu.permission
|
||||
└─ 放行
|
||||
│
|
||||
[Cors] ── OPTIONS 直接 204;响应追加 CORS 头(全局)
|
||||
▼
|
||||
[TenantMiddleware]
|
||||
├─ 查询用户 tenant_id
|
||||
├─ 注入 $request->tenantId
|
||||
└─ 校验租户状态(禁用/过期 → 403)
|
||||
│
|
||||
[ForceJsonResponse] ── Accept 强制 application/json(全局)
|
||||
▼
|
||||
[Authenticate]
|
||||
├─ 提取 Authorization token(头为空 → 401)
|
||||
├─ JwtService::parseToken()(过期/签名错 → 401)
|
||||
├─ 注入 attributes['userInfo']
|
||||
├─ 反射读取 #[Permission] 注解
|
||||
└─ checkPermission():uid=1 跳过 → Redis perms:{uid} → Cache 降级 → 查库
|
||||
└─ 不通过 → 403「无访问权限:sys:example:add」
|
||||
▼
|
||||
[ResolveTenant]
|
||||
├─ 无 userInfo → 放行
|
||||
├─ uid=1 → tenantId=0, isSuperAdmin=true
|
||||
└─ 非超管 → 查库 tenant_id → 状态/过期校验(禁用/过期/不存在 → 403)
|
||||
▼
|
||||
[DemoMiddleware]
|
||||
├─ 非演示环境 → 放行
|
||||
├─ 非演示模式(DEMO_MODE=false)→ 放行
|
||||
├─ GET 请求 → 放行
|
||||
└─ 有 #[DemoAllow] → 放行
|
||||
│
|
||||
└─ 写操作:有 #[DemoAllow] 放行,否则 403「演示模式下禁止操作」
|
||||
▼
|
||||
[LogMiddleware] ── 记录开始时间
|
||||
│
|
||||
[LogOperation::handle] ── 收集上下文、读 #[OperationLog]、判断是否记录
|
||||
▼
|
||||
[ExampleRequest] ── rules() 校验(失败 → code=1 + 字段明细)
|
||||
▼
|
||||
[ExampleController::add()]
|
||||
├─ #[Log] 注解元数据
|
||||
├─ #[Permission] 注解元数据
|
||||
└─ parent::_add($this->logic, $data)
|
||||
│
|
||||
└─ $this->_add($service, $request->validated())
|
||||
▼
|
||||
[ExampleLogic::add()]
|
||||
[ExampleService::add()]
|
||||
├─ beforeAdd() 钩子
|
||||
├─ array_camel_to_snake() 键名转换
|
||||
├─ processTenantId() 租户填充
|
||||
├─ processFileFieldsOnSave() 文件处理
|
||||
├─ filterTableFields() 字段过滤
|
||||
├─ checkUnique() 唯一性校验
|
||||
├─ Model::create() 写入数据库
|
||||
├─ checkUnique()(重复 → BizException → 回滚 → code=1)
|
||||
├─ DB::transaction { Model::create() }
|
||||
│ └─ HasAuditColumns:create_user / create_time 自动填充
|
||||
└─ afterAdd() 钩子
|
||||
│
|
||||
▼
|
||||
[BaseModel]
|
||||
├─ onBeforeInsert → create_user, create_time
|
||||
└─ 执行 SQL INSERT
|
||||
│
|
||||
▼
|
||||
[数据库] ── 返回自增 ID
|
||||
│
|
||||
▼ 逐层返回响应
|
||||
[Result::success()] ── {code:0, ok:true, msg:"添加成功", data:{id:42}}
|
||||
│
|
||||
▼
|
||||
[LogMiddleware] ── 记录操作日志(写操作)
|
||||
│
|
||||
[数据库] ── INSERT INTO laravel_example ... → 自增 ID
|
||||
▼ 逐层返回
|
||||
[Result::success] ── {code:0, ok:true, msg:"添加成功", data:{id:42}}(驼峰转换)
|
||||
▼
|
||||
[浏览器] ← JSON 响应
|
||||
▼(响应完成后)
|
||||
[LogOperation::terminate] ── 写入操作日志(失败静默,不影响业务)
|
||||
```
|
||||
|
||||
## 异常处理链路
|
||||
|
||||
当链路中发生异常时,ExceptionHandle 统一捕获并返回标准响应:
|
||||
当链路中发生异常时,`Handler` 统一捕获并返回标准响应:
|
||||
|
||||
| 异常类型 | 触发场景 | 响应 |
|
||||
|---------|---------|------|
|
||||
| `ValidateException` | 参数验证失败 | `{code:1, msg:"参数验证失败", data:{错误明细}}` |
|
||||
| `ModelNotFoundException` | 数据不存在 | `{code:1, msg:"数据不存在"}` |
|
||||
| `RuntimeException (401)` | JWT 过期/无效 | `{code:1, msg:"token已过期"}` |
|
||||
| `HttpException` | HTTP 错误 | `{code:1, msg:"请求错误"}` |
|
||||
| `Exception` | 未捕获异常 | `{code:1, msg:"操作失败"}`(生产环境隐藏详情) |
|
||||
| `HttpResponseException`(FormRequest) | 表单格式校验失败 | `{code:1, msg:"参数验证失败", data:{字段=>错误数组}}` |
|
||||
| `BizException` | 业务规则失败 | `{code:1(或自定义), msg:"业务提示"}` |
|
||||
| `ValidationException` | 代码内手动校验失败 | `{code:422, msg:"参数验证失败", data:{错误明细}}` |
|
||||
| `AuthenticationException` | 未登录 / 令牌失效 | `{code:401, msg:"未授权,请先登录"}` |
|
||||
| `NotFoundHttpException` | 路由不存在 | `{code:404, msg:"数据不存在"}` |
|
||||
| 其它异常 | 未捕获异常 | `{code:500, msg:调试模式为原文, 生产为"操作失败"}` |
|
||||
|
||||
:::warning 异常处理原则
|
||||
所有异常均返回 HTTP 200 状态码,通过响应体 `code` 字段区分业务成功(0)与失败(1/401/403/404/422)。前端统一按 `code` 判断,无需处理 HTTP 状态码差异。
|
||||
除 403(中间件直接返回时同时设置 HTTP 状态码)外,所有响应 HTTP 状态码为 200,通过响应体 `code` 字段区分业务成功(0)与失败。前端统一按 `code` 判断,`code=401` 时跳转登录,无需处理 HTTP 状态码差异。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
一个 API 请求从浏览器到数据库经过 7 个阶段、15+ 个处理节点。中间件链负责横切关注点(认证、权限、租户、日志),控制器层负责参数获取和响应返回,逻辑层负责业务处理和数据加工,模型层负责数据库操作。这种分层设计使得每一层职责单一、可独立测试。
|
||||
一个 API 请求从浏览器到数据库经过 7 个阶段、16+ 个处理节点。中间件链负责横切关注点(真实 IP、跨域、JSON 协商、认证、权限、租户、日志),FormRequest 负责参数格式,控制器层负责编排与响应封装,服务层负责业务处理与事务,模型层负责数据持久化。这种分层设计使得每一层职责单一、可独立测试。
|
||||
|
||||
Reference in New Issue
Block a user