diff --git a/README.md b/README.md index 99f5dea..ab7fef8 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ ```sh # 推荐: HTTP(任何能访问 git.zeromvc.com 的机器都能用) -npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.4.0 +npm install git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.5.0 # 也可以 SSH(需要 ssh key 配好) -npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.4.0 +npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.5.0 ``` > 仓库 ship `.ts` 源码,消费方项目需要 TS 运行器(`tsx` / `ts-node`)或在打包工具(vite / webpack / esbuild)里能解析 `.ts`。 @@ -32,11 +32,12 @@ npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.4.0 `tsx` 用户不需要这一步(tsx 默认就编译 node_modules 内的 .ts)。 -### TypeScript 用户:让 tsc 识别 `shrimp-server/server` 子路径 +### TypeScript 用户:让 tsc 识别子路径 -包用 package.json `exports` 字段划分了 `'shrimp-server'`(0 重依赖核心)和 -`'shrimp-server/server'`(Express 装配体)两个入口。**TS 默认的 `moduleResolution: "node"` -不读 `exports`,会找不到子路径模块。** 两个解法二选一: +包用 package.json `exports` 字段划分了 `'shrimp-server'`(0 重依赖核心)、 +`'shrimp-server/expmon'`(Express + Mongoose 整合方案)和 `'shrimp-server/types'` +(纯类型)三个入口。**TS 默认的 `moduleResolution: "node"` 不读 `exports`,会找不到子路径模块。** +两个解法二选一: ```jsonc // 方案 A: 升 moduleResolution(配合 module: node16,严格但纯净) @@ -52,8 +53,9 @@ npm install git+ssh://git@git.zeromvc.com:wc24/shrimp-server.git#v0.4.0 "module": "commonjs", "baseUrl": ".", "paths": { - "shrimp-server": ["node_modules/shrimp-server/src/core.ts"], - "shrimp-server/server": ["node_modules/shrimp-server/src/server/index.ts"] + "shrimp-server": ["node_modules/shrimp-server/src/main/index.ts"], + "shrimp-server/expmon": ["node_modules/shrimp-server/src/main/expmon.ts"], + "shrimp-server/types": ["node_modules/shrimp-server/src/main/types.ts"] } } ``` @@ -63,14 +65,14 @@ Node 22+ 的 CJS 加载器 **会**读 `exports`,所以运行时不需要额外 ### 三个入口,按需选择 -包内提供了**核心**、**完整 server** 和**纯类型**三个 entry,**peer deps 全部 optional**——你只装自己用得到的那一份: +包内提供**核心**、**expmon(Express+Mongo 整合)**和**纯类型**三个 entry,**peer deps 全部 optional**——你只装自己用得到的那一份: ```ts // 1) 核心: 0 重依赖(只需 node + tsx),适合自己接 HTTP / 只用内存 CRUD / 嵌入其它框架 import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "shrimp-server" -// 2) 完整 server: 自带 Express Router + Mongoose + JWT,需要装上面 4 个 peer -import { ShrimpServer, MongoShrimpCrud } from "shrimp-server/server" +// 2) expmon: Express + Mongoose + JWT 整合方案,需要装上面 4 个 peer +import { ShrimpServer, MongoShrimpCrud, MongoShrimp } from "shrimp-server/expmon" // 3) 纯类型: 只含 IShrimpType.ts(types + ShrimpCode enum + ShrimpError class) // 给前端 / shrimp-admin / 任何"只要协议、不要框架运行时"的消费方 diff --git a/package.json b/package.json index 39a803c..ff47c40 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,22 @@ { "name": "shrimp-server", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "Shrimp 框架后端 — 声明式字段驱动的 Node.js 管理后台框架(Express + Mongoose + JWT)", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "src/main/index.ts", + "types": "src/main/index.ts", "exports": { ".": { - "types": "./src/core.ts", - "default": "./src/core.ts" + "types": "./src/main/index.ts", + "default": "./src/main/index.ts" }, - "./server": { - "types": "./src/server/index.ts", - "default": "./src/server/index.ts" + "./expmon": { + "types": "./src/main/expmon.ts", + "default": "./src/main/expmon.ts" }, "./types": { - "types": "./src/IShrimpType.ts", - "default": "./src/IShrimpType.ts" + "types": "./src/main/types.ts", + "default": "./src/main/types.ts" }, "./package.json": "./package.json" }, diff --git a/src/MemoryShrimpCrud.ts b/src/MemoryShrimpCrud.ts index 4ca5fed..8a64719 100644 --- a/src/MemoryShrimpCrud.ts +++ b/src/MemoryShrimpCrud.ts @@ -1,4 +1,4 @@ -import { ShrimpCrud } from './Shrimp' +import type { ShrimpCrud } from './Shrimp/ShrimpCrud' import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, isShrimpCondition } from './IShrimpType' export class MemoryShrimpCrud implements ShrimpCrud { diff --git a/src/Shrimp.ts b/src/Shrimp.ts deleted file mode 100644 index c0c6339..0000000 --- a/src/Shrimp.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * Shrimp - * 后台管理系统快速开发框架 - */ - -import { ShrimpCode, IShrimpResponse, ITableCrudProto, IModuleInfo, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult, IFieldConfig, IFieldRule, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate, ShrimpConfig, IShrimpOperator, IMenuItem, ShrimpError, ShrimpFieldTypes, ILabel } from "./IShrimpType" - -import { AsyncLocalStorage } from 'async_hooks' -import { IPasswordHasher, bcryptHasher } from './PasswordHasher' - -export class Shrimp { - root: Shrimp = this - modules: Map = new Map() - /** 全局 CRUD 注册表,用于 MongoShrimpCrud 自动解析 relation */ - crudRegistry: Map> = new Map() - /** - * 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。 - * 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher` 或自定义实现, - * 让 Password 字段 reqFilter / UserModule.verifyPassword 都走自定义逻辑。 - */ - static passwordHasher: IPasswordHasher = bcryptHasher - /** 请求级操作者上下文,通过 AsyncLocalStorage 避免并发覆盖 */ - static asyncLocalStorage = new AsyncLocalStorage() - get operator(): IShrimpOperator | null { - return Shrimp.asyncLocalStorage.getStore() ?? null - } - async call(path: string[], ...args: any[]): Promise { - let module = this.modules.get(path[0]) - if (module == null) { - throw new Error(`Module "${path[0]}" not found`) - } - return await module.call(path.slice(1), ...args) - } - checkEvent(powers: Set, path: string[]): boolean { - let module = this.modules.get(path[0]) - if (module == null) return false - return module.checkEvent(powers, path.slice(1)) - } - getModuleInfo(powers: Set): IModuleInfo[] { - const list: IModuleInfo[] = [] - this.collectModuleInfo(this.modules, powers, undefined, list) - return list - } - - private collectModuleInfo(modules: Map, powers: Set, parent: string | undefined, out: IModuleInfo[]) { - modules.forEach(module => { - if (!powers.has(module.power.key)) return - const info: IModuleInfo = { - moduleKey: module.moduleKey, - events: module.getEventInfo(powers), - label: module.label, - fields: module instanceof ShrimpTableModule ? module.getFields() : [], - } - if (module.icon) info.icon = module.icon - if (parent) info.parent = parent - out.push(info) - if (module.modules.size > 0) { - this.collectModuleInfo(module.modules, powers, module.moduleKey, out) - } - }) - } - - /** 成功响应 */ - success(data: T, tip?: ILabel, log?: string): IShrimpResponse { - const r: IShrimpResponse = { code: ShrimpCode.OK, data } - if (tip) r.tip = tip - if (log) r.log = log - return r - } - - /** 失败响应 */ - fail(code: ShrimpCode, tip?: ILabel, log?: string): IShrimpResponse { - const r: IShrimpResponse = { code } - if (tip) r.tip = tip - if (log) r.log = log - return r - } - - async allPower(): Promise<{ key: string; label: ILabel }[]> { - const powers: { key: string; label: ILabel }[] = [] - - const collect = (module: any) => { - // 模块权限 - powers.push({ key: module.power.key, label: module.power.label }) - // 事件权限(children 已有 key 和 label) - powers.push(...module.power.children) - // 递归子模块 - module.modules.forEach(collect) - } - - this.root.modules.forEach(collect) - - // 去重 - const map = new Map() - powers.forEach(p => map.set(p.key, p.label)) - return Array.from(map.entries()).map(([key, label]) => ({ key, label })) - } - - /** 菜单树,默认平铺所有模块(label 用模块注册时声明的 label),子类可覆盖为分组结构 */ - getMenuTree(): IMenuItem[] { - const items: IMenuItem[] = [] - this.modules.forEach(module => { - items.push({ - name: module.moduleKey, - label: module.label, - moduleKey: module.moduleKey - }) - }) - return items - } - - /** 根据权限过滤菜单树(递归处理 children;children 全部被过滤后,分组节点也一并剔除) */ - filterMenuTree(fullMenuTree: IMenuItem[], rolePower: string[]): IMenuItem[] { - const allowedModules = new Set(rolePower.map(p => p.split('.')[0])) - const filter = (nodes: IMenuItem[]): IMenuItem[] => { - const result: IMenuItem[] = [] - for (const node of nodes) { - if (node.moduleKey) { - if (allowedModules.has(node.moduleKey)) { - result.push(node) - } - continue - } - if (!node.children || node.children.length === 0) { - continue - } - const filteredChildren = filter(node.children) - if (filteredChildren.length === 0) continue - result.push({ ...node, children: filteredChildren }) - } - return result - } - return filter(fullMenuTree) - } -} - - -type ShrimpEventHandler = (...args: any[]) => Promise - -export class ShrimpEvent { - constructor(public name: string, public handler: ShrimpEventHandler, public power: RootShrimpPower) { - } -} -export interface IShrimpModuleOptions { - icon?: string // 前端子模块跳转按钮图标(element-plus icon 名) - ownerKey?: string // 子表→父记录归属字段名(子模块专用,语义: 主人/上级/归属)。前端据此自动过滤和回填默认值 -} - -export class ShrimpModule

