零依赖 Node.js 后端 + PHP 门户 + UniApp 跨端:一张架构地图 + 9 类改动的落点,四端分销系统怎么改

20 阅读14分钟

拿到一个 3 万行的陌生代码库,你第一反应是翻 package.json 还是翻目录树?

我接手这套系统时两个都翻了,结果发现真正决定你改哪一行代码的,不是技术栈,是它有没有框架。有框架,你照着约定写;没框架,约定就散落在每个人的脑子里,你得自己把它们捞出来。

这套系统是后者。后端 backend/package.json 的 dependencies 是空的——没有 Express,没有 Koa,没有 Mongoose,连 ORM 都没有。整个 backend/src 才 2.9 万行(其中 1.28 万行在 42 个 handler 里),全部用 Node 内置模块手搓。

没有框架意味着没有约定来兜底。好在这套代码把约定明文写在文件里了:3 条铁律、9 类改动的落点、1 条请求链路。我把这张地图完整拆给你——看完你就知道该改哪个文件,不用再全文搜 grep。

项目:Distributor Touch(快消 B2B 分销 SaaS)· 后端 Node.js 22+ 零运行时依赖 · 许可证 Apache 2.0

一、这套系统到底解决什么

一句话:品牌商 → 代理商 → 消费者的一条链路打透,并且从架构上保证不会踩传销红线。

它不是一个单纯的商城,也不是一个单纯的代理管理系统,而是四端一体的组合:

端使用者形态职责
管理端品牌商运营B/S(PHP 门户页)管商品、订单、代理商、佣金、店铺、合规
代理端代理商B/S + 小程序看业绩、佣金、素材、培训、海报
C 端商城消费者B/S + 小程序逛店、加购、下单、支付
小程序代理 + 消费者UniApp 一套代码编译到 H5 / 微信小程序 / App / 企微侧边栏

注意这里的关键词是四端一体。这四个字直接决定了后端的设计——同一套业务规则要同时喂给 PHP 门户、UniApp 小程序、开放 API、企微侧边栏四条链路,所以规则只能写一份。

二、目录结构:改哪里先看这里

先把这棵树铺开,后面所有内容都在这上面定位:

distributor-touch/
├── backend/                      # Node.js 后端(零依赖,ESM)
│   └── src/
│       ├── server.js             # ★ 单一路由分发入口(2300+ 行,所有 API 都在这)
│       ├── persist.js            # ★ 持久化注册中心
│       ├── seed.js               # 种子数据
│       ├── config.js             # .env / 环境变量
│       ├── handlers/             # ★ 业务模块(42 个,一个领域一个文件)
│       ├── domain/               # ★ 领域规则(合规护栏、佣金引擎、代理层级)
│       ├── auth/                 # JWT + API Key + 中间件
│       ├── agent/                # AI 助手(意图解析 / 工具表 / 编排)
│       ├── ai/                   # ★ AI 引擎(选品/预测/销后/对账 四条能力线)
│       ├── store/file-store.js   # 原子写入的 JSON 存储层
│       ├── utils/id-gen.js       # ID 生成器
│       └── tests/                # node:test 单元测试(42 个文件)
│
├── services/
│   ├── php/portal/               # ★ B/S 门户(无框架)
│   │   ├── index.php             #   路由表
│   │   ├── config.php            #   配置 + 导航项
│   │   ├── controllers/          #   PortalController / AgentController / AuthController
│   │   └── views/                #   55 个视图模板
│   ├── docker/                   # Docker Compose(nginx + 多服务)
│   │   ├── nginx/nginx.conf
│   │   ├── www/                  #   官网静态站
│   │   └── docker-compose.yml
│   ├── go/ java/                 # 多语言服务端示例
│
├── frontend/                     # 管理端前端资源
├── harmony/                      # 鸿蒙端
├── uniapp/                       # ★ UniApp 小程序(vite 结构,源码在 src/)
│   └── src/
│       ├── pages.json            #   路由 + tabBar
│       ├── manifest.json         #   多端编译配置
│       ├── pages/                #   页面(含 shop/ 商城 5 页)
│       ├── utils/                #   api / cart / i18n / auth / config / nav
│       └── static/tabbar/        #   图标
│
└── docs/ openapi.yaml            # 文档与 API 契约

