feat(zjmfmanager_reserve): 新增魔方财务代理分销插件
22 个文件变更
+5657
-0
13172193298@163.com
| •app_plugins/zjmfmanager_reserve/README.md | +99 -0 |
| •app_plugins/zjmfmanager_reserve/assets/style.css | +67 -0 |
| •app_plugins/zjmfmanager_reserve/bootstrap.php | +810 -0 |
| •app_plugins/zjmfmanager_reserve/install.sql | +104 -0 |
| •app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php | +558 -0 |
| •app_plugins/zjmfmanager_reserve/lib/upstream.php | +658 -0 |
| •app_plugins/zjmfmanager_reserve/lib/zjmf.php | +925 -0 |
| •app_plugins/zjmfmanager_reserve/plugin.json | +10 -0 |
| •app_plugins/zjmfmanager_reserve/uninstall.sql | +6 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/hosts.php | +127 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/logs.php | +94 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/orders.php | +154 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/products.php | +445 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/suppliers.php | +285 -0 |
| •app_plugins/zjmfmanager_reserve/views/host.php | +161 -0 |
| •app_plugins/zjmfmanager_reserve/views/hosts.php | +68 -0 |
| •app_plugins/zjmfmanager_reserve/views/layout.php | +46 -0 |
| •app_plugins/zjmfmanager_reserve/views/order.php | +140 -0 |
| •app_plugins/zjmfmanager_reserve/views/orders.php | +93 -0 |
| •app_plugins/zjmfmanager_reserve/views/shop.php | +78 -0 |
| •app_plugins/zjmfmanager_reserve/views/upgrade.php | +249 -0 |
| •docs/prd/zjmfmanager-reserve.md | +480 -0 |
变更内容
diff --git a/app_plugins/zjmfmanager_reserve/README.md b/app_plugins/zjmfmanager_reserve/README.md
new file mode 100644
index 0000000..140073d
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/README.md
@@ -0,0 +1,99 @@
+# 魔方财务代理分销(zjmfmanager_reserve)
+
+以代理商身份分销[魔方财务](https://www.zjmf.com/)(cube_finance)产品的 MNBT 业务插件。
+
+- 供应商:可维护多个魔方财务上游站点,各自独立的 API 账号、加价规则与启用状态
+- 商品:同步弹窗勾选(按供应商)+ 手动添加 + 供应商/单品加价 + 上架管理
+- 下单:本地余额(或已启用支付方式)购买,按订单所属供应商直通开通(主机归属代理商账号)
+- 主机:状态/流量查看,开关机/重启/重置密码/重装
+- 升级:配置升级 + 产品升降级(余额支付差额,按主机所属供应商路由)
+- 管理端:供应商管理、商品管理、订单管理、主机管理、操作日志
+
+依赖插件:`user_info`(认证)、`balance`(余额支付/退款)。
+
+## 安装
+
+1. 将 `zjmfmanager_reserve` 目录放入 `app_plugins/`。
+2. 后台 → 系统管理 → 插件管理 → 安装 → 启用(自动执行 `install.sql` 建表)。
+3. 供应商管理页新增供应商(名称、站点 URL、API 用户名、API 密钥、加价规则),保存后点击「连通测试」验证。
+4. 商品管理页「同步商品」弹窗选择供应商并勾选商品同步,或「手动添加」商品;按需配置单品加价并上架。
+5. 停用供应商后其商品自动不可售,主机操作与升级将被拒绝。
+
+## 目录结构
+
+```text
+app_plugins/zjmfmanager_reserve/
+├── plugin.json # 插件元信息(声明依赖 user_info/balance)
+├── bootstrap.php # 主入口:注册钩子/路由/菜单/AJAX
+├── install.sql # 建表(supplier/product/order/host/log)
+├── uninstall.sql # 卸载清理
+├── lib/
+│ ├── CubeFinanceClient.php # 上游 API 客户端(JWT 登录/请求封装)
+│ ├── upstream.php # ZjmfUpstream 服务层(按供应商路由:同步/开通/主机/升级)
+│ └── zjmf.php # 辅助函数 + 数据表操作 + 开通编排
+├── views/
+│ ├── layout.php # 用户端公共布局
+│ ├── shop.php # 商品列表(按供应商分组)
+│ ├── order.php # 下单页
+│ ├── orders.php # 我的订单
+│ ├── hosts.php # 我的主机
+│ ├── host.php # 主机详情(状态/流量/操作)
+│ ├── upgrade.php # 升级页(配置/产品)
+│ └── admin/ # 管理端页面
+│ ├── suppliers.php # 供应商管理(新增/编辑/连通测试/启停/删除)
+│ ├── products.php # 商品管理(同步弹窗/手动添加/加价/上下架)
+│ ├── orders.php # 订单管理(供应商/状态筛选)
+│ ├── hosts.php # 主机管理(刷新状态)
+│ └── logs.php # 操作日志
+└── assets/style.css # 用户端样式
+```
+
+## 多供应商模型
+
+- 每个供应商一行 `MN_plugin_zjmf_supplier`,保存独立的站点地址、API 账号、加价规则。
+- 商品/订单/主机/日志均记录 `supplier_id`;下单、主机操作、升级按该字段路由到对应上游。
+- JWT 缓存按供应商隔离(`runtime/cache/s{id}`),多供应商凭证互不串扰。
+- 商品加价优先级:单品已配置加价 > 所属供应商加价。
+
+## 关键流程
+
+### 购买开通
+
+```
+商品列表(按供应商分组)→ 下单页(选周期/支付方式)
+ → 创建本地订单(pending, 含 supplier_id) + MN_dd(lx=zjmf)
+ → 支付网关确认 → mnbt_pay_settle_order() 触发 order.paid 钩子
+ → 本插件按 ddh 前缀 ZJM 过滤 → 标记 paid → zjmf_open_host()
+ → 校验供应商启用 → 按订单 supplier_id 调上游下单+余额抵扣 → 建主机映射(密码 authcode 加密)
+ → 供应商缺失/停用或开通失败 → 订单置 failed + balance_add(refund) 自动退款
+```
+
+### 升级
+
+```
+主机详情 → 升级页 → 选择目标 → 试算差额(GET 页面端点,不产生提交)
+ → 确认 → 创建升级订单(含 supplier_id)→ balance_deduct 扣差额
+ → 按主机供应商提交上游(POST)→ 成功回填订单/更新主机缓存;失败自动退款
+```
+
+### 金额约定
+
+所有金额以「分」整数存储;上游元金额通过 `toCents()` 转换,避免浮点误差。
+加价比例以千分比存储(如 10 表示 +1%),固定加价直接存分。
+
+## 联调注意点
+
+以下端点/字段因上游版本与定制模块而异,已集中在 `lib/upstream.php` 便于调整
+(详见 PRD §3.3 与开放问题 Q1/Q3):
+
+- 开通端点:`cart/checkout`(`ZJMF_CHECKOUT_PATH`),如上游为 provision 直通则修改此处
+- 主机操作 func:`on/off/reboot/passwd/reinstall`(`zjmf_action_func()` 映射)
+- 升级端点:`upgrade/upgrade_config_page|_post`、`upgrade/upgrade_product_page|_post`
+- 响应字段解析均为防御式(`pickPrice`/`findId`/`findHostId`/`parseTrialPrice`)
+
+## 安全说明
+
+- 上游 API 密钥存入 `MN_plugin_zjmf_supplier.api_password`,编辑页不回显,日志脱敏
+- 主机密码使用 `authcode()` 加密入库,页面默认打码
+- 操作日志内容脱敏,不落明文密码/密钥
+- 用户端全部操作经路由鉴权(`zjmf_require_user()`),管理员 AJAX 使用 `p_zjmf_*` 前缀并鉴权
diff --git a/app_plugins/zjmfmanager_reserve/assets/style.css b/app_plugins/zjmfmanager_reserve/assets/style.css
new file mode 100644
index 0000000..08396ad
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/assets/style.css
@@ -0,0 +1,67 @@
+/* zjmfmanager_reserve Layui 风格覆盖 */
+.zj-nav{background:#1e9fff;}
+.zj-nav-inner{max-width:1060px;margin:0 auto;width:100%;display:flex;
+ align-items:center;justify-content:space-between;padding:0 16px;height:54px;}
+.zj-nav-brand{color:#fff;font-size:17px;font-weight:700;text-decoration:none;letter-spacing:.5px;}
+.zj-nav-brand:hover{color:#fff;text-decoration:none;}
+.zj-nav-links a{color:rgba(255,255,255,.85);font-size:14px;margin-left:22px;text-decoration:none;transition:color .15s;}
+.zj-nav-links a:hover{color:#fff;text-decoration:none;}
+.zj-page{max-width:1060px;margin:30px auto;padding:0 16px;}
+.zj-msg{display:none;padding:10px 16px;border-radius:4px;margin-bottom:16px;font-size:14px;}
+.zj-msg-show{display:block;}
+.zj-msg-success{background:#e8f5e9;color:#2e7d32;border:1px solid #c8e6c9;}
+.zj-msg-error{background:#ffebee;color:#c62828;border:1px solid #ffcdd2;}
+.zj-section{margin-bottom:20px;text-align:center;}
+.zj-section h1{font-size:24px;color:#222;margin:0 0 6px;}
+.zj-section p{color:#888;font-size:14px;}
+
+/* 商品卡片 */
+.zj-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:20px;}
+.zj-card{background:#fff;border:1px solid #e8e8e8;border-radius:8px;overflow:hidden;transition:box-shadow .15s;}
+.zj-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);}
+.zj-card-head{background:#f9fafb;padding:20px 20px 14px;border-bottom:1px solid #eee;}
+.zj-card-head h2{font-size:18px;color:#222;margin:0;}
+.zj-tag{display:inline-block;padding:2px 10px;font-size:11px;background:#e6f4ff;
+ color:#1e9fff;border-radius:10px;margin-top:6px;}
+.zj-card-desc{padding:14px 20px;font-size:13px;color:#666;line-height:1.7;}
+.zj-desc{padding:10px 2px 18px;font-size:13px;color:#666;line-height:1.7;}
+.zj-price{display:flex;gap:14px;padding:12px 20px;background:#fafbfc;border-top:1px solid #f0f0f0;flex-wrap:wrap;}
+.zj-price-item{display:flex;align-items:center;gap:4px;font-size:12px;}
+.zj-price-label{background:#e8e8e8;color:#666;padding:1px 6px;border-radius:3px;}
+.zj-price-value{font-size:18px;font-weight:700;color:#1e9fff;}
+.zj-buy{display:block;margin:0 20px 20px;text-align:center;}
+.zj-buy .layui-btn{width:100%;}
+
+/* 下单表单 */
+.zj-order-form .layui-form-label{width:90px;padding:9px 10px;text-align:right;box-sizing:border-box;}
+.zj-order-form .layui-input-block{margin-left:110px;min-height:36px;line-height:36px;}
+.zj-choices{display:flex;flex-wrap:wrap;gap:10px;align-items:center;}
+.zj-choice{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;
+ background:#f7f9fa;border:1px solid #e4e7ed;border-radius:6px;cursor:pointer;
+ font-size:14px;color:#333;transition:border-color .15s,background .15s;}
+.zj-choice:hover{border-color:#1e9fff;background:#f0f8ff;}
+.zj-choice input[type="radio"]{margin:0;cursor:pointer;}
+.zj-choice.active{border-color:#1e9fff;background:#e6f4ff;color:#1e9fff;}
+
+/* 表格 */
+.zj-table{width:100%;border-collapse:collapse;font-size:13px;}
+.zj-table th{background:#fafafa;padding:10px 12px;text-align:left;color:#666;
+ font-weight:600;border-bottom:2px solid #e6e6e6;}
+.zj-table td{padding:10px 12px;border-bottom:1px solid #f0f0f0;color:#333;vertical-align:middle;}
+.zj-mono{font-family:Consolas,Monaco,monospace;font-size:12px;}
+.zj-status{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500;}
+.zj-status-active,.zj-status-paid,.zj-status-opened{background:#e8f5e9;color:#2e7d32;}
+.zj-status-pending{background:#fff3e0;color:#e65100;}
+.zj-status-expired,.zj-status-failed,.zj-status-cancelled,.zj-status-suspend{background:#ffebee;color:#c62828;}
+.zj-pager{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:16px;padding:12px;font-size:13px;}
+.zj-pager a{color:#1e9fff;text-decoration:none;padding:4px 12px;border:1px solid #ddd;border-radius:3px;}
+.zj-pager a:hover{border-color:#1e9fff;background:#f0f8ff;}
+.zj-pager-info{color:#888;}
+.zj-muted{color:#999;font-size:12px;}
+@media(max-width:600px){
+ .zj-grid{grid-template-columns:1fr;}
+ .zj-order-form .layui-form-label{width:auto;display:block;text-align:left;padding-left:0;}
+ .zj-order-form .layui-input-block{margin-left:0;}
+ .zj-choices{gap:8px;}
+ .zj-table{display:block;overflow-x:auto;white-space:nowrap;}
+}
diff --git a/app_plugins/zjmfmanager_reserve/bootstrap.php b/app_plugins/zjmfmanager_reserve/bootstrap.php
new file mode 100644
index 0000000..df2b03a
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/bootstrap.php
@@ -0,0 +1,810 @@
+<?php
+/**
+ * zjmfmanager_reserve 插件 - 主入口
+ *
+ * 功能:以代理商身份分销魔方财务(cube_finance)产品。
+ * 依赖:user_info 插件(认证)、balance 插件(余额支付/退款)。
+ * 架构:
+ * - 上游服务:lib/upstream.php(ZjmfUpstream,封装全部魔方财务 API 调用)
+ * - 辅助函数:lib/zjmf.php(URL/渲染/金额/加价计算 + 数据表操作 + 开通编排)
+ * - 用户端:通过 P2 路由注册 /reserve/* 路径
+ * - 管理员端:通过 mnbt_register_page('admin', ...) 注册到 plugin.php
+ * - 开通:通过 order.paid 钩子处理 lx=zjmf 订单,调用上游开通,失败自动退款
+ */
+
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+
+require_once __DIR__ . '/lib/zjmf.php';
+require_once __DIR__ . '/lib/upstream.php';
+
+mnbt_plugin_register('zjmfmanager_reserve', [
+ 'name' => '魔方财务代理分销',
+ 'description' => '商品同步加价、本地余额购买、代理商直通开通、主机管理与升降级',
+]);
+
+/* ============================================================
+ * order.paid 钩子:处理购买 / 升级订单
+ * ============================================================
+ * 支付成功后由 mnbt_pay_settle_order() 触发。
+ * 1. 按 lx=zjmf 过滤(仅处理本插件创建的 MN_dd 订单)
+ * 2. 按订单号找到本地业务订单
+ * 3. 防重复:已支付/已开通/已失败直接跳过
+ * 4. 标记 paid 后按 action 分流处理(buy→开通;升级→后续里程碑)
+ */
+mnbt_add_action('order.paid', function ($order_row, $ctx = []) {
+ if (!is_array($order_row)) {
+ return;
+ }
+ if (($order_row['lx'] ?? '') !== ZJMF_LX) {
+ return;
+ }
+ $order_no = (string)($order_row['ddh'] ?? '');
+ if ($order_no === '') {
+ return;
+ }
+
+ // 业务订单号前缀校验,防止串单
+ if (strpos($order_no, ZJMF_ORDER_PREFIX) !== 0) {
+ return;
+ }
+
+ $order = zjmf_order_get_by_no($order_no);
+ if (!$order) {
+ return;
+ }
+ // 已支付 / 已开通 / 已失败,跳过(防重复处理)
+ if (in_array($order['status'], ['paid', 'opened', 'failed'], true)) {
+ return;
+ }
+
+ zjmf_order_set_status((int)$order['id'], 'paid', '支付完成');
+
+ // 仅 buy 动作本期直接开通;升级订单由升级接口联动处理
+ if ($order['action'] === 'buy') {
+ $result = zjmf_open_host((int)$order['id']);
+ if (!$result['ok']) {
+ @error_log('[zjmfmanager_reserve] open failed order=' . $order_no
+ . ' : ' . ($result['msg'] ?? ''));
+ }
+ }
+}, 20);
+
+/* ============================================================
+ * 管理员端页面注册
+ * ============================================================ */
+
+mnbt_register_page('admin', 'suppliers', 'views/admin/suppliers.php', '供应商管理');
+mnbt_register_page('admin', 'products', 'views/admin/products.php', '商品管理');
+mnbt_register_page('admin', 'orders', 'views/admin/orders.php', '订单管理');
+mnbt_register_page('admin', 'hosts', 'views/admin/hosts.php', '主机管理');
+mnbt_register_page('admin', 'logs', 'views/admin/logs.php', '操作日志');
+
+// 侧边栏菜单(三级结构)
+mnbt_register_menu('admin', [
+ 'title' => '魔方财务分销',
+ 'icon' => 'mdi-currency-cny',
+ 'order' => 60,
+ 'children' => [
+ ['title' => '供应商管理', 'page' => 'suppliers', 'icon' => 'mdi-settings', 'multitabs' => true],
+ ['title' => '商品管理', 'page' => 'products', 'icon' => 'mdi-package-variant', 'multitabs' => true],
+ ['title' => '订单管理', 'page' => 'orders', 'icon' => 'mdi-receipt', 'multitabs' => true],
+ ['title' => '主机管理', 'page' => 'hosts', 'icon' => 'mdi-server', 'multitabs' => true],
+ ['title' => '操作日志', 'page' => 'logs', 'icon' => 'mdi-clipboard-text', 'multitabs' => true],
+ ],
+]);
+
+/* ============================================================
+ * 管理员端 AJAX
+ * ============================================================ */
+
+// 保存供应商(新增/编辑,密码留空不修改)
+mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
+ mnbt_plugin_require_admin();
+ global $DB, $date;
+
+ $id = (int)($_POST['id'] ?? 0);
+ $name = trim((string)($_POST['name'] ?? ''));
+ $url = trim((string)($_POST['api_url'] ?? ''));
+ $username = trim((string)($_POST['api_username'] ?? ''));
+ $password = (string)($_POST['api_password'] ?? '');
+ $timeout = max(5, min(120, (int)($_POST['api_timeout'] ?? 30)));
+ $markupType = in_array((string)($_POST['markup_type'] ?? ''), ['0', '1'], true)
+ ? (int)$_POST['markup_type'] : 0;
+ $markupValue = max(0, (int)($_POST['markup_value'] ?? 0));
+ $status = in_array((string)($_POST['status'] ?? ''), ['0', '1'], true)
+ ? (int)$_POST['status'] : 1;
+ $sort = max(0, (int)($_POST['sort'] ?? 0));
+ $remark = trim((string)($_POST['remark'] ?? ''));
+
+ if ($name === '') {
+ json_exit_error('请填写供应商名称');
+ }
+ if (mb_strlen($name) > 50) {
+ json_exit_error('供应商名称过长');
+ }
+ if ($url !== '' && !preg_match('#^https?://#i', $url)) {
+ json_exit_error('站点 URL 必须以 http:// 或 https:// 开头');
+ }
+ if (mb_strlen($url) > 255) {
+ json_exit_error('站点 URL 过长');
+ }
+ if ($url !== '' && $username === '') {
+ json_exit_error('请填写 API 用户名');
+ }
+ if ($password !== '' && mb_strlen($password) > 255) {
+ json_exit_error('API 密钥过长');
+ }
+
+ $now = $date ?: date('Y-m-d H:i:s');
+ if ($id > 0) {
+ $existing = zjmf_supplier_get($id);
+ if (!$existing) {
+ json_exit_error('供应商不存在');
+ }
+ $sql = "UPDATE MN_plugin_zjmf_supplier
+ SET name=?, api_url=?, api_username=?, api_timeout=?,
+ markup_type=?, markup_value=?, status=?, sort=?,
+ remark=?, updated_at=?";
+ $args = [$name, $url, $username, $timeout, $markupType, $markupValue,
+ $status, $sort, $remark, $now];
+ if ($password !== '') {
+ $sql .= ", api_password=?";
+ $args[] = $password;
+ }
+ $sql .= " WHERE id=?";
+ $args[] = $id;
+ $ok = $DB->query_prepare($sql, $args);
+ if (!$ok) {
+ json_exit_error('保存失败');
+ }
+ } else {
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_supplier
+ (name, api_url, api_username, api_password, api_timeout,
+ markup_type, markup_value, status, sort, remark, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+ [$name, $url, $username, $password, $timeout, $markupType,
+ $markupValue, $status, $sort, $remark, $now, $now]
+ );
+ if (!$ok) {
+ json_exit_error('保存失败');
+ }
+ }
+ json_exit_success('已保存');
+});
+
+// 启用/停用供应商
+mnbt_register_ajax('admin', 'p_zjmf_admin_toggle_supplier', function () {
+ mnbt_plugin_require_admin();
+ global $DB, $date;
+
+ $id = (int)($_POST['id'] ?? 0);
+ $supplier = zjmf_supplier_get($id);
+ if (!$supplier) {
+ json_exit_error('供应商不存在');
+ }
+ $newStatus = (int)$supplier['status'] === 1 ? 0 : 1;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_supplier SET status=?, updated_at=? WHERE id=?",
+ [$newStatus, $now, $id]
+ );
+ json_exit_success($newStatus === 1 ? '已启用' : '已停用');
+});
+
+// 删除供应商(有商品/订单/主机时拒绝)
+mnbt_register_ajax('admin', 'p_zjmf_admin_delete_supplier', function () {
+ mnbt_plugin_require_admin();
+ $id = (int)($_POST['id'] ?? 0);
+ $result = zjmf_supplier_delete($id);
+ if (empty($result['ok'])) {
+ json_exit_error($result['msg'] ?? '删除失败');
+ }
+ json_exit_success($result['msg'] ?? '已删除');
+});
+
+// 连通测试(登录 + 商品列表)
+mnbt_register_ajax('admin', 'p_zjmf_admin_test_supplier', function () {
+ mnbt_plugin_require_admin();
+ $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
+ if (!$supplier) {
+ json_exit_error('供应商不存在');
+ }
+ $result = ZjmfUpstream::testConnection($supplier);
+ if (empty($result['ok'])) {
+ json_exit_error($result['msg'] ?? '连接失败');
+ }
+ json_exit_success($result['msg'] ?? '连接成功');
+});
+
+// 拉取供应商上游商品列表(供同步弹窗选择)
+mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_products', function () {
+ mnbt_plugin_require_admin();
+ $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
+ if (!$supplier) {
+ json_exit_error('供应商不存在');
+ }
+ $result = ZjmfUpstream::upstreamProducts($supplier);
+ if (empty($result['ok'])) {
+ json_exit_error($result['msg'] ?? '拉取失败');
+ }
+ // 标注已同步商品,便于弹窗勾选时识别
+ $existing = [];
+ $rows = zjmf_product_list_all();
+ foreach ($rows as $p) {
+ if ((int)$p['supplier_id'] === (int)$supplier['id']) {
+ $existing[(int)$p['up_product_id']] = true;
+ }
+ }
+ $list = [];
+ foreach (($result['data']['list'] ?? []) as $item) {
+ $list[] = [
+ 'id' => (int)($item['id'] ?? 0),
+ 'name' => (string)($item['name'] ?? ''),
+ 'description' => (string)($item['description'] ?? ''),
+ 'synced' => isset($existing[(int)($item['id'] ?? 0)]),
+ ];
+ }
+ json_exit_success('拉取成功', [
+ 'currency' => (string)($result['data']['currency_code'] ?? ''),
+ 'list' => $list,
+ ]);
+});
+
+// 按所选供应商 + 勾选商品 ID 列表同步
+mnbt_register_ajax('admin', 'p_zjmf_admin_sync_products', function () {
+ mnbt_plugin_require_admin();
+ $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
+ if (!$supplier) {
+ json_exit_error('请先选择供应商');
+ }
+ $upIds = [];
+ if (isset($_POST['up_ids']) && is_array($_POST['up_ids'])) {
+ foreach ($_POST['up_ids'] as $v) {
+ $v = (int)$v;
+ if ($v > 0) {
+ $upIds[] = $v;
+ }
+ }
+ }
+ $result = ZjmfUpstream::syncProducts($supplier, $upIds);
+ if (empty($result['ok'])) {
+ json_exit_error($result['msg'] ?? '同步失败');
+ }
+ json_exit_success($result['msg'] ?? '同步完成');
+});
+
+// 手动添加商品(供应商 + 上游商品 ID + 名称 + 描述)
+mnbt_register_ajax('admin', 'p_zjmf_admin_add_product', function () {
+ mnbt_plugin_require_admin();
+ global $DB, $date;
+
+ $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
+ if (!$supplier || (int)$supplier['status'] !== 1) {
+ json_exit_error('供应商不存在或已停用');
+ }
+ $upId = (int)($_POST['up_product_id'] ?? 0);
+ $name = trim((string)($_POST['name'] ?? ''));
+ $description = trim((string)($_POST['description'] ?? ''));
+ if ($upId <= 0) {
+ json_exit_error('请填写上游商品 ID');
+ }
+ if ($name === '') {
+ json_exit_error('请填写商品名称');
+ }
+ if (mb_strlen($name) > 100) {
+ json_exit_error('商品名称过长');
+ }
+ if (zjmf_product_get_by_up((int)$supplier['id'], $upId)) {
+ json_exit_error('该供应商下已存在此上游商品');
+ }
+
+ $now = $date ?: date('Y-m-d H:i:s');
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_product
+ (supplier_id, up_product_id, name, description, currency,
+ agent_price_cents, cycles, status, sort, synced_at, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+ [(int)$supplier['id'], $upId, $name, $description, '', 0,
+ '[]', 0, 50, $now, $now, $now]
+ );
+ if (!$ok) {
+ json_exit_error('商品创建失败');
+ }
+
+ // 立即拉取代理价与各周期价格(失败不阻断,商品标记待同步)
+ $result = ZjmfUpstream::syncOneProductBySupplier($supplier, $upId);
+ if (empty($result['ok'])) {
+ json_exit_error('商品已添加,但价格同步失败:' . ($result['msg'] ?? ''));
+ }
+ json_exit_success('商品已添加,价格同步完成');
+});
+
+// 保存商品加价/排序/状态
+mnbt_register_ajax('admin', 'p_zjmf_admin_save_product', function () {
+ mnbt_plugin_require_admin();
+
+ $id = (int)($_POST['id'] ?? 0);
+ $product = zjmf_product_get($id);
+ if (!$product) {
+ json_exit_error('商品不存在');
+ }
+
+ $markupType = in_array((string)($_POST['markup_type'] ?? ''), ['0', '1'], true)
+ ? (int)$_POST['markup_type'] : 0;
+ $markupValue = max(0, (int)($_POST['markup_value'] ?? 0));
+ $sort = max(0, (int)($_POST['sort'] ?? 50));
+ $status = in_array((string)($_POST['status'] ?? ''), ['0', '1'], true)
+ ? (int)$_POST['status'] : 0;
+
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $ok = $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product
+ SET markup_type=?, markup_value=?, sort=?, status=?, updated_at=?
+ WHERE id=?",
+ [$markupType, $markupValue, $sort, $status, $now, $id]
+ );
+ if (!$ok) {
+ json_exit_error('保存失败');
+ }
+ // 加价变化后重算各周期本地售价
+ zjmf_product_recalc_price($id);
+ json_exit_success('已保存');
+});
+
+// 切换商品上架/下架
+mnbt_register_ajax('admin', 'p_zjmf_admin_toggle_product', function () {
+ mnbt_plugin_require_admin();
+
+ $id = (int)($_POST['id'] ?? 0);
+ $product = zjmf_product_get($id);
+ if (!$product) {
+ json_exit_error('商品不存在');
+ }
+
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $newStatus = $product['status'] == 1 ? 0 : 1;
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product SET status=?, updated_at=? WHERE id=?",
+ [$newStatus, $now, $id]
+ );
+ json_exit_success($newStatus == 1 ? '已上架' : '已下架');
+});
+
+/* ============================================================
+ * 用户端页面路由
+ * ============================================================ */
+
+// 商品列表
+mnbt_register_route('GET', '/reserve/shop', function ($params, $ctx) {
+ zjmf_require_user();
+ zjmf_render('shop', [
+ 'page_title' => '商品选购',
+ 'products' => zjmf_product_list_active(),
+ ]);
+});
+
+// 下单页(选周期 + 支付方式)
+mnbt_register_route('GET', '/reserve/product/{product_id}', function ($params, $ctx) {
+ zjmf_require_user();
+ $product_id = (int)($params['product_id'] ?? 0);
+ $product = zjmf_product_get($product_id);
+ if (!$product || (int)$product['status'] !== 1) {
+ http_response_code(404);
+ echo '商品不存在或已下架';
+ return;
+ }
+ if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
+ http_response_code(404);
+ echo '商品所属供应商已停用';
+ return;
+ }
+ $methods = function_exists('mnbt_get_enabled_payment_methods')
+ ? mnbt_get_enabled_payment_methods() : [];
+
+ zjmf_render('order', [
+ 'page_title' => '购买:' . $product['name'],
+ 'product' => $product,
+ 'methods' => $methods,
+ ]);
+});
+
+/* ============================================================
+ * 用户端 API 路由
+ * ============================================================ */
+
+// 创建购买订单 → 生成 MN_dd → 分发支付网关
+mnbt_register_route('POST', '/reserve/api/create_order', function ($params, $ctx) {
+ global $DB, $date, $siteurl;
+
+ $user = zjmf_require_user();
+ $user_id = (int)$user['id'];
+
+ $product_id = (int)($_POST['product_id'] ?? 0);
+ $cycle = isset($_POST['cycle']) ? trim($_POST['cycle']) : '';
+ $type = isset($_POST['type']) ? trim($_POST['type']) : '';
+
+ // 校验商品与周期
+ $product = zjmf_product_get($product_id);
+ if (!$product || (int)$product['status'] !== 1) {
+ zjmf_json('商品不存在或已下架');
+ }
+ if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
+ zjmf_json('商品所属供应商已停用,无法下单');
+ }
+ $cycles = zjmf_product_cycles($product);
+ if (!isset($cycles[$cycle])) {
+ zjmf_json('请选择有效的购买周期');
+ }
+ $cycleCfg = $cycles[$cycle];
+ $amount_cents = (int)$cycleCfg['price_cents'];
+ if ($amount_cents <= 0) {
+ zjmf_json('该商品此周期价格异常');
+ }
+ // 校验支付方式
+ if (!function_exists('mnbt_pay_parse_type') || !mnbt_pay_parse_type($type)) {
+ zjmf_json('请选择有效的支付方式');
+ }
+
+ // 创建本地业务订单
+ $create = zjmf_order_create($user, $product, $cycle, $cycleCfg, 'buy', [
+ 'cost_cents' => (int)$product['agent_price_cents'],
+ ]);
+ if (empty($create['ok'])) {
+ zjmf_json($create['msg'] ?? '创建订单失败');
+ }
+ $order_no = $create['order_no'];
+ $order_id = (int)$create['order_id'];
+
+ // 创建 MN_dd 支付订单(lx=zjmf,qk=false 待支付)
+ $amount_yuan = (string)round($amount_cents / 100, 2);
+ $cs = json_encode([
+ 'user_id' => $user_id,
+ 'order_id' => $order_id,
+ 'amount' => $amount_cents,
+ 'username' => $user['username'],
+ ], 256);
+ $ip = $_SERVER["REMOTE_ADDR"] ?? '127.0.0.1';
+
+ $lastRow = $DB->get_row_prepare("SELECT id FROM MN_dd ORDER BY id DESC LIMIT 1");
+ $dd_id = $lastRow ? ((int)$lastRow['id'] + 1) : 1;
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_dd (id, cs, date, zffs, je, ddh, lx, qk, ip)
+ VALUES (?,?,?,?,?,?,?,?,?)",
+ [$dd_id, $cs, $date, $type, $amount_yuan, $order_no, ZJMF_LX, 'false', $ip]
+ );
+ if (!$ok) {
+ zjmf_order_set_status($order_id, 'cancelled', '支付订单创建失败');
+ zjmf_json('支付订单创建失败,请稍后重试');
+ }
+
+ // 分发到支付网关(余额插件 / 其他支付插件)
+ $order_context = [
+ 'out_trade_no' => $order_no,
+ 'name' => '购买商品:' . $product['name'] . '(' . $cycleCfg['name'] . ')',
+ 'money' => $amount_yuan,
+ 'type' => $type,
+ 'siteurl' => $siteurl,
+ 'pay_lx' => ZJMF_LX,
+ ];
+ $html = mnbt_pay_dispatch_gateway($type, $order_context);
+ if ($html === false) {
+ zjmf_order_set_status($order_id, 'cancelled', '支付方式不可用');
+ zjmf_json('支付方式不可用,请检查支付插件是否已启用');
+ }
+
+ zjmf_json('正在跳转到支付页面', [
+ 'html' => $html,
+ 'order_no' => $order_no,
+ ]);
+});
+
+// 我的订单
+mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
+ $user = zjmf_require_user();
+ $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
+ $orders = zjmf_order_list_by_user((int)$user['id'], $page, 15);
+
+ zjmf_render('orders', [
+ 'page_title' => '我的订单',
+ 'orders' => $orders,
+ ]);
+});
+
+// 我的主机列表
+mnbt_register_route('GET', '/reserve/hosts', function ($params, $ctx) {
+ $user = zjmf_require_user();
+ zjmf_render('hosts', [
+ 'page_title' => '我的主机',
+ 'hosts' => zjmf_host_list_by_user((int)$user['id']),
+ ]);
+});
+
+// 主机详情(实时状态 + 流量 + 操作)
+mnbt_register_route('GET', '/reserve/hosts/{host_id}', function ($params, $ctx) {
+ $user = zjmf_require_user();
+ $host_id = (int)($params['host_id'] ?? 0);
+ $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+ if (!$host) {
+ http_response_code(404);
+ echo '主机不存在';
+ return;
+ }
+
+ // 实时信息(失败不致命,仅展示缓存)
+ $info = ['ok' => false, 'msg' => ''];
+ $traffic = ['ok' => false, 'data' => []];
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+ if ((int)$host['up_host_id'] > 0 && $supplier) {
+ $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+ $traffic = ZjmfUpstream::hostTraffic($supplier, (int)$host['up_host_id']);
+ // 拉取成功后同步缓存状态
+ if (!empty($info['ok']) && $info['status'] !== $host['status']) {
+ zjmf_host_update_cache((int)$host['id'], ['status' => $info['status']]);
+ $host['status'] = $info['status'];
+ }
+ }
+
+ zjmf_render('host', [
+ 'page_title' => '主机详情:' . $host['name'],
+ 'host' => $host,
+ 'info' => $info,
+ 'traffic' => $traffic,
+ ]);
+});
+
+// 主机操作(开机/关机/重启/重置密码/重装)
+mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx) {
+ $user = zjmf_require_user();
+
+ $host_id = (int)($_POST['host_id'] ?? 0);
+ $action = isset($_POST['action']) ? trim($_POST['action']) : '';
+
+ $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+ if (!$host) {
+ zjmf_json('主机不存在');
+ }
+ if ((int)$host['up_host_id'] <= 0) {
+ zjmf_json('该主机缺少上游主机 ID,无法执行操作');
+ }
+ if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
+ zjmf_json('供应商已停用,无法执行操作');
+ }
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+
+ $func = zjmf_action_func($action);
+ if ($func === '') {
+ zjmf_json('不支持的操作');
+ }
+
+ // 重置密码需要新密码
+ $extra = [];
+ if ($action === 'reset_password') {
+ $password = trim((string)($_POST['password'] ?? ''));
+ if ($password === '') {
+ zjmf_json('请输入新密码');
+ }
+ $extra['password'] = $password;
+ }
+
+ $result = ZjmfUpstream::hostAction($supplier, (int)$host['up_host_id'], $func, $extra);
+ $hostOrder = zjmf_order_get((int)$host['order_id']);
+ $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
+ zjmf_log((int)$user['id'], $orderNo, 'host_action:' . $action,
+ empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
+ (int)$host['supplier_id']);
+
+ if (empty($result['ok'])) {
+ zjmf_json($result['msg'] ?? '操作失败');
+ }
+
+ // 操作成功:更新缓存状态与密码
+ $status = zjmf_action_status($action);
+ if ($status !== '') {
+ zjmf_host_update_cache((int)$host['id'], ['status' => $status]);
+ }
+ if ($action === 'reset_password' && $extra['password'] !== '') {
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_host SET password=?, updated_at=? WHERE id=?",
+ [zjmf_encrypt($extra['password']), $now, (int)$host['id']]
+ );
+ }
+
+ zjmf_json('ok', ['msg' => '操作成功']);
+});
+
+/* ============================================================
+ * 升级(配置升级 / 产品升降级)
+ * ============================================================ */
+
+// 升级页(kind=config|product)
+mnbt_register_route('GET', '/reserve/hosts/{host_id}/upgrade', function ($params, $ctx) {
+ $user = zjmf_require_user();
+ $host_id = (int)($params['host_id'] ?? 0);
+ $kind = ($_GET['kind'] ?? 'config') === 'product' ? 'product' : 'config';
+
+ $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+ if (!$host) {
+ http_response_code(404);
+ echo '主机不存在';
+ return;
+ }
+ if ((int)$host['up_host_id'] <= 0) {
+ echo '该主机缺少上游主机 ID,无法升级';
+ return;
+ }
+ if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
+ echo '供应商已停用,无法升级';
+ return;
+ }
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+
+ $options = ZjmfUpstream::upgradeOptions($supplier, (int)$host['up_host_id'], $kind);
+
+ zjmf_render('upgrade', [
+ 'page_title' => '升级:' . $host['name'],
+ 'host' => $host,
+ 'kind' => $kind,
+ 'options' => $options,
+ ]);
+});
+
+// 升级接口(preview=1 试算差额;preview=0 确认扣款并提交)
+mnbt_register_route('POST', '/reserve/api/upgrade', function ($params, $ctx) {
+ $user = zjmf_require_user();
+
+ $host_id = (int)($_POST['host_id'] ?? 0);
+ $kind = ($_POST['kind'] ?? 'config') === 'product' ? 'product' : 'config';
+ $isPreview = (($_POST['preview'] ?? '') === '1');
+
+ $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+ if (!$host) {
+ zjmf_json('主机不存在');
+ }
+ if ((int)$host['up_host_id'] <= 0) {
+ zjmf_json('该主机缺少上游主机 ID,无法升级');
+ }
+ if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
+ zjmf_json('供应商已停用,无法升级');
+ }
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+
+ // 构造升级选择参数
+ if ($kind === 'config') {
+ $raw = (string)($_POST['config_json'] ?? '');
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded) || $decoded === []) {
+ zjmf_json('请选择配置项');
+ }
+ $selection = ['configoption' => $decoded];
+ } else {
+ $newpid = (int)($_POST['newpid'] ?? 0);
+ $billingcycle = trim((string)($_POST['billingcycle'] ?? ''));
+ if ($newpid <= 0 || $billingcycle === '') {
+ zjmf_json('请选择目标产品与周期');
+ }
+ $selection = ['newpid' => $newpid, 'billingcycle' => $billingcycle];
+ }
+
+ // 试算差额
+ $preview = ZjmfUpstream::upgradePreview($supplier, $kind, (int)$host['up_host_id'], $selection);
+ if (empty($preview['ok'])) {
+ zjmf_json($preview['msg'] ?? '试算失败');
+ }
+ $amount_cents = (int)($preview['price_cents'] ?? 0);
+ if ($isPreview) {
+ zjmf_json('ok', [
+ 'price_cents' => $amount_cents,
+ 'price' => zjmf_format_cents($amount_cents),
+ ]);
+ }
+
+ // 防重复:同一主机存在未完成升级单则拦截
+ global $DB;
+ $dup = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_order
+ WHERE host_id=? AND action=? AND status IN ('pending','paid')
+ LIMIT 1",
+ [(int)$host['id'], 'upgrade_' . $kind]
+ );
+ if ($dup) {
+ zjmf_json('该主机已有未完成的升级订单');
+ }
+
+ $action = 'upgrade_' . $kind;
+ $orderParams = json_encode($selection, JSON_UNESCAPED_UNICODE);
+
+ // 创建升级订单
+ $create = zjmf_upgrade_order_create($user, $host, $action, $amount_cents, $orderParams);
+ if (empty($create['ok'])) {
+ zjmf_json($create['msg'] ?? '创建升级订单失败');
+ }
+ $order_id = (int)$create['order_id'];
+ $order_no = $create['order_no'];
+
+ // 扣余额(差额为 0 时跳过)
+ if ($amount_cents > 0) {
+ $deducted = function_exists('balance_deduct')
+ && balance_deduct((int)$user['id'], $amount_cents, 'consume',
+ $order_no, '升级扣款');
+ if (!$deducted) {
+ zjmf_order_set_status($order_id, 'failed', '余额不足');
+ zjmf_json('余额不足,无法完成升级');
+ }
+ }
+
+ // 提交上游
+ $submit = ZjmfUpstream::upgradeSubmit($supplier, $kind, (int)$host['up_host_id'], $selection);
+ if (empty($submit['ok'])) {
+ $msg = (string)($submit['msg'] ?? '升级提交失败');
+ zjmf_order_set_status($order_id, 'failed', $msg);
+ // 原路退回余额
+ if ($amount_cents > 0 && function_exists('balance_add')) {
+ balance_add((int)$user['id'], $amount_cents, 'refund',
+ $order_no, '升级失败自动退款');
+ }
+ zjmf_log((int)$user['id'], $order_no, $action, 'failed',
+ json_encode(['msg' => $msg], JSON_UNESCAPED_UNICODE),
+ (int)$host['supplier_id']);
+ zjmf_json($msg);
+ }
+
+ // 升级成功:回填订单 + 更新主机缓存
+ zjmf_order_fill_opened($order_id, (int)($submit['up_order_id'] ?? 0),
+ (int)$host['up_host_id'], (string)$host['username']);
+ zjmf_order_set_status($order_id, 'opened', '升级完成');
+
+ $cache = [];
+ if ($kind === 'product') {
+ $cache['up_product_id'] = (int)($_POST['newpid'] ?? 0);
+ }
+ $cache['cycle'] = (string)($_POST['billingcycle'] ?? $host['cycle']);
+ $cache['renew_date'] = (string)($preview['data']['renew_date'] ?? '');
+ zjmf_host_update_cache((int)$host['id'], $cache);
+
+ zjmf_log((int)$user['id'], $order_no, $action, 'success',
+ json_encode(['amount_cents' => $amount_cents, 'selection' => $selection],
+ JSON_UNESCAPED_UNICODE),
+ (int)$host['supplier_id']);
+
+ zjmf_json('ok', ['msg' => '升级成功']);
+});
+
+/* ============================================================
+ * 管理员端 - 主机操作 AJAX
+ * ============================================================ */
+
+// 刷新主机状态(管理员)
+mnbt_register_ajax('admin', 'p_zjmf_admin_fetch_host', function () {
+ mnbt_plugin_require_admin();
+
+ $id = (int)($_POST['id'] ?? 0);
+ $host = zjmf_host_get($id);
+ if (!$host) {
+ json_exit_error('主机不存在');
+ }
+ if ((int)$host['up_host_id'] <= 0) {
+ json_exit_error('该主机缺少上游主机 ID');
+ }
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+ if (!$supplier) {
+ json_exit_error('主机所属供应商不存在');
+ }
+ $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+ if (empty($info['ok'])) {
+ json_exit_error($info['msg'] ?? '查询失败');
+ }
+ $cache = ['status' => $info['status']];
+ if (isset($info['data']['renewdate']) || isset($info['data']['renew_date'])) {
+ $cache['renew_date'] = (string)($info['data']['renew_date']
+ ?? $info['data']['renewdate'] ?? '');
+ }
+ zjmf_host_update_cache($id, $cache);
+ json_exit_success('已刷新', ['status' => $info['status']]);
+});
diff --git a/app_plugins/zjmfmanager_reserve/install.sql b/app_plugins/zjmfmanager_reserve/install.sql
new file mode 100644
index 0000000..eab3ff0
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/install.sql
@@ -0,0 +1,104 @@
+-- 魔方财务代理分销插件 - 建表
+-- v1.1 多供应商:新增供应商表,product/order/host/log 增加 supplier_id
+
+CREATE TABLE IF NOT EXISTS `MN_plugin_zjmf_supplier` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(50) NOT NULL DEFAULT '' COMMENT '供应商名称',
+ `api_url` varchar(255) NOT NULL DEFAULT '' COMMENT '上游站点根地址',
+ `api_username` varchar(64) NOT NULL DEFAULT '' COMMENT 'API 用户名',
+ `api_password` varchar(255) NOT NULL DEFAULT '' COMMENT 'API 密钥(仅保存时写入,不回显)',
+ `api_timeout` int(11) NOT NULL DEFAULT '30' COMMENT '请求超时(秒)',
+ `markup_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '加价方式:0=比例 1=固定(分)',
+ `markup_value` bigint(20) NOT NULL DEFAULT '0' COMMENT '加价比例(千分比)或固定加价(分)',
+ `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=启用 0=停用',
+ `sort` int(11) NOT NULL DEFAULT '50',
+ `remark` varchar(255) NOT NULL DEFAULT '',
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `status` (`status`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+CREATE TABLE IF NOT EXISTS `MN_plugin_zjmf_product` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `supplier_id` int(11) NOT NULL DEFAULT '0' COMMENT '所属供应商ID',
+ `up_product_id` int(11) NOT NULL DEFAULT '0' COMMENT '上游商品ID',
+ `name` varchar(100) NOT NULL DEFAULT '',
+ `description` text,
+ `currency` varchar(10) NOT NULL DEFAULT '',
+ `agent_price_cents` bigint(20) NOT NULL DEFAULT '0' COMMENT '上游代理价(分)',
+ `markup_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '加价方式:0=比例 1=固定(分)',
+ `markup_value` bigint(20) NOT NULL DEFAULT '0' COMMENT '加价比例(千分比)或固定加价(分)',
+ `cycles` text COMMENT '周期JSON:[{cycle,name,price_cents}]',
+ `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '1=上架 0=下架',
+ `sort` int(11) NOT NULL DEFAULT '50',
+ `synced_at` datetime DEFAULT NULL,
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_supplier_up` (`supplier_id`,`up_product_id`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+CREATE TABLE IF NOT EXISTS `MN_plugin_zjmf_order` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `order_no` varchar(64) NOT NULL DEFAULT '',
+ `action` varchar(20) NOT NULL DEFAULT 'buy' COMMENT 'buy/upgrade_config/upgrade_product',
+ `supplier_id` int(11) NOT NULL DEFAULT '0' COMMENT '所属供应商ID(开通路由依据)',
+ `user_id` int(11) NOT NULL DEFAULT '0',
+ `product_id` int(11) NOT NULL DEFAULT '0',
+ `up_product_id` int(11) NOT NULL DEFAULT '0',
+ `product_name` varchar(100) NOT NULL DEFAULT '',
+ `cycle` varchar(20) NOT NULL DEFAULT '',
+ `cycle_name` varchar(20) NOT NULL DEFAULT '',
+ `amount_cents` bigint(20) NOT NULL DEFAULT '0',
+ `cost_cents` bigint(20) NOT NULL DEFAULT '0',
+ `order_params` text,
+ `up_order_id` int(11) NOT NULL DEFAULT '0',
+ `up_host_id` int(11) NOT NULL DEFAULT '0',
+ `host_id` int(11) NOT NULL DEFAULT '0' COMMENT '本地主机映射ID',
+ `username` varchar(64) NOT NULL DEFAULT '',
+ `status` varchar(20) NOT NULL DEFAULT 'pending',
+ `pay_time` datetime DEFAULT NULL,
+ `opened_at` datetime DEFAULT NULL,
+ `remark` varchar(255) NOT NULL DEFAULT '',
+ `created_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `order_no` (`order_no`),
+ KEY `user_id` (`user_id`),
+ KEY `supplier_id` (`supplier_id`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+CREATE TABLE IF NOT EXISTS `MN_plugin_zjmf_host` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `supplier_id` int(11) NOT NULL DEFAULT '0' COMMENT '所属供应商ID(操作/升级路由依据)',
+ `user_id` int(11) NOT NULL DEFAULT '0',
+ `order_id` int(11) NOT NULL DEFAULT '0',
+ `up_host_id` int(11) NOT NULL DEFAULT '0',
+ `up_product_id` int(11) NOT NULL DEFAULT '0',
+ `name` varchar(100) NOT NULL DEFAULT '',
+ `username` varchar(64) NOT NULL DEFAULT '',
+ `password` varchar(255) NOT NULL DEFAULT '' COMMENT 'authcode加密存储',
+ `cycle` varchar(20) NOT NULL DEFAULT '',
+ `status` varchar(20) NOT NULL DEFAULT 'unknown',
+ `renew_date` varchar(20) NOT NULL DEFAULT '',
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `supplier_id` (`supplier_id`),
+ KEY `up_host_id` (`up_host_id`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+CREATE TABLE IF NOT EXISTS `MN_plugin_zjmf_log` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `user_id` int(11) NOT NULL DEFAULT '0',
+ `supplier_id` int(11) NOT NULL DEFAULT '0' COMMENT '供应商ID',
+ `order_no` varchar(64) NOT NULL DEFAULT '',
+ `action` varchar(50) NOT NULL DEFAULT '',
+ `result` varchar(20) NOT NULL DEFAULT 'success',
+ `content` text,
+ `created_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `order_no` (`order_no`),
+ KEY `supplier_id` (`supplier_id`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
diff --git a/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php b/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php
new file mode 100644
index 0000000..2001490
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php
@@ -0,0 +1,558 @@
+<?php
+/**
+ * 魔方财务 (cube_finance / 智简魔方财务) API 客户端
+ *
+ * 改编自 app_plugins/zjmfmanager_reserve/example/sdk/CubeFinanceClient.php。
+ * 认证流程与魔方云 v10 上游对接一致:
+ * POST /zjmf_api_login → JWT
+ * Authorization: Bearer {jwt}
+ *
+ * 适用于 PHP 7.2+,仅依赖 ext-curl / ext-json。
+ * 返回 status=401/405 时自动强制重登并重试一次。
+ */
+
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+
+if (!class_exists('CubeFinanceException', false)) {
+
+class CubeFinanceException extends Exception
+{
+ /** @var array|null */
+ public $response;
+
+ public function __construct($message, $code = 0, $response = null)
+ {
+ parent::__construct($message, (int)$code);
+ $this->response = $response;
+ }
+}
+
+}
+
+if (!class_exists('CubeFinanceClient', false)) {
+
+class CubeFinanceClient
+{
+ /** @var string */
+ protected $baseUrl;
+
+ /** @var string */
+ protected $username;
+
+ /** @var string */
+ protected $password;
+
+ /** @var string|null */
+ protected $jwt;
+
+ /** @var int */
+ protected $timeout = 30;
+
+ /** @var string|null JWT 文件缓存目录,null 则仅内存缓存 */
+ protected $cacheDir;
+
+ /** @var int JWT 缓存秒数,默认 2 小时(与 v10 一致) */
+ protected $jwtTtl = 7200;
+
+ /** @var bool */
+ protected $verifySsl = false;
+
+ /**
+ * @param array $config
+ * - url string 魔方财务站点根地址
+ * - username string API 账号(客户用户名)
+ * - password string API 密钥(客户 API 密码)
+ * - timeout int 请求超时秒数
+ * - cache_dir string JWT 缓存目录(可写)
+ * - jwt_ttl int JWT 缓存时长
+ * - verify_ssl bool 是否校验 HTTPS 证书
+ * - jwt string 已有 JWT(可选,跳过登录)
+ */
+ public function __construct(array $config)
+ {
+ if (empty($config['url'])) {
+ throw new InvalidArgumentException('缺少 url');
+ }
+ $this->baseUrl = rtrim($config['url'], '/');
+ $this->username = isset($config['username']) ? (string)$config['username'] : '';
+ $this->password = isset($config['password']) ? (string)$config['password'] : '';
+ $this->timeout = isset($config['timeout']) ? (int)$config['timeout'] : 30;
+ $this->cacheDir = isset($config['cache_dir']) ? rtrim($config['cache_dir'], '/\\') : null;
+ $this->jwtTtl = isset($config['jwt_ttl']) ? (int)$config['jwt_ttl'] : 7200;
+ $this->verifySsl = !empty($config['verify_ssl']);
+ if (!empty($config['jwt'])) {
+ $this->jwt = (string)$config['jwt'];
+ }
+ }
+
+ /**
+ * 登录获取 JWT(会写入缓存)。
+ *
+ * @param bool $force 强制重新登录
+ * @return string
+ * @throws CubeFinanceException
+ */
+ public function login($force = false)
+ {
+ if (!$force) {
+ $cached = $this->getCachedJwt();
+ if ($cached) {
+ $this->jwt = $cached;
+ return $this->jwt;
+ }
+ }
+
+ if ($this->username === '' || $this->password === '') {
+ throw new CubeFinanceException('缺少 username 或 password,无法登录');
+ }
+
+ $raw = $this->rawRequest('POST', 'zjmf_api_login', [
+ 'username' => $this->username,
+ 'password' => $this->password,
+ ], false);
+
+ $result = $this->decodeJson($raw);
+ if (!isset($result['status']) || (int)$result['status'] !== 200 || empty($result['jwt'])) {
+ $msg = isset($result['msg']) ? $result['msg'] : '登录失败';
+ throw new CubeFinanceException($msg, isset($result['status']) ? (int)$result['status'] : 400, $result);
+ }
+
+ $this->jwt = $result['jwt'];
+ $this->setCachedJwt($this->jwt);
+ return $this->jwt;
+ }
+
+ /**
+ * 当前 JWT(未登录则自动登录)。
+ *
+ * @return string
+ */
+ public function getJwt()
+ {
+ if ($this->jwt) {
+ return $this->jwt;
+ }
+ return $this->login();
+ }
+
+ /**
+ * 通用请求(自动附带 Bearer,401/405 时强制重登一次)。
+ *
+ * @param string $method GET|POST|PUT|DELETE
+ * @param string $path 相对路径,如 api/product/list
+ * @param array $data
+ * @return array
+ * @throws CubeFinanceException
+ */
+ public function request($method, $path, array $data = [])
+ {
+ $this->getJwt();
+ $raw = $this->rawRequest($method, $path, $data, true);
+ $result = $this->decodeJson($raw);
+
+ if (isset($result['status']) && in_array((int)$result['status'], [401, 405], true)) {
+ $this->login(true);
+ $raw = $this->rawRequest($method, $path, $data, true);
+ $result = $this->decodeJson($raw);
+ if (isset($result['status']) && (int)$result['status'] === 401) {
+ throw new CubeFinanceException(
+ isset($result['msg']) ? $result['msg'] : 'API 账号或密码错误',
+ 401,
+ $result
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ public function get($path, array $data = [])
+ {
+ return $this->request('GET', $path, $data);
+ }
+
+ public function post($path, array $data = [])
+ {
+ return $this->request('POST', $path, $data);
+ }
+
+ public function put($path, array $data = [])
+ {
+ return $this->request('PUT', $path, $data);
+ }
+
+ public function delete($path, array $data = [])
+ {
+ return $this->request('DELETE', $path, $data);
+ }
+
+ // ==================== 业务封装 ====================
+
+ /**
+ * 测试连通性:登录并拉取商品列表。
+ *
+ * @return array{ok:bool,msg:string,jwt?:string,product_count?:int,raw?:array}
+ */
+ public function testConnection()
+ {
+ try {
+ $jwt = $this->login(true);
+ $list = $this->productList();
+ $count = 0;
+ if (isset($list['data']['list']) && is_array($list['data']['list'])) {
+ $count = count($list['data']['list']);
+ }
+ return [
+ 'ok' => true,
+ 'msg' => '连接成功',
+ 'jwt' => substr($jwt, 0, 24) . '...',
+ 'product_count' => $count,
+ 'raw' => $list,
+ ];
+ } catch (Exception $e) {
+ return [
+ 'ok' => false,
+ 'msg' => $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * 商品列表。
+ * GET /api/product/list
+ *
+ * @return array status/msg/data.list/data.currency_code
+ */
+ public function productList()
+ {
+ return $this->get('api/product/list');
+ }
+
+ /**
+ * 商品详情。
+ * GET /api/product/{id}?price_basis=agent
+ *
+ * @param int $productId
+ * @param string $priceBasis agent|cost 等
+ * @return array
+ */
+ public function productDetail($productId, $priceBasis = 'agent')
+ {
+ return $this->get('api/product/' . intval($productId), [
+ 'price_basis' => $priceBasis,
+ ]);
+ }
+
+ /**
+ * 购物车配置 / 价格试算。
+ * GET cart/set_config
+ *
+ * @param array $params 通常含 pid、billingcycle、配置项等
+ * @return array
+ */
+ public function cartSetConfig(array $params)
+ {
+ return $this->get('cart/set_config', $params);
+ }
+
+ /**
+ * 账户信息 / 余额。
+ * GET user_info
+ *
+ * @return array
+ */
+ public function userInfo()
+ {
+ return $this->get('user_info');
+ }
+
+ /**
+ * 主机头信息。
+ * GET host/header?host_id={id}
+ *
+ * @param int $hostId 上游主机 ID
+ * @return array
+ */
+ public function hostHeader($hostId)
+ {
+ return $this->get('host/header', ['host_id' => intval($hostId)]);
+ }
+
+ /**
+ * 流量使用。
+ * GET host/trafficusage
+ *
+ * @param int $hostId
+ * @param array $extra 如 start/end
+ * @return array
+ */
+ public function hostTrafficUsage($hostId, array $extra = [])
+ {
+ $data = array_merge(['host_id' => intval($hostId)], $extra);
+ return $this->get('host/trafficusage', $data);
+ }
+
+ /**
+ * 开通模块默认操作(开关机/重启/重装/重置密码等)。
+ * POST provision/default
+ *
+ * @param array $params 含 id(host_id)、func 等
+ * @return array
+ */
+ public function provisionDefault(array $params)
+ {
+ return $this->post('provision/default', $params);
+ }
+
+ /**
+ * 配置升级页。
+ * GET upgrade/index/{hostId}
+ *
+ * @param int $hostId
+ * @param array $params
+ * @return array
+ */
+ public function upgradeIndex($hostId, array $params = [])
+ {
+ return $this->get('upgrade/index/' . intval($hostId), $params);
+ }
+
+ /**
+ * 提交配置升级。
+ * POST upgrade/upgrade_config_post
+ *
+ * @param array $params
+ * @return array
+ */
+ public function upgradeConfigPost(array $params)
+ {
+ return $this->post('upgrade/upgrade_config_post', $params);
+ }
+
+ /**
+ * 配置升级确认页。
+ * GET upgrade/upgrade_config_page
+ *
+ * @param int $hostId
+ * @param string $priceBasis
+ * @return array
+ */
+ public function upgradeConfigPage($hostId, $priceBasis = 'agent')
+ {
+ return $this->get('upgrade/upgrade_config_page', [
+ 'hid' => intval($hostId),
+ 'price_basis' => $priceBasis,
+ ]);
+ }
+
+ /**
+ * 产品升降级选项。
+ * GET upgrade/upgrade_product/{hostId}
+ *
+ * @param int $hostId
+ * @param array $params
+ * @return array
+ */
+ public function upgradeProduct($hostId, array $params = [])
+ {
+ return $this->get('upgrade/upgrade_product/' . intval($hostId), $params);
+ }
+
+ /**
+ * 提交产品升降级。
+ * POST upgrade/upgrade_product_post
+ *
+ * @param array $params
+ * @return array
+ */
+ public function upgradeProductPost(array $params)
+ {
+ return $this->post('upgrade/upgrade_product_post', $params);
+ }
+
+ /**
+ * 余额抵扣 / 支付(视站点配置而定)。
+ * POST apply_credit
+ *
+ * @param array $params
+ * @return array
+ */
+ public function applyCredit(array $params)
+ {
+ return $this->post('apply_credit', $params);
+ }
+
+ /**
+ * 任意未封装接口。
+ *
+ * @param string $method GET|POST|PUT|DELETE
+ * @param string $path 相对路径
+ * @param array $data
+ * @return array
+ */
+ public function custom($method, $path, array $data = [])
+ {
+ return $this->request($method, ltrim($path, '/'), $data);
+ }
+
+ // ==================== 底层 HTTP ====================
+
+ /**
+ * @param string $method
+ * @param string $path
+ * @param array $data
+ * @param bool $auth
+ * @return string 原始响应体
+ * @throws CubeFinanceException
+ */
+ protected function rawRequest($method, $path, array $data, $auth)
+ {
+ $method = strtoupper($method);
+ $url = $this->baseUrl . '/' . ltrim($path, '/');
+ $headers = [
+ 'User-Agent: CubeFinanceClient/1.0 (+php)',
+ 'Accept: application/json',
+ ];
+ if ($auth) {
+ $headers[] = 'Authorization: Bearer ' . $this->jwt;
+ }
+
+ $ch = curl_init();
+ if ($method === 'GET') {
+ $qs = http_build_query($data);
+ if ($qs !== '') {
+ $url .= (strpos($url, '?') === false ? '?' : '&') . $qs;
+ }
+ curl_setopt($ch, CURLOPT_HTTPGET, true);
+ } else {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ }
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeout,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_SSL_VERIFYPEER => $this->verifySsl,
+ CURLOPT_SSL_VERIFYHOST => $this->verifySsl ? 2 : 0,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_HEADER => false,
+ ]);
+
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($errno) {
+ throw new CubeFinanceException('网络错误: ' . $error, $errno);
+ }
+ if ($httpCode < 200 || $httpCode >= 300) {
+ $snippet = is_string($body) ? $this->truncate($body, 300) : '';
+ throw new CubeFinanceException(
+ "HTTP {$httpCode}" . ($snippet !== '' ? ': ' . $snippet : ''),
+ $httpCode,
+ ['http_code' => $httpCode, 'body' => $body]
+ );
+ }
+ if ($body === false || $body === '') {
+ throw new CubeFinanceException('空响应', $httpCode);
+ }
+
+ return $body;
+ }
+
+ /**
+ * @param string $raw
+ * @return array
+ * @throws CubeFinanceException
+ */
+ protected function decodeJson($raw)
+ {
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ throw new CubeFinanceException(
+ '响应不是合法 JSON: ' . $this->truncate((string)$raw, 200)
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * 截断字符串(mb_substr 不可用时回退 substr)。
+ *
+ * @param string $str
+ * @param int $len
+ * @return string
+ */
+ protected function truncate($str, $len)
+ {
+ if (function_exists('mb_substr')) {
+ return mb_substr($str, 0, $len);
+ }
+ return substr($str, 0, $len);
+ }
+
+ protected function cacheKey()
+ {
+ return 'jwt_' . md5($this->baseUrl . '|' . $this->username);
+ }
+
+ protected function cacheFile()
+ {
+ if (!$this->cacheDir) {
+ return null;
+ }
+ if (!is_dir($this->cacheDir)) {
+ @mkdir($this->cacheDir, 0755, true);
+ }
+ return $this->cacheDir . DIRECTORY_SEPARATOR . $this->cacheKey() . '.json';
+ }
+
+ protected function getCachedJwt()
+ {
+ $file = $this->cacheFile();
+ if (!$file || !is_file($file)) {
+ return null;
+ }
+ $json = @file_get_contents($file);
+ $data = json_decode((string)$json, true);
+ if (empty($data['jwt']) || empty($data['expire']) || time() >= (int)$data['expire']) {
+ return null;
+ }
+ return $data['jwt'];
+ }
+
+ protected function setCachedJwt($jwt)
+ {
+ $file = $this->cacheFile();
+ if (!$file) {
+ return;
+ }
+ @file_put_contents($file, json_encode([
+ 'jwt' => $jwt,
+ 'expire' => time() + $this->jwtTtl,
+ ]));
+ }
+
+ /**
+ * 清除 JWT 缓存。
+ */
+ public function clearCache()
+ {
+ $this->jwt = null;
+ $file = $this->cacheFile();
+ if ($file && is_file($file)) {
+ @unlink($file);
+ }
+ }
+}
+
+}
diff --git a/app_plugins/zjmfmanager_reserve/lib/upstream.php b/app_plugins/zjmfmanager_reserve/lib/upstream.php
new file mode 100644
index 0000000..1490a0b
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/lib/upstream.php
@@ -0,0 +1,658 @@
+<?php
+/**
+ * zjmfmanager_reserve 插件 - 上游服务层
+ *
+ * 封装对魔方财务(cube_finance)的所有上游调用,供订单开通、主机操作、
+ * 商品同步、升级使用。所有方法均按供应商行($supplier)路由,
+ * 每个供应商独立客户端实例与独立 JWT 缓存(runtime/cache/s{id})。
+ * 上游响应字段因版本/定制站存在差异,解析均为防御式,
+ * 联调时以实际站点返回为准(见 PRD §3.3 / §12 Q1、Q3)。
+ */
+
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+
+require_once __DIR__ . '/CubeFinanceClient.php';
+
+/** 上游下单端点(方案 A,Q1 联调确认;如上游为 provision 直通则改此处) */
+define('ZJMF_CHECKOUT_PATH', 'cart/checkout');
+
+class ZjmfUpstream
+{
+ /**
+ * 创建上游客户端(按供应商行);连接信息不完整返回 null。
+ * JWT 缓存目录按供应商 ID 隔离,避免多供应商凭证串扰。
+ *
+ * @param array|null $supplier MN_plugin_zjmf_supplier 行
+ * @return CubeFinanceClient|null
+ */
+ public static function client($supplier)
+ {
+ if (!is_array($supplier)) {
+ return null;
+ }
+ $apiUrl = (string)($supplier['api_url'] ?? '');
+ $username = (string)($supplier['api_username'] ?? '');
+ $password = (string)($supplier['api_password'] ?? '');
+ if ($apiUrl === '' || $username === '' || $password === '') {
+ return null;
+ }
+ $supplierId = (int)($supplier['id'] ?? 0);
+ $cacheDir = mnbt_plugin_path('zjmfmanager_reserve')
+ . 'runtime/cache/s' . $supplierId;
+ return new CubeFinanceClient([
+ 'url' => $apiUrl,
+ 'username' => $username,
+ 'password' => $password,
+ 'timeout' => (int)($supplier['api_timeout'] ?? 30) > 0
+ ? (int)($supplier['api_timeout'] ?? 30) : 30,
+ 'cache_dir' => $cacheDir,
+ 'verify_ssl' => false,
+ ]);
+ }
+
+ /** 连通性测试(登录 + 商品列表),按供应商。 */
+ public static function testConnection($supplier)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ return $client->testConnection();
+ }
+
+ /* ============================================================
+ * 商品同步
+ * ============================================================ */
+
+ /** 上游商品列表(同步弹窗拉取,不做入库)。 */
+ public static function upstreamProducts($supplier)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->productList();
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '上游返回异常')];
+ }
+ return ['ok' => true, 'data' => $res['data'] ?? []];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * 按供应商同步单个商品(手动添加后拉取代理价与各周期价格)。
+ * 幂等:按 supplier_id + up_product_id upsert。
+ *
+ * @param array $supplier 供应商行
+ * @param int $upProductId 上游商品 ID
+ * @return array ['ok'=>bool, 'msg'=>string]
+ */
+ public static function syncOneProductBySupplier($supplier, $upProductId)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $upId = (int)$upProductId;
+ $detail = $client->productDetail($upId, 'agent');
+ $prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
+ $item = [
+ 'id' => $upId,
+ 'name' => (string)($prod['name'] ?? ''),
+ 'description' => (string)($prod['description'] ?? ''),
+ ];
+ $currency = (string)($detail['data']['currency_code'] ?? '');
+ $ok = self::syncOneProduct(
+ $client, (int)($supplier['id'] ?? 0), $upId, $item, $currency
+ );
+ return $ok
+ ? ['ok' => true, 'msg' => '商品价格已同步']
+ : ['ok' => false, 'msg' => '商品写入失败'];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /** 上游商品详情(代理价),供手动添加 / 单品刷新。 */
+ public static function productDetail($supplier, $upProductId)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->productDetail((int)$upProductId, 'agent');
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '获取商品详情失败')];
+ }
+ return ['ok' => true, 'data' => $res['data'] ?? []];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * 同步商品(按供应商,可选勾选商品 ID 列表)。
+ * 列表 + 代理价详情 + 各周期试算价。
+ * 幂等:按 supplier_id + up_product_id upsert,
+ * 不覆盖管理员加价/上架/排序。$upIds 为空表示同步全部。
+ *
+ * @param array $supplier 供应商行
+ * @param array $upIds 勾选的上游商品 ID 列表
+ * @return array ['ok'=>bool, 'msg'=>string]
+ */
+ public static function syncProducts($supplier, array $upIds = [])
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ $supplierId = (int)($supplier['id'] ?? 0);
+ try {
+ $res = $client->productList();
+ if (($res['status'] ?? 0) != 200 || empty($res['data']['list'])) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '上游返回异常')];
+ }
+ $currency = (string)($res['data']['currency_code'] ?? '');
+ $count = 0;
+ $fail = 0;
+ $skip = 0;
+ foreach ($res['data']['list'] as $item) {
+ $upId = (int)($item['id'] ?? 0);
+ if ($upId <= 0) {
+ continue;
+ }
+ if ($upIds && !in_array($upId, $upIds, true)) {
+ $skip++;
+ continue;
+ }
+ if (self::syncOneProduct($client, $supplierId, $upId, $item, $currency)) {
+ $count++;
+ } else {
+ $fail++;
+ }
+ }
+ $msg = "同步完成:更新 {$count} 个,失败 {$fail} 个";
+ if ($skip > 0) {
+ $msg .= ",跳过 {$skip} 个";
+ }
+ return ['ok' => true, 'msg' => $msg];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /** 同步单个商品。 */
+ protected static function syncOneProduct($client, $supplierId, $upId, $item, $currency)
+ {
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+
+ // 代理价(详情接口)
+ $agentCents = 0;
+ try {
+ $detail = $client->productDetail($upId, 'agent');
+ $prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
+ $agentCents = self::toCents(self::pickPrice($prod));
+ } catch (CubeFinanceException $e) {
+ // 详情失败不致命,仅代理价缺失
+ }
+
+ // 各周期试算价
+ $cycles = [];
+ foreach (zjmf_cycles() as $cycle => $cfg) {
+ try {
+ $trial = $client->cartSetConfig(['pid' => $upId, 'billingcycle' => $cycle]);
+ $price = self::parseTrialPrice($trial);
+ if ($price > 0) {
+ $cycles[] = [
+ 'cycle' => $cycle,
+ 'name' => $cfg['name'],
+ 'agent_price_cents' => $price,
+ ];
+ }
+ } catch (CubeFinanceException $e) {
+ continue;
+ }
+ }
+ $cyclesJson = json_encode($cycles, JSON_UNESCAPED_UNICODE);
+
+ $existing = zjmf_product_get_by_up($supplierId, $upId);
+ if ($existing) {
+ $ok = $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product
+ SET name=?, description=?, currency=?, agent_price_cents=?,
+ cycles=?, synced_at=?, updated_at=?
+ WHERE id=?",
+ [
+ (string)($item['name'] ?? ''),
+ (string)($item['description'] ?? ''),
+ $currency,
+ $agentCents,
+ $cyclesJson,
+ $now,
+ $now,
+ (int)$existing['id'],
+ ]
+ );
+ } else {
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_product
+ (supplier_id, up_product_id, name, description, currency,
+ agent_price_cents, cycles, status, sort, synced_at,
+ created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+ [
+ $supplierId,
+ $upId,
+ (string)($item['name'] ?? ''),
+ (string)($item['description'] ?? ''),
+ $currency,
+ $agentCents,
+ $cyclesJson,
+ 0,
+ 50,
+ $now,
+ $now,
+ $now,
+ ]
+ );
+ }
+ if (!$ok) {
+ return false;
+ }
+ // 重算本地售价(保持已有加价配置不变)
+ $targetId = $existing ? (int)$existing['id'] : self::lastProductId($supplierId);
+ if ($targetId > 0) {
+ zjmf_product_recalc_price($targetId);
+ }
+ return true;
+ }
+
+ /** 取指定供应商内最近插入的商品 ID。 */
+ protected static function lastProductId($supplierId)
+ {
+ global $DB;
+ $row = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_product
+ WHERE supplier_id=? ORDER BY id DESC LIMIT 1",
+ [(int)$supplierId]
+ );
+ return $row ? (int)$row['id'] : 0;
+ }
+
+ /* ============================================================
+ * 开通(代理商直通)
+ * ============================================================ */
+
+ /**
+ * 上游开通主机(按订单所属供应商路由)。
+ * 方案 A:cart/set_config 试算 → 下单 → apply_credit 余额支付 → 解析主机信息。
+ * 端点与响应字段联调确认(Q1),调整仅需改动本方法。
+ *
+ * @param array $order MN_plugin_zjmf_order 行
+ * @param array $supplier MN_plugin_zjmf_supplier 行
+ * @return array ['ok'=>bool, 'msg'=>string, 'up_order_id'=>int, 'up_host_id'=>int,
+ * 'username'=>string, 'password'=>string, 'name'=>string,
+ * 'renew_date'=>string]
+ */
+ public static function purchase($order, $supplier)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+
+ $params = [
+ 'pid' => (int)($order['up_product_id'] ?? 0),
+ 'billingcycle' => (string)($order['cycle'] ?? ''),
+ ];
+ $extra = json_decode((string)($order['order_params'] ?? ''), true);
+ if (is_array($extra)) {
+ $params = array_merge($params, $extra);
+ }
+
+ try {
+ // 1. 下单
+ $res = $client->post(ZJMF_CHECKOUT_PATH, $params);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '上游下单失败')];
+ }
+ $data = is_array($res['data'] ?? null) ? $res['data'] : [];
+
+ // 2. 若有上游订单,尝试余额抵扣支付(部分站点下单即开通,忽略失败)
+ $upOrderId = self::findId($data);
+ if ($upOrderId > 0) {
+ try {
+ $client->applyCredit(['order_id' => $upOrderId]);
+ } catch (CubeFinanceException $e) {
+ // 忽略:非致命
+ }
+ }
+
+ // 3. 解析主机信息
+ $hostId = self::findHostId($data);
+ if ($hostId > 0) {
+ $header = self::safeHostHeader($client, $hostId);
+ return [
+ 'ok' => true,
+ 'msg' => '开通成功',
+ 'up_order_id' => $upOrderId,
+ 'up_host_id' => $hostId,
+ 'username' => $header['username'],
+ 'password' => $header['password'],
+ 'name' => $header['name'],
+ 'renew_date' => $header['renew_date'],
+ ];
+ }
+
+ // 4. 未拿到 host_id:订单已生成,需人工核对(不判失败,避免误退款)
+ return [
+ 'ok' => true,
+ 'msg' => '上游订单已创建,但未返回主机 ID,请到上游后台核对',
+ 'up_order_id' => $upOrderId,
+ 'up_host_id' => 0,
+ 'username' => '',
+ 'password' => '',
+ 'name' => (string)($order['product_name'] ?? ''),
+ 'renew_date' => '',
+ ];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /* ============================================================
+ * 主机查询与操作
+ * ============================================================ */
+
+ /** 主机头信息(状态/账号/到期),按主机所属供应商。 */
+ public static function hostInfo($supplier, $upHostId)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->hostHeader((int)$upHostId);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '查询失败')];
+ }
+ $data = $res['data'] ?? [];
+ if (is_array($data) && isset($data['host']) && is_array($data['host'])) {
+ $data = $data['host'];
+ }
+ return ['ok' => true, 'data' => $data, 'status' => self::mapHostStatus($data)];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /** 流量使用,按主机所属供应商。 */
+ public static function hostTraffic($supplier, $upHostId)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->hostTrafficUsage((int)$upHostId);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '查询失败')];
+ }
+ return ['ok' => true, 'data' => $res['data'] ?? []];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /** 主机操作(provision/default,func 由调用方传入)。 */
+ public static function hostAction($supplier, $upHostId, $func, $extra = [])
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $params = array_merge(['id' => (int)$upHostId, 'func' => $func], $extra);
+ $res = $client->provisionDefault($params);
+ if (($res['status'] ?? 0) == 200) {
+ return ['ok' => true, 'msg' => (string)($res['msg'] ?? '操作成功')];
+ }
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '操作失败')];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /* ============================================================
+ * 升级(配置升级 / 产品升降级)
+ * ============================================================ */
+
+ /** 只读获取升级选项(配置项或可升降级产品列表)。 */
+ public static function upgradeOptions($supplier, $upHostId, $kind)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $kind === 'product'
+ ? $client->upgradeProduct((int)$upHostId)
+ : $client->upgradeIndex((int)$upHostId);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '获取升级选项失败')];
+ }
+ return ['ok' => true, 'data' => $res['data'] ?? []];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * 试算升级差额(GET 页面端点,只读不提交)。
+ * 端点联调确认 Q1;selection 结构:
+ * config → ['configoption' => [option_id => value]]
+ * product → ['newpid' => id, 'billingcycle' => cycle]
+ *
+ * @param array $supplier 供应商行
+ * @param string $kind config|product
+ * @param int $upHostId 上游主机 ID
+ * @param array $selection
+ * @return array ['ok'=>bool, 'msg'=>string, 'price_cents'=>int, 'data'=>array]
+ */
+ public static function upgradePreview($supplier, $kind, $upHostId, $selection)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $path = $kind === 'product'
+ ? 'upgrade/upgrade_product_page'
+ : 'upgrade/upgrade_config_page';
+ $params = array_merge(
+ ['hid' => (int)$upHostId, 'price_basis' => 'agent'],
+ $selection
+ );
+ $res = $client->get($path, $params);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '试算失败')];
+ }
+ return [
+ 'ok' => true,
+ 'msg' => (string)($res['msg'] ?? ''),
+ 'price_cents' => self::parseTrialPrice($res),
+ 'data' => is_array($res['data'] ?? null) ? $res['data'] : [],
+ ];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * 提交升级(确认,POST 端点)。
+ * 调用方需先扣款;提交失败由调用方负责退款。
+ *
+ * @param array $supplier 供应商行
+ * @param string $kind config|product
+ * @param int $upHostId 上游主机 ID
+ * @param array $selection
+ * @return array ['ok'=>bool, 'msg'=>string, 'price_cents'=>int,
+ * 'up_order_id'=>int, 'up_host_id'=>int]
+ */
+ public static function upgradeSubmit($supplier, $kind, $upHostId, $selection)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $params = array_merge(['hid' => (int)$upHostId], $selection);
+ $res = $kind === 'product'
+ ? $client->upgradeProductPost($params)
+ : $client->upgradeConfigPost($params);
+ if (($res['status'] ?? 0) != 200) {
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '升级提交失败')];
+ }
+ $data = is_array($res['data'] ?? null) ? $res['data'] : [];
+ return [
+ 'ok' => true,
+ 'msg' => (string)($res['msg'] ?? '提交成功'),
+ 'price_cents' => self::parseTrialPrice($res),
+ 'up_order_id' => self::findId($data),
+ 'up_host_id' => (int)$upHostId,
+ ];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /* ============================================================
+ * 响应解析辅助(防御式,字段以实际上游返回为准)
+ * ============================================================ */
+
+ /** 从响应 data 中提取金额(分),常见字段/嵌套结构兜底。 */
+ protected static function parseTrialPrice($res)
+ {
+ $data = $res['data'] ?? [];
+ if (!is_array($data)) {
+ return 0;
+ }
+ foreach (['price', 'money', 'total', 'amount', 'subtotal', 'renewal_price'] as $k) {
+ if (isset($data[$k]) && $data[$k] !== '' && $data[$k] !== null) {
+ $c = self::toCents($data[$k]);
+ if ($c > 0) {
+ return $c;
+ }
+ }
+ }
+ foreach (['cart', 'order', 'product', 'host'] as $k) {
+ if (isset($data[$k]) && is_array($data[$k])) {
+ $c = self::toCents(self::pickPrice($data[$k]));
+ if ($c > 0) {
+ return $c;
+ }
+ }
+ }
+ return 0;
+ }
+
+ /** 从数组取价格字段(元),取首个非空值。 */
+ protected static function pickPrice($arr)
+ {
+ if (!is_array($arr)) {
+ return 0;
+ }
+ foreach (['price', 'renew_price', 'setup_fee', 'total', 'amount'] as $k) {
+ if (isset($arr[$k]) && $arr[$k] !== '' && $arr[$k] !== null) {
+ return $arr[$k];
+ }
+ }
+ return 0;
+ }
+
+ /** 金额(元)→ 分。 */
+ protected static function toCents($val)
+ {
+ return (int)round((float)$val * 100);
+ }
+
+ /** 从 data 中找通用 ID(订单 ID)。 */
+ protected static function findId($arr)
+ {
+ if (!is_array($arr)) {
+ return 0;
+ }
+ foreach (['id', 'order_id', 'orderid'] as $k) {
+ if (isset($arr[$k])) {
+ return (int)$arr[$k];
+ }
+ }
+ return 0;
+ }
+
+ /** 从 data 中找主机 ID(支持嵌套 host)。 */
+ protected static function findHostId($arr)
+ {
+ if (!is_array($arr)) {
+ return 0;
+ }
+ foreach (['host_id', 'hostid', 'hid', 'id'] as $k) {
+ if (isset($arr[$k]) && (int)$arr[$k] > 0) {
+ return (int)$arr[$k];
+ }
+ }
+ if (isset($arr['host']) && is_array($arr['host'])) {
+ return self::findHostId($arr['host']);
+ }
+ return 0;
+ }
+
+ /** 查询主机头信息并安全提取字段(失败给空)。 */
+ protected static function safeHostHeader($client, $hostId)
+ {
+ try {
+ $res = $client->hostHeader((int)$hostId);
+ $data = $res['data'] ?? [];
+ if (is_array($data) && isset($data['host']) && is_array($data['host'])) {
+ $data = $data['host'];
+ }
+ return [
+ 'username' => (string)($data['username'] ?? ''),
+ 'password' => (string)($data['password'] ?? ''),
+ 'name' => (string)($data['name'] ?? ''),
+ 'renew_date' => (string)($data['renew_date'] ?? $data['renewdate'] ?? ''),
+ ];
+ } catch (CubeFinanceException $e) {
+ return ['username' => '', 'password' => '', 'name' => '', 'renew_date' => ''];
+ }
+ }
+
+ /** 上游主机状态 → 本地展示状态(active/suspend/unknown)。 */
+ public static function mapHostStatus($data)
+ {
+ if (!is_array($data)) {
+ return 'unknown';
+ }
+ $qk = $data['qk'] ?? null;
+ if ($qk !== null && in_array((string)$qk, ['false', '0', ''], true)) {
+ return 'suspend';
+ }
+ $st = (string)($data['status'] ?? $data['domainstatus'] ?? '');
+ $st = strtolower($st);
+ if (in_array($st, ['active', 'on', 'true', '运行中'], true)) {
+ return 'active';
+ }
+ if (in_array($st, ['suspended', 'suspend', 'paused', 'off'], true)) {
+ return 'suspend';
+ }
+ return 'unknown';
+ }
+}
diff --git a/app_plugins/zjmfmanager_reserve/lib/zjmf.php b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
new file mode 100644
index 0000000..e4c89bc
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
@@ -0,0 +1,925 @@
+<?php
+/**
+ * zjmfmanager_reserve 插件 - 辅助函数库
+ *
+ * 提供:URL/渲染/认证/金额辅助、商品、订单、主机、日志的数据库操作,
+ * 以及主机开通编排(支付成功后调用上游开通,失败自动退款)。
+ */
+
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+
+/* ============================================================
+ * 常量
+ * ============================================================ */
+
+/** 业务类型标识(MN_dd.lx),用于 order.paid 钩子过滤 */
+define('ZJMF_LX', 'zjmf');
+
+/** 本地订单号前缀 */
+define('ZJMF_ORDER_PREFIX', 'ZJM');
+
+/* ============================================================
+ * URL / 渲染辅助
+ * ============================================================ */
+
+/** 生成带站点 base path 前缀的 URL。 */
+function zjmf_url($path = '')
+{
+ $scriptName = isset($_SERVER['SCRIPT_NAME'])
+ ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
+ $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
+ if ($basePath === '.' || $basePath === '/') {
+ $basePath = '';
+ }
+ // 使用查询参数路由(index.php?_r=/path),避免依赖 Web 服务器 rewrite
+ $p = ltrim($path, '/');
+ $qpos = strpos($p, '?');
+ if ($qpos !== false) {
+ $route = substr($p, 0, $qpos);
+ $query = substr($p, $qpos + 1);
+ return $basePath . '/index.php?_r=/' . $route . '&' . $query;
+ }
+ return $basePath . '/index.php?_r=/' . $p;
+}
+
+/** 插件静态资源 URL。 */
+function zjmf_asset_url($path = '')
+{
+ return mnbt_plugin_url('zjmfmanager_reserve', 'assets/' . ltrim($path, '/'));
+}
+
+/** 管理员端插件页面 URL(admin/plugin.php?p=zjmfmanager_reserve&page=xxx)。 */
+function zjmf_admin_url($page, $extra = '')
+{
+ $base = 'plugin.php?p=zjmfmanager_reserve&page=' . rawurlencode($page);
+ if ($extra !== '') {
+ $base .= '&' . ltrim($extra, '&');
+ }
+ return $base;
+}
+
+/** 金额(分)→ 元(保留 2 位小数)。 */
+function zjmf_format_cents($cents)
+{
+ return number_format((int)$cents / 100, 2, '.', '');
+}
+
+/** 生成本地订单号。 */
+function zjmf_order_no()
+{
+ return ZJMF_ORDER_PREFIX . date('YmdHis') . mt_rand(1000, 9999);
+}
+
+/** 获取当前登录的 user_info 用户,未登录跳转登录页。 */
+function zjmf_require_user()
+{
+ if (!function_exists('user_info_auth_current')) {
+ http_response_code(500);
+ echo '需要先启用 user_info 插件';
+ exit;
+ }
+ $user = user_info_auth_current();
+ if (!$user) {
+ header('Location: ' . zjmf_url('account/login'));
+ exit;
+ }
+ return $user;
+}
+
+/** 渲染用户端视图。 */
+function zjmf_render($view, $vars = [])
+{
+ $vars['current_user'] = $vars['current_user']
+ ?? (function_exists('user_info_auth_current') ? user_info_auth_current() : null);
+ extract($vars, EXTR_SKIP);
+ $viewFile = mnbt_plugin_path('zjmfmanager_reserve') . 'views/' . $view . '.php';
+ if (!is_file($viewFile)) {
+ http_response_code(500);
+ echo 'View not found: ' . htmlspecialchars($view);
+ return;
+ }
+ include $viewFile;
+}
+
+/** 渲染管理员端视图。 */
+function zjmf_render_admin($view, $vars = [])
+{
+ extract($vars, EXTR_SKIP);
+ $viewFile = mnbt_plugin_path('zjmfmanager_reserve') . 'views/admin/' . $view . '.php';
+ if (!is_file($viewFile)) {
+ http_response_code(500);
+ echo 'Admin view not found: ' . htmlspecialchars($view);
+ return;
+ }
+ include $viewFile;
+}
+
+/** 输出 JSON 并退出。 */
+function zjmf_json($code, $extra = [])
+{
+ @header('Content-Type: application/json; charset=UTF-8');
+ $payload = ['code' => $code];
+ if (is_array($extra)) {
+ $payload = array_merge($payload, $extra);
+ }
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+/** 明文加密(authcode,用于上游主机密码入库)。 */
+function zjmf_encrypt($plain)
+{
+ return authcode((string)$plain, 'ENCODE', SYS_KEY);
+}
+
+/** 密文解密。 */
+function zjmf_decrypt($cipher)
+{
+ return authcode((string)$cipher, 'DECODE', SYS_KEY);
+}
+
+/** 账号脱敏展示(保留前 3 后 2)。 */
+function zjmf_mask_account($username)
+{
+ $s = (string)$username;
+ $len = strlen($s);
+ if ($len <= 5) {
+ return substr($s, 0, 1) . '***';
+ }
+ return substr($s, 0, 3) . '***' . substr($s, -2);
+}
+
+/** 写操作日志。 */
+function zjmf_log($user_id, $order_no, $action, $result, $content, $supplier_id = 0)
+{
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_log
+ (user_id, supplier_id, order_no, action, result, content, created_at)
+ VALUES (?,?,?,?,?,?,?)",
+ [(int)$user_id, (int)$supplier_id, (string)$order_no, (string)$action,
+ (string)$result, (string)$content, $now]
+ );
+}
+
+/** 日志列表(管理员,分页)。 */
+function zjmf_log_list_all($page = 1, $per_page = 30)
+{
+ global $DB;
+ $page = max(1, (int)$page);
+ $per_page = max(1, min(200, (int)$per_page));
+ $offset = ($page - 1) * $per_page;
+ $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_zjmf_log");
+ $total = $count_row ? (int)$count_row['cnt'] : 0;
+ $list = $DB->get_all_prepare(
+ "SELECT l.*, u.username AS user_name, s.name AS supplier_name
+ FROM MN_plugin_zjmf_log l
+ LEFT JOIN MN_plugin_user u ON u.id = l.user_id
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = l.supplier_id
+ ORDER BY l.id DESC LIMIT {$offset},{$per_page}"
+ ) ?: [];
+ return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
+}
+
+/* ============================================================
+ * 计费周期
+ * ============================================================ */
+
+/** 可用计费周期(key 与上游 billingcycle 一致,WHMCS 风格)。 */
+function zjmf_cycles()
+{
+ return [
+ 'Monthly' => ['name' => '月付'],
+ 'Quarterly' => ['name' => '季付'],
+ 'SemiAnnually' => ['name' => '半年付'],
+ 'Annually' => ['name' => '年付'],
+ 'Biennially' => ['name' => '两年付'],
+ 'Triennially' => ['name' => '三年付'],
+ ];
+}
+
+/* ============================================================
+ * 供应商管理
+ * ============================================================ */
+
+/** 获取单个供应商(行数组)。 */
+function zjmf_supplier_get($supplier_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_supplier WHERE id=? LIMIT 1",
+ [(int)$supplier_id]
+ ) ?: null;
+}
+
+/** 全部供应商列表(管理员,按 sort 升序)。 */
+function zjmf_supplier_list_all()
+{
+ global $DB;
+ return $DB->get_all_prepare(
+ "SELECT * FROM MN_plugin_zjmf_supplier ORDER BY sort ASC, id ASC"
+ ) ?: [];
+}
+
+/** 供应商加价标签(用于列表展示)。 */
+function zjmf_supplier_markup_label($supplier)
+{
+ $type = (int)($supplier['markup_type'] ?? 0);
+ $value = (int)($supplier['markup_value'] ?? 0);
+ return $type === 1
+ ? '固定 +' . zjmf_format_cents($value) . ' 元'
+ : '比例 +' . ($value / 10) . '%';
+}
+
+/** 供应商是否可销售(存在且启用)。 */
+function zjmf_supplier_usable($supplier_id)
+{
+ $supplier = zjmf_supplier_get($supplier_id);
+ return $supplier && (int)$supplier['status'] === 1;
+}
+
+/**
+ * 删除供应商(有商品/订单/主机时拒绝)。
+ *
+ * @return array ['ok'=>bool, 'msg'=>string]
+ */
+function zjmf_supplier_delete($supplier_id)
+{
+ global $DB;
+ $supplier_id = (int)$supplier_id;
+ $tables = [
+ 'MN_plugin_zjmf_product' => '商品',
+ 'MN_plugin_zjmf_order' => '订单',
+ 'MN_plugin_zjmf_host' => '主机',
+ ];
+ foreach ($tables as $table => $label) {
+ $row = $DB->get_row_prepare(
+ "SELECT COUNT(*) AS cnt FROM {$table} WHERE supplier_id=? LIMIT 1",
+ [$supplier_id]
+ );
+ if ($row && (int)$row['cnt'] > 0) {
+ return ['ok' => false, 'msg' => '该供应商下存在' . $label . '数据,无法删除'];
+ }
+ }
+ $ok = $DB->query_prepare(
+ "DELETE FROM MN_plugin_zjmf_supplier WHERE id=?",
+ [$supplier_id]
+ );
+ return $ok ? ['ok' => true, 'msg' => '已删除'] : ['ok' => false, 'msg' => '删除失败'];
+}
+
+/* ============================================================
+ * 商品管理
+ * ============================================================ */
+
+/** 获取单个商品。 */
+function zjmf_product_get($product_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_product WHERE id=? LIMIT 1",
+ [(int)$product_id]
+ ) ?: null;
+}
+
+/** 按供应商 + 上游商品 ID 获取商品。 */
+function zjmf_product_get_by_up($supplier_id, $up_product_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_product
+ WHERE supplier_id=? AND up_product_id=? LIMIT 1",
+ [(int)$supplier_id, (int)$up_product_id]
+ ) ?: null;
+}
+
+/** 上架商品列表(用户端,仅所属供应商启用时可见)。 */
+function zjmf_product_list_active()
+{
+ global $DB;
+ return $DB->get_all_prepare(
+ "SELECT p.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_product p
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = p.supplier_id
+ WHERE p.status=1 AND s.status=1
+ ORDER BY s.sort ASC, p.sort ASC, p.id ASC"
+ ) ?: [];
+}
+
+/** 全部商品列表(管理员,含供应商名)。 */
+function zjmf_product_list_all()
+{
+ global $DB;
+ return $DB->get_all_prepare(
+ "SELECT p.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_product p
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = p.supplier_id
+ ORDER BY s.sort ASC, p.sort ASC, p.id ASC"
+ ) ?: [];
+}
+
+/** 解析商品周期 JSON,返回 ['cycle' => ['name'=>, 'price_cents'=>]]。 */
+function zjmf_product_cycles($product)
+{
+ $raw = isset($product['cycles']) ? json_decode($product['cycles'], true) : null;
+ if (!is_array($raw)) {
+ return [];
+ }
+ $map = [];
+ foreach ($raw as $item) {
+ $cycle = (string)($item['cycle'] ?? '');
+ if ($cycle === '') {
+ continue;
+ }
+ $map[$cycle] = [
+ 'name' => (string)($item['name'] ?? $cycle),
+ 'price_cents' => (int)($item['price_cents'] ?? 0),
+ ];
+ }
+ return $map;
+}
+
+/**
+ * 计算本地售价(分)。
+ *
+ * @param int $agentCents 上游代理价(分)
+ * @param int $markupType 0=比例 1=固定(分)
+ * @param int $markupValue 比例(千分比)或固定加价(分)
+ * @return int
+ */
+function zjmf_calc_price($agentCents, $markupType, $markupValue)
+{
+ $agentCents = max(0, (int)$agentCents);
+ if ($markupType === 1) {
+ return max(0, $agentCents + max(0, (int)$markupValue));
+ }
+ $rate = max(0, (int)$markupValue);
+ return max(0, (int)round($agentCents * (1000 + $rate) / 1000));
+}
+
+/**
+ * 重算商品各周期本地售价并写回 cycles 字段。
+ * 加价规则:单品有配置则用单品,否则用所属供应商配置。
+ *
+ * @param int $product_id
+ * @return void
+ */
+function zjmf_product_recalc_price($product_id)
+{
+ global $DB, $date;
+ $product = zjmf_product_get($product_id);
+ if (!$product) {
+ return;
+ }
+ $supplier = zjmf_supplier_get((int)$product['supplier_id']);
+ // 单品已配置加价(比例 type=0 或固定 type=1 且 value>0)时用单品规则,否则用供应商
+ $hasOwn = (int)($product['markup_type'] ?? 0) !== 0
+ || (int)($product['markup_value'] ?? 0) > 0;
+ $markupType = $hasOwn
+ ? (int)$product['markup_type'] : (int)($supplier['markup_type'] ?? 0);
+ $markupValue = $hasOwn
+ ? (int)$product['markup_value'] : (int)($supplier['markup_value'] ?? 0);
+
+ $cycles = zjmf_product_cycles($product);
+ if ($cycles === []) {
+ return;
+ }
+ foreach ($cycles as $cycle => &$cfg) {
+ // agent_price 保存的是该周期对应的上游价(分)
+ $cfg['price_cents'] = zjmf_calc_price(
+ $cfg['agent_price_cents'] ?? 0,
+ $markupType,
+ $markupValue
+ );
+ }
+ unset($cfg);
+ $now = $date ?: date('Y-m-d H:i:s');
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product SET cycles=?, updated_at=? WHERE id=?",
+ [json_encode(array_values($cycles), JSON_UNESCAPED_UNICODE), $now, (int)$product_id]
+ );
+}
+
+/* ============================================================
+ * 订单管理
+ * ============================================================ */
+
+/** 按 ID 查询订单。 */
+function zjmf_order_get($order_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_order WHERE id=? LIMIT 1",
+ [(int)$order_id]
+ ) ?: null;
+}
+
+/** 按订单号查询订单。 */
+function zjmf_order_get_by_no($order_no)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_order WHERE order_no=? LIMIT 1",
+ [$order_no]
+ ) ?: null;
+}
+
+/** 用户订单列表(分页)。 */
+function zjmf_order_list_by_user($user_id, $page = 1, $per_page = 20)
+{
+ global $DB;
+ $user_id = (int)$user_id;
+ $page = max(1, (int)$page);
+ $per_page = max(1, min(100, (int)$per_page));
+ $offset = ($page - 1) * $per_page;
+ $count_row = $DB->get_row_prepare(
+ "SELECT COUNT(*) AS cnt FROM MN_plugin_zjmf_order WHERE user_id=?",
+ [$user_id]
+ );
+ $total = $count_row ? (int)$count_row['cnt'] : 0;
+ $list = $DB->get_all_prepare(
+ "SELECT o.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_order o
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = o.supplier_id
+ WHERE o.user_id=? ORDER BY o.id DESC LIMIT {$offset},{$per_page}",
+ [$user_id]
+ ) ?: [];
+ return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
+}
+
+/** 全部订单列表(管理员,分页 + 简单筛选)。 */
+function zjmf_order_list_all($page = 1, $per_page = 30, $filters = [])
+{
+ global $DB;
+ $page = max(1, (int)$page);
+ $per_page = max(1, min(200, (int)$per_page));
+ $offset = ($page - 1) * $per_page;
+
+ $where = '1';
+ $params = [];
+ if (!empty($filters['status'])) {
+ $where .= ' AND status=?';
+ $params[] = $filters['status'];
+ }
+ if (!empty($filters['order_no'])) {
+ $where .= ' AND order_no LIKE ?';
+ $params[] = '%' . $filters['order_no'] . '%';
+ }
+ if (!empty($filters['user_id'])) {
+ $where .= ' AND user_id=?';
+ $params[] = (int)$filters['user_id'];
+ }
+ if (!empty($filters['supplier_id'])) {
+ $where .= ' AND supplier_id=?';
+ $params[] = (int)$filters['supplier_id'];
+ }
+
+ $count_row = $DB->get_row_prepare(
+ "SELECT COUNT(*) AS cnt FROM MN_plugin_zjmf_order WHERE {$where}",
+ $params
+ );
+ $total = $count_row ? (int)$count_row['cnt'] : 0;
+ $list = $DB->get_all_prepare(
+ "SELECT o.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_order o
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = o.supplier_id
+ WHERE {$where} ORDER BY o.id DESC LIMIT {$offset},{$per_page}",
+ $params
+ ) ?: [];
+ return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
+}
+
+/**
+ * 创建本地订单(未支付)。
+ *
+ * @param array $user user_info 当前用户
+ * @param array $product 本地商品行
+ * @param string $cycle 计费周期
+ * @param array $cycleCfg 周期配置(name/price_cents)
+ * @param string $action buy/upgrade_config/upgrade_product
+ * @param array $extra 附加字段(up_host_id/host_id/order_params/cost_cents)
+ * @return array ['ok'=>bool, 'order_no'=>string, 'order_id'=>int, 'msg'=>string]
+ */
+function zjmf_order_create($user, $product, $cycle, $cycleCfg, $action = 'buy', $extra = [])
+{
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $order_no = zjmf_order_no();
+ $cycleName = (string)($cycleCfg['name'] ?? $cycle);
+
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_order
+ (order_no, action, supplier_id, user_id, product_id, up_product_id,
+ product_name, cycle, cycle_name, amount_cents, cost_cents, order_params,
+ up_order_id, up_host_id, host_id, username, status,
+ pay_time, opened_at, remark, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ [
+ $order_no,
+ $action,
+ (int)($product['supplier_id'] ?? 0),
+ (int)$user['id'],
+ (int)($product['id'] ?? 0),
+ (int)($product['up_product_id'] ?? 0),
+ (string)($product['name'] ?? ''),
+ $cycle,
+ $cycleName,
+ (int)($cycleCfg['price_cents'] ?? 0),
+ (int)($extra['cost_cents'] ?? 0),
+ (string)($extra['order_params'] ?? ''),
+ (int)($extra['up_order_id'] ?? 0),
+ (int)($extra['up_host_id'] ?? 0),
+ (int)($extra['host_id'] ?? 0),
+ (string)($extra['username'] ?? ''),
+ 'pending',
+ '',
+ '',
+ '',
+ $now,
+ ]
+ );
+ if (!$ok) {
+ return ['ok' => false, 'order_no' => '', 'order_id' => 0, 'msg' => '订单写入失败'];
+ }
+ $row = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_order WHERE order_no=? LIMIT 1",
+ [$order_no]
+ );
+ return [
+ 'ok' => true,
+ 'order_no' => $order_no,
+ 'order_id' => $row ? (int)$row['id'] : 0,
+ 'msg' => '',
+ ];
+}
+
+/** 更新订单状态。 */
+function zjmf_order_set_status($order_id, $status, $remark = '')
+{
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $extra = '';
+ $params = [$status];
+ if ($status === 'paid') {
+ $extra = ', pay_time=?';
+ $params[] = $now;
+ } elseif ($status === 'opened') {
+ $extra = ', opened_at=?';
+ $params[] = $now;
+ }
+ if ($remark !== '') {
+ $extra .= ', remark=?';
+ $params[] = $remark;
+ }
+ $params[] = (int)$order_id;
+ return (bool)$DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_order SET status=?{$extra} WHERE id=?",
+ $params
+ );
+}
+
+/** 回填订单开通信息。 */
+function zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username)
+{
+ global $DB;
+ return (bool)$DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_order
+ SET up_order_id=?, up_host_id=?, username=?
+ WHERE id=?",
+ [(int)$upOrderId, (int)$upHostId, (string)$username, (int)$order_id]
+ );
+}
+
+/**
+ * 创建升级订单(扣款前)。
+ *
+ * @param array $user user_info 当前用户
+ * @param array $host 本地主机映射行
+ * @param string $action upgrade_config / upgrade_product
+ * @param int $amountCents 升级差额(分)
+ * @param string $orderParams 升级参数 JSON(selection)
+ * @return array ['ok'=>bool, 'order_no'=>string, 'order_id'=>int, 'msg'=>string]
+ */
+function zjmf_upgrade_order_create($user, $host, $action, $amountCents, $orderParams)
+{
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $order_no = zjmf_order_no();
+
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_order
+ (order_no, action, supplier_id, user_id, product_id, up_product_id,
+ product_name, cycle, cycle_name, amount_cents, cost_cents, order_params,
+ up_order_id, up_host_id, host_id, username, status,
+ pay_time, opened_at, remark, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ [
+ $order_no,
+ $action,
+ (int)($host['supplier_id'] ?? 0),
+ (int)$user['id'],
+ 0,
+ (int)$host['up_product_id'],
+ '升级:' . $host['name'],
+ (string)($host['cycle'] ?? ''),
+ (string)($host['cycle'] ?? ''),
+ (int)$amountCents,
+ 0,
+ (string)$orderParams,
+ 0,
+ (int)$host['up_host_id'],
+ (int)$host['id'],
+ '',
+ 'pending',
+ '',
+ '',
+ '',
+ $now,
+ ]
+ );
+ if (!$ok) {
+ return ['ok' => false, 'order_no' => '', 'order_id' => 0, 'msg' => '订单写入失败'];
+ }
+ $row = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_order WHERE order_no=? LIMIT 1",
+ [$order_no]
+ );
+ return [
+ 'ok' => true,
+ 'order_no' => $order_no,
+ 'order_id' => $row ? (int)$row['id'] : 0,
+ 'msg' => '',
+ ];
+}
+
+/* ============================================================
+ * 主机映射管理
+ * ============================================================ */
+
+/** 按 ID 查询主机映射。 */
+function zjmf_host_get($host_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_host WHERE id=? LIMIT 1",
+ [(int)$host_id]
+ ) ?: null;
+}
+
+/** 校验主机归属当前用户后返回。 */
+function zjmf_host_get_by_user($user_id, $host_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT h.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_host h
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = h.supplier_id
+ WHERE h.id=? AND h.user_id=? LIMIT 1",
+ [(int)$host_id, (int)$user_id]
+ ) ?: null;
+}
+
+/** 按上游主机 ID 查询映射。 */
+function zjmf_host_get_by_up($up_host_id)
+{
+ global $DB;
+ return $DB->get_row_prepare(
+ "SELECT * FROM MN_plugin_zjmf_host WHERE up_host_id=? LIMIT 1",
+ [(int)$up_host_id]
+ ) ?: null;
+}
+
+/** 用户主机列表。 */
+function zjmf_host_list_by_user($user_id)
+{
+ global $DB;
+ return $DB->get_all_prepare(
+ "SELECT h.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_host h
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = h.supplier_id
+ WHERE h.user_id=? ORDER BY h.id DESC",
+ [(int)$user_id]
+ ) ?: [];
+}
+
+/** 全部主机列表(管理员,分页,含供应商名)。 */
+function zjmf_host_list_all($page = 1, $per_page = 30)
+{
+ global $DB;
+ $page = max(1, (int)$page);
+ $per_page = max(1, min(200, (int)$per_page));
+ $offset = ($page - 1) * $per_page;
+ $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_zjmf_host");
+ $total = $count_row ? (int)$count_row['cnt'] : 0;
+ $list = $DB->get_all_prepare(
+ "SELECT h.*, u.username AS user_name, s.name AS supplier_name
+ FROM MN_plugin_zjmf_host h
+ LEFT JOIN MN_plugin_user u ON u.id = h.user_id
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = h.supplier_id
+ ORDER BY h.id DESC LIMIT {$offset},{$per_page}"
+ ) ?: [];
+ return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
+}
+
+/** 新增主机映射。 */
+function zjmf_host_create($data)
+{
+ global $DB, $date;
+ $now = $date ?: date('Y-m-d H:i:s');
+ $ok = $DB->query_prepare(
+ "INSERT INTO MN_plugin_zjmf_host
+ (supplier_id, user_id, order_id, up_host_id, up_product_id, name, username,
+ password, cycle, status, renew_date, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ [
+ (int)($data['supplier_id'] ?? 0),
+ (int)($data['user_id'] ?? 0),
+ (int)($data['order_id'] ?? 0),
+ (int)($data['up_host_id'] ?? 0),
+ (int)($data['up_product_id'] ?? 0),
+ (string)($data['name'] ?? ''),
+ (string)($data['username'] ?? ''),
+ (string)($data['password'] ?? ''),
+ (string)($data['cycle'] ?? ''),
+ (string)($data['status'] ?? 'active'),
+ (string)($data['renew_date'] ?? ''),
+ $now,
+ $now,
+ ]
+ );
+ if (!$ok) {
+ return 0;
+ }
+ $row = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_host
+ WHERE order_id=? ORDER BY id DESC LIMIT 1",
+ [(int)($data['order_id'] ?? 0)]
+ );
+ return $row ? (int)$row['id'] : 0;
+}
+
+/** 更新主机缓存信息(状态/到期/周期)。 */
+function zjmf_host_update_cache($host_id, $data)
+{
+ global $DB, $date;
+ $sets = [];
+ $params = [];
+ foreach (['status', 'renew_date', 'cycle', 'up_product_id'] as $key) {
+ if (array_key_exists($key, $data)) {
+ $sets[] = "`{$key}`=?";
+ $params[] = (string)$data[$key];
+ }
+ }
+ if ($sets === []) {
+ return false;
+ }
+ $now = $date ?: date('Y-m-d H:i:s');
+ $sets[] = "updated_at=?";
+ $params[] = $now;
+ $params[] = (int)$host_id;
+ return (bool)$DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_host SET " . implode(',', $sets) . " WHERE id=?",
+ $params
+ );
+}
+
+/* ============================================================
+ * 主机操作辅助
+ * ============================================================ */
+
+/** 操作标识 → 上游 func 名称(视上游模块而定,联调时按实际调整)。 */
+function zjmf_action_func($action)
+{
+ $map = [
+ 'on' => 'on',
+ 'off' => 'off',
+ 'reboot' => 'reboot',
+ 'reset_password' => 'passwd',
+ 'reinstall' => 'reinstall',
+ ];
+ return $map[$action] ?? '';
+}
+
+/** 操作成功后建议写入的缓存状态(空表示不修改)。 */
+function zjmf_action_status($action)
+{
+ $map = [
+ 'on' => 'active',
+ 'off' => 'suspend',
+ 'reboot' => 'active',
+ ];
+ return $map[$action] ?? '';
+}
+
+/** 用户端主机状态展示标签映射。 */
+function zjmf_host_status_label($status)
+{
+ $map = [
+ 'active' => '运行中',
+ 'suspend' => '已暂停',
+ 'unknown' => '未知',
+ ];
+ return $map[$status] ?? $status;
+}
+
+/* ============================================================
+ * 主机开通(核心编排)
+ * ============================================================ */
+
+/**
+ * 支付成功后开通主机:调用上游开通,落库映射,失败自动退款。
+ *
+ * @param int $order_id MN_plugin_zjmf_order.id
+ * @return array ['ok'=>bool, 'msg'=>string, 'host_id'=>int]
+ */
+function zjmf_open_host($order_id)
+{
+ global $DB, $date;
+ $order = zjmf_order_get($order_id);
+ if (!$order) {
+ return ['ok' => false, 'msg' => '订单不存在'];
+ }
+ if ($order['status'] !== 'paid') {
+ return ['ok' => false, 'msg' => '订单状态非已支付,无法开通'];
+ }
+ // 幂等:已开通或已有映射跳过
+ $existing = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_host WHERE order_id=? LIMIT 1",
+ [(int)$order_id]
+ );
+ if ($existing) {
+ return ['ok' => true, 'msg' => '该订单已开通', 'host_id' => (int)$existing['id']];
+ }
+
+ // 供应商校验:缺失或停用时直接失败退款
+ $supplier = zjmf_supplier_get((int)$order['supplier_id']);
+ if (!$supplier || (int)$supplier['status'] !== 1) {
+ $msg = '供应商不存在或已停用,无法开通';
+ zjmf_order_set_status($order_id, 'failed', $msg);
+ zjmf_log((int)$order['user_id'], $order['order_no'], 'purchase',
+ 'failed', json_encode(['msg' => $msg], JSON_UNESCAPED_UNICODE),
+ (int)$order['supplier_id']);
+ $amount = (int)$order['amount_cents'];
+ if ($amount > 0 && function_exists('balance_add')) {
+ balance_add((int)$order['user_id'], $amount, 'refund',
+ $order['order_no'], '开通失败自动退款');
+ }
+ return ['ok' => false, 'msg' => $msg];
+ }
+
+ // 调用上游开通(代理商直通,按订单供应商路由)
+ $result = ZjmfUpstream::purchase($order, $supplier);
+ if (empty($result['ok'])) {
+ $msg = (string)($result['msg'] ?? '上游开通失败');
+ zjmf_order_set_status($order_id, 'failed', $msg);
+ zjmf_log((int)$order['user_id'], $order['order_no'],
+ 'purchase', 'failed', json_encode(['msg' => $msg], JSON_UNESCAPED_UNICODE),
+ (int)$order['supplier_id']);
+ // 自动原路退回余额
+ $amount = (int)$order['amount_cents'];
+ if ($amount > 0 && function_exists('balance_add')) {
+ balance_add((int)$order['user_id'], $amount, 'refund',
+ $order['order_no'], '开通失败自动退款');
+ }
+ return ['ok' => false, 'msg' => $msg];
+ }
+
+ $now = $date ?: date('Y-m-d H:i:s');
+ $upHostId = (int)($result['up_host_id'] ?? 0);
+ $username = (string)($result['username'] ?? '');
+ $password = (string)($result['password'] ?? '');
+ $upOrderId = (int)($result['up_order_id'] ?? 0);
+
+ // 回填订单
+ zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username);
+ zjmf_order_set_status($order_id, 'opened', '主机已开通');
+
+ // 写主机映射
+ $hostId = zjmf_host_create([
+ 'supplier_id' => (int)$order['supplier_id'],
+ 'user_id' => (int)$order['user_id'],
+ 'order_id' => (int)$order_id,
+ 'up_host_id' => $upHostId,
+ 'up_product_id' => (int)$order['up_product_id'],
+ 'name' => (string)($result['name'] ?? $order['product_name']),
+ 'username' => $username,
+ 'password' => $password !== '' ? zjmf_encrypt($password) : '',
+ 'cycle' => $order['cycle'],
+ 'status' => 'active',
+ 'renew_date' => (string)($result['renew_date'] ?? ''),
+ ]);
+
+ zjmf_log((int)$order['user_id'], $order['order_no'],
+ 'purchase', 'success',
+ json_encode([
+ 'up_order_id' => $upOrderId,
+ 'up_host_id' => $upHostId,
+ 'username' => $username,
+ ], JSON_UNESCAPED_UNICODE),
+ (int)$order['supplier_id']);
+
+ return ['ok' => true, 'msg' => '开通成功', 'host_id' => $hostId];
+}
diff --git a/app_plugins/zjmfmanager_reserve/plugin.json b/app_plugins/zjmfmanager_reserve/plugin.json
new file mode 100644
index 0000000..3a237b9
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "zjmfmanager_reserve",
+ "name": "魔方财务代理分销",
+ "version": "1.0.0",
+ "author": "启明智联",
+ "description": "以代理商身份分销魔方财务产品:商品同步加价、本地余额购买、代理商直通开通、主机管理与升降级。依赖 user_info 与 balance 插件。",
+ "requires_mnbt": "1.81",
+ "requires_plugins": ["user_info", "balance"],
+ "type": ["business"]
+}
diff --git a/app_plugins/zjmfmanager_reserve/uninstall.sql b/app_plugins/zjmfmanager_reserve/uninstall.sql
new file mode 100644
index 0000000..7b44122
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/uninstall.sql
@@ -0,0 +1,6 @@
+-- 魔方财务代理分销插件 - 卸载清理
+DROP TABLE IF EXISTS `MN_plugin_zjmf_supplier`;
+DROP TABLE IF EXISTS `MN_plugin_zjmf_product`;
+DROP TABLE IF EXISTS `MN_plugin_zjmf_order`;
+DROP TABLE IF EXISTS `MN_plugin_zjmf_host`;
+DROP TABLE IF EXISTS `MN_plugin_zjmf_log`;
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/hosts.php b/app_plugins/zjmfmanager_reserve/views/admin/hosts.php
new file mode 100644
index 0000000..69a1c77
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/hosts.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * 管理员端 - 主机管理
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$page = max(1, (int)($_GET['page_num'] ?? 1));
+$hosts = zjmf_host_list_all($page, 30);
+$status_labels = [
+ 'active' => '运行中',
+ 'suspend' => '已暂停',
+ 'unknown' => '未知',
+];
+$status_classes = [
+ 'active' => 'badge-success',
+ 'suspend' => 'badge-danger',
+ 'unknown' => 'badge-secondary',
+];
+$title = $title ?? '主机管理';
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header"><h4 style="display:inline-block">主机管理</h4></div>
+ <div class="card-body">
+ <p class="text-muted">主机映射为开通成功的上游主机。可点击「刷新状态」同步上游实时状态。</p>
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>供应商</th>
+ <th>用户</th>
+ <th>主机名</th>
+ <th>上游主机ID</th>
+ <th>状态</th>
+ <th>周期</th>
+ <th>到期时间</th>
+ <th>创建时间</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($hosts['list'])): ?>
+ <tr><td colspan="10" class="text-center text-muted">暂无主机</td></tr>
+ <?php else: ?>
+ <?php foreach ($hosts['list'] as $h): ?>
+ <tr>
+ <td><?= (int)$h['id'] ?></td>
+ <td><?= htmlspecialchars($h['supplier_name'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($h['user_name'] ?: ('ID ' . (int)$h['user_id'])) ?></td>
+ <td><?= htmlspecialchars($h['name'], ENT_QUOTES) ?></td>
+ <td><?= (int)$h['up_host_id'] ?></td>
+ <td>
+ <span class="badge <?= htmlspecialchars($status_classes[$h['status']] ?? 'badge-secondary') ?>">
+ <?= htmlspecialchars($status_labels[$h['status']] ?? $h['status']) ?>
+ </span>
+ </td>
+ <td><?= htmlspecialchars($h['cycle'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($h['renew_date'] ?: '-') ?></td>
+ <td class="small"><?= htmlspecialchars($h['created_at']) ?></td>
+ <td>
+ <?php if ((int)$h['up_host_id'] > 0): ?>
+ <button type="button" class="btn btn-sm btn-outline-primary zjf-refresh"
+ data-id="<?= (int)$h['id'] ?>">刷新状态</button>
+ <?php else: ?>
+ <span class="text-muted">-</span>
+ <?php endif; ?>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <?php
+ $total_pages = max(1, (int)ceil($hosts['total'] / $hosts['per_page']));
+ $current_page = (int)$hosts['page'];
+ if ($total_pages > 1):
+ $qs = http_build_query(['p' => 'zjmfmanager_reserve', 'page' => 'hosts']);
+ ?>
+ <nav>
+ <ul class="pagination pagination-sm">
+ <?php if ($current_page > 1): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page - 1), ENT_QUOTES
+ ) ?>">上一页</a></li>
+ <?php endif; ?>
+ <li class="page-item disabled"><span class="page-link">
+ 第 <?= $current_page ?> / <?= $total_pages ?> 页(共 <?= (int)$hosts['total'] ?> 条)
+ </span></li>
+ <?php if ($current_page < $total_pages): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page + 1), ENT_QUOTES
+ ) ?>">下一页</a></li>
+ <?php endif; ?>
+ </ul>
+ </nav>
+ <?php endif; ?>
+ </div>
+ </div>
+</div>
+<script>
+document.querySelectorAll('.zjf-refresh').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var self = this;
+ self.disabled = true;
+ self.textContent = '刷新中...';
+ $.post('ajax.php', {gn: 'p_zjmf_admin_fetch_host', id: self.getAttribute('data-id')}, function (res) {
+ var d;
+ try { d = typeof res === 'string' ? JSON.parse(res) : res; } catch (e) { d = {code: res}; }
+ var ok = d.qk == 1 || d.success;
+ if (typeof $.notify === 'function') {
+ $.notify({message: d.msg || d.code || '完成'}, {type: ok ? 'success' : 'danger'});
+ } else {
+ alert(d.msg || d.code || '完成');
+ }
+ setTimeout(function () { location.reload(); }, 600);
+ });
+ });
+});
+</script>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/logs.php b/app_plugins/zjmfmanager_reserve/views/admin/logs.php
new file mode 100644
index 0000000..572b5c9
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/logs.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * 管理员端 - 操作日志
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$page = max(1, (int)($_GET['page_num'] ?? 1));
+$logs = zjmf_log_list_all($page, 30);
+$result_labels = [
+ 'success' => '成功',
+ 'failed' => '失败',
+];
+$result_classes = [
+ 'success' => 'badge-success',
+ 'failed' => 'badge-danger',
+];
+$title = $title ?? '操作日志';
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header"><h4 style="display:inline-block">操作日志</h4></div>
+ <div class="card-body">
+ <p class="text-muted">记录商品同步、开通、主机操作、升级等关键动作(内容已脱敏,不含密码/密钥)。</p>
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>供应商</th>
+ <th>用户</th>
+ <th>订单号</th>
+ <th>操作</th>
+ <th>结果</th>
+ <th>内容</th>
+ <th>时间</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($logs['list'])): ?>
+ <tr><td colspan="8" class="text-center text-muted">暂无日志</td></tr>
+ <?php else: ?>
+ <?php foreach ($logs['list'] as $l): ?>
+ <tr>
+ <td><?= (int)$l['id'] ?></td>
+ <td><?= htmlspecialchars($l['supplier_name'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($l['user_name'] ?: ('ID ' . (int)$l['user_id'])) ?></td>
+ <td class="small text-muted"><?= htmlspecialchars($l['order_no'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($l['action']) ?></td>
+ <td>
+ <span class="badge <?= htmlspecialchars($result_classes[$l['result']] ?? 'badge-secondary') ?>">
+ <?= htmlspecialchars($result_labels[$l['result']] ?? $l['result']) ?>
+ </span>
+ </td>
+ <td class="small"><?= htmlspecialchars($l['content'] ?: '-') ?></td>
+ <td class="small"><?= htmlspecialchars($l['created_at']) ?></td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <?php
+ $total_pages = max(1, (int)ceil($logs['total'] / $logs['per_page']));
+ $current_page = (int)$logs['page'];
+ if ($total_pages > 1):
+ $qs = http_build_query(['p' => 'zjmfmanager_reserve', 'page' => 'logs']);
+ ?>
+ <nav>
+ <ul class="pagination pagination-sm">
+ <?php if ($current_page > 1): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page - 1), ENT_QUOTES
+ ) ?>">上一页</a></li>
+ <?php endif; ?>
+ <li class="page-item disabled"><span class="page-link">
+ 第 <?= $current_page ?> / <?= $total_pages ?> 页(共 <?= (int)$logs['total'] ?> 条)
+ </span></li>
+ <?php if ($current_page < $total_pages): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page + 1), ENT_QUOTES
+ ) ?>">下一页</a></li>
+ <?php endif; ?>
+ </ul>
+ </nav>
+ <?php endif; ?>
+ </div>
+ </div>
+</div>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/orders.php b/app_plugins/zjmfmanager_reserve/views/admin/orders.php
new file mode 100644
index 0000000..8b4b2aa
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/orders.php
@@ -0,0 +1,154 @@
+<?php
+/**
+ * 管理员端 - 订单管理
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$page = max(1, (int)($_GET['page_num'] ?? 1));
+$filters = [
+ 'status' => $_GET['status'] ?? '',
+ 'order_no' => $_GET['order_no'] ?? '',
+ 'user_id' => $_GET['user_id'] ?? '',
+ 'supplier_id' => $_GET['supplier_id'] ?? '',
+];
+$orders = zjmf_order_list_all($page, 30, $filters);
+$suppliers = zjmf_supplier_list_all();
+
+$action_labels = [
+ 'buy' => '购买',
+ 'upgrade_config' => '配置升级',
+ 'upgrade_product' => '产品升级',
+];
+$status_labels = [
+ 'pending' => '待处理',
+ 'paid' => '已支付',
+ 'opened' => '已完成',
+ 'failed' => '失败',
+ 'cancelled' => '已取消',
+];
+$status_classes = [
+ 'pending' => 'badge-secondary',
+ 'paid' => 'badge-info',
+ 'opened' => 'badge-success',
+ 'failed' => 'badge-danger',
+ 'cancelled' => 'badge-warning',
+];
+$title = $title ?? '订单管理';
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header"><h4 style="display:inline-block">订单管理</h4></div>
+ <div class="card-body">
+ <form method="get" class="form-inline mb-3">
+ <input type="hidden" name="p" value="zjmfmanager_reserve">
+ <input type="hidden" name="page" value="orders">
+ <select name="status" class="form-control form-control-sm mr-2">
+ <option value="">全部状态</option>
+ <?php foreach ($status_labels as $k => $v): ?>
+ <option value="<?= htmlspecialchars($k, ENT_QUOTES) ?>"
+ <?= $filters['status'] === $k ? 'selected' : '' ?>>
+ <?= htmlspecialchars($v) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ <select name="supplier_id" class="form-control form-control-sm mr-2">
+ <option value="">全部供应商</option>
+ <?php foreach ($suppliers as $s): ?>
+ <option value="<?= (int)$s['id'] ?>"
+ <?= $filters['supplier_id'] == $s['id'] ? 'selected' : '' ?>>
+ <?= htmlspecialchars($s['name'], ENT_QUOTES) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ <input type="text" name="order_no" class="form-control form-control-sm mr-2"
+ placeholder="订单号" value="<?= htmlspecialchars($filters['order_no'], ENT_QUOTES) ?>">
+ <input type="number" name="user_id" class="form-control form-control-sm mr-2"
+ placeholder="用户 ID" value="<?= htmlspecialchars($filters['user_id'], ENT_QUOTES) ?>">
+ <button type="submit" class="btn btn-sm btn-primary">筛选</button>
+ </form>
+
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>订单号</th>
+ <th>动作</th>
+ <th>供应商</th>
+ <th>用户ID</th>
+ <th>商品</th>
+ <th>周期</th>
+ <th>金额</th>
+ <th>上游订单</th>
+ <th>上游主机</th>
+ <th>状态</th>
+ <th>下单时间</th>
+ <th>完成时间</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($orders['list'])): ?>
+ <tr><td colspan="13" class="text-center text-muted">暂无订单</td></tr>
+ <?php else: ?>
+ <?php foreach ($orders['list'] as $o): ?>
+ <tr>
+ <td><?= (int)$o['id'] ?></td>
+ <td class="small text-muted"><?= htmlspecialchars($o['order_no'], ENT_QUOTES) ?></td>
+ <td><?= htmlspecialchars($action_labels[$o['action']] ?? $o['action']) ?></td>
+ <td><?= htmlspecialchars($o['supplier_name'] ?: '-') ?></td>
+ <td><?= (int)$o['user_id'] ?></td>
+ <td><?= htmlspecialchars($o['product_name'], ENT_QUOTES) ?></td>
+ <td><?= htmlspecialchars($o['cycle_name'] ?: '-') ?></td>
+ <td>¥<?= zjmf_format_cents($o['amount_cents']) ?></td>
+ <td><?= (int)$o['up_order_id'] > 0 ? (int)$o['up_order_id'] : '-' ?></td>
+ <td><?= (int)$o['up_host_id'] > 0 ? (int)$o['up_host_id'] : '-' ?></td>
+ <td>
+ <span class="badge <?= htmlspecialchars($status_classes[$o['status']] ?? 'badge-secondary') ?>">
+ <?= htmlspecialchars($status_labels[$o['status']] ?? $o['status']) ?>
+ </span>
+ </td>
+ <td class="small"><?= htmlspecialchars($o['created_at']) ?></td>
+ <td class="small"><?= htmlspecialchars($o['opened_at'] ?: '-') ?></td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+
+ <?php
+ $total_pages = max(1, (int)ceil($orders['total'] / $orders['per_page']));
+ $current_page = (int)$orders['page'];
+ if ($total_pages > 1):
+ $qs = http_build_query([
+ 'p' => 'zjmfmanager_reserve', 'page' => 'orders',
+ 'status' => $filters['status'], 'order_no' => $filters['order_no'],
+ 'user_id' => $filters['user_id'], 'supplier_id' => $filters['supplier_id'],
+ ]);
+ ?>
+ <nav>
+ <ul class="pagination pagination-sm">
+ <?php if ($current_page > 1): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page - 1), ENT_QUOTES
+ ) ?>">上一页</a></li>
+ <?php endif; ?>
+ <li class="page-item disabled"><span class="page-link">
+ 第 <?= $current_page ?> / <?= $total_pages ?> 页(共 <?= (int)$orders['total'] ?> 条)
+ </span></li>
+ <?php if ($current_page < $total_pages): ?>
+ <li class="page-item"><a class="page-link"
+ href="plugin.php?<?= htmlspecialchars(
+ $qs . '&page_num=' . ($current_page + 1), ENT_QUOTES
+ ) ?>">下一页</a></li>
+ <?php endif; ?>
+ </ul>
+ </nav>
+ <?php endif; ?>
+ </div>
+ </div>
+</div>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/products.php b/app_plugins/zjmfmanager_reserve/views/admin/products.php
new file mode 100644
index 0000000..82692cc
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/products.php
@@ -0,0 +1,445 @@
+<?php
+/**
+ * 管理员端 - 商品管理
+ *
+ * 展示上游同步的商品列表(按供应商),支持:
+ * - 「同步商品」弹窗:选择供应商 → 拉取上游商品列表 → 勾选 → 同步
+ * - 「手动添加」表单:供应商 + 上游商品 ID + 名称 + 描述
+ * - 单品加价/上架/排序配置
+ * 同步仅刷新上游名称/价格,不覆盖管理员已配置的加价与上架状态。
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$products = zjmf_product_list_all();
+$suppliers = zjmf_supplier_list_all();
+
+function zjmf_admin_cycle_summary($product)
+{
+ $cycles = zjmf_product_cycles($product);
+ $parts = [];
+ foreach ($cycles as $cycle => $cfg) {
+ $parts[] = $cfg['name'] . ' ¥' . zjmf_format_cents($cfg['price_cents']);
+ }
+ return implode(' ', $parts);
+}
+
+function zjmf_admin_markup_label($product)
+{
+ $type = (int)$product['markup_type'];
+ $value = (int)$product['markup_value'];
+ if ($type === 0 && $value === 0) {
+ return '使用供应商规则';
+ }
+ return $type === 1
+ ? '固定 +' . zjmf_format_cents($value) . ' 元'
+ : '比例 +' . ($value / 10) . '%';
+}
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header">
+ <h4 style="display:inline-block">商品管理</h4>
+ <button type="button" class="btn btn-primary btn-sm float-right"
+ id="zjf-add">手动添加</button>
+ <button type="button" class="btn btn-primary btn-sm float-right mr-2"
+ id="zjf-sync">同步商品</button>
+ </div>
+ <div class="card-body">
+ <p class="text-muted">
+ 同步从上游拉取商品与代理价,并按各周期试算本地售价;
+ 同步不会覆盖已配置的加价、上架状态与排序。
+ </p>
+
+ <!-- 同步商品面板 -->
+ <div id="zjf-sync-wrap" class="mb-3 border rounded p-3" style="display:none;">
+ <h6 class="mb-2">同步商品</h6>
+ <div class="form-row mb-2">
+ <div class="col-md-4">
+ <select id="zjf-sync-supplier" class="form-control form-control-sm">
+ <?php if (empty($suppliers)): ?>
+ <option value="">请先在供应商管理中新增供应商</option>
+ <?php else: ?>
+ <?php foreach ($suppliers as $s): ?>
+ <option value="<?= (int)$s['id'] ?>"><?=
+ htmlspecialchars($s['name'], ENT_QUOTES)
+ ?></option>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </select>
+ </div>
+ <div class="col-md-3">
+ <button type="button" class="btn btn-sm btn-outline-primary"
+ id="zjf-sync-load">加载商品列表</button>
+ </div>
+ <div class="col-md-3">
+ <input type="text" id="zjf-sync-filter" class="form-control form-control-sm"
+ placeholder="按名称过滤">
+ </div>
+ </div>
+ <div id="zjf-sync-tip" class="small text-muted mb-2"></div>
+ <div class="table-responsive">
+ <table class="table table-bordered table-sm">
+ <thead>
+ <tr>
+ <th style="width:40px;">
+ <input type="checkbox" id="zjf-sync-all"> 全选
+ </th>
+ <th>上游ID</th>
+ <th>名称</th>
+ <th>状态</th>
+ </tr>
+ </thead>
+ <tbody id="zjf-sync-list">
+ <tr><td colspan="4" class="text-center text-muted">
+ 请先选择供应商并加载商品列表
+ </td></tr>
+ </tbody>
+ </table>
+ </div>
+ <button type="button" class="btn btn-sm btn-primary" id="zjf-sync-do">开始同步</button>
+ <button type="button" class="btn btn-sm btn-secondary" id="zjf-sync-cancel">关闭</button>
+ </div>
+
+ <!-- 手动添加商品面板 -->
+ <div id="zjf-add-wrap" class="mb-3 border rounded p-3" style="display:none;">
+ <h6 class="mb-2">手动添加商品</h6>
+ <div class="form-row">
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">所属供应商 *</label>
+ <select id="zjf-add-supplier" class="form-control form-control-sm">
+ <?php foreach ($suppliers as $s): ?>
+ <option value="<?= (int)$s['id'] ?>"><?=
+ htmlspecialchars($s['name'], ENT_QUOTES)
+ ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">上游商品 ID *</label>
+ <input type="number" id="zjf-add-upid" class="form-control form-control-sm" min="1">
+ </div>
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">商品名称 *</label>
+ <input type="text" id="zjf-add-name" class="form-control form-control-sm">
+ </div>
+ <div class="col-md-4 mb-2">
+ <label class="small text-muted">描述</label>
+ <input type="text" id="zjf-add-desc" class="form-control form-control-sm">
+ </div>
+ </div>
+ <div class="small text-muted mb-2">
+ 保存后将立即拉取该商品上游代理价与各周期价格;失败则该商品标记为「待同步」。
+ </div>
+ <button type="button" class="btn btn-sm btn-primary" id="zjf-add-save">添加</button>
+ <button type="button" class="btn btn-sm btn-secondary" id="zjf-add-cancel">取消</button>
+ </div>
+
+ <!-- 编辑商品面板 -->
+ <div id="zjf-edit-wrap" class="mb-3 border rounded p-3" style="display:none;">
+ <h6 class="mb-2">编辑商品</h6>
+ <input type="hidden" id="zjf-edit-id" value="0">
+ <div class="form-row">
+ <div class="col-md-4 mb-2">
+ <label class="small text-muted">商品名</label>
+ <div id="zjf-edit-name" class="pt-1"></div>
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">加价方式</label>
+ <select id="zjf-edit-type" class="form-control form-control-sm">
+ <option value="0">按比例</option>
+ <option value="1">固定加价(分)</option>
+ </select>
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">加价数值</label>
+ <input type="number" id="zjf-edit-value" class="form-control form-control-sm"
+ min="0" value="0">
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">排序</label>
+ <input type="number" id="zjf-edit-sort" class="form-control form-control-sm"
+ min="0" value="50">
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">状态</label>
+ <select id="zjf-edit-status" class="form-control form-control-sm">
+ <option value="1">上架</option>
+ <option value="0">下架</option>
+ </select>
+ </div>
+ </div>
+ <div class="small text-muted mb-2">
+ 留空加价数值表示使用所属供应商的加价规则
+ </div>
+ <button type="button" class="btn btn-sm btn-primary" id="zjf-edit-save">保存</button>
+ <button type="button" class="btn btn-sm btn-secondary" id="zjf-edit-cancel">取消</button>
+ </div>
+
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>供应商</th>
+ <th>商品名</th>
+ <th>上游ID</th>
+ <th>上游代理价</th>
+ <th>周期售价</th>
+ <th>加价</th>
+ <th>状态</th>
+ <th>排序</th>
+ <th>更新时间</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($products)): ?>
+ <tr><td colspan="11" class="text-center text-muted">
+ 暂无商品,请先点击右上角「同步商品」或「手动添加」
+ </td></tr>
+ <?php else: ?>
+ <?php foreach ($products as $p): ?>
+ <tr>
+ <td><?= (int)$p['id'] ?></td>
+ <td><?= htmlspecialchars($p['supplier_name'] ?: '-', ENT_QUOTES) ?></td>
+ <td><?= htmlspecialchars($p['name'], ENT_QUOTES) ?></td>
+ <td><?= (int)$p['up_product_id'] ?></td>
+ <td>¥<?= zjmf_format_cents($p['agent_price_cents']) ?></td>
+ <td class="small"><?=
+ htmlspecialchars(zjmf_admin_cycle_summary($p), ENT_QUOTES)
+ ?></td>
+ <td class="small"><?=
+ htmlspecialchars(zjmf_admin_markup_label($p), ENT_QUOTES)
+ ?></td>
+ <td>
+ <span class="badge <?= $p['status'] == 1 ? 'badge-success' : 'badge-secondary' ?>">
+ <?= $p['status'] == 1 ? '上架' : '下架' ?>
+ </span>
+ </td>
+ <td><?= (int)$p['sort'] ?></td>
+ <td class="small"><?= htmlspecialchars($p['updated_at'] ?: '-') ?></td>
+ <td class="text-nowrap">
+ <button type="button" class="btn btn-sm btn-outline-primary zjf-edit"
+ data-id="<?= (int)$p['id'] ?>"
+ data-name="<?= htmlspecialchars($p['name'], ENT_QUOTES) ?>"
+ data-type="<?= (int)$p['markup_type'] ?>"
+ data-value="<?= (int)$p['markup_value'] ?>"
+ data-sort="<?= (int)$p['sort'] ?>"
+ data-status="<?= (int)$p['status'] ?>">编辑</button>
+ <button type="button" class="btn btn-sm btn-outline-secondary zjf-toggle"
+ data-id="<?= (int)$p['id'] ?>">
+ <?= $p['status'] == 1 ? '下架' : '上架' ?>
+ </button>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+</div>
+<script>
+(function () {
+ function res(res) {
+ var d;
+ try { d = typeof res === 'string' ? JSON.parse(res) : res; } catch (e) { d = {code: res}; }
+ return d;
+ }
+ function notify(d, reload) {
+ var ok = d.qk == 1 || d.success;
+ if (typeof $.notify === 'function') {
+ $.notify({message: d.msg || d.code || '完成'}, {type: ok ? 'success' : 'danger'});
+ } else {
+ alert(d.msg || d.code || '完成');
+ }
+ if (ok && reload) setTimeout(function () { location.reload(); }, 600);
+ }
+ function post(data, reload) {
+ $.post('ajax.php', data, function (r) { notify(res(r), reload); });
+ }
+
+ var syncWrap = document.getElementById('zjf-sync-wrap');
+ var addWrap = document.getElementById('zjf-add-wrap');
+ var editWrap = document.getElementById('zjf-edit-wrap');
+ var syncList = document.getElementById('zjf-sync-list');
+ var syncTip = document.getElementById('zjf-sync-tip');
+
+ /* ---------------- 同步商品 ---------------- */
+ document.getElementById('zjf-sync').addEventListener('click', function () {
+ addWrap.style.display = 'none';
+ editWrap.style.display = 'none';
+ syncWrap.style.display = 'block';
+ syncWrap.scrollIntoView({behavior: 'smooth', block: 'start'});
+ });
+
+ document.getElementById('zjf-sync-load').addEventListener('click', function () {
+ var supplierId = document.getElementById('zjf-sync-supplier').value;
+ if (!supplierId) { alert('请先选择供应商'); return; }
+ var btn = this;
+ btn.disabled = true;
+ btn.textContent = '加载中...';
+ syncTip.textContent = '正在拉取上游商品列表...';
+ syncList.innerHTML = '<tr><td colspan="4" class="text-center text-muted">加载中...</td></tr>';
+ $.post('ajax.php', {gn: 'p_zjmf_admin_upstream_products', id: supplierId},
+ function (r) {
+ var d = res(r);
+ btn.disabled = false;
+ btn.textContent = '加载商品列表';
+ if (d.qk != 1 && !d.success) {
+ syncTip.textContent = '';
+ syncList.innerHTML = '<tr><td colspan="4" class="text-center text-muted">'
+ + (d.msg || d.code || '拉取失败') + '</td></tr>';
+ return;
+ }
+ var list = d.list || (d.data && d.data.list) || [];
+ if (!list.length) {
+ syncList.innerHTML = '<tr><td colspan="4" class="text-center text-muted">'
+ + '该供应商暂无商品</td></tr>';
+ return;
+ }
+ syncTip.textContent = '共 ' + list.length + ' 个商品,勾选要同步的项目后点击「开始同步」'
+ + '(已同步过的将更新价格,不覆盖加价与上架状态)';
+ var html = '';
+ for (var i = 0; i < list.length; i++) {
+ var it = list[i];
+ html += '<tr class="zjf-up-row" data-name="' + (it.name || '').toLowerCase() + '">'
+ + '<td><input type="checkbox" class="zjf-up-check" value="' + it.id + '"'
+ + (it.synced ? ' checked' : '') + '></td>'
+ + '<td>' + it.id + '</td>'
+ + '<td>' + (it.name || '-') + '</td>'
+ + '<td>' + (it.synced ? '<span class="badge badge-success">已同步</span>'
+ : '<span class="badge badge-secondary">未同步</span>') + '</td>'
+ + '</tr>';
+ }
+ syncList.innerHTML = html;
+ renderSyncFilter();
+ });
+ });
+
+ function renderSyncFilter() {
+ var kw = (document.getElementById('zjf-sync-filter').value || '').toLowerCase();
+ document.querySelectorAll('.zjf-up-row').forEach(function (row) {
+ row.style.display = (kw === '' || (row.getAttribute('data-name') || '').indexOf(kw) >= 0)
+ ? '' : 'none';
+ });
+ }
+ document.getElementById('zjf-sync-filter').addEventListener('input', renderSyncFilter);
+
+ document.getElementById('zjf-sync-all').addEventListener('change', function () {
+ var checked = this.checked;
+ document.querySelectorAll('.zjf-up-row').forEach(function (row) {
+ if (row.style.display !== 'none') {
+ row.querySelector('.zjf-up-check').checked = checked;
+ }
+ });
+ });
+
+ document.getElementById('zjf-sync-do').addEventListener('click', function () {
+ var supplierId = document.getElementById('zjf-sync-supplier').value;
+ if (!supplierId) { alert('请先选择供应商'); return; }
+ var ids = [];
+ document.querySelectorAll('.zjf-up-check:checked').forEach(function (c) {
+ ids.push(parseInt(c.value, 10));
+ });
+ if (!ids.length) { alert('请至少勾选一个商品'); return; }
+ var btn = this;
+ btn.disabled = true;
+ btn.textContent = '同步中...';
+ $.post('ajax.php', {
+ gn: 'p_zjmf_admin_sync_products',
+ supplier_id: supplierId,
+ up_ids: ids
+ }, function (r) {
+ var d = res(r);
+ notify(d, d.qk == 1 || d.success);
+ btn.disabled = false;
+ btn.textContent = '开始同步';
+ });
+ });
+
+ document.getElementById('zjf-sync-cancel').addEventListener('click', function () {
+ syncWrap.style.display = 'none';
+ });
+
+ /* ---------------- 手动添加 ---------------- */
+ document.getElementById('zjf-add').addEventListener('click', function () {
+ syncWrap.style.display = 'none';
+ editWrap.style.display = 'none';
+ addWrap.style.display = 'block';
+ addWrap.scrollIntoView({behavior: 'smooth', block: 'start'});
+ });
+
+ document.getElementById('zjf-add-cancel').addEventListener('click', function () {
+ addWrap.style.display = 'none';
+ });
+
+ document.getElementById('zjf-add-save').addEventListener('click', function () {
+ var supplierId = document.getElementById('zjf-add-supplier').value;
+ var upId = document.getElementById('zjf-add-upid').value;
+ var name = document.getElementById('zjf-add-name').value;
+ var desc = document.getElementById('zjf-add-desc').value;
+ if (!supplierId || !upId || !name) {
+ alert('请填写供应商、上游商品 ID 与名称');
+ return;
+ }
+ post({
+ gn: 'p_zjmf_admin_add_product',
+ supplier_id: supplierId,
+ up_product_id: upId,
+ name: name,
+ description: desc
+ }, true);
+ });
+
+ /* ---------------- 编辑商品 ---------------- */
+ var editId = document.getElementById('zjf-edit-id');
+ var editName = document.getElementById('zjf-edit-name');
+ var editType = document.getElementById('zjf-edit-type');
+ var editValue = document.getElementById('zjf-edit-value');
+ var editSort = document.getElementById('zjf-edit-sort');
+ var editStatus = document.getElementById('zjf-edit-status');
+
+ document.querySelectorAll('.zjf-edit').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ syncWrap.style.display = 'none';
+ addWrap.style.display = 'none';
+ editId.value = btn.getAttribute('data-id');
+ editName.textContent = btn.getAttribute('data-name');
+ editType.value = btn.getAttribute('data-type');
+ editValue.value = btn.getAttribute('data-value');
+ editSort.value = btn.getAttribute('data-sort');
+ editStatus.value = btn.getAttribute('data-status');
+ editWrap.style.display = 'block';
+ editWrap.scrollIntoView({behavior: 'smooth', block: 'start'});
+ });
+ });
+
+ document.getElementById('zjf-edit-cancel').addEventListener('click', function () {
+ editWrap.style.display = 'none';
+ });
+
+ document.getElementById('zjf-edit-save').addEventListener('click', function () {
+ var id = parseInt(editId.value, 10);
+ if (!id) return;
+ post({
+ gn: 'p_zjmf_admin_save_product',
+ id: id,
+ markup_type: editType.value,
+ markup_value: editValue.value,
+ sort: editSort.value,
+ status: editStatus.value
+ }, true);
+ });
+
+ document.querySelectorAll('.zjf-toggle').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ post({gn: 'p_zjmf_admin_toggle_product', id: btn.getAttribute('data-id')}, true);
+ });
+ });
+})();
+</script>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php b/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php
new file mode 100644
index 0000000..ffddb19
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php
@@ -0,0 +1,285 @@
+<?php
+/**
+ * 管理员端 - 供应商管理
+ *
+ * 维护多个魔方财务上游供应商(独立 API 账号 / 加价规则 / 启用状态)。
+ * 停用供应商后其商品不可售,主机操作与升级被拒绝。
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$suppliers = zjmf_supplier_list_all();
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header">
+ <h4 style="display:inline-block">供应商管理</h4>
+ <button type="button" class="btn btn-primary btn-sm float-right"
+ id="zjf-supplier-add">新增供应商</button>
+ </div>
+ <div class="card-body">
+ <p class="text-muted">
+ 可添加多个魔方财务上游站点,各自独立的 API 账号与加价规则;
+ 停用后其商品自动下架不可售,主机操作与升级将被拒绝。
+ </p>
+
+ <div id="zjf-edit-wrap" class="mb-3 border rounded p-3" style="display:none;">
+ <h6 class="mb-2" id="zjf-edit-title">编辑供应商</h6>
+ <input type="hidden" id="zjf-edit-id" value="0">
+ <div class="form-row">
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">名称 *</label>
+ <input type="text" id="zjf-edit-name" class="form-control form-control-sm"
+ placeholder="如:魔方A站">
+ </div>
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">站点 URL *</label>
+ <input type="text" id="zjf-edit-url" class="form-control form-control-sm"
+ placeholder="https://upstream.example.com">
+ </div>
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">API 用户名 *</label>
+ <input type="text" id="zjf-edit-username" class="form-control form-control-sm">
+ </div>
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">API 密钥</label>
+ <input type="password" id="zjf-edit-password" class="form-control form-control-sm"
+ placeholder="编辑时留空不修改">
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">超时(秒)</label>
+ <input type="number" id="zjf-edit-timeout" class="form-control form-control-sm"
+ min="5" max="120" value="30">
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">加价方式</label>
+ <select id="zjf-edit-mtype" class="form-control form-control-sm">
+ <option value="0">按比例</option>
+ <option value="1">固定加价(分)</option>
+ </select>
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">加价数值</label>
+ <input type="number" id="zjf-edit-mvalue" class="form-control form-control-sm"
+ min="0" value="0">
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">状态</label>
+ <select id="zjf-edit-status" class="form-control form-control-sm">
+ <option value="1">启用</option>
+ <option value="0">停用</option>
+ </select>
+ </div>
+ <div class="col-md-2 mb-2">
+ <label class="small text-muted">排序</label>
+ <input type="number" id="zjf-edit-sort" class="form-control form-control-sm"
+ min="0" value="0">
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="col-md-6 mb-2">
+ <label class="small text-muted">备注</label>
+ <input type="text" id="zjf-edit-remark" class="form-control form-control-sm">
+ </div>
+ </div>
+ <div class="small text-muted mb-2">
+ 加价数值:按比例填千分比(如 10 表示 +1%);固定加价填金额(分,1 元=100)。
+ 商品未单独配置加价时使用此处规则。
+ </div>
+ <button type="button" class="btn btn-sm btn-primary" id="zjf-edit-save">保存</button>
+ <button type="button" class="btn btn-sm btn-secondary" id="zjf-edit-cancel">取消</button>
+ </div>
+
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>名称</th>
+ <th>站点</th>
+ <th>加价</th>
+ <th>超时</th>
+ <th>状态</th>
+ <th>排序</th>
+ <th>备注</th>
+ <th>更新时间</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($suppliers)): ?>
+ <tr><td colspan="10" class="text-center text-muted">
+ 暂无供应商,请点击右上角「新增供应商」
+ </td></tr>
+ <?php else: ?>
+ <?php foreach ($suppliers as $s): ?>
+ <tr>
+ <td><?= (int)$s['id'] ?></td>
+ <td><?= htmlspecialchars($s['name'], ENT_QUOTES) ?></td>
+ <td class="small"><?= htmlspecialchars($s['api_url'], ENT_QUOTES) ?></td>
+ <td class="small"><?=
+ htmlspecialchars(zjmf_supplier_markup_label($s), ENT_QUOTES)
+ ?></td>
+ <td><?= (int)$s['api_timeout'] ?>s</td>
+ <td>
+ <span class="badge <?= $s['status'] == 1 ? 'badge-success' : 'badge-secondary' ?>">
+ <?= $s['status'] == 1 ? '启用' : '停用' ?>
+ </span>
+ </td>
+ <td><?= (int)$s['sort'] ?></td>
+ <td class="small"><?= htmlspecialchars($s['remark'] ?: '-') ?></td>
+ <td class="small"><?= htmlspecialchars($s['updated_at'] ?: '-') ?></td>
+ <td class="text-nowrap">
+ <button type="button" class="btn btn-sm btn-outline-primary zjf-test"
+ data-id="<?= (int)$s['id'] ?>">连通测试</button>
+ <button type="button" class="btn btn-sm btn-outline-primary zjf-edit"
+ data-id="<?= (int)$s['id'] ?>"
+ data-name="<?= htmlspecialchars($s['name'], ENT_QUOTES) ?>"
+ data-url="<?= htmlspecialchars($s['api_url'], ENT_QUOTES) ?>"
+ data-username="<?= htmlspecialchars($s['api_username'], ENT_QUOTES) ?>"
+ data-timeout="<?= (int)$s['api_timeout'] ?>"
+ data-mtype="<?= (int)$s['markup_type'] ?>"
+ data-mvalue="<?= (int)$s['markup_value'] ?>"
+ data-status="<?= (int)$s['status'] ?>"
+ data-sort="<?= (int)$s['sort'] ?>"
+ data-remark="<?= htmlspecialchars($s['remark'], ENT_QUOTES) ?>">编辑</button>
+ <button type="button" class="btn btn-sm btn-outline-secondary zjf-toggle"
+ data-id="<?= (int)$s['id'] ?>">
+ <?= $s['status'] == 1 ? '停用' : '启用' ?>
+ </button>
+ <button type="button" class="btn btn-sm btn-outline-danger zjf-del"
+ data-id="<?= (int)$s['id'] ?>"
+ data-name="<?= htmlspecialchars($s['name'], ENT_QUOTES) ?>">删除</button>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+</div>
+<script>
+(function () {
+ function res(res) {
+ var d;
+ try { d = typeof res === 'string' ? JSON.parse(res) : res; } catch (e) { d = {code: res}; }
+ return d;
+ }
+ function notify(d, reload) {
+ var ok = d.qk == 1 || d.success;
+ if (typeof $.notify === 'function') {
+ $.notify({message: d.msg || d.code || '完成'}, {type: ok ? 'success' : 'danger'});
+ } else {
+ alert(d.msg || d.code || '完成');
+ }
+ if (ok && reload) setTimeout(function () { location.reload(); }, 600);
+ }
+ function post(data, reload) {
+ $.post('ajax.php', data, function (r) { notify(res(r), reload); });
+ }
+
+ var wrap = document.getElementById('zjf-edit-wrap');
+ var editTitle = document.getElementById('zjf-edit-title');
+ var editId = document.getElementById('zjf-edit-id');
+
+ function openPanel(reset) {
+ if (reset) {
+ editId.value = '0';
+ document.getElementById('zjf-edit-name').value = '';
+ document.getElementById('zjf-edit-url').value = '';
+ document.getElementById('zjf-edit-username').value = '';
+ document.getElementById('zjf-edit-password').value = '';
+ document.getElementById('zjf-edit-timeout').value = '30';
+ document.getElementById('zjf-edit-mtype').value = '0';
+ document.getElementById('zjf-edit-mvalue').value = '0';
+ document.getElementById('zjf-edit-status').value = '1';
+ document.getElementById('zjf-edit-sort').value = '0';
+ document.getElementById('zjf-edit-remark').value = '';
+ editTitle.textContent = '新增供应商';
+ }
+ wrap.style.display = 'block';
+ wrap.scrollIntoView({behavior: 'smooth', block: 'start'});
+ }
+
+ document.getElementById('zjf-supplier-add').addEventListener('click', function () {
+ openPanel(true);
+ });
+
+ document.querySelectorAll('.zjf-edit').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ editId.value = btn.getAttribute('data-id');
+ document.getElementById('zjf-edit-name').value = btn.getAttribute('data-name');
+ document.getElementById('zjf-edit-url').value = btn.getAttribute('data-url');
+ document.getElementById('zjf-edit-username').value = btn.getAttribute('data-username');
+ document.getElementById('zjf-edit-password').value = '';
+ document.getElementById('zjf-edit-timeout').value = btn.getAttribute('data-timeout');
+ document.getElementById('zjf-edit-mtype').value = btn.getAttribute('data-mtype');
+ document.getElementById('zjf-edit-mvalue').value = btn.getAttribute('data-mvalue');
+ document.getElementById('zjf-edit-status').value = btn.getAttribute('data-status');
+ document.getElementById('zjf-edit-sort').value = btn.getAttribute('data-sort');
+ document.getElementById('zjf-edit-remark').value = btn.getAttribute('data-remark');
+ editTitle.textContent = '编辑供应商';
+ openPanel(false);
+ });
+ });
+
+ document.getElementById('zjf-edit-cancel').addEventListener('click', function () {
+ wrap.style.display = 'none';
+ });
+
+ document.getElementById('zjf-edit-save').addEventListener('click', function () {
+ post({
+ gn: 'p_zjmf_admin_save_supplier',
+ id: editId.value,
+ name: document.getElementById('zjf-edit-name').value,
+ api_url: document.getElementById('zjf-edit-url').value,
+ api_username: document.getElementById('zjf-edit-username').value,
+ api_password: document.getElementById('zjf-edit-password').value,
+ api_timeout: document.getElementById('zjf-edit-timeout').value,
+ markup_type: document.getElementById('zjf-edit-mtype').value,
+ markup_value: document.getElementById('zjf-edit-mvalue').value,
+ status: document.getElementById('zjf-edit-status').value,
+ sort: document.getElementById('zjf-edit-sort').value,
+ remark: document.getElementById('zjf-edit-remark').value
+ }, true);
+ });
+
+ document.querySelectorAll('.zjf-test').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var b = this;
+ b.disabled = true;
+ var old = b.textContent;
+ b.textContent = '测试中...';
+ $.post('ajax.php', {gn: 'p_zjmf_admin_test_supplier', id: btn.getAttribute('data-id')},
+ function (r) {
+ var d = res(r);
+ notify(d, false);
+ b.disabled = false;
+ b.textContent = old;
+ });
+ });
+ });
+
+ document.querySelectorAll('.zjf-toggle').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ post({gn: 'p_zjmf_admin_toggle_supplier', id: btn.getAttribute('data-id')}, true);
+ });
+ });
+
+ document.querySelectorAll('.zjf-del').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var name = btn.getAttribute('data-name');
+ if (!confirm('确定删除供应商「' + name + '」吗?\n该供应商下存在商品/订单/主机时将无法删除。')) {
+ return;
+ }
+ post({gn: 'p_zjmf_admin_delete_supplier', id: btn.getAttribute('data-id')}, true);
+ });
+ });
+})();
+</script>
diff --git a/app_plugins/zjmfmanager_reserve/views/host.php b/app_plugins/zjmfmanager_reserve/views/host.php
new file mode 100644
index 0000000..c2197b9
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/host.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * 用户端 - 主机详情(实时状态 / 流量 / 操作)
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '主机详情';
+$host = $host ?? null;
+$info = $info ?? ['ok' => false, 'msg' => ''];
+$traffic = $traffic ?? ['ok' => false, 'data' => []];
+
+$password = zjmf_decrypt((string)$host['password']);
+$hasUpId = (int)$host['up_host_id'] > 0;
+
+function zjmf_view_show_traffic($data)
+{
+ if (!is_array($data) || $data === []) {
+ return '暂无流量数据';
+ }
+ // 常见标量字段直接展示
+ $rows = [];
+ foreach (['total', 'used', 'free', 'percent', 'current', 'monthly_used'] as $k) {
+ if (isset($data[$k]) && $data[$k] !== '' && $data[$k] !== null) {
+ $rows[] = $k . ':' . htmlspecialchars((string)$data[$k]);
+ }
+ }
+ if ($rows) {
+ return implode('<br>', $rows);
+ }
+ return htmlspecialchars(json_encode($data, JSON_UNESCAPED_UNICODE));
+}
+ob_start();
+?>
+<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
+ <h1 style="font-size:20px;color:#222;margin:0;"><?= htmlspecialchars($host['name']) ?></h1>
+ <a class="layui-btn layui-btn-xs layui-btn-primary" href="<?= zjmf_url('reserve/hosts') ?>">返回主机列表</a>
+</div>
+
+<div class="zj-msg" id="zjf-msg"></div>
+
+<div class="layui-card">
+ <div class="layui-card-header">主机信息</div>
+ <div class="layui-card-body" style="padding:0;">
+ <table class="zj-table">
+ <tbody>
+ <tr><td style="width:120px;">状态</td><td>
+ <span class="zj-status zj-status-<?= htmlspecialchars($host['status']) ?>">
+ <?= htmlspecialchars(zjmf_host_status_label($host['status'])) ?>
+ </span>
+ <?php if (!empty($info['ok']) && $info['status'] !== $host['status']): ?>
+ <span class="zj-muted">(实时:<?= htmlspecialchars(zjmf_host_status_label($info['status'])) ?>)</span>
+ <?php endif; ?>
+ </td></tr>
+ <tr><td>供应商</td><td><?= htmlspecialchars($host['supplier_name'] ?: '-') ?></td></tr>
+ <tr><td>用户名</td><td class="zj-mono" id="zjf-username"><?=
+ htmlspecialchars(zjmf_mask_account($host['username']))
+ ?></td></tr>
+ <tr><td>密码</td><td class="zj-mono" id="zjf-password"><?= $password !== '' ? '••••••••' : '-' ?></td></tr>
+ <tr><td>周期</td><td><?= htmlspecialchars($host['cycle'] ?: '-') ?></td></tr>
+ <tr><td>到期时间</td><td><?= htmlspecialchars($host['renew_date'] ?: '-') ?></td></tr>
+ <tr><td>上游主机 ID</td><td class="zj-mono"><?= (int)$host['up_host_id'] ?></td></tr>
+ </tbody>
+ </table>
+ </div>
+</div>
+
+<div class="layui-card">
+ <div class="layui-card-header">流量使用</div>
+ <div class="layui-card-body" style="padding:16px;font-size:13px;color:#555;">
+ <?php if (!empty($traffic['ok'])): ?>
+ <?= zjmf_view_show_traffic($traffic['data']) ?>
+ <?php else: ?>
+ <span class="zj-muted"><?= (($traffic['msg'] ?? '') ?: '流量查询失败') ?></span>
+ <?php endif; ?>
+ </div>
+</div>
+
+<?php if ($hasUpId): ?>
+<div class="layui-card">
+ <div class="layui-card-header">主机操作</div>
+ <div class="layui-card-body">
+ <p class="zj-muted" style="margin-top:0;">重启 / 重装 / 重置密码为高危操作,请谨慎执行。</p>
+ <button type="button" class="layui-btn layui-btn-sm" data-act="on">开机</button>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-warm" data-act="off">关机</button>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-warm" data-act="reboot">重启</button>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-danger" data-act="reinstall">重装系统</button>
+ <button type="button" class="layui-btn layui-btn-sm" data-act="reset_password">重置密码</button>
+
+ <div id="zjf-pass-panel" style="display:none;margin-top:14px;padding:14px;
+ background:#fafbfc;border:1px solid #eee;border-radius:6px;">
+ <input type="password" id="zjf-pass-input" class="layui-input" style="max-width:260px;"
+ placeholder="输入新密码(至少 6 位)">
+ <button type="button" class="layui-btn layui-btn-sm" id="zjf-pass-confirm"
+ style="margin-top:10px;">确认重置</button>
+ </div>
+ </div>
+</div>
+<?php endif; ?>
+
+<script>
+(function () {
+ var msg = document.getElementById('zjf-msg');
+ var passPanel = document.getElementById('zjf-pass-panel');
+ var passInput = document.getElementById('zjf-pass-input');
+ var pendingAction = '';
+
+ function show(text, ok) {
+ msg.textContent = text;
+ msg.className = 'zj-msg zj-msg-show ' + (ok ? 'zj-msg-success' : 'zj-msg-error');
+ }
+ function call(action, extra) {
+ var body = new URLSearchParams();
+ body.append('host_id', '<?= (int)$host['id'] ?>');
+ body.append('action', action);
+ for (var k in (extra || {})) body.append(k, extra[k]);
+ fetch('<?= zjmf_url('reserve/api/host_action') ?>', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: body.toString()
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ var ok = res.code === 'ok' || res.success;
+ show(res.msg || res.code || '操作成功', ok);
+ if (ok) setTimeout(function () { location.reload(); }, 800);
+ }).catch(function () {
+ show('网络错误,请重试', false);
+ });
+ }
+
+ document.querySelectorAll('[data-act]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var act = btn.getAttribute('data-act');
+ if (act === 'reset_password') {
+ pendingAction = act;
+ passPanel.style.display = 'block';
+ passInput.focus();
+ return;
+ }
+ if (act === 'reinstall') {
+ if (!confirm('确定要重装该系统吗?重装将清空数据且不可恢复!')) return;
+ }
+ if (act === 'reboot') {
+ if (!confirm('确定要重启该主机吗?')) return;
+ }
+ call(act, {});
+ });
+ });
+
+ document.getElementById('zjf-pass-confirm').addEventListener('click', function () {
+ var pwd = passInput.value;
+ if (!pwd || pwd.length < 6) {
+ show('密码至少 6 位', false);
+ return;
+ }
+ call('reset_password', {password: pwd});
+ });
+})();
+</script>
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/app_plugins/zjmfmanager_reserve/views/hosts.php b/app_plugins/zjmfmanager_reserve/views/hosts.php
new file mode 100644
index 0000000..0114a00
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/hosts.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * 用户端 - 我的主机列表
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '我的主机';
+$hosts = $hosts ?? [];
+ob_start();
+?>
+<div class="zj-section">
+ <h1>我的主机</h1>
+ <p>查看已开通的主机,点击进入详情可执行状态/流量查询与操作</p>
+</div>
+
+<?php if (empty($hosts)): ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="text-align:center;padding:40px;color:#999;">
+ 暂无主机,<a href="<?= zjmf_url('reserve/shop') ?>">去选购商品</a>
+ </div>
+ </div>
+<?php else: ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="padding:0;">
+ <table class="zj-table">
+ <thead>
+ <tr>
+ <th>主机</th>
+ <th>供应商</th>
+ <th>状态</th>
+ <th>用户名</th>
+ <th>周期</th>
+ <th>到期时间</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($hosts as $host): ?>
+ <tr>
+ <td>
+ <b><?= htmlspecialchars($host['name']) ?></b>
+ <div class="zj-muted">ID <?= (int)$host['id'] ?>
+ / 上游 <?= (int)$host['up_host_id'] ?></div>
+ </td>
+ <td><?= htmlspecialchars($host['supplier_name'] ?: '-') ?></td>
+ <td>
+ <span class="zj-status zj-status-<?= htmlspecialchars($host['status']) ?>">
+ <?= htmlspecialchars(zjmf_host_status_label($host['status'])) ?>
+ </span>
+ </td>
+ <td class="zj-mono"><?= htmlspecialchars(zjmf_mask_account($host['username'])) ?></td>
+ <td><?= htmlspecialchars($host['cycle'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($host['renew_date'] ?: '-') ?></td>
+ <td>
+ <a class="layui-btn layui-btn-xs" href="<?= zjmf_url('reserve/hosts/' . (int)$host['id']) ?>">详情</a>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ </div>
+ </div>
+<?php endif; ?>
+
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/app_plugins/zjmfmanager_reserve/views/layout.php b/app_plugins/zjmfmanager_reserve/views/layout.php
new file mode 100644
index 0000000..58d1027
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/layout.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * zjmfmanager_reserve 用户端 - 公共布局
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$current_user = $current_user ?? null;
+$page_title = $page_title ?? '魔方财务分销';
+$content = $content ?? '';
+?>
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title><?= htmlspecialchars($page_title) ?> - 魔方财务分销</title>
+<link rel="stylesheet" href="https://unpkg.com/layui@2.9.8/dist/css/layui.css">
+<link rel="stylesheet" href="<?= zjmf_asset_url('style.css') ?>">
+</head>
+<body>
+
+<div class="zj-nav">
+ <div class="zj-nav-inner">
+ <a class="zj-nav-brand" href="<?= zjmf_url('reserve/shop') ?>">魔方财务分销</a>
+ <div class="zj-nav-links">
+ <?php if ($current_user): ?>
+ <a href="<?= zjmf_url('reserve/shop') ?>">商品</a>
+ <a href="<?= zjmf_url('reserve/hosts') ?>">我的主机</a>
+ <a href="<?= zjmf_url('reserve/orders') ?>">订单</a>
+ <a href="<?= zjmf_url('balance') ?>">余额</a>
+ <a href="<?= zjmf_url('account/profile') ?>">个人信息</a>
+ <a href="<?= zjmf_url('account/logout') ?>">退出</a>
+ <?php else: ?>
+ <a href="<?= zjmf_url('account/login') ?>">登录</a>
+ <a href="<?= zjmf_url('account/register') ?>">注册</a>
+ <?php endif; ?>
+ </div>
+ </div>
+</div>
+
+<div class="zj-page"><?= $content ?></div>
+
+<script src="https://unpkg.com/layui@2.9.8/dist/layui.js"></script>
+</body>
+</html>
diff --git a/app_plugins/zjmfmanager_reserve/views/order.php b/app_plugins/zjmfmanager_reserve/views/order.php
new file mode 100644
index 0000000..d8c5bf0
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/order.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * 用户端 - 下单页(选择周期 + 支付方式)
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '购买商品';
+$product = $product ?? null;
+$methods = $methods ?? [];
+ob_start();
+?>
+<div class="layui-card">
+ <div class="layui-card-body" style="padding:28px;">
+ <div class="zj-msg" id="zjf-msg"></div>
+
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;">
+ <h1 style="font-size:20px;color:#222;margin:0;">购买:<?= htmlspecialchars($product['name']) ?></h1>
+ <a class="layui-btn layui-btn-xs layui-btn-primary" href="<?= zjmf_url('reserve/shop') ?>">返回商品列表</a>
+ </div>
+
+ <div class="zj-desc"><?= nl2br(htmlspecialchars($product['description'])) ?></div>
+
+ <form class="zj-order-form" id="zjf-order-form">
+ <div class="layui-form-item">
+ <label class="layui-form-label">购买周期</label>
+ <div class="layui-input-block zj-choices" style="padding-top:8px;">
+ <?php
+ $cycles = zjmf_product_cycles($product);
+ foreach ($cycles as $cycle => $cfg):
+ ?>
+ <label class="zj-choice">
+ <input type="radio" name="cycle" value="<?= htmlspecialchars($cycle, ENT_QUOTES) ?>">
+ <?= htmlspecialchars($cfg['name']) ?> ¥<?= zjmf_format_cents($cfg['price_cents']) ?>
+ </label>
+ <?php endforeach; ?>
+ <?php if ($cycles === []): ?>
+ <span style="color:#999;">该商品未设置可购买周期</span>
+ <?php endif; ?>
+ </div>
+ </div>
+
+ <?php if (!empty($methods)): ?>
+ <div class="layui-form-item">
+ <label class="layui-form-label">支付方式</label>
+ <div class="layui-input-block zj-choices" style="padding-top:6px;">
+ <?php foreach ($methods as $m): ?>
+ <label class="zj-choice">
+ <input type="radio" name="type"
+ value="<?= htmlspecialchars($m['plugin'] . '__' . $m['method'], ENT_QUOTES) ?>" required>
+ <?= htmlspecialchars($m['display_name'] ?: ($m['plugin'] . ' / ' . $m['method'])) ?>
+ </label>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php else: ?>
+ <div class="layui-form-item">
+ <div class="layui-input-block" style="color:#999;">暂无可用的支付方式</div>
+ </div>
+ <?php endif; ?>
+
+ <?php if (!empty($methods) && $cycles !== []): ?>
+ <div class="layui-form-item">
+ <div class="layui-input-block">
+ <button type="submit" class="layui-btn layui-btn-lg" id="zjf-submit">确认购买</button>
+ </div>
+ </div>
+ <?php endif; ?>
+ </form>
+ </div>
+</div>
+
+<script>
+(function () {
+ var form = document.getElementById('zjf-order-form');
+ if (!form) return;
+ var msg = document.getElementById('zjf-msg');
+ var btn = document.getElementById('zjf-submit');
+
+ function showMsg(text, success) {
+ msg.textContent = text;
+ msg.className = 'zj-msg ' + (success ? 'zj-msg-success' : 'zj-msg-error');
+ }
+ function checkFirst(name) {
+ var list = form.querySelectorAll('input[name="' + name + '"]');
+ if (list.length && !form.querySelector('input[name="' + name + '"]:checked')) {
+ list[0].checked = true;
+ }
+ }
+ function updateChoices() {
+ form.querySelectorAll('.zj-choice').forEach(function (l) { l.classList.remove('active'); });
+ form.querySelectorAll('input[type="radio"]:checked').forEach(function (r) {
+ var p = r.closest('.zj-choice');
+ if (p) p.classList.add('active');
+ });
+ }
+ checkFirst('cycle');
+ checkFirst('type');
+ form.addEventListener('change', updateChoices);
+ updateChoices();
+
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ if (!btn) return;
+ btn.disabled = true;
+ btn.textContent = '正在创建订单...';
+ msg.className = 'zj-msg';
+
+ var body = new URLSearchParams();
+ body.append('product_id', '<?= (int)$product['id'] ?>');
+ var c = form.querySelector('input[name="cycle"]:checked');
+ body.append('cycle', c ? c.value : '');
+ var t = form.querySelector('input[name="type"]:checked');
+ body.append('type', t ? t.value : '');
+
+ fetch('<?= zjmf_url('reserve/api/create_order') ?>', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: body.toString()
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ if (res.html) {
+ document.open();
+ document.write(res.html);
+ document.close();
+ } else {
+ showMsg(res.code || '创建订单失败', false);
+ btn.disabled = false;
+ btn.textContent = '确认购买';
+ }
+ }).catch(function () {
+ showMsg('网络错误,请重试', false);
+ btn.disabled = false;
+ btn.textContent = '确认购买';
+ });
+ });
+})();
+</script>
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/app_plugins/zjmfmanager_reserve/views/orders.php b/app_plugins/zjmfmanager_reserve/views/orders.php
new file mode 100644
index 0000000..0f85805
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/orders.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * 用户端 - 我的订单
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '我的订单';
+$orders = $orders ?? ['list' => [], 'total' => 0, 'page' => 1, 'per_page' => 15];
+
+$action_labels = [
+ 'buy' => '购买',
+ 'upgrade_config' => '配置升级',
+ 'upgrade_product' => '产品升级',
+];
+$status_labels = [
+ 'pending' => '待处理',
+ 'paid' => '已支付',
+ 'opened' => '已完成',
+ 'failed' => '失败',
+ 'cancelled' => '已取消',
+];
+ob_start();
+?>
+<div class="zj-section">
+ <h1>我的订单</h1>
+ <p>购买与升级订单记录</p>
+</div>
+
+<?php if (empty($orders['list'])): ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="text-align:center;padding:40px;color:#999;">
+ 暂无订单,<a href="<?= zjmf_url('reserve/shop') ?>">去选购商品</a>
+ </div>
+ </div>
+<?php else: ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="padding:0;">
+ <table class="zj-table">
+ <thead>
+ <tr>
+ <th>订单号</th>
+ <th>类型</th>
+ <th>供应商</th>
+ <th>商品</th>
+ <th>周期</th>
+ <th>金额</th>
+ <th>状态</th>
+ <th>下单时间</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($orders['list'] as $o): ?>
+ <tr>
+ <td class="zj-mono"><?= htmlspecialchars($o['order_no']) ?></td>
+ <td><?= htmlspecialchars($action_labels[$o['action']] ?? $o['action']) ?></td>
+ <td><?= htmlspecialchars($o['supplier_name'] ?: '-') ?></td>
+ <td><?= htmlspecialchars($o['product_name']) ?></td>
+ <td><?= htmlspecialchars($o['cycle_name'] ?: '-') ?></td>
+ <td>¥<?= zjmf_format_cents($o['amount_cents']) ?></td>
+ <td>
+ <span class="zj-status zj-status-<?= htmlspecialchars($o['status']) ?>">
+ <?= htmlspecialchars($status_labels[$o['status']] ?? $o['status']) ?>
+ </span>
+ </td>
+ <td class="small"><?= htmlspecialchars($o['created_at']) ?></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <?php
+ $total_pages = max(1, (int)ceil($orders['total'] / $orders['per_page']));
+ $current_page = (int)$orders['page'];
+ if ($total_pages > 1):
+ ?>
+ <div class="zj-pager">
+ <?php if ($current_page > 1): ?>
+ <a href="<?= zjmf_url('reserve/orders?page=' . ($current_page - 1)) ?>">上一页</a>
+ <?php endif; ?>
+ <span class="zj-pager-info">第 <?= $current_page ?> / <?= $total_pages ?> 页</span>
+ <?php if ($current_page < $total_pages): ?>
+ <a href="<?= zjmf_url('reserve/orders?page=' . ($current_page + 1)) ?>">下一页</a>
+ <?php endif; ?>
+ </div>
+ <?php endif; ?>
+<?php endif; ?>
+
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/app_plugins/zjmfmanager_reserve/views/shop.php b/app_plugins/zjmfmanager_reserve/views/shop.php
new file mode 100644
index 0000000..2f8dcf9
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/shop.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * 用户端 - 商品列表(按供应商分组展示)
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '商品选购';
+$products = $products ?? [];
+
+// 按供应商分组
+$groups = [];
+foreach ($products as $product) {
+ $key = (int)$product['supplier_id'];
+ if (!isset($groups[$key])) {
+ $groups[$key] = [
+ 'name' => (string)($product['supplier_name'] ?? '其他供应商'),
+ 'items' => [],
+ ];
+ }
+ $groups[$key]['items'][] = $product;
+}
+ob_start();
+?>
+<div class="zj-section">
+ <h1>商品选购</h1>
+ <p>选择商品与周期,余额支付后自动开通主机</p>
+</div>
+
+<?php if (empty($groups)): ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="text-align:center;padding:40px;color:#999;">
+ 暂无可购买的商品,请稍后再来。
+ </div>
+ </div>
+<?php else: ?>
+ <?php foreach ($groups as $group): ?>
+ <div class="zj-group">
+ <div class="zj-group-head">
+ <span class="zj-group-name"><?= htmlspecialchars($group['name']) ?></span>
+ <span class="zj-tag">供应商</span>
+ </div>
+ <div class="zj-grid">
+ <?php foreach ($group['items'] as $product): ?>
+ <div class="zj-card">
+ <div class="zj-card-head">
+ <h2><?= htmlspecialchars($product['name']) ?></h2>
+ <?php if (!empty($product['currency'])): ?>
+ <span class="zj-tag"><?= htmlspecialchars($product['currency']) ?></span>
+ <?php endif; ?>
+ </div>
+ <div class="zj-card-desc"><?= nl2br(htmlspecialchars($product['description'])) ?></div>
+ <div class="zj-price">
+ <?php $cycles = zjmf_product_cycles($product); ?>
+ <?php foreach ($cycles as $cycle => $cfg): ?>
+ <div class="zj-price-item">
+ <span class="zj-price-label"><?= htmlspecialchars($cfg['name']) ?></span>
+ <span class="zj-price-value">¥<?= zjmf_format_cents($cfg['price_cents']) ?></span>
+ </div>
+ <?php endforeach; ?>
+ <?php if ($cycles === []): ?>
+ <span style="color:#999;font-size:12px;">暂无可购买周期</span>
+ <?php endif; ?>
+ </div>
+ <div class="zj-buy">
+ <a class="layui-btn"
+ href="<?= zjmf_url('reserve/product/' . (int)$product['id']) ?>">立即购买</a>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php endforeach; ?>
+<?php endif; ?>
+
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/app_plugins/zjmfmanager_reserve/views/upgrade.php b/app_plugins/zjmfmanager_reserve/views/upgrade.php
new file mode 100644
index 0000000..8d11aed
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/upgrade.php
@@ -0,0 +1,249 @@
+<?php
+/**
+ * 用户端 - 主机升级(配置升级 / 产品升降级)
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+$page_title = $page_title ?? '主机升级';
+$host = $host ?? null;
+$kind = $kind ?? 'config';
+$options = $options ?? ['ok' => false, 'msg' => ''];
+$optionsData = !empty($options['ok']) && is_array($options['data'])
+ ? $options['data'] : [];
+
+function zjf_config_options($data)
+{
+ $opts = $data['configoptions'] ?? null;
+ if (!is_array($opts)) {
+ return [];
+ }
+ $out = [];
+ foreach ($opts as $o) {
+ if (!is_array($o)) {
+ continue;
+ }
+ $id = $o['id'] ?? $o['configid'] ?? 0;
+ $name = (string)($o['name'] ?? $o['optionname'] ?? ('选项 ' . $id));
+ $choices = [];
+ $raw = $o['options'] ?? $o['values'] ?? null;
+ if (is_array($raw)) {
+ foreach ($raw as $c) {
+ if (is_array($c)) {
+ $v = $c['value'] ?? $c['id'] ?? '';
+ $l = (string)($c['name'] ?? $c['label'] ?? $v);
+ } else {
+ $v = $c;
+ $l = (string)$c;
+ }
+ if ($v !== '') {
+ $choices[] = ['value' => $v, 'label' => $l];
+ }
+ }
+ }
+ if ($id && $choices) {
+ $out[] = ['id' => $id, 'name' => $name, 'choices' => $choices];
+ }
+ }
+ return $out;
+}
+
+function zjf_upgrade_products($data)
+{
+ $list = $data['list'] ?? $data['products'] ?? null;
+ if (!is_array($list) && isset($data[0]) && is_array($data[0])) {
+ $list = $data;
+ }
+ if (!is_array($list)) {
+ return [];
+ }
+ $out = [];
+ foreach ($list as $p) {
+ if (!is_array($p)) {
+ continue;
+ }
+ $id = (int)($p['id'] ?? 0);
+ if ($id <= 0) {
+ continue;
+ }
+ $cycles = [];
+ $raw = $p['cycles'] ?? null;
+ if (is_array($raw)) {
+ foreach ($raw as $c) {
+ if (!is_array($c)) {
+ continue;
+ }
+ $bc = (string)($c['billingcycle'] ?? $c['cycle'] ?? '');
+ if ($bc === '') {
+ continue;
+ }
+ $cycles[$bc] = (string)($c['name'] ?? $bc);
+ }
+ }
+ $out[] = ['id' => $id, 'name' => (string)($p['name'] ?? ''), 'cycles' => $cycles];
+ }
+ return $out;
+}
+
+$configOptions = zjf_config_options($optionsData);
+$productList = zjf_upgrade_products($optionsData);
+ob_start();
+?>
+<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
+ <h1 style="font-size:20px;color:#222;margin:0;">升级:<?= htmlspecialchars($host['name']) ?></h1>
+ <a class="layui-btn layui-btn-xs layui-btn-primary"
+ href="<?= zjmf_url('reserve/hosts/' . (int)$host['id']) ?>">返回主机详情</a>
+</div>
+
+<div class="zj-msg" id="zjf-msg"></div>
+
+<div class="layui-btn-group" style="margin-bottom:16px;">
+ <a class="layui-btn <?= $kind === 'config' ? '' : 'layui-btn-primary' ?>"
+ href="<?= zjmf_url('reserve/hosts/' . (int)$host['id'] . '/upgrade?kind=config') ?>">配置升级</a>
+ <a class="layui-btn <?= $kind === 'product' ? '' : 'layui-btn-primary' ?>"
+ href="<?= zjmf_url('reserve/hosts/' . (int)$host['id'] . '/upgrade?kind=product') ?>">产品升级</a>
+</div>
+
+<?php if (empty($options['ok'])): ?>
+ <div class="layui-card">
+ <div class="layui-card-body" style="color:#999;">
+ 获取升级选项失败:<?= htmlspecialchars($options['msg'] ?? '未知错误') ?>
+ </div>
+ </div>
+<?php elseif ($kind === 'config'): ?>
+ <div class="layui-card">
+ <div class="layui-card-header">选择配置项</div>
+ <div class="layui-card-body">
+ <?php if ($configOptions === []): ?>
+ <p class="zj-muted">
+ 未解析到可配置项(上游返回结构需联调确认,见 PRD Q1)。请在上游后台直接调整配置。
+ </p>
+ <?php else: ?>
+ <form id="zjf-cfg-form">
+ <?php foreach ($configOptions as $opt): ?>
+ <div class="form-group" style="margin-bottom:14px;">
+ <label style="display:block;font-weight:600;margin-bottom:6px;">
+ <?= htmlspecialchars($opt['name']) ?>
+ </label>
+ <select class="form-control zjf-cfg-select"
+ data-id="<?= (int)$opt['id'] ?>"
+ style="max-width:360px;">
+ <?php foreach ($opt['choices'] as $c): ?>
+ <option value="<?= htmlspecialchars($c['value'], ENT_QUOTES) ?>">
+ <?= htmlspecialchars($c['label']) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <?php endforeach; ?>
+ <div id="zjf-price" class="zj-muted" style="margin:10px 0;"></div>
+ <button type="button" class="layui-btn" id="zjf-cfg-preview">试算差额</button>
+ <button type="button" class="layui-btn layui-btn-danger" id="zjf-cfg-confirm">确认升级</button>
+ </form>
+ <?php endif; ?>
+ </div>
+ </div>
+<?php else: ?>
+ <div class="layui-card">
+ <div class="layui-card-header">选择目标产品</div>
+ <div class="layui-card-body">
+ <?php if ($productList === []): ?>
+ <p class="zj-muted">
+ 未解析到可升级产品(上游返回结构需联调确认,见 PRD Q1)。
+ </p>
+ <?php else: ?>
+ <form id="zjf-prod-form">
+ <?php foreach ($productList as $p): ?>
+ <label class="zj-choice" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
+ <input type="radio" name="newpid" value="<?= (int)$p['id'] ?>" class="zjf-prod-pid">
+ <?= htmlspecialchars($p['name']) ?>
+ <select class="form-control form-control-sm zjf-prod-cycle" style="width:140px;">
+ <?php foreach ($p['cycles'] as $bc => $cn): ?>
+ <option value="<?= htmlspecialchars($bc, ENT_QUOTES) ?>">
+ <?= htmlspecialchars($cn) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </label>
+ <?php endforeach; ?>
+ <div id="zjf-price" class="zj-muted" style="margin:10px 0;"></div>
+ <button type="button" class="layui-btn" id="zjf-prod-preview">试算差额</button>
+ <button type="button" class="layui-btn layui-btn-danger" id="zjf-prod-confirm">确认升级</button>
+ </form>
+ <?php endif; ?>
+ </div>
+ </div>
+<?php endif; ?>
+
+<script>
+(function () {
+ var hostId = '<?= (int)$host['id'] ?>';
+ var kind = '<?= $kind === 'product' ? 'product' : 'config' ?>';
+ var msg = document.getElementById('zjf-msg');
+
+ function show(text, ok) {
+ msg.textContent = text;
+ msg.className = 'zj-msg zj-msg-show ' + (ok ? 'zj-msg-success' : 'zj-msg-error');
+ }
+ function buildBody(preview) {
+ var body = new URLSearchParams();
+ body.append('host_id', hostId);
+ body.append('kind', kind);
+ body.append('preview', preview);
+ if (kind === 'config') {
+ var cfg = {};
+ document.querySelectorAll('.zjf-cfg-select').forEach(function (s) {
+ cfg[s.getAttribute('data-id')] = s.value;
+ });
+ body.append('config_json', JSON.stringify(cfg));
+ } else {
+ var pid = document.querySelector('input[name="newpid"]:checked');
+ if (!pid) return null;
+ var sel = pid.closest('.zj-choice').querySelector('.zjf-prod-cycle');
+ body.append('newpid', pid.value);
+ body.append('billingcycle', sel.value);
+ }
+ return body;
+ }
+ function post(preview, confirmText) {
+ var body = buildBody(preview);
+ if (!body) {
+ show('请先选择升级目标', false);
+ return;
+ }
+ if (confirmText && !confirm(confirmText)) {
+ return;
+ }
+ fetch('<?= zjmf_url('reserve/api/upgrade') ?>', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: body.toString()
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ var ok = res.code === 'ok' || res.success;
+ if (preview && ok) {
+ document.getElementById('zjf-price').textContent =
+ '本次升级需支付:¥' + res.price;
+ return;
+ }
+ show(res.code || '完成', ok);
+ if (ok) setTimeout(function () {
+ window.location.href = '<?= zjmf_url('reserve/hosts/' . (int)$host['id']) ?>';
+ }, 800);
+ }).catch(function () {
+ show('网络错误,请重试', false);
+ });
+ }
+
+ var previewId = kind === 'config' ? 'zjf-cfg-preview' : 'zjf-prod-preview';
+ var confirmId = kind === 'config' ? 'zjf-cfg-confirm' : 'zjf-prod-confirm';
+ var pBtn = document.getElementById(previewId);
+ var cBtn = document.getElementById(confirmId);
+ if (pBtn) pBtn.addEventListener('click', function () { post('1', ''); });
+ if (cBtn) cBtn.addEventListener('click', function () {
+ post('0', '确认按试算差额扣除余额并执行升级吗?');
+ });
+})();
+</script>
+<?php
+$content = ob_get_clean();
+include __DIR__ . '/layout.php';
diff --git a/docs/prd/zjmfmanager-reserve.md b/docs/prd/zjmfmanager-reserve.md
new file mode 100644
index 0000000..70933af
--- /dev/null
+++ b/docs/prd/zjmfmanager-reserve.md
@@ -0,0 +1,480 @@
+---
+title: MNBT 智简魔方(魔方财务)代理分销插件 PRD
+description: MNBT 代理分销魔方财务产品:商品同步加价、本地余额购买、代理商直通开通、主机管理与升降级
+---
+
+# MNBT 智简魔方(魔方财务)代理分销插件 PRD
+
+> 版本:v1.1(待评审)
+> 日期:2026-08-12
+> 状态:评审中
+> 关联文档:[插件开发手册](../development/plugin/guide.md)、[user_info](../development/plugin/builtin/user-info.md)、
+> [balance](../development/plugin/builtin/balance.md)、示例 SDK `app_plugins/zjmfmanager_reserve/example/`
+
+---
+
+## 1. 背景与目标
+
+### 1.1 背景
+
+MNBT 需以**代理商(Reseller)**身份对外销售一个或多个上游**智简魔方 / 魔方财务(cube_finance)**
+站点的产品。上游通过客户 API(`zjmf_api_login` + JWT)提供商品、下单、开通、主机管理能力,
+示例 SDK 已封装登录/商品/主机/升级等基础请求。
+
+本插件在 MNBT 侧以业务插件形式实现代理分销,依赖已内置的 `user_info`(用户认证)
+与 `balance`(余额支付)插件,复用其认证与支付能力。
+
+### 1.2 目标
+
+| # | 目标 |
+|---|------|
+| G1 | 管理员维护多个上游供应商(魔方财务站点 + 代理商 API 账号),按供应商同步商品并加价出售 |
+| G2 | 本地用户浏览商品(按供应商分组)、选择周期、用本地余额支付下单 |
+| G3 | 支付成功后由插件调用对应供应商接口为**代理商账号**直通开通主机,用户获得主机信息 |
+| G4 | 用户查看主机状态/流量,执行开关机/重启/重置密码/重装等操作 |
+| G5 | 用户提交配置升级 / 产品升降级,余额支付后同步到上游 |
+| G6 | 管理员管理供应商、订单与主机,记录操作日志 |
+
+### 1.3 范围
+
+| 期次 | 内容 |
+|------|------|
+| **P0(本期)** | 供应商管理(多上游);商品同步(弹窗选择)+ 手动添加 + 加价 + 上架;本地下单 + 余额支付 + 代理商直通开通;<br>用户主机查看(状态/流量)+ 基本操作(开关机/重启/重置密码/重装);配置升级 + 产品升降级(余额支付);管理员订单/主机管理;操作日志 |
+| **P1(下期)** | 上游余额/成本对账;定时任务(cron)自动同步商品与价格、批量状态刷新;监控用量图表;商品配置项(config options)可视化选择 |
+
+> 说明:P0 下单暂不支持上游商品复杂配置项的自定义(如需可手填备注参数,见 Q2),
+> 仅支持商品 + 计费周期的选购。
+
+---
+
+## 2. 总体架构
+
+### 2.1 架构图
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ MNBT(本系统) │
+│ │
+│ 用户(user_info 认证) │
+│ │ 浏览/下单/余额支付(balance) │
+│ ▼ │
+│ zjmfmanager_reserve 插件 │
+│ ├─ 供应商管理(多上游,各自独立 API 账号/JWT) │
+│ ├─ 商品同步/加价(按供应商) │
+│ ├─ 本地订单 → 余额扣款 → 对应供应商开通(代理商直通) │
+│ ├─ 主机查询 / provision 操作 / 升级 │
+│ └─ 管理员:供应商、商品、订单、主机、日志 │
+└──────────────┬───────────────────────────────┬─────────────┘
+ │ HTTPS + JWT │ HTTPS + JWT
+ ▼ ▼
+┌─────────────────────────────┐ ┌─────────────────────────────┐
+│ 上游 魔方财务 A(供应商1) │ │ 上游 魔方财务 B(供应商2) │
+│ zjmf_api_login / product │ │ zjmf_api_login / product │
+│ cart/set_config / host/* │ … │ cart/set_config / host/* │
+│ provision/* / upgrade/* │ │ provision/* / upgrade/* │
+└─────────────────────────────┘ └─────────────────────────────┘
+```
+
+**核心模型**:本地用户不直接接触上游客户体系。每个供应商使用各自配置的**代理商 API 账号**
+在上游完成开通与操作,主机归属对应供应商的代理商账号,本地用户持有主机账号密码。
+商品、订单、主机均记录 `supplier_id`,下单/操作/升级按供应商路由到对应客户端实例
+(每个供应商独立 JWT 缓存,互不串扰)。
+
+### 2.2 模块命名
+
+| 项 | 值 |
+|----|----|
+| 插件 slug | `zjmfmanager_reserve`(与目录名一致) |
+| 显示名 | 魔方财务代理分销 |
+| 依赖插件 | `user_info`、`balance`(`plugin.json` 用 `requires_plugins` 声明) |
+
+---
+
+## 3. 上游 API 约定
+
+### 3.1 认证
+
+- `POST {BASE}/zjmf_api_login`(username=客户用户名, password=API 密钥)→ 顶层 `jwt`
+- 后续请求头 `Authorization: Bearer {jwt}`
+- JWT 缓存约 2 小时;返回 `status=401/405` 时强制重登重试一次
+- **多供应商**:JWT 缓存 key 含 `supplier_id`,各供应商独立登录、独立缓存、互不串扰
+- 客户端类改编自 `example/sdk/CubeFinanceClient.php`,置于插件 `lib/` 下
+
+### 3.2 接口清单与映射
+
+| 场景 | 上游接口 | 插件方法(lib/upstream.php) |
+|------|----------|------------------------------|
+| 登录 | `POST zjmf_api_login` | `login()` |
+| 连通测试 | 登录 + 商品列表 | `testConnection()` |
+| 商品列表 | `GET api/product/list` | `productList()` |
+| 商品详情(代理价) | `GET api/product/{id}?price_basis=agent` | `productDetail($id)` |
+| 价格试算 | `GET cart/set_config` | `cartSetConfig($params)` |
+| 开通(下单) | 见 §3.3(Q1) | `purchase($params)` |
+| 主机头信息 | `GET host/header?host_id=` | `hostHeader($hostId)` |
+| 流量使用 | `GET host/trafficusage` | `hostTrafficUsage($hostId)` |
+| 主机操作 | `POST provision/default` | `hostAction($hostId, $func, $extra)` |
+| 配置升级页 | `GET upgrade/index/{hostId}` | `upgradeIndex($hostId)` |
+| 配置升级确认 | `GET upgrade/upgrade_config_page` | `upgradeConfigPage($hostId)` |
+| 提交配置升级 | `POST upgrade/upgrade_config_post` | `upgradeConfigPost($params)` |
+| 产品升降级选项 | `GET upgrade/upgrade_product/{hostId}` | `upgradeProduct($hostId)` |
+| 提交产品升降级 | `POST upgrade/upgrade_product_post` | `upgradeProductPost($params)` |
+| 余额抵扣(开放问题) | `POST apply_credit` | `applyCredit($params)` |
+
+### 3.3 开通(下单)流程
+
+代理商直通开通的**具体上游端点需联调确认**(魔方财务各版本/定制站存在差异):
+
+**方案 A(推荐,先按此实现)**:提交上游订单 → `apply_credit` 余额支付 → 上游自动开通。
+- 步骤:`cart/set_config` 试算 → 创建上游订单(端点待确认,Q1)→ `apply_credit` 抵扣支付
+ → 查询订单/主机获取 `host_id` 与账号密码。
+
+**方案 B(备选)**:若开通可由 provision 类接口直接触发,则 `provision/default` 携 `func=create`
+类动作创建主机。
+
+> 插件将上游调用收敛在 `ZjmfUpstream::purchase()` 单一方法内,后续仅需改该方法适配,
+> 不影响订单、支付、页面逻辑。默认实现按方案 A 编码,联调按实际站点调整。
+
+---
+
+## 4. 数据表设计
+
+### 4.1 `MN_plugin_zjmf_supplier` 上游供应商表(v1.1 新增)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT AI | 主键 |
+| name | VARCHAR(50) | 供应商名称(站点标识,可自定) |
+| api_url | VARCHAR(255) | 上游站点根地址 |
+| api_username | VARCHAR(64) | API 用户名 |
+| api_password | VARCHAR(255) | API 密钥(仅保存时写入,不回显) |
+| api_timeout | INT | 请求超时(秒),默认 30 |
+| markup_type | TINYINT | 该供应商加价方式:0=比例 1=固定(分) |
+| markup_value | BIGINT | 加价比例(千分比)或固定加价(分) |
+| status | TINYINT | 1=启用 0=停用(停用后商品不可售、不可操作) |
+| sort | INT | 排序 |
+| remark | VARCHAR(255) | 备注 |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+> 加价规则按供应商独立配置(商品可单品覆盖);不再使用全局 option 加价。
+
+### 4.2 `MN_plugin_zjmf_product` 本地商品表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT AI | 主键 |
+| supplier_id | INT | 所属供应商 ID(v1.1 新增,联合唯一索引) |
+| up_product_id | INT | 上游商品 ID(与 supplier_id 联合唯一索引) |
+| name | VARCHAR(100) | 商品名 |
+| description | TEXT | 描述 |
+| currency | VARCHAR(10) | 货币代码(同步自上游) |
+| agent_price_cents | BIGINT | 上游代理价(分,最近同步) |
+| markup_type | TINYINT | 单品加价方式:0=比例 1=固定(分);0/0=未配置取供应商 |
+| markup_value | BIGINT | 加价比例(千分比)或固定加价(分) |
+| cycles | TEXT | 周期 JSON:`[{"cycle":"monthly","name":"月付","price_cents":12345}]`(本地售价) |
+| status | TINYINT | 1=上架 0=下架 |
+| sort | INT | 排序 |
+| synced_at | DATETIME | 最近同步时间 |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+> 价格计算:本地售价 = 上游代理价 ×(1 + 比例%)或 + 固定加价,单位**分**整数存储。
+> 未单独配置加价时取所属供应商(`MN_plugin_zjmf_supplier.markup_type/value`)配置。
+
+### 4.3 `MN_plugin_zjmf_order` 本地订单表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT AI | 主键 |
+| order_no | VARCHAR(64) | 本地订单号(唯一,前缀 `ZJM`) |
+| supplier_id | INT | 所属供应商 ID(v1.1 新增,开通路由依据) |
+| user_id | INT | 本地用户 ID |
+| product_id | INT | 本地商品 ID |
+| up_product_id | INT | 上游商品 ID(冗余) |
+| product_name | VARCHAR(100) | 商品名(冗余) |
+| cycle | VARCHAR(20) | 计费周期(billingcycle) |
+| cycle_name | VARCHAR(20) | 周期文案(冗余) |
+| amount_cents | BIGINT | 本地售价(分) |
+| cost_cents | BIGINT | 上游成本价(分) |
+| order_params | TEXT | 下单参数 JSON(备注/配置项透传) |
+| up_order_id | INT | 上游订单 ID(开通后回填) |
+| up_host_id | INT | 上游主机 ID(开通后回填) |
+| username | VARCHAR(64) | 上游主机账号(冗余,展示脱敏) |
+| status | VARCHAR(20) | `pending/paid/opened/failed/cancelled` |
+| pay_time | DATETIME | 支付时间 |
+| opened_at | DATETIME | 开通时间 |
+| remark | VARCHAR(255) | 备注 |
+| created_at | DATETIME | 下单时间 |
+
+### 4.4 `MN_plugin_zjmf_host` 上游主机映射表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT AI | 主键 |
+| supplier_id | INT | 所属供应商 ID(v1.1 新增,操作/升级路由依据) |
+| user_id | INT | 本地用户 ID |
+| order_id | INT | 本地订单 ID |
+| up_host_id | INT | 上游主机 ID |
+| up_product_id | INT | 上游商品 ID |
+| name | VARCHAR(100) | 主机名 |
+| username | VARCHAR(64) | 上游账号 |
+| password | VARCHAR(255) | 上游密码(`authcode` 加密存储) |
+| cycle | VARCHAR(20) | 周期 |
+| status | VARCHAR(20) | 状态缓存:active/suspend/unknown |
+| renew_date | VARCHAR(20) | 到期日(缓存) |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+### 4.5 `MN_plugin_zjmf_log` 操作日志表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT AI | 主键 |
+| user_id | INT | 本地用户 ID(可为 0=系统) |
+| supplier_id | INT | 供应商 ID(v1.1 新增,便于追溯) |
+| order_no | VARCHAR(64) | 关联订单号 |
+| action | VARCHAR(50) | 操作:sync/purchase/power/reset_password/upgrade/... |
+| result | VARCHAR(20) | success/failed |
+| content | TEXT | 详情 JSON(脱敏,不含密码/密钥) |
+| created_at | DATETIME | 时间 |
+
+> 建表语句写入 `install.sql`,删除语句写入 `uninstall.sql`,
+> 均用 `CREATE TABLE IF NOT EXISTS` / `DROP TABLE IF EXISTS`。
+> **迁移说明(v1.1)**:新增 `MN_plugin_zjmf_supplier` 表并在 product/order/host/log
+> 增加 `supplier_id` 字段属结构变更;插件未正式发布,安装环境需**卸载后重装**(数据清空)。
+> 若存在需保留数据的部署,另提供 `upgrade-1.1.sql` 做 `ALTER TABLE` + 默认供应商迁移。
+
+---
+
+## 5. 管理员端设计
+
+### 5.1 页面(`plugin.php?p=zjmfmanager_reserve&page=...`)
+
+| 页面 | page | 说明 |
+|------|------|------|
+| 供应商管理 | `suppliers` | 供应商列表(名称/站点/加价/启用/排序);新增/编辑弹窗(连接信息 + 加价配置);连通测试;停用后商品不可售 |
+| 商品管理 | `products` | 商品列表(所属供应商、上架状态、售价、同步时间);「同步商品」打开选择弹窗;「手动添加」表单;启停/编辑 |
+| 商品编辑 | `product_edit` | 单品加价(比例/固定)、排序、上架开关、查看上游代理价 |
+| 订单管理 | `orders` | 本地订单列表(供应商/状态筛选、按订单号/用户搜索) |
+| 主机管理 | `hosts` | 本地映射列表 + 上游主机详情查询入口 |
+
+### 5.2 供应商与商品同步
+
+**供应商管理**
+1. 新增供应商:名称、站点 URL、API 用户名、API 密钥、超时、加价方式/数值、启用、排序。
+2. 保存后可「连通测试」(登录 + 商品列表)验证凭证。
+3. 停用供应商后,其商品自动下架不可售、主机操作与升级拒绝执行(返回明确错误)。
+
+**商品同步(弹窗选择)**
+1. 商品管理页点击「同步商品」→ 弹出同步对话框。
+2. 对话框第一步选择供应商 → 拉取该供应商 `api/product/list` 列表(名称/描述/代理价)。
+3. 管理员勾选要同步的商品(默认全选,可过滤)→ 确认后逐条同步。
+4. 每个商品拉取 `api/product/{id}?price_basis=agent` 代理价,并按 `cart/set_config`
+ 试算各周期价格(失败则跳过该周期,标记该商品「价格未同步」)。
+5. 同步策略(幂等):按 `supplier_id + up_product_id` upsert;仅新增或更新名称/描述/代理价,
+ **不覆盖**管理员自定义的加价、上架、排序。
+
+**手动添加商品**
+1. 商品管理页点击「手动添加」→ 表单:所属供应商、商品名称、上游商品 ID、描述。
+2. 保存后立即按该商品拉取上游代理价与各周期价格(依赖上游 `api/product/{id}` 详情接口,
+ 失败则该商品标记「价格未同步」,可稍后再次同步)。
+3. 手动添加商品同样遵循单品加价配置。
+
+> 本地售价 = 代理价 × 加价(供应商或单品),写入 `cycles`。
+
+### 5.3 管理员 AJAX(`gn` 全部使用 `p_zjmf_admin_` 前缀)
+
+| gn | 说明 |
+|----|------|
+| `p_zjmf_admin_save_supplier` | 保存供应商(新增/编辑,密码留空不修改) |
+| `p_zjmf_admin_toggle_supplier` | 启用/停用供应商 |
+| `p_zjmf_admin_delete_supplier` | 删除供应商(有商品/主机时拒绝) |
+| `p_zjmf_admin_test_supplier` | 连通测试(登录 + 商品列表) |
+| `p_zjmf_admin_upstream_products` | 拉取指定供应商商品列表(供同步弹窗选择) |
+| `p_zjmf_admin_sync_products` | 按所选供应商 + 勾选商品 ID 列表同步 |
+| `p_zjmf_admin_save_product` | 保存单品加价/上架/排序 |
+| `p_zjmf_admin_toggle_product` | 上下架切换 |
+| `p_zjmf_admin_add_product` | 手动添加商品(供应商 + 上游产品 ID + 名称 + 描述) |
+| `p_zjmf_admin_order_list` | 订单分页列表 |
+| `p_zjmf_admin_host_list` | 主机分页列表 |
+| `p_zjmf_admin_fetch_host` | 拉取上游主机详情(header/流量) |
+
+---
+
+## 6. 用户端设计
+
+### 6.1 页面与路由(P2 通用路由,`index.php?_r=...` 或伪静态)
+
+| 页面 | 路由 | 说明 |
+|------|------|------|
+| 商品列表 | `GET /reserve/shop` | 上架商品卡片(价格/周期);**按供应商分组展示**(Tab 或分组区块,含供应商名/状态标识) |
+| 商品详情 | `GET /reserve/product/{id}` | 周期切换试算、下单按钮 |
+| 我的主机 | `GET /reserve/hosts` | 主机列表(状态/到期,含所属供应商标识) |
+| 主机详情 | `GET /reserve/hosts/{id}` | 状态、流量、操作按钮、升级入口 |
+| 我的订单 | `GET /reserve/orders` | 本地订单列表与状态 |
+
+API 路由:
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/reserve/api/create_order` | 创建订单并余额支付(核心,见 §7.1) |
+| POST | `/reserve/api/host_action` | 主机操作(on/off/reboot/reset_password/reinstall) |
+| POST | `/reserve/api/upgrade` | 提交配置升级 / 产品升降级(余额支付,见 §7.2) |
+| POST | `/reserve/api/refresh_price` | 前端周期价格试算(读本地售价,不上游) |
+
+> 辅助函数:`zjmf_url($path)`、`zjmf_admin_url($page)`、`zjmf_format_cents($cents)`、
+> `zjmf_require_user()`(包装 `user_info_auth_require`,输出统一布局)。
+
+### 6.2 下单支付流程
+
+```
+用户选择商品+周期 → 前端 POST /reserve/api/create_order
+ → zjmf_require_user()
+ → 校验商品上架、周期有效 → 写 MN_plugin_zjmf_order(status=pending)
+ → balance_deduct(user_id, amount, 'consume', order_no, 备注) # 余额扣款
+ → 扣款成功 → 订单 status=paid → 触发开通(§7.1)
+ → 返回结果(成功/余额不足)
+```
+
+- 余额不足时 `balance_deduct` 返回 false → 订单置 `cancelled` → 提示充值。
+- 余额扣款成功后,若上游开通失败,订单置 `failed` 并**自动原路退回**余额
+ (`balance_add`,type=refund),日志记录完整链路。
+
+### 6.3 主机操作与升级
+
+- 操作(开关机/重启/重置密码/重装):调对应供应商 `provision/default`,参数含 `host_id` 与 `func`。
+ 重装/重置密码等高危操作需二次确认;前端确认后调用。
+- 配置升级 / 产品升降级:先按主机所属供应商调 `upgrade/index`、`upgrade/upgrade_config_page` 或
+ `upgrade_product` 拉取选项与差额 → 前端确认 → 本地再建一条升级订单(`action` 标记,记录 supplier_id)
+ → `balance_deduct` 扣差额 → 调 `upgrade_config_post` / `upgrade_product_post` 提交对应上游
+ → 更新主机 `cycle/status/renew_date` 缓存。
+
+---
+
+## 7. 核心流程与钩子
+
+### 7.1 下单开通闭环
+
+```
+创建本地订单(pending, 含 supplier_id) → 余额扣款(balance_deduct)
+ → balance 插件触发 order.paid 钩子(与 hosting_shop 同模式,priority 默认 10)
+ → 本插件监听 order.paid(priority 20)
+ - 仅处理 order_no 前缀 ZJM 的订单,其余 return
+ - 幂等:status 已 paid/opened 直接 return
+ - 按订单 supplier_id 取对应供应商配置,调 ZjmfUpstream::purchase() 在上游开通
+ - 回填 up_order_id / up_host_id / username / password
+ - 写 MN_plugin_zjmf_host(含 supplier_id),订单 status=opened
+ - 失败:订单 status=failed + 原路退回余额 + 写日志
+```
+
+> 注:`order.paid` 由 balance 扣款链路触发(详见 balance 插件文档中 hosting_shop
+> 同款流程);支付插件(充值时)也会触发该钩子,本插件以订单号前缀严格过滤,
+> 避免误处理余额充值订单。供应商停用/缺失时开通返回明确错误并走失败退款路径。
+
+### 7.2 升级闭环
+
+```
+拉取升级选项/差额 → 前端确认 → 创建升级单(pending, action=upgrade_config|upgrade_product, 含 supplier_id)
+ → 余额扣款 → 按 supplier_id 提交对应上游 → 成功置 opened 并更新主机缓存 / 失败置 failed 并退余额
+```
+
+### 7.3 钩子清单
+
+| 钩子 | 优先级 | 说明 |
+|------|--------|------|
+| `order.paid` | 20 | 本地购买订单支付成功后触发上游开通(§7.1) |
+| `cron` | 10 | P1:定时同步商品价格、刷新主机状态 |
+
+---
+
+## 8. 状态映射
+
+### 8.1 上游主机状态 → 本地展示
+
+| 上游状态(host/header 返回值,以实际为准) | 本地展示 |
+|--------------------------------------------|----------|
+| 正常 / `qk=1` | 运行中 |
+| 暂停 / `qk=0` | 已暂停 |
+| 到期未续费 | 已到期 |
+| 查询失败 / 无数据 | 未知(缓存兜底) |
+
+### 8.2 本地订单状态
+
+`pending`(待支付)→ `paid`(已支付)→ `opened`(已开通);
+失败路径:`pending`→`cancelled`(未支付关闭)、`paid`→`failed`(开通失败,余额已退)。
+
+---
+
+## 9. 安全设计
+
+| 项 | 方案 |
+|----|------|
+| 上游凭证 | 存 `MN_plugin_zjmf_supplier.api_password`;编辑页密码框不回显;日志/响应脱敏 |
+| 主机密码 | `authcode()` 加密入库;页面默认打码,可「显示」二次确认 |
+| 用户侧鉴权 | 全部 API 走 `zjmf_require_user()`(user_info 认证) |
+| 管理侧鉴权 | 全部 AJAX 走 `mnbt_plugin_require_admin()` |
+| 出站请求 | 仅 `http(s)://`;JWT 缓存目录权限收敛;超时上限校验 |
+| AJAX 命名 | 管理员 `p_zjmf_admin_*`,避免与核心 gn 冲突 |
+| 越权 | 主机/订单查询均带 `user_id` 条件,无跨用户访问路径;供应商操作仅管理员 |
+| 订单号 | 前缀 `ZJM` + 时间 + 随机,全局唯一 |
+
+---
+
+## 10. 里程碑与验收
+
+### M1 — 供应商管理 + 上游服务层
+- [ ] `lib/CubeFinanceClient.php`(改编 SDK)+ `lib/upstream.php` 服务层(按供应商实例化,JWT 独立缓存)
+- [ ] 供应商管理页:新增/编辑/启停/删除、连接配置 + 加价配置、连通测试
+- **验收**:可添加多个供应商并分别连通测试;错误凭证返回明确错误;停用供应商后商品不可售。
+
+### M2 — 商品同步(弹窗选择/手动添加)+ 用户端商品与下单
+- [ ] 同步弹窗(选供应商 → 拉取列表 → 勾选同步)、手动添加商品、加价、上下架
+- [ ] 商品列表/详情页(按供应商分组)、`create_order`、余额支付闭环
+- **验收**:同步后本地售价 = 代理价 × 加价(供应商或单品);可仅同步勾选商品;
+ 手动添加后价格同步成功;下单扣款成功;余额不足被拒。
+
+### M3 — 上游开通 + 主机管理
+- [ ] `purchase()` 开通(Q1 方案 A,按 supplier_id 路由);`MN_plugin_zjmf_host` 落库
+- [ ] 主机列表/详情、状态与流量查询、开关机/重启/重置密码/重装
+- **验收**:支付后对应供应商上游出现主机并回填 host_id;操作类功能状态同步变化;
+ 开通失败自动退余额;停用供应商主机操作被拒。
+
+### M4 — 升级 + 管理员订单/主机 + 日志
+- [ ] 配置升级 / 产品升降级闭环(余额支付,按 supplier_id 路由)
+- [ ] 管理员订单/主机列表(供应商筛选)、拉取上游详情
+- [ ] 操作日志表完整记录
+- **验收**:升级扣差额并同步对应上游;订单/主机列表筛选正确;日志无明文密码/密钥。
+
+### M5 — 文档收尾
+- [ ] 插件 README、PRD 附录补充部署与联调用例;mdi 图标可用性核对
+
+---
+
+## 11. 决策点(待评审)
+
+| # | 决策 | 建议 |
+|---|------|------|
+| D1 | 开通模式 | 代理商直通开通(方案 A:上游下单 + apply_credit,Q1 确认端点) |
+| D2 | 定价 | 同步 + 加价;**按供应商独立配置加价**,商品可单品覆盖;单位分 |
+| D3 | 开通触发 | 复用 `order.paid` 钩子(priority 20),订单号前缀过滤 |
+| D4 | 开通失败处理 | 订单置 failed 并自动原路退回余额 |
+| D5 | 上游主机归属 | 全部归各供应商代理商 API 账号,本地不建上游客户 |
+| D6 | SDK 来源 | 改编 `example/sdk/CubeFinanceClient.php`,避免改动示例原文件 |
+| D7 | 多供应商(v1.1) | 新增 `MN_plugin_zjmf_supplier` 表;商品/订单/主机/日志记录 supplier_id;客户端按供应商实例化、JWT 独立缓存 |
+| D8 | 商品同步(v1.1) | 弹窗选择供应商与商品后同步;支持手动添加(基础信息 + 同步周期) |
+| D9 | 管理端图标 | 仅使用本项目 mdi 库中存在的图标(`mdi-cog` 不存在,改用 `mdi-settings`/`mdi-account-key` 等) |
+
+---
+
+## 12. 风险与开放问题
+
+| # | 风险/问题 | 影响 | 应对 |
+|---|-----------|------|------|
+| R1 | 上游「开通/下单」端点各版本差异 | 开通失败 | 收敛于 `ZjmfUpstream::purchase()`,联调按实际站点适配(§3.3) |
+| R2 | `order.paid` 触发链路与预期不符 | 支付后不开通 | 以 hosting_shop 同款 balance 扣款流程为准,联调验证;备选:create_order 内同步触发 |
+| R3 | 上游商品周期/价格口径差异 | 本地售价不准 | 以 `cart/set_config` 试算结果为准;失败标记「价格未同步」 |
+| R4 | 多供应商凭证/字段差异(v1.1) | 部分供应商不可用 | 供应商维度独立配置与缓存;连通测试逐供应商验证 |
+| Q1 | 上游创建订单的确切端点 | 影响 purchase() | 联调确认(`orders/checkout`/`cart/checkout` 等),默认方案 A 实现 |
+| Q2 | 商品配置项(config options)如何透传 | 影响下单参数 | P0 支持备注透传 `order_params`;可视化选择放 P1 |
+| Q3 | 上游 host/header 状态字段名 | 影响状态映射 | 以实际返回为准,§8.1 表联调校准 |
+| Q4 | 重置密码后上游主机密码回传时机 | 影响入库 | 开通/重置后主动查询 host/header 或订单回传字段 |