= Record> { - power: RootShrimpPower - root: Shrimp - modules: Map = new Map() - events: Map = new Map() - icon?: string - ownerKey?: string - constructor(public parent: Shrimp | ShrimpModule, public moduleKey: string, public label: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) { - if (power == null) { - if (parent instanceof Shrimp) { - power = new RootShrimpPower(moduleKey, label) - } - else { - power = new ShrimpPower(parent.power, moduleKey, label) - } - } - this.power = power - this.icon = options?.icon - this.ownerKey = options?.ownerKey - parent.modules.set(this.moduleKey, this) - this.root = parent.root - } - addEvent(name: K, label: ILabel): this - addEvent(name: K, power: RootShrimpPower): this - addEvent(name: K, powerOrLabel: RootShrimpPower | ILabel) { - const handler = (this as any)[name] - if (typeof handler !== 'function') { - throw new Error(`Method "${name}" not found on ${this.moduleKey}`) - } - let power: RootShrimpPower - if (powerOrLabel instanceof RootShrimpPower) { - power = powerOrLabel - } else { - power = new ShrimpPower(this.power, name, powerOrLabel) - } - this.events.set(name, new ShrimpEvent(name, handler.bind(this), power)) - return this - } - async call(path: string[], ...args: any[]): Promise { - if (path.length > 1) { - let module = this.modules.get(path[0]) - if (module == null) { - throw new Error(`Module "${path[0]}" not found`) - } - return await module.call(path.slice(1), ...args) - } else { - const event = this.events.get(path[0]) - if (event == null) { - throw new Error(`Event "${path[0]}" not found`) - } - return await event.handler(...args) - } - } - getEventInfo(powers: Set): string[] { - let events: string[] = [] - this.events.forEach(event => { - if (powers.has(event.power.key)) { - events.push(event.name) - } - }) - return events - } - checkEvent(powers: Set, path: string[]): boolean { - if (path.length > 1) { - let module = this.modules.get(path[0]) - if (module == null) return false - return module.checkEvent(powers, path.slice(1)) - } else { - const event = this.events.get(path[0]) - if (event == null) return false - return powers.has(event.power.key) - } - } -} - - -export class RootShrimpPower { - children: ShrimpPower[] = [] - constructor(public powerKey: string, public label: ILabel) { - } - get key(): string { - return this.powerKey - } -} - -export class ShrimpPower extends RootShrimpPower { - constructor(public parent: RootShrimpPower, public powerKey: string, public label: ILabel) { - super(powerKey, label) - parent.children.push(this) - } - get key(): string { - return this.parent.key + "." + this.powerKey - } -} - -// CRUD 接口 -export interface ShrimpCrud { - findOne(query?: any): Promise - list(query?: IShrimpQuery): Promise> - get(id: string): Promise - add(item: Omit): Promise - edit(id: string, item: Partial): Promise - delete(id: string): Promise - clear(): Promise - /** 为记录填充关联 _label 字段 */ - populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map>): Promise -} - -/** 内置字段类型处理器 */ -export const defaultTypeHandlers: Record = { - [ShrimpFieldTypes.Password]: { - reqFilter: async (value) => { - if (typeof value === 'string' && value.length > 0) { - return Shrimp.passwordHasher.hash(value) - } - return value - }, - resFilter: () => "***", - }, - [ShrimpFieldTypes.Date]: { - defaultValue: () => Date.now(), - }, - [ShrimpFieldTypes.Number]: { - defaultValue: 0, - }, - [ShrimpFieldTypes.Boolean]: { - defaultValue: false, - }, - [ShrimpFieldTypes.Json]: { - reqFilter: (value) => { - if (value == null || value === "") return undefined - if (typeof value === "string") { - try { - return JSON.parse(value) - } catch { - throw new ShrimpError({ t: 'shrimp.invalidJson' }) - } - } - return value - }, - resFilter: (value) => { - if (value == null) return value - if (typeof value === "object") { - return JSON.stringify(value) - } - return value - }, - }, - [ShrimpFieldTypes.Tab]: { - reqFilter: (value) => { - if (value == null || value === "") return undefined - if (typeof value === "string") { - try { - return JSON.parse(value) - } catch { - throw new ShrimpError({ t: 'shrimp.invalidJson' }) - } - } - return value - }, - resFilter: (value) => { - if (value == null) return value - if (typeof value === "object") { - return JSON.stringify(value) - } - return value - }, - }, -} - -export class ShrimpTableModule = ITableCrudProto> extends ShrimpModule

