# 第 12 步:操作日志 + RBAC 缓存失效 + 业务模块

第 12 步:操作日志 + RBAC 缓存失效 + 业务模块

🧠 知识点简介

操作日志(op-log)和访问日志的区别

类型 记录时机 记录内容 用途
访问日志(access_log) 每个请求 method + path + status + duration + ip 统计访问量、监控性能、排查 4xx/5xx
操作日志(oplog) 写操作 谁 + 哪张表 + 做了什么 + 改了哪些行 审计追溯、合规检查

response 中间件已经在做访问日志了。oplog 是在 create-service 链里的 enhancer------只在写操作(create/update/remove/upsert)之后触发。

rbac-invalidate enhancer 什么时候清缓存

sql 复制代码
写 sys_user_role → 清对应用户的 user + rules 缓存
写 sys_role_menu → 清全部缓存(所有用户都可能受影响)
写 sys_rule       → 清全部缓存(所有用户的 row/col rule 都变了)
写 sys_user       → 只清该用户的 user 缓存

create-service 里已经编排了:写操作完成后调 rbacInv.invalidate({ table })。这个 enhancer 内部根据 table 名决定是 clearUser(userId) 还是 clear()。

哪些表是"RBAC 相关"的

js 复制代码
const RBAC_TABLES = {
  sys_user: "user", // 只影响该用户
  sys_user_role: "user",
  sys_role: "all", // 影响所有用户
  sys_role_menu: "all",
  sys_menu: "all",
  sys_rule: "all",
  sys_dept: "all" // 部门树变了,dept_only 模式的 row_rule 要重新加载
}

密码存 bcrypt

之前 auth.js 里是明文比对,现在换成 bcryptjs:

js 复制代码
npm install bcryptjs

// 注册 / 修改密码时
const hashed = bcrypt.hashSync(password, 10)

// 登录时
bcrypt.compareSync(inputPassword, row.password)

登录时预热 cache

auth 中间件里已经加了 await repo.loadRules(user.id)------登录后立即把 roles/perms/rules 全部塞进内存缓存,后续请求零 DB 查询。


🎯 本节目标

  • 实现完整的 lib/enhancers/operation-log.js(替代占位空实现)
  • 实现完整的 lib/enhancers/rbac-invalidate.js(替代占位空实现)
  • auth.js 加 bcrypt
  • 补完所有业务模块:orders / products / 登录 / 登出 / dept / role / menu
  • app.js 完整装配

🛠️ 动手实现

1. lib/enhancers/operation-log.js

js 复制代码
const knex = require("../../db")
const { getUser } = require("../../utils/als-context")

const BUF = []
let flushTimer = null
const FLUSH_BATCH = 100
const FLUSH_INTERVAL = 2000

function doFlush() {
  if (BUF.length === 0) return
  const batch = BUF.splice(0, Math.min(BUF.length, FLUSH_BATCH))
  knex("sys_oplog")
    .insert(batch)
    .catch((e) => console.error("[oplog] flush failed:", e.message))
    .finally(() => {
      if (BUF.length > 0) scheduleFlush()
    })
}

function scheduleFlush() {
  if (flushTimer) return
  flushTimer = setTimeout(() => {
    flushTimer = null
    doFlush()
  }, FLUSH_INTERVAL)
}

function operationLogEnhancer(opts = {}) {
  if (opts === false || opts.enabled === false) return {}

  const record = async ({ action, table, data, result, perm }) => {
    const user = getUser()
    const affectedIds = extractIds(result)

    BUF.push({
      user_id: user?.id ?? null,
      user_nickname: user?.nickname ?? null,
      action,
      table_name: table,
      operation: JSON.stringify({
        action,
        table,
        data: typeof data === "object" ? truncate(data) : null,
        affected_ids: affectedIds,
        perm: perm || null
      }).slice(0, 4000),
      created_at: new Date().toISOString()
    })

    if (BUF.length >= FLUSH_BATCH) {
      if (flushTimer) {
        clearTimeout(flushTimer)
        flushTimer = null
      }
      doFlush()
    } else {
      scheduleFlush()
    }
  }

  return { record }
}

function extractIds(result) {
  if (!result) return []
  if (Array.isArray(result)) return result.map((r) => r?.id).filter(Boolean)
  if (result.id) return [result.id]
  return []
}

function truncate(obj) {
  if (!obj) return null
  try {
    const str = JSON.stringify(obj)
    return str.length > 2000 ? str.slice(0, 2000) + "...(truncated)" : obj
  } catch {
    return null
  }
}

operationLogEnhancer.flush = () => {
  doFlush()
}
module.exports = operationLogEnhancer

2. lib/enhancers/rbac-invalidate.js

js 复制代码
const repo = require("../../rbac/repository")
const { getUser } = require("../../utils/als-context")

const USER_LEVEL_TABLES = ["sys_user", "sys_user_role"]