读这棵树有三个要点:

带 ★ 的四行是你需要动手的地方,其余的都是被它们调用的叶子。domain/ 只有 3 个文件(compliance-guardrails.js、commission-engine.js、agent-hierarchy.js),但它是整个系统的规则内核——后面讲合规那篇会专门展开。

handlers/ 下面 42 个文件是平铺的,一个领域一个文件。没有 orders/、products/ 这种二级目录,因为单文件都不大(stores.js 只有 122 行),平铺反而好找。

ai/ 目录是后来新增的,它和 agent/ 不是一回事,这点特别容易混:

  • agent/ 是对话式 AI 助手——把已有 handler 包装成工具给大模型调用,关键词是"意图解析 / 工具表 / 编排"
  • ai/ 是业务 AI 引擎——选品、预测、销后、对账四条能力线,每条线独立跑、留痕、可回放

三、技术栈与三条设计铁律

3.1 后端零运行时依赖

所有能力用 Node 内置模块实现:

  • HTTP 服务:node:http
  • 路由:手写 if (path === '...' && method === '...') 分发
  • JWT:node:crypto 手写 HMAC
  • 持久化:node:fs 原子写 JSON
  • 测试:node:test 内置

这对你意味着什么:克隆下来 node src/server.js 就能跑,没有 npm install 的等待,也没有供应链风险。

但代价是没有框架帮你做约定——所以下面的规范必须自己遵守。这是一笔明码标交易的交换:用「约定要自己守」换「装起来零阻力、跑起来零依赖」。

3.2 三条铁律

这三条是这个代码库能不能长期维护的全部前提:

铁律说明违反后果
写操作必须 scheduleSave()改了内存里的 Map 一定要触发落盘,否则重启丢数据数据丢失
新增可持久化模块必须注册三处handler 导出 _internalState/_restoreState → persist.js MODULES → server.js 路由重启数据不恢复
领域规则放 domain/,不放 handler合规校验、佣金计算属于领域层护栏被绕过

第三条最容易被违反,而且违反之后短期完全看不出问题。你把"层级不能超过 2 级"的校验写进了 handlers/commission.js,测试也过了,但另一个新加的定时任务直接调 domain/commission-engine.js 就绕过去了。领域层是所有入口的必经之路,写在那儿才绕不过。

四、二次开发场景速查表

拿到源码后最常做的 9 件事,和对应要改的地方:

#场景主要改动点难度
1加一个业务实体(店铺、仓库、会员等级)handlers/xxx.js + persist.js + server.js + tests/中
2加一个管理端页面index.php 路由 + PortalController 方法 + views/ + config.php 导航低
3改商城业务规则(满减、拆单、优惠券)handlers/orders.js + coupons.js + 门户 cart.js + 小程序 utils/cart.js高
4小程序加页面 / 改 UIpages.json + pages/xxx.vue + utils/api.js + utils/i18n.js低
5接支付 / 短信 / 物流handlers/payment.js + gateway.js + config.js中
6加一种语言门户 i18n/ + 小程序 utils/i18n.js低
7改主题 / 品牌换肤handlers/theme.js + frontend/ 样式变量低
8扩展合规校验domain/compliance-guardrails.js高(禁区)
9加 AI 能力ai/config.js + ai/client.js + handlers/ai-*.js中

难度列是我自己加的,判据是改动的耦合面:只碰一个文件是低,同时要改"后端 + 门户 + 小程序"三端的是高。

第 3 行和第 8 行两个高危项有共同点——它们动的是规则而不是数据通路。规则错了不会报错,只会算出错的佣金或者踩到法律红线。

