分层架构规范(完整版)
本文是根目录
AGENTS.md「分层架构」节的展开版,供在线文档与 AI RAG 灌库使用。唯一事实源是本仓库(framework)的实际代码与根目录CLAUDE.md;文中代码片段均从真实源文件原样提取,并标注来源路径。
适用范围:后端 server/(ThinkPHP 8.0 + PHP 8.0+)。
1. 请求生命周期
一次写请求(以「创建文章」为例)的完整链路:
HTTP 请求
→ 路由匹配(route/article.php)
→ 中间件链:admin_auth → admin_permission → admin_log
→ Controller(参数提取 + 校验)
→ Service(业务编排 + 事务 + 事件触发)
→ Repository(唯一接触 Model 的层)
→ Model(ORM 映射)
← Response(Controller 统一封装)1.1 路由
文章模块的真实路由定义(来源:server/app/adminapi/route/article.php):
// 文章管理
Route::group('article', function () {
Route::get('list', 'v1.article.ArticleController/list');
Route::get('detail/:id', 'v1.article.ArticleController/detail');
Route::post('', 'v1.article.ArticleController/create');
Route::put(':id/status', 'v1.article.ArticleController/updateStatus');
Route::put(':id', 'v1.article.ArticleController/update');
Route::delete(':id', 'v1.article.ArticleController/delete');
})->middleware(['admin_auth', 'admin_permission', 'admin_log']);admin_auth / admin_permission / admin_log 是中间件别名,实际类映射见 server/config/middleware.php:
'alias' => [
'admin_auth' => app\adminapi\middleware\AdminAuthMiddleware::class,
'admin_permission' => app\adminapi\middleware\AdminPermissionMiddleware::class,
'admin_log' => app\adminapi\middleware\AdminLogMiddleware::class,
...
],1.2 中间件链
三个中间件按数组顺序依次执行,各自职责:
| 中间件 | 类 | 职责 |
|---|---|---|
admin_auth | AdminAuthMiddleware | 校验 JWT Token,解析出 request->userId / request->username |
admin_permission | AdminPermissionMiddleware | 反射读取 Controller 方法上的 #[Permission('xxx')] 注解,校验权限;同时注入 request->userInfo |
admin_log | AdminLogMiddleware | 仅对 POST/PUT/DELETE 请求,异步推送操作日志到队列(详见第 6 章) |
AdminAuthMiddleware::handle() 节选(来源:server/app/adminapi/middleware/AdminAuthMiddleware.php):
public function handle(Request $request, Closure $next): Response
{
$token = $this->tokenManager->getTokenFromHeader();
if (!$token) {
return $this->errorResponse(lang('auth.please_login'), 401);
}
try {
$payload = $this->tokenManager->verify($token);
if (($payload['type'] ?? '') !== 'admin') {
return $this->errorResponse(lang('auth.token_invalid'), 401);
}
$request->userId = (int)($payload['admin_id'] ?? 0);
$request->username = (string)($payload['username'] ?? '');
} catch (AuthException $e) {
return $this->errorResponse($e->getMessage(), 401);
}
return $next($request);
}AdminPermissionMiddleware 不使用硬编码的权限映射表,而是通过 ReflectionMethod 读取控制器方法上的 PHP 8 属性(Attribute)来解析所需权限(来源:server/app/adminapi/middleware/AdminPermissionMiddleware.php):
#[PermissionSkip]→ 跳过权限检查#[Permission('xxx')]→ 校验指定权限标识- 无注解 → 仅校验已登录,同时记录
Log::warning()提醒开发者补充注解
1.3 Controller → Service → Repository → Model
以创建文章为例,四层依次调用(来源:server/app/adminapi/controller/v1/article/ArticleController.php、server/app/service/article/ArticleService.php):
// Controller:只做参数提取、校验、调用 Service、封装响应
public function create(): Response
{
$data = $this->request->only([
'title', 'category_id', 'cover', 'summary', 'content',
'tags', 'author', 'status', 'publish_at',
]);
$this->validate($data, ArticleValidate::class, [], false, 'create');
$data['admin_id'] = $this->getUserId();
$result = $this->articleService->createArticle($data);
return $this->success(lang('messages.create_success'), $result);
}// Service:业务编排 + 触发事件,调用 Repository 完成持久化
public function createArticle(array $data): array
{
if (isset($data['status']) && (int) $data['status'] === Article::STATUS_PUBLISHED) {
$data['publish_at'] = $data['publish_at'] ?? date('Y-m-d H:i:s');
}
$article = $this->articleRepository->create($data);
$this->trigger('article.created', [
'article_id' => $article['id'],
'title' => $data['title'],
]);
return $article;
}ArticleRepository->create() 继承自 core\base\Repository::create(),是唯一直接调用 Model::create() 的地方(见第 2 章)。
2. 各层职责与基类
| 层 | 目录 | 基类 | 职责 |
|---|---|---|---|
| Controller | app/adminapi/controller/v1/{module}/ | core\base\Controller | 接收请求、参数校验、调用 Service、返回响应 |
| Service | app/service/{module}/ | core\base\Service | 业务逻辑编排、事务管理、触发事件 |
| Repository | app/repository/{module}/ | core\base\Repository | 数据访问封装、所有 ORM 查询集中于此 |
| Model | app/model/{module}/ | core\base\Model | ORM 映射、关联关系、访问器/修改器 |
| Listener | app/listener/{module}/ | — | 事件监听器,处理副作用(日志、通知、缓存清理),按模块分子目录(system/、user/、payment/ 等) |
| Validate | app/adminapi/validate/v1/{module}/ | — | 表单验证规则 |
五层均按模块建子目录,命名空间与目录一致(详见根目录 AGENTS.md)。
2.1 core\base\Controller 提供的能力
来源:server/core/base/Controller.php
| 方法 | 作用 |
|---|---|
resolveDependencies()(构造函数自动调用) | 扫描子类 protected 类型属性,自动从容器注入(DI,见第 3 章) |
success(string $message, $data, int $code = 200) | 成功响应,内部委托 core\http\Response::success() |
error(string $message, int $code = 400, $data = []) | 错误响应 |
paginate(array $data, string $message = '') | 分页响应 |
validate(array $data, $validate, array $message = [], bool|string $batch = false, ?string $scene = null) | 参数校验,第一个参数是数据数组而非验证类 |
getRequestData(array $rules = []) | 获取请求参数,并对分页字段 page/limit 与 page_no/page_size 做双向归一化 |
getUserId() / getUserInfo() | 读取中间件注入的 request->userId / request->userInfo |
getClientType() | 从 X-Client-Type 请求头读取客户端类型,白名单校验 |
2.2 core\base\Service 提供的能力
来源:server/core/base/Service.php
| 方法 | 作用 |
|---|---|
resolveDependencies()(构造函数自动调用) | 与 Controller 同机制的自动 DI |
log(string $message, array $context = [], string $level = 'info') | 记录文件日志(Log::record()),非数据库 |
throwBusinessException(string $message, int $code = 400) | 抛出业务异常 |
trigger(string $event, $data = null) | 触发事件(Event::trigger()),交由 Listener 处理副作用 |
extractPagination(array $params, ...) | 从参数中解构 [page, limit],兼容 page/limit 与 page_no/page_size |
findOrFail(Repository $repo, int $id, string $langKey = 'business.record_not_found') | 查找记录,不存在则抛业务异常,替代样板代码 |
runInTransaction(callable $callback) | 在事务中执行回调,替代手写 Db::startTrans/commit/rollback 样板(见第 4 章) |
cache() / forgetCache() / cacheTag() | 缓存读写与标签化缓存封装 |
2.3 core\base\Repository 提供的能力
来源:server/core/base/Repository.php
抽象方法 getModel(): Model 由子类实现,指定对应 Model。基类提供的通用查询方法:
find / findWhere / create / createAll / update / updateWhere / delete / deleteWhere / getList(含分页)/ getAll / count / exists / value / column / inc / dec,以及受保护的 buildPagination() 用于自定义复杂查询时统一分页结构。
Repository 是唯一直接调用 $this->model->where()->... 的层,Service 不允许绕过它直接使用 Db::table() 或 Model 静态方法。
3. 依赖注入机制
Controller 与 Service 基类内置几乎相同的自动 DI 实现:扫描子类声明的、带类型且非内置类型的 protected 属性,通过 ReflectionClass 反射并从容器 app()->make() 中解析实例,逐类缓存反射结果避免重复扫描。
节选(来源:server/core/base/Service.php,Controller.php 中逻辑一致,仅容器获取方式为 $this->app->make()):
private function resolveDependencies(): void
{
$class = static::class;
if (!isset(self::$resolveCache[$class])) {
$props = [];
$ref = new \ReflectionClass($class);
foreach ($ref->getProperties(\ReflectionProperty::IS_PROTECTED) as $prop) {
if ($prop->getDeclaringClass()->getName() === self::class) continue;
$type = $prop->getType();
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) continue;
$props[] = $prop;
}
self::$resolveCache[$class] = $props;
}
foreach (self::$resolveCache[$class] as $prop) {
if ($prop->isInitialized($this)) continue;
try {
$prop->setValue($this, app()->make($prop->getType()->getName()));
} catch (\Throwable $e) {
// 无法解析的属性保持未初始化;APP_DEBUG 开启时记录警告日志辅助排查
if (env('APP_DEBUG', false)) {
Log::warning(sprintf('[DI] 注入 %s::$%s 失败:%s(@ %s:%d)',
$class, $prop->getName(), $e->getMessage(), $e->getFile(), $e->getLine()));
}
}
}
}用法(来源:CLAUDE.md,也可在任意 Service/Controller 子类中验证):
class AdminService extends Service
{
protected AdminRepository $adminRepository; // 自动注入
protected TokenManager $tokenManager; // 自动注入
}注意:该机制仅存在于 core\base\Controller 与 core\base\Service 两个基类中。Listener 不继承这两个基类,其依赖注入通过普通的构造函数属性提升实现,例如(来源:server/app/listener/system/AdminLoginSuccessListener.php):
class AdminLoginSuccessListener
{
public function __construct(
protected AdminRepository $adminRepository,
protected AdminLoginLogRepository $loginLogRepository,
) {
}
...
}ThinkPHP 容器在实例化 Listener 时会自动解析构造函数参数类型,效果等价,但实现路径不同,不要混淆。
4. 事务规则
硬性规则:Db::startTrans() / commit() / rollback() 只允许出现在 Service 层。
基类提供了 runInTransaction() 封装(来源:server/core/base/Service.php):
protected function runInTransaction(callable $callback): mixed
{
Db::startTrans();
try {
$result = $callback();
Db::commit();
return $result;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}文章模块(ArticleService)当前没有事务场景(创建/更新/删除均为单表操作)。以下是仓库中真实存在事务逻辑的例子。
4.1 简单场景:AdminService::createAdmin()(创建管理员 + 分配角色,需原子性)
来源:server/app/service/system/AdminService.php(节选):
Db::startTrans();
try {
$adminData = [ /* ... */ ];
$admin = $this->adminRepository->create($adminData);
// 分配角色
if (!empty($data['role_ids'])) {
$this->adminRepository->assignRoles((int) $admin['id'], $data['role_ids']);
}
Db::commit();
// 清除权限缓存
$this->permission->clearUserCache((int) $admin['id']);
$this->log('创建管理员成功', ['admin_id' => $admin['id']]);
return $admin;
} catch (\Throwable $e) {
Db::rollback();
// ... 重新抛出 / 转换异常
}4.2 复杂场景:PaymentService::handleNotify()(支付回调,事务 + 行锁防并发 + 幂等 + 事务提交后再触发事件)
来源:server/app/service/payment/PaymentService.php(节选):
// 使用事务 + 行锁防止并发回调重复处理
Db::startTrans();
try {
$paymentOrder = $this->orderRepository->findByOrderNoForUpdate($data['out_trade_no']);
if (!$paymentOrder) {
Db::rollback();
Log::error("支付回调找不到订单: " . $data['out_trade_no']);
return 'fail';
}
// 幂等:已处理过的订单直接返回成功
if ($paymentOrder->status === PaymentOrder::STATUS_PAID) {
Db::rollback();
return $driver->successResponse();
}
if ($data['status'] === 'paid') {
$paymentOrder->status = PaymentOrder::STATUS_PAID;
$paymentOrder->trade_no = $data['trade_no'];
$paymentOrder->paid_at = date('Y-m-d H:i:s');
$paymentOrder->save();
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
Log::error("支付回调处理异常 [{$channel}]: " . $e->getMessage());
return 'fail';
}
// 事务提交后触发事件(避免事件处理器失败导致回滚)
if ($data['status'] === 'paid' && isset($paymentOrder) && $paymentOrder->status === PaymentOrder::STATUS_PAID) {
$this->trigger('payment.success', [ /* ... */ ]);
}要点:
- 两个例子都直接手写
Db::startTrans/commit/rollback,而非调用runInTransaction()——因为事务内部存在多个不同的提前return/rollback分支,直接手写比塞进单一回调更清晰。新代码可优先尝试runInTransaction(),仅当控制流复杂到不适合单一回调时才手写。 $this->trigger()必须放在事务提交之后调用,避免 Listener 中的副作用逻辑失败导致主事务被动回滚。- Repository 层需要提供支持行锁的方法(如
findByOrderNoForUpdate),事务和加锁逻辑本身仍然只能出现在 Service。
5. 事件系统
事件配置在 server/app/event.php,事件 → 监听器映射(来源:server/app/event.php,节选业务事件部分):
'listen' => [
// ---- 管理员事件 ----
'admin.login.success' => [\app\listener\system\AdminLoginSuccessListener::class],
'admin.login.failed' => [\app\listener\system\AdminLoginFailedListener::class],
// ---- 系统配置事件 ----
'config.changed' => [\app\listener\system\ConfigChangedListener::class],
// ---- 菜单事件 ----
'menu.changed' => [\app\listener\system\MenuChangedListener::class],
// ---- 用户事件 ----
'user.register' => [\app\listener\user\UserRegisterListener::class],
'user.login' => [\app\listener\user\UserLoginListener::class],
// ---- 支付事件 ----
'payment.success' => [\app\listener\payment\PaymentSuccessListener::class],
// ---- 反馈事件 ----
'feedback.created' => [\app\listener\feedback\FeedbackCreatedListener::class],
// ---- 消息推送事件 ----
'message.created' => [\app\listener\MessagePushListener::class],
// 预留事件(暂无监听器)
'announcement.created' => [],
'article.created' => [],
'user.notification.created' => [],
],全量事件表(含预留事件):
| 事件 | 监听器 | 所在模块目录 |
|---|---|---|
admin.login.success | AdminLoginSuccessListener | app/listener/system/ |
admin.login.failed | AdminLoginFailedListener | app/listener/system/ |
config.changed | ConfigChangedListener | app/listener/system/ |
menu.changed | MenuChangedListener | app/listener/system/ |
user.register | UserRegisterListener | app/listener/user/ |
user.login | UserLoginListener | app/listener/user/ |
payment.success | PaymentSuccessListener | app/listener/payment/ |
feedback.created | FeedbackCreatedListener | app/listener/feedback/ |
message.created | MessagePushListener | app/listener/(未分子目录) |
announcement.created | 无(预留) | — |
article.created | 无(预留,ArticleService::createArticle() 已 trigger 但暂无监听器消费) | — |
user.notification.created | 无(预留) | — |
5.1 目录约定
app/listener/{module}/:Listener 按模块分子目录(system/、user/、payment/、feedback/),与 app/service/{module}/、app/repository/{module}/、app/model/{module}/ 保持模块划分一致。只有 MessagePushListener 直接放在 app/listener/ 根目录(未按模块细分,因为它服务于跨模块的通用消息推送)。
5.2 Service 内联逻辑 vs Listener 副作用的判定标准
失败不影响主流程 → 放 Listener;必须成功(否则主流程应视为失败)→ 留在 Service。
真实对照:
AdminService::createAdmin()中「分配角色」放在事务内、留在 Service——因为「创建管理员但角色分配失败」是不可接受的不一致状态,必须与创建操作同生共死。- 「登录成功后更新
last_login时间、写入admin_login_logs」放在AdminLoginSuccessListener中——这些是审计副作用,即使记录失败也不应阻塞管理员登录这一主流程。 ConfigChangedListener清除配置缓存、重置存储驱动单例——属于「配置更新成功后尽力而为的收尾工作」,放 Listener。
ConfigChangedListener::handle()(来源:server/app/listener/system/ConfigChangedListener.php):
class ConfigChangedListener
{
public function handle(array $event): void
{
// 清除配置缓存(标签化管理,tag clear 会清除所有关联缓存)
Cache::tag('config')->clear();
// 重置存储驱动单例,使新配置生效
StorageManager::reset();
Log::info('系统配置已更新,缓存已清除', [
'keys' => $event['keys'] ?? [],
'group' => $event['group'] ?? '',
]);
}
}6. 操作日志三层
系统中「日志」分三个独立层次,用途和存储介质各不相同,不要混淆:
6.1 HTTP 级:AdminLogMiddleware(自动记录到 admin_operation_logs 表)
只对 POST/PUT/DELETE 请求生效,通过异步队列写入,避免拖慢主请求(来源:server/app/adminapi/middleware/AdminLogMiddleware.php):
protected function recordOperationLog(Request $request, Response $response, float $executionTime): void
{
$method = strtoupper($request->method());
if (!in_array($method, ['POST', 'PUT', 'DELETE'])) {
return;
}
$userId = $request->userId ?? 0;
if (!$userId) {
return;
}
$action = $this->getActionDescription($request);
// 记录日志(异步队列写入)
\core\queue\QueueManager::push(\app\job\AdminOperationLogJob::class, [
'admin_id' => $userId,
'username' => $request->username ?? '',
'method' => $method,
'path' => $request->pathinfo(),
'ip' => $request->ip(),
'user_agent' => $request->header('User-Agent', ''),
'action' => $action['action'],
'description' => $action['description'],
'params' => $request->param(),
'result' => ['code' => $response->getCode(), 'message' => $this->getResponseMessage($response)],
'execution_time' => $executionTime,
]);
}队列任务 AdminOperationLogJob::fire()(来源:server/app/job/AdminOperationLogJob.php)最终调用 AdminOperationLogRepository::record() 落库,写入失败时按队列重试策略处理(3 次后放弃)。这是全项目唯一自动记录、无需业务代码关心的操作日志层。
6.2 业务级:$this->log()(文件日志,非数据库)
Service 基类提供的 log() 方法委托给 Log::record(),写入 ThinkPHP 的文件日志系统,用于记录关键业务节点,便于问题排查(来源:server/app/service/system/AdminService.php 第 190 行):
$this->log('创建管理员成功', ['admin_id' => $admin['id']]);同类用法广泛存在于 MenuService、RoleService、PermissionService、UserService 等(如 RoleService::updateRoleStatus() 中 $this->log('更新角色状态', ['role_id' => $id, 'status' => $status]);)。这一层不落数据库,只写文件,不可用于业务查询或审计报表。
6.3 登录日志:事件驱动写入 admin_login_logs 表
由 Controller/Service 触发 admin.login.success / admin.login.failed 事件,AdminLoginSuccessListener / AdminLoginFailedListener 负责落库,属于第 5 章「事件系统」的具体应用(来源:server/app/listener/system/AdminLoginSuccessListener.php):
class AdminLoginSuccessListener
{
public function __construct(
protected AdminRepository $adminRepository,
protected AdminLoginLogRepository $loginLogRepository,
) {
}
public function handle(array $event): void
{
// 更新最后登录信息
$this->adminRepository->updateLastLogin((int) $event['admin_id'], $event['ip']);
// 记录登录成功日志
$this->loginLogRepository->record([
'admin_id' => $event['admin_id'],
'username' => $event['username'],
'ip' => $event['ip'],
'user_agent' => $event['user_agent'],
'login_result' => true,
'login_message' => lang('messages.login_success'),
]);
}
}对应 Model AdminLoginLog(来源:server/app/model/system/AdminLoginLog.php)是典型的「只追加不修改」日志表:
class AdminLoginLog extends Model
{
protected $name = 'admin_login_logs';
// 日志表不需要自动更新时间和软删除
protected $updateTime = false;
protected $deleteTime = false;
...
}6.4 三层对照表
| 层级 | 触发方式 | 存储介质 | 覆盖范围 | 典型来源 |
|---|---|---|---|---|
| HTTP 级 | AdminLogMiddleware 自动拦截 | admin_operation_logs 表(异步队列写入) | 所有 POST/PUT/DELETE 请求 | 中间件,无需业务代码介入 |
| 业务级 | Service 中显式调用 $this->log() | 文件日志(ThinkPHP Log) | 开发者认为需要记录的关键业务节点 | AdminService、MenuService、RoleService 等 |
| 登录日志 | admin.login.success/failed 事件 | admin_login_logs 表 | 仅管理员登录场景 | AdminLoginSuccessListener / AdminLoginFailedListener |
7. 数据流向禁令清单
以下与根目录 AGENTS.md「禁止事项」节逐字一致:
- 禁止在 Controller / Service 中出现 Db:: 查询或 Model 静态查询(Db::table/Db::query/::where/::find 等;Service 中仅允许事务方法 Db::startTrans/commit/rollback)
- 禁止使用 create_time / update_time / delete_time 等旧字段名
- 禁止物理删除有 deleted_at 字段的表数据
- 禁止绕过 Repository 直接查询
- 禁止把前端页面放到 admin/src/pages/(正确位置是 admin/src/views/)
配合本文档前六章的实现细节,以上禁令的技术依据分别是:
- 第 2 章:Repository 是唯一直接调用
$this->model->where()->...的层,Service/Controller 没有任何合法路径接触Db::或 Model 静态方法。 - 全局约束:本仓库统一使用
created_at/updated_at/deleted_at(见AdminLoginLog等 Model 的字段约定),上一条禁令中列出的旧字段命名风格不再使用。 - 第 6.3 节:软删除表通过
deleted_at字段实现,Repository::delete()底层调用的是 ThinkPHP Model 的软删除机制,不做物理DELETE。 - 第 2.3 节:Repository 封装了
find/create/update/delete等全部通用查询方法,Service 只能通过注入的 Repository 属性访问数据。 - 前端目录约定:
admin/src/views/存放页面组件,admin/src/pages/不是本项目使用的目录(详见AGENTS.md目录地图)。