Skip to content

分层架构规范(完整版)

本文是根目录 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):

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

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_authAdminAuthMiddleware校验 JWT Token,解析出 request->userId / request->username
admin_permissionAdminPermissionMiddleware反射读取 Controller 方法上的 #[Permission('xxx')] 注解,校验权限;同时注入 request->userInfo
admin_logAdminLogMiddleware仅对 POST/PUT/DELETE 请求,异步推送操作日志到队列(详见第 6 章)

AdminAuthMiddleware::handle() 节选(来源:server/app/adminapi/middleware/AdminAuthMiddleware.php):

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.phpserver/app/service/article/ArticleService.php):

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);
}
php
// 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. 各层职责与基类

目录基类职责
Controllerapp/adminapi/controller/v1/{module}/core\base\Controller接收请求、参数校验、调用 Service、返回响应
Serviceapp/service/{module}/core\base\Service业务逻辑编排、事务管理、触发事件
Repositoryapp/repository/{module}/core\base\Repository数据访问封装、所有 ORM 查询集中于此
Modelapp/model/{module}/core\base\ModelORM 映射、关联关系、访问器/修改器
Listenerapp/listener/{module}/事件监听器,处理副作用(日志、通知、缓存清理),按模块分子目录(system/user/payment/ 等)
Validateapp/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/limitpage_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/limitpage_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.phpController.php 中逻辑一致,仅容器获取方式为 $this->app->make()):

php
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 子类中验证):

php
class AdminService extends Service
{
    protected AdminRepository $adminRepository;   // 自动注入
    protected TokenManager $tokenManager;          // 自动注入
}

注意:该机制仅存在于 core\base\Controllercore\base\Service 两个基类中。Listener 继承这两个基类,其依赖注入通过普通的构造函数属性提升实现,例如(来源:server/app/listener/system/AdminLoginSuccessListener.php):

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):

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(节选):

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(节选):

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,节选业务事件部分):

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.successAdminLoginSuccessListenerapp/listener/system/
admin.login.failedAdminLoginFailedListenerapp/listener/system/
config.changedConfigChangedListenerapp/listener/system/
menu.changedMenuChangedListenerapp/listener/system/
user.registerUserRegisterListenerapp/listener/user/
user.loginUserLoginListenerapp/listener/user/
payment.successPaymentSuccessListenerapp/listener/payment/
feedback.createdFeedbackCreatedListenerapp/listener/feedback/
message.createdMessagePushListenerapp/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):

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):

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 行):

php
$this->log('创建管理员成功', ['admin_id' => $admin['id']]);

同类用法广泛存在于 MenuServiceRoleServicePermissionServiceUserService 等(如 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):

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)是典型的「只追加不修改」日志表:

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)开发者认为需要记录的关键业务节点AdminServiceMenuServiceRoleService
登录日志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/)

配合本文档前六章的实现细节,以上禁令的技术依据分别是:

  1. 第 2 章:Repository 是唯一直接调用 $this->model->where()->... 的层,Service/Controller 没有任何合法路径接触 Db:: 或 Model 静态方法。
  2. 全局约束:本仓库统一使用 created_at / updated_at / deleted_at(见 AdminLoginLog 等 Model 的字段约定),上一条禁令中列出的旧字段命名风格不再使用。
  3. 第 6.3 节:软删除表通过 deleted_at 字段实现,Repository::delete() 底层调用的是 ThinkPHP Model 的软删除机制,不做物理 DELETE
  4. 第 2.3 节:Repository 封装了 find/create/update/delete 等全部通用查询方法,Service 只能通过注入的 Repository 属性访问数据。
  5. 前端目录约定:admin/src/views/ 存放页面组件,admin/src/pages/ 不是本项目使用的目录(详见 AGENTS.md 目录地图)。

基于 MIT 许可发布