五、核心链路:一个请求是怎么走完的

理解这条链路,二开时就知道该在哪一层动手。

5.1 管理端打开「店铺管理」页

浏览器 GET /portal/stores
   ↓ nginx: location /portal/ { rewrite ^/portal/(.*)$ /$1 break; proxy_pass php-portal }
   ↓ PHP: index.php 路由表查到 '/stores' => PortalController::adminStores
   ↓ PortalController::adminStores()
        ├─ ensureToken()          会话里没 token 就用 admin 账号换一个
        ├─ apiCall('/api/stores')      → 后端
        ├─ apiCall('/api/products')    → 后端(用来统计每店商品数)
        └─ render('admin_stores', $data)
   ↓ 后端 GET /api/stores
        ├─ authenticate()         中间件:token 有效?角色够吗?
        └─ storesMod.listStores() 读内存 Map
   ↓ 返回 JSON → PHP 组装 → 渲染 views/admin_stores.php

关键点:PHP 门户不直接碰数据库,它只是后端的透传层 + 视图渲染层。所有业务规则都在 Node 后端。

⚠️ 很多二开同学在门户改了半天发现没效果,就是因为改错了层——表单字段要生效,必须同时改 PHP 的转发链和后端的 handler,少一头字段就静默丢失。

这句话值得反复读。"静默丢失"是这里最恶劣的地方:不报错、不告警,页面照常渲染,只是那个字段永远是空。你会以为是后端的问题,其实是门户没转发。

5.2 消费者下一笔跨店订单

购物车(前端 localStorage,按 storeId 分组)
   ↓ 点「结算」→ 逐店调用 POST /api/orders(一店一单)
   ↓ orders.js: 校验 storeId 存在 → 计算金额 → 落库 → scheduleSave()
   ↓ 前端拿到 N 个订单号 → 依次调用 POST /api/orders/:id/pay
   ↓ 成功一个就从购物车移除该店商品;失败的店保留

注意最后一行:支付是逐笔独立的,不是一笔事务。这是产品决定的(每个店铺独立选择支付方式),但它意味着你必须处理"部分成功"——所以那句"失败的店保留"是业务必需,不是偷懒。

六、数据是怎么存的

没有数据库,用的是单文件 JSON 快照 db.json。

写操作 → 改内存 Map → scheduleSave()(2 秒去抖)→ saveSnapshot() → db.json
启动   → loadSnapshot() 读 db.json → 各模块 _restoreState() → 跳过 seed

存储层 store/file-store.js 做了三件重要的事:

  1. 原子写入:先写 db.json.tmp 再 rename,避免进程被杀时留下半截文件
  2. Map/Set 序列化:JSON 不认 Map,写入时自动转 Object / Array
  3. 多机支持:config.dataDir 可指向共享文件系统

第 1 点值得多说一句。writeFileSync 到一半被 kill -9,你会得到一个语法错误的 JSON,然后整个系统启动就崩,连兜底的机会都没有。tmp + rename 在同一个文件系统内是原子操作——要么是旧的完整文件,要么是新的完整文件,不会出现中间态。

快照结构(每个注册的模块占一个 key):

{
  "_savedAt": "2026-08-29T04:00:00.000Z",
  "agents": [...],
  "products": [...],
  "orders": [...],
  "stores": [...],
  "coupons": [...]
}

💡 这个设计的取舍:零依赖、易备份、易调试(db.json 直接 cat);代价是不适合百万级数据和高并发写。如果你的客户量级上来了,把 store/file-store.js 换成 MySQL/PostgreSQL 实现即可,上层 handler 代码一行都不用改——这是预留好的替换点。

这个替换点不是随便说说的。handler 只认 Map 和几个纯函数,从来不碰文件;文件的存在性只有 persist.js 知道。所以换存储层改的是一个文件,不是 42 个。

七、权限模型

三级角色 + 两种认证方式:

