分销代理多端触达系统 · 二次开发教程(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>
三条视图铁律:
- 所有输出必须
htmlspecialchars()—— 店铺名、备注这些是用户可控输入,直接echo就是 XSS。 - 往 JS 里塞数据用
json_encode带JSON_HEX_APOS | JSON_HEX_QUOT—— 防止数据里的引号把onclick='...'截断。 - 取值一律带
?? ''默认值 —— 后端字段缺失时页面不能崩。
步骤 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;
}
// ... 否则走透传
}
逻辑是这样的:
- 先在
$routes里精确/模糊匹配 - 没匹配上、且路径以
/api/开头 → 走通用透传代理(把请求原样转发给后端) - 但 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 端商城与跨店铺拆单 —— 商城的数据模型、购物车分组、逐店结算与依次支付的完整实现。