function rbacInvalidateEnhancer(opts = {}) {
  if (opts === false || opts.enabled === false) return {}
  const { table } = opts

  const invalidate = async ({ action, table: opTable }) => {
    const target = opTable || table
    if (!target) return

    if (USER_LEVEL_TABLES.includes(target)) {
      const user = getUser()
      if (user?.id) {
        repo.clearUser(user.id)
        return
      }
    }

    const RBAC_ALL_TABLES = ["sys_role", "sys_role_menu", "sys_menu", "sys_rule", "sys_dept"]
    if (RBAC_ALL_TABLES.includes(target)) {
      repo.clear()
    }
  }

  return { invalidate }
}

module.exports = rbacInvalidateEnhancer

3. auth.js 加 bcrypt

js 复制代码
const bcrypt = require("bcryptjs")

// 登录时比对
const rawUser = await knex("sys_user").select("*").where({ username }).limit(1)
const user = rawUser[0]
if (!bcrypt.compareSync(password, user.password)) {
  throw new Unauthorized("密码错误")
}

// 种子数据里的密码也要从明文改成 bcrypt hash
// db/init-db.js 里:
const bcrypt = require("bcryptjs")
const PWD = bcrypt.hashSync("123456", 10)
// INSERT INTO sys_user ... VALUES (..., PWD)

4. 业务模块补完

modules/products.js:

js 复制代码
const createModule = require("../lib/create-module")
const { router, service, controller } = createModule("products")
module.exports = { router, service, controller }

modules/dept.js:

js 复制代码
const createModule = require("../lib/create-module")
const Router = require("@koa/router")
const createCrud = require("../lib/create-crud")
const knex = require("../db")

const { service, controller } = createModule("sys_dept", { routes: false })
const router = new Router({ prefix: "/api/depts" })

router.get("/", async (ctx) => {
  const crud = createCrud(knex, "sys_dept")
  const rows = await service.list({ includeDeleted: true })
  ctx.body = { data: buildTree(rows) }
})

function buildTree(rows, parentId = 0) {
  return rows
    .filter((r) => r.parent_id === parentId && !r.deleted_at)
    .map((r) => ({ ...r, children: buildTree(rows, r.id) }))
}

router.get("/:id", controller.detail)
router.post("/", controller.create)
router.put("/:id", controller.update)
router.delete("/:id", controller.remove)

module.exports = { router, service, controller }

auth.js 补 /register /profile:

js 复制代码
// 注册(简化版)
router.post("/register", async (ctx) => {
  const { username, password, nickname, deptId } = ctx.request.body
  if (!username || !password) throw new BadRequest("缺少字段")
  const existing = await knex("sys_user").where({ username }).limit(1)
  if (existing.length > 0) throw new BadRequest("用户名已存在")

  const hashed = bcrypt.hashSync(password, 10)
  const rows = await knex("sys_user")
    .insert({
      username,
      password: hashed,
      nickname: nickname || username,
      dept_id: deptId || null,
      status: 1,
      token_version: 0
    })
    .returning("*")

  const user = rows[0]
  const defaultRole = await knex("sys_role").where({ code: "employee" }).limit(1)
  if (defaultRole.length > 0) {
    await knex("sys_user_role").insert({ user_id: user.id, role_id: defaultRole[0].id })
  }

  ctx.status = 201
  ctx.body = { data: { id: user.id, username: user.username } }
})

5. 完整 app.js 装配

js 复制代码
const Koa = require("koa")
const koaBody = require("koa-body")
const { als } = require("./utils/als-context")

const response = require("./middleware/response")
const normalizeKeys = require("./middleware/normalize-keys")
const auth = require("./middleware/auth")
const requirePerm = require("./middleware/permission")

const orders = require("./modules/orders")
const products = require("./modules/products")
const dept = require("./modules/dept")
const authRouter = require("./modules/auth/auth")

const app = new Koa({ asyncLocalStorage: als })

app.use(response)
app.use(normalizeKeys)
app.use(koaBody({ multipart: true }))

app.use(auth)
app.use(authRouter.routes())

app.use(requirePerm())
app.use(orders.router.routes())
app.use(orders.router.allowedMethods())
app.use(products.router.routes())
app.use(products.router.allowedMethods())
app.use(dept.router.routes())
app.use(dept.router.allowedMethods())

// shutdown 时刷盘
process.on("SIGINT", () => {
  response.flushAll()
  require("./lib/enhancers/operation-log").flush?.()
  process.exit(0)
})

const PORT = process.env.PORT || 3000
app.listen(PORT, "0.0.0.0", () => {
  console.log(`织链塔 server running at http://localhost:${PORT}`)
})

✅ 完整验收测试

1. 种子数据还原

bash 复制代码
node db/init-db.js   # 如果换了 bcrypt,种子数据会重建
npm run dev

2. 角色对比(数据权限)