角色能做什么
admin全部(品牌商管理员)
agent只看自己的数据(佣金、业绩、素材)
userC 端公开数据

认证中间件 auth/middleware.js 用三张表控制:

const PUBLIC_ROUTES = [        // 无需登录(精确匹配)
  { method: 'GET', path: '/api/health' },
  { method: 'GET', path: '/api/themes' },
  // ...
];

const PUBLIC_READ_PREFIXES = [ // C 端公开只读(按前缀匹配)
  { method: 'GET', prefix: '/api/products/' },
  { method: 'GET', path: '/api/products' },
  { method: 'GET', prefix: '/api/stores/' },
  { method: 'GET', path: '/api/stores' },
];

const ADMIN_ONLY_ROUTES = [    // 必须 admin 角色
  { method: 'POST', path: '/api/commission/rules' },
  { method: 'POST', path: '/api/materials' },
  // ...
];

二开时加接口,第一件事就是想清楚它属于哪一类:

  • 公开 → 加进 PUBLIC_ROUTES(C 端匿名能访问)
  • 管理员 → 加进 ADMIN_ONLY_ROUTES,或在 handler 里判断 currentUser?.role !== 'admin'
  • 其他 → 只要求登录态即可

⚠️ 别图省事把接口全设成公开。C 端页面是完全匿名的,公开接口等于对互联网开放。

这个警告不是空话。PUBLIC_READ_PREFIXES 是前缀匹配——它下面挂着 /api/products/ 和 /api/stores/ 这类 C 端浏览接口,前缀匹配比精确匹配危险得多。

所以中间件里专门加了排除项:

// isPublicRoute() —— 前缀匹配前先排除管理类子端点
for (const r of PUBLIC_READ_PREFIXES) {
  if (r.method !== method) continue;
  // 排除管理类子端点(stats/status/skus 等不得匿名可见)
  if (path.startsWith('/api/products/stats')) continue;
  if (path.startsWith('/api/products/') && path.includes('/skus/')) continue;
  if (r.prefix && path.startsWith(r.prefix)) return true;
  if (r.path && path === r.path) return true;
}

前缀规则一开,/api/products/ 底下的一切默认公开,你新加的 GET /api/products/export 会被前缀规则直接捞成公开接口。所以才需要这层显式排除——默认开放 + 显式收紧,而不是默认关闭 + 逐个加白。

你加新接口时得记住:只要路径落在某个已公开的前缀下,就默认是公开的,得自己加排除项。

八、新增的 AI 引擎长什么样

前面提过 ai/ 目录是后加的,它的设计有一处我觉得特别值得抄。

ai/client.js 提供唯一入口 chatJSON(),四大能力线(选品 / 预测 / 销后 / 对账)都只调它,不关心厂商差异:

// ai/client.js —— 统一入口的守门逻辑
const cfg = getAIConfig(brandId);
const startedAt = Date.now();

// ① 订阅校验:到期后 AI 能力关闭(不删数据、不影响基础功能)
if (scene !== 'chat') {
  const access = checkAIAccess(brandId);
  if (!access.allowed) {
    const entry = log({ scene, brandId, actor, status: 'blocked', ms: 0, model: cfg.model, reason: access.message });
    const data = fallback ? await fallback({ reason: access.message, cfg }) : null;
    return { data, meta: { status: 'blocked', reason: access.message, blockReason: access.reason, logId: entry.id, model: cfg.model, ms: 0 } };
  }
  // ② 配额校验:超额不烧 token
  const quota = consumeQuota(brandId, scene);
  if (!quota.allowed) {
    const entry = log({ scene, brandId, actor, status: 'blocked', ms: 0, model: cfg.model, reason: quota.message });
    const data = fallback ? await fallback({ reason: quota.message, cfg }) : null;
    return { data, meta: { status: 'blocked', reason: quota.message, blockReason: 'quota', logId: entry.id, model: cfg.model, ms: 0, quota } };
  }
}

