引言
随着互联网技术的发展,问卷系统在各个领域得到了广泛应用。
我参与的XIAOJUSURVEY的开源问卷系统项目,是一个很不错的问卷开源项目,前后端都开源,该项目包含 B 端和 C 端,服务端采用 NestJS,前端采用 Vue3。
在项目的迭代过程中,我们引入了协作和空间功能,以提升用户体验和功能扩展性。
本文将详细介绍这些功能的设计与实现方案。
设计与实现
在设计问卷系统时,我们引入了空间概念,以更好地管理和组织问卷。空间功能分为个人空间和团队空间,两者在使用场景和权限管理上有显著区别。
定义
企业级系统往往涉及复杂的组织管理和数据隐私安全,引入空间的概念是为了做权限切割。
团队空间
第一期做的每个用户只能看到自己的问卷:
在此基础上拓展空间功能:
个人创建的问卷归属到个人空间
在空间下创建的问卷,归属于空间,可以添加空间用户
表结构调整:
1、新增两张表:
空间表、空间成员表,用于记录空间相关的信息
2、meta 表
新增workspaceId
字段用于记录问卷所属空间,为空则不属于任何空间
其中,空间的权限设计,采用了 RBAC(基于角色的访问控制)模型,添加空间成员时给用户分配角色即可。权限点比较多,且便于继续拓展,采用此模型能够降低成员的管理成本,优化用户体验。
后续更多的业务场景可以自行拓展。
问卷协作功能的设计与实现
协作功能允许用户将个人空间下的问卷共享给其他用户,并为协作者分配不同的权限。
当权限系统体量小,用户直接对应具体功能点即可满足系统诉求时,可以考虑使用 ACL 模型作为参考。
协作权限的设计
我们设计了三种协作权限,以满足不同的协作需求:
问卷管理:配置问卷的内容和设置。
问卷数据管理:分析和查看问卷数据。
问卷协作人管理:管理问卷的协作者。
协作的权限设计,我们采用了 ACL 模型,这种权限设计使得权限配置更加灵活,如果引入角色的概念,会产生 7 种角色,反而更加难理解和使用。
协作功能的技术实现
1、新增一个 model
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'collaborator' })
export class Collaborator extends BaseEntity {
@Column()
surveyId: string;
@Column()
userId: string;
@Column('jsonb')
permissions: Array<string>;
}
复制代码
2、新增对 collaborator 操作的 service,因为交互上我们是批量管理协作者,所以核心的功能是批量添加协作者、批量修改权限和批量删除这几个功能
import { Injectable } from '@nestjs/common';
import { Collaborator } from 'src/models/collaborator.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { ObjectId } from 'mongodb';
import { Logger } from 'src/logger';
@Injectable()
export class CollaboratorService {
constructor(
@InjectRepository(Collaborator)
private readonly collaboratorRepository: MongoRepository<Collaborator>,
private readonly logger: Logger,
) {}
...
async batchCreate({ surveyId, collaboratorList }) {
const res = await this.collaboratorRepository.insertMany(
collaboratorList.map((item) => {
return {
...item,
surveyId,
};
}),
);
return res;
}
async changeUserPermission({ userId, surveyId, permission }) {
const updateRes = await this.collaboratorRepository.updateOne(
{
surveyId,
userId,
},
{
$set: {
permission,
},
},
);
return updateRes;
}
async batchDelete({
idList,
neIdList,
userIdList,
surveyId,
}: {
idList?: Array<string>;
neIdList?: Array<string>;
userIdList?: Array<string>;
surveyId: string;
}) {
const query: Record<string, any> = {
surveyId,
$or: [],
};
if (Array.isArray(userIdList) && userIdList.length > 0) {
query.$or.push({
userId: {
$in: userIdList,
},
});
}
if (
(Array.isArray(idList) && idList.length > 0) ||
(Array.isArray(neIdList) && neIdList.length > 0)
) {
const idQuery: Record<string, any> = {
_id: {},
};
if (idList && idList.length > 0) {
idQuery._id.$in = idList.map((item) => new ObjectId(item));
}
if (neIdList && neIdList.length > 0) {
idQuery._id.$nin = neIdList.map((item) => new ObjectId(item));
}
query.$or.push(idQuery);
}
this.logger.info(JSON.stringify(query));
const delRes = await this.collaboratorRepository.deleteMany(query);
return delRes;
}
updateById({ collaboratorId, permissions }) {
return this.collaboratorRepository.updateOne(
{
_id: new ObjectId(collaboratorId),
},
{
$set: {
permissions,
},
},
);
}
...
}
复制代码
权限控制的设计与实现
通过权限守卫(Guard)来校验用户权限,确保用户只能进行授权范围内的操作。
我们设计了两个守卫:空间守卫(WorkspaceGuard)和问卷守卫(SurveyGuard),并借助 nestjs 提供的装饰器 @SetMetadata 来给接口配置权限。
空间守卫
具体实现如下:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { get } from 'lodash';
import { NoPermissionException } from '../exceptions/noPermissionException';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { ROLE_PERMISSION as WORKSPACE_ROLE_PERMISSION } from 'src/enums/workspace';
@Injectable()
export class WorkspaceGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const allowPermissions = this.reflector.get<string[]>(
'workspacePermissions',
context.getHandler(),
);
if (!allowPermissions) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
const workspaceIdInfo = this.reflector.get(
'workspaceId',
context.getHandler(),
);
let workspaceIdKey, optional;
if (typeof workspaceIdInfo === 'string') {
workspaceIdKey = workspaceIdInfo;
optional = false;
} else {
workspaceIdKey = workspaceIdInfo?.key;
optional = workspaceIdInfo?.optional || false;
}
const workspaceId = get(request, workspaceIdKey);
if (!workspaceId && optional === false) {
throw new NoPermissionException('没有空间权限');
}
if (workspaceId) {
const membersInfo = await this.workspaceMemberService.findOne({
workspaceId,
userId: user._id.toString(),
});
if (!membersInfo) {
throw new NoPermissionException('没有空间权限');
}
const userPermissions = WORKSPACE_ROLE_PERMISSION[membersInfo.role] || [];
if (
allowPermissions.some((permission) =>
userPermissions.includes(permission),
)
) {
return true;
}
throw new NoPermissionException('没有权限');
}
return true;
}
}
复制代码
在接口配置守卫:
@Post(':id')
@HttpCode(200)
@UseGuards(WorkspaceGuard)
@SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE])
@SetMetadata('workspaceId', 'params.id')
async update(@Param('id') id: string, @Body() workspace: CreateWorkspaceDto) {
....
}
复制代码
问卷守卫
问卷守卫的代码相对比较多,大家有兴趣可以查看工程:https://github.com/didi/xiaoju-survey
数据隔离方案
我们上线空间和协作功能后,更多的是对问卷的配置管理做了权限控制,但是我们的回收数据实际上更重要,我们可以把回收数据理解成资产,对于一个 SaaS 化的产品来说,资产是需要进行隔离的,以保障安全性,不同空间下的问卷,回收数据需要进行隔离。
数据库设计方案
数据隔离有几个方案:
数据库表隔离:每个租户使用独立的数据库表,简化权限管理。此方案实现简单,对当前代码的改动也小,我们后续开源计划也是使用此方案。
数据库的隔离:每个租户使用独立的数据库,确保数据隔离性和安全性。此方案稍微复杂,每创建一个空间,需要手动或者自动给改空间分配一个数据库,如果没有现成的数据库,还需要申请或创建数据库,并和空间进行关联,改动相对较大。
数据库集群与分区策略:通过数据库集群和分区提高系统性能和扩展性。此方案更加复杂,如果是有比较成熟的商业化方案,可以考虑此方案,本文暂不考虑此方案。
实现
数据隔离作为迭代的 Feature 进行建设,也欢迎大家认领:server侧系统优化 — 空间数据隔离优化:不同空间进行数据表隔离
结尾
本文介绍了问卷系统的协作和空间功能设计与实现,希望能够给大家带来一些有价值的参考和启发,欢迎大家一起讨论反馈。
关于我们
感谢看到最后,我们是一个多元、包容的社区,我们已有非常多的小伙伴在共建,欢迎你的加入。
Github:XIAOJUSURVEY
社区交流群
Star
开源不易,请star一下 ❤️❤️❤️,你的支持是我们最大的动力。
评论