PlatoAdmin

本地运行

仓库里带一套 docker-compose.yml:PHP 8.3-fpm、nginx、MySQL 8、Redis 7。通过 nginx 用域名访问,不用 php -S。测试也在同一个容器里跑。

1. 六条命令

docker compose up -d
docker compose exec php83 composer install
docker compose exec php83 cp .env.example .env
docker compose exec php83 php vendor/bin/plato key:generate
docker compose exec php83 php vendor/bin/plato migrate
docker compose exec -it php83 php vendor/bin/plato admin:create --username=admin --super

然后打开 http://admin.platoadmin.localhost:8080/

不需要改 hosts。 .localhost 是保留给回环地址的后缀,浏览器和系统解析器自己就答 127.0.0.1,所以 vhost 起的是这个名字而不是某个真域名。

复制完 .env 还有一处要手填:DB_PASSWORD=root,跟 docker-compose.yml 里建库时用的 root 密码对上。其余的数据库与 Redis 项已经指向 compose 里的服务名(mysql:3306redis:6379),不用动。

三个密钥由 key:generate 填,它只写空着的那几行,已经有值的不动:

变量 是什么
CSRF_SECRET 签 CSRF cookie,空值会让后台直接拒绝启动
JWT_SECRET api 签 access token,换掉它等于把所有 api 客户端一次性登出
DB_CRYPT_KEY 字段加密,丢了没有恢复路径——已有数据的环境不要换

要替换某个已经有值的:key:generate --force=JWT_SECRET,它会先问一句;--show 只打印不落盘。

三个可选的:第三方登录要 GOOGLE_CLIENT_IDS / APPLE_CLIENT_IDS(逗号分隔,ios / android / web 是三个不同的 client id,漏一个那个平台就登不进来),邮件验证码要 MAIL_HOST / MAIL_USER / MAIL_PASS,没配的话 send_code 会老老实实返回「暂时无法发送」而不是假装发了。短信通道还没有实现,手机号验证码同样会返回这个错。

端口冲突的话,HTTP_PORT 换 nginx 那个(默认 8080,设成 80 就能不带端口访问),MYSQL_PORT 换 MySQL 暴露到宿主机的那个(默认 3307,容器内始终是 3306)。

Linux 上还有一件事:php-fpm 在容器里以 www-data 跑,它写进 data/ 的文件在宿主机上属于那个 uid。碍事的话给 php83 那个 service 加一行 user: "$(id -u):$(id -g)"。macOS 和 Windows 的 Docker Desktop 会把属主映射掉,没有这个问题。

2. 为什么是容器 + 域名

两件事在这里不是可有可无的:

  • PHP 版本。容器是 8.3,宿主机可能是别的版本,所以 composer install 那条走的是 docker compose exec 而不是在宿主机上跑——在宿主机上解出来的依赖,容器不一定装得下。composer.json没有 platform 覆盖:钉一个版本会把它写进 vendor/composer/platform_check.php,于是任何 PHP 比它旧的主机(哪怕只旧一个补丁号)每个请求都在自动加载之前就死。运行时的地板由 require 声明,是 8.2;开发要 8.3,因为 Pest 要。
  • 域名。CSRF 校验拿请求的 Originreq::domain() 比对,session cookie 与 CSRF cookie 都种在应答的那个 host 上。127.0.0.1:8080 也能跑通,但跑不到线上会走的那条路径。端口无所谓——OriginHTTP_HOST 都带端口,两边对得上。用 admin.platoadmin.localhost 还给并排的 api.platoadmin.localhost 留了位置,两个应用各自一套 cookie。

两个 server block 而不是一个域名下分路径:document root 不同(admin/publicapi/public),认证方式不同,共用域名还会共用 cookie 域,而 api 是一个 cookie 都不设的应用。

vhost 在 deploy/nginx/,compose 把整个目录挂进 nginx 的 conf.d。极简版没有 api.confinstall.php 删掉了),于是那边自然只有一个 vhost,没有第二处要同步。

用自己的栈

不想用 compose 也行,compose 只是把这几件事替你做了:项目挂在 /data/web/platoadmin,php-fpm 在 php83:9000 上应答。vhost 装进你自己的 nginx:

cp deploy/nginx/admin.conf /etc/nginx/conf.d/platoadmin.conf
cp deploy/nginx/api.conf   /etc/nginx/conf.d/platoapi.conf
nginx -t && nginx -s reload

路径和 fastcgi_pass 跟你的不一样就改掉。include fastcgi-php.conf 那句在 Debian 系的 nginx 包里是现成的(snippets/fastcgi-php.conf);别的发行版没有的话,仓库里那份在 deploy/docker/nginx/fastcgi-php.conf

3. 建库与第一个账号

docker compose exec php83 php vendor/bin/plato migrate
docker compose exec -it php83 php vendor/bin/plato admin:create --username=admin --super

密码在提示符下输入,终端回显是关掉的。-it 是必须的——没有 tty 就没有终端可关,命令会照跑但会先警告一句你打的东西会显示出来。

没有种子管理员,也不会有。 种进仓库的账号意味着密码在代码里,意味着每个跑过 seeder 的部署都是同一个密码,也意味着任何能读代码的人都知道它。所以第一个账号是人跑命令建出来的。

--super* 直接授在账号上,是给第一个账号用的;有角色以后应该改成 --role=NAME 并把这个授权收回去。新账号默认带 must_change_password,第一次登录会被挡在改密页,直到改完为止——因为跑命令的人知道那个密码,而那个人不一定是账号的主人。

忘了密码:

docker compose exec -it php83 php vendor/bin/plato admin:password --username=admin

改完会顺手把该账号其余的会话都下线。密码是因为「觉得旧的已经泄露了」才改的,留着旧密码开出来的会话,这次修改就只是装饰。

4. 访问

http://admin.platoadmin.localhost:8080/ —— 没登录会跳 /auth/login

api 在 http://api.platoadmin.localhost:8080/,请求体是 JSON,认证是 Authorization: Bearer <access_token>

curl -H 'Content-Type: application/json' -d '{"account":"you@example.com"}' \
  http://api.platoadmin.localhost:8080/auth/send_code

HTTP_PORT 改成 80 的话,上面两处的 :8080 都去掉。)

接口清单在 docs/api/openapi.json(由契约生成,见 docs/api-contract.md)。要一页能点的,开 http://api.platoadmin.localhost:8080/docs/api/ —— 一整页 Swagger UI,没有别的东西,给前端看的就是这个。

它由 api 自己的 vhost 提供(deploy/nginx/api.conf 里那条 /docs/ alias),这一点是有意的:页面和接口同源, 生成的文档没写 servers,Swagger UI 于是把请求发到页面所在的站点,Authorize 里贴上 access_token 就能 直接 Execute,不需要配 CORS。token 存在 localStorage,刷新不用重贴。

那条 alias 只该出现在开发机上,别带去线上——它把接口的完整形状挂在了公网可达的地方。

