把 4 套 fork ERP(龙凤 / 摆件 / 妈祖 / 囍铺)合并为一份 Nexora ERP 主干代码,配合一租户一库物理隔离和模块化配置,通过 3 个阶段完成收敛。本文档是直接可执行的作战地图,每一步任务都带完成命令和验证判据。
以 龙凤 ERP(xylferp_php)为骨架起点建立 nexora/nexora-erp 主干 · 通过 3 个阶段依次接入龙凤(未启用客户,验证用)、摆件(已停,验证加租户流程)、妈祖(主战场,深度并入并抽模块) · 每个租户独立一个 DB(物理隔离) · 老的 xipugold/* repo 在完成迁移前原样保留 · 迁移完成后归档 read-only。
nexora_<slug>),物理隔离。业务表现有 aid 字段不动,值恒等于该库租户 id,退化为"防呆兜底"(错连别家库查不到)。tenant_modules 表按租户启用。关闭模块 = 该租户看不到任何相关菜单/接口/数据表。| 客户 | 主干 repo | 首次 commit | Commits | Ctrl | 近 30 天活动 | 血缘 |
|---|---|---|---|---|---|---|
| 龙凤 | xylferp_php | 2025-11-22 | 113 | 75 | 0 commits · 客户尚未启用 | 原始仓 · 唯一有真实开发历史 · 业务面最广(批发+零售) |
| 摆件 | baijian_php | 2026-02-09 | 4 | 73 | 0 commits · 客户已停 | 从 xylferp squash · 几乎没改动 |
| 妈祖 | pyerp_php | 2026-02-09 | 104 | 112 | 27 commits · 极活跃 | 从 xylferp squash 后深度定制 · 独有 58 controller(Ymetal 贵金属 / Ep* 妈祖商城 / Xipu 跨接) · 加了 pt_type 第三层租户 |
| 囍铺 | xipu_erp_php | 2026-02-09 | 17 | 75 | 10 commits · 客户正在生产 | 从 xylferp squash · 独有金价 3 方法 + 阿里云图搜 2 controller |
where('aid') 用了 1896~2285 次(pyerp 最多)· BaseController 里 define('aid', $aid) 从 token 回填 · 妈祖还有 bid(门店/业务)+ pt_type(产品线)三层app_xiao_shou_kotlin(UniApp,669 commits)已经在做多客户 App 壳 · SERVER_LIST 已定义"周锦记(Kotlin 后端)"和"妈祖(PHP 后端)"两种类型 · 但 pyerp_app 和它 md5 完全不同,是两个独立项目sprin-boot-kotlin219 有 52 个 controller,6 模块(api / member / order / print / shop / user),命名空间 rn.rw.erp⚠️ v8 → v9 修订原因
v8 §2.2 只做了 controller 文件级 md5 diff,假设了 4 家 fork 血缘相同 = 数据契约相同。执行到 Phase 2B 时暴露:妈祖 DB schema 与其他 3 家完全不同,主干 login 逻辑覆盖不了妈祖。v9 补此对比表 + Phase 2B v2 修复方案(见 §11)。
| 契约字段 | 龙凤 xylferp | 摆件 baijian | 囍铺 xipu | 妈祖 pyerp |
|---|---|---|---|---|
| 表前缀 | xipunum_ | xipunum_ | xipunum_ | is_ |
| user 表名 | xipunum_erp_user | 同 | 同 | is_user |
| 密码字段 | password | 同 | 同 | pwd |
| 密码盐 | bwqinr | xipunum → 已迁 bwqinr | xipunum → 已迁 bwqinr | erpsm |
| 密码算法 | md5(pwd+salt) | 同 | 同 | md5(md5(pwd)+salt) 双重 |
| Login 入口 | Api::login | 同 | 同 | Api::login + Apiadmin |
| 用户过滤 | state=0 + aid + bid + department | 同 | 同 | 无 state · aid/bid header |
🎯 教训条(v9 新增)
数据契约 first · 代码血缘 second。任何跨仓合并战略,方案必须先做数据契约对比表(schema / 字段名 / 密码算法 / 认证过滤逻辑),再做代码血缘 md5 diff。反过来做 = v8 到 v9 的痛苦教训。
nexora_platform 存 tenants / modules / users(跨租户账号)nexora_<slug>)· 备份/升级/隔离独立执行时遇到"这个 repo 要不要动 / 怎么动"的问题,以本表为准。Action 列 = 强制约束。
nexora/nexora-erp,原 repo 完成后归档 read-only| Repo | Action | 阶段 | 说明 |
|---|---|---|---|
| xylferp_php | MERGE | Phase 1 | 骨架起点 · clone 为 nexora-erp |
| xylferp_web | MERGE | Phase 1 | 前端骨架起点 · clone 为 nexora-erp-web |
| baijian_php | MERGE | Phase 2 | 验证「加租户」流程 |
| baijian_web | ARCHIVE | Phase 2 | 90 天 0 commits |
| pyerp_php | MERGE | Phase 2 | 主战场 · Ymetal / Ep* 抽模块 · pt_type 合入主干 |
| pyerp_web | MERGE | Phase 2 | 妈祖前端合入 nexora-erp-web |
| xipu_erp_php | LATER | Phase 3 | 73/75 controller 在龙凤里 · 视客户情况 |
| xipu_erp_web | LATER | Phase 3 | 同 xipu_erp_php |
| Repo | Action | 阶段 | 说明 |
|---|---|---|---|
| pyerp_shop_php | MERGE | Phase 3 | 唯一商城基座 · 合入 Nexora mall-sync 模块 |
| pyerp_shop_wx | MERGE | Phase 3 | 唯一小程序基座 · 未来所有小程序基于此 |
| baijian_shop_php | ARCHIVE | Phase 3 | 20G · 客户已停 |
| Repo | Action | 阶段 | 说明 |
|---|---|---|---|
| app_xiao_shou_kotlin | KEEP | Phase 3 | 669 commits 多客户 UniApp 壳 · Phase 3 演化为 Nexora 统一手机端 · Phase 1-2 保留原样 |
| pyerp_app | KEEP | Phase 3 | 妈祖手机端(和 app_xiao_shou_kotlin 目前无代码合并)· Phase 3 把功能重写进后再归档 |
| kotlinapi_uniapp | KEEP | Phase 3 | 囍铺原版手机端 · 已被 app_xiao_shou_kotlin 演化替代 · Phase 3 直接归档 |
| sprin-boot-kotlin219 | KEEP | Phase 3 | Kotlin 后端 · Phase 3 变薄:52 controller → ≤15 |
| sprin-boot-kotlin219-xylf | KEEP | Phase 3 | 2 commits 分叉 · 合入主线 |
| fastify_spring_app_api | ARCHIVE | Phase 3 | 废弃分支 |
| fastify_ts_app_api | ARCHIVE | Phase 3 | 废弃分支 |
| Repo | Action | 说明 |
|---|---|---|
| laiya_php | DROP | 莱雅商城主站 · 客户不再使用 |
| laiya_wx | DROP | 莱雅商城主小程序 |
| laiya_jxs_wx | DROP | 莱雅经销商小程序 |
| laiya_lss_wx | DROP | 莱雅零食师小程序 |
| lyshop_wx | DROP | 莱雅关联小程序 |
| hjhs | DROP | 黄金回收(CRMEB)· 客户已停 · 不同基因不合并 |
| Repo | Action | 说明 |
|---|---|---|
| pyerp_php_api | ARCHIVE | 2 commits · API 抽出实验未成气候 |
| pyerp_shop_h5_dist | OUT-OF-SCOPE | 前端构建产物 |
| wl_php / wl_web | OUT-OF-SCOPE | 未评估(疑似"物流") |
| waimao_php | OUT-OF-SCOPE | 未评估(疑似"外贸") |
| supererp / hjt / xipu_new | OUT-OF-SCOPE | 历史产物 · 未评估 |
| electron_exe / python-auto-print-desktop / password | OUT-OF-SCOPE | 桌面工具 |
| 模块 | 包含内容 |
|---|---|
| account | 账号 / 角色 / 权限 · 复用现有 aid/bid/pt_type 上下文注入 |
| feature-flag | 模块级 + 菜单级功能开关引擎 · 读中心库 tenant_modules / tenant_features |
| product | 商品 / SKU / 分类 · 通用 54 controller 里商品相关部分 |
| inventory | 库存 / 库位 / 调拨 / 盘点 / 出入库 · 含龙凤零售出库特有的 outboundBack/queryIsTuihuo |
| order | 采购 / 销售 / 审核状态机 / Bill / 财务 |
| store | 门店 / 仓库 / 收银 |
| supplier-customer | 供应商 / 客户 / 联系人 / 会员 |
| 模块 | 建于 | 包含内容 · 目标租户 |
|---|---|---|
| aliyun-image | Phase 1 | 阿里云图搜 + OSS(囍铺 2 独有 controller)· 未来给囍铺租户用 |
| ymetal | Phase 2 | 贵金属:50 个 Ymetal* controller + 40+ 张表 + 18884 行代码 · 妈祖专属 |
| myhw-shop | Phase 2 | 妈祖商城对接:Epuser / Eprole / Epservice / Epxipu* · 妈祖专属 |
| gold-price | Phase 3 | 金价管理(get/save_gold_price + get_gold_code)· 囍铺独有,合入时抽出 |
| mall-sync | Phase 3 | ERP ↔ 妈祖商城对接:products.sync / orders.push / stock.query |
| mobile-api | Phase 3 | Kotlin 变薄后的 WS + 打印 + 推送对接 |
每个模块目录 modules/<name>/ 自包含:
nexora/nexora-erp 主干,把龙凤客户作为第一个租户接入,验证「一份代码 + 一租户一库」框架成立。
nexora group 已建(gid=75, private)· 已完成ssh://git@gitlab.nexora.restry.cn:18826/nexora/nexora-erp.git
# 从本地 clone xylferp,推到 nexora-erp
git clone ssh://git@gitlab.nexora.restry.cn:18826/xipugold/xylferp_php.git nexora-erp
cd nexora-erp
git remote set-url origin ssh://git@gitlab.nexora.restry.cn:18826/nexora/nexora-erp.git
git push -u origin main --tags
curl -s $URL/api/v4/projects/nexora%2Fnexora-erp 返回 200git log --oneline | wc -l ≥ 113(保留原始 git 历史)
git clone ssh://git@gitlab.nexora.restry.cn:18826/xipugold/xylferp_web.git nexora-erp-web
cd nexora-erp-web
git remote set-url origin ssh://git@gitlab.nexora.restry.cn:18826/nexora/nexora-erp-web.git
git push -u origin main --tags
curl -s $URL/api/v4/projects/nexora%2Fnexora-erp-web 返回 200CREATE DATABASE nexora_platform DEFAULT CHARSET utf8mb4;
USE nexora_platform;
CREATE TABLE tenants (
id INT PRIMARY KEY AUTO_INCREMENT,
slug VARCHAR(32) UNIQUE NOT NULL, -- xylferp / baijian / pyerp / xipu
name VARCHAR(128) NOT NULL, -- 龙凤 / 摆件 / 妈祖 / 囍铺
db_dsn VARCHAR(255) NOT NULL, -- mysql://user:pass@host:port/nexora_xylferp
aid INT NOT NULL, -- 该租户在业务库的 aid 值(防呆参考)
bid INT DEFAULT 0,
pt_type TINYINT DEFAULT 1,
status ENUM('active','paused','archived') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tenant_modules (
tenant_id INT,
module VARCHAR(64), -- aliyun-image / ymetal / myhw-shop / gold-price / mall-sync / mobile-api
enabled TINYINT DEFAULT 1,
PRIMARY KEY (tenant_id, module)
);
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(128)
);
CREATE TABLE user_tenants (
user_id INT,
tenant_id INT,
role VARCHAR(32),
PRIMARY KEY (user_id, tenant_id)
);
CREATE TABLE schema_migrations (
version VARCHAR(64) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SHOW TABLES FROM nexora_platform 应返回 5 张表
# 备份龙凤当前 DB
mysqldump -u<user> -p xylferp | gzip > xylferp_$(date +%Y%m%d).sql.gz
# 恢复到 nexora_xylferp
gunzip -c xylferp_*.sql.gz | mysql -u<user> -p nexora_xylferp
# 中心库注册龙凤租户
INSERT INTO nexora_platform.tenants (slug, name, db_dsn, aid, bid, pt_type)
VALUES ('xylferp', '龙凤', 'mysql://...@.../nexora_xylferp', 1, 0, 1);
SELECT COUNT(*) FROM nexora_platform.tenants WHERE slug='xylferp' = 1SELECT COUNT(*) FROM nexora_xylferp.is_menu 数量和原 xylferp DB 一致
原 xylferp 的 aid/bid/pt_type 从 token 回填。改造后:token 里带 tenant_slug,BaseController 先查中心库 tenants 拿到 db_dsn + aid/bid/pt_type,再切到对应租户库。
/api/user/info,返回数据来自 nexora_xylferp从 xipu_erp_php 复制 Aliyunimagesearch.php + Aliyunoss.php 两个 controller,放到 modules/aliyun-image/,包括路由/菜单/权限声明。龙凤租户默认不启用。
grep -r "Aliyunimagesearch" app/controller/ 应无输出(不在主干)SELECT * FROM tenant_modules WHERE tenant_id=1 AND module='aliyun-image' 应返 0 或 enabled=0选一台服务器(建议内网 dev 环境优先),部署 nginx + PHP + nexora-erp 代码,前端部署 nexora-erp-web。域名先用 nexora-dev.internal。
curl -sI http://nexora-dev.internal/api/health 返回 200写 4 个脚本:tenant-onboard.sh / migrate-all.sh / backup-tenants.sh / monitor.sh。放到 nexora-erp/bin/。
bin/tenant-onboard.sh test 能建出 nexora_test DB 并注册到 tenants(用完删掉)bin/backup-tenants.sh 能在 /backup/tenant/xylferp/ 下生成 .sql.gz
grep -rE "aliyun|Aliyun" app/controller/ 主干目录零命中bin/tenant-onboard.sh baijian --name "摆件" --aid 2
SELECT * FROM nexora_platform.tenants WHERE slug='baijian' 返回 1 行 · status=activeSHOW DATABASES LIKE 'nexora_baijian' 存在
mysqldump -u<user> -p baijian > baijian.sql
mysql -u<user> -p nexora_baijian < baijian.sql
# 校验 aid 一致
UPDATE nexora_baijian.is_user SET aid=2 WHERE aid != 2; -- 全部对齐到租户 id
SELECT DISTINCT aid FROM nexora_baijian.is_user 只返回 2
用摆件账号登录 Nexora 前台,把主要业务流程走一遍。如果发现主干缺失能力(baijian 独有 0 个 controller,理论应完美复用),记录并补主干。
把 pyerp_php 里 50 个 Ymetal*.php controller + 相关 model + 40+ 张 Ymetal_* 表 migrations 全部搬到 modules/ymetal/。定义 Facade。清理主干里对 Ymetal 类的直接引用。
grep -rE "Ymetal|ymetal" app/(主干目录)零命中ls modules/ymetal/controller/ | wc -l = 50ls modules/ymetal/migrations/*.sql | wc -l ≥ 40
把 pyerp_php 里 Epuser / Eprole / Epservice / Epxipu* 系列 controller 搬到 modules/myhw-shop/。
grep -rE "^use.*Ep(user|role|service|xipu)" app/ 零命中pyerp 里的 Xipu.php 里查 erp_category 表 —— 是否仍在生产使用?
modules/myhw-shop/ 或单独模块 myhw-xipu-bridgemodules/DECISIONS.md · Xipu.php 归位pyerp 独有的 pt_type(产品线维度,1/2/3/4)合入主干 BaseController 的上下文注入。所有租户获得 pt_type 能力(默认值 1)。
define('pt_type' 有 1 处pyerp 的 BaseController.php 和 common.php md5 已与其他家不同。做 3-way diff,把有价值的公共逻辑(sqlAuth / getBaseRoot / frameScope / erpPostInput 等)合入主干,妈祖专属逻辑放模块。
app/common.php 覆盖 pyerp / xylferp / xipu 三家的公共函数需求modules/myhw-shop/common.php 只放妈祖专属
bin/tenant-onboard.sh pyerp --name "妈祖" --aid 1 \
--modules ymetal,myhw-shop
# 生产 pyerp DB → nexora_pyerp(选低峰,冻结原库)
mysqldump -u<user> -p pyerp | gzip > pyerp_$(date +%Y%m%d).sql.gz
gunzip -c pyerp_*.sql.gz | mysql -u<user> -p nexora_pyerp
SELECT COUNT(*) FROM nexora_platform.tenant_modules WHERE tenant_id=(SELECT id FROM tenants WHERE slug='pyerp') = 2SELECT * FROM nexora_pyerp.Ymetal_原料库存 LIMIT 1 能查到数据(模块 migrations 生效)
SELECT DISTINCT aid FROM nexora_pyerp.is_sell 出现非 1 的值)
grep -rE "Ymetal|Ep(user|role|service)" app/ 零命中cd sprin-boot-kotlin219
grep -rl "@RestController" --include="*.kt" | while read f; do
echo "== $f =="
grep -E "@GetMapping|@PostMapping" "$f" | head -20
done > kotlin-audit.md
每个 controller 分类:KEEP(WS / 打印 / 推送 / job)/ MIGRATE(业务 CRUD,迁到 PHP)。
对每个 MIGRATE controller,在 Nexora 主干或对应模块里实现相同的接口(路径 + 参数 + 返回结构)。
修改 SERVER_LIST,把囍铺/周锦记的 backend 从 kotlin 改为 php,请求走 Nexora 主干。
MIGRATE 类 controller 从 Kotlin 后端删除 · 只保留 KEEP 类。
grep -rl "@RestController" sprin-boot-kotlin219 | wc -l ≤ 15Kotlin 后端要写数据时,通过 HTTP 调 Nexora PHP 主干 API。DB 连接改成只读账号。
xylf 分叉只有 2 commits,cherry-pick 差异合入 sprin-boot-kotlin219,归档 -xylf。
-xylf repo GitLab 标 archivedSERVER_LIST 里"妈祖"/"周锦记" 等 host 改为 Nexora 主干域名。
pyerp_app 独有页面/组件(妈祖手机端专属)逐个移植。
GitLab 上把两个 repo 标 archived。
在 modules/mall-sync/ 定义 3 条 API:
POST /mall-sync/products.sync — ERP → 商城推商品/SKU/价格POST /mall-sync/orders.push — 商城 → ERP 推订单GET /mall-sync/stock.query — 商城 → ERP 查实时库存modules/mall-sync/docs/
移除 pyerp_shop_php 里对 ERP 库表的直查 · 全部改为 curl mall-sync API。
grep -rE "Db::connect|Db::name.*is_" pyerp_shop_php/application/ 应零命中如果决定把商城代码也纳入 nexora group,创建 nexora/nexora-mall · 从 pyerp_shop_php clone · 否则 pyerp_shop_php 原样保留在 xipugold。
modules/mall-sync/DECISION.md查最近 30 天囍铺业务活跃度(登录用户数 / 单据量 / 客户投诉) + 客户业务方向。
retiredDECISION-xipu.md一租户一库的代价 = 运维成本随客户数线性增长。必须把每个环节脚本化。Phase 1 就要落地最小可用版,后续 Phase 持续增强。
位置:nexora-erp/bin/tenant-onboard.sh
tenant-onboard.sh <slug> \
--name "<中文名>" \
--aid <租户 id> \
[--modules module1,module2,...]
# 一次做 4 件事:
# 1. 建 nexora_<slug> DB
# 2. 跑核心模块 migrations
# 3. 按 --modules 跑可选模块 migrations
# 4. 中心库 tenants + tenant_modules 注册
验证:执行完 30 秒内新租户能登录,前台菜单只显示启用的模块。
位置:nexora-erp/bin/migrate-all.sh
migrate-all.sh [--dry-run]
# 遍历中心库 tenants,对每个租户 DB:
# 1. 检查 schema_migrations 表
# 2. 执行未跑过的 migration
# 3. 失败即停,输出报错(不允许"跑了一半就走人")
# 4. 只跑该租户启用模块的 migrations
验证:主干加 1 张新表,跑 migrate-all,所有租户 DB 都出现该表。
位置:nexora-erp/bin/backup-tenants.sh(cron 每晚 03:00)
backup-tenants.sh
# 遍历中心库 tenants,每个租户:
# 1. mysqldump | gzip → /backup/tenant/<slug>/<date>.sql.gz
# 2. 保留策略:daily 7 天 · weekly 4 周 · monthly 6 月
# 3. 校验:大小比昨天缩水 >20% 报警
# 4. 校验:gunzip -t 能完整解压
验证:任一租户 ls /backup/tenant/<slug>/ 有当日备份文件。
位置:nexora-erp/bin/monitor.sh(cron 每 5 分钟)
monitor.sh
# 按租户视角检查:
# - 各租户 DB 可连接
# - 各租户前台 /api/health 返回 200
# - 备份状态(最近 24h 内有成功备份)
# - 慢查询(> 1s)计数
# - 磁盘用量 > 80% 报警
# 异常发飞书告警
验证:手动停一个租户 DB,5 分钟内收到告警。
执行时任何"顺便把 X 也做了"的问题,以本节为准。不在 §04 总表 + §06-08 路线图里的东西,默认不做。
v10 决策:妈祖 pyerp 客户端账号迁到龙凤主干(schema 冲突时以龙凤为准)· 商城代码不动 · ERP 换 Nexora 前端。 拆分方案由 v11 §13 迭代 · 已由 §14 落地 (6 家独立租户)。
v10 决策:平台超管入口复用龙凤 admin(platform_admin 标记)· 不建独立后台。
已由 §14/§15 落地 · PlatformAdmin 8 action controller · /platform-admin.html UI 完备。
v9 假设 pyerp 单租户 · 建单库合并 → v10 review 发现 is_goods.aid 有 10 个租户 → v11 §13 决策拆 6 家独立租户。
拆分方案已由 §14 落地(Phase 2C 执行完成)· 详见 §14 现状快照。
📸 v14 修正
v11 版曾声称有 10 家租户 · 那是基于错误的架构理解建的空壳。真实生产架构是两条产品线(见 §18)· Nexora 目前只承载珠宝线 3 家 · 原料线 6 家仍在原 myhw_py99999_com 生产库。
| slug | 品牌 | URL | nexora_ 库 | 生产库 | 数据一致? |
|---|---|---|---|---|---|
xylferp |
龙凤 | xylferp.erp.nexora.restry.cn |
nexora_xylferp(4.8 MB) | xylferp(4.8 MB) | ✅ 100% schema 一致(0 张真差异 · 之前 dump 报的 116/118 是 AUTO_INCREMENT 假差异) |
baijian |
摆件 | baijian.erp.nexora.restry.cn |
nexora_baijian(6.8 MB) | baijianerp(6.8 MB) | ✅ 100% schema 一致(0 张真差异 · 同龙凤) |
xipu |
囍铺 | xipu.erp.nexora.restry.cn |
nexora_xipu(schema) | xipu-mysql/xipuerp(独立容器) | ✅ schema 一致(117 表 · 用生产 schema 重建 · admin/menu/frame 已灌 · 真登进 dashboard 品牌"囍铺黄金"+ 金价配置 · 数据未迁) |
这 6 家客户按 aid 共存于 myhw_py99999_com 单库(2026-06-14 唐润辉合并方案上线)· schema 是 is_*(原料 ERP)· 与珠宝线 xipunum_erp_* 完全不同。需要 §18 阶段 B 让 Nexora 平台接管这条线。
| slug | 客户 | aid | 用户 | 商品 |
|---|---|---|---|---|
fjfl | 府见福礼 | 37 | 7 | 472 |
qywh | 清屿文化 | 52 | 2 | 351 |
ftgf | 凤天工坊 | 50 | 3 | 11 |
qyxs | 清屿销售 | 39 | 2 | 5 |
heian | 黑暗集团 | 19 | 3 | 3 |
? | aid=21(妈祖 · 待确认) | 21 | 5 | - |
*.erp.nexora.restry.cn · on_demand_tls · 新租户 0 配置*.erp.nexora.restry.cn · SNI 白名单已加9 家全部使用子域名 · 每家独立 host · localStorage 天然按域名隔离(多 tab 不串号)。老 URL 保留兼容。
| 类型 | URL |
|---|---|
| 租户入口 | https://<slug>.erp.nexora.restry.cn |
| 平台管理 | https://xylferp.erp.nexora.restry.cn/platform-admin.html |
| 兼容老 URL | https://nexora-erp.nexora.restry.cn/?tenant=<slug> |
平台超管在「租户列表」每行「编辑」按钮打开 modal · 可改以下 6 字段。
| 字段 | 存储位置 | 显示在 |
|---|---|---|
| 租户名 | nexora_platform.tenants.name | 平台管理租户列表 |
| 品牌名 | <租户库>.xipunum_sys.name | 前端 header |
| 公司全称 | <租户库>.xipunum_sys.company | 单据/报表落款 |
| ICP 备案号 | <租户库>.xipunum_sys.icp | 页脚 |
| 公告 | <租户库>.xipunum_sys.notice | 前端首页顶部 |
| 状态 | nexora_platform.tenants.status | active / archived |
不可改:slug / db_dsn / table_prefix / aid(锚点字段 · 改了要迁库)。
硬约束:xylferp 不能归档(平台超管账号所在租户)。
| 组件 | 文件 | 配置/接口 |
|---|---|---|
| frpc | /etc/frp/frpc.ini | custom_domains 含 *.erp.nexora.restry.cn |
| Caddy | /home/claw/lobster-platform/caddy/Caddyfile | 9 家 site block · (tenant_site) snippet import |
| Tenant.php | app/nexora/Tenant.php | detectSlug() 从 host parts[0] 取 slug |
| 后端接口 | app/controller/PlatformAdmin.php | POST /PlatformAdmin/tenantUpdateGET /PlatformAdmin/tenantDetail?slug= |
| 前端 UI | dist/platform-admin.html | 租户列表「编辑」按钮 → modal 表单 |
从 v9 假设错、v10 review、v11 §13 拆分、v11.2 URL 改造、v14 后台管理 一路走下来 · 这些经验可以直接用到别的项目(其他 ERP 合并 / SaaS 拆分 / 域名迁移)。
| 教训 | 具体 |
|---|---|
| 数据契约先于代码血缘 | v9 假设 pyerp 是单租户 · 直到 v10 review 才发现 is_goods 表按 aid 分 10 家客户。合并前必 SQL 里 GROUP BY tenant/aid 看多租户信号 · 光看 controller md5 是不够的。 |
| 一份代码 + 模块开关 | Nexora 主干 = 核心 + tenant_modules 表控制启停 · 4 家老客户 + 6 家新拆 = 一份代码(仅囍铺 3 模块 · pymetal 1 模块 · 龙凤 1 特权)。避免 fork 出 4 份代码维护地狱。 |
| 锚点字段禁改 | slug / db_dsn / table_prefix / aid 是数据锚点 · 改了要迁库。UI 层 disabled + 后端 tenantUpdate 硬拒 + 不能归档 xylferp(超管所在)。 |
| SaaS 用子域名 | v11.1 前用 ?tenant=xxx 参数 · 一浏览器只能登一家。v11.2 改子域名 · localStorage 天然按域名隔离 · 多 tab 无痕都不用。行业标准(Notion / Slack / Figma / Airtable 一致)。 |
| 禁写死名单 | Caddy 首版硬编码 9 家 slug · 建第 10 家立即崩。改成 *.erp 通配 + on_demand_tls + ask endpoint (校验中心库 slug 有效) · 建新租户 0 修改 Caddy。 |
| 教训 | 具体 |
|---|---|
| API 200 ≠ UI 可用 | 端到端验证必走 headless chrome + vision 判读。E2E 测试建租户时抓到了 Caddy 硬编码 bug · 光看 tenantCreate API 返 success 是发现不了的。 |
| 逐个测 · 不推断 | 9 家租户全部单独 login + dashboard 截图 · 别只测 1 家推 N 家。差异化模块(gold-price / ymetal / platform-admin)只有真登进去才能验。 |
| 老 URL 保留兼容 | v11.2 子域名改造保留了老 nexora-erp/?tenant= · 无破坏性 · 老书签/前端 hardcode 不会立即失效。 |
| frps SNI 白名单 | 235 通过 frpc 转发公网 · custom_domains 是白名单不是通配。调 TLS 问题先查全链路白名单(frps → Caddy → 后端) · 别死磕 Caddy。 |
| 建库脚本落 controller · 不 shell out | 首版 tenantCreate 用 shell_exec 跑 python · 不好调试。重构成 PHP PDO 内联 · 事务/错误处理全在同一个函数 · 好维护。 |
SELECT COUNT(DISTINCT tenant_key) FROM main_tables 判断是不是 SaaS<slug>.app.example.com + 通配 cert + on_demand_tls + ask endpointplatform_admin 标记 · 不建独立后台(避免账号双系统)tenant_modules(tenant_id, module, enabled) · ModuleRegistry 注入菜单/路由/守卫custom_domains 提前加 *.<subzone>.example.com · 不然每加一家改一次🎉 Y1-Y5 完成(2026-07-08)· 6 家原料客户上 Nexora · e2e 通
遵循 "通用同 schema · 业务分模块" 原则 · 6 家全部在 Nexora 主干代码 + 独立 nexora_<slug> 库 + 标准 xipunum_ schema 下跑起来。**pymetal 独享 ymetal 模块**(左菜单多"贵金属")· 其他 5 家标准菜单。
| 步 | 动作 | 结果 |
|---|---|---|
| Y1 | 建 6 家 nexora_<slug> · 标准 xipunum_ schema | ✅ 6 × 119 表 · 从 nexora_xylferp clone |
| Y2 | 灌 metadata + admin + 品牌 | ✅ 每家 85 菜单 · 26 岗位 · 2 frame · admin/123456 · sys.name 各异 |
| Y3 | 中心库注册 + pymetal 启用 ymetal 模块 | ✅ tenants 表 10 家(4 珠宝 · 6 metal)· tenant_modules 里 pymetal=ymetal enabled |
| Y4 | metal-inventory 模块抽取 | ⏭️ 跳过(通用 schema 已满足 6 家 UI · 有需求时再抽) |
| Y5 | headless chrome + vision 验证 | ✅ 6/6 dashboard 全通 · vision 判读:品牌各异 + pymetal 独有"贵金属"菜单 |
6 家原料客户 dashboard · 一份代码 · 6 家独立 nexora_<slug> · pymetal 独有"贵金属"菜单
| 现象 | 根因 + 修 |
|---|---|
heian.metal.nexora.restry.cn LE cert issue 挑战失败 · 30s timeout |
WAF 拦截 · heian(黑暗)中文含义敏感 · 阿里云边缘 reset 掉 http-01 挑战。**修**:slug 改成 darkgroup · 立刻通 |
is_user.pwd → nexora xipunum_erp_user.password 加盐 + is_user.role → department 值近似)核心原则(vol 2026-07-08)
| 表 | 用途 | 状态 |
|---|---|---|
xipunum_erp_user | 用户 · password(md5+bwqinr) · department · google/google_secret(2FA) | ✅ 珠宝 3 家已有 |
xipunum_erp_role | 岗位 · 权限位 | ✅ |
xipunum_menu | 菜单树 | ✅ |
xipunum_frame | 组织框架 | ✅ |
xipunum_warehouse | 仓库/门店 | ✅ |
xipunum_erp_category | 分类(通用维度) | ✅ |
xipunum_sys | 租户信息(name/company/icp/logo) | ✅ |
nexora_platform.* | 中心库(tenants / tenant_modules / platform_admin_log) | ✅ |
| 模块 | product_line | 业务 |
|---|---|---|
gold-price | jewelry | 金价管理(囍铺) |
aliyun-image | jewelry | 阿里云图搜(囍铺) |
ymetal | metal | 贵金属结价(妈祖 · 独立 is_ymerp_* 21 表) |
mall-sync | metal | ERP ↔ 商城 API |
myhw-shop | metal | 妈祖商城 · Ep* 9 controller + is_ep_* 6 表 |
tmp-orders | metal | 临时单据 6 controller(Sell/Buy/Bor/Bre/Sor/Sre 的 _tmp 分支) |
mobile-admin | metal | 妈祖 App 管理 3 controller |
inventory-api | metal | 库存/店铺 API |
pos-mall-bridge | metal | 门店 POS + 微信小程序码(pyerp Sell.php 3 独有 method) |
report-async | 通用 | 报表异步导出队列(async_queue 表)· 珠宝/原料都可用 |
mobile-api | jewelry | Kotlin 手机端 API 样板 |
metal-inventory(未建) | metal | 原料库存(is_room / is_summary / is_batch)· 未来 Y3 |
| 层 | 机制 |
|---|---|
| Codebase | nexora-erp 唯一主干 · pyerp_php 归档下线 |
| 租户识别 | Tenant::resolve() 从 host 拿 slug · 中心库拿 db_dsn + product_line |
| 数据隔离 | 一租户一库(nexora_<slug>)· 物理隔离 · 无需 aid 过滤 |
| 通用 schema | 所有 nexora_<slug> 库都用 xipunum_erp_user/menu/role/frame/warehouse · 主干代码硬编码 |
| 业务 schema | 按 tenant_modules 启用 · 模块自带 migration(如 ymetal 建 is_ymerp_* 21 表) |
| 业务代码 | 按模块拆到 modules/<name>/controller/ · 主干仅通用能力 |
| URL | 珠宝 *.erp.nexora.restry.cn · 原料 *.metal.nexora.restry.cn |
| 前端 | 按 product_line 决定 SPA(珠宝 dist / 原料 dist)· 都用同一份用户/菜单 API |
合并的价值:一份代码 · 一套通用能力 · 业务差异走模块开关。类比 Odoo:一份代码 · 装不同模块变成 CRM / ERP / 电商 / 库存 · 但用户/权限/公司都是同一套。不是"用一份代码兼容多种非标 schema"(那样在维护差异 · 不是消除差异)。
规则:每个"建完"里程碑必有对应"测完"里程碑(外部独立子代理无本次会话记忆验证)· 避免自己干活自己测。
| 里程碑 | 状态 | 交付 |
|---|---|---|
|
M1-ERP-READY
git tag · 07177cc
|
✅ 完成 2026-07-08 |
ERP 主干建完 · 一份代码 · 通用同 schema · 业务分模块
|
| M2-ERP-TEST-READY | ✅ 完成 FULL 24/24 |
ERP e2e 深度业务测试 · 24/24 案例全过 · 独立子代理外部验证
|
| M2.5-WEB-UNIFIED-READY | ✅ 完成 adafd1a |
前端 5 fork 合并统一 nexora-erp-web + 功能页系统审计修复
|
| M3-MOBILE-READY | ⏳ 待做 |
手机端合并建完 · Kotlin + uniapp 双 schema 归一到主干
|
| M4-MOBILE-TEST-READY | ⏳ 待做 |
手机端 e2e 验证通过 · 独立子代理外部验证 M3
|
| M5-MIGRATION-TEST-READY | ⏳ 待做 |
数据迁移演练通过 · dry-run + 抽样比对
|
| M6-ALL-READY | ⏳ 待做 |
生产全线 GA · 灰度切完 + pyerp-php 下线
|
方法:登录每家 ERP · 逐个打开左侧菜单每个功能页 · headless chrome + vision 判读是否 404 / 空白 / 报错。 教训:审计脚本一度手动映射路由,掩盖了"从菜单点会 404"的真问题 —— 后续改为用菜单里的 resource 直接跳转(模拟真实点击),9 家全部重审。
✅ 9 家全部审计完成 · 318 个功能页全绿
| 租户 | 产品线 | 功能页 | 结果 |
|---|---|---|---|
| xipu 囍铺 | 珠宝 | 40 | ✅ 全通过 |
| xylferp 龙凤 | 珠宝 | 37 | ✅ 全通过 |
| baijian 摆件 | 珠宝 | 33 | ✅ 全通过 |
| pymetal 妈祖贵金属 | 原料 | 33 | ✅ 全通过 |
| fjfl 府见福礼 | 原料 | 35 | ✅ 全通过 |
| darkgroup 黑暗集团 | 原料 | 35 | ✅ 全通过 |
| qyxs 清屿销售 | 原料 | 35 | ✅ 全通过 |
| ftgf 凤天工坊 | 原料 | 38 | ✅ 全通过 |
| qywh 清屿文化 | 原料 | 32 | ✅ 全通过 |
本轮修复的 8 类 bug
| 问题 | 根因 → 修复 | commit |
|---|---|---|
| 打印模板空白 | M2.5 合并漏拷 PrintConfig.vue + src/print/ 引擎(37 文件)→ 补齐 | 8c22544 |
| 入库订单表 [9999] | 建租户 admin data 字段 NULL → getWarehouse SQL 收到空数组崩 → 补 data=全部(7 家 + 建租户 SQL 固化) | 81b3284 |
| 贵金属看板"加载失败" | Ymetalapi 无 dashboard 方法 + 模块化路由下 ApiAuth 用 controller()/action() 判权限失效 → 补方法 + pathinfo 白名单 | aa77b09 |
| 菜单点 OSS/图搜/金价/贵金属 404 | 4 个模块菜单 resource 存后端接口路径而非前端 route → 全改前端 SPA route | a0de735 |
| 平台管理 3 子页 404 | 模块启停/平台超管/系统监控 只有菜单没前端页(后端 API 早齐全)→ 实装 3 个 Vue 页面 | adafd1a |
系统性发现
两类反复出现的坑:①菜单 resource 存错 —— 模块菜单里存的是后端接口路径,前端 vue-router 不认识就 404,必须存前端 route。
②ApiAuth 对模块化路由失效 —— request()->controller()/action() 在 modules/ 下返回空,导致 $url="/",白名单和菜单匹配全失效。已给只读接口加 pathinfo 白名单兜底,以后新加 modules 接口要注意。
目标:把手机端收敛为唯一 App 单体 app_xiao_shou_kotlin + 唯一后端 nexora-erp PHP(多租户),与 ERP 后端"一份代码 + 通用 schema + X-Tenant-Slug 租户头分流"对齐。基准 commit 9b5f67d(Phase 3B.1)。
🚀 执行进展(2026-07-09)
nexora/nexora-app(基于 app_xiao_shou_kotlin · 670 提交历史保留 · main 分支)· 老库不动3faedd6)· 后端契约实测:xylferp→龙凤 / baijian→摆件 · 错/缺 slug 后端 TENANT_NOT_RESOLVED 拒绝(防串库)深度侦察(/tmp/mobile-recon/REPORT.md)结论:主力 app_xiao_shou_kotlin 已是多 ERP 分流单体,靠登录用户 pt_type 运行时分流到不同业务模块 + 不同后端。
| App | 身份 / 处置 |
|---|---|
| app_xiao_shou_kotlin | 唯一活跃主力(670 提交,活跃到 2026-07-06)· 含 xerp囍铺/jerp+serp莆阳/merp妈祖占位 · 保留为唯一手机端 |
| pyerp_app | 莆阳原料线 · jerp/serp 55 页已逐字节 md5 一致并入主力 · 已是历史快照,merp 待搬后归档 |
| kotlinapi_uniapp | 被取代的旧双 tabbar 架构(停在 2026-04-13)· 归档 |
三大真缺口
http.js 拦截器没注入租户头,Phase 3B 半成品pages/merp/ 只剩 appEvent.js 占位| 阶段 | 核心动作 | 风险 | 里程碑 tag |
|---|---|---|---|
| P0 清理 | kotlinapi + pyerp_app 归档(先 diff 防丢) | 低 | MOBILE-P0-CLEAN |
| P1 租户头 | http.js 注入 X-Tenant-Slug + 端到端验证(补 3B 半成品) | 中·串库 | MOBILE-P1-TENANT |
| P2 后端收编 | Kotlin 7782/7783 接口迁 nexora-erp PHP | 高·WS/报表/秤 | MOBILE-P2-BACKEND |
| P3 妈祖并入 | 搬 merp 页面 + 统一请求栈 | 中·回归 | MOBILE-P3-MAZU |
P0 · 清理归档
xylf/* 逐页 diff 主力 xerp · 交付逐页对照表 · 验收:每页标"已覆盖/需补"P1 · 打通租户头(最高优先级)
http.js 拦截器:backend==='nexora' 时注入 X-Tenant-Slug + 缺 slug 断言拒发 · 验收:grep 有实际代码非注释,pt_type=1 不受影响X-Tenant-Slug: xylferp 登录 success + 数据确属 nexora_xylferp 库 + 错 slug 后端拒绝P2 · 珠宝线后端收编(最高工作量/风险)
/rwy / gridReport 报表 / netweight 网络秤 · 验收:各有落地方案 + POC 实测P3 · 妈祖 merp 并入 + 请求栈统一
envPathUtil.js PagePath[2] 占位 · 验收:pages.json 注册 merp,pt_type=2 能进妈祖首页pub_utils/http)改走主链路 + backend=nexora tenant=pyerp · 验收:妈祖请求不再走第二套栈loginUtils.js 铁律),防多客户串库灾难(有历史血泪)建议执行顺序:P0 → P1 → P3 → P2(不是 P0→1→2→3)。理由:P2 后端收编工作量/风险最大(WebSocket / gridReport 报表 / netweight 网络秤 PHP 无对等实现),放最后持续演进;先做 P3 让"App 一份代码"先落地。
登录认证从「查租户库验密码」上收到中心库 bcrypt 统一认证。网页端 + 手机端共用同一套登录。 核心设计原则:认证和「能进哪家」在中心库,进去后「能干什么」在租户库——中心库管门禁,租户库管店内工牌。
users 表存全局登录身份(username 带 slug 前缀 · bcrypt 密码);user_tenants 存归属 + 平台角色;user_tenant_map 存「中心库 id ↔ 租户库业务 id」映射。xipunum_erp_user(所有租户 · 一份 schema),仍被业务表 cjuser/salesperson 引用;降级为业务档案——不再存/管密码。{slug}_{user} → 中心库 bcrypt 验密码 → user_tenant_map 映射回租户库 user id → token 仍存租户库 id → 下游 18 个 controller 零改动。<slug>.erp/metal.nexora.restry.cn(自动拼前缀);平台超管走独立入口 admin.erp.nexora.restry.cn(username 无前缀 + role=platform_admin · 不进任何租户库)。
pt_type(1 普通/珠宝 · 2 金属)是 xipunum_erp_user 表的一个字段,决定登录后进哪套业务界面。
已给所有租户库补上该字段并回填(jewelry=1 · metal 保留 myhw 源真实值)。
做这次认证时,为了拿 pt_type,我一度把 metal 租户的 tenants.table_prefix 从 xipunum_ 改成 is_(想「珠宝查 xipunum_erp_user / 原料查 is_user」双表分取)。
结果破坏了原本已合并好的 metal 网页端——metal 库里 103 个表全是 xipunum_ 前缀,改 prefix 后业务代码查 is_erp_user/is_menu(不存在)→ store/dashboard 全崩。
根本错误:违背了方案 §18 + Y1 早已钉死的决策——「一份代码 · 通用一份 schema · 所有租户统一 xipunum_」。metal 6 家 nexora 库当初就是从 nexora_xylferp clone 的 xipunum_ 结构,这是有意设计不是失误。
正确做法(已改回):不动 table_prefix(保持 xipunum_),pt_type 作为字段补进 xipunum_erp_user。妈祖 is_user 是老生产库(myhw/ylerp)的表,迁进 nexora 时做格式转换灌进 xipunum_erp_user(Y6:is_user.pwd→password 加盐 · is_user.role→department),不是原样保留 is_user 表。
一句话铁律:囍铺系 xipunum_erp_user / 妈祖系 is_user 是老系统的区别;迁进 nexora 后统一成 xipunum_erp_user 一张表。禁止把 metal 租户 table_prefix 改成 is_。
users 85 条(bcrypt) + user_tenants + user_tenant_map;妈祖 6 家 23 个真实用户从 myhw 迁入各租户库 xipunum_erp_user(格式转换 · 不含业务数据)。app/nexora/CentralAuth.php;Api::login 走中心库认证 + 平台入口分支;token 结构不变。