21.服务端实战用户服务开发下
基于上一章的用户鉴权部分之后,本章节将介绍主要的用户系统的开发主要细节(包含前后端一起),由于篇幅有限,整体的代码量非常的多,所以并不能将所有的功能都全部复制进来,只能尽可能的介绍一些关键点,同时与前端不同的是,当服务端的整体架构设计结束之后,基本的都是业务模块的 CURD 所以观看本章的时候会较为枯燥,有能力的同学可以掠过本章直接自己开发,有兴趣的同学建议拉完本章的实际项目对比来看效果更佳。
业务代码开发
前置条件
如果想体验丝滑的开发体验感觉,可以使用 Nginx 代理去掉端口号,配置如下:
server {
listen 80;
server_name www.ig-space.com;
location / {
proxy_pass http://127.0.0.1:10010/;
}
}
server {
listen 80;
server_name api.ig-space.com;
location / {
proxy_pass http://127.0.0.1:4000/;
}
}
其中 www.ig-space.com 对应前端应用,api.ig-space.com 对应服务端域名
将上一章 github 授权的链接替换该图标的链接,然后可以进入授权界面。
根据拿回的 code 调用授权三方的接口 http://api.ig-space.com/api/auth?code=44fbc2070464ff2abda3 就可以正常拿到用户接口并且登录。
正常完成用户 jwt 注册之后可以看到如下所示,我们已经正常登录成功了。
这一块的前端后逻辑我还没有修改通顺,后期完成前后端的所有链路之后就可以自动授权跳转,目前还需要自己手动调用服务端登录一次。
接下来我们安装 RBAC 的权限模块体系来逐步讲解对应的前端端开发过程。
用户管理
用户管理模块在上一章介绍的比较多,对于此系统目前来说,用户都来源于 Github 授权的能力,所以就只有一张表,但如果需要做个人用户登录的功能的话,则需要拓展三方用户信息表来保证用户主表的唯一性。
当然整个系统的功能非常庞大,非阻塞不重要的模块,我们后置处理,所以目前用户的不具备自主新增的功能,只接受 GitHub 授权添加,以及部分字段属性的修改功能。
系统管理
用户系统的主要代码如下图所示区域:
实体类为:
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export enum STATUS {
disabled = 0,
enabled = 1,
}
@Entity()
export class System {
@PrimaryGeneratedColumn()
id?: number;
@Column()
name: string;
@Column({ type: 'text', default: null })
description?: string;
@Column({ default: STATUS.enabled })
status?: STATUS;
@Column()
creatorId?: number;
@Column()
creatorName?: string;
@Column()
updateId?: number;
@Column()
updateName?: string;
@CreateDateColumn()
createTime?: string;
@UpdateDateColumn()
updateTime?: string;
}
系统类的功能非常简单,主要帮助我们将权限限制再各个系统中,减少查询次数所以对应的 Controller 的功能也较为简洁,只有常规的 CURD 模块:
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BusinessException } from '@app/common';
import {
CreateSystemDto,
DeleteSystemDto,
UpdateSystemDto,
} from './system.dto';
import { SystemService } from './system.service';
import { PayloadUser } from '@app/common';
@ApiTags('系统')
@Controller('system')
export class SystemController {
constructor(private readonly systemService: SystemService) { }
@ApiOperation({
summary: '创建新系统',
})
@Post('create')
create(@Body() dto: CreateSystemDto, @PayloadUser() user: Payload) {
return this.systemService.create({
...dto,
creatorName: user.name,
creatorId: user.userId,
updateName: user.name,
updateId: user.userId,
});
}
@ApiOperation({
summary: '修改系统信息',
})
@Post('update')
async update(@Body() dto: UpdateSystemDto, @PayloadUser() user: Payload) {
const foundSystem = await this.systemService.findById(dto.id);
if (!foundSystem) {
throw new BusinessException('未找到系统');
}
return await this.systemService.update({
...foundSystem,
...dto,
updateName: user.name,
updateId: user.userId,
});
}
@ApiOperation({
summary: '删除系统',
})
@Post('/delete')
async delete(@Body() dto: DeleteSystemDto) {
return await this.systemService.delete(dto.id);
}
@ApiOperation({
summary: '所有系统列表',
})
@Post('/list')
async list() {
return await this.systemService.list();
}
}
资源管理
资源管理的服务端代码如下所示:
当我们创建好系统模块之后就可以创建对应的资源模块,资源模块有两种形态:
- 菜单 -> 对应页面级别的可见模块主要用于前端展示模块
- 常规模块 -> 对应功能级别的模块主要用于服务端
同时每个资源都可能存在父子级别嵌套的功能,所以系统模块的实体类的设计相对于系统会较为复杂:
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { STATUS } from '../system/system.mysql.entity';
export enum ResourceType {
Menu = 'menu',
Nomal = 'nomal',
}
@Entity()
export class Resource {
@PrimaryGeneratedColumn()
id?: number;
@Column()
name: string;
@Column()
key: string; // 对应资源的可识别 key,并不等同于系统自建 id
@Column({ default: 0 })
sort?: number; // 菜单类的资源才会有排序的功能
@Column({ default: null })
parentId?: number; // 父子嵌套,当为 null 为顶级资源
@Column()
systemId: number; // 归属于对应的系统
@Column({ default: ResourceType.Nomal })
type: ResourceType; // 资源类型
@Column({ default: STATUS.enabled })
status?: STATUS;
@Column({ type: 'text', default: null })
description?: string;
@CreateDateColumn()
createTime?: string;
@UpdateDateColumn()
updateTime?: string;
}
资源类的交互相对于系统来说会更多一下,毕竟涉及了下面的权限与角色模块:
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BusinessException } from '@app/common';
import {
CreateResourceDto,
DeleteResourceDto,
ListBySystemIdDto,
ListWithPaginationDto,
UpdateResourceDto,
} from './resource.dto';
import { ResourceService } from './resource.service';
import { SystemService } from '../system/system.service';
import { PrivilegeService } from '../privilege/privilege.service';
@Controller('resource')
@ApiTags('资源')
export class ResourceController {
constructor(
private readonly resourceService: ResourceService,
private readonly systemService: SystemService,
private readonly privilegeService: PrivilegeService,
) { }
@ApiOperation({
summary: '创建新资源',
})
@Post('create')
async create(@Body() dto: CreateResourceDto) {
const foundResource = await this.resourceService.findByKey(dto.key);
if (foundResource) {
throw new BusinessException('资源 Key 已存在');
}
return await this.resourceService.create(dto);
}
@ApiOperation({
summary: '修改资源信息',
})
@Post('update')
async update(@Body() dto: UpdateResourceDto) {
const foundResource = await this.resourceService.findById(dto.id);
if (!foundResource) {
throw new BusinessException('未找到资源');
}
const allowUpdateFields = {
name: dto.name,
description: dto.description,
};
return await this.resourceService.update({
...foundResource,
...allowUpdateFields,
});
}
@ApiOperation({
summary: '删除资源',
description: '',
})
@Post('/delete')
async delete(@Body() dto: DeleteResourceDto) {
return await this.resourceService.delete(dto.id);
}
@ApiOperation({
summary: '资源列表',
description: '根据角色名称查询',
})
@Post('/list/paginate')
async list(@Body() dto: ListWithPaginationDto) {
const { page, ...searchParams } = dto;
const rourceData = await this.resourceService.paginate(searchParams, page);
const systemIds = rourceData.items.map((role) => role.systemId);
const systemList = await this.systemService.findByIds(systemIds);
const systemMap = {};
systemList.forEach((system) => (systemMap[system.id] = system));
const newRource = rourceData.items.map((role) => {
role['systemName'] = systemMap[role.systemId].name;
return role;
});
return { ...rourceData, items: newRource };
}
@ApiOperation({
summary: '资源列表',
description: '根据系统 id 查询',
})
@Post('/listBySystemId')
async listBySystemId(@Body() dto: ListBySystemIdDto) {
const resourceList = await this.resourceService.listBySystemId(
dto.systemId,
);
const newResource = [];
for (const resource of resourceList) {
const privileges = await this.privilegeService.listByResourceKey(
resource.key,
);
newResource.push({
...resource,
privileges,
});
}
return newResource;
}
}
权限管理
权限的服务端代码集中在下图所示:
在新建资源之后就是对应资源下的具体权限管理,可以理解为某个页面下的按钮级别权限。
权限主要是对应的描述,所以它的实体类也并不会很复杂:
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
export enum PrivilegeStatus {
DENY = 0,
ALLOW = 1,
NOT_SET = 2,
}
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
@Entity()
export class Privilege {
@PrimaryGeneratedColumn()
id?: number;
@Column({ default: null })
systemId?: number;
@Column()
resourceKey: string;
@Column()
name: string;
@Column({ type: 'text', default: null })
description?: string;
@Column()
action: Action;
@Column({ default: PrivilegeStatus.ALLOW })
status?: PrivilegeStatus;
@CreateDateColumn()
createTime?: string;
}
同样对应的 Controller 也较为简单:
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BusinessException } from '@app/common';
import { SystemService } from '../system/system.service';
import { ResourceService } from '../resource/resource.service';
import {
CreatePrivilegeDto,
DeletePrivilegeDto,
DisablePrivilegeDto,
ListAllPrivilegeDto,
PrivilegeListWithPaginationDto,
UpdatePrivilegeDto,
} from './privilege.dto';
import { Privilege } from './privilege.mysql.entity';
import { PrivilegeService } from './privilege.service';
@ApiTags('权限')
@Controller('privilege')
export class PrivilegeController {
constructor(
private readonly privilegeService: PrivilegeService,
private readonly resourceService: ResourceService,
private readonly systemService: SystemService,
) { }
@ApiOperation({
summary: '创建权限',
})
@Post('create')
async create(@Body() dto: CreatePrivilegeDto) {
const privilege: Privilege = {
systemId: dto.systemId,
name: dto.name,
resourceKey: dto.resourceKey,
action: dto.action,
description: dto.description,
};
const resource = await this.resourceService.findByKey(dto.resourceKey);
if (!resource) {
throw new BusinessException('未找到资源 Key:' + dto.resourceKey);
}
return this.privilegeService.createOrUpdate(privilege);
}
@ApiOperation({
summary: '修改权限',
})
@Post('update')
async update(@Body() dto: UpdatePrivilegeDto) {
const updatedPrivilege: Privilege = {
name: dto.name,
systemId: dto.systemId,
resourceKey: dto.resourceKey,
action: dto.action,
description: dto.description,
};
const privilege = await this.privilegeService.findById(dto.id);
if (!privilege) {
throw new BusinessException(`未找到 id 为 ${dto.id} 的权限`);
}
const resource = await this.resourceService.findByKey(dto.resourceKey);
if (!resource) {
throw new BusinessException('未找到资源 Key:' + dto.resourceKey);
}
return this.privilegeService.createOrUpdate({
...privilege,
...updatedPrivilege,
});
}
@ApiOperation({
summary: '是否冻结权限',
})
@Post('changeStatus')
async changeStatus(@Body() dto: DisablePrivilegeDto) {
const found = await this.privilegeService.findById(dto.privilegeId);
if (!found) {
throw new BusinessException(`未找到 ID 为 ${dto.privilegeId} 的权限`);
}
return this.privilegeService.createOrUpdate({
...found,
status: dto.status,
});
}
@ApiOperation({
summary: '删除权限',
})
@Post('delete')
async delete(@Body() dto: DeletePrivilegeDto) {
return this.privilegeService.delete(dto.privilegeId);
}
@ApiOperation({
summary: '权限列表(分页)',
description: '根据权限名称查询',
})
@Post('/list/pagination')
async listWithPagination(@Body() dto: PrivilegeListWithPaginationDto) {
const { page, ...searchParams } = dto;
const pageData = await this.privilegeService.paginate(searchParams, page);
const systemIds = pageData.items.map((privilege) => privilege.systemId);
const systemList = await this.systemService.findByIds(systemIds);
const systemMap = {};
systemList.forEach((system) => (systemMap[system.id] = system));
const newRoles = pageData.items.map((privilege) => {
privilege['systemName'] = systemMap[privilege.systemId].name;
return privilege;
});
return { ...pageData, items: newRoles };
}
@ApiOperation({
summary: '获取所有权限',
})
@Post('listBySys')
async list(@Body() dto: ListAllPrivilegeDto) {
return await this.privilegeService.list(dto.systemId);
}
}
角色管理
这一块是最为复杂的一点,因为角色需要同时关联用户以及权限,所以整体的设计比较复杂,为了将系统的拓展性做的比较通用,我们采用的是 role-privilege 以及 role-user 关联表的设计。
角色管理的服务端代码如下:
所以对应的实体类有 3 张表:
// role
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { STATUS } from '../system/system.mysql.entity';
@Entity()
export class Role {
@PrimaryGeneratedColumn()
id?: number;
@Column()
name: string;
@Column()
systemId: number;
@Column()
systemName: string;
@Column()
creatorId?: number;
@Column()
creatorName?: string;
@Column()
updateId?: number;
@Column()
updateName?: string;
@Column({ default: STATUS.enabled })
status?: STATUS;
@Column({ type: 'text', default: null })
description?: string;
@CreateDateColumn()
createTime?: string;
@UpdateDateColumn()
updateTime?: string;
}
// role-user
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class UserRole {
@PrimaryGeneratedColumn()
id?: number;
@Column({ default: null })
systemId?: number;
@Column()
userId: number;
@Column()
roleId: number;
}
// role-privilege
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class RolePrivilege {
@PrimaryGeneratedColumn()
id?: number;
@Column({ default: null })
systemId?: number;
@Column()
roleId: number;
@Column()
privilegeId: number;
}
在新增角色的时候就需要根据系统来确定角色的归属:
所以在用户授权权限的时候,其实是新增 role-privilege 表数据:
再完成角色授权之后可以进行用户的角色给予:
同样这里的创建关系也是新增 role-user 表数据。
所以综合来看角色的 Controller 相对于之前会较为复杂点:
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BusinessException, PayloadUser } from '@app/common';
import { PrivilegeService } from '../privilege/privilege.service';
import { RolePrivilegeService } from '../role-privilege/role-privilege.service';
import { SystemService } from '../system/system.service';
import {
CreateRoleDto,
DeleteRoleDto,
GetPrivilegeListByIdDto,
RoleListDto,
RoleListWithPaginationDto,
RolePrivilegeSetDto,
UpdateRoleDto,
} from './role.dto';
import { RoleService } from './role.service';
@Controller('role')
@ApiTags('角色')
export class RoleController {
constructor(
private readonly roleService: RoleService,
private readonly rolePrivilegeService: RolePrivilegeService,
private readonly privilegeService: PrivilegeService,
private readonly systemService: SystemService,
) { }
@ApiOperation({
summary: '创建新角色',
})
@Post('create')
async create(
@Body() createRoleDto: CreateRoleDto,
@PayloadUser() user: Payload,
) {
const system = await this.systemService.findById(createRoleDto.systemId);
return this.roleService.create({
...createRoleDto,
systemName: system.name,
creatorName: user.name,
creatorId: user.userId,
updateName: user.name,
updateId: user.userId,
});
}
@ApiOperation({
summary: '修改角色信息',
})
@Post('update')
async update(@Body() dto: UpdateRoleDto, @PayloadUser() user: Payload) {
const foundRole = await this.roleService.findById(dto.id);
if (!foundRole) {
throw new BusinessException('未找到角色');
}
return await this.roleService.update({
...foundRole,
...dto,
updateName: user.name,
updateId: user.userId,
});
}
@ApiOperation({
summary: '删除角色',
description:
'如果发现角色有绑定权限,权限将同步删除 Role - privilege 关系表',
})
@Post('/delete')
async delete(@Body() dto: DeleteRoleDto) {
return await this.roleService.delete(dto.id);
}
@ApiOperation({
summary: '角色 ID 查权限',
description: '根据角色 id 查权限列表',
})
@Post('/getPrivilegeListById')
async getPrivilegeListById(@Body() dto: GetPrivilegeListByIdDto) {
const rolePrivilegeList = await this.rolePrivilegeService.listByRoleIds([
dto.roleId,
]);
const privilegeList = await this.privilegeService.findByIds(
rolePrivilegeList.map((rp) => rp.privilegeId),
);
return privilegeList;
}
@ApiOperation({
summary: '角色列表(分页)',
description: '根据角色名称查询',
})
@Post('/list/pagination')
async listWithPagination(@Body() dto: RoleListWithPaginationDto) {
const { page, ...searchParams } = dto;
const pageData = await this.roleService.paginate(searchParams, page);
const systemIds = pageData.items.map((role) => role.systemId);
const systemList = await this.systemService.findByIds(systemIds);
const systemMap = {};
systemList.forEach((system) => (systemMap[system.id] = system));
const newRoles = pageData.items.map((role) => {
role['systemName'] = systemMap[role.systemId].name;
return role;
});
return { ...pageData, items: newRoles };
}
@ApiOperation({
summary: 'tree 形状角色列表',
description: '系统级别树状',
})
@Post('/list/withSystem')
async listWithSys() {
const newSys = [];
const systemList = await this.systemService.list();
for (const sys of systemList) {
const roles = await this.roleService.listWithSys(sys.id);
newSys.push({
...sys,
roles,
});
}
return newSys;
}
@ApiOperation({
summary: '角色分配权限',
description: '',
})
@Post('set')
async set(@Body() dto: RolePrivilegeSetDto) {
await this.rolePrivilegeService.remove(dto.roleId);
return await this.rolePrivilegeService.set(
dto.roleId,
dto.privilegeIds,
dto.systemId,
);
}
}
写在最后
为什么只粘贴 Controller
因为 Service 职责比较单一,在用户系统里面并没有过多的复杂操作,本身就是对权限数据的增删改查,每个 Service 都比较类似就不放上到文章中了,而 Controller 有些部分是需要适配业务也有多表关联查询数据,所以就粘贴了 Controller 层的代码。
为什么不使用多对多的关系
关联表的设计比较复杂,代码写的比较麻烦,也未必是最好的选择,所以就抛弃了关联表设计,代码会比较傻瓜式的开发,而且从性能上,一般的服务也足够使用,并不需要考虑太多。
为什么数据库表要这么设计?
在最开始的 RBAC 用户权限设计中就已经介绍过了,这里再提一下现实中的常规场景,一般用户在登陆系统的时候是可以感知到对应的登陆系统的,所以此时可以通过系统以及用户来获取到对应的角色,在通过角色拿到对应的权限此时的路径是最为简便的,所以将系统作为用户的直接属性,而用户与权限则作为关联表存在。
为什么本章的代码含量居多
如开头所言,服务端开发反而不是最难的事情,难在架构设计、CICD、分布式、数据一致性等等额外的模块上,所以通常服务端的业务设计以及基础架构花费很多的时间,而代码开发方面占比并不高。
当我们将服务端的设计全部讲完,并且已经熟悉了 NestJS 的开发之后,理论上就可以开始服务端的开发,这也是为什么之前的 NestJS 的实战小册介绍完设计以及 NestJS 的开发之后就可以完结的原因。
因为业务代码的开发是重复且枯燥的,而其他有意思的内容对前端来说学起来又会很吃力,所以在这本大而全的体系中,才方便将所有的代码细节以及服务端的其他模块都串联起来。
下一章是物料服务的前后端开发,加油!狗狗狗!
如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