安装 PHP 版
PHP 版原样放到虚拟主机上即可:没有 Composer,没有包管理器,也没有需要常驻的服务。一个目录、 一个配置文件、一个定时任务。本教程从空目录一直讲到投入生产的实例,包括多数指南略过的那些 环节:权限、TLS、定时任务、备份与升级。
需要什么#
| 项目 | 版本 | 为什么 |
|---|---|---|
| PHP | 8.1 或更新 | 联合类型、enum、readonly、never:代码用到了它们。 |
pdo_sqlite | — | 整个数据库。没有它什么也跑不起来。 |
mbstring | — | 十六种语言下字符串的正确处理。 |
openssl | 建议 | 加密数据库中的机密(SMTP 密码、异地存放的令牌)。 |
curl | 建议 | 发出 Webhook,以及把备份送到第三方。 |
intl | 建议 | 日期写法与 Node 版一致。缺少它时,月份名称改由词典提供。 |
安装向导会逐项检查并在屏幕上显示,然后才让您继续:阻断性的问题会中止安装, 可选项则只提示、不阻断。
没有任何东西需要用 composer 安装:IMAP 读取、tar.gz 归档、PDF、
CSV、二维码和 HTTP 客户端都写在产品内部。正因如此,它才能被放到一个您连命令行都没有的
主机上。
1. 上传文件#
取得 PHP 版的压缩包,然后上传。目录结构如下:
toutadmin/
├── app/ 代码(Web 服务器绝不对外提供)
│ ├── Core/ 内核:路由、数据库、会话、安全
│ ├── Modules/ 业务:人力资源、财务、项目、质量……
│ ├── Controllers/
│ ├── views/ 模板
│ ├── locales/ 十六份词典
│ └── schema.sql 数据库结构,与 Node 版完全一致
├── public/ ← 网站根目录指向这里,别无他处
│ ├── index.php 前端控制器
│ ├── css/ js/
├── data/ 数据库、保险柜、签署夹、收到的单据、备份
├── tools/ cron.php、check-keys.php
├── tests/
├── config.sample.php
└── VERSION
public/,而不是整个目录
这是本页最要紧的一点。如果服务器把整个目录都对外提供,data/app.sqlite
就变得人人可下载:整个数据库,包括密码摘要和保险柜。data/ 位于
public/ 之上,正是有意为之。
在虚拟主机上#
会遇到两种情况。如果您的服务商允许选择域名的根目录(cPanel、Plesk,以及多数支持“根目录”
模式的主机),请把整个目录放在 www/ 之外,并让域名指向
toutadmin/public。
如果根目录是固定的——通常是 www/ 或 public_html/——请把
public/ 里的内容放进去,其余部分放在它的上一层,然后修改
public/index.php 开头的路径:
www/ ← 固定的根目录
├── index.php
├── css/ js/
toutadmin/ ← 其余部分,Web 够不着的地方
├── app/
├── data/
└── config.php
// www/index.php —— 需要调整的那一行
require_once __DIR__ . '/../toutadmin/app/bootstrap.php';
万不得已,如果根目录之上什么也放不了,data/ 中随附的 .htaccess
已经会拒绝访问。那是一条安全带,不是解决方案:始终优先选择根目录之外的存放方式。
2. 写好配置#
cp config.sample.php config.php
然后打开 config.php:
<?php
return [
// SQLite 数据库与数据目录:位于网站根目录之外。
'db_path' => __DIR__ . '/data/app.sqlite',
'data_dir' => __DIR__ . '/data',
// 本实例专属的密钥。生成一次即可:
// php -r "echo bin2hex(random_bytes(32));"
'session_secret' => '…',
// 安装令牌:见下一步。
'install_token' => '',
// 对外地址,用于邮件中发出的链接。
'base_url' => 'https://intranet.shili.com',
// 只有位于受信代理之后才开启。
'trust_proxy' => false,
// 上限:每个地址每一刻钟的登录尝试次数,
// 以及每个地址每分钟的请求数。
'login_rate_limit' => 10,
'global_rate_limit' => 300,
// 过期:闲置时长,以及任何操作都不会延长的绝对时长。
'session_idle_minutes' => 60,
'session_max_hours' => 12,
];
它为签署夹中的签名加封,并加密数据库中的机密。丢失它会让既有签名失效。若您保留示例中的
值,产品会自行在 data/.instance-key 中生成一把(权限 0600)——
那就别忘了把这个文件也一并纳入备份。
配置也可以放在别处:环境变量 TOUTADMIN_CONFIG 可指向另一个文件。
3. 设置安装令牌#
从上传文件到您进入安装向导之间,这个实例属于先发现它的人:谁先到,谁就 创建了管理员账户。这个窗口很短,但足够了。所以请在上线前先设一个随机值:
php -r "echo bin2hex(random_bytes(16));"
# 然后写入 config.php:
'install_token' => 'c3f1…',
向导会索要它,以恒定时间比对——普通比对会让令牌被逐字符猜出来——并把每次 拒绝都记入日志。没有令牌,什么也不会被创建。实例安装完成后,向导自行关闭:令牌可以留着, 它已不起作用。
4. 权限#
| 路径 | 权限 | 谁写入 |
|---|---|---|
data/ | 0770 | Web 服务器,且仅限它 |
data/app.sqlite | 0660 | 由安装过程创建 |
config.php | 0640 | 无人——对服务器只读 |
app/、public/ | 0755 | 无人——只读 |
# 以 PHP 以 www-data 身份运行的服务器为例
sudo chown -R nin:www-data /var/www/toutadmin
sudo find /var/www/toutadmin -type d -exec chmod 750 {} \;
sudo find /var/www/toutadmin -type f -exec chmod 640 {} \;
sudo chmod 770 /var/www/toutadmin/data
sudo chmod 750 /var/www/toutadmin/public
SQLite 还会在数据库旁边写入伴随文件(-wal、-shm):需要可写的是
目录,而不只是那个数据库文件。
5. Web 服务器#
Nginx#
server {
listen 443 ssl http2;
server_name intranet.shili.com;
root /var/www/toutadmin/public; # 别无他处
index index.php;
ssl_certificate /etc/letsencrypt/live/intranet.shili.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/intranet.shili.com/privkey.pem;
client_max_body_size 20M; # 保险柜上传、收到的单据
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# 上传的文件从不直接对外提供:它们要经过一条会校验
# 会话和文件摘要的路由。
location ~ ^/(data|app|tools|tests)/ { deny all; }
}
server {
listen 80;
server_name intranet.shili.com;
return 301 https://$host$request_uri;
}
Apache#
<VirtualHost *:443>
ServerName intranet.shili.com
DocumentRoot /var/www/toutadmin/public
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/intranet.shili.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/intranet.shili.com/privkey.pem
<Directory /var/www/toutadmin/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
随产品提供的 public/.htaccess 已经把所有地址都转向 index.php。
在 Apache 虚拟主机上,这就够了。
TLS#
只要连接是加密的,产品就会加上 Strict-Transport-Security 响应头,明文连接下
则绝不添加:从未加密页面上声明它,浏览器根本不会采纳,反倒会把一次本地试用锁在
https 里长达半年。在您自己维护的服务器上:
sudo certbot --nginx -d intranet.shili.com
若前面有代理或负载均衡器负责终结 TLS,请设 'trust_proxy' => true——也仅限
这种情况:否则任何人都能在请求头里随意声明 IP 地址和协议。
6. 定时任务#
PHP 站点只在请求发生时才运行。Node 版在其每小时巡检中所做的八项操作,这里改由主机的定时 任务完成:
* * * * * /usr/bin/php /var/www/toutadmin/tools/cron.php >> /var/log/toutadmin-cron.log 2>&1
| 任务做什么 | 频率 |
|---|---|
| 关闭合同已到期的账户 | 每次执行 |
| 为到期的订阅开具发票 | 每次执行,且绝不重复开票 |
| 把到期事项转成通知 | 每次执行,并去重 |
| 清理已读通知和审计日志 | 按所设定的保留期限 |
| 收取财务邮箱(IMAP) | 若已配置收取 |
| 清空 Webhook 队列,含各次重试 | 每次执行 |
| 生成自动备份并送往异地 | 当设定的间隔已到 |
调用脚本的频率高于所设的间隔,并不会让备份更频繁:时候未到就什么也不会触发。若主机把定时
任务限制为每十五分钟一次,请把 * * * * * 改为 */15 * * * *——
Webhook 只是会带上这点延迟发出而已。
某些入门级主机并不提供。从第三方服务发起的 HTTP 调用(可用性监测之类)并不管用:
cron.php 拒绝在命令行以外的地方执行,正是为了不让一个公开地址触发备份。
这种情况下,请在备份界面手动执行备份。
7. 安装向导#
打开您的域名。只要还没有任何账户,所有地址都会跳转到 /installation。
整个向导就在一页之内:
- 前置条件,逐项检查并显示——阻断性的会明确标出。
- 安装令牌,如果已设置的话。
- 公司:将出现在各处的名称,以及每位新入职(非自由职业)员工获得的年假天数。
- 实例的默认语言,从 16 种中选择。
- 管理员账户:邮箱地址,以及至少十二位的密码。
所有内容都在同一个事务中写入。一旦有了账户,/installation
就会转向登录页:向导已自行关闭,无需手动删除任何文件。
8. 检查一切正常#
# 测试,在本机运行(不会写入您的数据库)
php tests/run.php
# 翻译:没有任何界面用到词典中缺失的键
php tools/check-keys.php
然后在界面里:创建一名成员,用该账号登录,上传一个附件,手动执行一次备份,并在备份 界面校验它。这四个动作分别触及数据库、文件、权限和归档——也就是主机必须具备的全部能力。
9. 升级#
# 1. 先备份,永远如此
php tools/cron.php # 或使用“备份”界面
# 2. 替换代码,而不是数据
# app/ public/ tools/ tests/ VERSION ← 替换
# data/ config.php ← 原样保留
# 3. 数据库会在首次请求时自行迁移
数据库结构通过幂等迁移演进:重复执行升级不会造成任何破坏。Node 版实例与 PHP 版实例共用 同一套结构——142 张表、1311 个字段——以及同样的 密码:数据库在两者之间迁移无需任何转换。
10. 真正做好备份#
tar.gz 归档包含数据库(通过 VACUUM INTO 复制,因此即便在写入
过程中也保持一致)以及五个文件目录:头像、收到的简历、保险柜、签署夹、收到的单据。归档中
每个文件都带有各自的 SHA-256 校验值,还原时会逐一核验。
留在它所保护的那台服务器上的归档,什么也保护不了:请在备份界面配置异地目标 (FTPS 或 Google Drive)。详见备份与还原页面。
先在本地试一试#
cp config.sample.php config.php
php -S localhost:8000 -t public public/index.php
PHP 自带的服务器足以让您了解这套产品。它不适合生产环境:单进程、无 TLS、无负载限制。
Toutadmin 文档 —— 构建于 2026-09-13。 独立站点,与软件本身分离。