- 后端
- 前端
- 企业应用
- MCP 服务
【免费下载链接】ever-gauzy
Ever® Gauzy™ - Open Business Management Platform (ERP/CRM/HRM/ATS/PM) - https://gauzy.co
Ever® Gauzy™ 是一个开源的一体化业务管理平台(ERP/CRM/HRM/ATS/PM),在其插件体系中,@gauzy/plugin-videos承担了"视频上传与元数据管理"这一垂直能力。本文以该插件为线索,完整讲解其安装、构建、测试、发布流程,并深入packages/plugins/videos源码剖析其 CQRS 分层、多租户权限模型、存储提供商对接与文件安全防护,帮助读者掌握在 Gauzy 中集成视频管理功能的全链路方案。
插件定位:Gauzy 插件体系中的视频管理模块
@gauzy/plugin-videos是 Gauzy 插件体系中的一个标准库型插件,其 README 明确定义了两个核心能力:
- 视频上传与存储(Video upload and storage)
- 视频元数据管理(Video metadata management)
从项目结构看,该插件遵循 Gauzy 统一的插件开发约定(tags: ["type:plugin"],见 project.json),与ai-chat、integration-github、job-proposal等插件并列存放于packages/plugins目录下,通过 Nx 工作区管理。
插件的入口类VideosPlugin使用@GauzyCorePlugin装饰器声明,并实现了IOnPluginBootstrap与IOnPluginDestroy生命周期钩子(见 videos.plugin.ts):
@Plugin({ imports: [VideosModule], entities: [Video], providers: [], exports: [] }) export class VideosPlugin implements IOnPluginBootstrap, IOnPluginDestroy { private logEnabled = true; onPluginBootstrap(): void | Promise<void> { if (this.logEnabled) { console.log(chalk.green(`${VideosPlugin.name} is being bootstrapped...`)); } } onPluginDestroy(): void | Promise<void> { if (this.logEnabled) { console.log(chalk.red(`${VideosPlugin.name} is being destroyed...`)); } } }VideosPlugin通过imports: [VideosModule]引入业务模块、entities: [Video]注册数据实体,平台启动时自动执行onPluginBootstrap、关闭时执行onPluginDestroy(默认开启绿色/红色日志输出,注释表明该日志开关用于避免事件日志刷屏)。而插件对外的公共 API 面则统一在 index.ts 中导出:
export * from './lib/videos.plugin';快速开始:安装、构建、测试与发布
安装
README 给出了两种主流包管理器安装方式:
npm install @gauzy/plugin-videos # or yarn add @gauzy/plugin-videos该包发布到 npm 后,即可作为依赖引入 Gauzy 应用,并在应用模块中注册VideosPlugin。
构建
在仓库内使用 Nx 构建该库:
yarn nx build plugin-videos构建由@nx/js:tscexecutor 驱动(见 project.json),产物输出到dist/packages/plugins/videos,tsconfig.lib.json指定编译配置,src/index.ts作为入口,同时把packages/plugins/videos/*.md文档一并打包进产物。构建时还会因implicitDependencies声明自动先构建其依赖的contracts、core、plugin库。
运行单元测试
yarn nx test plugin-videos测试经由@nx/jest:jestexecutor 执行,使用仓库内的 jest.config.ts 配置。现有测试覆盖服务装配与控制器端点(见下文"测试与验证"小节)。
发布
构建完成后进入产物目录执行 npm 发布:
cd dist/packages/plugins/videos npm publish发布版本号由 Nx 的nx-release-publishtarget 与 git-tag 解析器管理(见 project.json 中release.version.currentVersionResolver: "git-tag")。
架构拆解:NestJS 模块 + CQRS 事件驱动
VideosModule是插件的核心业务装配单元(见 videos.module.ts):
@Module({ controllers: [VideosController], imports: [ TypeOrmModule.forFeature([Video]), MikroOrmModule.forFeature([Video]), RolePermissionModule, CqrsModule ], providers: [ VideosService, VideoSubscriber, TypeOrmVideoRepository, ...CommandHandlers, ...QueryHandlers ], exports: [VideosService] }) export class VideosModule { }从中可以提取出该插件的分层骨架:
| 层次 | 代表文件 | 职责 |
|---|---|---|
| 控制器层 | videos.controller.ts | 暴露 HTTP 端点,参数校验,权限守卫 |
| 命令层(写) | commands/ | CreateVideoCommand/UpdateVideoCommand/DeleteVideoCommand及对应 Handler |
| 查询层(读) | queries/ | GetVideosQuery/GetVideoQuery/GetVideoCountQuery及对应 Handler |
| 服务层 | services/videos.service.ts | 继承TenantAwareCrudService<Video>,封装 CRUD |
| 数据访问层 | repositories/ | TypeORM 与 MikroORM 双仓储实现 |
| 实体层 | entities/video.entity.ts | 数据库映射与字段校验 |
| 事件层 | subscribers/video.subscriber.ts | 实体加载/删除后的存储联动 |
这种"控制器只做参数与权限处理,业务逻辑通过CommandBus/QueryBus派发到 Handler,Handler 再调用服务层"的 CQRS 模式,与 Gauzy 平台其他模块保持一致:例如GET /列表请求在控制器中仅一行this.queryBus.execute(new GetVideosQuery(params)),POST创建请求执行CreateVideoCommand,读写完全分离。
数据模型:Video 实体与 IVideo 契约
实体字段与校验规则
Video实体继承TenantOrganizationBaseEntity(自带租户与组织归属字段),实现IVideo接口(见 video.model.ts)。核心字段及其校验约束(见 video.entity.ts):
| 字段 | 类型 | 约束与说明 |
|---|---|---|
title | string | 必填,3~255 字符,仅允许各语言字母、数字、空格与连字符(/^[\p{L}\p{N}\s-]+$/u) |
file | string | 必填,必须是 MP4 文件名(/^[\w-]+\.(mp4)$/i),实际存储的是文件 key |
recordedAt | Date | 可选,ISO 8601 时间字符串,且不得晚于当前时间 |
duration | number | 可选,≥0,单位为秒,存储为real类型 |
size | number | 可选,0 ~ 10GB(10737418240 字节)上限 |
fullUrl | string | 可选,合法的 http/https URL,由订阅器在实体加载时自动生成 |
description | string | 可选,≤1000 字符,仅允许字母、数字、空格与基础标点 |
storageProvider | enum | 可选,取值于FileStorageProviderEnum,序列化时被@Exclude隐藏 |
resolution | string | 可选,格式WIDTH:HEIGHT(如1920:1080),默认VideoResolutionEnum.FullHD |
codec | string | 可选,2~20 字符,默认VideoCodecEnum.libx264 |
frameRate | number | 可选,1~240 fps,默认 15 |
timeSlotId/uploadedById | UUID | 可选,关联TimeSlot与Employee的外键,均建有索引 |
预定义枚举:分辨率与编码
video.model.ts中定义了两套标准枚举,为元数据提供统一的取值范围:
VideoResolutionEnum:覆盖 SD(640:480)、HD(1280:720)、FullHD(1920:1080)、QHD(2560:1440)、UHD4K(3840:2160)、UHD5K、UHD6K、UHD8K、DCI4K(4096:2160)、Cinema2K(2048:1080)、WXGA、SXGA、UXGA、VGA(640:360)、PAL(720:576)、NTSC(720:480)共 16 档。VideoCodecEnum:libx264、libx265、libvpx、libaom、mpeg4、h263、h264、h265、theora、vp8、vp9共 11 种。
实体关系
实体与两个平台核心实体建立多对一关联,且均配置onDelete: 'CASCADE'与@JoinColumn():
timeSlot(时间片):视频可归属到某个TimeSlot(时间追踪体系的最小时间单元),关联字段timeSlotId校验 UUID v4;uploadedBy(上传者):记录哪位员工上传了视频,关联字段uploadedById同样校验 UUID v4 并建立索引。
从源码结构看,这两个关系使其天然服务于"员工时间追踪"场景——PermissionsEnum.TIME_TRACKER权限也印证了这一点。
HTTP API 全览:端点、守卫与权限
VideosController以@Controller('/plugins/videos')暴露五个 REST 端点(见 videos.controller.ts):
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /plugins/videos | 分页获取视频列表,支持按时间片/员工筛选(EmployeeTrackedDataGuard) |
POST | /plugins/videos | 上传视频文件(multipart/form-data,字段名file)并创建记录 |
GET | /plugins/videos/count | 统计当前租户内视频数量,可带过滤条件 |
GET | /plugins/videos/:id | 按 UUID 获取单个视频 |
PUT | /plugins/videos/:id | 按 UUID 更新视频元数据 |
DELETE | /plugins/videos/:id | 按 UUID 删除视频(可传软删除选项) |
守卫与权限链
控制器类级统一挂载两层守卫与权限声明:
@UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.TIME_TRACKER)TenantPermissionGuard:校验租户级权限;PermissionGuard+@Permissions(PermissionsEnum.TIME_TRACKER):要求调用者具备时间追踪权限;- 列表、详情与计数三个读端点额外挂
EmployeeTrackedDataGuard,确保员工只能访问与其追踪记录相关的数据。
所有变更端点(POST/PUT/DELETE)与部分读端点还叠加了UseValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })——白名单模式会剔除未声明字段、禁止非白名单字段传入,有效防御参数注入。ID 参数统一经UUIDValidationPipe校验。
数据隔离:租户 + 组织 + 员工三级过滤
以列表查询 Handler 为例(见 get-videos.handler.ts),查询逻辑展示了完整的数据隔离策略:
where基础条件固定为tenantId与organizationId;- 若当前用户不具备
PermissionsEnum.CHANGE_SELECTED_EMPLOYEE权限,则强制where.uploadedById = RequestContext.currentEmployeeId()——员工只能看到自己上传的视频,无员工上下文时直接返回空分页结果; - 若提供了
startDate/endDate,则使用moment-timezone将本地时区(默认UTC)换算为 UTC 后以Between区间过滤recordedAt; - 具备权限的管理者可通过
employeeIds数组以In操作符批量过滤上传者; - 关键安全细节:服务端生成的 where 条件排在客户端参数之后(
where: { ...params.where, ...where }),以覆盖方式防止调用方通过?where[uploadedById]=<他人ID>越权读取其他员工的录像——源码注释明确指出该缺陷曾可用于读取他人记录。
计数端点getCount(见 get-video-count.handler.ts)执行同样的租户、组织与员工权限校验,并在参数缺失时抛出BadRequestException(组织 ID 与租户 ID 均必填,且无权限时无法确定当前员工也会报错)。
文件上传:安全防线与存储对接
POST创建端点是本插件最复杂的环节,其安全设计值得专门拆解(见 videos.controller.ts)。
上传拦截器
@UseInterceptors( LazyFileInterceptor('file', { storage: () => FileStorageFactory.create('videos'), fileFilter: videoUploadFileFilter }) )LazyFileInterceptor是 Gauzy 核心(@gauzy/core)提供的懒加载文件拦截器,按需创建存储实例;FileStorageFactory.create('videos')按业务目录创建存储提供商实例;videoUploadFileFilter在文件落盘前过滤 MIME 类型。
三道防线的防脚本注入机制
源码注释明确引用了 GHSA-p334-cm7f-php5 类安全通告:视频文件经/public/<key>无鉴权路由按扩展名推断 Content-Type 对外提供,若一个伪装成video/mp4的.svg/.html文件成功落盘,就会在应用同源下执行脚本。为此插件建立三道防线:
- 拦截器层:
videoUploadFileFilter在接收阶段过滤不合法的视频 MIME; - DTO 层:file.dto.ts 对文件元数据做严格校验——
key必须匹配/\.(mp4)$/,mimetype必须匹配/^video\/(mp4)$/,size必须为正数,url上限 2083 字符、path上限 1024 字符; - 字节级复核:
shouldScanForMarkup(file.size)判断是否需要对已存储文件内容做标记语言(markup)扫描(大文件因需整体读入内存而跳过),随后assertNotMarkupContent(await provider.getFile(file.key))直接读取已落盘字节重新检查——即使客户端伪造 MIME 与文件名,恶意标记内容也无法在磁盘上存活。
失败清理与字段兜底
创建流程对任何异常都执行provider.deleteFile(file.key)最佳努力清理,避免脏文件残留;成功路径则将tenantId、organizationId、uploadedById从请求上下文兜底(RequestContext.currentTenantId()/currentEmployeeId()),并把storageProvider以大写形式记录入库。最终CreateVideoHandler(见 create-video.handler.ts)把file.key写入Video.file字段后落库。
存储生命周期:FileStorage 与实体订阅器
视频文件的实际读写由 Gauzy 核心的FileStorage抽象负责,它支持多存储提供商(FileStorageProviderEnum),视频插件只需按 key 操作而无需关心底层是本地磁盘、S3 还是其他云存储。
VideoSubscriber(见 subscribers/video.subscriber.ts)以 TypeORMEventSubscriber方式把文件生命周期与数据库事件绑定:
afterEntityLoad(加载后):读取实体的storageProvider与file,通过new FileStorage().setProvider(storageProvider).getProviderInstance().url(file)生成完整访问 URL 并写入fullUrl,缺失关键字段时仅记录警告日志;afterEntityDelete(删除后):调用provider.deleteFile(file)同步删除存储中的物理文件,成功删除输出Successfully deleted file for entity ID ...日志,失败则记录错误日志而不阻断删除流程。
这意味着数据库记录与存储文件的生命周期是联动的:删除视频记录会自动回收存储对象,加载记录会自动补全可访问 URL,调用方无需手动管理两处状态。
测试与验证
插件包含两组 Jest 测试,可用yarn nx test plugin-videos运行:
- videos.service.spec.ts:通过 NestJS
Test.createTestingModule注入 TypeORM/MikroORM 仓储 mock,验证VideosService可被正确实例化; - videos.controller.spec.ts:验证控制器装配;
- videos-employee-scope.spec.ts:专门覆盖"员工可见范围"的数据隔离逻辑,呼应上文第 5 节的权限过滤设计。
小结
@gauzy/plugin-videos是一个麻雀虽小五脏俱全的 Gauzy 插件范例:它演示了@Plugin装饰器 + 生命周期钩子的插件接入方式,NestJS 模块与 CQRS 命令/查询的完整分层,TenantOrganizationBaseEntity多租户数据模型与三级数据隔离,以及围绕视频上传构建的多层安全防线(MIME 过滤、DTO 白名单、字节级 markup 扫描、失败文件清理)。如果要在 Ever Gauzy 中为团队增加"录像/演示视频上传与检索"能力,或想参考其模式开发自己的业务插件,本文覆盖的 videos.plugin.ts、videos.controller.ts、video.entity.ts 与 video.subscriber.ts 是最佳的入手路径。
- 后端
- 前端
- 企业应用
- MCP 服务
【免费下载链接】ever-gauzy
Ever® Gauzy™ - Open Business Management Platform (ERP/CRM/HRM/ATS/PM) - https://gauzy.co
相关推荐
Ever Gauzy 视频管理 UI 插件 `@gauzy/plugin-videos-ui` 完整实战指南
Ever Gauzy 视频管理 UI 插件 @gauzy/plugin videos ui 完整实战指南 @gauzy/plugin videos ui 是 E
后端前端企业应用MCP 服务Ever Gauzy 插件系统深度指南:基于 @gauzy/plugin 的模块化插件开发实战
Ever Gauzy 插件系统深度指南:基于 @gauzy/plugin 的模块化插件开发实战 本指南以 Ever Gauzy 开源仓库中的 @gauzy/pl
后端前端企业应用MCP 服务Ever Gauzy Camshot 插件实战:摄像头快照上传、存储与管理全解析
Ever Gauzy Camshot 插件实战:摄像头快照上传、存储与管理全解析 本指南以 Ever Gauzy 开源仓库中的 @gauzy/plugin ca
后端前端企业应用MCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考