PlatoAdmin

项目结构

这篇回答「哪个目录放什么、我写的东西该落在哪里」。写法上的规矩——分层怎么划、失败怎么表示、鉴权怎么声明——在代码约定

目录本身是约定的一部分:这棵树里没有空占位目录。一个目录存在,就意味着里面有实现;真要用到再建,不让空目录冒充已经落地的架构。

全树

后台:

admin/
├── app/
│   ├── config/                config.php、database.php、schedule.php
│   ├── console/               控制台命令
│   ├── control/               控制器,一个屏一个 ctl_*
│   ├── lang/{zh-cn,en}/       后台自己的语言包
│   ├── middleware/            只服务后台的中间件
│   ├── model/                 后台专属 Model(目前只有 auth)
│   ├── support/               后台专属工具类
│   └── template/{ct}/{ac}.tpl Smarty 模板,一个动作一个
└── public/                    文档根:index.php 与 static/

接口:

api/
├── app/
│   ├── config/                config.php、database.php
│   ├── control/               生成物,源是 api/contracts/
│   ├── lang/{zh-cn,en}/
│   ├── logic/                 业务入口,人写的
│   └── model/                 api 专属 Model(目前只有 auth)
├── contracts/                 接口契约,改接口改这里
├── templates/                 生成器模板覆盖
└── public/                    文档根:index.php

其余目录不属于任何一个应用:

common/                        两个应用共用
├── config/                    两个应用确实共同覆盖的那两份
├── control/base.php           控制器基类
├── define/                    错误码
├── exception/                 biz_exception
├── integration/               第三方适配器
├── lang/{zh-cn,en}/           共用语言包
├── middleware/catcher.php     异常渲染
├── model/                     业务 Model 与 Model 基类
├── service/                   事务边界
└── support/                   跨应用工具类

database/
├── migrations/                建表与升级,唯一的表结构来源
└── seeders/                   内置数据(菜单树),不含账号

deploy/
├── nginx/                     vhost 样例
└── cron/                      crontab 条目样例

docs/                          项目文档,见下文
tests/{Unit,Feature}/          Unit 不连任何东西,Feature 要真库
data/                          运行时目录(缓存、日志、会话),不进版本库

plato.config.php               控制台的引导配置
phpstan.neon                   静态分析范围
phpunit.xml                    两个 testsuite 的划分
.env.example                   环境变量清单,复制成 .env 再填

data/ 是运行时目录,.gitignore 兜住了整个 /data/。它按应用分开:data/admindata/apidata/testing,由各自入口的 data_path 决定。这个目录要可写,但不能在文档根里。

入口与引导

每个入口做的是同一件事——plato::registry([...]) 然后 plato::run()

入口 app_path 谁在用
admin/public/index.php admin/app 浏览器
plato.config.php 默认 admin/app vendor/bin/plato

还有一个:api/public/index.phpapp_pathapi/app,服务 API 客户端。(它单独列在表外,是因为按档增减的条目不能放进表格——HTML 注释会把表格从那一行截断,后面的行渲染成字面竖线。)

app_path 决定 Controller、Model、config、template、lang 的查找位置。data_path 也跟着分开,所以两个应用的缓存与日志不会互相盖掉。

命令行只认一个 app_path:默认是 admin,要用 api 那份配置就 PLATO_APP=api php vendor/bin/plato ...。迁移、调度、账号命令都从 admin 这一侧操作。

两个应用必须用不同的 controller_namespaceadmin\control / api\control)。Composer 的 PSR-4 映射是进程级的:同一个 control\ 前缀映射到两个目录,先注册的那个会赢下所有查找,api 的请求就可能落进后台控制器。

另外两处差别是有意的:admin 开 session_start(会话认证,CSRF 令牌也绑在它上面),api 不开(客户端带 token,不发 cookie)。

common/ —— 两个应用共用的东西

判断标准是跨应用,不是「看起来通用」。只有后台用得上的东西放 admin/app/,哪怕它写得很像工具类。

