diff --git a/package.json b/package.json index 267da1a..b48e03b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/ShrimpServer.ts b/src/ShrimpServer.ts deleted file mode 100644 index 2dde6d4..0000000 --- a/src/ShrimpServer.ts +++ /dev/null @@ -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; 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 - globalOptions: Record = {} - forcedOptionAccess = new Map() - - registerOptions(key: string, options: any, access?: RootShrimpPower) { - this.globalOptions[key] = options - if (access) { - this.forcedOptionAccess.set(key, access) - } - } - - /** 缓存的 Mongoose Model,避免重复注册 */ - protected _userModel?: Model - protected _roleModel?: Model - protected _operationLogModel?: Model - - constructor(jwtSecret: string, config: Record = {}, 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() - for (const mod of moduleInfo) { - for (const field of mod.fields || []) { - if (typeof field.options === 'string') { - neededKeys.add(field.options) - } - } - } - const filteredOptions: Record = {} - 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({ - 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 { - if (!this._userModel) { - this._userModel = mongoose.model('shrimp_users', this.createUserSchema()) - } - return this._userModel - } - - protected getRoleModel(): Model { - if (!this._roleModel) { - this._roleModel = mongoose.model('shrimp_roles', this.createRoleSchema()) - } - return this._roleModel - } - - protected getOperationLogModel(): Model { - if (!this._operationLogModel) { - this._operationLogModel = mongoose.model('shrimp_operation_logs', this.createOperationLogSchema()) - } - return this._operationLogModel - } - - // ============== 可覆盖的 Crud 工厂方法 ============== - - protected createUserCrud(): MongoShrimpCrud> { - return new MongoShrimpCrud>(this.getUserModel()) - } - - protected createRoleCrud(): MongoShrimpCrud> { - return new MongoShrimpCrud>(this.getRoleModel()) - } - - protected createOperationLogCrud(): MongoShrimpCrud> { - return new MongoShrimpCrud>(this.getOperationLogModel()) - } - - // ============== 可覆盖的 Module 工厂方法 ============== - - protected createUserModule(dataSource: ShrimpCrud>): UserModule { - return new UserModule(this, dataSource) - } - - protected createRoleModule(dataSource: ShrimpCrud>): RoleModule { - return new RoleModule(this, dataSource) - } - - protected createOperationLogModule(dataSource: ShrimpCrud>): OperationLogModule { - return new OperationLogModule(this.userModule, dataSource) - } - - // ============== 可覆盖的用户/角色字段访问钩子 ============== - - /** 从用户对象生成 JWT payload,子类可覆盖 */ - protected getJwtPayload(user: WithId): Record { - return { _id: user._id, roleId: user.roleId } - } - - /** 判断用户是否可用,子类可覆盖 */ - protected isUserActive(user: WithId): boolean { - return user.active === true - } - - /** 从用户对象提取角色 ID 列表,子类可覆盖 */ - protected getUserRoleIds(user: WithId): string[] { - return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [] - } - - /** 判断角色是否为超级管理员,子类可覆盖 */ - protected isRoleSuperAdmin(role: WithId | null): boolean { - return !!role?.isSuperAdmin - } - - /** 从角色对象提取权限列表,子类可覆盖 */ - protected getRolePowers(role: WithId | null): string[] { - return role?.powers || [] - } - - /** 从用户对象提取显示名称,子类可覆盖 */ - protected getUserDisplayName(user: WithId): string { - return user.nickname || '' - } - - /** 数据库连接后的回调,子类可覆盖以执行连接后的逻辑 */ - protected async onDbConnected(): Promise { - - } - - /** 初始化默认数据(如默认角色、用户),子类可覆盖 */ - protected async initDefaultData(name: string = "Administrator", description: "System administrator with all permissions"): Promise { - 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() - }) - } -} diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index b0d5da3..0000000 --- a/src/server.ts +++ /dev/null @@ -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" diff --git a/src/server/ShrimpServer.ts b/src/server/ShrimpServer.ts new file mode 100644 index 0000000..414aa80 --- /dev/null +++ b/src/server/ShrimpServer.ts @@ -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 + globalOptions: Record = {} + forcedOptionAccess = new Map() + + registerOptions(key: string, options: any, access?: RootShrimpPower) { + this.globalOptions[key] = options + if (access) { + this.forcedOptionAccess.set(key, access) + } + } + + /** 缓存的 Mongoose Model,避免重复注册 */ + protected _userModel?: Model + protected _roleModel?: Model + protected _operationLogModel?: Model + + constructor(jwtSecret: string, config: Record = {}, 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 { + if (!this._userModel) { + this._userModel = mongoose.model("shrimp_users", this.createUserSchema()) + } + return this._userModel + } + + protected getRoleModel(): Model { + if (!this._roleModel) { + this._roleModel = mongoose.model("shrimp_roles", this.createRoleSchema()) + } + return this._roleModel + } + + protected getOperationLogModel(): Model { + if (!this._operationLogModel) { + this._operationLogModel = mongoose.model("shrimp_operation_logs", this.createOperationLogSchema()) + } + return this._operationLogModel + } + + // ============== 可覆盖的 Crud 工厂方法 ============== + + protected createUserCrud(): MongoShrimpCrud> { + return new MongoShrimpCrud>(this.getUserModel()) + } + + protected createRoleCrud(): MongoShrimpCrud> { + return new MongoShrimpCrud>(this.getRoleModel()) + } + + protected createOperationLogCrud(): MongoShrimpCrud> { + return new MongoShrimpCrud>(this.getOperationLogModel()) + } + + // ============== 可覆盖的 Module 工厂方法 ============== + + protected createUserModule(dataSource: ShrimpCrud>): UserModule { + return new UserModule(this, dataSource) + } + + protected createRoleModule(dataSource: ShrimpCrud>): RoleModule { + return new RoleModule(this, dataSource) + } + + protected createOperationLogModule(dataSource: ShrimpCrud>): OperationLogModule { + return new OperationLogModule(this.userModule, dataSource) + } + + // ============== 可覆盖的用户/角色字段访问钩子(实现 IUserAccessor) ============== + + /** 从用户对象生成 JWT payload,子类可覆盖 */ + getJwtPayload(user: WithId): Record { + return { _id: user._id, roleId: user.roleId } + } + + /** 判断用户是否可用,子类可覆盖 */ + isUserActive(user: WithId): boolean { + return user.active === true + } + + /** 从用户对象提取角色 ID 列表,子类可覆盖 */ + getUserRoleIds(user: WithId): string[] { + return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [] + } + + /** 判断角色是否为超级管理员,子类可覆盖 */ + isRoleSuperAdmin(role: WithId | null): boolean { + return !!role?.isSuperAdmin + } + + /** 从角色对象提取权限列表,子类可覆盖 */ + getRolePowers(role: WithId | null): string[] { + return role?.powers || [] + } + + /** 从用户对象提取显示名称,子类可覆盖 */ + getUserDisplayName(user: WithId): string { + return user.nickname || "" + } + + // ============== 启动钩子 ============== + + /** 数据库连接后的回调,子类可覆盖以执行连接后的逻辑 */ + protected async onDbConnected(): Promise { + + } + + /** 初始化默认数据(如默认角色、用户),子类可覆盖 */ + protected async initDefaultData(name: string = "Administrator", description: "System administrator with all permissions"): Promise { + 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(), + }) + } +} diff --git a/src/server/buildShrimpRouter.ts b/src/server/buildShrimpRouter.ts new file mode 100644 index 0000000..66030eb --- /dev/null +++ b/src/server/buildShrimpRouter.ts @@ -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; 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() + for (const mod of moduleInfo) { + for (const field of mod.fields || []) { + if (typeof field.options === "string") { + neededKeys.add(field.options) + } + } + } + const filteredOptions: Record = {} + 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({ + 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 +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..5bf11f2 --- /dev/null +++ b/src/server/index.ts @@ -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" diff --git a/src/server/jwtAuth.ts b/src/server/jwtAuth.ts new file mode 100644 index 0000000..72b6951 --- /dev/null +++ b/src/server/jwtAuth.ts @@ -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 + }, + } +} diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..467f73a --- /dev/null +++ b/src/server/types.ts @@ -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 + verify(token: string): Record +} + +/** + * 用户/角色字段访问器 — 让 routes 不直接知道 user/role 的字段名 + * + * 默认实现读 IUser / IRole 的标准字段(username, password, roleId, active, + * isSuperAdmin, powers, nickname);消费方覆盖这些方法即可适配自己的 schema。 + */ +export interface IUserAccessor { + getJwtPayload(user: WithId): Record + isUserActive(user: WithId): boolean + getUserRoleIds(user: WithId): string[] + isRoleSuperAdmin(role: WithId | null): boolean + getRolePowers(role: WithId | null): string[] + getUserDisplayName(user: WithId): string +} + +/** buildShrimpRouter 需要的全部协作者 */ +export interface IShrimpRouterDeps { + shrimp: Shrimp + userModule: UserModule + roleModule: RoleModule + operationLogModule: OperationLogModule + tokenAuth: ITokenAuth + config: Record + globalOptions: Record + forcedOptionAccess: Map + userAccessor: IUserAccessor +} + +// 拼接 Express 时给 Router/Req 用的扩展类型(在 buildShrimpRouter 处声明 global) +export type { Router } diff --git a/test/buildShrimpRouter.test.ts b/test/buildShrimpRouter.test.ts new file mode 100644 index 0000000..aad8830 --- /dev/null +++ b/test/buildShrimpRouter.test.ts @@ -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>() + 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>() + const roleCrud = new MemoryShrimpCrud>() + const logCrud = new MemoryShrimpCrud() + + 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 = {}) { + 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") + }) +})