第 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 后端服务 🏗️