目录 放什么 现在有
common/define/ 错误码常量,一个域一个类 codeadmin_codeattachment_code
common/exception/ 业务异常 biz_exception
common/control/ 控制器基类:信封、分页、校验、身份 base
common/middleware/ 跨应用中间件 catcher(唯一把异常变成响应的地方)
common/model/ 业务主体,一个 Model 默认管一张主表 基类 model,加后台域 adminadmin_roleadmin_sessionadmin_login_logadmin_oplogadmin_menusettingschedule_state,以及两个应用共用的 attachment
common/service/ 事务边界,别的什么都不做 基类 service,加后台各域
common/support/ 跨应用工具类 bootcsrfidentitylangfield_crypttotpdisks
common/integration/ 第三方适配器,外部服务只从这里进来 s3,以及各档次自带的邮件、短信与身份适配器
common/lang/ 共用语言包,PHP 文件返回 key => text zh-cnen
common/config/ 两个应用确实共同覆盖的配置 config.php(中间件、语言)、database.phpstorage.php

common/config/ 不在框架的配置合并链上,它是被应用配置文件 require 进来再合并的。

storage.php 里两个本地盘的根目录都写成绝对路径,这一条必须留意:框架默认让 local 盘落到 plato::data_path('storage'),而 data_path 是按应用分的(后台是 data/admin,api 是 data/api),照默认走两个应用会各拿到一棵互相看不见的存储树。两个盘分别是 local(私有,只能经 /attachment/download 鉴权取)和 public(nginx 从 /uploads/ 直接服务)。

第三块盘是 s3,驱动是 common/integration/s3.php——它实现框架的 plato\storage\disk 接口,配置里直接写类名,所以既不用改 vendor/,也不用在启动时注册。只有配了 S3_BUCKET 它才存在common\support\disks::names() 会把它整个漏掉,于是资源屏的筛选框、对账任务和上传都当它不在,没打算用对象存储的部署不用管它。凭据只从 .env 读,setting 表里不许出现。

上传落到哪块盘由两处决定,而且是一处压另一处.envSTORAGE_DISK 是这套部署写哪块盘(也就是 common/config/storage.phpdefault,不指名盘的 storage::put() 用的也是它),后台 系统设置 → 文件存储 → 上传落到哪块盘 可以就上传这件事覆盖它,而这个设置项的初值就是「跟随 .env 的 STORAGE_DISK」。合并点在 common\service\attachment::upload_disk()

这么接是因为反过来会骗人:设置项要是只能在后台改,STORAGE_DISK 就成了一个看起来管上传、实际什么都不管的变量——那正是它上线第一天的样子。

改哪一处都只影响之后上传的文件,attachment 每一行都记着自己写到了哪块盘。

App 用户域也在 common/ 里,虽然目前只有 api 读它:Model useruser_openiduser_session,Service user_auth,错误码 user_code,工具类 jwtverify_code,第三方登录适配器 oidcgoogle_identityapple_identity。放在这里是因为后台迟早要管这些数据,而不是因为 api 需要它。

admin/ —— 后台

Smarty 渲染的后台界面。一个屏对应一个控制器动作和一个模板,这一屏的 JavaScript 就在模板的 script 块里:

admin/app/control/ctl_setting.php     控制器动作 index
admin/app/template/setting/index.tpl  模板,含 script 块
  • admin/app/model/ 只放应用专属职责。现在只有 auth——会话与权限判定是后台自己的事,业务数据一律在 common/model/
  • admin/app/support/ 同理:menupurviewsviewroutesdateserrorsqrschedule_tasks
  • admin/app/console/ 是控制台命令:account(建账号、改密码)、prune(清理过期数据)、schedule替换框架同名命令,加上停用开关与运行记录)、attachment(对账存储盘)。
  • admin/app/config/schedule.php 是计划任务的定义,跟代码一起进版本库、一起发布。后台界面能停用和恢复任务,不能新增任务或改执行时间

前端约定(layui、没有构建步骤、Smarty 分隔符为什么是 <{ }>layui.use 怎么写)见后台前端约定

api/ —— 接口

api 比后台多一层,且控制器不是手写的:

api/contracts/auth.php        契约,人写的,改接口改这里
        ↓  vendor/bin/plato api:generate
api/app/control/ctl_auth.php  生成物,不要手改
        ↓  调用
api/app/logic/auth_login.php  业务入口,人写的
        ↓