implements ITableCrudProto { - fields: IFieldConfig[] = [] - - constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud, fields?: IFieldConfig[], label?: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) { - super(parent, moduleKey, label || { t: moduleKey }, power, options) - if (fields) { - this.fields = fields - } - // 自动注册 CRUD 到 root.crudRegistry,populateItems 据此解析 relation - this.root.crudRegistry.set(moduleKey, dataSource) - } - - getFields(): IFieldConfig[] { - return this.fields - } - - static typeHandlers: Map = new Map(Object.entries(defaultTypeHandlers)) - - static registerTypeHandler(type: string, handler: IFieldTypeHandler) { - this.typeHandlers.set(type, handler) - } - - private getTypeHandler(field: IFieldConfig): IFieldTypeHandler | undefined { - return ShrimpTableModule.typeHandlers.get(field.type) - } - - /** 前置验证:基于 IFieldConfig 的通用校验 */ - private async validateField(field: IFieldConfig, value: any, item: Record): Promise { - const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) - - // 1. required - if (field.required && isEmpty) { - return field.rules?.find(r => r.required)?.message || { t: 'shrimp.fieldRequired', params: { field: field.label } } - } - - if (isEmpty) return null - - // 2. options 校验 - if (Array.isArray(field.options)) { - const validValues = new Set(field.options.map(o => o.value)) - const values = field.multiple && Array.isArray(value) ? value : [value] - for (const v of values) { - if (!validValues.has(v)) { - return field.rules?.find(r => r.message)?.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } } - } - } - } - - // 3. type 基础校验 - switch (field.type) { - case ShrimpFieldTypes.String: - if (typeof value !== 'string') return { t: 'shrimp.fieldTypeError', params: { field: field.label } } - break - case ShrimpFieldTypes.Number: - if (typeof value !== 'number' || Number.isNaN(value)) return { t: 'shrimp.fieldTypeError', params: { field: field.label } } - break - case ShrimpFieldTypes.Boolean: - if (typeof value !== 'boolean') return { t: 'shrimp.fieldTypeError', params: { field: field.label } } - break - case ShrimpFieldTypes.Date: - if (typeof value !== 'number' && !(value instanceof Date) && isNaN(Date.parse(value))) { - return { t: 'shrimp.fieldTypeError', params: { field: field.label } } - } - break - } - - // 4. rules 校验 - if (field.rules) { - for (const rule of field.rules) { - const msg = rule.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } } - - if (rule.min !== undefined) { - if (typeof value === 'number' && value < rule.min) return { t: 'shrimp.fieldMin', params: { field: field.label, min: rule.min } } - if (typeof value === 'string' && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } } - if (Array.isArray(value) && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } } - } - - if (rule.max !== undefined) { - if (typeof value === 'number' && value > rule.max) return { t: 'shrimp.fieldMax', params: { field: field.label, max: rule.max } } - if (typeof value === 'string' && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } } - if (Array.isArray(value) && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } } - } - - if (rule.pattern) { - const regex = new RegExp(rule.pattern) - if (!regex.test(String(value))) return msg - } - } - } - - return null - } - - /** 准备新增数据:过滤 readonly → 默认值 → reqFilter → validate */ - private async prepareAddItem(item: Record): Promise> { - const result = { ...item } - for (const field of this.fields) { - if (field.editable === "readonly") { - delete result[field.key] - continue - } - - const handler = this.getTypeHandler(field) - - if (result[field.key] === undefined) { - const dv = field.defaultValue !== undefined ? field.defaultValue : handler?.defaultValue - if (dv !== undefined) { - result[field.key] = typeof dv === 'function' ? (dv as () => any)() : dv - } - } - - if (handler?.reqFilter && result[field.key] !== undefined) { - result[field.key] = await handler.reqFilter(result[field.key], field, result) - } - - const fieldErr = await this.validateField(field, result[field.key], result) - if (fieldErr) throw new ShrimpError(fieldErr) - - if (handler?.validate && result[field.key] !== undefined) { - const err = await handler.validate(result[field.key], field, result) - if (err) throw new ShrimpError(err) - } - } - return result - } - - /** 准备编辑数据:过滤 readonly/addOnly → reqFilter → validate */ - private async prepareEditItem(item: Record): Promise> { - const result = { ...item } - for (const field of this.fields) { - if (field.editable === "readonly" || field.editable === "addOnly") { - delete result[field.key] - continue - } - - // 编辑时 undefined 表示未修改,跳过校验并从结果中移除 - if (result[field.key] === undefined) { - delete result[field.key] - continue - } - - const handler = this.getTypeHandler(field) - - if (handler?.reqFilter) { - result[field.key] = await handler.reqFilter(result[field.key], field, result) - } - - const fieldErr = await this.validateField(field, result[field.key], result) - if (fieldErr) throw new ShrimpError(fieldErr) - - if (handler?.validate) { - const err = await handler.validate(result[field.key], field, result) - if (err) throw new ShrimpError(err) - } - } - return result - } - - /** 处理响应数据:resFilter */ - private async processResItem(item: Record): Promise> { - const result = { ...item } - for (const field of this.fields) { - const handler = this.getTypeHandler(field) - if (handler?.resFilter && result[field.key] !== undefined) { - result[field.key] = await handler.resFilter(result[field.key], field, result) - } - } - return result - } - - /** 填充关联 _label 并应用 resFilter(多条) */ - private async prepareResponseItems(items: any[]): Promise { - if (items.length === 0) return items - const populates = this.getRelationPopulates() - if (populates.length > 0 && this.dataSource.populateItems) { - await this.dataSource.populateItems(items, populates, this.root.crudRegistry) - } - return Promise.all(items.map(item => this.processResItem(item))) - } - - /** 填充关联 _label 并应用 resFilter(单条) */ - private async prepareResponseItem(item: any): Promise { - if (!item) return item - const populates = this.getRelationPopulates() - if (populates.length > 0 && this.dataSource.populateItems) { - await this.dataSource.populateItems([item], populates, this.root.crudRegistry) - } - return this.processResItem(item) - } - - private excludeKeys(item: any, keys: string[]): any { - if (keys.length === 0) return item - const result = { ...item } - for (const key of keys) { - delete result[key] - } - return result - } - - private filterHidden(item: any): any { - return this.excludeKeys(item, this.fields.filter(f => f.hidden).map(f => f.key)) - } - - private getRelationFields(): IFieldConfig[] { - return this.fields.filter(f => f.type === "relation" && f.relation) - } - - private getRelationPopulates(): IShrimpPopulate[] { - return this.getRelationFields().map(f => ({ - key: f.key, - moduleKey: f.relation!.moduleKey, - labelKey: f.relation!.labelKey, - })) - } - - /** 将前端 IPageQuery 翻译为数据源 IShrimpQuery */ - private buildQuery(query?: IPageQuery & any): IShrimpQuery { - const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {} - const p = page || 1 - const ps = pageSize || ShrimpConfig.defaultPageSize - const shrimpQuery: IShrimpQuery = { - offset: (p - 1) * ps, - limit: ps, - } - - // filter → where - if (filter && typeof filter === 'object') { - const filterableFields = new Map(this.fields.filter(f => f.filterable).map(f => [f.key, f])) - const stringLikeTypes = new Set(['string', 'textarea']) - const where: Record = {} - for (const [key, value] of Object.entries(filter)) { - const field = filterableFields.get(key) - if (!field) continue - if (value == null) continue - if (Array.isArray(value) && value.length === 0) continue - if (typeof value === 'string' && value === '') continue - const obj = value as any - if (typeof obj === 'object' && !Array.isArray(obj) && obj.min === undefined && obj.max === undefined) continue - - if (Array.isArray(value)) { - where[key] = { in: value } - } else if (typeof value === 'object') { - const cond: IShrimpCondition = {} - if (obj.min !== undefined) cond.gte = obj.min - if (obj.max !== undefined) cond.lte = obj.max - where[key] = cond - } else if (typeof value === 'boolean') { - where[key] = { eq: value } - } else if (typeof value === 'string' && stringLikeTypes.has(field.type)) { - where[key] = { like: value } - } else { - where[key] = { eq: value } - } - } - if (Object.keys(where).length > 0) shrimpQuery.where = where - } - - // 子表场景: ownerId + 模块 ownerKey 配置 → 强制注入过滤(用户无法绕过) - if (ownerId != null && ownerId !== '' && this.ownerKey) { - shrimpQuery.where = shrimpQuery.where || {} - shrimpQuery.where[this.ownerKey] = { eq: ownerId } - } - - // keyword → orWhere(仅对字符串类型字段做模糊匹配) - if (keyword) { - const stringLikeTypes = new Set(['string', 'textarea']) - const searchFields = this.fields - .filter(f => f.searchable && stringLikeTypes.has(f.type)) - .map(f => f.key) - if (searchFields.length > 0) { - shrimpQuery.orWhere = searchFields.map(key => ({ [key]: { like: keyword } })) - } - } - - // 排序 - if (sortBy) { - shrimpQuery.order = [[sortBy, sortOrder === 'desc' ? 'desc' : 'asc']] - } - - return shrimpQuery - } - - async list(query?: IPageQuery & any): Promise> { - const shrimpQuery = this.buildQuery(query) - const result = await this.dataSource.list(shrimpQuery) - result.list = await this.prepareResponseItems(result.list) - result.list = result.list.map(item => this.filterHidden(item)) - return result - } - - async get(id: string): Promise { - const item = await this.dataSource.get(id) - if (item) { - const processed = await this.prepareResponseItem(item) - return this.filterHidden(processed) as T - } - return item - } - - async add(item: Omit, ownerId?: string): Promise { - if (ownerId && this.ownerKey) { - (item as any)[this.ownerKey] = ownerId - } - const prepared = await this.prepareAddItem(item as any) - const result = await this.dataSource.add(prepared as Omit) - const processed = await this.prepareResponseItem(result) - return this.filterHidden(processed) as T - } - async edit(id: string, item: Partial): Promise { - const prepared = await this.prepareEditItem(item as any) - const result = await this.dataSource.edit(id, prepared as Partial) - if (result) { - const processed = await this.prepareResponseItem(result) - return this.filterHidden(processed) as T - } - return result - } - async delete(id: string): Promise { - return await this.dataSource.delete(id) - } - async clear(query?: IPageQuery & any): Promise { - const hasFilter = query && ( - (query.keyword) || - (query.filter && typeof query.filter === 'object' && Object.keys(query.filter).length > 0) - ) - if (hasFilter) { - const shrimpQuery = this.buildQuery(query) - delete shrimpQuery.offset - delete shrimpQuery.limit - const result = await this.dataSource.list(shrimpQuery) - for (const item of result.list as any[]) { - await this.dataSource.delete(item._id) - } - return true - } - return await this.dataSource.clear() - } - async export(query?: IPageQuery & any): Promise { - const shrimpQuery = this.buildQuery(query) - delete shrimpQuery.offset - delete shrimpQuery.limit - const result = await this.dataSource.list(shrimpQuery) - result.list = await this.prepareResponseItems(result.list) - return result.list.map(item => this.filterHidden(item)) - } - async import(data?: T[], ownerId?: string): Promise { - if (!data || data.length === 0) return - for (const raw of data) { - const item = { ...raw } as Record - if (ownerId && this.ownerKey) item[this.ownerKey] = ownerId - const id = item._id ? String(item._id) : undefined - if (id) { - // 有 _id 且数据库里已存在 → 编辑覆盖 - const existing = await this.dataSource.get(id) - if (existing) { - const prepared = await this.prepareEditItem(item) - await this.dataSource.edit(id, prepared as Partial) - continue - } - } - // 无 _id,或 _id 在库里找不到 → 当作新增 - const prepared = await this.prepareAddItem(item) - await this.dataSource.add(prepared as Omit) - } - } - - initCrud() { - return this - .addEvent("list", this.power) - .addEvent("get", this.power) - .addEvent("add", { t: "power.add" }) - .addEvent("edit", { t: "power.edit" }) - .addEvent("delete", { t: "power.delete" }) - } -} - - - - -/** 用户模块扩展 - 添加登录和获取用户信息功能 */ -const userFields: IFieldConfig[] = [ - { key: "username", label: { t: "user.username" }, type: "string", required: true, searchable: true, editable: "addOnly" }, - { key: "password", label: { t: "user.password" }, type: "password", tableHide: true }, - { key: "nickname", label: { t: "user.nickname" }, type: "string", searchable: true }, - { key: "roleId", label: { t: "user.roleId" }, type: "relation", relation: { moduleKey: "role", labelKey: "name" }, filterable: true, multiple: true, defaultValue: [] }, - // { key: "status", label: { t: "user.status" }, type: "select", options: [{ label: { t: "base.disabled" }, value: 0 }, { label: { t: "base.enabled" }, value: 1 }], filterable: true, defaultValue: 1 }, - { key: "active", label: { t: "user.active" }, type: "boolean", filterable: true, defaultValue: true }, - { key: "createdAt", label: { t: "user.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true }, - { key: "updatedAt", label: { t: "user.updatedAt" }, type: "date", editable: "readonly", sortable: true }, -] - -export class UserModule extends ShrimpTableModule> { - constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[]) { - super(parent, "user", dataSource, fields || userFields, { t: "user.menuManage" }) - this.initCrud() - } - - /** 校验密码,子类可覆盖。默认走 `Shrimp.passwordHasher` 上挂载的哈希器。 */ - protected async verifyPassword(storedPassword: string, plainPassword: string): Promise { - return Shrimp.passwordHasher.verify(plainPassword, storedPassword) - } - - /** 从用户对象提取角色 ID 列表,子类可覆盖 */ - protected getUserRoleIds(user: WithId): string[] { - return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [] - } - - /** 用户登录 */ - async getUser(username: string, password: string): Promise> { - - const user = await this.dataSource.findOne({ username, active: true }) - if (!user || !(await this.verifyPassword(user.password, password))) { - throw new ShrimpError({ t: 'shrimp.loginFailed' }) - } - return user - } - - /** 获取当前用户信息 */ - async getModule(userId: string, roleModule: RoleModule): Promise { - const user = await this.dataSource.get(userId) - if (!user) { - throw new ShrimpError({ t: 'shrimp.userNotFound' }) - } - const roleIds = this.getUserRoleIds(user) - const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id))) - const isSuperAdmin = roles.some(r => r?.isSuperAdmin) - const powerKeys = isSuperAdmin - ? (await this.root.allPower()).map(p => p.key) - : roles.flatMap(r => r?.powers || []) - return this.root.getModuleInfo(new Set(powerKeys)) - } - - /** 覆盖编辑 - 禁止禁用自己、禁止修改自己的角色 */ - async edit(id: string, item: Partial): Promise | null> { - const op = this.root.operator - if (op && String(op.userId) === String(id)) { - if (item.active === false) { - throw new ShrimpError({ t: 'shrimp.cannotDisableSelf' }) - } - if (item.roleId != null) { - const newRoleIds = Array.isArray(item.roleId) ? item.roleId.map(String) : [String(item.roleId)] - const oldUser = await this.dataSource.get(id) - const oldRoleIds = oldUser ? this.getUserRoleIds(oldUser) : [] - if (JSON.stringify([...newRoleIds].sort()) !== JSON.stringify([...oldRoleIds].sort())) { - throw new ShrimpError({ t: 'shrimp.cannotChangeOwnRole' }) - } - } - } - return super.edit(id, item) - } - - /** 覆盖删除 - 禁止删除自己 */ - async delete(id: string): Promise { - const op = this.root.operator - if (op && String(op.userId) === String(id)) { - throw new ShrimpError({ t: 'shrimp.cannotDeleteSelf' }) - } - return super.delete(id) - } - - /** 覆盖清空 - 禁止清空所有用户 */ - async clear(): Promise { - if (this.root.operator) { - throw new ShrimpError({ t: 'shrimp.cannotClearAllUsers' }) - } - return super.clear() - } -} - - -/** 角色模块 */ -const roleFields: IFieldConfig[] = [ - { key: "name", label: { t: "role.name" }, type: "string", required: true, searchable: true }, - { key: "description", label: { t: "role.description" }, type: "textarea" }, - { key: "isSuperAdmin", label: { t: "role.isSuperAdmin" }, type: "boolean" }, - { key: "powers", label: { t: "role.powers" }, type: "string" }, - { key: "createdAt", label: { t: "role.createdAt" }, type: "date", editable: "readonly", sortable: true }, - { key: "updatedAt", label: { t: "role.updatedAt" }, type: "date", editable: "readonly", sortable: true }, -] - -export class RoleModule extends ShrimpTableModule, IRoleProto> { - constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[]) { - super(parent, "role", dataSource, fields || roleFields, { t: "role.menuManage" }) - this.initCrud() - this.addEvent("clear", { t: "power.clear" }) - this.addEvent("export", { t: "power.export" }) - this.addEvent("import", { t: "power.import" }) - this.addEvent("allPower", this.power) - } - - async allPower(): Promise<{ key: string; label: ILabel }[]> { - return this.root.allPower() - } - - /** 从操作者上下文提取角色 ID 列表,子类可覆盖 */ - protected getOperatorRoleIds(op: IShrimpOperator): string[] { - return Array.isArray(op.roleId) ? op.roleId.map(String) : op.roleId ? [String(op.roleId)] : [] - } - - /** 覆盖编辑 - 禁止编辑自己的任一角色(防止锁死自己) */ - async edit(id: string, item: Partial): Promise | null> { - const op = this.root.operator - if (op) { - const opRoleIds = this.getOperatorRoleIds(op) - if (opRoleIds.includes(String(id))) { - throw new ShrimpError({ t: 'shrimp.cannotEditOwnRole' }) - } - } - return super.edit(id, item) - } - - /** 填充超级管理员权限(展示用) */ - private async fillSuperAdminPowers(role: WithId | null): Promise | null> { - if (role && role.isSuperAdmin) { - const allPowers = await this.allPower() - role.powers = allPowers.map(p => p.key) - } - return role - } - - async list(query?: IPageQuery & any): Promise>> { - const result = await super.list(query) - for (const item of result.list) { - if (item.isSuperAdmin) { - const allPowers = await this.allPower() - item.powers = allPowers.map(p => p.key) - } - } - return result - } - - async get(id: string): Promise | null> { - const result = await super.get(id) - return this.fillSuperAdminPowers(result) - } - - /** 覆盖删除 - 禁止删除自己的任一角色 */ - async delete(id: string): Promise { - const op = this.root.operator - if (op) { - const opRoleIds = this.getOperatorRoleIds(op) - if (opRoleIds.includes(String(id))) { - throw new ShrimpError({ t: 'shrimp.cannotDeleteOwnRole' }) - } - } - return super.delete(id) - } - - /** 覆盖清空 - 禁止清空所有角色 */ - async clear(): Promise { - if (this.root.operator) { - throw new ShrimpError({ t: 'shrimp.cannotClearAllRoles' }) - } - return super.clear() - } -} diff --git a/src/OperationLogModule.ts b/src/Shrimp/OperationLogModule.ts similarity index 77% rename from src/OperationLogModule.ts rename to src/Shrimp/OperationLogModule.ts index a1bac02..bfe7398 100644 --- a/src/OperationLogModule.ts +++ b/src/Shrimp/OperationLogModule.ts @@ -2,19 +2,20 @@ * OperationLogModule * 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么" * - 默认只记 mutation(add/edit/delete/clear/import) + login - * - args 自动脱敏(password/pwd/token/secret → "***") + JSON.stringify + 截断到 2000 字符 + * - args 自动脱敏 + JSON.stringify + 截断到 2000 字符 * - 日志失败不影响主调用 * - 只暴露 list/get,不开 add/edit/delete 防止误改 */ -import { ShrimpModule, ShrimpTableModule, ShrimpCrud } from "./Shrimp" +import { IFieldConfig, IOperationLog, WithId, ILabel } from "../IShrimpType" import { Shrimp } from "./Shrimp" -import { IFieldConfig, IOperationLog, WithId, ILabel } from "./IShrimpType" +import { ShrimpModule } from "./ShrimpModule" +import { ShrimpTableModule } from "./ShrimpTableModule" +import type { ShrimpCrud } from "./ShrimpCrud" const SENSITIVE_KEY_RE = /(password|pwd|token|secret)/i const ARGS_MAX_LEN = 2000 -// operatorId 是子表→父记录(user)的外键,纯数据库字段,不进 fields(框架按 ownerKey 自动注入 filter) const operationLogFields: IFieldConfig[] = [ { key: "ip", label: { t: "operationLog.ip" }, type: "string", searchable: true, editable: "readonly" }, { key: "method", label: { t: "operationLog.method" }, type: "string", searchable: true, filterable: true, editable: "readonly" }, @@ -25,21 +26,17 @@ const operationLogFields: IFieldConfig[] = [ ] export class OperationLogModule extends ShrimpTableModule> { - /** 触发记录的事件名集合,可被外部 add/delete 扩展 */ logEvents: Set = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login']) constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[], label?: ILabel) { super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, { icon: "Tickets", - // 声明 operatorId 是父记录(user)的归属字段,框架据此自动做子表过滤 ownerKey: "operatorId", }) - // 只挂只读事件 — 不暴露 add/edit/delete,防止外部改日志 this.addEvent("list", this.power) this.addEvent("get", this.power) } - /** 路径最后一段在 logEvents 内,且不是 operationLog 自身的调用(防递归) */ shouldLog(path: string[]): boolean { if (path.length === 0) return false if (path.includes('operationLog')) return false @@ -47,7 +44,6 @@ export class OperationLogModule extends ShrimpTableModule> return this.logEvents.has(last) } - /** 深度遍历替换敏感字段值,再 JSON.stringify 并截断 */ sanitizeArgs(args: any[]): string { const walk = (val: any): any => { if (val == null) return val @@ -70,7 +66,6 @@ export class OperationLogModule extends ShrimpTableModule> return str.length > ARGS_MAX_LEN ? str.substring(0, ARGS_MAX_LEN) + '...' : str } - /** 写一条日志,内部吞错(日志失败不能搞挂主流程)。operatorId 强制 String 化,避免 ObjectId 与字符串查询不匹配 */ async record(operatorId: any, ip: string, path: string[], args: any[], success: boolean, errorMsg: string = ''): Promise { try { await this.dataSource.add({ diff --git a/src/Shrimp/Shrimp.ts b/src/Shrimp/Shrimp.ts new file mode 100644 index 0000000..72609b7 --- /dev/null +++ b/src/Shrimp/Shrimp.ts @@ -0,0 +1,126 @@ +import { AsyncLocalStorage } from 'async_hooks' +import { + ShrimpCode, IShrimpResponse, IModuleInfo, IShrimpOperator, IMenuItem, ILabel, +} from "../IShrimpType" +import { IPasswordHasher, bcryptHasher } from '../PasswordHasher' +import type { ShrimpModule } from "./ShrimpModule" +import type { ShrimpCrud } from "./ShrimpCrud" + +export class Shrimp { + root: Shrimp = this + modules: Map = new Map() + /** 全局 CRUD 注册表,用于自动解析 relation */ + crudRegistry: Map> = new Map() + /** + * 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。 + * 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher` + */ + static passwordHasher: IPasswordHasher = bcryptHasher + /** 请求级操作者上下文 */ + static asyncLocalStorage = new AsyncLocalStorage() + get operator(): IShrimpOperator | null { + return Shrimp.asyncLocalStorage.getStore() ?? null + } + async call(path: string[], ...args: any[]): Promise { + let module = this.modules.get(path[0]) + if (module == null) { + throw new Error(`Module "${path[0]}" not found`) + } + return await module.call(path.slice(1), ...args) + } + checkEvent(powers: Set, path: string[]): boolean { + let module = this.modules.get(path[0]) + if (module == null) return false + return module.checkEvent(powers, path.slice(1)) + } + getModuleInfo(powers: Set): IModuleInfo[] { + const list: IModuleInfo[] = [] + this.collectModuleInfo(this.modules, powers, undefined, list) + return list + } + + private collectModuleInfo(modules: Map, powers: Set, parent: string | undefined, out: IModuleInfo[]) { + modules.forEach(module => { + if (!powers.has(module.power.key)) return + const info: IModuleInfo = { + moduleKey: module.moduleKey, + events: module.getEventInfo(powers), + label: module.label, + fields: module.getFields(), + } + if (module.icon) info.icon = module.icon + if (parent) info.parent = parent + out.push(info) + if (module.modules.size > 0) { + this.collectModuleInfo(module.modules, powers, module.moduleKey, out) + } + }) + } + + success(data: T, tip?: ILabel, log?: string): IShrimpResponse { + const r: IShrimpResponse = { code: ShrimpCode.OK, data } + if (tip) r.tip = tip + if (log) r.log = log + return r + } + + fail(code: ShrimpCode, tip?: ILabel, log?: string): IShrimpResponse { + const r: IShrimpResponse = { code } + if (tip) r.tip = tip + if (log) r.log = log + return r + } + + async allPower(): Promise<{ key: string; label: ILabel }[]> { + const powers: { key: string; label: ILabel }[] = [] + + const collect = (module: any) => { + powers.push({ key: module.power.key, label: module.power.label }) + powers.push(...module.power.children) + module.modules.forEach(collect) + } + + this.root.modules.forEach(collect) + + const map = new Map() + powers.forEach(p => map.set(p.key, p.label)) + return Array.from(map.entries()).map(([key, label]) => ({ key, label })) + } + + /** 菜单树,默认平铺所有模块;子类可覆盖为分组结构 */ + getMenuTree(): IMenuItem[] { + const items: IMenuItem[] = [] + this.modules.forEach(module => { + items.push({ + name: module.moduleKey, + label: module.label, + moduleKey: module.moduleKey + }) + }) + return items + } + + /** 根据权限过滤菜单树 */ + filterMenuTree(fullMenuTree: IMenuItem[], rolePower: string[]): IMenuItem[] { + const allowedModules = new Set(rolePower.map(p => p.split('.')[0])) + const filter = (nodes: IMenuItem[]): IMenuItem[] => { + const result: IMenuItem[] = [] + for (const node of nodes) { + if (node.moduleKey) { + if (allowedModules.has(node.moduleKey)) { + result.push(node) + } + continue + } + if (!node.children || node.children.length === 0) { + continue + } + const filteredChildren = filter(node.children) + if (filteredChildren.length === 0) continue + result.push({ ...node, children: filteredChildren }) + } + return result + } + return filter(fullMenuTree) + } +} diff --git a/src/Shrimp/ShrimpCrud.ts b/src/Shrimp/ShrimpCrud.ts new file mode 100644 index 0000000..68ebdb6 --- /dev/null +++ b/src/Shrimp/ShrimpCrud.ts @@ -0,0 +1,12 @@ +import type { IShrimpQuery, IShrimpPopulate, IPageResult } from "../IShrimpType" + +export interface ShrimpCrud { + findOne(query?: any): Promise + list(query?: IShrimpQuery): Promise> + get(id: string): Promise + add(item: Omit): Promise + edit(id: string, item: Partial): Promise + delete(id: string): Promise + clear(): Promise + populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map>): Promise +} diff --git a/src/Shrimp/ShrimpModule.ts b/src/Shrimp/ShrimpModule.ts new file mode 100644 index 0000000..db4f124 --- /dev/null +++ b/src/Shrimp/ShrimpModule.ts @@ -0,0 +1,93 @@ +import type { IFieldConfig, ILabel } from "../IShrimpType" +import { Shrimp } from "./Shrimp" +import { RootShrimpPower, ShrimpPower } from "./ShrimpPower" + +type ShrimpEventHandler = (...args: any[]) => Promise + +export class ShrimpEvent { + constructor(public name: string, public handler: ShrimpEventHandler, public power: RootShrimpPower) { + } +} + +export interface IShrimpModuleOptions { + icon?: string + ownerKey?: string +} + +export class ShrimpModule

= Record> { + power: RootShrimpPower + root: Shrimp + modules: Map = new Map() + events: Map = new Map() + icon?: string + ownerKey?: string + constructor(public parent: Shrimp | ShrimpModule, public moduleKey: string, public label: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) { + if (power == null) { + if (parent instanceof Shrimp) { + power = new RootShrimpPower(moduleKey, label) + } + else { + power = new ShrimpPower(parent.power, moduleKey, label) + } + } + this.power = power + this.icon = options?.icon + this.ownerKey = options?.ownerKey + parent.modules.set(this.moduleKey, this) + this.root = parent.root + } + addEvent(name: K, label: ILabel): this + addEvent(name: K, power: RootShrimpPower): this + addEvent(name: K, powerOrLabel: RootShrimpPower | ILabel) { + const handler = (this as any)[name] + if (typeof handler !== 'function') { + throw new Error(`Method "${name}" not found on ${this.moduleKey}`) + } + let power: RootShrimpPower + if (powerOrLabel instanceof RootShrimpPower) { + power = powerOrLabel + } else { + power = new ShrimpPower(this.power, name, powerOrLabel) + } + this.events.set(name, new ShrimpEvent(name, handler.bind(this), power)) + return this + } + async call(path: string[], ...args: any[]): Promise { + if (path.length > 1) { + let module = this.modules.get(path[0]) + if (module == null) { + throw new Error(`Module "${path[0]}" not found`) + } + return await module.call(path.slice(1), ...args) + } else { + const event = this.events.get(path[0]) + if (event == null) { + throw new Error(`Event "${path[0]}" not found`) + } + return await event.handler(...args) + } + } + getEventInfo(powers: Set): string[] { + let events: string[] = [] + this.events.forEach(event => { + if (powers.has(event.power.key)) { + events.push(event.name) + } + }) + return events + } + checkEvent(powers: Set, path: string[]): boolean { + if (path.length > 1) { + let module = this.modules.get(path[0]) + if (module == null) return false + return module.checkEvent(powers, path.slice(1)) + } else { + const event = this.events.get(path[0]) + if (event == null) return false + return powers.has(event.power.key) + } + } + getFields(): IFieldConfig[] { + return [] + } +} diff --git a/src/Shrimp/ShrimpPower.ts b/src/Shrimp/ShrimpPower.ts new file mode 100644 index 0000000..2c0f28f --- /dev/null +++ b/src/Shrimp/ShrimpPower.ts @@ -0,0 +1,20 @@ +import type { ILabel } from "../IShrimpType" + +export class RootShrimpPower { + children: ShrimpPower[] = [] + constructor(public powerKey: string, public label: ILabel) { + } + get key(): string { + return this.powerKey + } +} + +export class ShrimpPower extends RootShrimpPower { + constructor(public parent: RootShrimpPower, public powerKey: string, public label: ILabel) { + super(powerKey, label) + parent.children.push(this) + } + get key(): string { + return this.parent.key + "." + this.powerKey + } +} diff --git a/src/Shrimp/ShrimpTableModule.ts b/src/Shrimp/ShrimpTableModule.ts new file mode 100644 index 0000000..48263b6 --- /dev/null +++ b/src/Shrimp/ShrimpTableModule.ts @@ -0,0 +1,429 @@ +import { + IFieldConfig, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate, + IPageQuery, IPageResult, ITableCrudProto, ILabel, ShrimpConfig, ShrimpError, ShrimpFieldTypes, +} from "../IShrimpType" +import { Shrimp } from "./Shrimp" +import { ShrimpModule, IShrimpModuleOptions } from "./ShrimpModule" +import type { ShrimpCrud } from "./ShrimpCrud" +import type { RootShrimpPower } from "./ShrimpPower" + +/** 内置字段类型处理器 */ +export const defaultTypeHandlers: Record = { + [ShrimpFieldTypes.Password]: { + reqFilter: async (value) => { + if (typeof value === 'string' && value.length > 0) { + return Shrimp.passwordHasher.hash(value) + } + return value + }, + resFilter: () => "***", + }, + [ShrimpFieldTypes.Date]: { + defaultValue: () => Date.now(), + }, + [ShrimpFieldTypes.Number]: { + defaultValue: 0, + }, + [ShrimpFieldTypes.Boolean]: { + defaultValue: false, + }, + [ShrimpFieldTypes.Json]: { + reqFilter: (value) => { + if (value == null || value === "") return undefined + if (typeof value === "string") { + try { + return JSON.parse(value) + } catch { + throw new ShrimpError({ t: 'shrimp.invalidJson' }) + } + } + return value + }, + resFilter: (value) => { + if (value == null) return value + if (typeof value === "object") { + return JSON.stringify(value) + } + return value + }, + }, + [ShrimpFieldTypes.Tab]: { + reqFilter: (value) => { + if (value == null || value === "") return undefined + if (typeof value === "string") { + try { + return JSON.parse(value) + } catch { + throw new ShrimpError({ t: 'shrimp.invalidJson' }) + } + } + return value + }, + resFilter: (value) => { + if (value == null) return value + if (typeof value === "object") { + return JSON.stringify(value) + } + return value + }, + }, +} + +export class ShrimpTableModule = ITableCrudProto> extends ShrimpModule

implements ITableCrudProto { + fields: IFieldConfig[] = [] + + constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud, fields?: IFieldConfig[], label?: ILabel, power?: RootShrimpPower, options?: IShrimpModuleOptions) { + super(parent, moduleKey, label || { t: moduleKey }, power, options) + if (fields) { + this.fields = fields + } + this.root.crudRegistry.set(moduleKey, dataSource) + } + + getFields(): IFieldConfig[] { + return this.fields + } + + static typeHandlers: Map = new Map(Object.entries(defaultTypeHandlers)) + + static registerTypeHandler(type: string, handler: IFieldTypeHandler) { + this.typeHandlers.set(type, handler) + } + + private getTypeHandler(field: IFieldConfig): IFieldTypeHandler | undefined { + return ShrimpTableModule.typeHandlers.get(field.type) + } + + /** 前置验证 */ + private async validateField(field: IFieldConfig, value: any, item: Record): Promise { + const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) + + if (field.required && isEmpty) { + return field.rules?.find(r => r.required)?.message || { t: 'shrimp.fieldRequired', params: { field: field.label } } + } + + if (isEmpty) return null + + if (Array.isArray(field.options)) { + const validValues = new Set(field.options.map(o => o.value)) + const values = field.multiple && Array.isArray(value) ? value : [value] + for (const v of values) { + if (!validValues.has(v)) { + return field.rules?.find(r => r.message)?.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } } + } + } + } + + switch (field.type) { + case ShrimpFieldTypes.String: + if (typeof value !== 'string') return { t: 'shrimp.fieldTypeError', params: { field: field.label } } + break + case ShrimpFieldTypes.Number: + if (typeof value !== 'number' || Number.isNaN(value)) return { t: 'shrimp.fieldTypeError', params: { field: field.label } } + break + case ShrimpFieldTypes.Boolean: + if (typeof value !== 'boolean') return { t: 'shrimp.fieldTypeError', params: { field: field.label } } + break + case ShrimpFieldTypes.Date: + if (typeof value !== 'number' && !(value instanceof Date) && isNaN(Date.parse(value))) { + return { t: 'shrimp.fieldTypeError', params: { field: field.label } } + } + break + } + + if (field.rules) { + for (const rule of field.rules) { + const msg = rule.message || { t: 'shrimp.fieldInvalid', params: { field: field.label } } + + if (rule.min !== undefined) { + if (typeof value === 'number' && value < rule.min) return { t: 'shrimp.fieldMin', params: { field: field.label, min: rule.min } } + if (typeof value === 'string' && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } } + if (Array.isArray(value) && value.length < rule.min) return { t: 'shrimp.fieldMinLength', params: { field: field.label, min: rule.min } } + } + + if (rule.max !== undefined) { + if (typeof value === 'number' && value > rule.max) return { t: 'shrimp.fieldMax', params: { field: field.label, max: rule.max } } + if (typeof value === 'string' && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } } + if (Array.isArray(value) && value.length > rule.max) return { t: 'shrimp.fieldMaxLength', params: { field: field.label, max: rule.max } } + } + + if (rule.pattern) { + const regex = new RegExp(rule.pattern) + if (!regex.test(String(value))) return msg + } + } + } + + return null + } + + private async prepareAddItem(item: Record): Promise> { + const result = { ...item } + for (const field of this.fields) { + if (field.editable === "readonly") { + delete result[field.key] + continue + } + + const handler = this.getTypeHandler(field) + + if (result[field.key] === undefined) { + const dv = field.defaultValue !== undefined ? field.defaultValue : handler?.defaultValue + if (dv !== undefined) { + result[field.key] = typeof dv === 'function' ? (dv as () => any)() : dv + } + } + + if (handler?.reqFilter && result[field.key] !== undefined) { + result[field.key] = await handler.reqFilter(result[field.key], field, result) + } + + const fieldErr = await this.validateField(field, result[field.key], result) + if (fieldErr) throw new ShrimpError(fieldErr) + + if (handler?.validate && result[field.key] !== undefined) { + const err = await handler.validate(result[field.key], field, result) + if (err) throw new ShrimpError(err) + } + } + return result + } + + private async prepareEditItem(item: Record): Promise> { + const result = { ...item } + for (const field of this.fields) { + if (field.editable === "readonly" || field.editable === "addOnly") { + delete result[field.key] + continue + } + + if (result[field.key] === undefined) { + delete result[field.key] + continue + } + + const handler = this.getTypeHandler(field) + + if (handler?.reqFilter) { + result[field.key] = await handler.reqFilter(result[field.key], field, result) + } + + const fieldErr = await this.validateField(field, result[field.key], result) + if (fieldErr) throw new ShrimpError(fieldErr) + + if (handler?.validate) { + const err = await handler.validate(result[field.key], field, result) + if (err) throw new ShrimpError(err) + } + } + return result + } + + private async processResItem(item: Record): Promise> { + const result = { ...item } + for (const field of this.fields) { + const handler = this.getTypeHandler(field) + if (handler?.resFilter && result[field.key] !== undefined) { + result[field.key] = await handler.resFilter(result[field.key], field, result) + } + } + return result + } + + private async prepareResponseItems(items: any[]): Promise { + if (items.length === 0) return items + const populates = this.getRelationPopulates() + if (populates.length > 0 && this.dataSource.populateItems) { + await this.dataSource.populateItems(items, populates, this.root.crudRegistry) + } + return Promise.all(items.map(item => this.processResItem(item))) + } + + private async prepareResponseItem(item: any): Promise { + if (!item) return item + const populates = this.getRelationPopulates() + if (populates.length > 0 && this.dataSource.populateItems) { + await this.dataSource.populateItems([item], populates, this.root.crudRegistry) + } + return this.processResItem(item) + } + + private excludeKeys(item: any, keys: string[]): any { + if (keys.length === 0) return item + const result = { ...item } + for (const key of keys) { + delete result[key] + } + return result + } + + private filterHidden(item: any): any { + return this.excludeKeys(item, this.fields.filter(f => f.hidden).map(f => f.key)) + } + + private getRelationFields(): IFieldConfig[] { + return this.fields.filter(f => f.type === "relation" && f.relation) + } + + private getRelationPopulates(): IShrimpPopulate[] { + return this.getRelationFields().map(f => ({ + key: f.key, + moduleKey: f.relation!.moduleKey, + labelKey: f.relation!.labelKey, + })) + } + + private buildQuery(query?: IPageQuery & any): IShrimpQuery { + const { keyword, page, pageSize, sortBy, sortOrder, filter, ownerId } = query || {} + const p = page || 1 + const ps = pageSize || ShrimpConfig.defaultPageSize + const shrimpQuery: IShrimpQuery = { + offset: (p - 1) * ps, + limit: ps, + } + + if (filter && typeof filter === 'object') { + const filterableFields = new Map(this.fields.filter(f => f.filterable).map(f => [f.key, f])) + const stringLikeTypes = new Set(['string', 'textarea']) + const where: Record = {} + for (const [key, value] of Object.entries(filter)) { + const field = filterableFields.get(key) + if (!field) continue + if (value == null) continue + if (Array.isArray(value) && value.length === 0) continue + if (typeof value === 'string' && value === '') continue + const obj = value as any + if (typeof obj === 'object' && !Array.isArray(obj) && obj.min === undefined && obj.max === undefined) continue + + if (Array.isArray(value)) { + where[key] = { in: value } + } else if (typeof value === 'object') { + const cond: IShrimpCondition = {} + if (obj.min !== undefined) cond.gte = obj.min + if (obj.max !== undefined) cond.lte = obj.max + where[key] = cond + } else if (typeof value === 'boolean') { + where[key] = { eq: value } + } else if (typeof value === 'string' && stringLikeTypes.has(field.type)) { + where[key] = { like: value } + } else { + where[key] = { eq: value } + } + } + if (Object.keys(where).length > 0) shrimpQuery.where = where + } + + // 子表场景: ownerId + ownerKey 强制注入过滤 + if (ownerId != null && ownerId !== '' && this.ownerKey) { + shrimpQuery.where = shrimpQuery.where || {} + shrimpQuery.where[this.ownerKey] = { eq: ownerId } + } + + if (keyword) { + const stringLikeTypes = new Set(['string', 'textarea']) + const searchFields = this.fields + .filter(f => f.searchable && stringLikeTypes.has(f.type)) + .map(f => f.key) + if (searchFields.length > 0) { + shrimpQuery.orWhere = searchFields.map(key => ({ [key]: { like: keyword } })) + } + } + + if (sortBy) { + shrimpQuery.order = [[sortBy, sortOrder === 'desc' ? 'desc' : 'asc']] + } + + return shrimpQuery + } + + async list(query?: IPageQuery & any): Promise> { + const shrimpQuery = this.buildQuery(query) + const result = await this.dataSource.list(shrimpQuery) + result.list = await this.prepareResponseItems(result.list) + result.list = result.list.map(item => this.filterHidden(item)) + return result + } + + async get(id: string): Promise { + const item = await this.dataSource.get(id) + if (item) { + const processed = await this.prepareResponseItem(item) + return this.filterHidden(processed) as T + } + return item + } + + async add(item: Omit, ownerId?: string): Promise { + if (ownerId && this.ownerKey) { + (item as any)[this.ownerKey] = ownerId + } + const prepared = await this.prepareAddItem(item as any) + const result = await this.dataSource.add(prepared as Omit) + const processed = await this.prepareResponseItem(result) + return this.filterHidden(processed) as T + } + async edit(id: string, item: Partial): Promise { + const prepared = await this.prepareEditItem(item as any) + const result = await this.dataSource.edit(id, prepared as Partial) + if (result) { + const processed = await this.prepareResponseItem(result) + return this.filterHidden(processed) as T + } + return result + } + async delete(id: string): Promise { + return await this.dataSource.delete(id) + } + async clear(query?: IPageQuery & any): Promise { + const hasFilter = query && ( + (query.keyword) || + (query.filter && typeof query.filter === 'object' && Object.keys(query.filter).length > 0) + ) + if (hasFilter) { + const shrimpQuery = this.buildQuery(query) + delete shrimpQuery.offset + delete shrimpQuery.limit + const result = await this.dataSource.list(shrimpQuery) + for (const item of result.list as any[]) { + await this.dataSource.delete(item._id) + } + return true + } + return await this.dataSource.clear() + } + async export(query?: IPageQuery & any): Promise { + const shrimpQuery = this.buildQuery(query) + delete shrimpQuery.offset + delete shrimpQuery.limit + const result = await this.dataSource.list(shrimpQuery) + result.list = await this.prepareResponseItems(result.list) + return result.list.map(item => this.filterHidden(item)) + } + async import(data?: T[], ownerId?: string): Promise { + if (!data || data.length === 0) return + for (const raw of data) { + const item = { ...raw } as Record + if (ownerId && this.ownerKey) item[this.ownerKey] = ownerId + const id = item._id ? String(item._id) : undefined + if (id) { + const existing = await this.dataSource.get(id) + if (existing) { + const prepared = await this.prepareEditItem(item) + await this.dataSource.edit(id, prepared as Partial) + continue + } + } + const prepared = await this.prepareAddItem(item) + await this.dataSource.add(prepared as Omit) + } + } + + initCrud() { + return this + .addEvent("list", this.power) + .addEvent("get", this.power) + .addEvent("add", { t: "power.add" }) + .addEvent("edit", { t: "power.edit" }) + .addEvent("delete", { t: "power.delete" }) + } +} diff --git a/src/Shrimp/UserRoleModule.ts b/src/Shrimp/UserRoleModule.ts new file mode 100644 index 0000000..699db2e --- /dev/null +++ b/src/Shrimp/UserRoleModule.ts @@ -0,0 +1,172 @@ +import { + IFieldConfig, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult, + IShrimpOperator, IModuleInfo, ILabel, ShrimpError, +} from "../IShrimpType" +import { Shrimp } from "./Shrimp" +import { ShrimpModule } from "./ShrimpModule" +import { ShrimpTableModule } from "./ShrimpTableModule" +import type { ShrimpCrud } from "./ShrimpCrud" + +/** 用户模块扩展 - 添加登录和获取用户信息功能 */ +const userFields: IFieldConfig[] = [ + { key: "username", label: { t: "user.username" }, type: "string", required: true, searchable: true, editable: "addOnly" }, + { key: "password", label: { t: "user.password" }, type: "password", tableHide: true }, + { key: "nickname", label: { t: "user.nickname" }, type: "string", searchable: true }, + { key: "roleId", label: { t: "user.roleId" }, type: "relation", relation: { moduleKey: "role", labelKey: "name" }, filterable: true, multiple: true, defaultValue: [] }, + { key: "active", label: { t: "user.active" }, type: "boolean", filterable: true, defaultValue: true }, + { key: "createdAt", label: { t: "user.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true }, + { key: "updatedAt", label: { t: "user.updatedAt" }, type: "date", editable: "readonly", sortable: true }, +] + +export class UserModule extends ShrimpTableModule> { + constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[]) { + super(parent, "user", dataSource, fields || userFields, { t: "user.menuManage" }) + this.initCrud() + } + + protected async verifyPassword(storedPassword: string, plainPassword: string): Promise { + return Shrimp.passwordHasher.verify(plainPassword, storedPassword) + } + + protected getUserRoleIds(user: WithId): string[] { + return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : [] + } + + async getUser(username: string, password: string): Promise> { + const user = await this.dataSource.findOne({ username, active: true }) + if (!user || !(await this.verifyPassword(user.password, password))) { + throw new ShrimpError({ t: 'shrimp.loginFailed' }) + } + return user + } + + async getModule(userId: string, roleModule: RoleModule): Promise { + const user = await this.dataSource.get(userId) + if (!user) { + throw new ShrimpError({ t: 'shrimp.userNotFound' }) + } + const roleIds = this.getUserRoleIds(user) + const roles = await Promise.all(roleIds.map(id => roleModule.dataSource.get(id))) + const isSuperAdmin = roles.some(r => r?.isSuperAdmin) + const powerKeys = isSuperAdmin + ? (await this.root.allPower()).map(p => p.key) + : roles.flatMap(r => r?.powers || []) + return this.root.getModuleInfo(new Set(powerKeys)) + } + + async edit(id: string, item: Partial): Promise | null> { + const op = this.root.operator + if (op && String(op.userId) === String(id)) { + if (item.active === false) { + throw new ShrimpError({ t: 'shrimp.cannotDisableSelf' }) + } + if (item.roleId != null) { + const newRoleIds = Array.isArray(item.roleId) ? item.roleId.map(String) : [String(item.roleId)] + const oldUser = await this.dataSource.get(id) + const oldRoleIds = oldUser ? this.getUserRoleIds(oldUser) : [] + if (JSON.stringify([...newRoleIds].sort()) !== JSON.stringify([...oldRoleIds].sort())) { + throw new ShrimpError({ t: 'shrimp.cannotChangeOwnRole' }) + } + } + } + return super.edit(id, item) + } + + async delete(id: string): Promise { + const op = this.root.operator + if (op && String(op.userId) === String(id)) { + throw new ShrimpError({ t: 'shrimp.cannotDeleteSelf' }) + } + return super.delete(id) + } + + async clear(): Promise { + if (this.root.operator) { + throw new ShrimpError({ t: 'shrimp.cannotClearAllUsers' }) + } + return super.clear() + } +} + + +/** 角色模块 */ +const roleFields: IFieldConfig[] = [ + { key: "name", label: { t: "role.name" }, type: "string", required: true, searchable: true }, + { key: "description", label: { t: "role.description" }, type: "textarea" }, + { key: "isSuperAdmin", label: { t: "role.isSuperAdmin" }, type: "boolean" }, + { key: "powers", label: { t: "role.powers" }, type: "string" }, + { key: "createdAt", label: { t: "role.createdAt" }, type: "date", editable: "readonly", sortable: true }, + { key: "updatedAt", label: { t: "role.updatedAt" }, type: "date", editable: "readonly", sortable: true }, +] + +export class RoleModule extends ShrimpTableModule, IRoleProto> { + constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[]) { + super(parent, "role", dataSource, fields || roleFields, { t: "role.menuManage" }) + this.initCrud() + this.addEvent("clear", { t: "power.clear" }) + this.addEvent("export", { t: "power.export" }) + this.addEvent("import", { t: "power.import" }) + this.addEvent("allPower", this.power) + } + + async allPower(): Promise<{ key: string; label: ILabel }[]> { + return this.root.allPower() + } + + protected getOperatorRoleIds(op: IShrimpOperator): string[] { + return Array.isArray(op.roleId) ? op.roleId.map(String) : op.roleId ? [String(op.roleId)] : [] + } + + async edit(id: string, item: Partial): Promise | null> { + const op = this.root.operator + if (op) { + const opRoleIds = this.getOperatorRoleIds(op) + if (opRoleIds.includes(String(id))) { + throw new ShrimpError({ t: 'shrimp.cannotEditOwnRole' }) + } + } + return super.edit(id, item) + } + + private async fillSuperAdminPowers(role: WithId | null): Promise | null> { + if (role && role.isSuperAdmin) { + const allPowers = await this.allPower() + role.powers = allPowers.map(p => p.key) + } + return role + } + + async list(query?: IPageQuery & any): Promise>> { + const result = await super.list(query) + for (const item of result.list) { + if (item.isSuperAdmin) { + const allPowers = await this.allPower() + item.powers = allPowers.map(p => p.key) + } + } + return result + } + + async get(id: string): Promise | null> { + const result = await super.get(id) + return this.fillSuperAdminPowers(result) + } + + async delete(id: string): Promise { + const op = this.root.operator + if (op) { + const opRoleIds = this.getOperatorRoleIds(op) + if (opRoleIds.includes(String(id))) { + throw new ShrimpError({ t: 'shrimp.cannotDeleteOwnRole' }) + } + } + return super.delete(id) + } + + async clear(): Promise { + if (this.root.operator) { + throw new ShrimpError({ t: 'shrimp.cannotClearAllRoles' }) + } + return super.clear() + } +} diff --git a/src/server/buildShrimpRouter.ts b/src/forExpress/buildShrimpRouter.ts similarity index 93% rename from src/server/buildShrimpRouter.ts rename to src/forExpress/buildShrimpRouter.ts index 66030eb..c48bf0b 100644 --- a/src/server/buildShrimpRouter.ts +++ b/src/forExpress/buildShrimpRouter.ts @@ -5,16 +5,12 @@ * - 数据库类型(只通过 dataSource 抽象访问) * - JWT(只通过 ITokenAuth 注入) * - 自己的字段名(通过 IUserAccessor 适配) - * - * 这让消费方可以: - * - 把 JWT 换成 session(传入自己的 ITokenAuth 实现) - * - 不用 ShrimpServer 装配体,自己拼 deps 直接调 buildShrimpRouter */ import { Router, Request, Response, NextFunction } from "express" -import { Shrimp } from "../Shrimp" +import { Shrimp } from "../Shrimp/Shrimp" import { ShrimpCode, IMeResult, IShrimpOperator, ShrimpError } from "../IShrimpType" -import { IShrimpRouterDeps } from "./types" +import type { IShrimpRouterDeps } from "./types" declare global { namespace Express { @@ -32,12 +28,10 @@ export function buildShrimpRouter(deps: IShrimpRouterDeps): Router { 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 @@ -55,7 +49,6 @@ export function buildShrimpRouter(deps: IShrimpRouterDeps): Router { } }) - // 鉴权中间件 router.use(async (req: Request, res: Response, next: NextFunction) => { try { const auth = req.headers.authorization @@ -82,7 +75,6 @@ export function buildShrimpRouter(deps: IShrimpRouterDeps): Router { } }) - // 获取当前用户信息 router.get("/me", async (req: Request, res: Response) => { try { const moduleInfo = await userModule.getModule(req.user._id, roleModule) @@ -97,7 +89,6 @@ export function buildShrimpRouter(deps: IShrimpRouterDeps): Router { ? (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 || []) { @@ -135,7 +126,6 @@ export function buildShrimpRouter(deps: IShrimpRouterDeps): Router { } }) - // 通用事件调用接口 router.post("/*path", async (req: Request, res: Response) => { const raw = req.params.path const path = (Array.isArray(raw) ? raw.join("/") : raw).split("/").filter(Boolean) diff --git a/src/server/jwtAuth.ts b/src/forExpress/jwtAuth.ts similarity index 95% rename from src/server/jwtAuth.ts rename to src/forExpress/jwtAuth.ts index 72b6951..a70ec42 100644 --- a/src/server/jwtAuth.ts +++ b/src/forExpress/jwtAuth.ts @@ -5,7 +5,7 @@ */ import jsonwebtoken from "jsonwebtoken" -import { ITokenAuth } from "./types" +import type { ITokenAuth } from "./types" export interface IJwtTokenAuthOptions { secret: string diff --git a/src/server/types.ts b/src/forExpress/types.ts similarity index 57% rename from src/server/types.ts rename to src/forExpress/types.ts index 467f73a..e6a1564 100644 --- a/src/server/types.ts +++ b/src/forExpress/types.ts @@ -1,22 +1,19 @@ /** - * server 子层共用接口 + * forExpress 子层共用接口 * - * 这些接口让 buildShrimpRouter 不直接依赖 ShrimpServer 类。 - * ShrimpServer 提供默认装配体,但消费方也可以自己拼。 + * 让 buildShrimpRouter 不直接依赖 ShrimpServer 类。 */ import type { Router } from "express" -import type { Shrimp, UserModule, RoleModule, RootShrimpPower } from "../Shrimp" -import type { OperationLogModule } from "../OperationLogModule" +import type { Shrimp } from "../Shrimp/Shrimp" +import type { UserModule, RoleModule } from "../Shrimp/UserRoleModule" +import type { OperationLogModule } from "../Shrimp/OperationLogModule" +import type { RootShrimpPower } from "../Shrimp/ShrimpPower" import type { IUser, IRole, WithId } from "../IShrimpType" /** * Token 鉴权抽象 — 替代 JWT 直接调用 - * - * 默认实现 createJwtTokenAuth(secret) 用 jsonwebtoken;消费方可以换成 - * session / cookie / 自定义签名,只要满足: - * - sign(payload) 产生 token 字符串 - * - verify(token) 解出 payload(失败抛错) + * 默认 createJwtTokenAuth 用 jsonwebtoken;可换成 session / cookie / 自定义签名。 */ export interface ITokenAuth { sign(payload: Record): string @@ -24,10 +21,8 @@ export interface ITokenAuth { } /** - * 用户/角色字段访问器 — 让 routes 不直接知道 user/role 的字段名 - * - * 默认实现读 IUser / IRole 的标准字段(username, password, roleId, active, - * isSuperAdmin, powers, nickname);消费方覆盖这些方法即可适配自己的 schema。 + * 用户/角色字段访问器 — 让 routes 不直接知道 user/role 的字段名。 + * 默认实现读 IUser / IRole 标准字段;消费方覆盖即可适配自己的 schema。 */ export interface IUserAccessor { getJwtPayload(user: WithId): Record @@ -51,5 +46,4 @@ export interface IShrimpRouterDeps { userAccessor: IUserAccessor } -// 拼接 Express 时给 Router/Req 用的扩展类型(在 buildShrimpRouter 处声明 global) export type { Router } diff --git a/src/server/ShrimpServer.ts b/src/forMongo/MongoShrimp.ts similarity index 54% rename from src/server/ShrimpServer.ts rename to src/forMongo/MongoShrimp.ts index 414aa80..2dafa48 100644 --- a/src/server/ShrimpServer.ts +++ b/src/forMongo/MongoShrimp.ts @@ -1,28 +1,24 @@ /** - * ShrimpServer + * MongoShrimp — Shrimp + Mongoose 装配体(无 HTTP 层) * - * 开箱即用的装配体:Express Router + JWT + Mongoose 三块拼起来, - * 用户只关心模块声明和子类钩子,不必自己拼 Router / Auth / Store。 + * 提供: + * - 默认 Mongoose Schema(User / Role / OperationLog),子类可覆盖 + * - Mongoose Model 工厂 + 缓存 + * - 默认 CRUD / Module 工厂,绑定 MongoShrimpCrud + * - mongoose.connect + onDbConnected 钩子 + * - initDefaultData 默认角色/用户播种 * - * 想换掉其中一块(比如换 session、换 SQL Store)的高级用法: - * - 自己造 ITokenAuth(看 createJwtTokenAuth 实现) - * - 自己造 dataSource(实现 ShrimpCrud 接口) - * - 直接 `buildShrimpRouter(deps)` 拼自己的 Express app,绕开本类 + * 不知道:express / jwt / 任何 HTTP 概念。 + * 上层把 HTTP 装到这之上,见 main/expmon.ts 的 ShrimpServer。 */ -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 { Shrimp } from "../Shrimp/Shrimp" +import { UserModule, RoleModule } from "../Shrimp/UserRoleModule" +import { OperationLogModule } from "../Shrimp/OperationLogModule" +import type { ShrimpCrud } from "../Shrimp/ShrimpCrud" import { IUser, IRole, IOperationLog, WithId } from "../IShrimpType" -import { buildShrimpRouter } from "./buildShrimpRouter" -import { createJwtTokenAuth } from "./jwtAuth" -import { ITokenAuth, IUserAccessor } from "./types" - -// ============== 默认 Mongoose Schema(可导出,也可被子类覆盖) ============== +import { MongoShrimpCrud } from "./MongoShrimpCrud" export const UserSchema = new Schema({ username: { type: String, required: true }, @@ -49,72 +45,33 @@ export const OperationLogSchema = new Schema({ createdAt: { type: Number, default: () => Date.now() }, }, { timestamps: false }) -// ============== ShrimpServer ============== - -export class ShrimpServer extends Shrimp implements IUserAccessor { +export class MongoShrimp extends Shrimp { 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") { + constructor(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 工厂方法 ============== + // ============== 可覆盖的 Schema 工厂 ============== protected createUserSchema(): mongoose.Schema { return UserSchema @@ -149,7 +106,7 @@ export class ShrimpServer extends Shrimp implements IUserAccessor { return this._operationLogModel } - // ============== 可覆盖的 Crud 工厂方法 ============== + // ============== 可覆盖的 Crud 工厂 ============== protected createUserCrud(): MongoShrimpCrud> { return new MongoShrimpCrud>(this.getUserModel()) @@ -163,7 +120,7 @@ export class ShrimpServer extends Shrimp implements IUserAccessor { return new MongoShrimpCrud>(this.getOperationLogModel()) } - // ============== 可覆盖的 Module 工厂方法 ============== + // ============== 可覆盖的 Module 工厂 ============== protected createUserModule(dataSource: ShrimpCrud>): UserModule { return new UserModule(this, dataSource) @@ -177,47 +134,14 @@ export class ShrimpServer extends Shrimp implements IUserAccessor { 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 { + protected async initDefaultData(name: string = "Administrator", description: string = "System administrator with all permissions"): Promise { const result = await this.userModule.dataSource.list({ limit: 1 }) if (result.total > 0) return console.log("初始化默认数据") diff --git a/src/MongoShrimpCrud.ts b/src/forMongo/MongoShrimpCrud.ts similarity index 82% rename from src/MongoShrimpCrud.ts rename to src/forMongo/MongoShrimpCrud.ts index 90ef881..b8dd023 100644 --- a/src/MongoShrimpCrud.ts +++ b/src/forMongo/MongoShrimpCrud.ts @@ -1,19 +1,17 @@ -import { Types, Model, FilterQuery } from 'mongoose'; -import { ShrimpCrud } from './Shrimp'; -import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from './IShrimpType'; +import { Model, FilterQuery } from 'mongoose' +import type { ShrimpCrud } from "../Shrimp/ShrimpCrud" +import { IShrimpQuery, IShrimpCondition, IShrimpPopulate, IPageResult, ShrimpConfig, isShrimpCondition } from "../IShrimpType" export class MongoShrimpCrud implements ShrimpCrud { constructor(private model: Model) { } - /** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */ + /** 获取关联模块的 Mongoose Model(用于 populate / $lookup) */ getRelatedModel?: (moduleKey: string) => Model | undefined - /** 判断值是否为 IShrimpCondition 对象 */ private isCondition = isShrimpCondition - /** 将 IShrimpCondition 翻译为 MongoDB 查询片段 */ private translateCondition(cond: IShrimpCondition | any): any { - if (!this.isCondition(cond)) return cond // 简写: 原始值 = eq + if (!this.isCondition(cond)) return cond const q: any = {} if (cond.eq !== undefined) q.$eq = cond.eq if (cond.in !== undefined) q.$in = cond.in @@ -23,18 +21,15 @@ export class MongoShrimpCrud implements ShrimpCrud { return q } - /** 将 IShrimpQuery 翻译为 Mongoose 查询 */ private buildMongooseQuery(query?: IShrimpQuery): { filter: FilterQuery, sort: any, skip: number, limit: number } { const filter: FilterQuery = {} - // where → AND 条件 if (query?.where) { for (const [key, cond] of Object.entries(query.where)) { (filter as any)[key] = this.translateCondition(cond) } } - // orWhere → $or if (query?.orWhere && query.orWhere.length > 0) { filter.$or = query.orWhere.map(group => { const obj: any = {} @@ -45,7 +40,6 @@ export class MongoShrimpCrud implements ShrimpCrud { }) } - // 排序 const sort: any = {} if (query?.order) { for (const [key, dir] of query.order) { @@ -75,7 +69,6 @@ export class MongoShrimpCrud implements ShrimpCrud { async populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map): Promise { for (const pop of populates) { - // 优先用 getRelatedModel,其次用 crudRegistry(自动解析 relation) let relatedModel = this.getRelatedModel?.(pop.moduleKey) if (!relatedModel && crudRegistry) { const relatedCrud = crudRegistry.get(pop.moduleKey) as any @@ -111,20 +104,20 @@ export class MongoShrimpCrud implements ShrimpCrud { } async get(id: string): Promise { - return await this.model.findById(id).lean() as T | null; + return await this.model.findById(id).lean() as T | null } async add(item: Omit): Promise { - return await this.model.create(item); + return await this.model.create(item) } async edit(id: string, item: Partial): Promise { - return await this.model.findByIdAndUpdate(id, item, { new: true }); + return await this.model.findByIdAndUpdate(id, item, { new: true }) } async delete(id: string): Promise { - const result = await this.model.findByIdAndDelete(id); - return !!result; + const result = await this.model.findByIdAndDelete(id) + return !!result } async clear(): Promise { - await this.model.deleteMany({}); - return true; + await this.model.deleteMany({}) + return true } } diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index cdab37c..0000000 --- a/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 默认入口 = 核心 (0 重依赖) - * - * - import ... from 'shrimp-server' => 这个文件 / src/core.ts(纯框架,无 peer) - * - import ... from 'shrimp-server/server' => src/server.ts(再带 ShrimpServer + Mongo) - * - * 该文件存在的意义是兼容不识别 `package.json#exports` 的旧工具链(npm < 7、 - * 部分 bundler 的 legacy resolve),让 `main: src/index.ts` 也能正常工作。 - */ -export * from "./core" diff --git a/src/main/expmon.ts b/src/main/expmon.ts new file mode 100644 index 0000000..66826c0 --- /dev/null +++ b/src/main/expmon.ts @@ -0,0 +1,99 @@ +/** + * Shrimp expmon 入口 — Express + Mongoose 整合方案 + * + * 在 core 之上把 forMongo(MongoShrimp + 数据源)和 forExpress(buildShrimpRouter + * + JWT)拼起来,导出开箱即用的 ShrimpServer 装配体。 + * + * 这一入口需要消费方安装 peer:express / mongoose / jsonwebtoken / bcrypt。 + * 不需要时改从 'shrimp-server' 导入,不会触发任何 peer。 + * + * 想换掉其中一块的高级用法: + * - 自己造 ITokenAuth(看 createJwtTokenAuth 实现) + * - 自己造 dataSource(实现 ShrimpCrud 接口) + * - 直接 buildShrimpRouter(deps) 拼自己的 Express app,绕开本类 + */ + +import { Router } from "express" +import { MongoShrimp, UserSchema, RoleSchema, OperationLogSchema } from "../forMongo/MongoShrimp" +import { buildShrimpRouter } from "../forExpress/buildShrimpRouter" +import { createJwtTokenAuth } from "../forExpress/jwtAuth" +import type { ITokenAuth, IUserAccessor } from "../forExpress/types" +import type { RootShrimpPower } from "../Shrimp/ShrimpPower" +import type { IUser, IRole, WithId } from "../IShrimpType" + +// 再导出所有 core 的符号,'shrimp-server/expmon' 单一入口就够用 +export * from "./index" + +// ===== forMongo / forExpress 公开符号 ===== +export { MongoShrimp, UserSchema, RoleSchema, OperationLogSchema } from "../forMongo/MongoShrimp" +export { MongoShrimpCrud } from "../forMongo/MongoShrimpCrud" +export { buildShrimpRouter } from "../forExpress/buildShrimpRouter" +export { createJwtTokenAuth } from "../forExpress/jwtAuth" +export type { IJwtTokenAuthOptions } from "../forExpress/jwtAuth" +export type { ITokenAuth, IUserAccessor, IShrimpRouterDeps } from "../forExpress/types" + +// ============== ShrimpServer ============== + +export class ShrimpServer extends MongoShrimp implements IUserAccessor { + router: Router + jwtSecret: string + config: Record + globalOptions: Record = {} + forcedOptionAccess = new Map() + + constructor(jwtSecret: string, config: Record = {}, mongodbUri: string = "mongodb://localhost:27017/shrimp") { + super(mongodbUri) + this.jwtSecret = jwtSecret + this.config = config + + 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, + }) + } + + registerOptions(key: string, options: any, access?: RootShrimpPower) { + this.globalOptions[key] = options + if (access) { + this.forcedOptionAccess.set(key, access) + } + } + + /** 创建 Token 鉴权,默认 JWT。子类可覆盖换成 session 之类。 */ + protected createTokenAuth(): ITokenAuth { + return createJwtTokenAuth(this.jwtSecret) + } + + // ============== 可覆盖的用户/角色字段访问钩子(实现 IUserAccessor) ============== + + getJwtPayload(user: WithId): Record { + return { _id: user._id, roleId: user.roleId } + } + + isUserActive(user: WithId): boolean { + return user.active === true + } + + 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 || "" + } +} diff --git a/src/core.ts b/src/main/index.ts similarity index 62% rename from src/core.ts rename to src/main/index.ts index 7fd2e8c..d3805c0 100644 --- a/src/core.ts +++ b/src/main/index.ts @@ -10,7 +10,7 @@ * - 自己接 HTTP / 自己写存储层 * - 跑测试 / 跑无 DB 的 demo * - * 需要完整后台(Express + Mongoose + JWT)请从 'shrimp-server/server' 导入。 + * 需要完整后台(Express + Mongoose + JWT)请从 'shrimp-server/expmon' 导入。 */ // ===== 类型 / 接口 / 枚举 ===== @@ -20,7 +20,7 @@ export { ShrimpFieldTypes, ShrimpConfig, isShrimpCondition, -} from "./IShrimpType" +} from "../IShrimpType" export type { ILabel, @@ -52,31 +52,21 @@ export type { IPowerItem, IUserProto, IRoleProto, -} from "./IShrimpType" +} from "../IShrimpType" // ===== 框架核心 ===== -export { - Shrimp, - ShrimpModule, - ShrimpTableModule, - ShrimpPower, - RootShrimpPower, - UserModule, - RoleModule, - defaultTypeHandlers, -} from "./Shrimp" - -export type { - IShrimpModuleOptions, - ShrimpCrud, -} from "./Shrimp" +export { Shrimp } from "../Shrimp/Shrimp" +export { ShrimpModule, ShrimpEvent } from "../Shrimp/ShrimpModule" +export type { IShrimpModuleOptions } from "../Shrimp/ShrimpModule" +export { ShrimpTableModule, defaultTypeHandlers } from "../Shrimp/ShrimpTableModule" +export { ShrimpPower, RootShrimpPower } from "../Shrimp/ShrimpPower" +export type { ShrimpCrud } from "../Shrimp/ShrimpCrud" +export { UserModule, RoleModule } from "../Shrimp/UserRoleModule" +export { OperationLogModule } from "../Shrimp/OperationLogModule" // ===== 密码哈希(可注入,默认 bcrypt lazy import) ===== -export { bcryptHasher, plainHasher } from "./PasswordHasher" -export type { IPasswordHasher } from "./PasswordHasher" +export { bcryptHasher, plainHasher } from "../PasswordHasher" +export type { IPasswordHasher } from "../PasswordHasher" // ===== 数据源(纯内存,无外部依赖) ===== -export { MemoryShrimpCrud } from "./MemoryShrimpCrud" - -// ===== 可选模块(纯框架代码,无外部依赖) ===== -export { OperationLogModule } from "./OperationLogModule" +export { MemoryShrimpCrud } from "../MemoryShrimpCrud" diff --git a/src/main/types.ts b/src/main/types.ts new file mode 100644 index 0000000..d8fc75f --- /dev/null +++ b/src/main/types.ts @@ -0,0 +1,9 @@ +/** + * Shrimp 纯类型入口 + * + * 只导出 IShrimpType.ts 的全部协议(类型 + ShrimpCode enum + ShrimpError class), + * 前端 shrimp-admin / 任何只需要协议定义的消费方从这里 import, + * 不会触发任何运行时依赖。 + */ + +export * from "../IShrimpType" diff --git a/src/server/index.ts b/src/server/index.ts deleted file mode 100644 index 5bf11f2..0000000 --- a/src/server/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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/test/ILabel.test.ts b/test/ILabel.test.ts index 4206819..d8da68b 100644 --- a/test/ILabel.test.ts +++ b/test/ILabel.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" -import { ShrimpError, ShrimpCode } from "../src" -import type { ILabel } from "../src" +import { ShrimpError, ShrimpCode } from "../src/main" +import type { ILabel } from "../src/main" describe("ILabel", () => { it("ShrimpError 接受字面量 string", () => { diff --git a/test/MemoryShrimpCrud.test.ts b/test/MemoryShrimpCrud.test.ts index 7a416ac..c9114aa 100644 --- a/test/MemoryShrimpCrud.test.ts +++ b/test/MemoryShrimpCrud.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest" -import { MemoryShrimpCrud } from "../src" +import { MemoryShrimpCrud } from "../src/main" interface IItem { _id: string diff --git a/test/PasswordHasher.test.ts b/test/PasswordHasher.test.ts index 94a3d5f..99d6659 100644 --- a/test/PasswordHasher.test.ts +++ b/test/PasswordHasher.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { Shrimp, plainHasher, bcryptHasher, UserModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src" -import type { IPasswordHasher } from "../src" +import { Shrimp, plainHasher, bcryptHasher, UserModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src/main" +import type { IPasswordHasher } from "../src/main" describe("PasswordHasher 抽象", () => { const original = Shrimp.passwordHasher diff --git a/test/buildShrimpRouter.test.ts b/test/buildShrimpRouter.test.ts index aad8830..b986b85 100644 --- a/test/buildShrimpRouter.test.ts +++ b/test/buildShrimpRouter.test.ts @@ -20,10 +20,10 @@ import { 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" +} from "../src/main" +import { buildShrimpRouter } from "../src/forExpress/buildShrimpRouter" +import type { ITokenAuth, IUserAccessor } from "../src/forExpress/types" +import type { WithId, IUser, IRole } from "../src/main" function makeFakeTokenAuth(): ITokenAuth { const store = new Map>() diff --git a/test/crudRegistry.test.ts b/test/crudRegistry.test.ts index 95e8382..428050b 100644 --- a/test/crudRegistry.test.ts +++ b/test/crudRegistry.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" -import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src" -import type { IFieldConfig } from "../src" +import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src/main" +import type { IFieldConfig } from "../src/main" describe("crudRegistry 自动登记", () => { it("ShrimpTableModule 构造时自动把 dataSource 登记到 root.crudRegistry", () => { diff --git a/test/getMenuTree.test.ts b/test/getMenuTree.test.ts index b014de9..e0cc69f 100644 --- a/test/getMenuTree.test.ts +++ b/test/getMenuTree.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, UserModule, RoleModule } from "../src" +import { Shrimp, ShrimpTableModule, MemoryShrimpCrud, UserModule, RoleModule } from "../src/main" describe("Shrimp.getMenuTree", () => { it("返回的 label 等于模块注册时声明的 label", () => {