PlatoAdmin

代码约定

这篇是写法上的规矩:分层怎么划、失败怎么表示、路由与鉴权怎么声明、什么东西绝对不能进数据库。文件放哪里在项目结构

框架自己的用法不在这里——看 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',
    ],
];

methodsauth 两个键都必须写auth 只能是 none / optional / required。少一个键或者写错值,框架不是回退到默认,而是当成畸形声明整条作废,那个动作变成 500。phpstan 查不出来(它就是个数组字面量),靠 tests/Unit/admin/routesTest.php 断言。

三种模式的差别是回调调不调用、以及答不上来算不算失败none 根本不调用鉴权回调,optional 调用但允许答「没人」,required 调用且必须拿到身份对象,拿不到框架自己答 401。

中间件按 config/config.phpmiddleware 模式匹配:'*''ct:*''ct:ac'——跟 purview 和角色权限用的是同一套记法。

7. 鉴权

后台的会话权威是 #PB#_admin_session 数据库行,不是 cookie;账号、角色、权限每个请求重新读,不缓存在会话里——这样封号和改权限立刻生效。

哪些路由需要权限写在 admin/app/config/config.phppurview

  • 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=pubSYS_DEBUG=false——debug 面板会原样打印 session 和 cookie。

10. 注释与语言

  • 代码注释一律英文,包括模板里的 <{* *}>script 块里的 JS 注释。
  • 界面多语言只有 zh-cnen。语言包是 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.neonphpcs.xml<file> 清单跟它对齐)——新文件自动进入两道检查,不需要一个个往里加。
  • Feature 测试跑在真实 DDL 上,不 mock 数据库
  • 不能用「单元测试通过」代替外部服务、常驻进程、定时任务的运行验证。有适配代码不等于跑得通。

怎么在容器里跑、连不上库怎么办,见本地运行