接口 admin zhang_san li_si
GET /api/orders 5 条全字段 dept_id=3 的订单,7 字段 自己的订单,4 字段
PUT /api/orders/1 能改任何字段 能改白名单内字段 能改白名单内字段
DELETE /api/orders/1 能删 没 orders:remove 权限 → 403 没 orders:remove → 403
GET /api/auth/me roles=admin perms 全部 roles=manager, auditor perms 部分 roles=employee perms 最少

3. RBAC 缓存失效

用 admin 登录,临时把 zhang_san 的角色改掉:

bash 复制代码
node -e "
const knex = require('./db')
;(async () => {
  await knex('sys_user_role').where({ user_id: 2 }).del()
  await knex('sys_user_role').insert({ user_id: 2, role_id: 1 })  // 只留 admin
  console.log('done')
  await knex.destroy()
})()
"

张三不需要重新登录(因为 sys_user_role 是 user_level_table,只清用户自己的缓存),下次请求会自动拿到新的 roles:

bash 复制代码
curl http://localhost:3000/api/auth/me -H "Authorization: Bearer <张三的 token>"
# 第二次请求 roles 应该只剩 [admin]

4. 操作日志

bash 复制代码
# 用 admin 做几个写操作
curl -X POST http://localhost:3000/api/products \
  -H "Authorization: Bearer <admin>" \
  -H "Content-Type: application/json" \
  -d '{"sku":"NEW","name":"新产品","price":99}'

curl -X PUT http://localhost:3000/api/orders/1 \
  -H "Authorization: Bearer <admin>" \
  -H "Content-Type: application/json" \
  -d '{"status":"archived"}'

# 等 2 秒让批量刷盘
sleep 3

npx better-sqlite3 db/test.db "SELECT user_nickname, action, table_name FROM sys_oplog ORDER BY id DESC LIMIT 5"
# 应该能看到两条记录

📝 最终小结

整个项目的三层编织

matlab 复制代码
Middleware 链
  response → normalize → auth → permission → router

Service 链(enhancer 编排)
  dataPerm.mergeWhere → softDelete.mergeWhere → crud.find
  dataPerm.filterWrite → audit.stamp → crud.create → opLog → rbacInvalidate

Repository 缓存层
  user: {id, roles, perms}
  rules: {perms → {rowMode, rowRule, colFields}}

从头到尾你造了什么

步骤 模块 核心贡献
01 db Knex 实例 + 10 张表 + 种子
02 errors AppError 体系,response 中间件按 instanceof 分发
03 schema-cache 自动读表结构,模块级内存缓存,create-crud 的依赖
04 builder-where JSON 条件编译引擎,13 种操作符,data-permission 的动力
05 crud + query 闭包工厂,安全开关防全表事故,pickSchema 白名单
06 response + normalize 错误兜底 + 异步批量日志 + 字段风格互转
07 als + audit + softDelete ALS 让 enhancer 拿到 ctx.user,审计自动填,软删拦截
08 service/controller/module enhancer 编排中心,三层分离,一行组装
09 jwt + auth + permission version 失效,inferPerm 自动推断
10 rbac/repository roles + perms + rules 集中缓存,手动失效,rule 合并
11 data-permission 行级 mergeWhere + 列级 filterSelect/filterWrite,$ctx 变量替换
12 oplog + invalidate 写后记日志 + RBAC 变更即时生效 + bcrypt + 业务模块闭环

织链塔这个名字的意义

三条链编织成塔:

  • Factory 链(crud → service → controller → module)是骨架主梁
  • Enhancer 链(audit → softDelete → dataPerm → opLog → rbacInvalidate)是织在骨架上的横切网
  • Middleware 链(response → normalize → auth → permission)是裹在塔外的保护层
  • Repository 是地基里的蓄水池,让权限查询零 DB 压力

恭喜,你已经从零造出来了一个 Enhancer 驱动 · JSON 条件编译 · 带 RBAC 行级/列级数据权限 的 Koa 后端服务 🏗️

相关推荐
用户574385305111 小时前
# 织链塔:12 步从零造一个 Koa 后端框架 —— 第 4 步:JSON 条件编译引擎(buildWhere)
后端
coderCN1 小时前
NestJS + Prisma + PostgreSQL
后端
苍何1 小时前
开源微信流 APP,手机微信聊天记录,可以直接给 WorkBuddy 和 Obsidian 了
后端
智驭未来掌门人2 小时前
Qt 项目不建议导入 CLion 说明
后端
Lumos_2 小时前
P1 签名 (PKCS#1) vs 裸签 (Raw Signature)
后端
今天早睡2 小时前
调用真实模型,用 Pydantic 校验后再路由
后端
智驭未来掌门人2 小时前
CLion 创建并运行 Qt 项目指南
后端
数据库小学妹2 小时前
OLAP数据库怎么选、怎么优化?路线、提速与边界
运维·数据库·后端
加洛斯2 小时前
万字推演Elasticsearch修炼方法,从炼气到元婴
后端·elasticsearch·面试