分销代理多端触达系统 · 二次开发教程(03)

📌 转载声明:本文为《分销代理多端触达系统 · 二次开发教程》系列第 03 篇。转载请注明出处;可运行的配套代码与最新版本见官方开源仓库(Apache 2.0):atomgit.com/pangzi_zi/distributor-touch

管理端页面开发(PHP 门户)

上一篇:02 新增业务模块端到端实战 本篇目标:给新加的业务模块配一个能用的管理页面。 范例同样来自真实代码:/portal/stores(店铺管理页),对应 PortalController::adminStores() + views/admin_stores.php。

一、先搞清楚门户的定位

PHP 门户不是业务层,它是「透传层 + 视图层」。

浏览器 ──► nginx ──► PHP 门户 ──► Node 后端(业务规则的唯一真相)
              ▲            │
              └────────────┘
           只做:会话、组装、渲染

这意味着:

  • ❌ 不要在 PHP 里写业务规则(不要在门户算佣金、算折扣、判断库存)
  • ✅ PHP 只做三件事:拿会话 token、调后端 API、把数据渲染成 HTML

为什么这么设计? 因为同一套后端要同时服务 B/S 门户、小程序、开放 API、企微。规则放在 PHP 里,小程序就得重写一遍。


二、门户的四个组成部分

services/php/portal/
├── index.php                       # ① 路由表 + 分发
├── config.php                      # ② 配置(后端地址、导航项)
├── controllers/
│   ├── PortalController.php        # ③ 控制器(3234 行,管理端 + C 端)
│   ├── AgentController.php         #    代理端
│   └── AuthController.php          #    登录 / 登出
└── views/
    ├── layout.php                  # ④ 布局骨架
    ├── admin_stores.php            #    38 个视图模板
    └── ...

三、手把手:加一个管理页面(以店铺页为例)

步骤 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} 被替换成 ([^/]+) —— 只匹配单段路径,不含斜杠。

步骤 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:写视图

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>
            </tr>
        </thead>
        <tbody id="stoRows">
            <?php foreach (($stores ?? []) as $s): ?>
            <tr>
                <td><?= htmlspecialchars($s['name'] ?? '') ?></td>
                <td><?= htmlspecialchars($s['code'] ?? '') ?></td>
                <td><?= htmlspecialchars($s['contact'] ?? '') ?></td>
                <td><?= (int)($s['productCount'] ?? 0) ?></td>
                <td><?= ($s['status'] ?? '') === 'active' ? '启用' : '停用' ?></td>
                <td>
                    <button onclick='openEdit(<?= json_encode($s, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
                        编辑
                    </button>
                </td>
            </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
</div>

三条视图铁律:

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

步骤 4:加导航

config.php 的 nav_items:

'nav_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;
$data['navItems'] = array_values(array_filter(
    $this->config['nav_items'],
    fn($item) => ($editionOrder[$item['edition'] ?? 'demo'] ?? 0) <= $currentEdition
));

EDITION 由环境变量注入(docker-compose 里 main 站是 source,show 站是 demo)。


四、写操作:表单怎么提交

管理页的新建/编辑通常走 POST 到门户 → 门户转调后端:

// 路由
'POST' => [
    '/api/stores/create' => ['PortalController', 'createStoreAction'],
    '/api/stores/update' => ['PortalController', 'updateStoreAction'],
],
public function createStoreAction(): void
{
    $this->ensureToken();

    $payload = [
        'name'    => trim($_POST['name'] ?? ''),
        'code'    => trim($_POST['code'] ?? ''),
        'contact' => trim($_POST['contact'] ?? ''),
        'phone'   => trim($_POST['phone'] ?? ''),
        'address' => trim($_POST['address'] ?? ''),
        'status'  => $_POST['status'] ?? 'active',
        // ⚠️ 如果商品页要选店铺,这里必须转发 storeId
    ];

    $resp = $this->apiCall('/api/stores', 'POST', $payload);

    if (isset($resp['error'])) {
        $this->json(['code' => 1, 'msg' => $resp['error']]);
        return;
    }
    $this->json(['code' => 0, 'data' => $resp]);
}
⚠️ 最常踩的坑:字段静默丢失。 新增一个业务字段(比如商品的 storeId),必须同时改三处: 1. 后端的 normalize()(接受这个字段) 2. 门户的 Action(转发这个字段)← 最容易漏 3. 前端表单(提交这个字段) 漏了第 2 步的表现是:接口不报错,但字段永远是空。