同一个文件在文档服务器上也开得起来(php -S 127.0.0.1:8088 -t docs 后开 http://127.0.0.1:8088/api/),但那是跨域的,要调试得两件事都做:.envAPI_ALLOW_ORIGIN=http://127.0.0.1:8088,然后用 ?api=http://api.platoadmin.localhost:8080 指明打给谁。

SYS_DEBUG=true 时右下角有一个 Profiler 按钮,点开是框架的调试面板:benchmark、这次请求跑过的每条 SQL(带 Copy)、GET/POST、请求头、cookie 和 session。

面板由 plato\debug\profiler_middleware 挂上去(在 admin/app/config/config.php 的中间件表里,夹在 catcher 和 page_errors 之间,所以错误页也带着它)。抽屉的样式和开合都是框架自带的,开合状态与高度记在 localStorage,跟着你翻页走。后台这边只加了一条 CSS:抽屉打开时 .layui-body 让出同样高度,免得列表最后几行被压在下面。

面板会原样打印 session 和 cookie,所以线上不能开 debug——框架文档里也写了这一条。

5. 调度器

本地默认不跑。 compose 里没有跑调度器的进程,所以计划任务屏上每个任务都会显示「还没跑过」——这是对的,不是坏了。要看真实效果就手动跑:

# 当前有哪些任务、各自下次什么时候跑
docker compose exec php83 php vendor/bin/plato schedule:list

# 跑一遍这一分钟到期的(--force 忽略到期判断,全跑)
docker compose exec php83 php vendor/bin/plato schedule:run --force

# 只跑一个,不管它有没有被停用
docker compose exec php83 php vendor/bin/plato schedule:exec --task=prune:session

跑完去 /schedule/index 看,上次执行时间和耗时应该出现了。

schedule:run 只负责启动到期的任务就返回,不等子进程——每分钟拉起一次的 cron 条目如果等,会堆起来。所以 --force 之后要停一两秒再去查数据库。

要在容器里真的跑起来:

docker compose cp deploy/cron/platoadmin.cron php83:/etc/crontabs/platoadmin
docker compose exec php83 crond -b -l 8

schedule:runschedule:execadmin\console\schedule 接管的(plato.config.php 里注册,覆盖同名内置命令),它比框架多做两件事:跳过后台停用的任务,以及把每次运行记进 #PB#_schedule_state。停用只对 schedule:run 生效——schedule:exec 是人在终端里手打的,那是明确的意图。

6. 存储盘与资源管理

两个本地盘在 common/config/storage.php 里:data/storage 是私有盘,data/public 是 nginx 从 /uploads/ 直接服务的那个。两个目录都在 .gitignore/data/ 之下,第一次用之前不用手动建——写入时会建。

/uploads/ 那条 location 在两个 vhost 里都有,compose 起来就已经生效。可以直接验:

curl -s -o /dev/null -w '%{http_code}\n' http://admin.platoadmin.localhost:8080/uploads/<某个文件>

资源管理屏(/attachment/index)读的是 #PB#_attachment,不是磁盘。盘上已经有的文件要先对账才会出现:

docker compose exec php83 php vendor/bin/plato attachment:reconcile

页面上的「对账」按钮做的是同一件事。两个方向都不删东西——多出来的文件收进来,指不到文件的记录标成缺失。真正删文件的是 prune:attachment,它只动那些已经在页面上删掉、且超过「已删文件保留」天数的行。

6.1 上传

资源屏工具栏上的「上传」直接传。单文件上限和扩展名白名单在 系统设置 → 文件存储,改完不用重启。

落到哪块盘是两处决定的.envSTORAGE_DISK 说这套部署写哪块盘,后台同一处的「上传落到哪块盘」可以覆盖它,而这一项默认就是「跟随 .env 的 STORAGE_DISK」。所以只改 .env 是生效的(前提是后台那项没被人动过),只改后台也是生效的(此后 .env 对上传不再有话语权)。想让它重新跟随 .env,把后台那项调回「跟随」。

上限还受 php.ini 管:upload_max_filesizepost_max_size 在这个应用跑起来之前就已经判过了,设置里填得比它们大只是换成 PHP 先说不。容器里现在是多少:

docker compose exec php83 php -i | grep -E 'upload_max_filesize|post_max_size'

6.2 接对象存储

第三块盘 s3common/integration/s3.php 驱动,SigV4 是自己签的,走 plato\http\client,没有引入 aws-sdk-php。S3_BUCKET 是总开关:填了它这块盘才会出现在设置的下拉框、资源屏的筛选框和对账任务里;不填就当它不存在。

要配的就是 .env 里这几项(完整注释见 .env.example):

变量 说明
S3_KEY / S3_SECRET 访问密钥,必填
S3_BUCKET 桶名,必填,同时是这块盘的开关
S3_REGION 参与签名计算,非 AWS 的服务也要填,默认 us-east-1
S3_ENDPOINT 空表示真 AWS;MinIO / R2 / OSS / COS 填自己的 scheme://host[:port]
S3_PATH_STYLE MinIO 必须 true,AWS 和 R2 用 false
S3_PREFIX 桶内前缀,多环境共用一个桶时用
S3_URL 公开读的基址或 CDN 域名;留空按私有桶处理
S3_VISIBILITY 新对象的 ACL,默认 private
S3_SIGN_TTL 私有桶签名链接的有效期,秒

私有桶(S3_URL 留空)时 /attachment/download 不会自己转发字节,而是签一条短时链接把浏览器 302 过去;Content-Disposition 和内容类型作为 response-content-* 一起签进去,所以本地盘上「哪些类型允许内联渲染」的那套判断在桶上仍然成立。

配好之后可以先不动页面,直接验连通性——下面这段把一个文件写进桶再读回来,两次都成功才算通:

docker compose exec php83 php -r "
require \"vendor/autoload.php\";
require \"plato.config.php\";
\$d = plato\storage\storage::disk(\"s3\");
var_dump(\$d->put(\"probe.txt\", \"ok\"), \$d->get(\"probe.txt\"), \$d->delete(\"probe.txt\"));
"

密钥错、桶不在、region 填错都会抛 storage_exception 并带上 S3 自己的错误码和错误消息,不会静悄悄地当成「文件不存在」——这条是故意的,否则配错的桶看起来就只是个空桶,对账还会把这块盘上的记录全标成缺失。

消息那半句是排查权限时唯一有用的东西。AWS 认证通过但策略不给权限时答的是 403 AccessDenied,光看这个词只会让人去查密钥;跟在后面的消息才说清楚是谁、少了哪个动作:

User: arn:aws:iam::…:user/xxx is not authorized to perform: s3:PutObject
on resource: "arn:aws:s3:::your-bucket/…" because no identity-based policy
allows the s3:PutObject action

这个后台要用到四个动作,对应四件事:s3:PutObject 上传、s3:GetObject 下载与签名链接、s3:DeleteObject 过期清理、s3:ListBucket 对账。IAM 用户上挂这么一条就够:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::your-bucket/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket"
    }
  ]
}

S3_VISIBILITY=public 时还要加 s3:PutObjectAcl——公开读是靠给对象打 ACL 实现的。缺 s3:ListBucket 有个额外的坑:这种情况下取一个不存在的 key,S3 答的是 403 而不是 404(免得泄露对象存不存在),于是「文件缺失」会表现成「没权限」。

7. 测试

docker compose exec -e DB_PASSWORD=root php83 vendor/bin/pest

.env.testing 已经指向 mysql / redis 两个服务名,库名是 platoadmin_test,唯一要在命令行给的是密码——仓库里不放真凭据,所以那一项是空的,由进程环境覆盖。compose 建库时用的是 roottests/bootstrap.php 允许进程环境覆盖所有 DB_* 就是为这个留的口子。

测试读的是仓库里的 .env.testing,不是你的 .env——一次测试跑不该依赖开发者自己的环境,更不该有写到它上面的可能。

不给密码也能跑,Feature 那部分会自己 skip 掉,Unit 照常。

只跑一部分:

docker compose exec php83 vendor/bin/pest --testsuite=Unit
docker compose exec -e DB_PASSWORD=root php83 vendor/bin/pest --filter=auth

三道门:

docker compose exec php83 composer style     # phpcs,0 error 是门槛
docker compose exec php83 composer analyse   # phpstan level 8
docker compose exec -e DB_PASSWORD=root php83 composer test

8. 排查

现象 原因
每个请求 502 / 白屏,nginx error log 里是 PHP 启动失败 vendor/composer/platform_check.php 要求的版本高于 8.3——多半是在宿主机上跑了 composer install/update。在容器里重跑一次:docker compose exec php83 composer install
docker compose up 之后 migrate 报连不上数据库 MySQL 第一次启动要建数据目录,几十秒。compose 里有 healthcheck,docker compose ps 看 mysql 是不是还在 starting
数据库拒绝密码 .envDB_PASSWORD 没填成 root,跟 docker-compose.yml 建库时用的对不上
csrf: request.csrf_secret must contain at least 32 bytes .envCSRF_SECRET 是空的
csrf: request.csrf_binding must return a non-empty string 入口没有 'session_start' => true。后台的 CSRF token 是用 session id 签的
登录一直 403,返回 Forbidden 那张 HTML 请求没带 X-CSRF-TOKEN,或者 Origin 跟访问的 host 对不上
Feature 测试全部 skip 没给 -e DB_PASSWORD=root,或者容器连不上 mysql
静态资源 404 vhost 的 root 指到了项目根目录,应该是 admin/public