- 6 个框架源文件搬到 src/, 加 src/index.ts barrel 作为公共 API 入口 - package.json 标 private, main/types 指 src/index.ts (ts-node 消费方编译) - 加 vitest + 22 条单元测试 (ILabel / MemoryShrimpCrud) 全通过 - examples/basic-admin/ 用 MemoryShrimpCrud 跑零 Mongo 依赖 demo 待办: ShrimpServer 强耦合 Mongo+JWT+Express, crudRegistry 用 .model 判断数据源, 这两个问题在 demo 中已暴露, 计划后续重构补到 90 分。
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
}
|
|
}
|