v0.5.0: 重新组织目录结构,按职责拆分

- Shrimp.ts (875 行) 按类拆分到 Shrimp/(7 文件: Shrimp, ShrimpModule, ShrimpTableModule, ShrimpPower, ShrimpCrud, UserRoleModule, OperationLogModule)
- forMongo/: MongoShrimpCrud + MongoShrimp(原 ShrimpServer 的 mongo 装配部分,无 express)
- forExpress/: buildShrimpRouter + jwtAuth + types
- main/: index(0 依赖核心) + expmon(express+mongo 整合,导出 ShrimpServer) + types(纯协议)
- 入口子路径 server → expmon(express+mongo 整合方案命名更准确)
- 删除冗余的 index.ts / core.ts(合并为 main/index.ts)
This commit is contained in:
2026-05-23 19:44:02 +08:00
parent e8fc126fa6
commit ca283c6733
27 changed files with 1059 additions and 1128 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* OperationLogModule
* 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么"
* - 默认只记 mutation(add/edit/delete/clear/import) + login
* - args 自动脱敏 + JSON.stringify + 截断到 2000 字符
* - 日志失败不影响主调用
* - 只暴露 list/get,不开 add/edit/delete 防止误改
*/
import { IFieldConfig, IOperationLog, WithId, ILabel } from "../IShrimpType"
import { Shrimp } from "./Shrimp"
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
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" },
{ key: "args", label: { t: "operationLog.args" }, type: "textarea", editable: "readonly" },
{ key: "success", label: { t: "operationLog.success" }, type: "boolean", filterable: true, editable: "readonly" },
{ key: "errorMsg", label: { t: "operationLog.errorMsg" }, type: "string", editable: "readonly" },
{ key: "createdAt", label: { t: "operationLog.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true },
]
export class OperationLogModule extends ShrimpTableModule<WithId<IOperationLog>> {
logEvents: Set<string> = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login'])
constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IOperationLog>>, fields?: IFieldConfig[], label?: ILabel) {
super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, {
icon: "Tickets",
ownerKey: "operatorId",
})
this.addEvent("list", this.power)
this.addEvent("get", this.power)
}
shouldLog(path: string[]): boolean {
if (path.length === 0) return false
if (path.includes('operationLog')) return false
const last = path[path.length - 1]
return this.logEvents.has(last)
}
sanitizeArgs(args: any[]): string {
const walk = (val: any): any => {
if (val == null) return val
if (Array.isArray(val)) return val.map(walk)
if (typeof val === 'object') {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(val)) {
out[k] = SENSITIVE_KEY_RE.test(k) ? '***' : walk(v)
}
return out
}
return val
}
let str: string
try {
str = JSON.stringify(walk(args))
} catch {
str = '[unserializable]'
}
return str.length > ARGS_MAX_LEN ? str.substring(0, ARGS_MAX_LEN) + '...' : str
}
async record(operatorId: any, ip: string, path: string[], args: any[], success: boolean, errorMsg: string = ''): Promise<void> {
try {
await this.dataSource.add({
operatorId: operatorId == null ? null : String(operatorId),
ip,
method: path.join('.'),
args: this.sanitizeArgs(args),
success,
errorMsg,
createdAt: Date.now(),
} as Omit<WithId<IOperationLog>, '_id'>)
} catch (e: any) {
console.warn('[OperationLog] record failed', e?.message || e)
}
}
}
+126
View File
@@ -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<string, ShrimpModule> = new Map()
/** 全局 CRUD 注册表,用于自动解析 relation */
crudRegistry: Map<string, ShrimpCrud<any>> = new Map()
/**
* 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。
* 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher`
*/
static passwordHasher: IPasswordHasher = bcryptHasher
/** 请求级操作者上下文 */
static asyncLocalStorage = new AsyncLocalStorage<IShrimpOperator | null>()
get operator(): IShrimpOperator | null {
return Shrimp.asyncLocalStorage.getStore() ?? null
}
async call(path: string[], ...args: any[]): Promise<any> {
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<string>, 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<string>): IModuleInfo[] {
const list: IModuleInfo[] = []
this.collectModuleInfo(this.modules, powers, undefined, list)
return list
}
private collectModuleInfo(modules: Map<string, ShrimpModule>, powers: Set<string>, 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<T>(data: T, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { code: ShrimpCode.OK, data }
if (tip) r.tip = tip
if (log) r.log = log
return r
}
fail<T = any>(code: ShrimpCode, tip?: ILabel, log?: string): IShrimpResponse<T> {
const r: IShrimpResponse<T> = { 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<string, ILabel>()
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)
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { IShrimpQuery, IShrimpPopulate, IPageResult } from "../IShrimpType"
export interface ShrimpCrud<T> {
findOne(query?: any): Promise<T | null>
list(query?: IShrimpQuery): Promise<IPageResult<T>>
get(id: string): Promise<T | null>
add(item: Omit<T, '_id'>): Promise<T>
edit(id: string, item: Partial<T>): Promise<T | null>
delete(id: string): Promise<boolean>
clear(): Promise<boolean>
populateItems(items: any[], populates: IShrimpPopulate[], crudRegistry?: Map<string, ShrimpCrud<any>>): Promise<void>
}
+93
View File
@@ -0,0 +1,93 @@
import type { IFieldConfig, ILabel } from "../IShrimpType"
import { Shrimp } from "./Shrimp"
import { RootShrimpPower, ShrimpPower } from "./ShrimpPower"
type ShrimpEventHandler = (...args: any[]) => Promise<any>
export class ShrimpEvent {
constructor(public name: string, public handler: ShrimpEventHandler, public power: RootShrimpPower) {
}
}
export interface IShrimpModuleOptions {
icon?: string
ownerKey?: string
}
export class ShrimpModule<P extends Record<string, any> = Record<string, any>> {
power: RootShrimpPower
root: Shrimp
modules: Map<string, ShrimpModule> = new Map()
events: Map<string, ShrimpEvent> = 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<K extends keyof P & string>(name: K, label: ILabel): this
addEvent<K extends keyof P & string>(name: K, power: RootShrimpPower): this
addEvent<K extends keyof P & string>(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<any> {
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>): string[] {
let events: string[] = []
this.events.forEach(event => {
if (powers.has(event.power.key)) {
events.push(event.name)
}
})
return events
}
checkEvent(powers: Set<string>, 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 []
}
}
+20
View File
@@ -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
}
}
+429
View File
@@ -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<string, IFieldTypeHandler> = {
[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<T, P extends ITableCrudProto<T> = ITableCrudProto<T>> extends ShrimpModule<P> implements ITableCrudProto<T> {
fields: IFieldConfig[] = []
constructor(public parent: Shrimp | ShrimpModule, moduleKey: string, public dataSource: ShrimpCrud<T>, 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<string, IFieldTypeHandler> = 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<string, any>): Promise<ILabel | null> {
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<string, any>): Promise<Record<string, any>> {
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<string, any>): Promise<Record<string, any>> {
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<string, any>): Promise<Record<string, any>> {
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<any[]> {
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<any> {
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<string, IShrimpCondition> = {}
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<IPageResult<T>> {
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<T | null> {
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<T, '_id'>, ownerId?: string): Promise<T> {
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<T, '_id'>)
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
async edit(id: string, item: Partial<T>): Promise<T | null> {
const prepared = await this.prepareEditItem(item as any)
const result = await this.dataSource.edit(id, prepared as Partial<T>)
if (result) {
const processed = await this.prepareResponseItem(result)
return this.filterHidden(processed) as T
}
return result
}
async delete(id: string): Promise<boolean> {
return await this.dataSource.delete(id)
}
async clear(query?: IPageQuery & any): Promise<boolean> {
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<T[]> {
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<void> {
if (!data || data.length === 0) return
for (const raw of data) {
const item = { ...raw } as Record<string, any>
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<T>)
continue
}
}
const prepared = await this.prepareAddItem(item)
await this.dataSource.add(prepared as Omit<T, '_id'>)
}
}
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" })
}
}
+172
View File
@@ -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<WithId<IUser>> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IUser>>, fields?: IFieldConfig[]) {
super(parent, "user", dataSource, fields || userFields, { t: "user.menuManage" })
this.initCrud()
}
protected async verifyPassword(storedPassword: string, plainPassword: string): Promise<boolean> {
return Shrimp.passwordHasher.verify(plainPassword, storedPassword)
}
protected getUserRoleIds(user: WithId<IUser>): string[] {
return Array.isArray(user.roleId) ? user.roleId.map(String) : user.roleId ? [String(user.roleId)] : []
}
async getUser(username: string, password: string): Promise<WithId<IUser>> {
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<IModuleInfo[]> {
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<IUser>): Promise<WithId<IUser> | 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<boolean> {
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<boolean> {
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<WithId<IRole>, IRoleProto> {
constructor(public parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud<WithId<IRole>>, 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<IRole>): Promise<WithId<IRole> | 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<IRole> | null): Promise<WithId<IRole> | 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<IPageResult<WithId<IRole>>> {
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<WithId<IRole> | null> {
const result = await super.get(id)
return this.fillSuperAdminPowers(result)
}
async delete(id: string): Promise<boolean> {
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<boolean> {
if (this.root.operator) {
throw new ShrimpError({ t: 'shrimp.cannotClearAllRoles' })
}
return super.clear()
}
}