五、两个绕不开的机制

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 当字段名,会被吃掉 —— 换个名字。

5.2 ensureToken() 的自动登录(以及生产风险)

private function ensureToken(): void
{
    if (!empty($_SESSION['portal_token'])) return;

    $postData = json_encode([
        'username' => 'admin',
        'password' => 'admin123',
    ], JSON_UNESCAPED_UNICODE);
    // ... 调 /api/auth/login,把 accessToken 存进 $_SESSION
}

门户没有独立的账号体系 —— 它用配置里的管理员账号去后端换 token,所有门户请求都以此身份调用后端。

🔴 生产安全提示(重要) 这段代码硬编码了 admin / admin123。它让门户开箱即用,但也意味着: 1. 门户的后端权限 = 管理员权限 —— 门户路由没做细粒度鉴权,能进门户就能调管理员接口 2. 默认密码必须改 —— 上线前改 DEFAULT_ADMIN_USER / DEFAULT_ADMIN_PASS 环境变量,并同步改这里的硬编码(或改成读配置) 3. 门户本身要挡在登录后面 —— index.php 里有这层保护: ``php if (!$isAuthPage && !$isPublicPoster && !$isAgentPage && empty($_SESSION['portal_user'])) { header('Location: /portal/login'); exit; } ` $isPublicPoster(/u/ 开头)和 $isAgentPage(/agent 开头)是免登录白名单。**新增公开页面时,路径必须以 /u/ 或 /agent` 开头,否则会被重定向到登录页。 更彻底的做法:给门户配置一个只读的专用后端账号**,而不是用 admin。

六、demo 版的 403 之谜

这个坑值得单独开一节,因为它排查起来很反直觉。

现象:本地开发时新增的 /api/xxx 接口一切正常,部署到展示站后返回 403,index.php 里明明没这个限制。

原因在 index.php 的这段:

// 未显式注册的 /api/* 请求 → 通用透传代理
// 展示版(demo)禁用透传,杜绝二开信息暴露
if (!$matched && str_starts_with($path, '/api/')) {
    if (($config['edition'] ?? 'standard') === 'demo') {
        http_response_code(403);
        echo json_encode(['error' => 'Forbidden']);
        return;
    }
    // ... 否则走透传
}

逻辑是这样的:

  1. 先在 $routes 里精确/模糊匹配
  2. 没匹配上、且路径以 /api/ 开头 → 走通用透传代理(把请求原样转发给后端)
  3. 但 demo 版(展示站)禁用这个透传,直接 403

为什么 demo 版要禁用? 通用透传等于把整个后端 API 面暴露出去。展示站是给外部客户随便点的,不能让他们通过透传调到 /api/openapi/keys 之类的接口。

解法:把你的接口在 $routes 里显式注册,绕过透传分支:

'GET' => [
    '/api/training/quiz/{id}' => ['PortalController', 'quizDetail'],
    // ↑ 注释里就写着:demo 版禁透传,必须显式注册,否则 403
],
📌 规则:任何 demo 版也要用的 /api/* 接口,必须在路由表里显式注册。依赖通用透传的接口,在展示站必然 403。

七、布局与主题

render() 在渲染视图前,会往 $data 里塞几个全局可用的变量:

$data['navItems']    = /* 按 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);
ob_start();
include $viewFile;          // 你的视图
$content = ob_get_clean();
include dirname(__DIR__) . '/views/layout.php';   // 塞进布局

所以视图里可以直接用 $navItems、$theme、$currentUser、$currentPath。

视图文件不存在时会 fallback 到 placeholder.php —— 写错视图名不会报错,只会显示占位页。这个"贴心"设计反而容易让人以为是数据问题。

主题配色通过 $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)

下一篇

04 C 端商城与跨店铺拆单 —— 商城的数据模型、购物车分组、逐店结算与依次支付的完整实现。