上一章 :中间件链 + schema 反射两块"地基"打好------能启动、能登录,Model 能读到表结构。 这一章 :造"引擎",全框架最核心的一章------createBusiness 三层装配,回答一个问题:为什么
createBusiness("ops_ticket")一行就能出完整的 CRUD REST API?
答案在这 4 个文件里:
| 模块 | 文件 | 行数 | 职责 |
|---|---|---|---|
| 工厂 | business/index.js | 79 行 | 三层装配 + 扩展点注入 |
| Model | business/model.js | 467 行 | schema 消费 + where 构建 + 数据权限 + CRUD 实现 |
| Service | business/service.js | 60 行 | 薄封装 + tree 配置触发 |
| Controller | business/controller.js | 70 行 | ctx ↔ service 的入参出参翻译 |
三层架构是典型的 Model / Service / Controller,但 Service 极薄(只是 Model 的别名),真正的业务都堆在 Model 里。 为什么?因为 90% 的 CRUD 逻辑都是一样的,Service 层没有差异化。真正需要差异化的地方(联表、复杂查询)用扩展点注入。
3.1 工厂长什么样
createBusiness(tableName, opts) 做的事情:
js
// business/index.js --- 完整源码
function createBusiness(tableName, opts = {}) {
// 1. 合并配置(全局默认 + 本次 opts)
let cfg = { ..._defaults }
for (const k of CONFIG_KEYS) {
if (k in opts && !(k in (opts.config || {}))) cfg[k] = opts[k]
}
if (opts.config) cfg = { ...cfg, ...opts.config }
// 2. 三层装配 ------ 各 factory 接收同一份 cfg + db
const db = opts.db || _db
const model = createModel(tableName, { ...cfg, db })
const service = createService(model, cfg)
const controller = createController(service, cfg)
// 3. 扩展点注入 ------ 支持函数式和对象式两种写法
if (typeof opts.model === "function") {
Object.assign(model, opts.model(model)) // 函数式:接收 model,返回扩展
} else if (opts.model) {
Object.assign(model, opts.model) // 对象式:直接 merge
}
if (typeof opts.service === "function") {
Object.assign(service, opts.service.call(service, model))
// 注意 .call(service, model):扩展里 this = service,model 是第一个参数
} else if (opts.service) {
Object.assign(service, opts.service)
}
if (typeof opts.controller === "function") {
Object.assign(controller, opts.controller.call(controller, service))
} else if (opts.controller) {
Object.assign(controller, opts.controller)
}
return { model, service, controller }
}
三个关键设计:
配置合并策略
css
全局 createBusiness.config({ business: { tree: ... } }) ← 默认值
↓ merge
本次 opts = { tree: ... } ← 表级覆盖
↓ 优先级
opts.config (如果有) ← 最高
CONFIG_KEYS 列出所有"业务配置项"(pk / pkAuto / tree / softDelete / createTime ...),工厂只从 opts 里摘这些 key,其他的(model / service / controller / db)当作扩展点参数。职责分离清晰------配置和扩展不混。
扩展点注入:函数式 vs 对象式
js
// 对象式扩展:直接 merge
createBusiness("ops_ticket", {
model: {
findByTitle: async function (title) { ... }
}
})
// 函数式扩展:可以访问 model 实例本身(推荐)
createBusiness("ops_ticket", {
model: (model) => ({
findByTitle: async function (title) {
// model 就是工厂刚创建的 model 实例
return model.newBuilder().where("title", title).first()
}
})
})
// service / controller 同理,但函数式用 .call(service, model) 绑定 this
createBusiness("ops_ticket", {
service: function (model) {
// this === service, model 是第一个参数
return {
pageWithDetail: async function (query) {
// 可以用 this.mergeRows / model.newBuilder / model.applyQueryFilters
...
}
}
}
})
函数式扩展更强------能拿到工厂刚创建的实例,进而调用其方法(newBuilder / applyQueryFilters / mergeRows)。
工厂挂载方式
js
// app.js 里先挂 db 和全局默认
createBusiness.use(config.db)
createBusiness.config(config.business)
// 然后就可以零参数调用 createBusiness
const ticket = createBusiness("ops_ticket")
.use() 和 .config() 都是返回 createBusiness 本身,支持链式调用。本质上就是闭包里的两个变量:
js
let _db = null
let _defaults = {}
createBusiness.use = (db) => {
_db = db
return createBusiness
}
createBusiness.config = (cfg = {}) => {
_defaults = cfg
return createBusiness
}
3.2 Model 层 CRUD:核心实现
model.js 前半(schema 消费)已经在 02 章讲过了,后半是真正的 CRUD 实现。先看骨架:
js
// business/model.js (CRUD 骨架)
const model = { tableName, pk, softDelete, createTime, updateTime, createBy, updateBy, ... }
// 基础工具函数
model.newBuilder() // → db(tableName) + 自动加软删默认条件
model.getColumns() // → Object.keys(schemaMeta.columns)
model.applyQueryFilters(builder, query, options) // 解析 where/keyword/order/fields/分页
model.allowedColumns(requestedFields) // 数据权限用的列白名单
// CRUD
model.list(query) // → applyQueryFilters → builder.select → 执行
model.count(query) // → applyQueryFilters(跳过 paging/sort/select) → builder.count
model.one(where) // → builder.where(where).first
model.create(data) // → sanitizePayload + 审计字段填充 → db.insert → 返回新记录
model.update(where, data) // → sanitizePayload + 审计字段填充 → builder.where(where).update
model.delete(where) // → 软删: update(softDeleteField=mark) / 硬删: builder.where(where).del
newBuilder:每次查询的起点
js
model.newBuilder = function () {
if (!db) throw new Error("db 未注入")
const builder = db(tableName)
// 自动加软删默认条件:WHERE del_flag = '0'
if (softDeleteField) {
builder.where(tableName + "." + softDeleteField, softDeleteValue)
}
return builder
}
软删条件在 newBuilder 里统一加 ------所有 list/count/detail 自动生效,扩展代码直接 model.newBuilder() 也不会漏。update 和 delete 不会走 newBuilder(因为要手动决定是软删还是硬删、update 是否要受软删保护)。
list:查询 + 数据权限注入
js
model.list = async function (query = {}) {
const builder = model.newBuilder()
// 1. 注入数据权限(如果当前用户有 row_expr)
// admin 用户 resolveDataScope() 返回 null,跳过
await injectDataScope(builder, info.names, info.columnsMeta, {
applyColumns: true
})
// 2. 解析 query(where / keyword / order / fields / 分页)
await model.applyQueryFilters(builder, query, {
applyPaging: true,
applySort: true,
applySelect: true
})
// 3. 执行
return await builder
}
数据权限注入发生在 applyQueryFilters 之前------row_expr 条件是强约束,不能被用户的 where 覆盖。
create:payload 校验 + 审计字段填充
js
model.create = async function (data) {
const ctx = getCtx()
const user = ctx?.state?.user
const now = formatNow()
// 1. 白名单校验 + 类型强转(fail-close)
const payload = sanitizePayload(data, schemaMeta.columns, {
tableName,
readonlyFields,
forbidAutoPk: true
})
// 2. 审计字段自动填充(前端传了也会被 schema-guard 过滤掉)
if (createTime) payload[createTime] = now
if (updateTime) payload[updateTime] = now
if (createBy && user?.user_id) payload[createBy] = user.user_id
if (updateBy && user?.user_id) payload[updateBy] = user.user_id
// 3. 插入
const inserted = await db(tableName).insert(payload)
// 4. 返回新记录(Knex.insert 在 SQLite 上返回自增 id 数组)
const pkValue = Array.isArray(inserted) ? inserted[0] : inserted
return await model.one({ [pk]: pkValue })
}
审计字段在 create 里自动填充 ------前端的 create 请求里哪怕传了 createTime 也会被 schema-guard 的 readonlyFields 跳过。这是安全设计。
update:和 create 类似,但多加一条
js
model.update = async function (where, data) {
const ctx = getCtx()
const user = ctx?.state?.user
const now = formatNow()
const payload = sanitizePayload(data, schemaMeta.columns, {
tableName,
readonlyFields,
allowReadonly: false
// allowReadonly: false → 审计字段不允许更新
})
if (updateTime) payload[updateTime] = now
if (updateBy && user?.user_id) payload[updateBy] = user.user_id
const builder = db(tableName)
// ⚠️ update 不走 newBuilder,但要加软删保护------防止把已软删的记录更新
if (softDeleteField)
builder.where(tableName + "." + softDeleteField, softDeleteValue)
if (Object.keys(where).length > 0) builder.where(where)
return await builder.update(payload)
}
delete:软删 or 硬删
js
model.delete = async function (where) {
if (softDeleteField) {
// 软删:UPDATE SET del_flag = '2' WHERE ...
const builder = db(tableName)
if (softDeleteField)
builder.where(tableName + "." + softDeleteField, softDeleteValue)
builder.where(where)
return await builder.update({ [softDeleteField]: softDeleteMark })
} else {
// 硬删:DELETE FROM ... WHERE ...
const builder = db(tableName)
builder.where(where)
return await builder.del()
}
}
软删 mark 和 value 是两个不同的值:value 是"正常"(默认 '0'),mark 是"已删除"(默认 '2')。不用 '1' 是因为有些表用 '1' 表示"启用"状态。
one:单条查询
js
model.one = async function (where) {
const builder = model.newBuilder()
await injectDataScope(builder, ...)
const columns = schemaMeta.columns
// one 也支持数据权限的列裁剪
await model.applyQueryFilters(builder, { where }, { applyPaging: false, applySort: false })
return await builder.first()
}
3.3 Service 层:薄到几乎透明
js
// business/service.js
function createService(model, opts = {}) {
const service = { model, pk: model.pk }
// 基础 CRUD ------ 全是转发
service.list = (query) => model.list(query)
service.count = (query) => model.count(query)
service.detail = (where) => model.one(where)
service.create = (data) => model.create(data)
service.update = (where, data) => model.update(where, data)
service.delete = (where) => model.delete(where)
// 额外:mergeRows(联表合并辅助)
service.mergeRows = mergeRows
// 如果 opts.tree 存在 → 挂载树相关方法(05 章讲)
if (opts.tree) {
service.tree = (where) => model.list(where).then(list => buildTree(list, treeOpts))
service.filterTree = (kw, where, fields) => ...
service.flattenTree = (where) => ...
}
return service
}
为什么 Service 这么薄?因为 90% 的 CRUD 没有差异化------转发一层就够了。真正需要差异化的场景(联表、复杂业务逻辑)通过工厂的 service 扩展点注入:
js
createBusiness("ops_ticket", {
service: function (model) {
return {
// 覆盖 list:查完后追加关联用户信息
list: async function (query) {
const list = await model.list(query)
// 追加用户信息、状态字典、分类名称...
return enrich(list)
},
// 新增方法:联表分页
pageWithDetail: async function (query) { ... }
}
}
})
3.4 Controller 层:最薄的一层
js
// business/controller.js
function createController(service, opts = {}) {
const pk = service.pk
const controller = { service, opts }
controller.list = (ctx) => service.list(ctx.query)
controller.detail = (ctx) => service.detail({ [pk]: ctx.params.id })
controller.create = (ctx) => service.create(ctx.request.body)
controller.update = async (ctx) => {
await service.update({ [pk]: ctx.params.id }, ctx.request.body)
ctx.body = {} // update 成功返回空对象(或返回更新后的记录,看业务)
}
controller.delete = async (ctx) => {
await service.delete({ [pk]: ctx.params.id })
ctx.body = {}
}
controller.count = (ctx) => service.count(ctx.query)
if (opts.tree) {
controller.tree = (ctx) => ...
controller.flattenTree = (ctx) => ...
}
return controller
}
Controller 只做 ctx 和 service 的翻译 :从 ctx.query 拿参数,调 service,把结果直接赋给 ctx.body。response 中间件会在最外层自动包 { code: 200, data: ... }。
Router 注册就一行:
js
// router.js (示意)
const ticket = createBusiness("ops_ticket")
router
.prefix("/api/ops/tickets")
.get("/", ticket.controller.list)
.get("/count", ticket.controller.count)
.get("/:id", ticket.controller.detail)
.post("/", ticket.controller.create)
.put("/:id", ticket.controller.update)
.delete("/:id", ticket.controller.delete)
3.5 验证:一行出 REST
把 router 注册好,跑一遍全链路:
js
// verify-03-crud.js
const axios = require("axios")
const api = axios.create({
baseURL: "http://127.0.0.1:3000/api",
timeout: 5000
})
async function login() {
const { data } = await api.post("/system/auth/login", {
userName: "admin",
password: "admin123"
})
api.defaults.headers.Authorization = `Bearer ${data.data.token}`
}
async function main() {
await login()
// 1. create
const { data: created } = await api.post("/ops/tickets", {
title: "登录按钮点不动",
content: "Chrome 120 下复现",
status: 0
})
console.log("✅ create:", created.data)
const id = created.data.ticketId
// 2. detail
const { data: detail } = await api.get(`/ops/tickets/${id}`)
console.log("✅ detail:", detail.data.title) // "登录按钮点不动"
console.log(" 审计字段自动填充? createTime =", detail.data.createTime)
console.log(" 审计字段自动填充? createBy =", detail.data.createBy)
// 3. update
await api.put(`/ops/tickets/${id}`, { status: 1 })
const { data: afterUpdate } = await api.get(`/ops/tickets/${id}`)
console.log("✅ update:", afterUpdate.data.status, "(应该是 1)")
// 4. list
const { data: list } = await api.get("/ops/tickets", {
params: { page: 1, pageSize: 10 }
})
console.log(
"✅ list:",
list.data.total,
"条, 第 1 页",
list.data.list?.length,
"条"
)
// 5. count
const { data: cnt } = await api.get("/ops/tickets/count")
console.log("✅ count:", cnt.data)
// 6. delete
await api.delete(`/ops/tickets/${id}`)
const { data: afterDel } = await api.get(`/ops/tickets/${id}`)
console.log("✅ delete (软删):", afterDel.data, "(应该是 null/undefined)")
}
main().catch((e) => {
console.error(e.response?.data || e.message)
process.exit(1)
})
预期 6 个接口全绿。createBusiness 一行出的 REST,开箱能用。
3.6 这一章造了什么
| 之前 | 之后 |
|---|---|
| 每个表写 5 个 Controller + 5 个 Service + 1 个 DAO | createBusiness(tableName) 一行搞定 |
| 每个 DAO 手写软删条件 | newBuilder 自动加 |
| 每个 create 手写审计字段填充 | model.create 自动填 |
| update 可能误更新已软删记录 | builder.where(softDeleteField) 保护 |
| 扩展逻辑散在各处 | 统一通过 opts.model/service/controller 注入 |
三层架构的妙处不在于分层,而在于"默认用工厂的实现,差异化时用扩展点覆盖"。 下一章讲查询 DSL------applyQueryFilters 是 list 的大脑。