第 8 章:Service 层的诞生(硬编码版)

第 8 章:Service 层的诞生(硬编码版)

问题

上一章统一错误体系跑通了,crud 里的 throw 全部换成了 BadRequest/Forbidden 等带状态码的错误类。现在回到横切需求的问题------controller 直接调 crud 三层跑通了,但写了几个业务模块后,四个横切需求砸过来:

  1. 软删除:查询自动加 deleted_at IS NULL,删除改为更新 deleted_at = now()
  2. 审计打戳:create 自动填 created_at / created_by,update 自动填 updated_at / updated_by
  3. 操作日志:写操作后异步记一条日志
  4. 数据权限:不同角色看到不同范围的数据

三个地方都试过了,都不行:

  • Koa 中间件在 HTTP 层,拿不到 crud 内部的 where,改不了查询条件
  • crud 里硬编码,crud 不再纯净,每张表的 crud 代码都不一样
  • controller 里写,5 个接口 × N 张表 = 爆炸

那就在 crud 和 controller 之间插一层吧。


第一版:硬编码 service

先不管什么 enhancer、插拔、工厂。就是一个 service.js,里面硬编码三个横切逻辑:软删除、审计、操作日志。能跑就行。

js 复制代码
function createService(crud) {
  // ── 三个硬编码的横切函数 ──────────────────

  // 软删除:读操作自动加 deleted_at: null
  const applySoftDelete = async (where) => {
    // 先用简单判断:表里有 deleted_at 列就自动加
    const schema = await crud.getSchema()
    if ("deleted_at" in schema.names) {
      return { deleted_at: null, ...where }
    }
    return where
  }

  // 审计打戳:写操作自动填时间戳
  const applyAudit = (data, action) => {
    const now = new Date().toISOString()
    if (action === "create") {
      return { ...data, created_at: now, updated_at: now }
    }
    if (action === "update") {
      return { ...data, updated_at: now }
    }
    return data
  }

  // 操作日志:写操作后异步记(先硬编码写 console.log)
  const logOp = async (action, args) => {
    console.log(`[OPLOG] ${action} on ${crud.table}:`, args)
  }

  // ── 对外方法 ──────────────────

  return {
    async find(opts = {}) {
      const where = await applySoftDelete(opts)
      return await crud.find(where)
    },

    async findOne(where, fields) {
      const finalWhere = await applySoftDelete(where)
      return await crud.findOne(finalWhere, fields)
    },

    async create(data) {
      const finalData = applyAudit(data, "create")
      const result = await crud.create(finalData)
      await logOp("CREATE", [finalData])
      return result
    },

    async update(where, data) {
      const finalWhere = await applySoftDelete(where)
      const finalData = applyAudit(data, "update")
      const result = await crud.update(finalWhere, finalData)
      await logOp("UPDATE", [finalWhere, finalData])
      return result
    },

    async remove(where) {
      // 软删除拦截:如果表里有 deleted_at,就改成 update
      const schema = await crud.getSchema()
      if ("deleted_at" in schema.names) {
        const finalWhere = await applySoftDelete(where)
        const result = await crud.update(finalWhere, {
          deleted_at: new Date().toISOString()
        })
        await logOp("SOFT_REMOVE", [finalWhere])
        return result
      }
      // 没有 deleted_at,物理删除
      const finalWhere = await applySoftDelete(where)
      const result = await crud.remove(finalWhere)
      await logOp("REMOVE", [finalWhere])
      return result
    }
  }
}

就这么多。没有设计模式,没有抽象层,就是三个硬编码的函数塞进 service 里。

controller 这时候改成调 service 而不是 crud:

js 复制代码
function createController(service) {
  return {
    list: async (ctx) => {
      ctx.body = { data: await service.find(ctx.query) }
    },
    detail: async (ctx) => {
      ctx.body = { data: await service.findOne(ctx.params.id) }
    },
    create: async (ctx) => {
      ctx.body = { data: await service.create(ctx.request.body) }
    },
    update: async (ctx) => {
      ctx.body = { data: await service.update(ctx.params.id, ctx.request.body) }
    },
    remove: async (ctx) => {
      ctx.body = { data: await service.remove(ctx.params.id) }
    }
  }
}

三层变成了:crud(纯净数据访问)→ service(横切编排)→ controller(HTTP 适配)。


跑通标志

js 复制代码
const knex = require("knex")({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true })

async function main() {
  await knex.schema.createTable("orders", (t) => {
    t.increments("id").primary()
    t.string("status")
    t.dateTime("deleted_at")
    t.dateTime("created_at")
    t.dateTime("updated_at")
  })

  const crud = createCrud(knex, "orders")
  const service = createService(crud)

  // create 自动填审计时间
  const o = await service.create({ status: "paid" })
  console.log("created_at:", o.created_at) // 应该有值

  // find 自动过滤 deleted_at
  await crud.update({ id: o.id }, { deleted_at: new Date().toISOString() })
  const list = await service.find()
  console.log("软删过滤后数量:", list.length) // 0

  // remove 被拦截成软删
  const o2 = await service.create({ status: "shipped" })
  await service.remove(o2.id)
  const list2 = await crud.find({ deleted_at: null })
  console.log("物理删数量:", list2.length) // 1(o 还在,o2 被软删了)
}
main()

跑通就说明 service 层的方向是对的。


跑通之后的痛点

现在 service 里硬编码了三个横切。但我马上发现了新问题:

  1. 每个表都要软删吗? 有些表(比如字典表)不需要软删除。但现在 applySoftDelete 里虽然有 schema 探测,service 工厂没有开关让我关掉它。
  2. 每个表都要审计吗? 有些表是系统表,不需要 created_at。同理,没有开关。
  3. 数据权限还没加。这是最复杂的横切------要按当前用户的角色决定 WHERE 条件(行级)和 SELECT 字段(列级)。一旦加进去,service 文件会变得更长。
  4. 硬编码的横切顺序是固定的 。如果某天我想在软删之前加一个条件(比如租户隔离),就得改动 applySoftDelete 的调用位置,或者再硬编码一个函数插进去。
  5. 审计要填 created_by(当前用户 ID) ,操作日志要记 IP。但 service 层里拿不到 Koa 的 ctx------service 是被 controller 调的,controller 有 ctx,但没往下传。

问题 1-4 指向同一个方向:横切逻辑应该可插拔、有接口约定、能编排顺序。下一章先解决这个,把硬编码改成 enhancer 插拔模式。

问题 5 更棘手:service 层在 Koa 中间件链里,ctx 怎么透传到 service 深处------这是插拔之后紧接着要解决的。

相关推荐
用户574385305111 小时前
第 2 章:裸写 Query(原生驱动)
后端
用户574385305111 小时前
第 3 章:引入 Knex + Schema 自动加载
后端
flash俊杰1 小时前
合并式恢复的事务边界:幂等跳过、外键顺序与向量重建标记
后端
学长毕设1 小时前
基于springboot的实验室设备管理系统
java·spring boot·后端
学长毕设2 小时前
基于springboot的社区养老监护系统的设计与实现
java·spring boot·后端
会编程的吕洞宾2 小时前
RAG检索质量怎么评估?3个指标+LLM裁判实战
java·开发语言·人工智能·后端
wno7042 小时前
Springboot国际化信息(i18n)解析
java·spring boot·后端
郭小李3 小时前
spring 的 @CookieValue 注解 用法
java·后端·spring
god_cvz3 小时前
麒麟 V10(ARM64 / aarch64)源码编译 3dtiles 全步骤
后端·gis