PHP 门户接后端接口,字段为什么总是静默丢失?4 个固定动作 + 6 个静默踩坑点

23 阅读10分钟

"我加了字段,为什么保存之后还是空的?"

这是我最常收到的问题之一。通常伴随着这样的画面:改了 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>

三条视图铁律:

  1. 所有输出必须 htmlspecialchars() —— 店铺名、备注这些是用户可控输入,直接 echo 就是 XSS。
  2. 往 JS 里塞数据用 json_encode 带 JSON_HEX_APOS | JSON_HEX_QUOT —— 防止数据里的引号把 onclick='...' 截断。
  3. 取值一律带 ?? '' 默认值 —— 后端字段缺失时页面不能崩。

第 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),必须同时改三处:

  1. 后端的 normalize()(接受这个字段)
  2. 门户的 Action(转发这个字段)← 最容易漏
  3. 前端表单(提交这个字段)

漏了第 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 / <你的密码>。它让门户开箱即用,但也意味着:

  1. 门户的后端权限 = 管理员权限 —— 门户路由没做细粒度鉴权,能进门户就能调管理员接口
  2. 默认密码必须改 —— 上线前改 DEFAULT_ADMIN_USER / DEFAULT_ADMIN_PASS 环境变量,并同步改这里的硬编码(或改成读配置)
  3. 门户本身要挡在登录后面 —— 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;
}

逻辑是这样的:

  1. 先在 $routes 里精确/模糊匹配
  2. 没匹配上、且路径以 /api/ 开头 → 走通用透传代理(把请求原样转发给后端)
  3. 但 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() 静默丢弃
2demo 版 403本地正常,展示站 403依赖通用透传,而 demo 版禁透传
3引号截断 XSS单引号数据破坏 onclickjson_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 许可证,可自由使用、修改与商用。