写完用户模块、会议室模块、预订模块之后,就只剩下统计模块了,这节就来写下这个模块。
这个模块只有 2 个统计的功能:
分别是统计会议室的使用频率、用户的预订频率:
没有新的实体,只是对已有数据的统计。
在 echarts 官网可以看到柱形图和饼图都只需要一个二维的数据,也就是 [[a, b], [a, b], [a, b]] 这样的形式的数据:
先在数据库里写下这个 sql:
select u.username 用户名, count(*) 预订次数
from booking b
left join users u
on b.userId = u.id
where b.startTime between '2023-09-24' and '2023-09-30'
group by b.userId;
关联 users 和 booking 表,过滤出在这段日期内的预订记录,根据用户分组,统计每组的预订数量。
这样查询出来的就是这段时间内每个用户预订了多少次会议室。
同理,也可以很轻松的统计出会议室被预订的频率:
select m.name 会议室名字, count(*) 预订次数
from booking b
left join meeting_room m
on b.roomId = m.id
where b.startTime between '2023-09-24' and '2023-09-30'
group by b.roomId;把关联的表换成 meeting_room 就好了。
当然,现在的数据不是很多,添加一些数据:
直接通过 mysql workbench 的 copy row 和 paste row 快速复制一些数据就好了。
复制出来的数据要改下 id,以及其他一些信息。
我添加了 4 条数据,并且指定了不同的 userId 和 roomId,点击 apply 应用修改。
然后再跑下那两个统计 sql
没啥问题。
接下来在 nest 里把这个统计 sql 实现就好了。
nest g module statistic生成一个新的 module。
nest g service statistic
nest g controller statistic之后生成 controller 和 service。
然后在 service 里实现下上面两个统计。
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { Booking } from 'src/booking/entities/booking.entity';
import { User } from 'src/user/entities/user.entity';
import { EntityManager } from 'typeorm';
@Injectable()
export class StatisticService {
@InjectEntityManager()
private entityManager: EntityManager;
async userBookingCount() {
const res = await this.entityManager
.createQueryBuilder(Booking, 'b')
.select('u.id', '用户id')
.addSelect('u.username', '用户名')
.leftJoin(User, 'u', 'b.userId = u.id')
.addSelect('count(1)', '预订次数')
.where('b.startTime between :time1 and :time2', {
time1: '2023-09-24',
time2: '2023-09-30'
})
.addGroupBy('b.user')
.getRawMany();
return res;
}
async meetingRoomUsedCount() {
}
}注入 entityManager 来查询。
统计相关的 sql 比较复杂,使用 queryBuilder 的 api。
queryBuilder 的 api 和写 sql 的体验差不多。
用 repl 的方式跑下试试:
npm run replawait get(StatisticService).userBookingCount()仔细观察下这个打印的 sql,其实和前面在 mysql workbench 里写的是一样的。
用 typeorm 的 query builder 的 api 可以写各种 sql。
然后加上参数,并且改下别名:
async userBookingCount(startTime: string, endTime: string) {
const res = await this.entityManager
.createQueryBuilder(Booking, 'b')
.select('u.id', 'userId')
.addSelect('u.username', 'username')
.leftJoin(User, 'u', 'b.userId = u.id')
.addSelect('count(1)', 'bookingCount')
.where('b.startTime between :time1 and :time2', {
time1: startTime,
time2: endTime
})
.addGroupBy('b.user')
.getRawMany();
return res;
}再跑下:
await get(StatisticService).userBookingCount('2023-09-23', '2023-09-30')没啥问题。
然后在 controller 里加个接口:
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { StatisticService } from './statistic.service';
@Controller('statistic')
export class StatisticController {
@Inject(StatisticService)
private statisticService: StatisticService;
@Get('userBookingCount')
async userBookingCount(@Query('startTime') startTime: string, @Query('endTime') endTime) {
return this.statisticService.userBookingCount(startTime, endTime);
}
}把 repl 的模式停掉,重新跑服务:
npm run start:dev用 postman 访问下:
http://localhost:3005/statistic/userBookingCount?startTime=2023-09-23&endTime=2023-09-30可以看到,返回了这段时间的统计数据。
这样,加个 echarts 就可以实现饼图、柱形图了:
然后,再写另一个接口。
和用户预订次数的统计差不多:
async meetingRoomUsedCount(startTime: string, endTime: string) {
const res = await this.entityManager
.createQueryBuilder(Booking, 'b')
.select('m.id', 'meetingRoomId')
.addSelect('m.name', 'meetingRoomName')
.leftJoin(MeetingRoom, 'm', 'b.roomId = m.id')
.addSelect('count(1)', 'usedCount')
.where('b.startTime between :time1 and :time2', {
time1: startTime,
time2: endTime
})
.addGroupBy('b.roomId')
.getRawMany();
return res;
}上面是 service 部分。
然后是 controller:
@Get('meetingRoomUsedCount')
async meetingRoomUsedCount(@Query('startTime') startTime: string, @Query('endTime') endTime) {
return this.statisticService.meetingRoomUsedCount(startTime, endTime);
}postman 里测试下:
http://localhost:3005/statistic/meetingRoomUsedCount?startTime=2023-09-23&endTime=2023-09-30也没啥问题,和在 mysql workbench 里自己写 sql 统计的结果一样。
这样,统计模块的后端部分就完成了。
这节实现了统计模块的后端代码。
就两个统计 sql,先在 mysql workbench 里写了这个统计 sql,然后在 typeorm 里用 query builder 的方式实现。
query builder 的 api 和直接写 sql 差不多。
前端部分拿到统计的数据,就可以用 echarts 展示饼图或者柱形图了。
上节写了统计用的两个接口,这节来加一下 swagger 文档,然后写下前端部分。
这个接口有 2 个 query 参数,返回值是一个对象,所以这样写:
@ApiBearerAuth()
@ApiQuery({
name: 'startTime',
type: String,
description: '开始时间'
})
@ApiQuery({
name: 'endTime',
type: String,
description: '结束时间'
})
@ApiResponse({
status: HttpStatus.OK,
type: UserBookingCount
})涉及到的 vo 在 src/statistic/vo/UserBookingCount.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class UserBookingCount {
@ApiProperty()
userId: string;
@ApiProperty()
username: string;
@ApiProperty()
bookingCount: string;
}访问下 http://localhost:3005/api-doc
可以看到这个接口的文档:
没啥问题。
然后添加另一个接口的:
@ApiBearerAuth()
@ApiQuery({
name: 'startTime',
type: String,
description: '开始时间'
})
@ApiQuery({
name: 'endTime',
type: String,
description: '结束时间'
})
@ApiResponse({
status: HttpStatus.OK,
type: MeetingRoomUsedCount
})src/statistic/vo/MeetingRoomUsedCount.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class MeetingRoomUsedCount {
@ApiProperty()
meetingRoomId: string;
@ApiProperty()
meetingRoomName: string;
@ApiProperty()
usedCount: string;
}然后再加个 @ApiTags 把这俩接口文档分成一组:
这样,swagger 文档就完成了。
然后来写前端代码:
统计的路由已经写过了,只要填内容就行。
原型图是这样的:
加个 antd 的 Form,然后再用 echarts 的图表展示下数据就好了。
先加下 form:
import { Button, DatePicker, Form, Select } from "antd";
import "./statistics.css";
export function Statistics() {
function getStatisticData(values: { startTime: string; endTime: string; }) {
console.log(values);
}
return <div id="statistics-container">
<div className="statistics-form">
<Form
onFinish={getStatisticData}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="开始日期" name="startTime">
<DatePicker />
</Form.Item>
<Form.Item label="结束日期" name="endTime">
<DatePicker />
</Form.Item>
<Form.Item label="图表类型" name="chartType" initialValue={"bar"}>
<Select>
<Select.Option value="pie">饼图</Select.Option>
<Select.Option value="bar">柱形图</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
</div>
<div className="statistics-chart">
图表
</div>
</div>
}css:
#statistics-container {
padding: 20px;
}
#statistics-container .statistics-form {
margin-bottom: 40px;
}
#statistics-container .statistics-chart {
width: 800px;
height: 600px;
}点击查询,会打印 form 的值:
然后安装 echarts:
npm install echarts --save然后通过 useRef 拿到 dom 元素,再初始化下 echarts 的柱状图:
import { Button, DatePicker, Form, Select } from "antd";
import "./statistics.css";
import * as echarts from 'echarts';
import { useEffect, useRef } from "react";
export function Statistics() {
const containerRef = useRef<HTMLDivElement>(null);
function getStatisticData(values: { startTime: string; endTime: string; }) {
console.log(values);
}
useEffect(() => {
const myChart = echarts.init(containerRef.current);
myChart.setOption({
title: {
text: 'ECharts 入门示例'
},
tooltip: {},
xAxis: {
data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
},
yAxis: {},
series: [
{
name: '销量',
type: 'bar',
data: [5, 20, 36, 10, 10, 20]
}
]
});
}, []);
return <div id="statistics-container">
<div className="statistics-form">
<Form
onFinish={getStatisticData}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="开始日期" name="startTime">
<DatePicker />
</Form.Item>
<Form.Item label="结束日期" name="endTime">
<DatePicker />
</Form.Item>
<Form.Item label="图表类型" name="chartType" initialValue={"bar"}>
<Select>
<Select.Option value="pie">饼图</Select.Option>
<Select.Option value="bar">柱形图</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
</div>
<div className="statistics-chart" ref={containerRef}></div>
</div>
}这样 echarts 就成功引入了:
然后加一下接口:
在 src/interfaces/interfaces.ts 里加一下:
export async function meetingRoomUsedCount(startTime: string, endTime: string) {
return await axiosInstance.get('/statistic/meetingRoomUsedCount', {
params: {
startTime,
endTime
}
});
}
export async function userBookingCount(startTime: string, endTime: string) {
return await axiosInstance.get('/statistic/userBookingCount', {
params: {
startTime,
endTime
}
});
}加一个 state 来存储返回的数据,然后点击查询的时候请求接口:
当数据变化的时候,渲染图表:
import { Button, DatePicker, Form, Select, message } from "antd";
import "./statistics.css";
import * as echarts from 'echarts';
import { useEffect, useRef, useState } from "react";
import { userBookingCount } from "../../interfaces/interfaces";
import dayjs from "dayjs";
interface UserBookingData {
userId: string;
username: string;
bookingCount: string;
}
export function Statistics() {
const [userBookingData, setUserBookingData] = useState<Array<UserBookingData>>();
const containerRef = useRef<HTMLDivElement>(null);
async function getStatisticData(values: { startTime: string; endTime: string; }) {
const startTime = dayjs(values.startTime).format('YYYY-MM-DD');
const endTime = dayjs(values.endTime).format('YYYY-MM-DD');
const res = await userBookingCount(startTime, endTime);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
setUserBookingData(data);
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
const myChart = echarts.init(containerRef.current);
if(!userBookingData) {
return;
}
myChart.setOption({
title: {
text: '用户预订情况'
},
tooltip: {},
xAxis: {
data: userBookingData?.map(item => item.username)
},
yAxis: {},
series: [
{
name: '预订次数',
type: 'bar',
data: userBookingData?.map(item => {
return {
name: item.username,
value: item.bookingCount
}
})
}
]
});
}, [userBookingData]);
return <div id="statistics-container">
<div className="statistics-form">
<Form
onFinish={getStatisticData}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="开始日期" name="startTime">
<DatePicker />
</Form.Item>
<Form.Item label="结束日期" name="endTime">
<DatePicker />
</Form.Item>
<Form.Item label="图表类型" name="chartType" initialValue={"bar"}>
<Select>
<Select.Option value="pie">饼图</Select.Option>
<Select.Option value="bar">柱形图</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
</div>
<div className="statistics-chart" ref={containerRef}></div>
</div>
}这样,点击查询的时候就会根据返回的数据渲染柱形图:
然后再加上饼图的部分:
这样,统计的图表就完成了:
在下面再加一个会议室使用情况的图表。
过程一模一样。
import { Button, DatePicker, Form, Select, message } from "antd";
import "./statistics.css";
import * as echarts from 'echarts';
import { useEffect, useRef, useState } from "react";
import { meetingRoomUsedCount, userBookingCount } from "../../interfaces/interfaces";
import dayjs from "dayjs";
import { useForm } from "antd/es/form/Form";
interface UserBookingData {
userId: string;
username: string;
bookingCount: string;
}
interface MeetingRoomUsedData {
meetingRoomName: string;
meetingRoomId: number;
usedCount: string;
}
export function Statistics() {
const [userBookingData, setUserBookingData] = useState<Array<UserBookingData>>();
const [meetingRoomUsedData, setMeetingRoomUsedData] = useState<Array<MeetingRoomUsedData>>();
const containerRef = useRef<HTMLDivElement>(null);
const containerRef2 = useRef<HTMLDivElement>(null);
async function getStatisticData(values: { startTime: string; endTime: string; }) {
const startTime = dayjs(values.startTime).format('YYYY-MM-DD');
const endTime = dayjs(values.endTime).format('YYYY-MM-DD');
const res = await userBookingCount(startTime, endTime);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
setUserBookingData(data);
} else {
message.error(data || '系统繁忙,请稍后再试');
}
const res2 = await meetingRoomUsedCount(startTime, endTime);
const { data: data2 } = res2.data;
if(res2.status === 201 || res2.status === 200) {
setMeetingRoomUsedData(data2);
} else {
message.error(data2 || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
const myChart = echarts.init(containerRef.current);
if(!userBookingData) {
return;
}
myChart.setOption({
title: {
text: '用户预订情况'
},
tooltip: {},
xAxis: {
data: userBookingData?.map(item => item.username)
},
yAxis: {},
series: [
{
name: '预订次数',
type: form.getFieldValue('chartType'),
data: userBookingData?.map(item => {
return {
name: item.username,
value: item.bookingCount
}
})
}
]
});
}, [userBookingData]);
useEffect(() => {
const myChart = echarts.init(containerRef2.current);
if(!meetingRoomUsedData) {
return;
}
myChart.setOption({
title: {
text: '会议室使用情况'
},
tooltip: {},
xAxis: {
data: meetingRoomUsedData?.map(item => item.meetingRoomName)
},
yAxis: {},
series: [
{
name: '使用次数',
type: form.getFieldValue('chartType'),
data: meetingRoomUsedData?.map(item => {
return {
name: item.meetingRoomName,
value: item.usedCount
}
})
}
]
});
}, [meetingRoomUsedData]);
const [form] = useForm();
return <div id="statistics-container">
<div className="statistics-form">
<Form
form={form}
onFinish={getStatisticData}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="开始日期" name="startTime">
<DatePicker />
</Form.Item>
<Form.Item label="结束日期" name="endTime">
<DatePicker />
</Form.Item>
<Form.Item label="图表类型" name="chartType" initialValue={"bar"}>
<Select>
<Select.Option value="pie">饼图</Select.Option>
<Select.Option value="bar">柱形图</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
</div>
<div className="statistics-chart" ref={containerRef}></div>
<div className="statistics-chart" ref={containerRef2}></div>
</div>
}这节加了 swagger 文档并且写了统计管理模块的前端代码。
前端部分主要是 echarts 的图表,这个根据返回的数据调整下格式,然后设置到 echarts 的 options 就行。
至此,所有模块的钱后端代码就都完成了。
前面项目部署到了阿里云,能够在全球各地通过 ip 来访问了。
方式是在服务器上通过 git 下载代码,然后用 docker compose 来跑。
但并没有把 typeorm 的 synchronize 关掉,也就是现在还是根据 entity 自动创建表的方式。
这种方式并不安全,entity 一改表结构就自动跟着改,容易丢数据。
在生产环境应该把 synchronize 关掉,然后用 migration 来管理创建表、数据初始化、表结构修改等操作。
这节来做一下:
首先,在 mysql workbench 里把现有的数据导出。
这样每个表一个 sql 文件:
选择第二个选项,重新导出下:
这样就都集中到一个 sql 文件里了。
打开这个生成的 sql 文件看下:
包含了所有的表的 create table 和 insert into 语句。
然后在项目的 package.json 里加一下 migration 的 npm scripts:
"typeorm": "ts-node ./node_modules/typeorm/cli",
"migration:create": "npm run typeorm -- migration:create",
"migration:generate": "npm run typeorm -- migration:generate -d ./src/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d ./src/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d ./src/data-source.ts"migration 需要单独一个 data-source.ts 文件。
创建下 src/data-source.ts
import { DataSource } from "typeorm";
import { config } from 'dotenv';
import { Permission } from './user/entities/permission.entity';
import { Role } from './user/entities/role.entity';
import { User } from './user/entities/user.entity';
import { MeetingRoom } from "./meeting-room/entities/meeting-room.entity";
import { Booking } from "./booking/entities/booking.entity";
config({ path: 'src/.env-migration' });
console.log(process.env);
export default new DataSource({
type: "mysql",
host: `${process.env.mysql_server_host}`,
port: +`${process.env.mysql_server_port}`,
username: `${process.env.mysql_server_username}`,
password: `${process.env.mysql_server_password}`,
database: `${process.env.mysql_server_database}`,
synchronize: false,
logging: true,
entities: [
User, Role, Permission, MeetingRoom, Booking
],
poolSize: 10,
migrations: ['src/migrations/**.ts'],
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
});synchronize 指定为 false,顺便把 AppModule 里的 synchronize 也改为 false。
创建个单独的 .env-migration 文件:
mysql_server_host=localhost
mysql_server_port=3306
mysql_server_username=root
mysql_server_password=guang
mysql_server_database=meeting_room_booking_system因为 .env 里配置的 host 是容器名,那个只对 docker compose 内部生效:
而 migration 的时候是需要真实的 host 的地址的,所以单独搞一个 .env 文件来配置。
跑下 migration:generate 试试:
npm run migration:generate src/migrations/init如果遇到找不到 entity 的错误,把绝对路径改为相对路径就好了:
这时候没什么更改:
因为 entity 和数据库表是同步的。
把数据表给删掉:
再跑下:
npm run migration:generate src/migrations/init这时候就成功生成了迁移代码:
里面包含的自然就是所有表的 create table 语句:
跑下这个迁移:
npm run migration:run可以看到,所有 create table 语句都执行了:
数据库里也有了这些表:
并且在 migrations 表里记录了这个 migration 的执行记录:
表创建好了,还要做初始化数据。
同样通过 migration 来做,但是这个是没法 generate 的,generate 只会对比表结构,然后生成迁移 sql。
执行 create 生成 migration 类:
npm run migration:create src/migrations/data这次自己来填 sql:
之前导出的 sql 文件现在就有用了。
把里面的 insert into 语句复制出来:
import { MigrationInterface, QueryRunner } from "typeorm";
export class Data1718869390167 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("INSERT INTO `booking` VALUES (1,'2023-09-29 10:54:01','2023-09-29 11:54:01','审批通过','',1,3,'2023-09-29 03:06:54.088220','2023-11-17 01:58:53.000000'),(2,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批驳回','',2,6,'2023-09-29 03:06:54.088220','2023-10-21 03:42:53.000000'),(3,'2023-09-29 11:54:02','2023-09-29 12:54:02','已解除','',2,3,'2023-09-29 03:06:54.088220','2023-11-19 01:10:49.741279'),(4,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',1,6,'2023-09-29 03:06:54.088220','2023-11-19 02:30:58.000000'),(5,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',8,11,'2023-09-29 03:06:54.088220','2023-11-19 02:30:58.000000'),(6,'2023-09-29 10:54:02','2023-09-29 11:54:02','已解除','',9,11,'2023-09-29 03:06:54.088220','2024-01-01 01:23:53.000000'),(7,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',9,3,'2023-09-29 03:06:54.088220','2024-01-01 01:10:14.279639'),(8,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',9,6,'2023-09-29 03:06:54.088220','2024-01-01 01:19:23.388907'),(13,'2023-12-31 09:40:59','2023-12-31 09:57:39','申请中','',1,11,'2023-12-31 04:34:38.917720','2024-01-01 01:08:53.884596'),(14,'2023-12-31 09:42:39','2023-12-31 10:14:19','申请中','',1,6,'2023-12-31 04:35:06.508185','2024-01-01 01:08:04.023433'),(15,'2024-01-01 11:00:00','2024-01-01 12:00:00','申请中','',1,3,'2024-01-01 02:43:26.920016','2024-01-01 02:43:26.920016'),(16,'2024-01-01 13:00:00','2024-01-01 14:00:00','申请中','',1,3,'2024-01-01 02:45:47.758012','2024-01-01 02:45:47.758012'),(17,'2024-01-01 15:00:00','2024-01-01 16:00:00','申请中','',1,3,'2024-01-01 02:46:39.654219','2024-01-01 02:46:39.654219');")
await queryRunner.query("INSERT INTO `meeting_room` VALUES (3,'天王星33',30,'三层东','白板,电视','',0,'2023-09-16 08:10:25.319000','2023-09-16 08:10:25.319000'),(6,'aa',10,'bb','','',0,'2023-09-16 16:11:31.532151','2023-09-16 16:11:31.532151'),(11,'xxx',0,'xxx','xxx','xxx',0,'2023-09-28 00:27:28.529606','2023-09-28 00:27:28.529606'),(12,'aaa',0,'xxx','xxx','xxx',0,'2023-09-28 00:28:09.291785','2023-09-28 00:28:09.291785');")
await queryRunner.query("INSERT INTO `permissions` VALUES (7,'ccc','访问 ccc 接口'),(8,'ddd','访问 ddd 接口');")
await queryRunner.query("INSERT INTO `role_permissions` VALUES (7,7),(7,8),(8,7);")
await queryRunner.query("INSERT INTO `roles` VALUES (7,'管理员'),(8,'普通用户');")
await queryRunner.query("INSERT INTO `user_roles` VALUES (8,7),(9,8);")
await queryRunner.query("INSERT INTO `users` VALUES (1,'guang','e10adc3949ba59abbe56e057f20f883e','神说要有光','xxxx@xx.com',NULL,NULL,1,0,'2023-07-25 02:25:03.379725','2023-09-12 03:40:40.000000'),(2,'guang2','e10adc3949ba59abbe56e057f20f883e','神说要有光2','1024195375@qq.com',NULL,NULL,1,0,'2023-07-26 01:39:33.456072','2023-09-12 03:57:06.000000'),(8,'zhangsan','e3ceb5881a0a1fdaad01296d7554868d','张三','1024195375@qq.com','uploads/1694845395657-974488092-headpic1.png','13233323333',1,1,'2023-07-26 03:19:06.523236','2023-09-16 06:59:32.000000'),(9,'lisi','1a100d2c0dab19c4430e7d73762b3423','里斯','yy@yy.com','xxx.png',NULL,1,0,'2023-07-26 03:19:06.553169','2023-08-02 13:05:53.000000'),(10,'dongdong','1a100d2c0dab19c4430e7d73762b3423','东东555','1024195375@qq.com','uploads/1692775078275-227950493-headpic2.png',NULL,1,0,'2023-08-12 15:41:10.537642','2023-09-12 04:01:41.000000'),(11,'gang','96e79218965eb72c92a549dd5a330112','小刚','1024195375@qq.com',NULL,NULL,1,0,'2023-08-12 23:54:38.856096','2023-09-12 03:50:45.000000'),(12,'xiaohong','96e79218965eb72c92a549dd5a330112','小红','1024195375@qq.com',NULL,NULL,0,0,'2023-08-13 00:00:53.802850','2023-09-12 03:54:32.846920'),(13,'xiaodong','96e79218965eb72c92a549dd5a330112','东东','1024195375@qq.com',NULL,NULL,1,0,'2023-08-13 00:01:52.641836','2023-09-12 03:53:09.000000');")
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("TRUNCATE TABLE booking");
}
}这里要用你自己的 sql。
然后 down 部分如果你想 revert 的话也可以写一下,就是 insert into 的反义词 delete from,或者直接 truncate table 清空数据。
跑一下:
npm run migration:run这一步有可能报错:
也就是列的顺序对不上。
因为 sql 文件里的顺序是这样的:
第 6、7 列是 userId、roomId,和 insert into 语句对应。
而现在第 6 列是 createTime:
所以就报了上面的错误。
这个问题的解决也简单,insert into 的时候指定列名就好了。
右键表,选择复制 insert into 语句:
在输入框粘贴下:
把列名这部分复制出来,调整成正确的顺序然后放到 insert into 的 sql 里:
只有这一个有问题的 insert 语句指定下列名就行,别的 insert 语句不用改。
然后你还可能遇到外键约束的问题:
就是你插入了一条 booking 记录,里面的 userId 在 user 表里没关联的 user。
这种调整下插入顺序就好了,先插入 user 表,再插入 booking 表。
import { MigrationInterface, QueryRunner } from "typeorm";
export class Data1718869390167 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("INSERT INTO `booking`(`id`,`startTime`,`endTime`,`status`,`note`,`userId`,`roomId`,`createTime`,`updateTime`) VALUES (1,'2023-09-29 10:54:01','2023-09-29 11:54:01','审批通过','',1,3,'2023-09-29 03:06:54.088220','2023-11-17 01:58:53.000000'),(2,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批驳回','',2,6,'2023-09-29 03:06:54.088220','2023-10-21 03:42:53.000000'),(3,'2023-09-29 11:54:02','2023-09-29 12:54:02','已解除','',2,3,'2023-09-29 03:06:54.088220','2023-11-19 01:10:49.741279'),(4,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',1,6,'2023-09-29 03:06:54.088220','2023-11-19 02:30:58.000000'),(5,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',8,11,'2023-09-29 03:06:54.088220','2023-11-19 02:30:58.000000'),(6,'2023-09-29 10:54:02','2023-09-29 11:54:02','已解除','',9,11,'2023-09-29 03:06:54.088220','2024-01-01 01:23:53.000000'),(7,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',9,3,'2023-09-29 03:06:54.088220','2024-01-01 01:10:14.279639'),(8,'2023-09-29 10:54:02','2023-09-29 11:54:02','审批通过','',9,6,'2023-09-29 03:06:54.088220','2024-01-01 01:19:23.388907'),(13,'2023-12-31 09:40:59','2023-12-31 09:57:39','申请中','',1,11,'2023-12-31 04:34:38.917720','2024-01-01 01:08:53.884596'),(14,'2023-12-31 09:42:39','2023-12-31 10:14:19','申请中','',1,6,'2023-12-31 04:35:06.508185','2024-01-01 01:08:04.023433'),(15,'2024-01-01 11:00:00','2024-01-01 12:00:00','申请中','',1,3,'2024-01-01 02:43:26.920016','2024-01-01 02:43:26.920016'),(16,'2024-01-01 13:00:00','2024-01-01 14:00:00','申请中','',1,3,'2024-01-01 02:45:47.758012','2024-01-01 02:45:47.758012'),(17,'2024-01-01 15:00:00','2024-01-01 16:00:00','申请中','',1,3,'2024-01-01 02:46:39.654219','2024-01-01 02:46:39.654219');")
await queryRunner.query("INSERT INTO `users` VALUES (1,'guang','e10adc3949ba59abbe56e057f20f883e','神说要有光','xxxx@xx.com',NULL,NULL,1,0,'2023-07-25 02:25:03.379725','2023-09-12 03:40:40.000000'),(2,'guang2','e10adc3949ba59abbe56e057f20f883e','神说要有光2','1024195375@qq.com',NULL,NULL,1,0,'2023-07-26 01:39:33.456072','2023-09-12 03:57:06.000000'),(8,'zhangsan','e3ceb5881a0a1fdaad01296d7554868d','张三','1024195375@qq.com','uploads/1694845395657-974488092-headpic1.png','13233323333',1,1,'2023-07-26 03:19:06.523236','2023-09-16 06:59:32.000000'),(9,'lisi','1a100d2c0dab19c4430e7d73762b3423','里斯','yy@yy.com','xxx.png',NULL,1,0,'2023-07-26 03:19:06.553169','2023-08-02 13:05:53.000000'),(10,'dongdong','1a100d2c0dab19c4430e7d73762b3423','东东555','1024195375@qq.com','uploads/1692775078275-227950493-headpic2.png',NULL,1,0,'2023-08-12 15:41:10.537642','2023-09-12 04:01:41.000000'),(11,'gang','96e79218965eb72c92a549dd5a330112','小刚','1024195375@qq.com',NULL,NULL,1,0,'2023-08-12 23:54:38.856096','2023-09-12 03:50:45.000000'),(12,'xiaohong','96e79218965eb72c92a549dd5a330112','小红','1024195375@qq.com',NULL,NULL,0,0,'2023-08-13 00:00:53.802850','2023-09-12 03:54:32.846920'),(13,'xiaodong','96e79218965eb72c92a549dd5a330112','东东','1024195375@qq.com',NULL,NULL,1,0,'2023-08-13 00:01:52.641836','2023-09-12 03:53:09.000000');")
await queryRunner.query("INSERT INTO `meeting_room` VALUES (3,'天王星33',30,'三层东','白板,电视','',0,'2023-09-16 08:10:25.319000','2023-09-16 08:10:25.319000'),(6,'aa',10,'bb','','',0,'2023-09-16 16:11:31.532151','2023-09-16 16:11:31.532151'),(11,'xxx',0,'xxx','xxx','xxx',0,'2023-09-28 00:27:28.529606','2023-09-28 00:27:28.529606'),(12,'aaa',0,'xxx','xxx','xxx',0,'2023-09-28 00:28:09.291785','2023-09-28 00:28:09.291785');")
await queryRunner.query("INSERT INTO `permissions` VALUES (7,'ccc','访问 ccc 接口'),(8,'ddd','访问 ddd 接口');")
await queryRunner.query("INSERT INTO `roles` VALUES (7,'管理员'),(8,'普通用户');")
await queryRunner.query("INSERT INTO `role_permissions` VALUES (7,7),(7,8),(8,7);")
await queryRunner.query("INSERT INTO `user_roles` VALUES (8,7),(9,8);")
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("TRUNCATE TABLE booking");
}
}调整完再跑:
npm run migration:run执行成功了!
在数据库看下:
数据都插入成功了,并且 migrations 表也记录了这次的执行记录:
这样之后,生产环境就有表、也有数据了,之后就可以正常跑 Nest 应用了。
这节实现了 migration 数据迁移,也就是创建表、初始化数据。
在生产环境会把 synchronize 关掉,然后跑 migration。
用 migration:generate 生成了 create table 的 migration。
然后用 migration:create 生成了空的 migration,填入了导出的 insert 语句。
执行完这两个 migration 之后,表和数据就都有了,就可以跑 Nest 项目了。
线上项目,都是这样用手动跑 migration 的方式来修改表的。
前面实现了头像的上传:
图片是保存在服务器 uploads 目录下的:
然后把这个目录设置为静态文件目录:
这样图片就能直接访问了:
数据库里保存的也是这个路径:
这样是能完成功能,但有一些问题:
文件直接保存在某个目录下,服务器磁盘空间是有上限的,如果满了怎么办?
这些文件都没有一个管理界面来管理,很不方便。
所以,一般没人这么干,都会用 OSS 来做文件的管理,比如阿里云 OSS、或者用 minio 自己搭的 OSS 服务。
这节就把头像上传到用 minio 搭的 OSS。
把 minio 的 docker 镜像跑起来:
指定本地的某个目录,映射到容器里的 /bitnami/minio/data 目录。
指定端口 9000 和 9001 的映射。(9000 是文件访问的端口,9001 是管理页面的端口)
然后指定登录的用户名、密码 MINIO_ROOT_USER、MINIO_ROOT_PASSWORD
跑起来之后,访问下 http://localhost:9001
输入用户名密码后点击登录:
创建个 bucket:
设置下可以公开访问:
然后上传个文件:
复制路径,然后浏览器访问下:
http://localhost:9000/meeting-room-booking-system/headpic1.jpg
这样,文件就上传到了 OSS 里的 bucket,并且能够访问了。
这不比直接把文件放到文件目录下方便的多?
然后直接前端直传 minio 就好了:
上传完之后把 url 给服务端保存到数据库就行。
但是这个 accessKey 也不能暴露到前端代码里,需要在服务端做预签名。
这个在前端直传文件到 minio那节讲过。
进入 backend 的项目,安装 minio 的包:
npm install --save minio然后创建个 minio 模块:
nest g module minioimport { Global, Module } from '@nestjs/common';
import * as Minio from 'minio';
@Global()
@Module({
providers: [
{
provide: 'MINIO_CLIENT',
async useFactory() {
const client = new Minio.Client({
endPoint: 'localhost',
port: 9000,
useSSL: false,
accessKey: '',
secretKey: ''
})
return client;
}
}
],
exports: ['MINIO_CLIENT']
})
export class MinioModule {}把 minio client 封装成 provider,放到 exports 里,并设置模块为 @Global。
用到 accessKey 和 secretKey 在这里创建:
这些当然也可以放到配置文件里配置:
# minio 配置
minio_endpoint=localhost
minio_port=9000
minio_access_key=xxx
minio_secret_key=xxx因为 ConfigModule 声明为了全局模块:
所以可以直接在 MinioModule 里注入 ConfigService:
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';
@Global()
@Module({
providers: [
{
provide: 'MINIO_CLIENT',
async useFactory(configService: ConfigService) {
const client = new Minio.Client({
endPoint: configService.get('minio_endpoint'),
port: +configService.get('minio_port'),
useSSL: false,
accessKey: configService.get('minio_access_key'),
secretKey: configService.get('minio_secret_key')
})
return client;
},
inject: [ConfigService]
},
],
exports: ['MINIO_CLIENT']
})
export class MinioModule {}然后创建 MinioController
nest g controller minio --no-spec注入 Minio Client:
import { Controller, Get, Inject, Query } from '@nestjs/common';
import * as Minio from 'minio';
@Controller('minio')
export class MinioController {
@Inject('MINIO_CLIENT')
private minioClient: Minio.Client;
@Get('presignedUrl')
presignedPutObject(@Query('name') name: string) {
return this.minioClient.presignedPutObject('meeting-room-booking-system', name, 3600);
}
}presignedPutObject 第一个参数是 bucketName,第二个参数是 objectName,第三个参数是 expires。
bucketName 就是 meeting-room-booking-system,这个也可以抽到 .env 文件里,用 configService 读取。
objectName 需要上传文件的时候拿到 file.name 作为参数传入。
expires 是生成的临时签名的过期时间,指定 3600 秒,也就是一小时。
调用下这个接口试试:
可以看到,返回了 aaa.png 的预签名的 url,这样前端不需要 accessKey 也可以用这个 url 来上传文件到 minio 了。
在 frontend_user 项目里用一下:
之前是指定了 action 参数,当选择文件后,antd 的 Dragger 组件会自动上传文件:
它还支持函数的形式,会传入 file 然后做处理后返回 url:
这里很显然就可以调用 presignedUrl 接口,拿到直传 minio 的 url。
在 interfaces.ts 添加一个接口:
export async function presignedUrl(fileName: string) {
return axiosInstance.get(`/minio/presignedUrl?name=${fileName}`);
}然后 action 这里调用下:
action: async (file) => {
const res = await presignedUrl(file.name);
return res.data.data;
},
async customRequest(options) {
const { onSuccess, file, action } = options;
const res = await axios.put(action, file);
onSuccess!(res.data);
},为什么要 customRequest 呢?
因为默认 Dragger 是用 FormData 的格式上传的,也就是 key value 的格式。
指定的 name 就是 key。
但是 minio 要求直接把文件放到 body 里。
所以要用 customRequest 自定义请求方式。
试一下:
提示上传成功。
在 minio 管理界面也可以看到这个文件:
接下来只要改下 onChange 的值,以及回显的图片的 url 就好了。
onChange('http://localhost:9000/meeting-room-booking-system/' + info.file.name);试下效果:
输入验证码,点击修改,会提示更新成功:
去数据库里看下:
确实改过来了。
这样,基于 minio 搭的 OSS 服务的图片上传功能就完成了。
然后改下右上角按钮,改成显示用户头像:
首先,用户信息更新完后,同步修改下 localStorage 里的 user_info
const userInfo = localStorage.getItem('user_info');
if(userInfo) {
const info = JSON.parse(userInfo);
info.headPic = values.headPic;
info.nickName = values.nickName;
localStorage.setItem('user_info', JSON.stringify(info));
}测试下:
可以看到,点击修改后,localStorage 里的数据也同步更新了。
然后右上角的按钮也可以从 localStorage 里取最新的 headPic:
import { UserOutlined } from "@ant-design/icons";
import { Link, Outlet } from "react-router-dom";
import './index.css';
import { useEffect, useState } from "react";
export function Index() {
const [headPic, setHeadPic] = useState();
useEffect(() => {
const userInfo = localStorage.getItem('user_info');
if(userInfo) {
const info = JSON.parse(userInfo);
setHeadPic(info.headPic);
}
}, []);
return <div id="index-container">
<div className="header">
<h1>会议室预订系统</h1>
<Link to={'/update_info'} >
{
headPic ? <img src={headPic} width={40} height={40} className="icon"/> : <UserOutlined className="icon"/>
}
</Link>
</div>
<div className="body">
<Outlet></Outlet>
</div>
</div>
}useState 创建一个状态来保存 headPic。
在 useEffect 里读取 localStorage 里的值,调用 setHeadPic。
渲染的时候如果 headPic 有值就渲染 img,否则渲染默认的 icon。
这样,头像就正确显示了。
这节把文件上传从基于 multer 实现,保存在项目目录下,换成了基于 minio 实现的 OSS 服务。
是用前端直传 OSS,然后把文件 url 发给应用服务器的方式。
但是又不想在前端代码暴露 accessKey,所以是用的预签名的方式,服务端用 presignedPutObject 返回一个预签名 url 给前端。前端用这个 url 来发送 put 请求,来把文件直传 minio。
antd 的 Dragger 组件默认用 form data 来发送请求,通过 customRequest 来重写了上传逻辑。
这样,文件就都保存在了 minio 服务里,可以更方便的管理。
我们的网站实现了用户名密码登录,用户忘记了密码也可以通过邮箱验证码来修改密码,之后再登录。
这样功能上没问题,但是不方便。
用户会访问很多个网站,如果每个网站都需要注册一个账号,那对于不常用的网站,很容易忘记账号密码。
解决方式就是支持三方账号登录。
比如 dockerhub 的登录:
你可以登录 google、github 账号,然后授权,这样 dockerhub 网站就可以自动登录了。
根本不需要单独的账号密码。
原理就是他用你 github、google 的信息来给你创建了一个账号,之后只要是同样的 github、google 信息就可以自动登录了。
当你第一次授权的时候:
会跳到这个页面:
因为缺少了 username 的信息,你填上之后就可以注册了。
并且它还自动给你登录了:
然后退出登录用 google 账号登录一次:
因为你授权过,短时间内不需要再次授权,于是就直接登录了。
感受下现在的登录体验。
需要记住用户名密码么?
不需要。
你只需要点下 google 登录,然后授权,就自动登录了。
也在会议室预订系统里集成下 google 登录。
会用到 passport-google 的策略来做三方登录,所以要集成 passport。
进入 backend 项目,安装 passport 的包:
npm install --save @nestjs/passport passport安装用户名密码认证的 passport-local 策略包:
npm install --save passport-local
npm install --save-dev @types/passport-local然后创建一个认证模块:
nest g module auth添加用户名密码认证的策略 auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
async validate(username: string, password: string) {
}
}这里需要用到 UserService,在 UserModule 导出一下:
然后 AuthModule 引入 UserModule:
并把 LocalStrategy 声明为 provider。
LocalStrategy 里直接复用 userService.login 来做登录认证:
passport 会把返回的 user 信息放在 request.user 上。
然后 user/login 里就不需要手动调用 userService 了:
改成这样,用 AuthGuard('local') 来做处理即可,处理完从 request.user 里拿 user 信息:
把服务跑起来:
npm run start:dev然后进入 frontend_user 也把开发服务跑起来:
npm run dev当用户不存在:
当密码错误:
用户名密码都正确时:
没啥问题。
这样,我们的 passport 就集成成功了。
当然,passport-local、passport-jwt 都不集成也可以,直接用 passport-google 和 passport-github 就行。
各个策略都是独立的,可以单独使用。
下面来实现 google 和 github 登录:
前面实现了基于 passport 的用户名密码登录,这节继续来实现下 Google 登录。
在 passport 的网站搜索:
找下载量最多的那个。
然后安装下:
npm install --save passport-google-oauth20
npm install --save-dev @types/passport-google-oauth20获取 client id 和 client secret 的步骤前面章节讲过。
授权的域名、callback url 要和应用中的对应才行,否则就重新搞一个。
我这里重新注册了一个应用,拿到了 client id:
创建 auth/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor() {
super({
clientID: '你的 clientID',
clientSecret: '你的 clientSecret',
callbackURL: 'http://localhost:3005/user/callback/google',
scope: ['email', 'profile'],
});
}
validate (accessToken: string, refreshToken: string, profile: any) {
const { name, emails, photos } = profile
const user = {
email: emails[0].value,
firstName: name.givenName,
lastName: name.familyName,
picture: photos[0].value,
accessToken
}
return user;
}
}这里填入刚刚的 clientID、clientSecret、callbackURL。
在 AuthModule 引入:
之后在 UserController 添加两个路由:
@Get('google')
@UseGuards(AuthGuard('google'))
async googleAuth() {}
@Get('callback/google')
@UseGuards(AuthGuard('google'))
googleAuthRedirect(@Req() req) {
if (!req.user) {
return 'No user from google'
}
return {
message: 'User information from google',
user: req.user
}
}一个是触发登录的,一个是回调的。
测试下:
可以看到,google 的用户信息拿到了:
那只要在拿到用户信息的时候自动注册下就好了。
在 user.entity.ts 添加 loginType 字段:
@Column({
type: 'int',
comment: '登录类型, 0 用户名密码登录, 1 Google 登录, 2 Github 登录',
default: 0
})
loginType: LoginType;export enum LoginType {
USERNAME_PASSWORD = 0,
GOOGLE = 1,
GITHUB = 2
}默认是用户名密码登录,值是 0,当 google 或者 github 登录时,loginType 是 1 和 2。
因为前几节把 synchronize 关掉了,所以现在并不会自动创建这一列:
通过 migration:generate 生成个迁移:
npm run migration:generate src/migrations/add-user-loginType-column生成的迁移类没问题:
跑一下:
npm run migration:run在数据库看一下:
loginType 列添加成功了。
migrations 表也记录了这条执行记录。
然后补充下 google 授权后自动注册的逻辑:
在 UserService 里添加 registerByGoogleInfo 方法:
async registerByGoogleInfo(email: string, nickName: string, headPic: string) {
const newUser = new User();
newUser.email = email;
newUser.nickName = nickName;
newUser.headPic = headPic;
newUser.password = '';
newUser.username = email + Math.random().toString().slice(2, 10);
newUser.loginType = LoginType.GOOGLE;
newUser.isAdmin = false;
return this.userRepository.save(newUser);
}email、nickName、headPic 都是基于 google 返回的信息。
username 就用 email + 随机数的方式生成,反正也不需要用用户名密码登录,保证唯一就行。
passport 也是一样。
在 UserController 调用下:
@Get('callback/google')
@UseGuards(AuthGuard('google'))
async googleAuthRedirect(@Req() req) {
if (!req.user) {
throw new BadRequestException('google 登录失败');
}
const user = await this.userService.registerByGoogleInfo(
req.user.email,
req.user.firstName + ' ' + req.user.lastName,
req.user.picture
);
const vo = new LoginUserVo();
vo.userInfo = {
id: user.id,
username: user.username,
nickName: user.nickName,
email: user.email,
phoneNumber: user.phoneNumber,
headPic: user.headPic,
createTime: user.createTime.getTime(),
isFrozen: user.isFrozen,
isAdmin: user.isAdmin,
roles: [],
permissions: []
}
vo.accessToken = this.jwtService.sign({
userId: vo.userInfo.id,
username: vo.userInfo.username,
email: vo.userInfo.email,
roles: vo.userInfo.roles,
permissions: vo.userInfo.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
vo.refreshToken = this.jwtService.sign({
userId: vo.userInfo.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return vo;
}就是用 google 返回的信息来自动注册,并且自动登录,返回 accessToken 和 refreshToken。
测试下:
可以看到,google 授权之后,自动注册并返回了 token 信息,这样带上这个访问就是登录状态了:
数据库里 user 表也插入了该用户的记录:
nickName、headPic、username、loginType 都是对的。
后续 refreshToken 的流程不受影响,因为它只是取出 jwt 里的 userId 来查询用户信息并重新生成 token 返回:
然后继续处理 google 登录:
google 第一次账号授权会自动注册并登录,但是后续就不需要注册了。
所以要加个判断:
如果 email 能查到用户,那就直接登录:
@Get('callback/google')
@UseGuards(AuthGuard('google'))
async googleAuthRedirect(@Req() req) {
if (!req.user) {
throw new BadRequestException('google 登录失败');
}
const foundUser = await this.userService.findUserByEmail(req.user.email);
if(foundUser) {
const vo = new LoginUserVo();
vo.userInfo = {
id: foundUser.id,
username: foundUser.username,
nickName: foundUser.nickName,
email: foundUser.email,
phoneNumber: foundUser.phoneNumber,
headPic: foundUser.headPic,
createTime: foundUser.createTime.getTime(),
isFrozen: foundUser.isFrozen,
isAdmin: foundUser.isAdmin,
roles: foundUser.roles.map(item => item.name),
permissions: foundUser.roles.reduce((arr, item) => {
item.permissions.forEach(permission => {
if(arr.indexOf(permission) === -1) {
arr.push(permission);
}
})
return arr;
}, [])
}
vo.accessToken = this.jwtService.sign({
userId: vo.userInfo.id,
username: vo.userInfo.username,
email: vo.userInfo.email,
roles: vo.userInfo.roles,
permissions: vo.userInfo.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
vo.refreshToken = this.jwtService.sign({
userId: vo.userInfo.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return vo;
} else {
const user = await this.userService.registerByGoogleInfo(
req.user.email,
req.user.firstName + ' ' + req.user.lastName,
req.user.picture
);
const vo = new LoginUserVo();
vo.userInfo = {
id: user.id,
username: user.username,
nickName: user.nickName,
email: user.email,
phoneNumber: user.phoneNumber,
headPic: user.headPic,
createTime: user.createTime.getTime(),
isFrozen: user.isFrozen,
isAdmin: user.isAdmin,
roles: [],
permissions: []
}
vo.accessToken = this.jwtService.sign({
userId: vo.userInfo.id,
username: vo.userInfo.username,
email: vo.userInfo.email,
roles: vo.userInfo.roles,
permissions: vo.userInfo.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
vo.refreshToken = this.jwtService.sign({
userId: vo.userInfo.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return vo;
}
}在 UserService 实现 findUserByEmail 方法:
async findUserByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
email: email,
isAdmin: false,
},
relations: [ 'roles', 'roles.permissions']
});
return user;
}测试下:
数据库已经注册了这个 google 账号对应的 user,再次授权,会查询注册的用户信息返回。
可以看到,只执行了 select 语句:
但网站登录后一般都会重定向到首页:
这时候一般都是用 cookie 返回 token 的,比如 https://hub.docker.com 就是这么做的:
可以看到,它并不是直接返回 jwt 的 token,而是重定向回首页,在 cookie 里携带 token。
前端只要判断下如果 cookie 里有这些 token 就自动登录就好了。
也处理下:
操作 cookie 需要用到 cookie-parser 中间件:
npm install --save cookie-parser
npm install --save-dev @types/cookie-parser在 main.ts 启用下:
在 callback/google 里注入 Response,设置 cookie:
然后重定向:
试一下:
重定向没问题。
cookie 也没问题:
此外,还要处理下普通的登录,用户名密码登录的时候要过滤下 loginType:
google 登录的账号是不能通过用户名密码登录的。
这样,google 登录的后端部分就完成了。
案例代码上传了
我们的应用之前只支持用户名密码登录,今天实现了 google 登录的后端部分。
首先把用户名密码的身份认证改成了用 passport。
当然,这不是必须的,每个策略都是可以独立用的。
然后创建了新的 google 应用,拿到 client id 和 client secret。
用 passport-google-oauth20 的策略来实现了 google 登录。
在 callback 的路由里,基于 google 返回的信息做了自动注册,如果用户已经注册过,就直接返回登录信息。
google 登录的 callback 里重定向到网站首页,然后通过 cookie 携带 userInfo、accessToken、refreshToken 等信息。
前端代码再处理下 cookie,同步登录状态就好了。
上节实现了 Google 账号登录的后端代码,这节在前端代码里调用下就好了。
类似 https://login.docker.com/u/login
在登录框下加个 google 账号登录的按钮。
改下 Login.tsx
<Form.Item
{...layout2}
>
<div>
<a href="#" onClick={() => {
window.location.href = "http://localhost:3005/user/google";
}}>Google 账号登录</a>
</div>
</Form.Item>点击的时候跳转 google 登录页面。
试一下:
跳转没问题。
再看下 cookie:
也没问题。
接下来只需要在首页处理下 cookie,同步登录状态就好了。
那怎么同步登录状态呢?
看下之前的登录流程:
登录后在 localStorage 里保存了 access_token、refresh_token、user_info 这三个信息,这就是保存了登录状态了。
那现在只要判断下有这三个 cookie,就把它设置到 localStorage,然后刷新下页面不就好了?
安装下操作 cookie 的包:
npm install --save js-cookie
npm i --save-dev @types/js-cookie然后在 Index 组件里写下逻辑:
useEffect(() => {
const userInfo = cookies.get('userInfo');
const accessToken = cookies.get('accessToken');
const refreshToken = cookies.get('refreshToken');
if(userInfo && accessToken && refreshToken) {
localStorage.setItem('user_info', userInfo);
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
cookies.remove('userInfo');
cookies.remove('accessToken');
cookies.remove('refreshToken');
}
}, []);如果 cookie 包含 userInfo、accessToken、refreshToken,就设置到 localStorage,然后把 cookie 删掉。
再试下:
登录成功!
短时间内再次登录不需要再次授权:
你用 dockerhub 也是这样:
这样,Google 账号登录的前端代码就完成了。
案例代码上传了
这节实现了 Google 账号登录的前端部分。
点击 Google 登录按钮的时候修改 location.href 为 /user/google 触发 Google 账号登录授权。
授权后会回调 /user/callback/google,在接口查询了用户信息,通过 cookie 返回 userInfo 和 jwt 的 token,然后重定向到首页。