Files
api_user/handler/users.js
2026-06-12 15:24:20 +08:00

398 lines
13 KiB
JavaScript

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) {
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 };