代码约定
这篇是写法上的规矩:分层怎么划、失败怎么表示、路由与鉴权怎么声明、什么东西绝对不能进数据库。文件放哪里在项目结构。
框架自己的用法不在这里——看 https://platophp.com,或 composer reinstall lumnd/platophp --prefer-source 之后读 vendor/lumnd/platophp/docs/,入口 llms-zh-CN.txt。
1. 分层
{admin,api}\control\ctl_* 收参、鉴权入口、调用 Service/Model、返回 reply、写操作日志
↓
common\service\* 薄事务入口:划事务边界,别的什么都不做
↓
common\model\* 业务主体:过滤、查询写入、状态流转、幂等、锁、跨表调用
↓
common\model\model Model 基类:find/first/get/paginate/insert/update/delete、软删、cast
↓
plato\database\db Query Builder
- 规则放 Model,不放 Service,也不放控制器。这样换一个用例进来——控制台命令、定时任务、另一个应用——规则照样生效。
- 一个 Model 默认只管一张主表。订单、订单明细、商品各自建 Model,由主 Model 调用其他 Model 的业务方法。
- 简单单表 CRUD 不建 Service,控制器直接调 Model。不要为了形式统一拆出无意义的包装方法。
- 应用专属职责(鉴权、菜单、视图)放
{app}/app/model/、{app}/app/support/;跨应用的业务数据一律放common/。
2. Service 只划事务边界
final class order extends service
{
public static function pay(int $id, int $amount): array
{
$paid = self::_transaction(static fn () => order_model::pay($id, $amount));
queue::push('order.paid', ['id' => $id]); // 提交之后,不在闭包里
return $paid;
}
}
- 缓存刷新、队列投递、推送、事件一律放在
_transaction()返回之后。回滚能撤销数据行,撤销不了已经被消费者取走的消息。 - 事务外动作失败记日志,不得把已提交的事务伪装成失败。
- 不要为了单测在 Service 上加
_get_*/_update_*这类数据访问包装。
3. 失败只有异常
没有返回值符号约定,没有错误码映射表。一种机制:
throw new biz_exception(code::NOT_FOUND); // 业务拒绝
throw (new biz_exception(code::INVALID_PARAMETER, $msg)) // 带 data
->with_data(['errors' => $errors]);
biz_exception表示「应用正常工作,答案是拒绝」。common\middleware\catcher渲染成信封,不记日志——用户输错密码不是事故。- 其他任何
Throwable表示「有东西坏了」。catcher记日志,统一渲染成SYSTEM_ERROR,不透露细节(异常消息可能带着 SQL、路径或连接串)。只有 debug 打开时才附上细节。 db::transaction()遇到任何Throwable都回滚并重新抛出,所以抛异常就是中止事务的方式,中间没有一层需要检查返回值。- 没有中间件包着的调用方(控制台命令、队列 handler、常驻服务)用
service::capture()换成['code','msg','data']。走 HTTP 管线的一律让异常飞上去。
catcher 是唯一把异常变成响应的地方。后台多一层 admin\middleware\page_errors 套在它内层,把浏览器导航该得到页面的那几种(未登录、强制改密、无权限)换成跳转或 403 页;其余仍旧走信封。
4. 错误码
错误码在 common\define\code,按块分:
0 成功
-1 .. -99 任何功能都可能有的结果
-100 .. -999 按域分块,每个域一个类(-300 是后台,见 admin_code)
-1001 .. -1099 会话:客户端的反应是重新认证,不是显示一句话
-1101 .. -1199 客户端:升级、维护
-9001 .. -9099 服务端坏了
新域自己建 common\define\{域}_code,不要往 code 里堆,也不得越出自己的块。会话段是单独一块的原因是客户端要按段判断:-1001 ~ -1099 意味着「去重新登录」,不是「弹一句提示」。
文案在语言包里,不在常量旁边。
5. 响应形状
所有响应是 {code, msg, data, timestamp}。控制器用基类的辅助方法产出:
return $this->_ok($data); // code = 0
return $this->_paged($rows, $meta); // 列表
列表固定是 data.list + data.total,且 list 必须 array_values() 过。用 array_filter 过滤后的数组保留原下标,json_encode 会把它写成对象——客户端会在「恰好有一行被过滤掉」的那次请求上炸,而不是在测过的那些请求上。_paged() 已经替你做了这件事。
校验用 _validated():不通过就抛,所以动作读起来像是校验永远成功。第一条消息进 msg,全部消息进 data.errors。
6. 路由与 $actions
路径式路由 /{ct}/{ac},控制器 return reply 对象,不 exit。
每个控制器必须声明 public static array $actions(admin 已打开 strict_actions,没列进去的动作是 404)。没有这一条,每个 public 方法都是路由,包括那些只是碰巧是 public 的辅助方法。
public static array $actions = [
'index' => [
'methods' => ['GET'],
'auth' => 'required',
],
];
methods 与 auth 两个键都必须写,auth 只能是 none / optional / required。少一个键或者写错值,框架不是回退到默认,而是当成畸形声明整条作废,那个动作变成 500。phpstan 查不出来(它就是个数组字面量),靠 tests/Unit/admin/routesTest.php 断言。
三种模式的差别是回调调不调用、以及答不上来算不算失败:none 根本不调用鉴权回调,optional 调用但允许答「没人」,required 调用且必须拿到身份对象,拿不到框架自己答 401。
中间件按 config/config.php 的 middleware 模式匹配:'*'、'ct:*'、'ct:ac'——跟 purview 和角色权限用的是同一套记法。
7. 鉴权
后台的会话权威是 #PB#_admin_session 数据库行,不是 cookie;账号、角色、权限每个请求重新读,不缓存在会话里——这样封号和改权限立刻生效。
哪些路由需要权限写在 admin/app/config/config.php 的 purview:
public—— 免登录,只放真正必须免登录的protected—— 登录即可,放控制台、退出、个人中心这些「不是特权」的屏- 两者都不在的 —— 需要权限
默认是关闭:漏写一个动作等于关掉它,不是打开它。这个方向是有意选的——漏写导致「进不去」,反过来会导致「谁都进得去」。
api 不走 purview 配置。 契约里的 auth 直接生成进控制器 $actions,默认 required,免登录的动作显式写 auth: 'none';框架按这个值决定调不调 api\model\auth::auth()。没有独立访问注册表,也没有控制器里的二次鉴权。
凭证是 Authorization: Bearer <access_token>。CSRF 在 api 侧关掉——它不发 cookie,没有可被搭车的东西。签发出去的 access token 在过期前收不回来,能吊销的是 refresh 那一半,加上封号在每个请求里重查。
8. 配置
三层递归合并:框架 vendor/lumnd/platophp/config/ → 应用 {app}/app/config/。common/config/ 不在这条链上,它是被应用配置文件 require 进来再 array_merge 的。
array_merge整键替换,所以在应用配置里列出的段必须是完整的。要往已有段里追加(比如 middleware),在$configs上改,不要写进返回的数组里。- 敏感值只从
$_ENV读,永远不写进配置文件。 - 表名统一写
#PB#_xxx,运行时由DB_PREFIX替换。
9. 密钥与敏感数据
这一节没有例外。
- 密钥、连接串、第三方凭据只存
.env/$_ENV,不进仓库。.gitignore用.env.*兜底,只放行.env.example和.env.testing,这两个里面不许有真凭据。 setting表不得存密钥、连接串或第三方凭据。 数据库会被导出、备份、拷进开发环境——存进去等于把它复制到每一个拿到过备份的地方。DB_CRYPT_KEY是 64 位十六进制。丢了就是丢了,没有恢复路径,只有从明文源重新加密的路径。不得直接更换已有环境的这个值。- 字段加密用
common\support\field_crypt(确定性加密,等值可查)。 - 不在命令行参数里传密码——它进 shell history,命令跑着的时候机器上每个进程都能从
ps看到。用admin:create/admin:password的提示符,并且带docker exec -it:没有 tty 就关不掉回显。 - 日志、异常、调试 SQL 不得泄露解密后的个人信息。
- 生产
SYS_ENV=pub、SYS_DEBUG=false——debug 面板会原样打印 session 和 cookie。
10. 注释与语言
- 代码注释一律英文,包括模板里的
<{* *}>和script块里的 JS 注释。 - 界面多语言只有
zh-cn和en。语言包是 PHP 文件返回key => text,放common/lang/{locale}/与{app}/app/lang/{locale}/。 - README 有两份:
README.md是英文,README.zh-CN.md是中文,改一份就要改另一份。docs/下的文档只有中文一份。 - 注释写为什么,不逐行翻译代码。类和公开方法说明职责与业务语义;状态、类型、错误码常量写清数值代表什么。
- 改行为时同步改注释与文档——过期的注释和文档按缺陷处理。
11. 代码风格
**权威是 phpcs.xml,不是这一节。**散文会漂,规则文件跑得起来——composer style 说了算。
规则本身在 lumnd/plato-coding-standard 里,框架和每个基于它的应用引的是同一份;phpcs.xml 只留真正属于本树的部分——查哪些目录、跳过哪些生成物。这是有意的:应用代码读起来是它所调用的那个框架的延续,贡献者不用同时记住两套规矩。
排除一条 PSR-12 规则只是让两种写法都合法,不是要求另一种,所以那个包里除了排除清单还有一个 sniff,把被排除的规则以相反方向装回来。错误码前缀 PlatoPHP.Style.ProjectConventions。内容是 PSR-12 减去下面几条明文偏离:
| 偏离 | 约定 |
|---|---|
| 类名、方法名 | 蛇形小写 |
private / protected 成员 |
以 _ 开头 |
| 大括号 | Allman,控制结构和跨行签名都独占一行 |
| 控制结构 | 括号内侧留空格:if ( $ready ) |
前两条对魔术方法整体不适用:__toString、__callStatic、__debugInfo 是 camelCase,__construct 以下划线开头却是 public——这些拼写由引擎规定,sniff 认得 PHP 的那份魔术方法清单并整体跳过。
名字由外部接口规定的地方是另一回事:实现 PSR-16 的类必须提供 getMultiple(),改名就等于没实现这个接口。这种不放宽规则,而是在那个类上写 // phpcs:disable PlatoPHP.Style.ProjectConventions.FunctionName -- 理由,用完 // phpcs:enable 收口——偏离留在它发生的那一行旁边,而不是变成一条谁都能用的全局豁免。那个 sniff 给自己 PHPCS 加载器强加的 StudlyCaps 类名用的就是这个办法。
其余按 PSR-12 执行。4 空格缩进、UTF-8 无 BOM、LF 换行这几项由 .editorconfig 管,编辑器在 linter 看到文件之前就定下来了。
行长 120 只报 warning:PSR-12 规定检查器必须警告、但不得报错,所以门槛是 0 error,warning 不影响退出码。能自动修的跑 composer style:fix,但要读 diff——phpcbf 是重写文件,改错了也是一次改动。
上表里空白那两类(大括号、括号内侧空格)style:fix 能修,命名那两类不能——改一个方法名要动它所有的调用方,不是格式化工具该做的事。另有两种情况有意只报错不修:跨行的条件表达式(把换行变成空格是把两行并成一行),以及签名和大括号之间夹着注释(它属于换行的某一侧,而 sniff 不知道是哪一侧)。
api/app/control/ 被排除在检查之外:那是生成的,格式问题是生成模板的缺陷,在输出里改掉,下一次 api:generate 就覆盖回去了。
12. 测试与静态分析
composer style # phpcs,格式
composer analyse # phpstan level 8,类型
composer test # Pest,行为
vendor/bin/pest --testsuite=Unit # 不连任何东西
- 三道门管的是三件不同的事。phpstan 不看格式,所以没有
composer style的话,一份用别的风格写的文件不会被任何东西拦住。 - phpstan level 8,范围是
common与各应用的app目录的全部文件(清单在phpstan.neon,phpcs.xml的<file>清单跟它对齐)——新文件自动进入两道检查,不需要一个个往里加。 - Feature 测试跑在真实 DDL 上,不 mock 数据库。
- 不能用「单元测试通过」代替外部服务、常驻进程、定时任务的运行验证。有适配代码不等于跑得通。
怎么在容器里跑、连不上库怎么办,见本地运行。