"我加了字段,为什么保存之后还是空的?"
这是我最常收到的问题之一。通常伴随着这样的画面:改了 PHP、也改了后端、控制台没报错、接口返回 200,但页面上那个字段就是空的。
答案往往是:你只改了两处,第三处漏了。
更麻烦的是"本地好好的,部署上去就 403"——这个坑更反直觉,它能让人排查一整天。
这篇讲 PHP 门户怎么接后端接口:门户的定位、四个固定动作、三条视图铁律,以及那个 403 之谜的真正原因。范例来自真实代码:/portal/stores(店铺管理页),对应 PortalController::adminStores() + views/admin_stores.php。
一、先搞清楚门户的定位
PHP 门户不是业务层,它是「透传层 + 视图层」。
浏览器 ──► nginx ──► PHP 门户 ──► Node 后端(业务规则的唯一真相)
▲ │
└────────────┘
只做:会话、组装、渲染
这意味着:
- ❌ 不要在 PHP 里写业务规则(不要在门户算佣金、算折扣、判断库存)
- ✅ PHP 只做三件事:拿会话 token、调后端 API、把数据渲染成 HTML
为什么这么设计? 因为同一套后端要同时服务 B/S 门户、小程序、开放 API、企微。规则放在 PHP 里,小程序就得重写一遍。
这句话的代价是你要多改一个地方——加字段时 PHP 的转发链也得改。但收益是规则只有一份,四端行为永远一致。
对快消分销这种四端一体的系统,这个取舍是必须的:你没法让运营去跟开发说"小程序上的佣金算法跟后台不一样"。
二、门户的四个组成部分
services/php/portal/
├── index.php # ① 路由表 + 分发
├── config.php # ② 配置(后端地址、导航项)
├── controllers/
│ ├── PortalController.php # ③ 控制器(3669 行,管理端 + C 端)
│ ├── AgentController.php # 代理端
│ └── AuthController.php # 登录 / 登出
└── views/
├── layout.php # ④ 布局骨架
├── admin_stores.php # 55 个视图模板
└── ...
注意 PortalController.php 有 3669 行——它同时管管理端和 C 端。这不是好设计,但它是现状,二开时你要知道改错方法会影响到哪一侧。
三、手把手:加一个管理页面(以店铺页为例)
步骤 1:注册路由
index.php 的 $routes 按 HTTP 方法分组:
$routes = [
'GET' => [
'/dashboard' => ['PortalController', 'dashboard'],
'/products' => ['PortalController', 'products'],
'/stores' => ['PortalController', 'adminStores'], // ← 新增
// ...
],
'POST' => [
'/api/stores/create' => ['PortalController', 'createStoreAction'],
// ...
],
];
路由支持带参数,用 {id} 占位,会按顺序传给控制器方法:
'/u/product/{id}' => ['PortalController', 'userProduct'],
'/u/order/detail/{orderNo}' => ['PortalController', 'userOrderDetail'],
'/poster/print/{id}' => ['PortalController', 'posterPrint'],
public function userProduct(string $id): void { /* ... */ }
匹配逻辑(index.php 里):
$routePattern = '#^' . preg_replace('#\{[^}]+\}#', '([^/]+)', $routePath) . '$#';
即 {id} 被替换成 ([^/]+) —— 只匹配单段路径,不含斜杠。
这个正则是"不支持多段路径参数"的根源。如果你想支持 /api/xxx/{id}/sub/{subId},现在这个实现做不到——([^/]+) 遇到斜杠就停。
步骤 2:写控制器方法
/**
* 店铺(门店)管理页
* 店铺是「跨店铺拆单」的分组依据:商品归属店铺后,购物车按店铺分组,
* 结算时每个店铺独立生成一张订单并逐笔支付。
*/
public function adminStores(): void
{
$this->ensureToken(); // ① 确保有后端 token
$storeResp = $this->apiCall('/api/stores'); // ② 调后端
$prodResp = $this->apiCall('/api/products');
$stores = $storeResp['items'] ?? [];
// ③ 组装:统计每家店铺下的商品数
$counts = [];
foreach (($prodResp['items'] ?? []) as $p) {
$sid = (string)($p['storeId'] ?? '');
if ($sid === '') continue;
$counts[$sid] = ($counts[$sid] ?? 0) + 1;
}
foreach ($stores as $i => $s) {
$stores[$i]['productCount'] = $counts[(string)($s['id'] ?? '')] ?? 0;
}
// ④ 渲染
$data = [
'pageTitle' => '店铺管理',
'stores' => $stores,
'selfCount' => count(array_filter(
($prodResp['items'] ?? []),
fn($p) => (string)($p['storeId'] ?? '') === ''
)),
];
$this->render('admin_stores', $data);
}
四个固定动作:
| 动作 | 方法 | 说明 |
|---|---|---|
| 保 token | $this->ensureToken() | 会话里没有就用配置的管理员账号换一个 |
| 取数据 | $this->apiCall('/api/xxx') | 自动带 Bearer,401 自动重登重试一次 |
| 组装 | 纯 PHP 数组操作 | 可以在这里做轻量统计、字段裁剪 |
| 渲染 | $this->render('view_name', $data) | 数据 extract 成变量给视图用 |
第 3 步"组装"是这套设计里唯一允许你写逻辑的地方,但只允许写"展示逻辑"。像"统计每家店有几个商品"这种纯读时聚合是合适的;而"算这个商品该给多少佣金"就越界了——那必须回后端算。
判断标准很简单:这个计算如果小程序也要用,它就不该待在 PHP 里。
步骤 3:写视图
views/admin_stores.php:
<?php
/**
* 店铺(门店)管理页
* 店铺是「跨店铺拆单」的分组依据:
* - 商品可归属到某个店铺(未归属的归「平台自营」)
* - 购物车按店铺分组展示,每店按各自小计独立计算满减
* - 结算时每个店铺独立生成一张订单,逐笔支付
* @var array $stores
* @var int $selfCount 未归属店铺(平台自营)的商品数
*/
?>
<div class="card">
<div class="card-header">
<span class="card-title">店铺管理</span>
<button class="btn btn-primary" onclick="openCreate()">+ 新建店铺</button>
</div>
<div class="filter-bar">
<input id="kw" placeholder="按店铺名称 / 编码 / 联系人筛选…" oninput="Portal.filterTable('stoRows', this.value)">
</div>
<table class="table" id="stoTable">
<thead>
<tr>
<th>店铺名称</th><th>编码</th><th>联系人</th><th>电话</th>
<th>地址</th><th>商品数</th><th>状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody id="stoRows">
<?php foreach (($stores ?? []) as $s): ?>
<tr>
<td><b><?= htmlspecialchars($s['name'] ?? '') ?></b></td>
<td><?= htmlspecialchars($s['code'] ?? '—') ?></td>
<td><?= htmlspecialchars($s['contact'] ?? '—') ?></td>
<td><?= htmlspecialchars($s['phone'] ?? '—') ?></td>
<td><?= (int)($s['productCount'] ?? 0) ?></td>
<td><?= ($s['status'] ?? '') === 'active' ? '启用' : '停用' ?></td>
<td>
<a href="javascript:;" style="color:var(--primary);margin-right:8px;" onclick="openEdit('<?= htmlspecialchars($s['id']) ?>')">编辑</a>
<a href="javascript:;" style="color:var(--danger);" onclick="doDelete('<?= htmlspecialchars($s['id']) ?>')">删除</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
三条视图铁律:
- 所有输出必须
htmlspecialchars()—— 店铺名、备注这些是用户可控输入,直接echo就是 XSS。 - 往 JS 里塞数据用
json_encode带JSON_HEX_APOS | JSON_HEX_QUOT—— 防止数据里的引号把onclick='...'截断。 - 取值一律带
?? ''默认值 —— 后端字段缺失时页面不能崩。
第 2 条值得展开,因为它防的是一类特别隐蔽的 XSS。如果你在 onclick='...' 里用单引号包裹数据,而数据里有个单引号(比如店名叫 Bob's Store),生成的 HTML 就变成:
onclick='openEdit({"name":"Bob's Store"})'
——单引号提前闭合,攻击者可以借此注入任意 JS。JSON_HEX_APOS 会把 ' 转成 \u0027,JSON_HEX_QUOT 把 " 转成 \u0022,两个一起用才安全。
不过这个项目里的真实做法更稳一层:它根本不把整行数据塞进 onclick,而是只传 id,数据已经在页面顶部用 var STORES = <?= json_encode($stores ?? [], JSON_UNESCAPED_UNICODE) ?>; 存成了一份 JS 变量:
<script>
var STORES = <?= json_encode($stores ?? [], JSON_UNESCAPED_UNICODE) ?>;
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
</script>
然后 onclick 里只传 htmlspecialchars($s['id']),编辑时再从 STORES 里查回来:
function openEdit(id) {
var s = STORES.find(function (x) { return x.id === id; });
if (!s) return;
Portal.showModal('编辑店铺', formHtml(s), {
confirmText: '保存',
onConfirm: function (data) {
Portal.api('/portal/api/stores/' + encodeURIComponent(id), 'PUT', normalize(data)).then(function (res) {
if (res.code === 0 || !res.error) { Portal.toast('店铺已更新', 'success'); setTimeout(function(){ location.reload(); }, 700); }
else Portal.toast('保存失败:' + (res.msg || res.error || '未知错误'), 'error');
}).catch(function(){ Portal.toast('网络错误', 'error'); });
}
});
}
两种写法各有取舍。单行 JSON 直接内联,省一次查找,但把整行数据暴露在 HTML 属性里;STORES 变量方案多一次查表,但 onclick 里只有一个被转义过的 id,属性短且安全。只要记住一条:凡是往 HTML 属性里塞用户数据,那个属性值必须转义。
第 3 条的 ?? '' 也值得强调:它防的是后端加了字段但门户还没跟上的中间态。少一个 ?? '',整个页面直接 500 白屏,而不是"这一列空着"。
步骤 4:加导航
config.php 的 nav_groups(导航按分组组织,每组下挂 items,items 还能有 children 子菜单):
'nav_groups' => [
[
'title' => '概览',
'items' => [
['name' => '工作台', 'path' => '/portal/dashboard', 'icon' => 'icon-dashboard', 'edition' => 'demo'],
['name' => '商品', 'path' => '/portal/products', 'icon' => 'icon-material', 'edition' => 'demo'],
['name' => '店铺', 'path' => '/portal/stores', 'icon' => 'icon-store', 'edition' => 'standard'],
// ...
],
],
],
edition 字段控制哪些版本能看到这个菜单:
| edition | 含义 | 可见性 |
|---|---|---|
demo | 展示版起可见 | 所有版本 |
standard | 标准版起可见 | 展示版隐藏 |
过滤逻辑在 render() 里:
$editionOrder = ['demo' => 0, 'standard' => 1, 'source' => 2];
$currentEdition = $editionOrder[$this->config['edition'] ?? 'standard'] ?? 1;
$groups = $this->config['nav_groups'] ?? [];
$filteredGroups = [];
foreach ($groups as $g) {
$keptItems = [];
foreach ($g['items'] as $it) {
if (($editionOrder[$it['edition'] ?? 'demo'] ?? 0) > $currentEdition) {
continue;
}
if (!empty($it['children'])) {
$kids = [];
foreach ($it['children'] as $c) {
if (($editionOrder[$c['edition'] ?? 'demo'] ?? 0) > $currentEdition) {
continue;
}
$kids[] = $c;
}
$it['children'] = $kids;
}
$keptItems[] = $it;
}
// ...
}
EDITION 由环境变量注入(docker-compose 里不同站点配不同值)。
这套 edition 机制是为了区分展示版 / 标准版 / 源码版三档交付。判断逻辑是"菜单的 edition 序号 ≤ 当前版本的序号",所以 demo 是最宽松的(所有人可见),source 最严格。
注意 children 子菜单是独立判断 edition 的——父项可见不代表子项可见。真实项目里"AI 中心"这组全是 standard,在展示版上整个组都不出现。
四、写操作:表单怎么提交
管理页的新建/编辑走 前端 → 门户 → 后端。这个项目里有两种写法,各有用处:
| 写法 | 路由注册 | 适用场景 |
|---|---|---|
| 通用透传 | 不注册,靠 index.php 的 /api/ 分支兜底 | 标准 REST 路径,如 /api/stores |
| 显式 Action | 在 $routes['POST'] 里注册 | 需要在门户层做字段转换/校验 |
通用透传:视图直接调后端 API,门户原样转发。
// views/admin_stores.php
Portal.api('/portal/api/stores', 'POST', normalize(data)).then(function (res) {
if (res.code === 0 || !res.error) { Portal.toast('店铺已创建', 'success'); setTimeout(function(){ location.reload(); }, 700); }
else Portal.toast('保存失败:' + (res.msg || res.error || '未知错误'), 'error');
});
请求打到门户的 /api/stores,index.php 里没匹配到显式路由,落到通用透传分支,proxyApi() 再转给后端。
显式 Action:需要在门户层做点什么时才用。比如商品页要选店铺,就得在门户把 storeId 转发过去:
// index.php —— 显式注册
'/api/products/create' => ['PortalController', 'createProductAction'],
'/api/products/update/{id}' => ['PortalController', 'updateProductAction'],
// PortalController::createProductAction()
public function createProductAction(): void
{
$this->ensureToken();
$body = $this->getJsonBody();
$skus = [];
foreach (($body['skus'] ?? []) as $s) {
if (!empty($s['spec'])) {
$skus[] = [
'skuCode' => $s['skuCode'] ?? '',
'spec' => $s['spec'],
'price' => floatval($s['price'] ?? 0),
'stock' => intval($s['stock'] ?? 0),
'warnStock' => intval($s['warnStock'] ?? 10),
];
}
}
$commissionRate = isset($body['commissionRate']) && $body['commissionRate'] !== '' ? floatval($body['commissionRate']) : null;
$uplineRate = isset($body['uplineRate']) && $body['uplineRate'] !== '' ? floatval($body['uplineRate']) : null;
$resp = $this->apiCall('/api/products', 'POST', [
'brandId' => 'BRAND_001',
'name' => $body['name'] ?? '',
'category' => $body['category'] ?? '榴莲鲜果',
'description' => $body['description'] ?? '',
'commissionRate' => $commissionRate,
'uplineRate' => $uplineRate,
// 所属店铺(空 = 平台自营)
'storeId' => trim((string)($body['storeId'] ?? '')) ?: null,
'skus' => $skus,
]);
if (isset($resp['error'])) {
$this->json(['code' => 1, 'msg' => $resp['error']]);
return;
}
$this->json(['code' => 0, 'data' => $resp]);
}
注意 'storeId' => trim((string)($body['storeId'] ?? '')) ?: null; 这一行——空串转 null。因为后端把"空"理解成"平台自营",而空字符串是个非空值,直接透传会让后端认为这家店编码是 "" 而不是"没归属"。
⚠️ 最常踩的坑:字段静默丢失。
新增一个业务字段(比如商品的
storeId),必须同时改三处:
- 后端的
normalize()(接受这个字段)- 门户的 Action(转发这个字段)← 最容易漏
- 前端表单(提交这个字段)
漏了第 2 步的表现是:接口不报错,但字段永远是空。
为什么漏了第 2 步不报错? 因为后端的 normalize() 是宽容的——它只挑自己认识的字段,不认识的直接丢掉:
// backend/src/handlers/stores.js
function normalize(body = {}, excludeId = null) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('店铺名称不能为空'), { status: 400 });
// ...
return {
name,
code,
posCode,
contact: String(body.contact || '').trim(),
phone: String(body.phone || '').trim(),
address: String(body.address || '').trim(),
note: String(body.note || '').trim(),
status,
};
// ↑ 不在返回列表里的字段(比如漏转发的 storeId)直接消失
}
前端发了一个 storeId,后端不认,就当它不存在。没有 unknown field 警告,没有 400,就是静默丢弃。
这也是为什么"字段静默丢失"这三个字要强调。任何会静默丢弃输入的校验逻辑,都是这种 bug 的温床。 好一点的实现会在 normalize() 里检测未识别的字段并告警——那样这个 bug 在开发第一天就暴露了。
所以现在回过头看 createProductAction() 里那句 // 所属店铺(空 = 平台自营)——它不是注释,是给未来的自己的警告。
五、两个绕不开的机制
5.1 apiCall() 的 401 自动重登
private function apiCall(string $endpoint, string $method = 'GET', array $data = []): array
{
$url = rtrim($this->config['api_gateway_url'], '/') . $endpoint;
$token = $_SESSION['portal_token'] ?? '';
$result = $this->doHttp($url, $method, $token, $data);
// 401 → 重新登录后重试一次
if (($result['__status'] ?? 0) === 401) {
unset($_SESSION['portal_token']);
$this->ensureToken();
$token = $_SESSION['portal_token'] ?? '';
if ($token) {
$result = $this->doHttp($url, $method, $token, $data);
}
}
unset($result['__status']);
return $result;
}
为什么需要它? JWT 有 8 小时有效期,用户开着页面过夜,第二天点任何按钮都会 401。有了这层,用户无感知地自动续上。
二开注意:apiCall 内部用 $result['__status'] 传递 HTTP 状态码,返回前会 unset。你的业务响应体里如果用 __status 当字段名,会被吃掉 —— 换个名字。
这个约定有点糙(用双下划线前缀避开常见字段名),但它是已存在的契约。你新写的接口如果也想透传状态码,得沿用同一套约定,否则 apiCall 的 401 重试逻辑就识别不到了。
5.2 ensureToken() 的自动登录(以及生产风险)
private function ensureToken(): void
{
if (!empty($_SESSION['portal_token'])) {
return;
}
$loginUrl = $this->config['auth']['login_url'];
$postData = json_encode([
'username' => 'admin',
'password' => '<你的密码>',
], JSON_UNESCAPED_UNICODE);
$opts = [
'http' => [
'method' => 'POST',
'header' => implode("\r\n", [
'Content-Type: application/json',
'Accept: application/json',
]),
'content' => $postData,
'timeout' => 10,
'ignore_errors' => true,
],
];
$context = stream_context_create($opts);
$response = @file_get_contents($loginUrl, false, $context);
// ... 解析 accessToken,存进 $_SESSION['portal_token']
}
门户没有独立的账号体系 —— 它用配置里的管理员账号去后端换 token,所有门户请求都以此身份调用后端。
🔴 生产安全提示(重要)
这段代码硬编码了
admin / <你的密码>。它让门户开箱即用,但也意味着:
- 门户的后端权限 = 管理员权限 —— 门户路由没做细粒度鉴权,能进门户就能调管理员接口
- 默认密码必须改 —— 上线前改
DEFAULT_ADMIN_USER/DEFAULT_ADMIN_PASS环境变量,并同步改这里的硬编码(或改成读配置)- 门户本身要挡在登录后面 ——
index.php里有这层保护:$isAuthPage = in_array($path, ['/login', '/logout'], true); $isPublicPoster = str_starts_with($path, '/u/'); $isAgentPage = str_starts_with($path, '/agent'); if (!$isAuthPage && !$isPublicPoster && !$isAgentPage && !$isShipPublic && empty($_SESSION['portal_user'])) { header('Location: /portal/login'); exit; }
$isPublicPoster(/u/开头)、$isAgentPage(/agent开头)和$isShipPublic是免登录白名单。新增公开页面时,路径必须落在这些前缀里,否则会被重定向到登录页。更彻底的做法:给门户配置一个只读的专用后端账号,而不是用 admin。
第 3 点里的"路径前缀决定是否免登录"是个约定与实现耦合的典型例子:白名单是靠路径前缀判断的,所以你的路由命名不是随意的——/u/ 和 /agent 已经被占了,别的公开页面得另想办法。
第 1 点(门户权限 = 管理员权限)是这套设计的结构性代价。因为门户是个内部工具,没有做多角色,所以它的 token 就是 admin。这意味着任何能访问门户 URL 的人,都是管理员。所以门户必须严格地挡在登录后面,而且那个登录不能和后端 admin 共用凭据。
六、demo 版的 403 之谜
这个坑值得单独开一节,因为它排查起来很反直觉。
现象:本地开发时新增的 /api/xxx 接口一切正常,部署到展示站后返回 403,index.php 里明明没这个限制。
原因在 index.php 的这段:
// 未显式注册的 /api/* 请求 → 通用透传代理(OpenAPI「测试 API」等)
// 展示版(demo)禁用透传,杜绝二开信息暴露
if (!$matched && str_starts_with($path, '/api/')) {
if (($config['edition'] ?? 'standard') === 'demo') {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => '展示版不开放 API 透传', 'code' => 403]);
return;
}
$controller = new PortalController($config);
$controller->proxyApi($path);
$matched = true;
}
逻辑是这样的:
- 先在
$routes里精确/模糊匹配 - 没匹配上、且路径以
/api/开头 → 走通用透传代理(把请求原样转发给后端) - 但 demo 版(展示站)禁用这个透传,直接 403
为什么 demo 版要禁用? 通用透传等于把整个后端 API 面暴露出去。展示站是给外部客户随便点的,不能让他们通过透传调到 /api/openapi/keys 之类的接口。
解法:把你的接口在 $routes 里显式注册,绕过透传分支:
'GET' => [
'/api/training/quiz/{id}' => ['PortalController', 'quizDetail'],
'/api/products/create' => ['PortalController', 'createProductAction'],
],
📌 规则:任何 demo 版也要用的
/api/*接口,必须在路由表里显式注册。依赖通用透传的接口,在展示站必然 403。
这个坑之所以反直觉,是因为透传是一个"默认放行"的隐式行为。你在本地测的时候,请求能通,你就以为接口是通的——实际上它走的是透传,不是你注册的路由。一旦切到 demo 版,透传关闭,隐式的依赖就暴露成显式的 403。
值得记取的教训是:如果你要依赖一个隐式行为,就把它变成显式的。显式注册一条路由的成本是 1 行,隐式透传的成本是线上 403 + 半小时排查。
顺带一提,proxyApi() 内部又挡了一道:
public function proxyApi(string $path = ''): void
{
// 展示版(demo)禁用 API 透传(防御纵深,index.php 已拦一道)
if (($this->config['edition'] ?? 'standard') === 'demo') {
http_response_code(403);
$this->json(['code' => 403, 'msg' => '展示版不开放 API 透传']);
return;
}
// ...
}
同一道门挡两次,这是"防御纵深"的思路:万一 index.php 那个分支被改漏了,proxyApi() 这道还能拦住。
七、布局与主题
render() 在渲染视图前,会往 $data 里塞几个全局可用的变量:
$data['navGroups'] = $filteredGroups; // 按 edition 过滤后的导航分组
$data['appName'] = $this->config['app_name'];
$data['currentPath'] = '/portal' . getRequestPath(); // 用于高亮当前菜单
$data['currentUser'] = $_SESSION['portal_user'] ?? null;
$data['edition'] = $this->config['edition'] ?? 'standard';
$data['theme'] = $this->getActiveTheme(); // 当前生效主题
然后:
extract($data, EXTR_SKIP);
$viewFile = dirname(__DIR__) . '/views/' . $view . '.php';
if (!file_exists($viewFile)) {
$viewFile = dirname(__DIR__) . '/views/placeholder.php';
}
ob_start();
include $viewFile; // 你的视图
$content = ob_get_clean();
include dirname(__DIR__) . '/views/layout.php'; // 塞进布局
所以视图里可以直接用 $navGroups、$theme、$currentUser、$currentPath。
视图文件不存在时会 fallback 到 placeholder.php —— 写错视图名不会报错,只会显示占位页。这个"贴心"设计反而容易让人以为是数据问题。
这个 fallback 是典型的为了不崩而牺牲可观测性。但你只要记住"页面显示成一个空白占位框 = 视图文件名写错了或路径不对",5 秒钟就能定位。前提是你知道有这回事——这就是它反直觉的地方。
主题配色通过 $theme 注入到 layout,二开做品牌定制时改 frontend/ 的 CSS 变量即可,不用动 PHP。
八、本篇 checklist
加一个管理页面,按顺序走:
-
index.php的GET路由表加了页面路由 -
index.php的POST路由表加了表单 Action(如有) -
PortalController加了对应方法,ensureToken()+apiCall()+render() -
views/xxx.php写好了,所有输出htmlspecialchars() -
config.php的nav_items加了入口,选对edition - 表单新字段三处都改了(后端 normalize / 门户 Action / 前端表单)
- 如果是 demo 站也要用的
/api/*接口,确认已显式注册 - rebuild 了 php-portal 镜像(
docker compose up -d --build php-portal)
最后一条是个纯粹的部署形态问题:php-portal 的代码是 COPY 进镜像的,不是 volume 挂载。所以你改了本地文件,容器里的还是旧的。改完不 rebuild,你会看到自己刚写的代码"没有生效"——而且因为文件确实存在于宿主机,你很难往那个方向想。
总结
接后端接口这件事,技术难度不高,但坑的密度不低。这篇拆开的 6 个静默踩坑点:
| # | 坑 | 表现 | 根因 |
|---|---|---|---|
| 1 | 字段静默丢失 | 接口 200,字段永远空 | 门户 Action 没转发,后端 normalize() 静默丢弃 |
| 2 | demo 版 403 | 本地正常,展示站 403 | 依赖通用透传,而 demo 版禁透传 |
| 3 | 引号截断 XSS | 单引号数据破坏 onclick | json_encode 没加 JSON_HEX_APOS |
| 4 | 缺 ?? '' 白屏 | 后端加字段后页面 500 | 取值没默认值 |
| 5 | __status 被吃 | 业务字段凭空消失 | 与 apiCall 的内部约定撞名 |
| 6 | 不 rebuild 不生效 | 改了代码页面没变 | php-portal 是 COPY 进镜像,不是 volume |
前两个坑已经被单独开节讲透了(第 4、6 节),后面四个散落在正文里。
归纳成三句话:
**门户是透传层 + 视图层,不是业务层。**所以加字段要改三处,漏了中间那处就是"静默丢失"——因为后端的 normalize() 会宽容地丢弃它不认识的字段。
**demo 版禁透传。**任何要在展示站用的 /api/* 接口,必须在 $routes 里显式注册。隐式行为在切换环境时就会变成显式故障。
**权限是继承来的,不是声明的。**门户用 admin 身份调后端,意味着门户的每个用户都是管理员——所以门户必须挡在登录后面,而且那个登录不该和后端 admin 共用凭据。
真正要带走的,其实是**"静默"这两个字**。上面 6 个坑的共同点不是难,而是它们都不给你任何信号——不报错、不告警、不中断。所以每加一个字段,就把这三处都过一遍;每改一次 PHP,就 rebuild 一次。
项目已开源,代码可自取:atomgit.com/pangzi_zi/d…
采用 Apache 2.0 许可证,可自由使用、修改与商用。