create-pixle-koa-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-app.js +76 -0
- package/package.json +17 -0
- package/template/.env +20 -0
- package/template/app.js +49 -0
- package/template/config/db.js +29 -0
- package/template/config/email.js +30 -0
- package/template/config/index.js +36 -0
- package/template/config/redis.js +19 -0
- package/template/controllers/AuthController.js +71 -0
- package/template/controllers/DownloadController.js +18 -0
- package/template/controllers/UploadController.js +60 -0
- package/template/controllers/UserController.js +90 -0
- package/template/middleware/auth.js +91 -0
- package/template/middleware/errorHandler.js +17 -0
- package/template/middleware/logger.js +41 -0
- package/template/middleware/notFound.js +84 -0
- package/template/middleware/upload.js +165 -0
- package/template/models/Auth.js +11 -0
- package/template/models/BaseDAO.js +449 -0
- package/template/models/User.js +10 -0
- package/template/package-lock.json +3427 -0
- package/template/package.json +34 -0
- package/template/public/404.html +160 -0
- package/template/routes/auth.js +21 -0
- package/template/routes/download.js +9 -0
- package/template/routes/index.js +105 -0
- package/template/routes/upload.js +28 -0
- package/template/routes/user.js +22 -0
- package/template/services/AuthService.js +190 -0
- package/template/services/CodeRedisService.js +94 -0
- package/template/services/DownloadService.js +54 -0
- package/template/services/EmailService.js +245 -0
- package/template/services/JwtTokenService.js +50 -0
- package/template/services/PasswordService.js +133 -0
- package/template/services/TokenRedisService.js +29 -0
- package/template/services/UserService.js +128 -0
- package/template/utils/crypto.js +9 -0
- package/template/utils/passwordValidator.js +81 -0
- package/template/utils/prototype/day.js +237 -0
- package/template/utils/prototype/deepClone.js +32 -0
- package/template/utils/prototype/index.js +61 -0
- package/template/utils/response.js +26 -0
- package/template//344/275/277/347/224/250/346/225/231/347/250/213.md +881 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pixle-koa-template",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Koa2 template",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nodemon app.js",
|
|
8
|
+
"start": "node app.js"
|
|
9
|
+
},
|
|
10
|
+
"author": "pixle",
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@koa/multer": "^4.0.0",
|
|
14
|
+
"@koa/router": "^12.0.0",
|
|
15
|
+
"argon2": "^0.44.0",
|
|
16
|
+
"crypto": "^1.0.1",
|
|
17
|
+
"dayjs": "^1.11.19",
|
|
18
|
+
"dotenv": "^17.2.3",
|
|
19
|
+
"ioredis": "^5.8.1",
|
|
20
|
+
"jsonwebtoken": "^9.0.2",
|
|
21
|
+
"koa": "^3.0.1",
|
|
22
|
+
"koa-bodyparser": "^4.4.1",
|
|
23
|
+
"koa-jwt": "^4.0.4",
|
|
24
|
+
"koa-mount": "^4.2.0",
|
|
25
|
+
"koa-send": "^5.0.1",
|
|
26
|
+
"koa-static": "^5.0.0",
|
|
27
|
+
"multer": "^2.0.2",
|
|
28
|
+
"mysql2": "^3.15.2",
|
|
29
|
+
"nodemailer": "^7.0.11"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"nodemon": "^3.1.10"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>页面未找到 - 404</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* 全局样式重置 */
|
|
9
|
+
* {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
17
|
+
line-height: 1.6;
|
|
18
|
+
color: #333;
|
|
19
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
20
|
+
min-height: 100vh;
|
|
21
|
+
display: flex;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
align-items: center;
|
|
24
|
+
padding: 20px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.container {
|
|
28
|
+
background: white;
|
|
29
|
+
border-radius: 16px;
|
|
30
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
31
|
+
padding: 40px;
|
|
32
|
+
text-align: center;
|
|
33
|
+
max-width: 500px;
|
|
34
|
+
width: 100%;
|
|
35
|
+
transform: translateY(0);
|
|
36
|
+
animation: float 6s ease-in-out infinite;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@keyframes float {
|
|
40
|
+
0% { transform: translateY(0px); }
|
|
41
|
+
50% { transform: translateY(-20px); }
|
|
42
|
+
100% { transform: translateY(0px); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.error-code {
|
|
46
|
+
font-size: 120px;
|
|
47
|
+
font-weight: 900;
|
|
48
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
49
|
+
-webkit-background-clip: text;
|
|
50
|
+
-webkit-text-fill-color: transparent;
|
|
51
|
+
margin-bottom: 20px;
|
|
52
|
+
line-height: 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.error-title {
|
|
56
|
+
font-size: 32px;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
margin-bottom: 15px;
|
|
59
|
+
color: #333;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.error-message {
|
|
63
|
+
font-size: 18px;
|
|
64
|
+
color: #666;
|
|
65
|
+
margin-bottom: 30px;
|
|
66
|
+
line-height: 1.5;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.home-button {
|
|
70
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
71
|
+
color: white;
|
|
72
|
+
border: none;
|
|
73
|
+
border-radius: 50px;
|
|
74
|
+
padding: 12px 30px;
|
|
75
|
+
font-size: 16px;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
transition: all 0.3s ease;
|
|
79
|
+
text-decoration: none;
|
|
80
|
+
display: inline-block;
|
|
81
|
+
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.home-button:hover {
|
|
85
|
+
transform: translateY(-3px);
|
|
86
|
+
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.home-button:active {
|
|
90
|
+
transform: translateY(-1px);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* 响应式设计 */
|
|
94
|
+
@media (max-width: 600px) {
|
|
95
|
+
.container {
|
|
96
|
+
padding: 30px 20px;
|
|
97
|
+
border-radius: 12px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.error-code {
|
|
101
|
+
font-size: 80px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.error-title {
|
|
105
|
+
font-size: 24px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.error-message {
|
|
109
|
+
font-size: 16px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.home-button {
|
|
113
|
+
padding: 10px 24px;
|
|
114
|
+
font-size: 14px;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* 装饰元素 */
|
|
119
|
+
.decor {
|
|
120
|
+
position: absolute;
|
|
121
|
+
z-index: -1;
|
|
122
|
+
opacity: 0.5;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.decor-1 {
|
|
126
|
+
top: 20%;
|
|
127
|
+
left: 10%;
|
|
128
|
+
width: 80px;
|
|
129
|
+
height: 80px;
|
|
130
|
+
background: rgba(255, 255, 255, 0.3);
|
|
131
|
+
border-radius: 50%;
|
|
132
|
+
filter: blur(10px);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.decor-2 {
|
|
136
|
+
bottom: 20%;
|
|
137
|
+
right: 10%;
|
|
138
|
+
width: 120px;
|
|
139
|
+
height: 120px;
|
|
140
|
+
background: rgba(255, 255, 255, 0.2);
|
|
141
|
+
border-radius: 50%;
|
|
142
|
+
filter: blur(15px);
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
145
|
+
</head>
|
|
146
|
+
<body>
|
|
147
|
+
<!-- 装饰元素 -->
|
|
148
|
+
<div class="decor decor-1"></div>
|
|
149
|
+
<div class="decor decor-2"></div>
|
|
150
|
+
|
|
151
|
+
<div class="container">
|
|
152
|
+
<div class="error-code">404</div>
|
|
153
|
+
<h1 class="error-title">请求的资源未找到</h1>
|
|
154
|
+
<p class="error-message">
|
|
155
|
+
抱歉,您访问的资源不存在或已被移除。<br>
|
|
156
|
+
请检查您输入的资源路径是否正确
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const authController=require('../controllers/AuthController')
|
|
2
|
+
const Router = require("@koa/router");
|
|
3
|
+
const router = new Router({
|
|
4
|
+
prefix: '/auth'
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
//登录
|
|
8
|
+
router.post('/login',authController.login)
|
|
9
|
+
//退出登录
|
|
10
|
+
router.post('/logout',authController.logout)
|
|
11
|
+
//注册
|
|
12
|
+
router.post('/register',authController.register)
|
|
13
|
+
//发送验证码
|
|
14
|
+
router.post('/verify-code',authController.sendVerificationCode)
|
|
15
|
+
//修改密码
|
|
16
|
+
router.post('/change-password',authController.changePassword)
|
|
17
|
+
//重置密码
|
|
18
|
+
router.post('/reset-password',authController.resetPassword)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
module.exports = router;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由管理器
|
|
3
|
+
* 自动遍历routes目录下的所有路由文件并注册
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const Router = require("@koa/router");
|
|
9
|
+
|
|
10
|
+
// 存储所有已注册的路由信息
|
|
11
|
+
let registeredRoutes = [];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 获取所有已注册的路由信息
|
|
15
|
+
* @returns {Array} 路由信息数组
|
|
16
|
+
*/
|
|
17
|
+
const getRegisteredRoutes = () => {
|
|
18
|
+
return [...registeredRoutes];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 收集路由器中的所有路由信息
|
|
23
|
+
* @param {Object} router - koa-router实例
|
|
24
|
+
* @param {String} basePath - 基础路径
|
|
25
|
+
*/
|
|
26
|
+
const collectRoutes = (router, basePath = "") => {
|
|
27
|
+
if (!router || !router.stack) return;
|
|
28
|
+
|
|
29
|
+
router.stack.forEach(layer => {
|
|
30
|
+
// 构建完整路径
|
|
31
|
+
const path = basePath + layer.path;
|
|
32
|
+
// 获取HTTP方法
|
|
33
|
+
const methods = layer.methods || [];
|
|
34
|
+
|
|
35
|
+
// 只收集有实际路径和方法的路由
|
|
36
|
+
if (path !== '/' || methods.length > 0) {
|
|
37
|
+
registeredRoutes.push({
|
|
38
|
+
path,
|
|
39
|
+
methods,
|
|
40
|
+
// 存储路由的正则表达式,用于后续匹配
|
|
41
|
+
regexp: layer.regexp
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 如果有嵌套路由,递归收集
|
|
46
|
+
if (layer.router) {
|
|
47
|
+
collectRoutes(layer.router, path);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 注册所有路由
|
|
54
|
+
* @param {Object} app - Koa应用实例
|
|
55
|
+
* @param {String} prefix - API前缀
|
|
56
|
+
*/
|
|
57
|
+
const registerRoutes = (app, prefix = "") => {
|
|
58
|
+
const routesDir = path.dirname(__filename);
|
|
59
|
+
|
|
60
|
+
// 清空已注册路由列表
|
|
61
|
+
registeredRoutes = [];
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const files = fs.readdirSync(routesDir);
|
|
65
|
+
const routeFiles = files.filter(
|
|
66
|
+
(file) => file.endsWith(".js") && file !== "index.js"
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 创建一个主路由器用于所有路由
|
|
70
|
+
const mainRouter = new Router({ prefix });
|
|
71
|
+
|
|
72
|
+
routeFiles.forEach((file) => {
|
|
73
|
+
try {
|
|
74
|
+
const routeName = path.basename(file, ".js");
|
|
75
|
+
const subRouter = require(path.join(routesDir, file));
|
|
76
|
+
|
|
77
|
+
if (subRouter && typeof subRouter.routes === "function") {
|
|
78
|
+
// 将子路由挂载到主路由的指定前缀下
|
|
79
|
+
mainRouter.use(subRouter.routes(), subRouter.allowedMethods());
|
|
80
|
+
console.log(`路由模块 ${routeName} 已成功注册,前缀: ${prefix}`);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`导入路由模块 ${file} 时出错:`, error.message);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 收集所有路由信息
|
|
88
|
+
collectRoutes(mainRouter);
|
|
89
|
+
|
|
90
|
+
console.log(`已收集 ${registeredRoutes.length} 个路由`);
|
|
91
|
+
// 注册主路由到应用 - 确保notFoundMiddleware在allowedMethods之后执行
|
|
92
|
+
app.use(mainRouter.routes()).use(mainRouter.allowedMethods());
|
|
93
|
+
|
|
94
|
+
// 将路由信息挂载到app实例上,方便其他地方访问
|
|
95
|
+
app.context.registeredRoutes = registeredRoutes;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error("遍历路由目录时出错:", error.message);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 导出路由注册函数和获取路由信息的函数
|
|
102
|
+
module.exports = {
|
|
103
|
+
registerRoutes,
|
|
104
|
+
getRegisteredRoutes
|
|
105
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const Router = require("@koa/router");
|
|
2
|
+
const router = new Router({prefix: '/upload'});
|
|
3
|
+
const UploadController = require("../controllers/UploadController.js");
|
|
4
|
+
const uploadMiddleware = require('../middleware/upload');//上传中间件
|
|
5
|
+
const upload = uploadMiddleware(
|
|
6
|
+
//可选配置项
|
|
7
|
+
// {
|
|
8
|
+
// limits:{
|
|
9
|
+
// fileSize: 20 * 1024 * 1024, // 单个文件最大20MB
|
|
10
|
+
// files: 5, //一次最多5个文件
|
|
11
|
+
// },
|
|
12
|
+
// allowedMimes:["image/jpeg", "image/png"],//允许上传文件格式
|
|
13
|
+
// //自定义过滤函数
|
|
14
|
+
// fileFilter:(req, file, cb)=>{
|
|
15
|
+
|
|
16
|
+
// }
|
|
17
|
+
// }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
//上传路由(单文件)
|
|
21
|
+
router.post("/single", upload.single(), UploadController.single);
|
|
22
|
+
|
|
23
|
+
//上传路由(多文件)
|
|
24
|
+
router.post("/multiple", upload.array(), UploadController.multiple);
|
|
25
|
+
|
|
26
|
+
module.exports = router;
|
|
27
|
+
|
|
28
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const userController=require('../controllers/UserController')
|
|
2
|
+
const Router = require("@koa/router");
|
|
3
|
+
const router = new Router({
|
|
4
|
+
prefix: '/user',
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
//当前登录用户详情
|
|
10
|
+
router.get('/current',userController.getCurrentUser)
|
|
11
|
+
//查询用户列表
|
|
12
|
+
router.get('/list',userController.getUserList)
|
|
13
|
+
//新增用户
|
|
14
|
+
router.post('/',userController.createUser)
|
|
15
|
+
//更新用户
|
|
16
|
+
router.put('/:id',userController.updateUser)
|
|
17
|
+
//删除用户
|
|
18
|
+
router.delete('/:id',userController.deleteUser)
|
|
19
|
+
//某个用户详情
|
|
20
|
+
router.get('/:id',userController.getUserById)
|
|
21
|
+
|
|
22
|
+
module.exports = router;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 认证服务(登录、注册、退出登录、发送验证码)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const authDAO = require("../models/Auth.js");
|
|
6
|
+
const { error } = require("../utils/response.js");
|
|
7
|
+
const { generateToken } = require("./JwtTokenService.js");
|
|
8
|
+
const argon2 = require("argon2");
|
|
9
|
+
const passwordValidator = require("../utils/passwordValidator.js");
|
|
10
|
+
const {
|
|
11
|
+
generateVerificationCode,
|
|
12
|
+
sendVerificationCode,
|
|
13
|
+
isValidEmail,
|
|
14
|
+
} = require("./EmailService.js");
|
|
15
|
+
const CodeRedisService = require("./CodeRedisService.js");
|
|
16
|
+
const { deleteToken } = require("./TokenRedisService.js");
|
|
17
|
+
|
|
18
|
+
class AuthService {
|
|
19
|
+
/**
|
|
20
|
+
* 登录
|
|
21
|
+
* @param {*} ctx :koa上下文
|
|
22
|
+
* @param {*} account :账号
|
|
23
|
+
* @param {*} password :密码
|
|
24
|
+
* @returns
|
|
25
|
+
*/
|
|
26
|
+
async login(ctx, account, password) {
|
|
27
|
+
if (!account) {
|
|
28
|
+
return error(ctx, "请输入账号", 400);
|
|
29
|
+
} else if (!password) {
|
|
30
|
+
return error(ctx, "请输入密码", 400);
|
|
31
|
+
}
|
|
32
|
+
//找出用户
|
|
33
|
+
let conditions = { account };
|
|
34
|
+
let result = await authDAO.findOne(conditions);
|
|
35
|
+
//新用户
|
|
36
|
+
if (!result) {
|
|
37
|
+
return error(ctx, "用户不存在", 400);
|
|
38
|
+
}
|
|
39
|
+
//存在用户
|
|
40
|
+
else {
|
|
41
|
+
//验证密码正确性
|
|
42
|
+
const { id: userId, password: hashedPassword } = result;
|
|
43
|
+
let isMatch = await argon2.verify(hashedPassword, password);
|
|
44
|
+
if (!isMatch) {
|
|
45
|
+
return error(ctx, "密码错误", 400);
|
|
46
|
+
}
|
|
47
|
+
//生成token
|
|
48
|
+
let token = await generateToken({
|
|
49
|
+
userId,
|
|
50
|
+
account,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return { token, userId };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async logout(ctx) {
|
|
58
|
+
// 从上下文获取用户ID
|
|
59
|
+
const { userId } = ctx.state.user;
|
|
60
|
+
// 从redis删除用户的token
|
|
61
|
+
return await deleteToken(userId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 注册
|
|
66
|
+
* @param {*} ctx:koa上下文
|
|
67
|
+
* @param {*} account :账号
|
|
68
|
+
* @param {*} password :密码
|
|
69
|
+
* @param {*} email :邮箱
|
|
70
|
+
* @param {*} code :验证码
|
|
71
|
+
* @param {*} isRegister :是否注册,默认true
|
|
72
|
+
* @returns {Promise<Object>} - 返回包含用户ID的对象 { id }
|
|
73
|
+
*/
|
|
74
|
+
async register(
|
|
75
|
+
ctx,
|
|
76
|
+
{ account, password, email, code, ...args },
|
|
77
|
+
isRegister = true
|
|
78
|
+
) {
|
|
79
|
+
if (!account) return error(ctx, "请输入账号", 400);
|
|
80
|
+
if (!password) return error(ctx, "请输入密码", 400);
|
|
81
|
+
if (!email) return error(ctx, "请输入邮箱", 400);
|
|
82
|
+
if (isRegister && !code) return error(ctx, "请输入验证码", 400);
|
|
83
|
+
//密码强度验证
|
|
84
|
+
let validateResult = passwordValidator.validate(password);
|
|
85
|
+
if (!validateResult.isValid) {
|
|
86
|
+
return error(ctx, validateResult.errors[0], 400);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//账号唯一性校验
|
|
90
|
+
if (await authDAO.isFieldValueExists("account", account)) {
|
|
91
|
+
return error(ctx, "账号已存在", 409);
|
|
92
|
+
}
|
|
93
|
+
//邮箱格式校验
|
|
94
|
+
if (!isValidEmail(email)) {
|
|
95
|
+
return error(ctx, "邮箱格式不正确", 400);
|
|
96
|
+
}
|
|
97
|
+
// 邮箱唯一性校验
|
|
98
|
+
if (await authDAO.isFieldValueExists("email", email)) {
|
|
99
|
+
return error(ctx, "邮箱已被注册", 409);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 实例化验证码Redis服务,指定用于注册场景
|
|
103
|
+
const codeRedisService = new CodeRedisService("register");
|
|
104
|
+
//验证码校验
|
|
105
|
+
if (isRegister) {
|
|
106
|
+
const storedCode = await codeRedisService.getVerifyCode(email);
|
|
107
|
+
if (!storedCode) {
|
|
108
|
+
return error(ctx, "验证码无效或已过期,请重新获取", 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (storedCode !== code) {
|
|
112
|
+
return error(ctx, "验证码错误,请重新输入", 400);
|
|
113
|
+
}
|
|
114
|
+
// 验证成功后清理 - 防止重复使用
|
|
115
|
+
await codeRedisService.delVerifyCode(email);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//密码加密存储
|
|
119
|
+
const hashedPassword = await argon2.hash(password, {
|
|
120
|
+
type: argon2.argon2id,
|
|
121
|
+
memoryCost: 2 ** 16,
|
|
122
|
+
timeCost: 3,
|
|
123
|
+
parallelism: 1,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
//注册用户
|
|
127
|
+
let { id } = await authDAO.create({
|
|
128
|
+
account,
|
|
129
|
+
password: hashedPassword,
|
|
130
|
+
email,
|
|
131
|
+
...args,
|
|
132
|
+
});
|
|
133
|
+
return { id };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 发送验证码
|
|
138
|
+
* @param {*} ctx :上下文
|
|
139
|
+
* @param {*} email :邮箱
|
|
140
|
+
* @param {*} type :验证码类型: register:注册, resetPassword:重置密码
|
|
141
|
+
*/
|
|
142
|
+
async sendVerificationCode(ctx, email, type = "register") {
|
|
143
|
+
const typeOptions = ["register", "resetPassword"];
|
|
144
|
+
if (!typeOptions.includes(type)) {
|
|
145
|
+
return error(ctx, "验证码类型错误", 400);
|
|
146
|
+
}
|
|
147
|
+
//邮箱基础校验
|
|
148
|
+
if (!email) {
|
|
149
|
+
return error(ctx, "请输入邮箱", 400);
|
|
150
|
+
}
|
|
151
|
+
if (!isValidEmail(email)) {
|
|
152
|
+
return error(ctx, "邮箱格式不正确,请输入正确的邮箱", 400);
|
|
153
|
+
}
|
|
154
|
+
if (type == "register") {
|
|
155
|
+
// 邮箱注册状态检查
|
|
156
|
+
if (await authDAO.isFieldValueExists("email", email)) {
|
|
157
|
+
return error(ctx, "邮箱已被注册", 409);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 实例化验证码Redis服务
|
|
162
|
+
const codeRedisService = new CodeRedisService(type);
|
|
163
|
+
|
|
164
|
+
// 每日发送次数限制
|
|
165
|
+
if (!(await codeRedisService.checkDailyAttemptsLimit(email))) {
|
|
166
|
+
return error(ctx, "今日发送验证码已达上限", 400);
|
|
167
|
+
}
|
|
168
|
+
// 发送频率限制
|
|
169
|
+
if (!(await codeRedisService.checkSendLimit(email))) {
|
|
170
|
+
return error(
|
|
171
|
+
ctx,
|
|
172
|
+
`${process.env.VERIFICATION_SEND_LIMIT_EXPIRE}秒内已发送验证码,请稍后再试`,
|
|
173
|
+
400
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
//生成验证码
|
|
178
|
+
const code = generateVerificationCode();
|
|
179
|
+
//发送邮件
|
|
180
|
+
await sendVerificationCode(email, code, type);
|
|
181
|
+
//存储验证码
|
|
182
|
+
await codeRedisService.setVerifyCode(email, code);
|
|
183
|
+
await codeRedisService.setSendLimit(email);
|
|
184
|
+
await codeRedisService.setDailyAttemptsCount(email);
|
|
185
|
+
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = new AuthService();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 验证码校验和存储相关-redis存储
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const redis = require("../config/redis.js");
|
|
6
|
+
const dayjs = require("dayjs");
|
|
7
|
+
// 验证码Key前缀
|
|
8
|
+
CODE_KEY_PREFIX = "email:code:";
|
|
9
|
+
// 短时间内发送限制Key前缀
|
|
10
|
+
LIMIT_KEY_PREFIX = "email:limit:";
|
|
11
|
+
// 每天发送次数限制Key前缀
|
|
12
|
+
DAILY_COUNT_KEY_PREFIX = "email:day:";
|
|
13
|
+
|
|
14
|
+
class CodeRedisService {
|
|
15
|
+
/**
|
|
16
|
+
* 构造函数
|
|
17
|
+
* @param {*} type 验证码类型: register:注册, resetPassword:重置密码
|
|
18
|
+
*/
|
|
19
|
+
constructor(type) {
|
|
20
|
+
this.type = type;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 存储验证码
|
|
25
|
+
* @param {*} email 邮箱
|
|
26
|
+
* @param {*} code 验证码
|
|
27
|
+
*/
|
|
28
|
+
async setVerifyCode(email, code) {
|
|
29
|
+
const key = `${CODE_KEY_PREFIX}${this.type}:${email}`;
|
|
30
|
+
// 存储验证码,设置过期时间
|
|
31
|
+
await redis.set(key, code, "EX", process.env.VERIFICATION_CODE_EXPIRE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 获取验证码
|
|
36
|
+
* @param {*} email 邮箱
|
|
37
|
+
*/
|
|
38
|
+
async getVerifyCode(email) {
|
|
39
|
+
const key = `${CODE_KEY_PREFIX}${this.type}:${email}`;
|
|
40
|
+
return await redis.get(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 删除验证码
|
|
45
|
+
* @param {*} email 邮箱
|
|
46
|
+
*/
|
|
47
|
+
async delVerifyCode(email) {
|
|
48
|
+
const key = `${CODE_KEY_PREFIX}${this.type}:${email}`;
|
|
49
|
+
return await redis.del(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 验证码短时间(60s)内发送限制验证
|
|
54
|
+
* @param {*} email 邮箱
|
|
55
|
+
*/
|
|
56
|
+
async checkSendLimit(email) {
|
|
57
|
+
const key = `${LIMIT_KEY_PREFIX}${this.type}:${email}`;
|
|
58
|
+
const exists = await redis.exists(key);
|
|
59
|
+
return !exists;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 设置验证码短时间(60s)内是否发送过
|
|
64
|
+
* @param {*} email 邮箱
|
|
65
|
+
*/
|
|
66
|
+
async setSendLimit(email) {
|
|
67
|
+
const key = `${LIMIT_KEY_PREFIX}${this.type}:${email}`;
|
|
68
|
+
await redis.set(key, "1", "EX", process.env.VERIFICATION_SEND_LIMIT_EXPIRE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 验证码每天发送次数限制验证
|
|
73
|
+
* @param {*} email 邮箱
|
|
74
|
+
*/
|
|
75
|
+
async checkDailyAttemptsLimit(email) {
|
|
76
|
+
const today = dayjs().format("YYYY-MM-DD");
|
|
77
|
+
const key = `${DAILY_COUNT_KEY_PREFIX}${this.type}:${email}:${today}`;
|
|
78
|
+
const count = Number((await redis.get(key)) ?? 0);
|
|
79
|
+
return count < Number(process.env.MAX_ATTEMPTS_PER_DAY);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 设置验证码每天发送次数限制
|
|
84
|
+
* @param {*} email 邮箱
|
|
85
|
+
*/
|
|
86
|
+
async setDailyAttemptsCount(email) {
|
|
87
|
+
const today = dayjs().format("YYYY-MM-DD");
|
|
88
|
+
const key = `${DAILY_COUNT_KEY_PREFIX}${this.type}:${email}:${today}`;
|
|
89
|
+
const count = Number((await redis.get(key)) ?? 0);
|
|
90
|
+
await redis.set(key, count + 1, "EX", 24 * 60 * 60);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = CodeRedisService;
|