元数据驱动的通用 CRUD 后端框架 (3- createBusiness 工厂:三层装配 + 一行出 REST)

上一章 :中间件链 + 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 的大脑。

相关推荐
高级程序源2 小时前
django大学生创新创业项目管理系统94923-计算机课程设计、毕业设计
javascript·vue.js·spring boot·后端·python·django·课程设计
Sam_Deep_Thinking2 小时前
new Thread()之后发生了什么?
java·后端·面试·程序员
步行cgn2 小时前
Spring util 命名空间详解
java·后端·spring
打工仔折腾 AI3 小时前
普通摄像头接入AI识别:绿联NAS部署Frigate监控实战
人工智能·后端·python·性能优化·ai agent 实战
万物智能5 小时前
SARADC模数转换—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
花间相见5 小时前
【后端开发|网络编程】—— 五种实时通信方案全解析:短轮询、长轮询、SSE、MQTT、WebSocket
后端·网络协议
坤岭5 小时前
LLM应用安全护栏实战
后端
程序猿阿越5 小时前
containerd如何创建Pod
后端·kubernetes·源码阅读