common/service/user_auth.php → common/model/user*
  • api/app/control/ 是生成物。 手改会被 api/manifest.json 挡住——它记着生成时的哈希,api:check 会发现改动。
  • api/app/logic/api/app/model/ 是项目代码,生成器不覆盖。一个动作一个 Logic 文件(auth_loginauth_refresh……)。
  • 生成控制器的形状由 plato.config.phpapi_contract 回答:exception 决定参数错误怎么答,成功答的是框架的 resp::response()。本项目不覆盖生成器模板。
  • docs/api/openapi.json 同样由 api:generate 产出。

契约怎么写、生成什么、哪些文件是人写的,见API 契约与代码生成

不属于任何应用的目录

database/migrations/ 是表结构的唯一来源,没有 init.sql。文件名 YYYYMMDD_HHMMSS_description.php,表名一律写 #PB#_xxx,运行时由 DB_PREFIX 替换。字段约定与已建表见数据库设计

database/seeders/ 只放内置数据。现在只有菜单树,没有账号,也不会有——种进仓库的账号意味着密码在代码里,且每个跑过 seeder 的部署都是同一个密码。第一个管理员用 admin:create 建。

deploy/ 是样例,不是被读取的配置:nginx vhost 拷进 nginx 的 conf.d/,crontab 条目拷进宿主机的 crontab。

tests/ 分两个 testsuite。Unit 不连任何东西,任何环境都能跑;Feature 要真库,连不上会 skip 而不是失败。

docs/ 是文档源加生成的静态站:*.md 是源,build.php 生成 site/*.htmlmanifest.json 是页面清单,assets/ 是样式与 vendored 的 swagger-ui。site/.gitignore 挡在仓库外,由 CI 每次推送重新生成并发布,所以改文档只改 .md,要本地预览再跑 php docs/build.php。新增一篇要往 manifest.jsonpages 里按阅读顺序加一条,漏加的页面不会出现在站点里。

新代码放哪里

要加的东西 放哪里
后台的一个新屏 admin/app/control/ctl_*.php + admin/app/template/{ct}/{ac}.tpl
业务规则、查询、状态流转 common/model/
需要事务的多步写入 common/service/,只划事务边界
只有后台用得上的判定或渲染辅助 admin/app/model/admin/app/support/
新的错误码 common/define/{域}_code.php,不要往 code 里堆
调用第三方服务 common/integration/,业务代码不直接发 HTTP
表结构变更 database/migrations/ 新建一个文件,不改已经跑过的那个
界面文案 common/lang/{locale}/{app}/app/lang/{locale}/

再加一条:一个新接口——在 api/contracts/ 写契约,跑 api:generate,然后在 api/app/logic/ 写实现。

简单的单表 CRUD 不建 Service,控制器直接调 Model。分层是用来划边界的,不是用来凑齐层数的。

生成物,不要手改

路径 谁生成
docs/site/*.html php docs/build.php docs/*.md
vendor/ Composer composer.json

接口那一侧还有两个,源都是 api/contracts/,都由 plato api:generate 生成:api/app/control/docs/api/openapi.json

vendor/lumnd/platophp/ 是框架,任何情况下不改——下一次 composer install 就没了。发现框架 Bug 到框架仓库里改,不要在业务侧写绕过它的补丁而不说。

命名

  • 类名、文件名、命名空间一律蛇形小写,一个文件一个类,文件名等于类名。
  • 前缀只保留框架强制的 ctl_。没有 pub_mod_serv_ 这类前缀。
  • 控制器 ctl_{域},模板 {ct}/{ac}.tpl——两者靠路由对上,不靠约定俗成的记忆。
  • api 的 Logic 是 {域}_{动作}auth_loginauth_send_code
  • Migration 是 YYYYMMDD_HHMMSS_description.php,描述用英文小写下划线。
  • 测试文件是 {被测对象}Test.php(驼峰),目录跟着被测代码的目录走。

发布时被裁掉的目录

composer create-project 装出来的树不一定是完整的:install.php 按三档裁剪,minimal 没有 api/、没有 App 用户域、没有契约生成。所以新加的文件如果只服务 api 或只服务常驻服务,要同时登记进 install.phpPROFILES,否则极简版会留下一个引用了已删类的文件。规则见仓库根目录的 AGENTS.md 第 12 节。