为什么要在入口就拦,而不是在每个业务 handler 里判?

因为 AI 调用是唯一有边际成本的能力。选品、预测这些每次都要调大模型,token 是真金白银。把配额检查放在 chatJSON() 入口,意味着新增任何一条能力线都自动受配额约束——你不可能忘记写检查,因为它压根不在你的代码里。

而 ai/schema.js 处理的是另一个现实问题:大模型即使被要求"只输出 JSON",也经常不听话:

// ai/schema.js —— 从模型输出中提取 JSON 对象
export function extractJSON(raw) {
  if (!raw || typeof raw !== 'string') return null;
  let s = raw.trim();

  // 1. 去掉 Markdown 代码块包裹
  const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
  if (fence) s = fence[1].trim();

  // 2. 直接尝试
  const direct = tryParse(s);
  if (direct) return direct;

  // 3. 截取第一个 { 到最后一个 } 之间
  const start = s.indexOf('{');
  const end = s.lastIndexOf('}');
  if (start !== -1 && end > start) {
    const sliced = tryParse(s.slice(start, end + 1));
    if (sliced) return sliced;

    // 4. 括号不平衡时逐段尝试(截断输出)
    const obj = tryParse(s.slice(start, end + 1) + '}'.repeat(Math.max(0, countOpen(s.slice(start)) - countClose(s.slice(start)))));
    if (obj) return obj;
  }

  // 5. 兜底:找所有 {...} 平衡块,取第一个能解析的
  const blocks = balancedBlocks(s);
  for (const b of blocks) {
    const obj = tryParse(b);
    if (obj) return obj;
  }
  return null;  // 拿不到就返回 null,由 client 触发一次纠正重问
}

配套的还有一次自纠正重问:如果 JSON 解析出来但缺了 schema 里的必需字段,client 会带着"你上次少了 XXX、YYY"再问一次模型。这招把"字段缺失直接报错"变成了"字段缺失自动修复一次"。

九、先记住这 5 个坑

这 5 个坑是实战中真踩过的,写在最前面帮你省时间:

  1. Windows 下 npm test 会 MODULE_NOT_FOUND —— node --test src/tests/ 的尾斜杠在 Windows 解析不了,要用 node --test "src/tests/*.test.js"。
  2. createProduct() 返回的是 { product, warnings } 不是商品本身 —— 直接拿返回值读 .id 会 undefined。
  3. assert.throws() 不返回错误对象 —— 想断言 err.status 得自己包一层 expectError(fn, re)。
  4. PHP 门户的 demo 版禁止接口透传 —— 新增的 /api/xxx 必须在 index.php 路由表里显式注册,否则 403。
  5. 改了 php-portal 的代码必须 rebuild 镜像 —— 它是 COPY 进容器的,不是 volume 挂载,改文件不重建不生效。

第 1 条和第 3 条是跨平台 + Node API 语义的双重坑,第 4、5 条是部署形态的坑——它们共同的特点是本地不复现。你本地跑得好好的,提交上去才发现。

总结

拆完这张地图,最值得带走的其实不是某个文件路径,是三个判断:

**零依赖不是炫技,是把"装不起来"这个最大的失败模式消灭在源头。**代价是你得自己维护约定,这套系统用"把约定写进注释、写进铁律表"的方式还回去了。

**没有框架的系统,二开的成本几乎全在"找对文件"上。**所以一份准确的改动地图比十篇架构原则有用。

**分层不清的代价是沉默的。**改错层不会报错,只会没效果。所以每加一个字段都问一遍"这三处都要改吗"。

想动手的话,建议路线是:先跑通本地环境 → 复刻一个已有的业务模块(照着 handlers/stores.js 抄)→ 再加管理端页面。第一个模块最难,之后都是重复。


项目已开源,代码可自取:atomgit.com/pangzi_zi/d…

采用 Apache 2.0 许可证,可自由使用、修改与商用。