510 lines
13 KiB
JavaScript
510 lines
13 KiB
JavaScript
|
|
const bcrypt = require('bcryptjs');
|
||
|
|
const User = require('../models/User');
|
||
|
|
const Role = require('../models/Role');
|
||
|
|
const Menu = require('../models/Menu');
|
||
|
|
const Tenant = require('../models/Tenant');
|
||
|
|
const Category = require('../models/Category');
|
||
|
|
const Setting = require('../models/Setting');
|
||
|
|
const { SubscriptionPlan } = require('../models');
|
||
|
|
const { generateToken } = require('../middleware/auth');
|
||
|
|
const { logLogin, getClientIp } = require('../utils/logger');
|
||
|
|
|
||
|
|
// 登录
|
||
|
|
exports.login = async (req, res) => {
|
||
|
|
const clientIp = getClientIp(req);
|
||
|
|
const userAgent = req.headers['user-agent'];
|
||
|
|
let username = req.body.username;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const { password } = req.body;
|
||
|
|
|
||
|
|
// 参数验证
|
||
|
|
if (!username || !password) {
|
||
|
|
await logLogin({
|
||
|
|
username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: '用户名和密码不能为空'
|
||
|
|
});
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '用户名和密码不能为空'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 查找用户
|
||
|
|
const user = await User.findOne({
|
||
|
|
where: { username },
|
||
|
|
include: [
|
||
|
|
{
|
||
|
|
model: Role,
|
||
|
|
as: 'role'
|
||
|
|
},
|
||
|
|
{
|
||
|
|
model: Tenant,
|
||
|
|
as: 'tenant'
|
||
|
|
}
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) {
|
||
|
|
await logLogin({
|
||
|
|
username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: '用户名或密码错误'
|
||
|
|
});
|
||
|
|
return res.status(401).json({
|
||
|
|
code: 401,
|
||
|
|
message: '用户名或密码错误'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查用户状态
|
||
|
|
if (user.status === 'disabled') {
|
||
|
|
await logLogin({
|
||
|
|
userId: user.id,
|
||
|
|
username: user.username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: '账号已被禁用'
|
||
|
|
});
|
||
|
|
return res.status(401).json({
|
||
|
|
code: 401,
|
||
|
|
message: '账号已被禁用'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 验证密码
|
||
|
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||
|
|
if (!isPasswordValid) {
|
||
|
|
await logLogin({
|
||
|
|
userId: user.id,
|
||
|
|
username: user.username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: '用户名或密码错误'
|
||
|
|
});
|
||
|
|
return res.status(401).json({
|
||
|
|
code: 401,
|
||
|
|
message: '用户名或密码错误'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查租户计费状态(仅非系统管理员需要检查)
|
||
|
|
if (user.tenant && user.userType !== 'super_admin') {
|
||
|
|
const billingStatus = user.tenant.billingStatus;
|
||
|
|
if (billingStatus === 'trial_expired' || billingStatus === 'paid_expired') {
|
||
|
|
await logLogin({
|
||
|
|
userId: user.id,
|
||
|
|
username: user.username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: '账户已过期,请续费'
|
||
|
|
});
|
||
|
|
return res.status(403).json({
|
||
|
|
code: 403,
|
||
|
|
message: '您的账户已过期,请前往续费',
|
||
|
|
data: {
|
||
|
|
expired: true,
|
||
|
|
billingStatus: billingStatus,
|
||
|
|
trialEndDate: user.tenant.trialEndDate,
|
||
|
|
paidEndDate: user.tenant.paidEndDate
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生成 Token
|
||
|
|
const token = generateToken(user);
|
||
|
|
|
||
|
|
// 获取用户菜单权限
|
||
|
|
let menus = [];
|
||
|
|
// 系统管理员返回所有菜单
|
||
|
|
if (user.userType === 'super_admin') {
|
||
|
|
const allMenus = await Menu.findAll({
|
||
|
|
where: {
|
||
|
|
isDeleted: 0,
|
||
|
|
status: 'active'
|
||
|
|
},
|
||
|
|
order: [['sort', 'ASC']]
|
||
|
|
});
|
||
|
|
menus = buildMenuTree(allMenus);
|
||
|
|
} else if (user.roleId) {
|
||
|
|
// 租户管理员和普通用户返回角色分配的菜单
|
||
|
|
const roleData = await Role.findByPk(user.roleId, {
|
||
|
|
include: [{
|
||
|
|
model: Menu,
|
||
|
|
as: 'menus',
|
||
|
|
where: {
|
||
|
|
isDeleted: 0,
|
||
|
|
status: 'active'
|
||
|
|
},
|
||
|
|
through: { attributes: [] }
|
||
|
|
}]
|
||
|
|
});
|
||
|
|
|
||
|
|
if (roleData && roleData.menus) {
|
||
|
|
const sortedMenus = roleData.menus.sort((a, b) => a.sort - b.sort);
|
||
|
|
menus = buildMenuTree(sortedMenus);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 记录登录成功日志
|
||
|
|
await logLogin({
|
||
|
|
userId: user.id,
|
||
|
|
username: user.username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'success',
|
||
|
|
message: '登录成功'
|
||
|
|
});
|
||
|
|
|
||
|
|
// 返回用户信息和 Token
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '登录成功',
|
||
|
|
data: {
|
||
|
|
token,
|
||
|
|
userInfo: {
|
||
|
|
id: user.id,
|
||
|
|
username: user.username,
|
||
|
|
nickname: user.nickname,
|
||
|
|
role: user.role,
|
||
|
|
tenantId: user.tenantId,
|
||
|
|
userType: user.userType
|
||
|
|
},
|
||
|
|
tenant: user.tenant,
|
||
|
|
menus
|
||
|
|
}
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('登录错误:', error);
|
||
|
|
await logLogin({
|
||
|
|
username,
|
||
|
|
loginType: 'login',
|
||
|
|
ip: clientIp,
|
||
|
|
userAgent,
|
||
|
|
status: 'fail',
|
||
|
|
message: error.message
|
||
|
|
});
|
||
|
|
res.status(500).json({
|
||
|
|
code: 500,
|
||
|
|
message: '登录失败',
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 登出
|
||
|
|
exports.logout = async (req, res) => {
|
||
|
|
try {
|
||
|
|
// 记录登出日志
|
||
|
|
const user = req.user || {};
|
||
|
|
await logLogin({
|
||
|
|
userId: user.id,
|
||
|
|
username: user.username,
|
||
|
|
loginType: 'logout',
|
||
|
|
ip: getClientIp(req),
|
||
|
|
userAgent: req.headers['user-agent'],
|
||
|
|
status: 'success',
|
||
|
|
message: '登出成功'
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '登出成功'
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('登出错误:', error);
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '登出成功'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 构建菜单树
|
||
|
|
function buildMenuTree(menus, parentId = null) {
|
||
|
|
return menus
|
||
|
|
.filter(menu => menu.parentId === parentId)
|
||
|
|
.map(menu => {
|
||
|
|
// 获取菜单的原始数据(处理 Sequelize 模型对象)
|
||
|
|
const menuData = menu.toJSON ? menu.toJSON() : menu;
|
||
|
|
return {
|
||
|
|
id: menuData.id,
|
||
|
|
name: menuData.name,
|
||
|
|
code: menuData.code,
|
||
|
|
type: menuData.type,
|
||
|
|
path: menuData.path,
|
||
|
|
component: menuData.component,
|
||
|
|
icon: menuData.icon,
|
||
|
|
sort: menuData.sort,
|
||
|
|
visible: menuData.visible,
|
||
|
|
status: menuData.status,
|
||
|
|
isDeleted: menuData.isDeleted,
|
||
|
|
parentId: menuData.parentId,
|
||
|
|
children: buildMenuTree(menus, menuData.id)
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 获取当前用户信息
|
||
|
|
exports.getCurrentUser = async (req, res) => {
|
||
|
|
try {
|
||
|
|
// req.user 由 authMiddleware 附加
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '获取成功',
|
||
|
|
data: req.user
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('获取用户信息错误:', error);
|
||
|
|
res.status(500).json({
|
||
|
|
code: 500,
|
||
|
|
message: '获取用户信息失败',
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 修改密码
|
||
|
|
exports.changePassword = async (req, res) => {
|
||
|
|
try {
|
||
|
|
const { oldPassword, newPassword } = req.body;
|
||
|
|
const userId = req.user.id;
|
||
|
|
|
||
|
|
// 参数验证
|
||
|
|
if (!oldPassword || !newPassword) {
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '原密码和新密码不能为空'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (newPassword.length < 6) {
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '新密码长度不能少于6位'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 查找用户
|
||
|
|
const user = await User.findByPk(userId);
|
||
|
|
if (!user) {
|
||
|
|
return res.status(404).json({
|
||
|
|
code: 404,
|
||
|
|
message: '用户不存在'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 验证原密码
|
||
|
|
const isPasswordValid = await bcrypt.compare(oldPassword, user.password);
|
||
|
|
if (!isPasswordValid) {
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '原密码错误'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 加密新密码
|
||
|
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||
|
|
|
||
|
|
// 更新密码
|
||
|
|
await user.update({ password: hashedPassword });
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '密码修改成功'
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('修改密码错误:', error);
|
||
|
|
res.status(500).json({
|
||
|
|
code: 500,
|
||
|
|
message: '修改密码失败',
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 租户自助注册
|
||
|
|
exports.registerTenant = async (req, res) => {
|
||
|
|
const {
|
||
|
|
contactName,
|
||
|
|
contactPhone,
|
||
|
|
contactEmail,
|
||
|
|
adminUsername,
|
||
|
|
adminPassword
|
||
|
|
} = req.body;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// 参数验证
|
||
|
|
if (!contactName || !contactPhone || !contactEmail || !adminUsername || !adminPassword) {
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '请填写所有必填项'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 检查管理员账号是否已存在
|
||
|
|
const existingUser = await User.findOne({ where: { username: adminUsername } });
|
||
|
|
if (existingUser) {
|
||
|
|
return res.status(400).json({
|
||
|
|
code: 400,
|
||
|
|
message: '管理员账号已存在'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 查询默认套餐
|
||
|
|
const defaultPlan = await SubscriptionPlan.findOne({
|
||
|
|
where: {
|
||
|
|
isDefault: true,
|
||
|
|
isDeleted: 0
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!defaultPlan) {
|
||
|
|
return res.status(500).json({
|
||
|
|
code: 500,
|
||
|
|
message: '系统未配置默认套餐,请联系管理员'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生成租户编码:时间戳 + 随机数
|
||
|
|
const timestamp = Date.now().toString(36).toUpperCase();
|
||
|
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||
|
|
const code = `T${timestamp}${random}`;
|
||
|
|
|
||
|
|
const now = new Date();
|
||
|
|
const trialEndDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||
|
|
|
||
|
|
// 创建租户(使用默认套餐配置)
|
||
|
|
const tenant = await Tenant.create({
|
||
|
|
code,
|
||
|
|
contactName,
|
||
|
|
contactPhone,
|
||
|
|
contactEmail,
|
||
|
|
maxUsers: defaultPlan.maxUsers,
|
||
|
|
maxApartments: defaultPlan.maxApartments,
|
||
|
|
maxRooms: defaultPlan.maxRooms,
|
||
|
|
status: 'active',
|
||
|
|
billingStatus: 'trial_active',
|
||
|
|
planId: defaultPlan.id,
|
||
|
|
trialStartDate: now,
|
||
|
|
trialEndDate: trialEndDate,
|
||
|
|
currentPeriodStart: now,
|
||
|
|
currentPeriodEnd: trialEndDate
|
||
|
|
});
|
||
|
|
|
||
|
|
// 创建租户管理员账号
|
||
|
|
const hashedPassword = await bcrypt.hash(adminPassword, 10);
|
||
|
|
const user = await User.create({
|
||
|
|
username: adminUsername,
|
||
|
|
password: hashedPassword,
|
||
|
|
nickname: adminUsername,
|
||
|
|
tenantId: tenant.id,
|
||
|
|
userType: 'tenant_admin',
|
||
|
|
status: 'active'
|
||
|
|
});
|
||
|
|
|
||
|
|
// 创建默认管理员角色并分配所有菜单权限
|
||
|
|
const adminRole = await Role.create({
|
||
|
|
name: '管理员',
|
||
|
|
code: 'admin',
|
||
|
|
description: '租户默认管理员角色,拥有所有权限',
|
||
|
|
tenantId: tenant.id,
|
||
|
|
status: 'active'
|
||
|
|
});
|
||
|
|
|
||
|
|
// 获取基础菜单并分配给角色(新租户只分配基础功能菜单)
|
||
|
|
const basicMenus = await Menu.findAll({
|
||
|
|
where: {
|
||
|
|
isDeleted: 0,
|
||
|
|
status: 'active',
|
||
|
|
isBasic: 1
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (basicMenus.length > 0) {
|
||
|
|
const menuIds = basicMenus.map(menu => menu.id);
|
||
|
|
await adminRole.setMenus(menuIds);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 将用户关联到管理员角色
|
||
|
|
await user.update({ roleId: adminRole.id });
|
||
|
|
|
||
|
|
// 初始化默认收支类目
|
||
|
|
const defaultCategories = [
|
||
|
|
// 收入类目
|
||
|
|
{ name: '租金', code: 'rent', type: 'income', sort: 1 },
|
||
|
|
{ name: '水费', code: 'water', type: 'income', sort: 2 },
|
||
|
|
{ name: '电费', code: 'electricity', type: 'income', sort: 3 },
|
||
|
|
{ name: '燃气费', code: 'gas', type: 'income', sort: 4 },
|
||
|
|
{ name: '押金', code: 'deposit', type: 'income', sort: 5 },
|
||
|
|
{ name: '物业费', code: 'property_fee', type: 'income', sort: 6 },
|
||
|
|
{ name: '违约金', code: 'penalty', type: 'income', sort: 7 },
|
||
|
|
{ name: '其他收入', code: 'other_income', type: 'income', sort: 8 },
|
||
|
|
// 支出类目
|
||
|
|
{ name: '维修费', code: 'maintenance', type: 'expense', sort: 1 },
|
||
|
|
{ name: '中介费', code: 'agency_fee', type: 'expense', sort: 2 },
|
||
|
|
{ name: '物业费', code: 'property_fee_expense', type: 'expense', sort: 3 },
|
||
|
|
{ name: '水费', code: 'water_expense', type: 'expense', sort: 4 },
|
||
|
|
{ name: '电费', code: 'electricity_expense', type: 'expense', sort: 5 },
|
||
|
|
{ name: '燃气费', code: 'gas_expense', type: 'expense', sort: 6 },
|
||
|
|
{ name: '装修费', code: 'renovation', type: 'expense', sort: 7 },
|
||
|
|
{ name: '其他支出', code: 'other_expense', type: 'expense', sort: 8 }
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const category of defaultCategories) {
|
||
|
|
await Category.create({
|
||
|
|
...category,
|
||
|
|
tenantId: tenant.id,
|
||
|
|
status: 'active',
|
||
|
|
isDefault: 1,
|
||
|
|
createBy: user.id
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 初始化默认系统设置
|
||
|
|
const defaultSettings = [
|
||
|
|
{ key: 'expireReminderDays', value: '30', description: '房间到期提前提醒天数' }
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const setting of defaultSettings) {
|
||
|
|
await Setting.create({
|
||
|
|
...setting,
|
||
|
|
tenantId: tenant.id,
|
||
|
|
createBy: user.id
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
code: 200,
|
||
|
|
message: '注册成功,欢迎试用',
|
||
|
|
data: {
|
||
|
|
tenantId: tenant.id,
|
||
|
|
code: tenant.code,
|
||
|
|
status: tenant.status
|
||
|
|
}
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('租户注册失败:', error);
|
||
|
|
res.status(500).json({
|
||
|
|
code: 500,
|
||
|
|
message: '注册失败',
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|