Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70307d4eb8 | |||
| ddcf200de2 | |||
| fba44ca015 | |||
| b3f933e30a | |||
| 6fe4ba9fdd | |||
| 12be175ce8 | |||
| f3cd769d7d | |||
| ca147ac2a8 | |||
| 40f2c21bfa | |||
| 99108b2187 | |||
| 803d8b7bfb | |||
| 603f2987f6 | |||
| 2245960ccf | |||
| 2c6efee887 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules/
|
||||||
|
/logs/
|
||||||
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
// 使用 IntelliSense 了解相关属性。
|
||||||
|
// 悬停以查看现有属性的描述。
|
||||||
|
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "debug",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
],
|
||||||
|
"program": "${workspaceFolder}\\index.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
75
app.js
Normal file
75
app.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Koa from 'koa';
|
||||||
|
import bodyParser from 'koa-bodyparser';
|
||||||
|
import cors from '@koa/cors';
|
||||||
|
import http from 'http';
|
||||||
|
import { DBModel } from './models/index.js';
|
||||||
|
import registerRoutes from './routes/index.js';
|
||||||
|
import ResponseUtil from './utils/api_response.js';
|
||||||
|
|
||||||
|
class APP {
|
||||||
|
constructor() {
|
||||||
|
this.app = new Koa();
|
||||||
|
this.setupDB();
|
||||||
|
this.setupMiddleware();
|
||||||
|
this.setupRoutes();
|
||||||
|
this.setupFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDB() {
|
||||||
|
DBModel.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMiddleware() {
|
||||||
|
this.app.use(cors());
|
||||||
|
this.app.use(bodyParser());
|
||||||
|
|
||||||
|
this.app.use(async (ctx, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await next();
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRoutes() {
|
||||||
|
this.app.use(async (ctx, next) => {
|
||||||
|
if (ctx.path === '/') {
|
||||||
|
ResponseUtil.success(ctx, { name: 'attendant-api', version: '1.0.0' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.use(registerRoutes.getRoutes());
|
||||||
|
this.app._router = registerRoutes.router;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFallback() {
|
||||||
|
this.app.use(async (ctx) => {
|
||||||
|
ctx.status = 404;
|
||||||
|
ctx.body = { code: 404, msg: 'Not Found' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start(port) {
|
||||||
|
this.server = http.createServer(this.app.callback());
|
||||||
|
this.server.listen(port, () => {
|
||||||
|
console.log(`Server: running on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close();
|
||||||
|
this.server = null;
|
||||||
|
console.log('Server stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static createKoaApp() {
|
||||||
|
const instance = new APP();
|
||||||
|
return instance.app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { APP };
|
||||||
28
conf.json
Normal file
28
conf.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"port": 9010,
|
||||||
|
"app": {
|
||||||
|
"wxapp-escort": {
|
||||||
|
"appid": "wxf73c79e16837af07",
|
||||||
|
"secret": "5a061d65f4d35d19e62e83b98f6c63cf"
|
||||||
|
},
|
||||||
|
"wxapp-escort-admin": {
|
||||||
|
"appid": "wxf94dd97b91af09a2",
|
||||||
|
"secret": "79ba226684740830af0029763d3045f0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mongodb": {
|
||||||
|
"str": "mongodb://huashengtec.com:6000",
|
||||||
|
"host": "huashengtec.com",
|
||||||
|
"db": "health",
|
||||||
|
"option": {
|
||||||
|
"user": "ehason",
|
||||||
|
"pass": "Ehason_dbuser_2026",
|
||||||
|
"dbName": "eiot_user",
|
||||||
|
"authSource": "admin",
|
||||||
|
"autoIndex": true,
|
||||||
|
"socketTimeoutMS": 3000,
|
||||||
|
"serverSelectionTimeoutMS": 30000
|
||||||
|
},
|
||||||
|
"debug": true
|
||||||
|
}
|
||||||
|
}
|
||||||
408
handler/users.js
Normal file
408
handler/users.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import Joi from 'joi';
|
||||||
|
import { DBModel } from "../models/index.js";
|
||||||
|
import ResponseUtil from "../utils/api_response.js";
|
||||||
|
import { hashPassword, verifyPassword } from "../utils/crypto.js";
|
||||||
|
import { sanitizeUser, extractToken } from "../middleware/auth.js";
|
||||||
|
import config from "../conf.json" with { type: "json" };
|
||||||
|
|
||||||
|
// 输入校验 schema
|
||||||
|
const registerSchema = Joi.object({
|
||||||
|
userInfo: Joi.object({
|
||||||
|
profile: Joi.object({
|
||||||
|
mobile: Joi.string().required().messages({
|
||||||
|
'any.required': '缺少手机号',
|
||||||
|
'string.empty': '手机号不能为空'
|
||||||
|
}),
|
||||||
|
name: Joi.string().allow(''),
|
||||||
|
}).required(),
|
||||||
|
security: Joi.object({
|
||||||
|
passwd: Joi.string().min(6).required().messages({
|
||||||
|
'any.required': '缺少密码',
|
||||||
|
'string.min': '密码至少6位'
|
||||||
|
}),
|
||||||
|
}).required(),
|
||||||
|
location: Joi.object({
|
||||||
|
province: Joi.string().allow(''),
|
||||||
|
city: Joi.string().allow(''),
|
||||||
|
district: Joi.string().allow(''),
|
||||||
|
}).optional(),
|
||||||
|
addresses: Joi.array().optional(),
|
||||||
|
app: Joi.object().optional(),
|
||||||
|
}).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signinSchema = Joi.object({
|
||||||
|
mobile: Joi.string().required().messages({
|
||||||
|
'any.required': '缺少手机号',
|
||||||
|
'string.empty': '手机号不能为空'
|
||||||
|
}),
|
||||||
|
passwd: Joi.string().required().messages({
|
||||||
|
'any.required': '缺少密码',
|
||||||
|
'string.empty': '密码不能为空'
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wxSigninSchema = Joi.object({
|
||||||
|
code: Joi.string().required().messages({ 'any.required': '缺少微信登录凭证 code' }),
|
||||||
|
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
||||||
|
phoneNumber: Joi.string().allow('', null),
|
||||||
|
name: Joi.string().allow('', null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wxGetPhoneSchema = Joi.object({
|
||||||
|
code: Joi.string().required().messages({ 'any.required': '缺少手机号凭证 code' }),
|
||||||
|
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
class HandlerUser {
|
||||||
|
// 生成 token
|
||||||
|
async genToken(uid) {
|
||||||
|
const crypto = await import("crypto");
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(uid + Date.now() + Math.random());
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户注册
|
||||||
|
async register(ctx) {
|
||||||
|
const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false });
|
||||||
|
if (error) {
|
||||||
|
return ResponseUtil.badRequest(ctx, error.details[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userInfo } = value;
|
||||||
|
const mobile = userInfo.profile.mobile;
|
||||||
|
const passwd = userInfo.security.passwd;
|
||||||
|
|
||||||
|
// 检查手机号是否已注册
|
||||||
|
const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile });
|
||||||
|
if (existingUser) {
|
||||||
|
return ResponseUtil.error(ctx, "手机号已注册", null, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bcrypt 加密密码
|
||||||
|
const encryptedPasswd = await hashPassword(passwd);
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
profile: userInfo.profile,
|
||||||
|
security: {
|
||||||
|
passwd: encryptedPasswd,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
province: userInfo.location?.province || '',
|
||||||
|
city: userInfo.location?.city || '',
|
||||||
|
district: userInfo.location?.district || '',
|
||||||
|
},
|
||||||
|
addresses: userInfo.addresses || [],
|
||||||
|
social: {
|
||||||
|
wechat: {}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
account: "normal",
|
||||||
|
},
|
||||||
|
app: userInfo.app || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await DBModel.User.setUser(newUser);
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.internalError(ctx, "注册失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 token
|
||||||
|
const token = await this.genToken(user._id.toString());
|
||||||
|
user.security.token = token;
|
||||||
|
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "注册成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手机号码登录
|
||||||
|
async signin(ctx) {
|
||||||
|
const { error, value } = signinSchema.validate(ctx.request.body, { abortEarly: false });
|
||||||
|
if (error) {
|
||||||
|
return ResponseUtil.badRequest(ctx, error.details[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { mobile, passwd } = value;
|
||||||
|
|
||||||
|
// 查找用户
|
||||||
|
let user = await DBModel.User.findOne({ "profile.mobile": mobile });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
|
||||||
|
const { valid, needsUpgrade } = await verifyPassword(
|
||||||
|
passwd, user.security.passwd, user.security.passwdSalt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
// 记录失败登录次数
|
||||||
|
await DBModel.User.incrementFailedLoginAttempts(user._id);
|
||||||
|
return ResponseUtil.unauthorized(ctx, "密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐进式迁移:如果是旧 MD5 密码,登录时升级为 bcrypt
|
||||||
|
if (needsUpgrade) {
|
||||||
|
user.security.passwd = await hashPassword(passwd);
|
||||||
|
user.security.passwdSalt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置失败登录次数
|
||||||
|
if (user.security.failedLoginAttempts > 0) {
|
||||||
|
await DBModel.User.resetFailedLoginAttempts(user._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成/更新 token
|
||||||
|
const token = await this.genToken(user._id.toString());
|
||||||
|
user.security.token = token;
|
||||||
|
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||||
|
user.security.lastLoginAt = new Date();
|
||||||
|
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
async signout(ctx) {
|
||||||
|
const token = extractToken(ctx);
|
||||||
|
if (!token) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await DBModel.User.findOne({ "security.token": token });
|
||||||
|
if (user) {
|
||||||
|
user.security.token = null;
|
||||||
|
user.security.tokenExpiry = null;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, null, "退出登录成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
async userInfo(ctx) {
|
||||||
|
// 临时支持:通过 body 里的 userId 获取用户信息
|
||||||
|
const { userId } = ctx.request.body || {};
|
||||||
|
if (userId) {
|
||||||
|
const user = await DBModel.User.findOne({ _id: userId });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.notFound(ctx, "用户不存在");
|
||||||
|
}
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原有逻辑:通过 token 获取用户信息
|
||||||
|
const token = extractToken(ctx);
|
||||||
|
if (!token) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await DBModel.User.findOne({ "security.token": token });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
async updateUser(ctx) {
|
||||||
|
const token = extractToken(ctx);
|
||||||
|
if (!token) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await DBModel.User.findOne({ "security.token": token });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = ctx.request.body;
|
||||||
|
if (!userInfo) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少用户信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await DBModel.User.updateFromUserInfo(user._id, userInfo);
|
||||||
|
if (!updatedUser) {
|
||||||
|
return ResponseUtil.internalError(ctx, "更新用户失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(updatedUser) }, "更新成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
async userList(ctx) {
|
||||||
|
const token = extractToken(ctx);
|
||||||
|
if (!token) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await DBModel.User.findOne({ "security.token": token });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('wxapp-escort-admin' in user.app)) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "用户无管理员权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 100 } = ctx.request.body;
|
||||||
|
|
||||||
|
const users = await DBModel.User.find({ "app.wxapp-escort": { $exists: true } })
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.limit(pageSize);
|
||||||
|
|
||||||
|
const safeUsers = users.map(u => sanitizeUser(u));
|
||||||
|
return ResponseUtil.success(ctx, { users: safeUsers }, "获取用户列表成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信登录
|
||||||
|
async wxSignin(ctx) {
|
||||||
|
const { error, value } = wxSigninSchema.validate(ctx.request.body, { abortEarly: false });
|
||||||
|
if (error) {
|
||||||
|
return ResponseUtil.badRequest(ctx, error.details[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, phoneNumber, name, appId } = value;
|
||||||
|
|
||||||
|
let app = config.app[appId];
|
||||||
|
if (!app) {
|
||||||
|
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 code 换取 openid/session_key
|
||||||
|
const sessionUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${app.appid}&secret=${app.secret}&js_code=${code}&grant_type=authorization_code`;
|
||||||
|
const wxSessionRes = await fetch(sessionUrl);
|
||||||
|
const sessionData = await wxSessionRes.json();
|
||||||
|
if (sessionData.errcode) {
|
||||||
|
return ResponseUtil.error(ctx, `微信接口错误: ${sessionData.errmsg}`, null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { openid } = sessionData;
|
||||||
|
if (!openid) {
|
||||||
|
return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用openid和phoneNumber查询用户
|
||||||
|
let key = `app.${appId}.wxopenid`;
|
||||||
|
let user = await DBModel.User.findOne({ [key]: openid });
|
||||||
|
if (!user) {
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await DBModel.User.findOne({ "profile.mobile": phoneNumber });
|
||||||
|
if (!user) {
|
||||||
|
const newUser = {
|
||||||
|
profile: { name: name || phoneNumber, mobile: phoneNumber },
|
||||||
|
status: { account: "normal" },
|
||||||
|
app: {},
|
||||||
|
};
|
||||||
|
newUser.app[appId] = { role: ["user"], wxopenid: openid };
|
||||||
|
user = await DBModel.User.setUser(newUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) {
|
||||||
|
user.profile.mobile = phoneNumber;
|
||||||
|
}
|
||||||
|
if (!(appId in user.app)) {
|
||||||
|
user.app[appId] = { role: ["user"], wxopenid: openid };
|
||||||
|
}
|
||||||
|
user.app[appId].wxopenid = openid;
|
||||||
|
} else {
|
||||||
|
return ResponseUtil.internalError(ctx, "用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Token
|
||||||
|
const token = await this.genToken(user._id.toString());
|
||||||
|
user.security.token = token;
|
||||||
|
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||||
|
user.security.lastLoginAt = new Date();
|
||||||
|
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取微信的手机号码
|
||||||
|
async wxGetPhoneNumber(ctx) {
|
||||||
|
const { error, value } = wxGetPhoneSchema.validate(ctx.request.body, { abortEarly: false });
|
||||||
|
if (error) {
|
||||||
|
return ResponseUtil.badRequest(ctx, error.details[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, appId } = value;
|
||||||
|
|
||||||
|
let app = config.app[appId];
|
||||||
|
if (!app) {
|
||||||
|
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取access_token
|
||||||
|
const client_credential_url = `https://api.weixin.qq.com/cgi-bin/token?appid=${app.appid}&secret=${app.secret}&grant_type=client_credential`;
|
||||||
|
const fetch = (await import("node-fetch")).default;
|
||||||
|
let sessionRes = await fetch(client_credential_url);
|
||||||
|
const resp = await sessionRes.json();
|
||||||
|
if (!resp.access_token) {
|
||||||
|
return ResponseUtil.internalError(ctx, "获取微信 access_token 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取phoneNumber
|
||||||
|
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${resp.access_token}`;
|
||||||
|
const phoneRes = await fetch(phoneUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: code })
|
||||||
|
});
|
||||||
|
const phoneData = await phoneRes.json();
|
||||||
|
if (phoneData.errcode) {
|
||||||
|
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = phoneData.phone_info?.phoneNumber;
|
||||||
|
return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功");
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HandlerUser };
|
||||||
5
index.js
Normal file
5
index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { APP } from './app.js'
|
||||||
|
|
||||||
|
// HTTP server
|
||||||
|
const koaApp = new APP();
|
||||||
|
koaApp.start(9010);
|
||||||
83
middleware/auth.js
Normal file
83
middleware/auth.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { DBModel } from '../models/index.js';
|
||||||
|
import ResponseUtil from '../utils/api_response.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取 token(优先 Authorization header)
|
||||||
|
*/
|
||||||
|
function extractToken(ctx) {
|
||||||
|
// 优先从 Authorization: Bearer xxx 获取
|
||||||
|
const authHeader = ctx.header?.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.slice(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧方式:body/query/header 中的 token 字段
|
||||||
|
return ctx.request.body?.token
|
||||||
|
|| ctx.request.query?.token
|
||||||
|
|| ctx.header?.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证中间件 - 验证 token 并挂载用户信息到 ctx
|
||||||
|
* 可选参数:
|
||||||
|
* - required: 是否必须登录(默认 true)
|
||||||
|
* - roles: 允许的角色列表(可选)
|
||||||
|
*/
|
||||||
|
function auth(options = {}) {
|
||||||
|
const { required = true, roles } = options;
|
||||||
|
|
||||||
|
return async (ctx, next) => {
|
||||||
|
const token = extractToken(ctx);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (!required) {
|
||||||
|
ctx.state.user = null;
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
return ResponseUtil.unauthorized(ctx, '缺少认证 token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await DBModel.User.findOne({ 'security.token': token });
|
||||||
|
if (!user) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||||
|
return ResponseUtil.unauthorized(ctx, '登录已过期,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status.account === 'lock') {
|
||||||
|
return ResponseUtil.forbidden(ctx, '账户已被锁定');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色检查
|
||||||
|
if (roles && roles.length > 0) {
|
||||||
|
const hasRole = roles.some(role => {
|
||||||
|
// 检查 app 字段中的角色
|
||||||
|
return Object.values(user.app || {}).some(appData =>
|
||||||
|
appData && Array.isArray(appData.role) && appData.role.includes(role)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!hasRole) {
|
||||||
|
return ResponseUtil.forbidden(ctx, '权限不足');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.user = user;
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回用户安全对象(去除密码等敏感字段)
|
||||||
|
*/
|
||||||
|
function sanitizeUser(user) {
|
||||||
|
const obj = user.toObject ? user.toObject() : { ...user };
|
||||||
|
delete obj.security?.passwd;
|
||||||
|
delete obj.security?.passwdSalt;
|
||||||
|
delete obj.security?.passwordResetToken;
|
||||||
|
delete obj.security?.passwordResetExpiry;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { auth, extractToken, sanitizeUser };
|
||||||
51
middleware/ratelimit.js
Normal file
51
middleware/ratelimit.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import ResponseUtil from '../utils/api_response.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内存速率限制
|
||||||
|
* 基于 IP + 接口路径维度,不引入 Redis
|
||||||
|
*/
|
||||||
|
const rateLimitStore = new Map();
|
||||||
|
|
||||||
|
// 每 60 秒清理一次过期记录
|
||||||
|
const cleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, record] of rateLimitStore.entries()) {
|
||||||
|
if (now - record.resetAt > record.windowMs) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
cleanupTimer.unref();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建速率限制中间件
|
||||||
|
* @param {number} max - 窗口内最大请求数
|
||||||
|
* @param {number} windowMs - 窗口时长(毫秒),默认 60 秒
|
||||||
|
*/
|
||||||
|
function rateLimit(max = 10, windowMs = 60_000) {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
const ip = ctx.ip || ctx.request.ip;
|
||||||
|
const key = `${ip}:${ctx.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let record = rateLimitStore.get(key);
|
||||||
|
if (!record || now - record.resetAt > windowMs) {
|
||||||
|
record = { count: 0, resetAt: now, windowMs };
|
||||||
|
rateLimitStore.set(key, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
|
||||||
|
if (record.count > max) {
|
||||||
|
const retryAfter = Math.ceil((record.resetAt + windowMs - now) / 1000);
|
||||||
|
ctx.set('Retry-After', retryAfter);
|
||||||
|
return ResponseUtil.error(ctx, `请求过于频繁,请 ${retryAfter} 秒后重试`, null, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set('X-RateLimit-Limit', max);
|
||||||
|
ctx.set('X-RateLimit-Remaining', Math.max(0, max - record.count));
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { rateLimit };
|
||||||
45
models/index.js
Normal file
45
models/index.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { UserSchema } from "./schema/user.js"
|
||||||
|
import config from '../conf.json' with { type: 'json' };
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
class MongoDBSchema {
|
||||||
|
constructor() {
|
||||||
|
this.dbConnection = null;
|
||||||
|
this.User = null;
|
||||||
|
this.EscortRecord = null;
|
||||||
|
this.Hospital = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
mongoose.set("debug", config.mongodb.debug);
|
||||||
|
|
||||||
|
this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option);
|
||||||
|
this.dbConnection.on("error", () => {
|
||||||
|
logger.error.bind(logger, "...mongodb connect error ...")
|
||||||
|
});
|
||||||
|
this.dbConnection.on("connected", async () => {
|
||||||
|
logger.info("Mongodb: " + config.mongodb.str + " connected");
|
||||||
|
});
|
||||||
|
this.dbConnection.on("disconnected", () =>
|
||||||
|
logger.warn("Mongodb: " + config.mongodb.str + " disconnected")
|
||||||
|
);
|
||||||
|
this.dbConnection.on("reconnected", () =>
|
||||||
|
logger.info("Mongodb: " + config.mongodb.str + " reconnected")
|
||||||
|
);
|
||||||
|
this.dbConnection.on("disconnecting", () =>
|
||||||
|
logger.warn("Mongodb: " + config.mongodb.str + " disconnecting")
|
||||||
|
);
|
||||||
|
this.dbConnection.on("close", () =>
|
||||||
|
logger.warn("Mongodb: " + config.mongodb.str + " closed")
|
||||||
|
);
|
||||||
|
|
||||||
|
this.User = this.dbConnection.model('user', UserSchema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DBModel = new MongoDBSchema()
|
||||||
|
export { DBModel };
|
||||||
|
|
||||||
289
models/schema/user.js
Normal file
289
models/schema/user.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户Schema定义
|
||||||
|
*
|
||||||
|
* 包含6个主要分类块:基本信息、安全信息、社交信息、状态信息、中医相关信息和元数据。
|
||||||
|
* 支持多种登录方式和中医系统中的角色权限管理。
|
||||||
|
*/
|
||||||
|
const UserSchema = mongoose.Schema(
|
||||||
|
{
|
||||||
|
// 基本信息 - 用户的个人标识信息
|
||||||
|
profile: {
|
||||||
|
name: { type: String, comment: '用户姓名' },
|
||||||
|
pinyin: { type: String, default: '', comment: '姓名的拼音,用于搜索' },
|
||||||
|
pinyinFL: { type: String, default: '', comment: '姓名拼音的首字母,用于搜索' },
|
||||||
|
mobile: { type: String, index: true, trim: true, comment: '手机号码' },
|
||||||
|
email: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '电子邮箱' },
|
||||||
|
idnumber: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '身份证号码' },
|
||||||
|
ssn: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '社保卡号' },
|
||||||
|
sex: { type: String, enum: ["male", "female"], default: "male", comment: '性别' },
|
||||||
|
birth: { type: Date, default: 0, comment: '出生日期' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 安全信息 - 用户的登录凭证和安全相关数据
|
||||||
|
security: {
|
||||||
|
passwd: { type: String, comment: '密码(加密存储)' },
|
||||||
|
passwdSalt: { type: String, comment: '密码盐值' },
|
||||||
|
token: { type: String, index: true, sparse: true, comment: '认证令牌' },
|
||||||
|
tokenExpiry: { type: Date, comment: '令牌过期时间' },
|
||||||
|
failedLoginAttempts: { type: Number, default: 0, comment: '失败登录尝试次数' },
|
||||||
|
lastLoginAt: { type: Date, comment: '最后登录时间' },
|
||||||
|
lastLoginIp: { type: String, comment: '最后登录IP' },
|
||||||
|
passwordResetToken: { type: String, sparse: true, comment: '密码重置令牌' },
|
||||||
|
passwordResetExpiry: { type: Date, comment: '密码重置令牌过期时间' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 位置信息 - 用户的省份、城市和区县
|
||||||
|
location: {
|
||||||
|
province: { type: String, default: '', comment: '省份' },
|
||||||
|
city: { type: String, default: '', comment: '城市' },
|
||||||
|
district: { type: String, default: '', comment: '区县' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 地址信息 - 用户多个地址(用于邮寄等)
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
label: { type: String, default: '', comment: '地址标签,如"家庭"、"公司"' },
|
||||||
|
province: { type: String, default: '', comment: '省份' },
|
||||||
|
city: { type: String, default: '', comment: '城市' },
|
||||||
|
district: { type: String, default: '', comment: '区县' },
|
||||||
|
address: { type: String, default: '', comment: '详细地址' },
|
||||||
|
postcode: { type: String, default: '', comment: '邮政编码' },
|
||||||
|
isDefault: { type: Boolean, default: false, comment: '是否默认地址' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 社交信息 - 用户的社交账号关联
|
||||||
|
social: {
|
||||||
|
wechat:{
|
||||||
|
unionid: { type: String, index: true, unique: true, sparse: true, comment: '微信UnionID' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 状态信息 - 用户账户的状态标记
|
||||||
|
status: {
|
||||||
|
account: { type: String, enum: ["normal", "lock"], default: "normal", comment: '账户状态' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 带wxopenid
|
||||||
|
app: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 元数据 - 系统管理信息
|
||||||
|
meta: {
|
||||||
|
createtime: { type: Date, default: Date.now, comment: '创建时间' },
|
||||||
|
updatetime: { type: Date, default: Date.now, comment: '更新时间' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minimize: false, // 允许保存空对象
|
||||||
|
strict: false, // 允许添加Schema中未定义的字段
|
||||||
|
collection: "user", // MongoDB集合名称
|
||||||
|
timestamps: false, // 不自动添加createdAt和updatedAt字段
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户标识信息查找用户
|
||||||
|
*
|
||||||
|
* 支持通过手机号、邮箱、token或微信unionid查找用户
|
||||||
|
*
|
||||||
|
* @param {Object} _user - 包含查找条件的用户对象
|
||||||
|
* @param {Function} cb - 可选的回调函数
|
||||||
|
* @returns {Promise<Object|null>} 找到的用户对象或null
|
||||||
|
*/
|
||||||
|
UserSchema.statics.findUser = async function (_user, cb) {
|
||||||
|
let filter = {};
|
||||||
|
if (_user.profile && _user.profile.mobile) {
|
||||||
|
filter = { "profile.mobile": _user.profile.mobile };
|
||||||
|
} else if (_user.profile && _user.profile.email) {
|
||||||
|
filter = { "profile.email": new RegExp(_user.profile.email, "i") };
|
||||||
|
} else if (_user.security && _user.security.token) {
|
||||||
|
filter = { "security.token": new RegExp(_user.security.token, "i") };
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.findOne(filter, cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置/更新用户信息
|
||||||
|
*
|
||||||
|
* @param {Object} _user - 要设置或更新的用户对象
|
||||||
|
* @param {Function} cb - 可选的回调函数
|
||||||
|
* @returns {Promise<Object|null>} 操作结果
|
||||||
|
*/
|
||||||
|
UserSchema.statics.setUser = async function (_user, cb) {
|
||||||
|
const filter = {};
|
||||||
|
let update = {};
|
||||||
|
|
||||||
|
// 如果有_id字段,说明是更新操作
|
||||||
|
if (_user._id) {
|
||||||
|
filter._id = _user._id;
|
||||||
|
// 移除_id字段,避免尝试更新它
|
||||||
|
delete _user._id;
|
||||||
|
update = _user;
|
||||||
|
update.meta.updatetime = Date.now();
|
||||||
|
} else {
|
||||||
|
// 新建用户
|
||||||
|
update = _user;
|
||||||
|
update.meta = {
|
||||||
|
createtime: Date.now(),
|
||||||
|
updatetime: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (filter._id) {
|
||||||
|
// 更新用户
|
||||||
|
return await this.findOneAndUpdate(filter, { $set: update }, { new: true, upsert: true }, cb);
|
||||||
|
} else {
|
||||||
|
// 创建新用户
|
||||||
|
const newUser = new this(update);
|
||||||
|
return await newUser.save(cb);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户信息更新用户文档
|
||||||
|
*
|
||||||
|
* @param {ObjectId} userId - 用户ID
|
||||||
|
* @param {Object} userInfo - 包含要更新字段的用户信息对象
|
||||||
|
* @returns {Promise<Object|null>} 更新后的用户对象或null
|
||||||
|
*/
|
||||||
|
UserSchema.statics.updateFromUserInfo = async function (userId, userInfo) {
|
||||||
|
const updateData = {};
|
||||||
|
|
||||||
|
updateData['meta.updatetime'] = Date.now();
|
||||||
|
|
||||||
|
if (userInfo.profile) {
|
||||||
|
if (userInfo.profile.name !== undefined) {
|
||||||
|
updateData['profile.name'] = userInfo.profile.name;
|
||||||
|
}
|
||||||
|
if (userInfo.profile.sex !== undefined) {
|
||||||
|
updateData['profile.sex'] = userInfo.profile.sex;
|
||||||
|
}
|
||||||
|
if (userInfo.profile.birth !== undefined) {
|
||||||
|
updateData['profile.birth'] = userInfo.profile.birth ? new Date(userInfo.profile.birth) : null;
|
||||||
|
}
|
||||||
|
if (userInfo.profile.mobile !== undefined) {
|
||||||
|
updateData['profile.mobile'] = userInfo.profile.mobile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.location) {
|
||||||
|
if (userInfo.location.province !== undefined) {
|
||||||
|
updateData['location.province'] = userInfo.location.province;
|
||||||
|
}
|
||||||
|
if (userInfo.location.city !== undefined) {
|
||||||
|
updateData['location.city'] = userInfo.location.city;
|
||||||
|
}
|
||||||
|
if (userInfo.location.district !== undefined) {
|
||||||
|
updateData['location.district'] = userInfo.location.district;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.addresses) {
|
||||||
|
updateData['addresses'] = userInfo.addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.findOneAndUpdate(
|
||||||
|
{ _id: userId },
|
||||||
|
{ $set: updateData },
|
||||||
|
{ new: true, upsert: false }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户
|
||||||
|
*
|
||||||
|
* @param {ObjectId} _id - 用户ID
|
||||||
|
* @param {string} mobile - 手机号码
|
||||||
|
* @param {string} email - 电子邮箱
|
||||||
|
* @param {string} wxunionid - 微信unionid
|
||||||
|
* @param {Function} cb - 可选的回调函数
|
||||||
|
* @returns {Promise<Object|null>} 操作结果
|
||||||
|
*/
|
||||||
|
UserSchema.statics.delUser = async function (_id, mobile, email, wxunionid, cb) {
|
||||||
|
const filter = {};
|
||||||
|
|
||||||
|
if (_id) {
|
||||||
|
filter._id = _id;
|
||||||
|
} else if (mobile) {
|
||||||
|
filter['profile.mobile'] = mobile;
|
||||||
|
} else if (email) {
|
||||||
|
filter['profile.email'] = email;
|
||||||
|
} else if (wxunionid) {
|
||||||
|
filter['social.wechat.unionid'] = wxunionid;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.findOneAndDelete(filter, cb);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加失败登录尝试次数
|
||||||
|
*
|
||||||
|
* @param {ObjectId} _id - 用户ID
|
||||||
|
* @returns {Promise<Object|null>} 操作结果
|
||||||
|
*/
|
||||||
|
UserSchema.statics.incrementFailedLoginAttempts = async function (_id) {
|
||||||
|
try {
|
||||||
|
return await this.findOneAndUpdate(
|
||||||
|
{ _id },
|
||||||
|
{
|
||||||
|
$inc: { 'security.failedLoginAttempts': 1 },
|
||||||
|
'meta.updatetime': Date.now()
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置失败登录尝试次数
|
||||||
|
*
|
||||||
|
* @param {ObjectId} _id - 用户ID
|
||||||
|
* @returns {Promise<Object|null>} 操作结果
|
||||||
|
*/
|
||||||
|
UserSchema.statics.resetFailedLoginAttempts = async function (_id) {
|
||||||
|
try {
|
||||||
|
return await this.findOneAndUpdate(
|
||||||
|
{ _id },
|
||||||
|
{
|
||||||
|
'security.failedLoginAttempts': 0,
|
||||||
|
'meta.updatetime': Date.now()
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 索引设置 - 优化查询性能
|
||||||
|
|
||||||
|
// 手机号和密码复合索引,用于登录验证
|
||||||
|
UserSchema.index({ "profile.mobile": 1, "security.passwd": 1 });
|
||||||
|
|
||||||
|
export { UserSchema };
|
||||||
1606
package-lock.json
generated
Normal file
1606
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "api_user",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "user api service",
|
||||||
|
"type": "module",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@koa/cors": "^5.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"joi": "^18.2.1",
|
||||||
|
"koa": "^2.15.3",
|
||||||
|
"koa-bodyparser": "^4.4.1",
|
||||||
|
"koa-router": "^12.0.1",
|
||||||
|
"mongoose": "^8.4.0",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
routes/index.js
Normal file
45
routes/index.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Router from 'koa-router';
|
||||||
|
import { HandlerUser } from '../handler/users.js';
|
||||||
|
import { rateLimit } from '../middleware/ratelimit.js';
|
||||||
|
|
||||||
|
class ApiRouter {
|
||||||
|
constructor() {
|
||||||
|
this.router = new Router();
|
||||||
|
this.handler = new HandlerUser();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRoutes() {
|
||||||
|
const userRouter = new Router({ prefix: '/user' });
|
||||||
|
|
||||||
|
// 限流:注册/登录接口 5次/分钟,微信登录 10次/分钟
|
||||||
|
userRouter.post('/register', rateLimit(5, 60_000), this.handler.register.bind(this.handler));
|
||||||
|
userRouter.post('/signin', rateLimit(10, 60_000), this.handler.signin.bind(this.handler));
|
||||||
|
userRouter.post('/wxsignin', rateLimit(10, 60_000), this.handler.wxSignin.bind(this.handler));
|
||||||
|
|
||||||
|
userRouter.post('/signout', this.handler.signout.bind(this.handler));
|
||||||
|
userRouter.post('/userInfo', this.handler.userInfo.bind(this.handler));
|
||||||
|
userRouter.post('/update', this.handler.updateUser.bind(this.handler));
|
||||||
|
userRouter.post('/list', this.handler.userList.bind(this.handler));
|
||||||
|
userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler));
|
||||||
|
|
||||||
|
this.router.use(userRouter.routes());
|
||||||
|
|
||||||
|
this.printRoutes(this.router.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoutes() {
|
||||||
|
return this.router.routes();
|
||||||
|
}
|
||||||
|
|
||||||
|
printRoutes(stack) {
|
||||||
|
for (const layer of stack) {
|
||||||
|
if (layer.path) {
|
||||||
|
const methods = layer.methods.filter(m => m !== '_all');
|
||||||
|
methods.forEach(m => console.log(` [${m.toUpperCase()}] ${layer.path}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ApiRouter();
|
||||||
265
test/api.test.js
Normal file
265
test/api.test.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import { APP } from '../app.js';
|
||||||
|
import { DBModel } from '../models/index.js';
|
||||||
|
|
||||||
|
describe('用户接口集成测试', () => {
|
||||||
|
let app, agent;
|
||||||
|
const testMobile = '13900000001';
|
||||||
|
const testPasswd = 'test123456';
|
||||||
|
let token = '';
|
||||||
|
let userId = '';
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
app = new APP();
|
||||||
|
agent = supertest(app.app.callback());
|
||||||
|
// 等待数据库连接
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
if (userId) {
|
||||||
|
await DBModel.User.delUser(userId);
|
||||||
|
}
|
||||||
|
app.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/register', () => {
|
||||||
|
it('应成功注册新用户', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/register')
|
||||||
|
.send({
|
||||||
|
userInfo: {
|
||||||
|
profile: { mobile: testMobile, name: '测试用户' },
|
||||||
|
security: { passwd: testPasswd },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0, `注册失败: ${res.body.msg}`);
|
||||||
|
assert.ok(res.body.data.user);
|
||||||
|
assert.ok(res.body.data.user.security.token);
|
||||||
|
assert.equal(res.body.data.user.profile.mobile, testMobile);
|
||||||
|
// 密码字段不应返回
|
||||||
|
assert.equal(res.body.data.user.security.passwd, undefined);
|
||||||
|
assert.equal(res.body.data.user.security.passwdSalt, undefined);
|
||||||
|
|
||||||
|
token = res.body.data.user.security.token;
|
||||||
|
userId = res.body.data.user._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复注册应返回 409', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/register')
|
||||||
|
.send({
|
||||||
|
userInfo: {
|
||||||
|
profile: { mobile: testMobile, name: '测试用户' },
|
||||||
|
security: { passwd: testPasswd },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 409);
|
||||||
|
assert.equal(res.body.msg, '手机号已注册');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少手机号应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/register')
|
||||||
|
.send({
|
||||||
|
userInfo: {
|
||||||
|
profile: { name: '测试用户' },
|
||||||
|
security: { passwd: testPasswd },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少密码应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/register')
|
||||||
|
.send({
|
||||||
|
userInfo: {
|
||||||
|
profile: { mobile: '13900000099' },
|
||||||
|
security: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('密码少于6位应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/register')
|
||||||
|
.send({
|
||||||
|
userInfo: {
|
||||||
|
profile: { mobile: '13900000098' },
|
||||||
|
security: { passwd: '123' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/signin', () => {
|
||||||
|
it('用正确密码登录应成功', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signin')
|
||||||
|
.send({ mobile: testMobile, passwd: testPasswd });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0, `登录失败: ${res.body.msg}`);
|
||||||
|
assert.ok(res.body.data.user.security.token);
|
||||||
|
// 更新 token
|
||||||
|
token = res.body.data.user.security.token;
|
||||||
|
// 应记录最后登录信息
|
||||||
|
assert.ok(res.body.data.user.security.lastLoginAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误密码应返回 401', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signin')
|
||||||
|
.send({ mobile: testMobile, passwd: 'wrongpassword' });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的用户应返回 401', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signin')
|
||||||
|
.send({ mobile: '13999999999', passwd: 'test123456' });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少手机号应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signin')
|
||||||
|
.send({ passwd: testPasswd });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/userInfo', () => {
|
||||||
|
it('用 Authorization Bearer 获取用户信息', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/userInfo')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0, `获取用户信息失败: ${res.body.msg}`);
|
||||||
|
assert.equal(res.body.data.user.profile.mobile, testMobile);
|
||||||
|
assert.equal(res.body.data.user.security.passwd, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('用 body token 获取用户信息(向后兼容)', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/userInfo')
|
||||||
|
.send({ token });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0);
|
||||||
|
assert.equal(res.body.data.user.profile.mobile, testMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无效 token 应返回 401', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/userInfo')
|
||||||
|
.set('Authorization', 'Bearer invalid_token')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少 token 应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/userInfo')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/update', () => {
|
||||||
|
it('更新用户信息应成功', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/update')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({
|
||||||
|
profile: { name: '更新后的名字' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0, `更新失败: ${res.body.msg}`);
|
||||||
|
assert.equal(res.body.data.user.profile.name, '更新后的名字');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 token 更新应返回 400', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/update')
|
||||||
|
.send({ profile: { name: 'test' } });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/signout', () => {
|
||||||
|
it('退出登录应成功', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signout')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0);
|
||||||
|
assert.equal(res.body.msg, '退出登录成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('退出后用旧 token 获取信息应返回 401', async () => {
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/userInfo')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('密码迁移:MD5 → bcrypt', () => {
|
||||||
|
it('旧 MD5 用户登录应自动升级密码', async () => {
|
||||||
|
// 先注册一个新用户
|
||||||
|
const mobile = '13900000002';
|
||||||
|
const passwd = 'md5test123';
|
||||||
|
|
||||||
|
// 直接写入一个 MD5 密码的用户
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const salt = crypto.randomBytes(8).toString('hex');
|
||||||
|
const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex');
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
profile: { mobile, name: 'MD5测试用户' },
|
||||||
|
security: { passwd: md5Hash, passwdSalt: salt },
|
||||||
|
status: { account: 'normal' },
|
||||||
|
app: {},
|
||||||
|
social: { wechat: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await DBModel.User.setUser(newUser);
|
||||||
|
assert.ok(user);
|
||||||
|
|
||||||
|
// 用旧密码登录
|
||||||
|
const res = await agent
|
||||||
|
.post('/user/signin')
|
||||||
|
.send({ mobile, passwd });
|
||||||
|
|
||||||
|
assert.equal(res.body.code, 0, `MD5迁移登录失败: ${res.body.msg}`);
|
||||||
|
|
||||||
|
// 验证密码已升级为 bcrypt
|
||||||
|
const updatedUser = await DBModel.User.findOne({ 'profile.mobile': mobile });
|
||||||
|
assert.ok(updatedUser.security.passwd.startsWith('$2'), '密码应已升级为 bcrypt');
|
||||||
|
assert.equal(updatedUser.security.passwdSalt, undefined, 'salt 应已清除');
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
await DBModel.User.delUser(updatedUser._id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
test/auth.test.js
Normal file
85
test/auth.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { extractToken, sanitizeUser } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
// 模拟 ctx 对象
|
||||||
|
function mockCtx(overrides = {}) {
|
||||||
|
return {
|
||||||
|
header: {},
|
||||||
|
request: { body: {}, query: {} },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractToken', () => {
|
||||||
|
it('优先从 Authorization Bearer header 提取', () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
header: { authorization: 'Bearer abc123' },
|
||||||
|
request: { body: { token: 'body_token' } },
|
||||||
|
});
|
||||||
|
assert.equal(extractToken(ctx), 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 Bearer header 时从 body 提取', () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
request: { body: { token: 'body_token' } },
|
||||||
|
});
|
||||||
|
assert.equal(extractToken(ctx), 'body_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 Bearer header 时从 query 提取', () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
request: { query: { token: 'query_token' } },
|
||||||
|
});
|
||||||
|
assert.equal(extractToken(ctx), 'query_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 Bearer header 时从 header token 字段提取', () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
header: { token: 'header_token' },
|
||||||
|
});
|
||||||
|
assert.equal(extractToken(ctx), 'header_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无任何 token 时返回 undefined', () => {
|
||||||
|
const ctx = mockCtx();
|
||||||
|
assert.equal(extractToken(ctx), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeUser', () => {
|
||||||
|
it('应删除密码和重置令牌相关字段', () => {
|
||||||
|
const user = {
|
||||||
|
toObject: () => ({
|
||||||
|
profile: { mobile: '13800138000' },
|
||||||
|
security: {
|
||||||
|
passwd: 'hashed',
|
||||||
|
passwdSalt: 'salt',
|
||||||
|
token: 'valid_token',
|
||||||
|
passwordResetToken: 'reset_token',
|
||||||
|
passwordResetExpiry: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const safe = sanitizeUser(user);
|
||||||
|
assert.equal(safe.security.passwd, undefined);
|
||||||
|
assert.equal(safe.security.passwdSalt, undefined);
|
||||||
|
assert.equal(safe.security.passwordResetToken, undefined);
|
||||||
|
assert.equal(safe.security.passwordResetExpiry, undefined);
|
||||||
|
assert.equal(safe.security.token, 'valid_token');
|
||||||
|
assert.equal(safe.profile.mobile, '13800138000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('处理普通对象(无 toObject 方法)', () => {
|
||||||
|
const user = {
|
||||||
|
profile: { mobile: '13800138000' },
|
||||||
|
security: { passwd: 'x', passwdSalt: 'y' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const safe = sanitizeUser(user);
|
||||||
|
assert.equal(safe.security.passwd, undefined);
|
||||||
|
assert.equal(safe.security.passwdSalt, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
test/crypto.test.js
Normal file
51
test/crypto.test.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { hashPassword, verifyPassword } from '../utils/crypto.js';
|
||||||
|
|
||||||
|
describe('crypto 工具', () => {
|
||||||
|
it('bcrypt 加密后验证应通过', async () => {
|
||||||
|
const passwd = 'test123456';
|
||||||
|
const hash = await hashPassword(passwd);
|
||||||
|
assert.ok(hash.startsWith('$2'), 'bcrypt hash 应以 $2 开头');
|
||||||
|
|
||||||
|
const { valid, needsUpgrade } = await verifyPassword(passwd, hash, '');
|
||||||
|
assert.equal(valid, true);
|
||||||
|
assert.equal(needsUpgrade, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误密码验证应失败', async () => {
|
||||||
|
const hash = await hashPassword('correct');
|
||||||
|
const { valid } = await verifyPassword('wrong', hash, '');
|
||||||
|
assert.equal(valid, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('兼容旧 MD5 密码验证', async () => {
|
||||||
|
const passwd = 'mypass';
|
||||||
|
const salt = 'abc123';
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex');
|
||||||
|
|
||||||
|
const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, salt);
|
||||||
|
assert.equal(valid, true);
|
||||||
|
assert.equal(needsUpgrade, true, 'MD5 密码应标记为需要升级');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MD5 密码错误时应返回 false', async () => {
|
||||||
|
const salt = 'abc123';
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const md5Hash = crypto.createHash('md5').update('correct' + salt).digest('hex');
|
||||||
|
|
||||||
|
const { valid } = await verifyPassword('wrong', md5Hash, salt);
|
||||||
|
assert.equal(valid, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空 salt 时 MD5 也能验证', async () => {
|
||||||
|
const passwd = 'test';
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const md5Hash = crypto.createHash('md5').update(passwd + '').digest('hex');
|
||||||
|
|
||||||
|
const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, '');
|
||||||
|
assert.equal(valid, true);
|
||||||
|
assert.equal(needsUpgrade, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
test/ratelimit.test.js
Normal file
49
test/ratelimit.test.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, before, after, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Koa from 'koa';
|
||||||
|
import bodyParser from 'koa-bodyparser';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import { rateLimit } from '../middleware/ratelimit.js';
|
||||||
|
|
||||||
|
describe('rateLimit 中间件', () => {
|
||||||
|
let app, agent;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
app = new Koa();
|
||||||
|
app.use(bodyParser());
|
||||||
|
app.use(rateLimit(3, 60_000));
|
||||||
|
app.use(async (ctx) => {
|
||||||
|
ctx.body = { ok: true };
|
||||||
|
});
|
||||||
|
agent = supertest(app.callback());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('前3次请求应正常通过', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const res = await agent.post('/test').send({});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('第4次请求应被限流', async () => {
|
||||||
|
const res = await agent.post('/test').send({});
|
||||||
|
assert.equal(res.status, 200); // HTTP 200,业务码 429
|
||||||
|
assert.equal(res.body.code, 429);
|
||||||
|
assert.ok(res.body.msg.includes('请求过于频繁'));
|
||||||
|
assert.ok(res.headers['retry-after']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('限流响应头应包含 X-RateLimit 信息', async () => {
|
||||||
|
// 新建一个不受之前计数影响的中间件(不同限流阈值 + 不同路径)
|
||||||
|
const freshApp = new Koa();
|
||||||
|
freshApp.use(bodyParser());
|
||||||
|
freshApp.use(rateLimit(100, 60_000));
|
||||||
|
freshApp.use(async (ctx) => { ctx.body = { ok: true }; });
|
||||||
|
const freshAgent = supertest(freshApp.callback());
|
||||||
|
|
||||||
|
const res = await freshAgent.post('/headercheck').send({});
|
||||||
|
assert.equal(res.headers['x-ratelimit-limit'], '100');
|
||||||
|
assert.equal(res.headers['x-ratelimit-remaining'], '99');
|
||||||
|
});
|
||||||
|
});
|
||||||
85
utils/api_response.js
Normal file
85
utils/api_response.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 响应工具类
|
||||||
|
* 提供统一的响应格式
|
||||||
|
*/
|
||||||
|
class ResponseUtil {
|
||||||
|
/**
|
||||||
|
* 成功响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {Object} data - 响应数据
|
||||||
|
* @param {string} message - 响应消息
|
||||||
|
*/
|
||||||
|
static success(ctx, data, message = '操作成功') {
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
code: 0,
|
||||||
|
msg: message,
|
||||||
|
data,
|
||||||
|
ts: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
* @param {number} errorCode - 应用错误码
|
||||||
|
*/
|
||||||
|
static error(ctx, message, data = null, errorCode = 500) {
|
||||||
|
// 根据规则,只要进入controller,http状态码都是200
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
code: errorCode,
|
||||||
|
msg: message,
|
||||||
|
data,
|
||||||
|
ts: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求参数错误响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
*/
|
||||||
|
static badRequest(ctx, message = '请求参数错误', data = null) {
|
||||||
|
return ResponseUtil.error(ctx, message, data, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未授权响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
*/
|
||||||
|
static unauthorized(ctx, message = '未授权', data = null) {
|
||||||
|
return ResponseUtil.error(ctx, message, data, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止访问响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
*/
|
||||||
|
static forbidden(ctx, message = '禁止访问', data = null) {
|
||||||
|
return ResponseUtil.error(ctx, message, data, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源不存在响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
*/
|
||||||
|
static notFound(ctx, message = '资源不存在', data = null) {
|
||||||
|
return ResponseUtil.error(ctx, message, data, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器内部错误响应
|
||||||
|
* @param {Object} ctx - Koa上下文对象
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
*/
|
||||||
|
static internalError(ctx, message = '服务器内部错误', data = null) {
|
||||||
|
return ResponseUtil.error(ctx, message, data, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResponseUtil;
|
||||||
36
utils/crypto.js
Normal file
36
utils/crypto.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密密码(bcrypt)
|
||||||
|
*/
|
||||||
|
async function hashPassword(passwd) {
|
||||||
|
return await bcrypt.hash(passwd, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码
|
||||||
|
* 支持 bcrypt 新密码和 MD5 旧密码(渐进式迁移)
|
||||||
|
* @returns {{ valid: boolean, needsUpgrade: boolean }}
|
||||||
|
*/
|
||||||
|
async function verifyPassword(passwd, storedHash, salt) {
|
||||||
|
// 尝试 bcrypt 验证
|
||||||
|
if (storedHash && storedHash.startsWith('$2')) {
|
||||||
|
const valid = await bcrypt.compare(passwd, storedHash);
|
||||||
|
return { valid, needsUpgrade: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧 MD5 密码
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const hash = crypto.createHash('md5');
|
||||||
|
hash.update(passwd + (salt || ''));
|
||||||
|
const md5Hash = hash.digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: storedHash === md5Hash,
|
||||||
|
needsUpgrade: storedHash === md5Hash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashPassword, verifyPassword };
|
||||||
48
utils/logger.js
Normal file
48
utils/logger.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const logDir = path.join(process.cwd(), 'logs');
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVELS = {
|
||||||
|
ERROR: 'ERROR',
|
||||||
|
WARN: 'WARN',
|
||||||
|
INFO: 'INFO',
|
||||||
|
DEBUG: 'DEBUG'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentTimestamp = () => {
|
||||||
|
return new Date().toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeLog = (level, message, meta = {}) => {
|
||||||
|
const timestamp = getCurrentTimestamp();
|
||||||
|
const logEntry = {
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
meta
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] ${level}: ${message}`, meta);
|
||||||
|
|
||||||
|
const logFileName = `${new Date().toISOString().slice(0, 10)}.log`;
|
||||||
|
const logFilePath = path.join(logDir, logFileName);
|
||||||
|
|
||||||
|
fs.appendFile(logFilePath, JSON.stringify(logEntry) + '\n', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to write log to file:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
error: (message, meta) => writeLog(LEVELS.ERROR, message, meta),
|
||||||
|
warn: (message, meta) => writeLog(LEVELS.WARN, message, meta),
|
||||||
|
info: (message, meta) => writeLog(LEVELS.INFO, message, meta),
|
||||||
|
debug: (message, meta) => writeLog(LEVELS.DEBUG, message, meta)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
Reference in New Issue
Block a user