年前接手了一个旧项目——一个给移动端和管理后台提供数据的API系统,用的是ThinkPHP 5.1。每次新增接口都要在构造函数里手动做token校验,跨域处理写在BaseController里,业务逻辑和SQL散落在控制器各处。维护起来非常痛苦,尤其是当token过期需要刷新时,多个控制器的代码都要改。趁着业务升级,我把项目整体迁移到了ThinkPHP 8,并重新设计了认证和架构。这篇文章记录下整个改造过程,重点讲讲中间件管道、JWT 的实际落地以及服务层的剥离。
一、先搭一个干净的起点
用Composer创建TP8项目,指定使用多应用模式会比较灵活。如果只是单一API服务,单应用也可以,但考虑到后面可能会加入后台管理模块,我直接用了多应用结构。
composer create-project topthink/think tp8-api
cd tp8-api
composer require firebase/php-jwt # 用这个库处理JWT
composer require topthink/think-multi-app # 多应用扩展
安装完毕后,删除默认的`app/controller`目录,我们在`app`下新建一个`api`应用目录,结构如下:
app/
├─ api/
│ ├─ controller/
│ ├─ middleware/
│ ├─ service/
│ └─ route/
├─ common/
│ └─ model/
└─ ...
其中`common/model`放公共模型,`api/middleware`放自定义中间件,`api/service`放业务逻辑层。路由使用TP8的注解路由,这样控制器和路由绑定在一起,清晰很多。
二、JWT 中间件不是简单的验证逻辑
很多教程里把JWT的签发和验证直接写在控制器或者基类里,这样做虽然能跑,但耦合太重。正确的方式是把JWT验证抽象成一个中间件,并且只负责验证token本身,不处理具体用户信息。用户信息的加载交给另一个独立的进程或者服务。
先定义JWT管理类,放在`app/common/service/JwtService.php`,负责token的生成、验证和刷新。这里我设定了access_token和refresh_token双令牌机制,access_token有效期15分钟,refresh_token有效期7天。
<?php
namespace appcommonservice;
use FirebaseJWTJWT;
use FirebaseJWTKey;
use FirebaseJWTExpiredException;
use thinkfacadeConfig;
class JwtService
{
protected string $secret;
protected string $algorithm = 'HS256';
protected int $accessTtl = 900; // 15分钟
protected int $refreshTtl = 604800; // 7天
public function __construct()
{
$this->secret = Config::get('jwt.secret') ?: 'default_secret_key';
}
// 生成access token
public function createAccessToken(int $userId, array $extra = []): string
{
$payload = array_merge([
'iss' => 'tp8-api',
'sub' => $userId,
'iat' => time(),
'exp' => time() + $this->accessTtl,
'type' => 'access'
], $extra);
return JWT::encode($payload, $this->secret, $this->algorithm);
}
// 生成refresh token(可存储在数据库以便吊销)
public function createRefreshToken(int $userId): string
{
$payload = [
'iss' => 'tp8-api',
'sub' => $userId,
'iat' => time(),
'exp' => time() + $this->refreshTtl,
'type' => 'refresh'
];
return JWT::encode($payload, $this->secret, $this->algorithm);
}
// 验证token并返回payload
public function verifyToken(string $token): ?object
{
try {
return JWT::decode($token, new Key($this->secret, $this->algorithm));
} catch (ExpiredException $e) {
// 抛出特定异常,让中间件捕获后返回401
throw new thinkexceptionHttpException(401, 'token已过期');
} catch (Exception $e) {
throw new thinkexceptionHttpException(401, 'token无效');
}
}
// 刷新access token
public function refreshAccessToken(string $refreshToken): string
{
$payload = $this->verifyToken($refreshToken);
if ($payload->type !== 'refresh') {
throw new thinkexceptionHttpException(400, 'token类型错误');
}
return $this->createAccessToken($payload->sub);
}
}
注意这里我并没有把用户查询逻辑放进去,JwtService只处理令牌本身。接下来写中间件`app/api/middleware/JwtAuth.php`:
<?php
namespace appapimiddleware;
use appcommonserviceJwtService;
use thinkfacadeRequest;
use thinkResponse;
class JwtAuth
{
public function handle($request, Closure $next)
{
$token = Request::header('Authorization', '');
if (empty($token) || !str_starts_with($token, 'Bearer ')) {
return json(['code' => 401, 'msg' => '缺少认证令牌'], 401);
}
$token = substr($token, 7);
$jwtService = app(JwtService::class);
try {
$payload = $jwtService->verifyToken($token);
} catch (thinkexceptionHttpException $e) {
return json(['code' => $e->getCode(), 'msg' => $e->getMessage()], $e->getCode());
}
// 将用户ID注入请求上下文,方便后续使用
Request::macro('userId', function () use ($payload) {
return $payload->sub;
});
return $next($request);
}
}
这里用到了TP8的`Request::macro`动态宏,可以把userId临时绑定到请求对象上,后面在控制器里直接`request()->userId()`就能拿到,不用再额外查询。中间件注册在`app/api/middleware.php`(应用中间件配置)里,或者直接用注解路由的`middleware`参数绑定。
三、管道模式不止一个中间件
API通常还需要处理跨域、请求日志记录、参数过滤等。TP8的中间件管道可以像剥洋葱一样层层处理。我习惯把跨域中间件放在最外层,JWT验证放在次外层,最后是参数过滤。
跨域中间件很简单,用TP自带的`thinkmiddlewareAllowCrossDomain`就行,但需要结合自己的域名做限制,所以稍微改一下:
<?php
namespace appapimiddleware;
class Cors
{
public function handle($request, Closure $next)
{
$origin = $request->header('Origin', '');
$allowedOrigins = ['https://myapp.com', 'http://localhost:8080'];
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Max-Age: 86400');
}
if ($request->method() === 'OPTIONS') {
return response('', 204);
}
return $next($request);
}
}
请求日志中间件记录每个请求的耗时和返回状态码,方便排查问题:
<?php
namespace appapimiddleware;
use thinkfacadeLog;
use thinkfacadeRequest;
class RequestLog
{
public function handle($request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
$cost = round((microtime(true) - $start) * 1000, 2);
$logData = [
'method' => $request->method(),
'path' => $request->pathinfo(),
'ip' => $request->ip(),
'status' => $response->getCode(),
'time_ms' => $cost,
];
Log::info(json_encode($logData, JSON_UNESCAPED_UNICODE));
return $response;
}
}
在路由定义里把这些中间件组合起来。TP8支持在路由文件或注解中给路由组或单个路由指定中间件。比如在`app/api/route/route.php`里:
<?php
use thinkfacadeRoute;
Route::group('v1', function () {
// 无需认证的接口
Route::post('login', 'api/Login/login')->middleware([appapimiddlewareCors::class, appapimiddlewareRequestLog::class]);
Route::post('refresh', 'api/Login/refresh')->middleware([appapimiddlewareCors::class, appapimiddlewareRequestLog::class]);
// 需要认证的接口
Route::group(function () {
Route::get('user/profile', 'api/User/profile');
Route::put('user/update', 'api/User/update');
})->middleware([appapimiddlewareCors::class, appapimiddlewareJwtAuth::class, appapimiddlewareRequestLog::class]);
});
这样所有需要认证的接口自动就在JWT保护之下,而且增加了请求日志和跨域处理,层级清晰。
四、控制器只做两件事:接收请求、调用服务
老项目里控制器经常直接操作模型,甚至拼接复杂的查询条件。迁移时我定了一条死规矩:控制器代码不超过20行,所有业务逻辑下沉到Service层。Service层可以互相调用,并且通过依赖注入和容器管理。
例如用户资料接口,控制器`app/api/controller/User.php`:
<?php
namespace appapicontroller;
use appapiserviceUserService;
use thinkresponseJson;
class User
{
protected UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function profile(): Json
{
$userId = request()->userId(); // 来自JWT中间件注入
$data = $this->userService->getProfile($userId);
return json(['code' => 200, 'data' => $data]);
}
public function update(): Json
{
$userId = request()->userId();
$params = request()->only(['nickname', 'avatar', 'email']);
$this->userService->updateProfile($userId, $params);
return json(['code' => 200, 'msg' => '更新成功']);
}
}
服务层`app/api/service/UserService.php`负责具体的查询和业务规则:
<?php
namespace appapiservice;
use appcommonmodelUser as UserModel;
use thinkexceptionValidateException;
class UserService
{
public function getProfile(int $userId): array
{
$user = UserModel::field('id,nickname,avatar,email,created_at')
->where('status', 1)
->findOrEmpty($userId);
if ($user->isEmpty()) {
throw new ValidateException('用户不存在');
}
return $user->toArray();
}
public function updateProfile(int $userId, array $data): void
{
// 简单验证示例
if (!empty($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new ValidateException('邮箱格式不正确');
}
UserModel::where('id', $userId)->update($data);
}
}
这样做有两个明显好处:一是服务层可以独立进行单元测试,不依赖HTTP请求;二是当业务需要复用(比如定时任务也要更新用户资料)时,直接调用`UserService`即可,不用复制代码。
五、统一响应和异常兜底
API需要一致的响应格式,并且所有异常都要被捕获成JSON返回。TP8的全局异常处理在`app/ExceptionHandle.php`里,我把它改成下面这样:
<?php
namespace app;
use thinkdbexceptionDataNotFoundException;
use thinkdbexceptionModelNotFoundException;
use thinkexceptionHandle;
use thinkexceptionHttpException;
use thinkexceptionValidateException;
use thinkResponse;
use Throwable;
class ExceptionHandle extends Handle
{
public function render($request, Throwable $e): Response
{
// 验证器异常
if ($e instanceof ValidateException) {
return json(['code' => 422, 'msg' => $e->getMessage()], 422);
}
// 模型未找到
if ($e instanceof ModelNotFoundException) {
return json(['code' => 404, 'msg' => '资源不存在'], 404);
}
// HTTP异常
if ($e instanceof HttpException) {
return json(['code' => $e->getStatusCode(), 'msg' => $e->getMessage()], $e->getStatusCode());
}
// 其他异常
return json(['code' => 500, 'msg' => '服务器内部错误'], 500);
}
}
这样无论哪里抛出异常,客户端都能收到统一结构的JSON,再也不会有HTML错误页面混进来。
六、模型关联别忘了用预加载
模型全部放在`app/common/model`下,因为多个应用可能共享。拿订单和订单明细举例:
<?php
namespace appcommonmodel;
use thinkModel;
class Order extends Model
{
protected $name = 'orders';
public function items()
{
return $this->hasMany(OrderItem::class, 'order_id', 'id');
}
public function user()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
}
在服务层查询订单列表时,用`with`预加载避免N+1问题:
public function getUserOrders(int $userId): array
{
return Order::with(['items', 'user'])
->where('user_id', $userId)
->order('created_at', 'desc')
->select()
->toArray();
}
TP8的模型查询非常直观,关联模型也可以加条件约束,比如只查已支付的明细等等。
七、刷新令牌的痛点和解决方案
双令牌机制里,refresh_token一般只在登录和刷新接口使用。我把刷新逻辑也放在JwtService里,但刷新接口本身应该只能由refresh_token调用。这里有一个容易被忽略的安全点:必须验证refresh_token的类型,防止攻击者拿access_token来刷新,导致无限延期。
登录控制器`app/api/controller/Login.php`:
<?php
namespace appapicontroller;
use appcommonserviceJwtService;
use thinkRequest;
use thinkresponseJson;
class Login
{
public function login(Request $request, JwtService $jwtService): Json
{
$username = $request->param('username');
$password = $request->param('password');
// 实际应该从数据库验证用户
if ($username !== 'admin' || $password !== '123456') {
return json(['code' => 401, 'msg' => '账号或密码错误'], 401);
}
$userId = 1; // 假设验证通过后获得用户ID
$accessToken = $jwtService->createAccessToken($userId);
$refreshToken = $jwtService->createRefreshToken($userId);
return json([
'code' => 200,
'data' => [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 900
]
]);
}
public function refresh(Request $request, JwtService $jwtService): Json
{
$refreshToken = $request->param('refresh_token', '');
if (empty($refreshToken)) {
return json(['code' => 400, 'msg' => 'refresh_token不能为空'], 400);
}
try {
$newAccessToken = $jwtService->refreshAccessToken($refreshToken);
} catch (thinkexceptionHttpException $e) {
return json(['code' => $e->getCode(), 'msg' => $e->getMessage()], $e->getCode());
}
return json(['code' => 200, 'data' => ['access_token' => $newAccessToken, 'expires_in' => 900]]);
}
}
刷新接口仅需要`refresh_token`,不经过JwtAuth中间件,但要经过Cors和RequestLog。这样整个认证流程就完整闭环了。
八、写在最后
整个迁移过程花了大约三个星期,但带来的收益立竿见影。中间件管道让认证、日志、跨域这些横切关注点彻底分离,控制器变得像乐高说明书一样简单。服务层的加入让代码复用率大幅提升,而且因为依赖注入的关系,单元测试覆盖率终于上了70%。
ThinkPHP 8 相比旧版本在架构灵活性和性能上都有明显进步,特别是容器的自动绑定和注解路由,非常适合中型API项目的快速开发。如果你也在做类似的升级,建议不要一次性推翻,可以选一个小模块按照这个结构先试点,跑通后再逐步替换。安全无小事,JWT的使用一定要结合业务做细颗粒度的控制,别怕多写几行校验代码。
完整的代码我已经整理在本地仓库里,读者可以按照上面的步骤自己搭建,遇到问题欢迎在技术社区交流。希望这套中间件管道+服务层的架子能帮你少踩几个坑。

