refactor(server): 拆分 ShrimpServer → buildShrimpRouter + tokenAuth + accessor
ShrimpServer 之前一锅端 Express routes / JWT / Mongoose / 用户字段访问。
现在拆成独立部件,消费方可以单独换掉任何一块:
- src/server/types.ts: ITokenAuth, IUserAccessor, IShrimpRouterDeps
- src/server/jwtAuth.ts: createJwtTokenAuth(secret | options) 默认 JWT 适配器
- src/server/buildShrimpRouter.ts: 纯函数,不直接 import jsonwebtoken/mongoose
- src/server/ShrimpServer.ts: 装配体,protected create* factory 保留,
子类化扩展不破坏(hand-server-2 的 AdminApi extends ShrimpServer 仍能用)
- src/server/index.ts: 'shrimp-server/server' entry,额外暴露低阶 API
test/buildShrimpRouter.test.ts: 用 MemoryShrimpCrud + 假 ITokenAuth 拼出
独立 Router,跑通 /config /login /me 全流程 — 保证 buildShrimpRouter 不
偷偷耦合回 ShrimpServer。
v0.3.0
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shrimp-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)",
|
||||
"main": "src/index.ts",
|
||||
@@ -11,8 +11,8 @@
|
||||
"default": "./src/core.ts"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"default": "./src/server.ts"
|
||||
"types": "./src/server/index.ts",
|
||||
"default": "./src/server/index.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* ShrimpServer
|
||||
* Shrimp 的服务端实现,接入 express + mongoose + JWT
|
||||
* 提供通用的后台管理 API(登录、鉴权、菜单、事件调度)
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express"
|
||||
import jsonwebtoken from "jsonwebtoken"
|
||||
import mongoose, { Schema, Model } from "mongoose"
|
||||
import { Shrimp, ShrimpCrud, UserModule, RoleModule, RootShrimpPower } from "./Shrimp"
|
||||
import { MongoShrimpCrud } from "./MongoShrimpCrud"
|
||||
import { OperationLogModule } from "./OperationLogModule"
|
||||
import { ShrimpCode, IMeResult, IUser, IRole, IOperationLog, WithId, IMenuItem, IShrimpOperator, ShrimpError } from "./IShrimpType"
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user: { _id: string; name: string; power: Set<string>; roleId: string[]; isSuperAdmin: boolean }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 默认 Mongoose Schema(可导出,也可被子类覆盖) ==============
|
||||
|
||||
export const UserSchema = new Schema({
|
||||
username: { type: String, required: true },
|
||||
password: { type: String, required: true },
|
||||
nickname: { type: String, default: '' },
|
||||
roleId: { type: [Schema.Types.ObjectId], ref: 'Role', default: [] },
|
||||
active: { type: Boolean, default: true }
|
||||
}, { timestamps: true })
|
||||
|
||||
export const RoleSchema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
isSuperAdmin: { type: Boolean, default: false },
|
||||
powers: { type: [String], default: [] },
|
||||
}, { timestamps: true })
|
||||
|
||||
export const OperationLogSchema = new Schema({
|
||||
operatorId: { type: String, default: null },
|
||||
ip: { type: String, default: '' },
|
||||
method: { type: String, required: true },
|
||||
args: { type: String, default: '' },
|
||||
success: { type: Boolean, default: true },
|
||||
errorMsg: { type: String, default: '' },
|
||||
createdAt: { type: Number, default: () => Date.now() },
|
||||
}, { timestamps: false })
|
||||
|
||||
// ============== ShrimpServer ==============
|
||||
|
||||
export class ShrimpServer extends Shrimp {
|
||||
userModule: UserModule
|
||||
roleModule: RoleModule
|
||||
operationLogModule: OperationLogModule
|
||||
router: Router = Router()
|
||||
|
||||
jwtSecret: string
|
||||
config: Record<string, any>
|
||||
globalOptions: Record<string, any> = {}
|
||||
forcedOptionAccess = new Map<string, RootShrimpPower>()
|
||||
|
||||
registerOptions(key: string, options: any, access?: RootShrimpPower) {
|
||||
this.globalOptions[key] = options
|
||||
if (access) {
|
||||
this.forcedOptionAccess.set(key, access)
|
||||
}
|
||||
}
|
||||
|
||||
/** 缓存的 Mongoose Model,避免重复注册 */
|
||||
protected _userModel?: Model<IUser>
|
||||
protected _roleModel?: Model<IRole>
|
||||
protected _operationLogModel?: Model<IOperationLog>
|
||||
|
||||
constructor(jwtSecret: string, config: Record<string, any> = {}, mongodbUri: string = 'mongodb://localhost:27017/shrimp') {
|
||||
super()
|
||||
this.jwtSecret = jwtSecret
|
||||
this.config = config
|
||||
|
||||
// 初始化数据源
|
||||
const userCrud = this.createUserCrud()
|
||||
const roleCrud = this.createRoleCrud()
|
||||
const operationLogCrud = this.createOperationLogCrud()
|
||||
|
||||
// 初始化模块
|
||||
this.userModule = this.createUserModule(userCrud)
|
||||
this.roleModule = this.createRoleModule(roleCrud)
|
||||
this.operationLogModule = this.createOperationLogModule(operationLogCrud)
|
||||
|
||||
// 连接数据库
|
||||
mongoose.connect(mongodbUri).then(() => {
|
||||
console.log(mongodbUri + ' MongoDB connected')
|
||||
this.onDbConnected()
|
||||
})
|
||||
|
||||
// ---- 路由 ----
|
||||
|
||||
// 后台配置接口(不需要鉴权)
|
||||
this.router.get('/config', (_req: Request, res: Response) => {
|
||||
res.json(this.success(this.config))
|
||||
})
|
||||
|
||||
// 登录接口(不需要鉴权)
|
||||
this.router.post('/login', async (req: Request, res: Response) => {
|
||||
const ip = req.ip || ''
|
||||
const { username, password } = req.body
|
||||
const args = [{ username, password }]
|
||||
try {
|
||||
const user = await this.userModule.getUser(username, password)
|
||||
const token = jsonwebtoken.sign(this.getJwtPayload(user), this.jwtSecret, { expiresIn: '7d' })
|
||||
this.operationLogModule.record(user._id, ip, ['login'], args, true)
|
||||
res.json(this.success({ token }))
|
||||
} catch (error: any) {
|
||||
const msg = error instanceof ShrimpError ? error.tip : { t: 'shrimp.loginFailed' }
|
||||
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || '')
|
||||
this.operationLogModule.record(null, ip, ['login'], args, false, errMsg)
|
||||
res.json(this.fail(ShrimpCode.FAIL, msg))
|
||||
}
|
||||
})
|
||||
|
||||
// 鉴权中间件
|
||||
this.router.use(async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, { t: 'shrimp.relogin' }, 'No token'))
|
||||
}
|
||||
const token = auth.split(' ')[1]
|
||||
const decoded = jsonwebtoken.verify(token, this.jwtSecret) as any
|
||||
const user = await this.userModule.dataSource.get(decoded._id)
|
||||
if (!user || !this.isUserActive(user)) {
|
||||
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, { t: 'shrimp.userDisabled' }, 'User disabled'))
|
||||
}
|
||||
const roleIds = this.getUserRoleIds(user)
|
||||
const roles = await Promise.all(roleIds.map(id => this.roleModule.dataSource.get(id)))
|
||||
const isSuperAdmin = roles.some(r => this.isRoleSuperAdmin(r))
|
||||
const powers = isSuperAdmin
|
||||
? new Set((await this.allPower()).map(p => p.key))
|
||||
: new Set(roles.flatMap(r => this.getRolePowers(r)))
|
||||
req.user = { _id: user._id, name: this.getUserDisplayName(user), power: powers, roleId: roleIds, isSuperAdmin }
|
||||
next()
|
||||
} catch (error: any) {
|
||||
const msg = error instanceof ShrimpError ? error.tip : { t: 'shrimp.relogin' }
|
||||
return res.json(this.fail(ShrimpCode.UNAUTHORIZED, msg))
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前用户信息
|
||||
this.router.get('/me', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const moduleInfo = await this.userModule.getModule(req.user._id, this.roleModule)
|
||||
const user = await this.userModule.dataSource.get(req.user._id)
|
||||
if (!user) {
|
||||
return res.json(this.fail(ShrimpCode.NOT_FOUND, { t: 'shrimp.userNotFound' }))
|
||||
}
|
||||
const roleIds = this.getUserRoleIds(user)
|
||||
const roles = await Promise.all(roleIds.map(id => this.roleModule.dataSource.get(id)))
|
||||
const isSuperAdmin = roles.some(r => this.isRoleSuperAdmin(r))
|
||||
const powers = isSuperAdmin
|
||||
? (await this.allPower()).map(p => p.key)
|
||||
: roles.flatMap(r => this.getRolePowers(r))
|
||||
const filteredMenuTree = this.filterMenuTree(this.getMenuTree(), powers)
|
||||
// 按需收集该用户有权限的模块中引用的全局 options key(自动去重)
|
||||
const neededKeys = new Set<string>()
|
||||
for (const mod of moduleInfo) {
|
||||
for (const field of mod.fields || []) {
|
||||
if (typeof field.options === 'string') {
|
||||
neededKeys.add(field.options)
|
||||
}
|
||||
}
|
||||
}
|
||||
const filteredOptions: Record<string, any> = {}
|
||||
for (const key of neededKeys) {
|
||||
if (this.globalOptions[key] != null) {
|
||||
filteredOptions[key] = this.globalOptions[key]
|
||||
}
|
||||
}
|
||||
for (const [key, access] of this.forcedOptionAccess) {
|
||||
if (this.globalOptions[key] != null) {
|
||||
if (isSuperAdmin || powers.includes(access.powerKey)) {
|
||||
filteredOptions[key] = this.globalOptions[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.json(this.success<IMeResult>({
|
||||
nickname: this.getUserDisplayName(user),
|
||||
moduleInfo,
|
||||
menuTree: filteredMenuTree,
|
||||
options: filteredOptions
|
||||
}))
|
||||
} catch (error: any) {
|
||||
if (error instanceof ShrimpError) {
|
||||
res.json(this.fail(error.code, error.tip))
|
||||
} else {
|
||||
res.json(this.fail(ShrimpCode.FAIL, { t: 'shrimp.serverError' }, error.message))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 通用事件调用接口
|
||||
this.router.post('/*path', async (req: Request, res: Response) => {
|
||||
const raw = req.params.path
|
||||
const path = (Array.isArray(raw) ? raw.join('/') : raw).split('/').filter(Boolean)
|
||||
const args = Array.isArray(req.body) ? req.body : [req.body]
|
||||
const ip = req.ip || ''
|
||||
const shouldLog = this.operationLogModule.shouldLog(path)
|
||||
try {
|
||||
if (path.length === 0) {
|
||||
return res.json(this.fail(ShrimpCode.FAIL, { t: 'shrimp.emptyPath' }))
|
||||
}
|
||||
const powers = req.user.power
|
||||
if (!this.checkEvent(powers, path)) {
|
||||
return res.json(this.fail(ShrimpCode.FORBIDDEN, { t: 'shrimp.forbidden' }))
|
||||
}
|
||||
const operator: IShrimpOperator = {
|
||||
userId: req.user._id,
|
||||
roleId: req.user.roleId,
|
||||
isSuperAdmin: req.user.isSuperAdmin
|
||||
}
|
||||
const result = await Shrimp.asyncLocalStorage.run(operator, () => this.call(path, ...args))
|
||||
if (shouldLog) this.operationLogModule.record(req.user._id, ip, path, args, true)
|
||||
res.json(this.success(result))
|
||||
} catch (error: any) {
|
||||
if (shouldLog) {
|
||||
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || '')
|
||||
this.operationLogModule.record(req.user?._id ?? null, ip, path, args, false, errMsg)
|
||||
}
|
||||
if (error instanceof ShrimpError) {
|
||||
res.json(this.fail(error.code, error.tip))
|
||||
} else {
|
||||
res.json(this.fail(ShrimpCode.SERVER_ERROR, { t: 'shrimp.serverError' }, error.message))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Schema 工厂方法 ==============
|
||||
|
||||
protected createUserSchema(): mongoose.Schema {
|
||||
return UserSchema
|
||||
}
|
||||
|
||||
protected createRoleSchema(): mongoose.Schema {
|
||||
return RoleSchema
|
||||
}
|
||||
|
||||
protected createOperationLogSchema(): mongoose.Schema {
|
||||
return OperationLogSchema
|
||||
}
|
||||
|
||||
protected getUserModel(): Model<IUser> {
|
||||
if (!this._userModel) {
|
||||
this._userModel = mongoose.model<IUser>('shrimp_users', this.createUserSchema())
|
||||
}
|
||||
return this._userModel
|
||||
}
|
||||
|
||||
protected getRoleModel(): Model<IRole> {
|
||||
if (!this._roleModel) {
|
||||
this._roleModel = mongoose.model<IRole>('shrimp_roles', this.createRoleSchema())
|
||||
}
|
||||
return this._roleModel
|
||||
}
|
||||
|
||||
protected getOperationLogModel(): Model<IOperationLog> {
|
||||
if (!this._operationLogModel) {
|
||||
this._operationLogModel = mongoose.model<IOperationLog>('shrimp_operation_logs', this.createOperationLogSchema())
|
||||
}
|
||||
return this._operationLogModel
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Crud 工厂方法 ==============
|
||||
|
||||
protected createUserCrud(): MongoShrimpCrud<WithId<IUser>> {
|
||||
return new MongoShrimpCrud<WithId<IUser>>(this.getUserModel())
|
||||
}
|
||||
|
||||
protected createRoleCrud(): MongoShrimpCrud<WithId<IRole>> {
|
||||
return new MongoShrimpCrud<WithId<IRole>>(this.getRoleModel())
|
||||
}
|
||||
|
||||
protected createOperationLogCrud(): MongoShrimpCrud<WithId<IOperationLog>> {
|
||||
return new MongoShrimpCrud<WithId<IOperationLog>>(this.getOperationLogModel())
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Module 工厂方法 ==============
|
||||
|
||||
protected createUserModule(dataSource: ShrimpCrud<WithId<IUser>>): UserModule {
|
||||
return new UserModule(this, dataSource)
|
||||
}
|
||||
|
||||
protected createRoleModule(dataSource: ShrimpCrud<WithId<IRole>>): RoleModule {
|
||||
return new RoleModule(this, dataSource)
|
||||
}
|
||||
|
||||
protected createOperationLogModule(dataSource: ShrimpCrud<WithId<IOperationLog>>): OperationLogModule {
|
||||
return new OperationLogModule(this.userModule, dataSource)
|
||||
}
|
||||
|
||||
// ============== 可覆盖的用户/角色字段访问钩子 ==============
|
||||
|
||||
/** 从用户对象生成 JWT payload,子类可覆盖 */
|
||||
protected getJwtPayload(user: WithId<IUser>): Record<string, any> {
|
||||
return { _id: user._id, roleId: user.roleId }
|
||||
}
|
||||
|
||||
/** 判断用户是否可用,子类可覆盖 */
|
||||
protected isUserActive(user: WithId<IUser>): boolean {
|
||||
return user.active === true
|
||||
}
|
||||
|
||||
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
|
||||
protected getUserRoleIds(user: WithId<IUser>): string[] {
|
||||
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
|
||||
}
|
||||
|
||||
/** 判断角色是否为超级管理员,子类可覆盖 */
|
||||
protected isRoleSuperAdmin(role: WithId<IRole> | null): boolean {
|
||||
return !!role?.isSuperAdmin
|
||||
}
|
||||
|
||||
/** 从角色对象提取权限列表,子类可覆盖 */
|
||||
protected getRolePowers(role: WithId<IRole> | null): string[] {
|
||||
return role?.powers || []
|
||||
}
|
||||
|
||||
/** 从用户对象提取显示名称,子类可覆盖 */
|
||||
protected getUserDisplayName(user: WithId<IUser>): string {
|
||||
return user.nickname || ''
|
||||
}
|
||||
|
||||
/** 数据库连接后的回调,子类可覆盖以执行连接后的逻辑 */
|
||||
protected async onDbConnected(): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
/** 初始化默认数据(如默认角色、用户),子类可覆盖 */
|
||||
protected async initDefaultData(name: string = "Administrator", description: "System administrator with all permissions"): Promise<void> {
|
||||
const result = await this.userModule.dataSource.list({ limit: 1 })
|
||||
if (result.total > 0) return
|
||||
console.log("初始化默认数据")
|
||||
const adminRole = await this.roleModule.dataSource.add({
|
||||
name: name,
|
||||
description: description,
|
||||
isSuperAdmin: true,
|
||||
powers: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
await this.userModule.add({
|
||||
username: "admin",
|
||||
password: "admin",
|
||||
nickname: "Administrator",
|
||||
roleId: [adminRole._id],
|
||||
active: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Shrimp 服务端入口 — 包含 Express + Mongoose + JWT 实现
|
||||
*
|
||||
* 在 core 之上额外暴露:
|
||||
* - ShrimpServer:Express Router + JWT 鉴权 + Mongo 连接
|
||||
* - MongoShrimpCrud:Mongoose 数据源
|
||||
* - 内置 User/Role/OperationLog 的 Mongoose Schema
|
||||
*
|
||||
* 这一入口需要消费方安装 peer 依赖:express / mongoose / jsonwebtoken / bcrypt。
|
||||
* 不需要这些时请改从 'shrimp-server' 导入,不会触发任何 peer。
|
||||
*/
|
||||
|
||||
// 再导出所有 core 的符号,'shrimp-server/server' 单一入口就够用
|
||||
export * from "./core"
|
||||
|
||||
// ===== 服务端实现(Express + Mongoose + JWT) =====
|
||||
export {
|
||||
ShrimpServer,
|
||||
UserSchema,
|
||||
RoleSchema,
|
||||
OperationLogSchema,
|
||||
} from "./ShrimpServer"
|
||||
|
||||
// ===== Mongoose 数据源 =====
|
||||
export { MongoShrimpCrud } from "./MongoShrimpCrud"
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* ShrimpServer
|
||||
*
|
||||
* 开箱即用的装配体:Express Router + JWT + Mongoose 三块拼起来,
|
||||
* 用户只关心模块声明和子类钩子,不必自己拼 Router / Auth / Store。
|
||||
*
|
||||
* 想换掉其中一块(比如换 session、换 SQL Store)的高级用法:
|
||||
* - 自己造 ITokenAuth(看 createJwtTokenAuth 实现)
|
||||
* - 自己造 dataSource(实现 ShrimpCrud 接口)
|
||||
* - 直接 `buildShrimpRouter(deps)` 拼自己的 Express app,绕开本类
|
||||
*/
|
||||
|
||||
import { Router } from "express"
|
||||
import mongoose, { Schema, Model } from "mongoose"
|
||||
import {
|
||||
Shrimp, ShrimpCrud, UserModule, RoleModule, RootShrimpPower,
|
||||
} from "../Shrimp"
|
||||
import { MongoShrimpCrud } from "../MongoShrimpCrud"
|
||||
import { OperationLogModule } from "../OperationLogModule"
|
||||
import { IUser, IRole, IOperationLog, WithId } from "../IShrimpType"
|
||||
import { buildShrimpRouter } from "./buildShrimpRouter"
|
||||
import { createJwtTokenAuth } from "./jwtAuth"
|
||||
import { ITokenAuth, IUserAccessor } from "./types"
|
||||
|
||||
// ============== 默认 Mongoose Schema(可导出,也可被子类覆盖) ==============
|
||||
|
||||
export const UserSchema = new Schema({
|
||||
username: { type: String, required: true },
|
||||
password: { type: String, required: true },
|
||||
nickname: { type: String, default: "" },
|
||||
roleId: { type: [Schema.Types.ObjectId], ref: "Role", default: [] },
|
||||
active: { type: Boolean, default: true },
|
||||
}, { timestamps: true })
|
||||
|
||||
export const RoleSchema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, default: "" },
|
||||
isSuperAdmin: { type: Boolean, default: false },
|
||||
powers: { type: [String], default: [] },
|
||||
}, { timestamps: true })
|
||||
|
||||
export const OperationLogSchema = new Schema({
|
||||
operatorId: { type: String, default: null },
|
||||
ip: { type: String, default: "" },
|
||||
method: { type: String, required: true },
|
||||
args: { type: String, default: "" },
|
||||
success: { type: Boolean, default: true },
|
||||
errorMsg: { type: String, default: "" },
|
||||
createdAt: { type: Number, default: () => Date.now() },
|
||||
}, { timestamps: false })
|
||||
|
||||
// ============== ShrimpServer ==============
|
||||
|
||||
export class ShrimpServer extends Shrimp implements IUserAccessor {
|
||||
userModule: UserModule
|
||||
roleModule: RoleModule
|
||||
operationLogModule: OperationLogModule
|
||||
router: Router
|
||||
|
||||
jwtSecret: string
|
||||
config: Record<string, any>
|
||||
globalOptions: Record<string, any> = {}
|
||||
forcedOptionAccess = new Map<string, RootShrimpPower>()
|
||||
|
||||
registerOptions(key: string, options: any, access?: RootShrimpPower) {
|
||||
this.globalOptions[key] = options
|
||||
if (access) {
|
||||
this.forcedOptionAccess.set(key, access)
|
||||
}
|
||||
}
|
||||
|
||||
/** 缓存的 Mongoose Model,避免重复注册 */
|
||||
protected _userModel?: Model<IUser>
|
||||
protected _roleModel?: Model<IRole>
|
||||
protected _operationLogModel?: Model<IOperationLog>
|
||||
|
||||
constructor(jwtSecret: string, config: Record<string, any> = {}, mongodbUri: string = "mongodb://localhost:27017/shrimp") {
|
||||
super()
|
||||
this.jwtSecret = jwtSecret
|
||||
this.config = config
|
||||
|
||||
// 初始化数据源
|
||||
const userCrud = this.createUserCrud()
|
||||
const roleCrud = this.createRoleCrud()
|
||||
const operationLogCrud = this.createOperationLogCrud()
|
||||
|
||||
// 初始化模块
|
||||
this.userModule = this.createUserModule(userCrud)
|
||||
this.roleModule = this.createRoleModule(roleCrud)
|
||||
this.operationLogModule = this.createOperationLogModule(operationLogCrud)
|
||||
|
||||
// 连接数据库
|
||||
mongoose.connect(mongodbUri).then(() => {
|
||||
console.log(mongodbUri + " MongoDB connected")
|
||||
this.onDbConnected()
|
||||
})
|
||||
|
||||
// 路由:把所有 routes 委托给 buildShrimpRouter
|
||||
this.router = buildShrimpRouter({
|
||||
shrimp: this,
|
||||
userModule: this.userModule,
|
||||
roleModule: this.roleModule,
|
||||
operationLogModule: this.operationLogModule,
|
||||
tokenAuth: this.createTokenAuth(),
|
||||
config: this.config,
|
||||
globalOptions: this.globalOptions,
|
||||
forcedOptionAccess: this.forcedOptionAccess,
|
||||
userAccessor: this,
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建 Token 鉴权,默认 JWT。子类可覆盖换成 session 之类。 */
|
||||
protected createTokenAuth(): ITokenAuth {
|
||||
return createJwtTokenAuth(this.jwtSecret)
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Schema 工厂方法 ==============
|
||||
|
||||
protected createUserSchema(): mongoose.Schema {
|
||||
return UserSchema
|
||||
}
|
||||
|
||||
protected createRoleSchema(): mongoose.Schema {
|
||||
return RoleSchema
|
||||
}
|
||||
|
||||
protected createOperationLogSchema(): mongoose.Schema {
|
||||
return OperationLogSchema
|
||||
}
|
||||
|
||||
protected getUserModel(): Model<IUser> {
|
||||
if (!this._userModel) {
|
||||
this._userModel = mongoose.model<IUser>("shrimp_users", this.createUserSchema())
|
||||
}
|
||||
return this._userModel
|
||||
}
|
||||
|
||||
protected getRoleModel(): Model<IRole> {
|
||||
if (!this._roleModel) {
|
||||
this._roleModel = mongoose.model<IRole>("shrimp_roles", this.createRoleSchema())
|
||||
}
|
||||
return this._roleModel
|
||||
}
|
||||
|
||||
protected getOperationLogModel(): Model<IOperationLog> {
|
||||
if (!this._operationLogModel) {
|
||||
this._operationLogModel = mongoose.model<IOperationLog>("shrimp_operation_logs", this.createOperationLogSchema())
|
||||
}
|
||||
return this._operationLogModel
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Crud 工厂方法 ==============
|
||||
|
||||
protected createUserCrud(): MongoShrimpCrud<WithId<IUser>> {
|
||||
return new MongoShrimpCrud<WithId<IUser>>(this.getUserModel())
|
||||
}
|
||||
|
||||
protected createRoleCrud(): MongoShrimpCrud<WithId<IRole>> {
|
||||
return new MongoShrimpCrud<WithId<IRole>>(this.getRoleModel())
|
||||
}
|
||||
|
||||
protected createOperationLogCrud(): MongoShrimpCrud<WithId<IOperationLog>> {
|
||||
return new MongoShrimpCrud<WithId<IOperationLog>>(this.getOperationLogModel())
|
||||
}
|
||||
|
||||
// ============== 可覆盖的 Module 工厂方法 ==============
|
||||
|
||||
protected createUserModule(dataSource: ShrimpCrud<WithId<IUser>>): UserModule {
|
||||
return new UserModule(this, dataSource)
|
||||
}
|
||||
|
||||
protected createRoleModule(dataSource: ShrimpCrud<WithId<IRole>>): RoleModule {
|
||||
return new RoleModule(this, dataSource)
|
||||
}
|
||||
|
||||
protected createOperationLogModule(dataSource: ShrimpCrud<WithId<IOperationLog>>): OperationLogModule {
|
||||
return new OperationLogModule(this.userModule, dataSource)
|
||||
}
|
||||
|
||||
// ============== 可覆盖的用户/角色字段访问钩子(实现 IUserAccessor) ==============
|
||||
|
||||
/** 从用户对象生成 JWT payload,子类可覆盖 */
|
||||
getJwtPayload(user: WithId<IUser>): Record<string, any> {
|
||||
return { _id: user._id, roleId: user.roleId }
|
||||
}
|
||||
|
||||
/** 判断用户是否可用,子类可覆盖 */
|
||||
isUserActive(user: WithId<IUser>): boolean {
|
||||
return user.active === true
|
||||
}
|
||||
|
||||
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
|
||||
getUserRoleIds(user: WithId<IUser>): string[] {
|
||||
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
|
||||
}
|
||||
|
||||
/** 判断角色是否为超级管理员,子类可覆盖 */
|
||||
isRoleSuperAdmin(role: WithId<IRole> | null): boolean {
|
||||
return !!role?.isSuperAdmin
|
||||
}
|
||||
|
||||
/** 从角色对象提取权限列表,子类可覆盖 */
|
||||
getRolePowers(role: WithId<IRole> | null): string[] {
|
||||
return role?.powers || []
|
||||
}
|
||||
|
||||
/** 从用户对象提取显示名称,子类可覆盖 */
|
||||
getUserDisplayName(user: WithId<IUser>): string {
|
||||
return user.nickname || ""
|
||||
}
|
||||
|
||||
// ============== 启动钩子 ==============
|
||||
|
||||
/** 数据库连接后的回调,子类可覆盖以执行连接后的逻辑 */
|
||||
protected async onDbConnected(): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
/** 初始化默认数据(如默认角色、用户),子类可覆盖 */
|
||||
protected async initDefaultData(name: string = "Administrator", description: "System administrator with all permissions"): Promise<void> {
|
||||
const result = await this.userModule.dataSource.list({ limit: 1 })
|
||||
if (result.total > 0) return
|
||||
console.log("初始化默认数据")
|
||||
const adminRole = await this.roleModule.dataSource.add({
|
||||
name: name,
|
||||
description: description,
|
||||
isSuperAdmin: true,
|
||||
powers: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
await this.userModule.add({
|
||||
username: "admin",
|
||||
password: "admin",
|
||||
nickname: "Administrator",
|
||||
roleId: [adminRole._id],
|
||||
active: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 把 ShrimpServer 内联的 Express routes 抽成纯函数。
|
||||
*
|
||||
* routes 完全不知道:
|
||||
* - 数据库类型(只通过 dataSource 抽象访问)
|
||||
* - JWT(只通过 ITokenAuth 注入)
|
||||
* - 自己的字段名(通过 IUserAccessor 适配)
|
||||
*
|
||||
* 这让消费方可以:
|
||||
* - 把 JWT 换成 session(传入自己的 ITokenAuth 实现)
|
||||
* - 不用 ShrimpServer 装配体,自己拼 deps 直接调 buildShrimpRouter
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from "express"
|
||||
import { Shrimp } from "../Shrimp"
|
||||
import { ShrimpCode, IMeResult, IShrimpOperator, ShrimpError } from "../IShrimpType"
|
||||
import { IShrimpRouterDeps } from "./types"
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user: { _id: string; name: string; power: Set<string>; roleId: string[]; isSuperAdmin: boolean }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildShrimpRouter(deps: IShrimpRouterDeps): Router {
|
||||
const {
|
||||
shrimp, userModule, roleModule, operationLogModule,
|
||||
tokenAuth, config, globalOptions, forcedOptionAccess, userAccessor,
|
||||
} = deps
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 后台配置接口(不需要鉴权)
|
||||
router.get("/config", (_req: Request, res: Response) => {
|
||||
res.json(shrimp.success(config))
|
||||
})
|
||||
|
||||
// 登录接口(不需要鉴权)
|
||||
router.post("/login", async (req: Request, res: Response) => {
|
||||
const ip = req.ip || ""
|
||||
const { username, password } = req.body
|
||||
const args = [{ username, password }]
|
||||
try {
|
||||
const user = await userModule.getUser(username, password)
|
||||
const token = tokenAuth.sign(userAccessor.getJwtPayload(user))
|
||||
operationLogModule.record(user._id, ip, ["login"], args, true)
|
||||
res.json(shrimp.success({ token }))
|
||||
} catch (error: any) {
|
||||
const msg = error instanceof ShrimpError ? error.tip : { t: "shrimp.loginFailed" }
|
||||
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || "")
|
||||
operationLogModule.record(null, ip, ["login"], args, false, errMsg)
|
||||
res.json(shrimp.fail(ShrimpCode.FAIL, msg))
|
||||
}
|
||||
})
|
||||
|
||||
// 鉴权中间件
|
||||
router.use(async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const auth = req.headers.authorization
|
||||
if (!auth || !auth.startsWith("Bearer ")) {
|
||||
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, { t: "shrimp.relogin" }, "No token"))
|
||||
}
|
||||
const token = auth.split(" ")[1]
|
||||
const decoded = tokenAuth.verify(token)
|
||||
const user = await userModule.dataSource.get(decoded._id)
|
||||
if (!user || !userAccessor.isUserActive(user)) {
|
||||
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, { t: "shrimp.userDisabled" }, "User disabled"))
|
||||
}
|
||||
const roleIds = userAccessor.getUserRoleIds(user)
|
||||
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
|
||||
const isSuperAdmin = roles.some(r => userAccessor.isRoleSuperAdmin(r))
|
||||
const powers = isSuperAdmin
|
||||
? new Set((await shrimp.allPower()).map(p => p.key))
|
||||
: new Set(roles.flatMap(r => userAccessor.getRolePowers(r)))
|
||||
req.user = { _id: user._id, name: userAccessor.getUserDisplayName(user), power: powers, roleId: roleIds, isSuperAdmin }
|
||||
next()
|
||||
} catch (error: any) {
|
||||
const msg = error instanceof ShrimpError ? error.tip : { t: "shrimp.relogin" }
|
||||
return res.json(shrimp.fail(ShrimpCode.UNAUTHORIZED, msg))
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前用户信息
|
||||
router.get("/me", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const moduleInfo = await userModule.getModule(req.user._id, roleModule)
|
||||
const user = await userModule.dataSource.get(req.user._id)
|
||||
if (!user) {
|
||||
return res.json(shrimp.fail(ShrimpCode.NOT_FOUND, { t: "shrimp.userNotFound" }))
|
||||
}
|
||||
const roleIds = userAccessor.getUserRoleIds(user)
|
||||
const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id)))
|
||||
const isSuperAdmin = roles.some(r => userAccessor.isRoleSuperAdmin(r))
|
||||
const powers = isSuperAdmin
|
||||
? (await shrimp.allPower()).map(p => p.key)
|
||||
: roles.flatMap(r => userAccessor.getRolePowers(r))
|
||||
const filteredMenuTree = shrimp.filterMenuTree(shrimp.getMenuTree(), powers)
|
||||
// 按需收集该用户有权限的模块中引用的全局 options key(自动去重)
|
||||
const neededKeys = new Set<string>()
|
||||
for (const mod of moduleInfo) {
|
||||
for (const field of mod.fields || []) {
|
||||
if (typeof field.options === "string") {
|
||||
neededKeys.add(field.options)
|
||||
}
|
||||
}
|
||||
}
|
||||
const filteredOptions: Record<string, any> = {}
|
||||
for (const key of neededKeys) {
|
||||
if (globalOptions[key] != null) {
|
||||
filteredOptions[key] = globalOptions[key]
|
||||
}
|
||||
}
|
||||
for (const [key, access] of forcedOptionAccess) {
|
||||
if (globalOptions[key] != null) {
|
||||
if (isSuperAdmin || powers.includes(access.powerKey)) {
|
||||
filteredOptions[key] = globalOptions[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
res.json(shrimp.success<IMeResult>({
|
||||
nickname: userAccessor.getUserDisplayName(user),
|
||||
moduleInfo,
|
||||
menuTree: filteredMenuTree,
|
||||
options: filteredOptions,
|
||||
}))
|
||||
} catch (error: any) {
|
||||
if (error instanceof ShrimpError) {
|
||||
res.json(shrimp.fail(error.code, error.tip))
|
||||
} else {
|
||||
res.json(shrimp.fail(ShrimpCode.FAIL, { t: "shrimp.serverError" }, error.message))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 通用事件调用接口
|
||||
router.post("/*path", async (req: Request, res: Response) => {
|
||||
const raw = req.params.path
|
||||
const path = (Array.isArray(raw) ? raw.join("/") : raw).split("/").filter(Boolean)
|
||||
const args = Array.isArray(req.body) ? req.body : [req.body]
|
||||
const ip = req.ip || ""
|
||||
const shouldLog = operationLogModule.shouldLog(path)
|
||||
try {
|
||||
if (path.length === 0) {
|
||||
return res.json(shrimp.fail(ShrimpCode.FAIL, { t: "shrimp.emptyPath" }))
|
||||
}
|
||||
const powers = req.user.power
|
||||
if (!shrimp.checkEvent(powers, path)) {
|
||||
return res.json(shrimp.fail(ShrimpCode.FORBIDDEN, { t: "shrimp.forbidden" }))
|
||||
}
|
||||
const operator: IShrimpOperator = {
|
||||
userId: req.user._id,
|
||||
roleId: req.user.roleId,
|
||||
isSuperAdmin: req.user.isSuperAdmin,
|
||||
}
|
||||
const result = await Shrimp.asyncLocalStorage.run(operator, () => shrimp.call(path, ...args))
|
||||
if (shouldLog) operationLogModule.record(req.user._id, ip, path, args, true)
|
||||
res.json(shrimp.success(result))
|
||||
} catch (error: any) {
|
||||
if (shouldLog) {
|
||||
const errMsg = error instanceof ShrimpError ? JSON.stringify(error.tip) : (error?.message || "")
|
||||
operationLogModule.record(req.user?._id ?? null, ip, path, args, false, errMsg)
|
||||
}
|
||||
if (error instanceof ShrimpError) {
|
||||
res.json(shrimp.fail(error.code, error.tip))
|
||||
} else {
|
||||
res.json(shrimp.fail(ShrimpCode.SERVER_ERROR, { t: "shrimp.serverError" }, error.message))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shrimp 服务端入口 — 包含 Express + Mongoose + JWT 实现
|
||||
*
|
||||
* 在 core 之上额外暴露:
|
||||
* - ShrimpServer:Express Router + JWT 鉴权 + Mongo 连接(开箱即用装配体)
|
||||
* - buildShrimpRouter / ITokenAuth / IUserAccessor:自己拼路由的低阶 API
|
||||
* - createJwtTokenAuth:默认 JWT 适配器(也可换成 session 实现)
|
||||
* - MongoShrimpCrud:Mongoose 数据源
|
||||
* - 内置 User/Role/OperationLog 的 Mongoose Schema
|
||||
*
|
||||
* 这一入口需要消费方安装 peer 依赖:express / mongoose / jsonwebtoken / bcrypt。
|
||||
* 不需要这些时请改从 'shrimp-server' 导入,不会触发任何 peer。
|
||||
*/
|
||||
|
||||
// 再导出所有 core 的符号,'shrimp-server/server' 单一入口就够用
|
||||
export * from "../core"
|
||||
|
||||
// ===== 服务端装配体 =====
|
||||
export {
|
||||
ShrimpServer,
|
||||
UserSchema,
|
||||
RoleSchema,
|
||||
OperationLogSchema,
|
||||
} from "./ShrimpServer"
|
||||
|
||||
// ===== 低阶 API:自己拼路由时用 =====
|
||||
export { buildShrimpRouter } from "./buildShrimpRouter"
|
||||
export { createJwtTokenAuth } from "./jwtAuth"
|
||||
export type { IJwtTokenAuthOptions } from "./jwtAuth"
|
||||
export type { ITokenAuth, IUserAccessor, IShrimpRouterDeps } from "./types"
|
||||
|
||||
// ===== Mongoose 数据源 =====
|
||||
export { MongoShrimpCrud } from "../MongoShrimpCrud"
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* JWT 实现 ITokenAuth — buildShrimpRouter 的默认 token 适配器
|
||||
*
|
||||
* 通过工厂函数注入 secret 和 expiresIn,routes 完全不需要 import jsonwebtoken。
|
||||
*/
|
||||
|
||||
import jsonwebtoken from "jsonwebtoken"
|
||||
import { ITokenAuth } from "./types"
|
||||
|
||||
export interface IJwtTokenAuthOptions {
|
||||
secret: string
|
||||
/** 过期时间,默认 '7d' */
|
||||
expiresIn?: string | number
|
||||
}
|
||||
|
||||
export function createJwtTokenAuth(secretOrOptions: string | IJwtTokenAuthOptions): ITokenAuth {
|
||||
const opts: IJwtTokenAuthOptions = typeof secretOrOptions === "string"
|
||||
? { secret: secretOrOptions }
|
||||
: secretOrOptions
|
||||
const expiresIn = opts.expiresIn ?? "7d"
|
||||
|
||||
return {
|
||||
sign(payload) {
|
||||
return jsonwebtoken.sign(payload, opts.secret, { expiresIn } as jsonwebtoken.SignOptions)
|
||||
},
|
||||
verify(token) {
|
||||
return jsonwebtoken.verify(token, opts.secret) as Record<string, any>
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* server 子层共用接口
|
||||
*
|
||||
* 这些接口让 buildShrimpRouter 不直接依赖 ShrimpServer 类。
|
||||
* ShrimpServer 提供默认装配体,但消费方也可以自己拼。
|
||||
*/
|
||||
|
||||
import type { Router } from "express"
|
||||
import type { Shrimp, UserModule, RoleModule, RootShrimpPower } from "../Shrimp"
|
||||
import type { OperationLogModule } from "../OperationLogModule"
|
||||
import type { IUser, IRole, WithId } from "../IShrimpType"
|
||||
|
||||
/**
|
||||
* Token 鉴权抽象 — 替代 JWT 直接调用
|
||||
*
|
||||
* 默认实现 createJwtTokenAuth(secret) 用 jsonwebtoken;消费方可以换成
|
||||
* session / cookie / 自定义签名,只要满足:
|
||||
* - sign(payload) 产生 token 字符串
|
||||
* - verify(token) 解出 payload(失败抛错)
|
||||
*/
|
||||
export interface ITokenAuth {
|
||||
sign(payload: Record<string, any>): string
|
||||
verify(token: string): Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户/角色字段访问器 — 让 routes 不直接知道 user/role 的字段名
|
||||
*
|
||||
* 默认实现读 IUser / IRole 的标准字段(username, password, roleId, active,
|
||||
* isSuperAdmin, powers, nickname);消费方覆盖这些方法即可适配自己的 schema。
|
||||
*/
|
||||
export interface IUserAccessor {
|
||||
getJwtPayload(user: WithId<IUser>): Record<string, any>
|
||||
isUserActive(user: WithId<IUser>): boolean
|
||||
getUserRoleIds(user: WithId<IUser>): string[]
|
||||
isRoleSuperAdmin(role: WithId<IRole> | null): boolean
|
||||
getRolePowers(role: WithId<IRole> | null): string[]
|
||||
getUserDisplayName(user: WithId<IUser>): string
|
||||
}
|
||||
|
||||
/** buildShrimpRouter 需要的全部协作者 */
|
||||
export interface IShrimpRouterDeps {
|
||||
shrimp: Shrimp
|
||||
userModule: UserModule
|
||||
roleModule: RoleModule
|
||||
operationLogModule: OperationLogModule
|
||||
tokenAuth: ITokenAuth
|
||||
config: Record<string, any>
|
||||
globalOptions: Record<string, any>
|
||||
forcedOptionAccess: Map<string, RootShrimpPower>
|
||||
userAccessor: IUserAccessor
|
||||
}
|
||||
|
||||
// 拼接 Express 时给 Router/Req 用的扩展类型(在 buildShrimpRouter 处声明 global)
|
||||
export type { Router }
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* buildShrimpRouter 独立装配冒烟测试
|
||||
*
|
||||
* 验证 buildShrimpRouter 不依赖 ShrimpServer 类,消费方完全可以:
|
||||
* - 用 MemoryShrimpCrud(而非 MongoShrimpCrud)
|
||||
* - 自己造 ITokenAuth(而非默认 JWT)
|
||||
* - 自己实现 IUserAccessor
|
||||
* 然后拼出可工作的 Express Router。
|
||||
*
|
||||
* 这是"内部解耦"的核心契约 — 如果哪天 buildShrimpRouter 偷偷耦合回
|
||||
* ShrimpServer / mongoose / jsonwebtoken,本测试会编译失败或运行失败。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import express from "express"
|
||||
import {
|
||||
Shrimp,
|
||||
MemoryShrimpCrud,
|
||||
UserModule,
|
||||
RoleModule,
|
||||
OperationLogModule,
|
||||
plainHasher,
|
||||
} from "../src/core"
|
||||
import { buildShrimpRouter } from "../src/server/buildShrimpRouter"
|
||||
import type { ITokenAuth, IUserAccessor } from "../src/server/types"
|
||||
import type { WithId, IUser, IRole } from "../src/core"
|
||||
|
||||
function makeFakeTokenAuth(): ITokenAuth {
|
||||
const store = new Map<string, Record<string, any>>()
|
||||
let seq = 0
|
||||
return {
|
||||
sign(payload) {
|
||||
const tok = `tok-${++seq}`
|
||||
store.set(tok, payload)
|
||||
return tok
|
||||
},
|
||||
verify(token) {
|
||||
const payload = store.get(token)
|
||||
if (!payload) throw new Error("invalid token")
|
||||
return payload
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const userAccessor: IUserAccessor = {
|
||||
getJwtPayload: (user) => ({ _id: user._id }),
|
||||
isUserActive: (user) => user.active === true,
|
||||
getUserRoleIds: (user) =>
|
||||
Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [],
|
||||
isRoleSuperAdmin: (role) => !!role?.isSuperAdmin,
|
||||
getRolePowers: (role) => role?.powers || [],
|
||||
getUserDisplayName: (user) => user.nickname || "",
|
||||
}
|
||||
|
||||
async function makeApp() {
|
||||
Shrimp.passwordHasher = plainHasher
|
||||
|
||||
const shrimp = new Shrimp()
|
||||
const userCrud = new MemoryShrimpCrud<WithId<IUser>>()
|
||||
const roleCrud = new MemoryShrimpCrud<WithId<IRole>>()
|
||||
const logCrud = new MemoryShrimpCrud<any>()
|
||||
|
||||
const userModule = new UserModule(shrimp, userCrud)
|
||||
const roleModule = new RoleModule(shrimp, roleCrud)
|
||||
const operationLogModule = new OperationLogModule(userModule, logCrud)
|
||||
|
||||
const adminRole = await roleCrud.add({
|
||||
name: "admin",
|
||||
description: "",
|
||||
isSuperAdmin: true,
|
||||
powers: [],
|
||||
} as any)
|
||||
await userModule.add({
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
nickname: "Alice",
|
||||
roleId: [adminRole._id],
|
||||
active: true,
|
||||
} as any)
|
||||
|
||||
const tokenAuth = makeFakeTokenAuth()
|
||||
|
||||
const router = buildShrimpRouter({
|
||||
shrimp,
|
||||
userModule,
|
||||
roleModule,
|
||||
operationLogModule,
|
||||
tokenAuth,
|
||||
config: { title: "smoke" },
|
||||
globalOptions: {},
|
||||
forcedOptionAccess: new Map(),
|
||||
userAccessor,
|
||||
})
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use(router)
|
||||
return app
|
||||
}
|
||||
|
||||
async function jsonFetch(app: express.Express, method: string, path: string, body?: any, headers: Record<string, string> = {}) {
|
||||
return new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const port = (server.address() as any).port
|
||||
const data = body == null ? undefined : JSON.stringify(body)
|
||||
const req = require("http").request(
|
||||
{
|
||||
method,
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(data ? { "content-length": Buffer.byteLength(data) } : {}),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res: any) => {
|
||||
let buf = ""
|
||||
res.on("data", (c: any) => (buf += c))
|
||||
res.on("end", () => {
|
||||
server.close()
|
||||
try {
|
||||
resolve({ status: res.statusCode, body: JSON.parse(buf) })
|
||||
} catch (e) {
|
||||
reject(new Error("non-JSON body: " + buf))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
req.on("error", reject)
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("buildShrimpRouter 独立装配", () => {
|
||||
it("可以用纯内存 CRUD + 假 token 鉴权拼出可工作的 Router", async () => {
|
||||
const app = await makeApp()
|
||||
|
||||
// /config 不需要鉴权
|
||||
const cfg = await jsonFetch(app, "GET", "/config")
|
||||
expect(cfg.body.data).toEqual({ title: "smoke" })
|
||||
|
||||
// /login 用注入的 IUserAccessor + fake tokenAuth
|
||||
const login = await jsonFetch(app, "POST", "/login", { username: "alice", password: "secret" })
|
||||
expect(login.body.code).toBe(0)
|
||||
const token = login.body.data.token
|
||||
expect(typeof token).toBe("string")
|
||||
|
||||
// 无 token 拒绝
|
||||
const noTok = await jsonFetch(app, "GET", "/me")
|
||||
expect(noTok.body.code).not.toBe(0)
|
||||
|
||||
// 有 token 通过
|
||||
const me = await jsonFetch(app, "GET", "/me", undefined, { authorization: `Bearer ${token}` })
|
||||
expect(me.body.code).toBe(0)
|
||||
expect(me.body.data.nickname).toBe("Alice")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user