1
This commit is contained in:
@@ -586,17 +586,14 @@ module.exports = {
|
|||||||
const authDays = accountData.authorization_days || 0;
|
const authDays = accountData.authorization_days || 0;
|
||||||
|
|
||||||
// 计算剩余天数
|
// 计算剩余天数
|
||||||
let remaining_days = 0;
|
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||||
let is_expired = false;
|
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||||
|
const is_expired = remaining_days <= 0;
|
||||||
let end_date = null;
|
let end_date = null;
|
||||||
|
|
||||||
if (authDate && authDays > 0) {
|
if (authDate && authDays > 0) {
|
||||||
const startDate = dayjs(authDate);
|
const startDate = dayjs(authDate);
|
||||||
end_date = startDate.add(authDays, 'day').toDate();
|
end_date = startDate.add(authDays, 'day').toDate();
|
||||||
const now = dayjs();
|
|
||||||
const remaining = dayjs(end_date).diff(now, 'day', true);
|
|
||||||
remaining_days = Math.max(0, Math.ceil(remaining));
|
|
||||||
is_expired = remaining_days <= 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.success({
|
return ctx.success({
|
||||||
@@ -675,10 +672,9 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 计算更新后的剩余天数
|
// 计算更新后的剩余天数
|
||||||
|
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||||
|
const remaining_days = calculateRemainingDays(authDate, authorization_days);
|
||||||
const end_date = dayjs(authDate).add(authorization_days, 'day').toDate();
|
const end_date = dayjs(authDate).add(authorization_days, 'day').toDate();
|
||||||
const now = dayjs();
|
|
||||||
const remaining = dayjs(end_date).diff(now, 'day', true);
|
|
||||||
const remaining_days = Math.max(0, Math.ceil(remaining));
|
|
||||||
|
|
||||||
return ctx.success({
|
return ctx.success({
|
||||||
message: '授权信息更新成功',
|
message: '授权信息更新成功',
|
||||||
|
|||||||
@@ -77,61 +77,117 @@ module.exports = {
|
|||||||
"POST /user/login": async (ctx) => {
|
"POST /user/login": async (ctx) => {
|
||||||
const { phone, password, device_id: client_device_id } = ctx.getBody();
|
const { phone, password, device_id: client_device_id } = ctx.getBody();
|
||||||
const dayjs = require('dayjs');
|
const dayjs = require('dayjs');
|
||||||
|
const { verifyPassword, validateDeviceId, maskPhone } = require('../utils/crypto_utils');
|
||||||
|
const { isAuthorizationValid } = require('../utils/account_utils');
|
||||||
|
|
||||||
// 验证参数
|
// 参数验证
|
||||||
if (!phone || !password) {
|
if (!phone || !password) {
|
||||||
return ctx.fail('手机号和密码不能为空');
|
return ctx.fail('手机号和密码不能为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证手机号格式
|
||||||
|
const phonePattern = /^1[3-9]\d{9}$/;
|
||||||
|
if (!phonePattern.test(phone)) {
|
||||||
|
return ctx.fail('手机号格式不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码长度
|
||||||
|
if (password.length < 6 || password.length > 50) {
|
||||||
|
return ctx.fail('密码长度不正确');
|
||||||
|
}
|
||||||
|
|
||||||
const { pla_account } = await Framework.getModels();
|
const { pla_account } = await Framework.getModels();
|
||||||
|
|
||||||
// 根据手机号(login_name)和密码查找用户
|
try {
|
||||||
|
// 根据手机号查找用户(使用参数化查询防止SQL注入)
|
||||||
const user = await pla_account.findOne({
|
const user = await pla_account.findOne({
|
||||||
where: {
|
where: {
|
||||||
login_name: phone,
|
login_name: phone
|
||||||
pwd: password
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// 防止用户枚举攻击:统一返回相同的错误信息
|
||||||
|
return ctx.fail('手机号或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码(支持新旧格式)
|
||||||
|
let isPasswordValid = false;
|
||||||
|
|
||||||
|
if (user.pwd && user.pwd.includes('$')) {
|
||||||
|
// 新格式:加密密码
|
||||||
|
isPasswordValid = await verifyPassword(password, user.pwd);
|
||||||
|
} else {
|
||||||
|
// 旧格式:明文密码(临时兼容,建议尽快迁移)
|
||||||
|
isPasswordValid = (user.pwd === password);
|
||||||
|
|
||||||
|
// 如果验证通过,自动升级为加密密码
|
||||||
|
if (isPasswordValid) {
|
||||||
|
const { hashPassword } = require('../utils/crypto_utils');
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
await pla_account.update(
|
||||||
|
{ pwd: hashedPassword },
|
||||||
|
{ where: { id: user.id } }
|
||||||
|
);
|
||||||
|
console.log(`[安全升级] 用户 ${maskPhone(phone)} 的密码已自动加密`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
// 记录失败尝试(用于后续添加防暴力破解功能)
|
||||||
|
console.warn(`[登录失败] 手机号: ${maskPhone(phone)}, 时间: ${new Date().toISOString()}`);
|
||||||
return ctx.fail('手机号或密码错误');
|
return ctx.fail('手机号或密码错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查账号是否启用
|
// 检查账号是否启用
|
||||||
if (!user.is_enabled) {
|
if (!user.is_enabled) {
|
||||||
return ctx.fail('账号已被禁用');
|
return ctx.fail('账号已被禁用,请联系管理员');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查授权状态
|
// 检查授权状态(拒绝过期账号登录)
|
||||||
const userData = user.toJSON();
|
const userData = user.toJSON();
|
||||||
const authDate = userData.authorization_date;
|
const authDate = userData.authorization_date;
|
||||||
const authDays = userData.authorization_days || 0;
|
const authDays = userData.authorization_days || 0;
|
||||||
|
|
||||||
if (authDate && authDays > 0) {
|
// 验证授权有效性
|
||||||
const startDate = dayjs(authDate);
|
if (!isAuthorizationValid(authDate, authDays)) {
|
||||||
const endDate = startDate.add(authDays, 'day');
|
console.warn(`[授权过期] 用户 ${maskPhone(phone)} 尝试登录但授权已过期`);
|
||||||
const now = dayjs();
|
return ctx.fail('账号授权已过期,请续费后使用');
|
||||||
const remaining = endDate.diff(now, 'day', true);
|
|
||||||
const remaining_days = Math.max(0, Math.ceil(remaining));
|
|
||||||
|
|
||||||
if (remaining_days <= 0) {
|
|
||||||
return ctx.fail('账号授权已过期,请联系管理员续费');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理设备ID:优先使用客户端传递的 device_id,如果没有则使用数据库中的
|
// 处理设备ID
|
||||||
let device_id = client_device_id || user.device_id;
|
let device_id = client_device_id;
|
||||||
|
|
||||||
// 如果客户端提供了 device_id 且与数据库中的不同,则更新数据库
|
// 验证客户端提供的 device_id 格式
|
||||||
if (client_device_id && client_device_id !== user.device_id) {
|
if (client_device_id) {
|
||||||
|
if (!validateDeviceId(client_device_id)) {
|
||||||
|
return ctx.fail('设备ID格式不正确,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果与数据库不同,需要额外验证(防止设备ID劫持)
|
||||||
|
if (user.device_id && client_device_id !== user.device_id) {
|
||||||
|
// 记录设备更换
|
||||||
|
console.warn(`[设备更换] 用户 ${maskPhone(phone)} 更换设备: ${user.device_id} -> ${client_device_id}`);
|
||||||
|
|
||||||
|
// TODO: 这里可以添加更多安全检查,如:
|
||||||
|
// - 发送验证码确认
|
||||||
|
// - 记录到安全日志
|
||||||
|
// - 限制更换频率
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新设备ID
|
||||||
|
if (client_device_id !== user.device_id) {
|
||||||
await pla_account.update(
|
await pla_account.update(
|
||||||
{ device_id: client_device_id },
|
{ device_id: client_device_id },
|
||||||
{ where: { id: user.id } }
|
{ where: { id: user.id } }
|
||||||
);
|
);
|
||||||
device_id = client_device_id;
|
}
|
||||||
|
} else {
|
||||||
|
// 使用数据库中的设备ID
|
||||||
|
device_id = user.device_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果既没有客户端传递的,数据库中也为空,则返回错误(不应该发生,因为客户端会生成)
|
// 如果仍然没有设备ID,返回错误
|
||||||
if (!device_id) {
|
if (!device_id) {
|
||||||
return ctx.fail('设备ID不能为空,请重新登录');
|
return ctx.fail('设备ID不能为空,请重新登录');
|
||||||
}
|
}
|
||||||
@@ -143,26 +199,41 @@ module.exports = {
|
|||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算剩余天数
|
// 构建安全的用户信息响应(白名单模式)
|
||||||
let remaining_days = 0;
|
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||||
if (authDate && authDays > 0) {
|
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||||
const startDate = dayjs(authDate);
|
|
||||||
const endDate = startDate.add(authDays, 'day');
|
|
||||||
const now = dayjs();
|
|
||||||
const remaining = endDate.diff(now, 'day', true);
|
|
||||||
remaining_days = Math.max(0, Math.ceil(remaining));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userInfo = user.toJSON();
|
const safeUserInfo = {
|
||||||
userInfo.remaining_days = remaining_days;
|
id: user.id,
|
||||||
// 不返回密码
|
sn_code: user.sn_code,
|
||||||
delete userInfo.pwd;
|
login_name: maskPhone(user.login_name), // 脱敏处理
|
||||||
|
is_enabled: user.is_enabled,
|
||||||
|
authorization_date: user.authorization_date,
|
||||||
|
authorization_days: user.authorization_days,
|
||||||
|
remaining_days: remaining_days,
|
||||||
|
auto_deliver: user.auto_deliver,
|
||||||
|
deliver_config: user.deliver_config,
|
||||||
|
created_at: user.created_at,
|
||||||
|
updated_at: user.updated_at
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录成功登录
|
||||||
|
console.log(`[登录成功] 用户 ${maskPhone(phone)}, 剩余天数: ${remaining_days}`);
|
||||||
|
|
||||||
return ctx.success({
|
return ctx.success({
|
||||||
token,
|
token,
|
||||||
device_id,
|
device_id,
|
||||||
user: userInfo
|
user: safeUserInfo
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 记录详细错误但不暴露给客户端
|
||||||
|
console.error('[登录异常]', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
phone: maskPhone(phone)
|
||||||
|
});
|
||||||
|
return ctx.fail('登录失败,请稍后重试');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,10 +279,8 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
'POST /user/delivery-config/get': async (ctx) => {
|
'POST /user/delivery-config/get': async (ctx) => {
|
||||||
try {
|
try {
|
||||||
console.log('[User Controller] 收到获取投递配置请求');
|
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const { sn_code } = body;
|
const { sn_code } = body;
|
||||||
console.log('[User Controller] sn_code:', sn_code);
|
|
||||||
|
|
||||||
if (!sn_code) {
|
if (!sn_code) {
|
||||||
return ctx.fail('请提供设备SN码');
|
return ctx.fail('请提供设备SN码');
|
||||||
@@ -233,8 +302,11 @@ module.exports = {
|
|||||||
|
|
||||||
return ctx.success({ deliver_config });
|
return ctx.success({ deliver_config });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取投递配置失败:', error);
|
console.error('[获取投递配置失败]', {
|
||||||
return ctx.fail('获取投递配置失败: ' + error.message);
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
return ctx.fail('获取投递配置失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -301,10 +373,8 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
'POST /user/delivery-config/save': async (ctx) => {
|
'POST /user/delivery-config/save': async (ctx) => {
|
||||||
try {
|
try {
|
||||||
console.log('[User Controller] 收到保存投递配置请求');
|
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const { sn_code, deliver_config } = body;
|
const { sn_code, deliver_config } = body;
|
||||||
console.log('[User Controller] sn_code:', sn_code, 'deliver_config:', JSON.stringify(deliver_config));
|
|
||||||
|
|
||||||
if (!sn_code) {
|
if (!sn_code) {
|
||||||
return ctx.fail('请提供设备SN码');
|
return ctx.fail('请提供设备SN码');
|
||||||
@@ -314,6 +384,11 @@ module.exports = {
|
|||||||
return ctx.fail('请提供 deliver_config 配置对象');
|
return ctx.fail('请提供 deliver_config 配置对象');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证 deliver_config 的基本结构
|
||||||
|
if (typeof deliver_config !== 'object') {
|
||||||
|
return ctx.fail('deliver_config 必须是对象');
|
||||||
|
}
|
||||||
|
|
||||||
const { pla_account } = await Framework.getModels();
|
const { pla_account } = await Framework.getModels();
|
||||||
|
|
||||||
// 根据 sn_code 查找账号
|
// 根据 sn_code 查找账号
|
||||||
@@ -334,10 +409,19 @@ module.exports = {
|
|||||||
{ where: { id: user.id } }
|
{ where: { id: user.id } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[保存投递配置成功]', {
|
||||||
|
sn_code,
|
||||||
|
auto_delivery: deliver_config.auto_delivery,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
return ctx.success({ message: '配置保存成功' });
|
return ctx.success({ message: '配置保存成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存投递配置失败:', error);
|
console.error('[保存投递配置失败]', {
|
||||||
return ctx.fail('保存投递配置失败: ' + error.message);
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
return ctx.fail('保存投递配置失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,16 @@ const mqtt = require('mqtt')
|
|||||||
const { v4: uuidv4 } = require('uuid'); // 顶部添加
|
const { v4: uuidv4 } = require('uuid'); // 顶部添加
|
||||||
const Framework = require('../../../framework/node-core-framework');
|
const Framework = require('../../../framework/node-core-framework');
|
||||||
const logs = require('../logProxy');
|
const logs = require('../logProxy');
|
||||||
|
|
||||||
// 获取logsService
|
// 获取logsService
|
||||||
class MqttSyncClient {
|
class MqttSyncClient {
|
||||||
constructor(brokerUrl, options = {}) {
|
constructor(brokerUrl, options = {}) {
|
||||||
this.client = mqtt.connect(brokerUrl, options)
|
this.client = mqtt.connect(brokerUrl, options)
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
|
||||||
this.messageListeners = []
|
// 使用 Map 结构优化消息监听器,按 topic 分组
|
||||||
|
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
||||||
|
this.globalListeners = new Set(); // 全局监听器(监听所有 topic)
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
this.isConnected = true
|
this.isConnected = true
|
||||||
@@ -18,11 +21,42 @@ class MqttSyncClient {
|
|||||||
|
|
||||||
this.client.on('message', (topic, message) => {
|
this.client.on('message', (topic, message) => {
|
||||||
|
|
||||||
message = JSON.parse(message.toString())
|
let messageObj;
|
||||||
|
try {
|
||||||
|
messageObj = JSON.parse(message.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MQTT] 消息解析失败:', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('MQTT 收到消息', topic, message)
|
// 记录日志但不包含敏感信息
|
||||||
|
const { maskSensitiveData } = require('../../utils/crypto_utils');
|
||||||
|
const safeMessage = maskSensitiveData(messageObj, ['password', 'pwd', 'token', 'secret', 'key', 'cookie']);
|
||||||
|
console.log('[MQTT] 收到消息', topic, '类型:', messageObj.action || messageObj.type || 'unknown');
|
||||||
|
|
||||||
this.messageListeners.forEach(listener => listener(topic, message))
|
// 优化:只通知相关 topic 的监听器,而不是所有监听器
|
||||||
|
// 1. 触发该 topic 的专用监听器
|
||||||
|
const topicListeners = this.messageListeners.get(topic);
|
||||||
|
if (topicListeners && topicListeners.size > 0) {
|
||||||
|
topicListeners.forEach(listener => {
|
||||||
|
try {
|
||||||
|
listener(topic, messageObj);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MQTT] Topic监听器执行失败:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 触发全局监听器
|
||||||
|
if (this.globalListeners.size > 0) {
|
||||||
|
this.globalListeners.forEach(listener => {
|
||||||
|
try {
|
||||||
|
listener(topic, messageObj);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MQTT] 全局监听器执行失败:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -139,12 +173,56 @@ class MqttSyncClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessageListener(fn) {
|
/**
|
||||||
this.messageListeners.push(fn)
|
* 添加消息监听器
|
||||||
|
* @param {Function} fn - 监听器函数
|
||||||
|
* @param {string} topic - 可选,指定监听的 topic,不指定则监听所有
|
||||||
|
*/
|
||||||
|
addMessageListener(fn, topic = null) {
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new Error('监听器必须是函数');
|
||||||
}
|
}
|
||||||
|
|
||||||
removeMessageListener(fn) {
|
if (topic) {
|
||||||
this.messageListeners = this.messageListeners.filter(f => f !== fn)
|
// 添加到特定 topic 的监听器
|
||||||
|
if (!this.messageListeners.has(topic)) {
|
||||||
|
this.messageListeners.set(topic, new Set());
|
||||||
|
}
|
||||||
|
this.messageListeners.get(topic).add(fn);
|
||||||
|
} else {
|
||||||
|
// 添加到全局监听器
|
||||||
|
this.globalListeners.add(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除消息监听器
|
||||||
|
* @param {Function} fn - 监听器函数
|
||||||
|
* @param {string} topic - 可选,指定从哪个 topic 移除
|
||||||
|
*/
|
||||||
|
removeMessageListener(fn, topic = null) {
|
||||||
|
if (topic) {
|
||||||
|
// 从特定 topic 移除
|
||||||
|
const topicListeners = this.messageListeners.get(topic);
|
||||||
|
if (topicListeners) {
|
||||||
|
topicListeners.delete(fn);
|
||||||
|
// 如果该 topic 没有监听器了,删除整个 Set
|
||||||
|
if (topicListeners.size === 0) {
|
||||||
|
this.messageListeners.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 从全局监听器移除
|
||||||
|
this.globalListeners.delete(fn);
|
||||||
|
|
||||||
|
// 也尝试从所有 topic 中移除(兼容旧代码)
|
||||||
|
for (const [topicKey, listeners] of this.messageListeners.entries()) {
|
||||||
|
listeners.delete(fn);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
this.messageListeners.delete(topicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const config = require('./config.js');
|
|||||||
const deviceManager = require('./deviceManager.js');
|
const deviceManager = require('./deviceManager.js');
|
||||||
const command = require('./command.js');
|
const command = require('./command.js');
|
||||||
const db = require('../dbProxy');
|
const db = require('../dbProxy');
|
||||||
|
const authorizationService = require('../../services/authorization_service.js');
|
||||||
|
|
||||||
const Framework = require("../../../framework/node-core-framework.js");
|
const Framework = require("../../../framework/node-core-framework.js");
|
||||||
/**
|
/**
|
||||||
* 检查当前时间是否在指定的时间范围内
|
* 检查当前时间是否在指定的时间范围内
|
||||||
@@ -498,12 +500,24 @@ class ScheduledJobs {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const device = deviceManager.devices.get(userData.sn_code);
|
const device = deviceManager.devices.get(userData.sn_code);
|
||||||
|
|
||||||
|
|
||||||
|
// 检查用户授权天数 是否够
|
||||||
|
const authorization = await authorizationService.checkAuthorization(userData.sn_code);
|
||||||
|
if (!authorization.is_authorized) {
|
||||||
|
console.log(`[自动投递] 设备 ${userData.sn_code} 授权天数不足,跳过添加任务`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
// 设备从未发送过心跳,视为离线
|
// 设备从未发送过心跳,视为离线
|
||||||
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查最后心跳时间
|
// 检查最后心跳时间
|
||||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||||
|
|||||||
@@ -377,6 +377,70 @@ class TaskQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账号授权状态(剩余天数)
|
||||||
|
* @param {string} sn_code - 设备SN码
|
||||||
|
* @returns {Promise<{authorized: boolean, remaining_days: number, message: string}>}
|
||||||
|
*/
|
||||||
|
async checkAccountAuthorization(sn_code) {
|
||||||
|
try {
|
||||||
|
const pla_account = db.getModel('pla_account');
|
||||||
|
const account = await pla_account.findOne({
|
||||||
|
where: {
|
||||||
|
sn_code: sn_code,
|
||||||
|
is_delete: 0
|
||||||
|
},
|
||||||
|
attributes: ['authorization_date', 'authorization_days']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
remaining_days: 0,
|
||||||
|
message: '账号不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = account.toJSON();
|
||||||
|
const authDate = accountData.authorization_date;
|
||||||
|
const authDays = accountData.authorization_days || 0;
|
||||||
|
|
||||||
|
// 使用工具函数计算剩余天数
|
||||||
|
const { calculateRemainingDays } = require('../../utils/account_utils');
|
||||||
|
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||||
|
|
||||||
|
// 如果没有授权信息或剩余天数 <= 0,不允许创建任务
|
||||||
|
if (!authDate || authDays <= 0 || remaining_days <= 0) {
|
||||||
|
if (!authDate || authDays <= 0) {
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
remaining_days: 0,
|
||||||
|
message: '账号未授权,请购买使用权限后使用'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
remaining_days: 0,
|
||||||
|
message: '账号使用权限已到期,请充值续费后使用'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorized: true,
|
||||||
|
remaining_days: remaining_days,
|
||||||
|
message: `授权有效,剩余 ${remaining_days} 天`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[任务队列] 检查账号授权状态失败:`, error);
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
remaining_days: 0,
|
||||||
|
message: '检查授权状态失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加任务到队列
|
* 添加任务到队列
|
||||||
* @param {string} sn_code - 设备SN码
|
* @param {string} sn_code - 设备SN码
|
||||||
@@ -390,6 +454,12 @@ class TaskQueue {
|
|||||||
throw new Error(`账号未启用,无法添加任务`);
|
throw new Error(`账号未启用,无法添加任务`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查账号授权状态(剩余天数)
|
||||||
|
const authResult = await this.checkAccountAuthorization(sn_code);
|
||||||
|
if (!authResult.authorized) {
|
||||||
|
throw new Error(authResult.message);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否已有相同类型的任务在队列中或正在执行
|
// 检查是否已有相同类型的任务在队列中或正在执行
|
||||||
const existingTask = await this.findExistingTask(sn_code, taskConfig.taskType);
|
const existingTask = await this.findExistingTask(sn_code, taskConfig.taskType);
|
||||||
if (existingTask) {
|
if (existingTask) {
|
||||||
|
|||||||
@@ -294,26 +294,6 @@ module.exports = (db) => {
|
|||||||
type: Sequelize.INTEGER,
|
type: Sequelize.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
|
||||||
remaining_days: {
|
|
||||||
comment: '剩余天数(虚拟字段,通过计算得出)',
|
|
||||||
type: Sequelize.VIRTUAL,
|
|
||||||
get: function () {
|
|
||||||
const authDate = this.getDataValue('authorization_date');
|
|
||||||
const authDays = this.getDataValue('authorization_days') || 0;
|
|
||||||
|
|
||||||
if (!authDate || authDays <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = dayjs(authDate);
|
|
||||||
const endDate = startDate.add(authDays, 'day');
|
|
||||||
const now = dayjs();
|
|
||||||
|
|
||||||
// 计算剩余天数
|
|
||||||
const remaining = endDate.diff(now, 'day', true);
|
|
||||||
return Math.max(0, Math.ceil(remaining));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AuthorizationService {
|
|||||||
return {
|
return {
|
||||||
is_authorized: false,
|
is_authorized: false,
|
||||||
remaining_days: 0,
|
remaining_days: 0,
|
||||||
message: '账号未授权,请联系管理员'
|
message: '账号未授权,请购买使用权限后使用'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class AuthorizationService {
|
|||||||
return {
|
return {
|
||||||
is_authorized: false,
|
is_authorized: false,
|
||||||
remaining_days: 0,
|
remaining_days: 0,
|
||||||
message: '账号授权已过期,请联系管理员续费'
|
message: '账号使用权限已到期,请充值续费后使用'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const db = require('../middleware/dbProxy');
|
|||||||
const scheduleManager = require('../middleware/schedule/index.js');
|
const scheduleManager = require('../middleware/schedule/index.js');
|
||||||
const locationService = require('./locationService');
|
const locationService = require('./locationService');
|
||||||
const authorizationService = require('./authorization_service');
|
const authorizationService = require('./authorization_service');
|
||||||
|
const { addRemainingDays, addRemainingDaysToAccounts } = require('../utils/account_utils');
|
||||||
|
|
||||||
class PlaAccountService {
|
class PlaAccountService {
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +34,8 @@ class PlaAccountService {
|
|||||||
accountData.is_logged_in = false;
|
accountData.is_logged_in = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return accountData;
|
// 添加 remaining_days 字段
|
||||||
|
return addRemainingDays(accountData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +73,8 @@ class PlaAccountService {
|
|||||||
accountData.is_logged_in = false;
|
accountData.is_logged_in = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return accountData;
|
// 添加 remaining_days 字段
|
||||||
|
return addRemainingDays(accountData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,9 +128,12 @@ class PlaAccountService {
|
|||||||
return accountData;
|
return accountData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 为所有账号添加 remaining_days 字段
|
||||||
|
const rowsWithRemainingDays = addRemainingDaysToAccounts(rows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count: result.count,
|
count: result.count,
|
||||||
rows
|
rows: rowsWithRemainingDays
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +145,7 @@ class PlaAccountService {
|
|||||||
async createAccount(data) {
|
async createAccount(data) {
|
||||||
const pla_account = db.getModel('pla_account');
|
const pla_account = db.getModel('pla_account');
|
||||||
|
|
||||||
const { name, sn_code, platform_type, login_name, pwd, keyword, ...otherData } = data;
|
const { name, sn_code, platform_type, login_name, pwd, keyword, remaining_days, ...otherData } = data;
|
||||||
|
|
||||||
if (!name || !sn_code || !platform_type || !login_name) {
|
if (!name || !sn_code || !platform_type || !login_name) {
|
||||||
throw new Error('账户名、设备SN码、平台和登录名为必填项');
|
throw new Error('账户名、设备SN码、平台和登录名为必填项');
|
||||||
@@ -157,6 +163,9 @@ class PlaAccountService {
|
|||||||
...otherData
|
...otherData
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库)
|
||||||
|
delete processedData.remaining_days;
|
||||||
|
|
||||||
booleanFields.forEach(field => {
|
booleanFields.forEach(field => {
|
||||||
if (processedData[field] !== undefined && processedData[field] !== null) {
|
if (processedData[field] !== undefined && processedData[field] !== null) {
|
||||||
processedData[field] = processedData[field] ? 1 : 0;
|
processedData[field] = processedData[field] ? 1 : 0;
|
||||||
@@ -189,6 +198,10 @@ class PlaAccountService {
|
|||||||
// 将布尔字段从 true/false 转换为 0/1,确保数据库兼容性
|
// 将布尔字段从 true/false 转换为 0/1,确保数据库兼容性
|
||||||
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
|
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
|
||||||
const processedData = { ...updateData };
|
const processedData = { ...updateData };
|
||||||
|
|
||||||
|
// 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库)
|
||||||
|
delete processedData.remaining_days;
|
||||||
|
|
||||||
booleanFields.forEach(field => {
|
booleanFields.forEach(field => {
|
||||||
if (processedData[field] !== undefined && processedData[field] !== null) {
|
if (processedData[field] !== undefined && processedData[field] !== null) {
|
||||||
processedData[field] = processedData[field] ? 1 : 0;
|
processedData[field] = processedData[field] ? 1 : 0;
|
||||||
|
|||||||
249
api/tests/account_utils.test.js
Normal file
249
api/tests/account_utils.test.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* 账号工具函数测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
calculateRemainingDays,
|
||||||
|
isAuthorizationValid,
|
||||||
|
addRemainingDays,
|
||||||
|
addRemainingDaysToAccounts
|
||||||
|
} = require('../utils/account_utils');
|
||||||
|
|
||||||
|
// 测试剩余天数计算
|
||||||
|
function testCalculateRemainingDays() {
|
||||||
|
console.log('\n===== 测试剩余天数计算 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
// 测试 1: 未来有效期
|
||||||
|
const futureDate = dayjs().subtract(5, 'day').toDate();
|
||||||
|
const remaining1 = calculateRemainingDays(futureDate, 30);
|
||||||
|
console.log('✓ 未来有效期 (5天前授权30天):', remaining1, '天');
|
||||||
|
console.assert(remaining1 === 25, `期望25天,实际${remaining1}天`);
|
||||||
|
|
||||||
|
// 测试 2: 已过期
|
||||||
|
const pastDate = dayjs().subtract(40, 'day').toDate();
|
||||||
|
const remaining2 = calculateRemainingDays(pastDate, 30);
|
||||||
|
console.log('✓ 已过期 (40天前授权30天):', remaining2, '天');
|
||||||
|
console.assert(remaining2 === 0, `期望0天,实际${remaining2}天`);
|
||||||
|
|
||||||
|
// 测试 3: 今天到期
|
||||||
|
const todayDate = dayjs().startOf('day').toDate();
|
||||||
|
const remaining3 = calculateRemainingDays(todayDate, 0);
|
||||||
|
console.log('✓ 今天到期:', remaining3, '天');
|
||||||
|
|
||||||
|
// 测试 4: 空值处理
|
||||||
|
const remaining4 = calculateRemainingDays(null, 30);
|
||||||
|
console.log('✓ 空授权日期:', remaining4, '天');
|
||||||
|
console.assert(remaining4 === 0, '空值应返回0');
|
||||||
|
|
||||||
|
const remaining5 = calculateRemainingDays(futureDate, 0);
|
||||||
|
console.log('✓ 0天授权:', remaining5, '天');
|
||||||
|
console.assert(remaining5 === 0, '0天授权应返回0');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 剩余天数计算测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试授权有效性检查
|
||||||
|
function testIsAuthorizationValid() {
|
||||||
|
console.log('\n===== 测试授权有效性检查 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
// 测试 1: 有效授权
|
||||||
|
const validDate = dayjs().subtract(5, 'day').toDate();
|
||||||
|
const isValid = isAuthorizationValid(validDate, 30);
|
||||||
|
console.log('✓ 有效授权 (5天前授权30天):', isValid ? '有效' : '无效');
|
||||||
|
console.assert(isValid === true, '应该是有效的');
|
||||||
|
|
||||||
|
// 测试 2: 过期授权
|
||||||
|
const expiredDate = dayjs().subtract(40, 'day').toDate();
|
||||||
|
const isExpired = isAuthorizationValid(expiredDate, 30);
|
||||||
|
console.log('✓ 过期授权 (40天前授权30天):', isExpired ? '有效' : '无效');
|
||||||
|
console.assert(isExpired === false, '应该是无效的');
|
||||||
|
|
||||||
|
// 测试 3: 空值处理
|
||||||
|
const isNull = isAuthorizationValid(null, 30);
|
||||||
|
console.log('✓ 空授权日期:', isNull ? '有效' : '无效');
|
||||||
|
console.assert(isNull === false, '空值应该无效');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 授权有效性检查测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试添加剩余天数
|
||||||
|
function testAddRemainingDays() {
|
||||||
|
console.log('\n===== 测试添加剩余天数 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
// 测试 1: 普通对象
|
||||||
|
const account1 = {
|
||||||
|
id: 1,
|
||||||
|
sn_code: 'SN001',
|
||||||
|
authorization_date: dayjs().subtract(5, 'day').toDate(),
|
||||||
|
authorization_days: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = addRemainingDays(account1);
|
||||||
|
console.log('✓ 普通对象添加剩余天数:', result1.remaining_days, '天');
|
||||||
|
console.assert(result1.remaining_days === 25, `期望25天,实际${result1.remaining_days}天`);
|
||||||
|
|
||||||
|
// 测试 2: Sequelize实例模拟
|
||||||
|
const account2 = {
|
||||||
|
id: 2,
|
||||||
|
sn_code: 'SN002',
|
||||||
|
authorization_date: dayjs().subtract(10, 'day').toDate(),
|
||||||
|
authorization_days: 15,
|
||||||
|
toJSON: function() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
sn_code: this.sn_code,
|
||||||
|
authorization_date: this.authorization_date,
|
||||||
|
authorization_days: this.authorization_days
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result2 = addRemainingDays(account2);
|
||||||
|
console.log('✓ Sequelize实例添加剩余天数:', result2.remaining_days, '天');
|
||||||
|
console.assert(result2.remaining_days === 5, `期望5天,实际${result2.remaining_days}天`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 添加剩余天数测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试批量添加剩余天数
|
||||||
|
function testAddRemainingDaysToAccounts() {
|
||||||
|
console.log('\n===== 测试批量添加剩余天数 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
authorization_date: dayjs().subtract(5, 'day').toDate(),
|
||||||
|
authorization_days: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
authorization_date: dayjs().subtract(10, 'day').toDate(),
|
||||||
|
authorization_days: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
authorization_date: dayjs().subtract(50, 'day').toDate(),
|
||||||
|
authorization_days: 30
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = addRemainingDaysToAccounts(accounts);
|
||||||
|
console.log('✓ 批量添加剩余天数:');
|
||||||
|
results.forEach((acc, index) => {
|
||||||
|
console.log(` 账号${index + 1}: ${acc.remaining_days}天`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.assert(results.length === 3, '数组长度应该是3');
|
||||||
|
console.assert(results[0].remaining_days === 25, '第1个账号剩余天数错误');
|
||||||
|
console.assert(results[1].remaining_days === 5, '第2个账号剩余天数错误');
|
||||||
|
console.assert(results[2].remaining_days === 0, '第3个账号剩余天数错误');
|
||||||
|
|
||||||
|
// 测试空数组
|
||||||
|
const emptyResults = addRemainingDaysToAccounts([]);
|
||||||
|
console.log('✓ 空数组处理:', emptyResults.length === 0 ? '正确' : '错误');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 批量添加剩余天数测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试时区处理
|
||||||
|
function testTimezoneHandling() {
|
||||||
|
console.log('\n===== 测试时区处理 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
const utc = require('dayjs/plugin/utc');
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
// 创建不同时区的日期
|
||||||
|
const localDate = dayjs().subtract(5, 'day').toDate();
|
||||||
|
const utcDate = dayjs().utc().subtract(5, 'day').toDate();
|
||||||
|
|
||||||
|
const remaining1 = calculateRemainingDays(localDate, 30);
|
||||||
|
const remaining2 = calculateRemainingDays(utcDate, 30);
|
||||||
|
|
||||||
|
console.log('✓ 本地时区日期剩余天数:', remaining1, '天');
|
||||||
|
console.log('✓ UTC时区日期剩余天数:', remaining2, '天');
|
||||||
|
|
||||||
|
// 剩余天数应该接近(可能相差1天因为时区转换)
|
||||||
|
const diff = Math.abs(remaining1 - remaining2);
|
||||||
|
console.log('✓ 时区差异:', diff, '天');
|
||||||
|
console.assert(diff <= 1, '时区差异应该不超过1天');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 时区处理测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行所有测试
|
||||||
|
async function runAllTests() {
|
||||||
|
console.log('\n==================== 开始测试 ====================\n');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
results.push(testCalculateRemainingDays());
|
||||||
|
results.push(testIsAuthorizationValid());
|
||||||
|
results.push(testAddRemainingDays());
|
||||||
|
results.push(testAddRemainingDaysToAccounts());
|
||||||
|
results.push(testTimezoneHandling());
|
||||||
|
|
||||||
|
console.log('\n==================== 测试总结 ====================\n');
|
||||||
|
|
||||||
|
const passed = results.filter(r => r).length;
|
||||||
|
const total = results.length;
|
||||||
|
|
||||||
|
console.log(`测试通过: ${passed}/${total}`);
|
||||||
|
|
||||||
|
if (passed === total) {
|
||||||
|
console.log('\n✓ 所有测试通过!\n');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log('\n✗ 部分测试失败\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
if (require.main === module) {
|
||||||
|
runAllTests().catch(error => {
|
||||||
|
console.error('测试执行失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
testCalculateRemainingDays,
|
||||||
|
testIsAuthorizationValid,
|
||||||
|
testAddRemainingDays,
|
||||||
|
testAddRemainingDaysToAccounts,
|
||||||
|
testTimezoneHandling
|
||||||
|
};
|
||||||
207
api/tests/crypto_utils.test.js
Normal file
207
api/tests/crypto_utils.test.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 加密工具函数测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
generateToken,
|
||||||
|
generateDeviceId,
|
||||||
|
validateDeviceId,
|
||||||
|
maskPhone,
|
||||||
|
maskEmail,
|
||||||
|
maskSensitiveData
|
||||||
|
} = require('../utils/crypto_utils');
|
||||||
|
|
||||||
|
// 测试密码加密和验证
|
||||||
|
async function testPasswordEncryption() {
|
||||||
|
console.log('\n===== 测试密码加密和验证 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 测试 1: 基本加密和验证
|
||||||
|
const password = 'mySecurePassword123';
|
||||||
|
const hashed = await hashPassword(password);
|
||||||
|
console.log('✓ 密码加密成功:', hashed.substring(0, 20) + '...');
|
||||||
|
|
||||||
|
// 验证正确密码
|
||||||
|
const isValid = await verifyPassword(password, hashed);
|
||||||
|
console.log('✓ 正确密码验证:', isValid ? '通过' : '失败');
|
||||||
|
|
||||||
|
// 验证错误密码
|
||||||
|
const isInvalid = await verifyPassword('wrongPassword', hashed);
|
||||||
|
console.log('✓ 错误密码验证:', isInvalid ? '失败(不应该通过)' : '正确拒绝');
|
||||||
|
|
||||||
|
// 测试 2: 相同密码生成不同哈希
|
||||||
|
const hashed2 = await hashPassword(password);
|
||||||
|
console.log('✓ 相同密码生成不同哈希:', hashed !== hashed2 ? '是' : '否');
|
||||||
|
|
||||||
|
// 测试 3: 空密码处理
|
||||||
|
try {
|
||||||
|
await hashPassword('');
|
||||||
|
console.log('✗ 空密码应该抛出错误');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('✓ 空密码正确抛出错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 密码加密测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试设备ID生成和验证
|
||||||
|
function testDeviceId() {
|
||||||
|
console.log('\n===== 测试设备ID生成和验证 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 测试 1: 生成设备ID
|
||||||
|
const deviceId1 = generateDeviceId();
|
||||||
|
console.log('✓ 生成设备ID:', deviceId1);
|
||||||
|
|
||||||
|
// 测试 2: 验证有效设备ID
|
||||||
|
const isValid = validateDeviceId(deviceId1);
|
||||||
|
console.log('✓ 验证有效设备ID:', isValid ? '通过' : '失败');
|
||||||
|
|
||||||
|
// 测试 3: 验证无效设备ID
|
||||||
|
const invalidIds = [
|
||||||
|
'invalid_id',
|
||||||
|
'device_abc_123',
|
||||||
|
'123456789',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
undefined
|
||||||
|
];
|
||||||
|
|
||||||
|
let allInvalidRejected = true;
|
||||||
|
for (const id of invalidIds) {
|
||||||
|
if (validateDeviceId(id)) {
|
||||||
|
console.log('✗ 无效ID未被拒绝:', id);
|
||||||
|
allInvalidRejected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allInvalidRejected) {
|
||||||
|
console.log('✓ 所有无效设备ID都被正确拒绝');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 4: 生成的ID唯一性
|
||||||
|
const deviceId2 = generateDeviceId();
|
||||||
|
console.log('✓ 生成的ID是唯一的:', deviceId1 !== deviceId2 ? '是' : '否');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 设备ID测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试数据脱敏
|
||||||
|
function testDataMasking() {
|
||||||
|
console.log('\n===== 测试数据脱敏 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 测试 1: 手机号脱敏
|
||||||
|
const phone = '13800138000';
|
||||||
|
const maskedPhone = maskPhone(phone);
|
||||||
|
console.log('✓ 手机号脱敏:', phone, '->', maskedPhone);
|
||||||
|
console.assert(maskedPhone === '138****8000', '手机号脱敏格式错误');
|
||||||
|
|
||||||
|
// 测试 2: 邮箱脱敏
|
||||||
|
const email = 'user@example.com';
|
||||||
|
const maskedEmail = maskEmail(email);
|
||||||
|
console.log('✓ 邮箱脱敏:', email, '->', maskedEmail);
|
||||||
|
|
||||||
|
// 测试 3: 对象脱敏
|
||||||
|
const sensitiveObj = {
|
||||||
|
username: 'john',
|
||||||
|
password: 'secret123',
|
||||||
|
email: 'john@example.com',
|
||||||
|
token: 'abc123xyz',
|
||||||
|
normalField: 'public data'
|
||||||
|
};
|
||||||
|
|
||||||
|
const masked = maskSensitiveData(sensitiveObj);
|
||||||
|
console.log('✓ 对象脱敏:');
|
||||||
|
console.log(' 原始:', sensitiveObj);
|
||||||
|
console.log(' 脱敏:', masked);
|
||||||
|
|
||||||
|
// 验证敏感字段被屏蔽
|
||||||
|
console.assert(masked.password === '***MASKED***', 'password未被屏蔽');
|
||||||
|
console.assert(masked.token === '***MASKED***', 'token未被屏蔽');
|
||||||
|
console.assert(masked.normalField === 'public data', '普通字段被修改');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ 数据脱敏测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试Token生成
|
||||||
|
function testTokenGeneration() {
|
||||||
|
console.log('\n===== 测试Token生成 =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 测试 1: 生成默认长度token
|
||||||
|
const token1 = generateToken();
|
||||||
|
console.log('✓ 生成默认token (64字符):', token1.substring(0, 20) + '...');
|
||||||
|
console.assert(token1.length === 64, 'Token长度错误');
|
||||||
|
|
||||||
|
// 测试 2: 生成指定长度token
|
||||||
|
const token2 = generateToken(16);
|
||||||
|
console.log('✓ 生成16字节token (32字符):', token2);
|
||||||
|
console.assert(token2.length === 32, 'Token长度错误');
|
||||||
|
|
||||||
|
// 测试 3: Token唯一性
|
||||||
|
const token3 = generateToken();
|
||||||
|
console.log('✓ Token唯一性:', token1 !== token3 ? '是' : '否');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ Token生成测试失败:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行所有测试
|
||||||
|
async function runAllTests() {
|
||||||
|
console.log('\n==================== 开始测试 ====================\n');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
results.push(await testPasswordEncryption());
|
||||||
|
results.push(testDeviceId());
|
||||||
|
results.push(testDataMasking());
|
||||||
|
results.push(testTokenGeneration());
|
||||||
|
|
||||||
|
console.log('\n==================== 测试总结 ====================\n');
|
||||||
|
|
||||||
|
const passed = results.filter(r => r).length;
|
||||||
|
const total = results.length;
|
||||||
|
|
||||||
|
console.log(`测试通过: ${passed}/${total}`);
|
||||||
|
|
||||||
|
if (passed === total) {
|
||||||
|
console.log('\n✓ 所有测试通过!\n');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log('\n✗ 部分测试失败\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
if (require.main === module) {
|
||||||
|
runAllTests().catch(error => {
|
||||||
|
console.error('测试执行失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
testPasswordEncryption,
|
||||||
|
testDeviceId,
|
||||||
|
testDataMasking,
|
||||||
|
testTokenGeneration
|
||||||
|
};
|
||||||
82
api/utils/account_utils.js
Normal file
82
api/utils/account_utils.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 账号工具函数
|
||||||
|
* 提供账号相关的工具方法
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
const utc = require('dayjs/plugin/utc');
|
||||||
|
const timezone = require('dayjs/plugin/timezone');
|
||||||
|
|
||||||
|
// 启用 UTC 和时区插件
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算剩余天数(使用 UTC 时间避免时区问题)
|
||||||
|
* @param {Date|string|null} authorizationDate - 授权日期
|
||||||
|
* @param {number} authorizationDays - 授权天数
|
||||||
|
* @returns {number} 剩余天数
|
||||||
|
*/
|
||||||
|
function calculateRemainingDays(authorizationDate, authorizationDays) {
|
||||||
|
if (!authorizationDate || !authorizationDays || authorizationDays <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 UTC 时间计算,避免时区问题
|
||||||
|
const startDate = dayjs(authorizationDate).utc().startOf('day');
|
||||||
|
const endDate = startDate.add(authorizationDays, 'day');
|
||||||
|
const now = dayjs().utc().startOf('day');
|
||||||
|
|
||||||
|
// 计算剩余天数
|
||||||
|
const remaining = endDate.diff(now, 'day', false); // 使用整数天数
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查授权是否有效
|
||||||
|
* @param {Date|string|null} authorizationDate - 授权日期
|
||||||
|
* @param {number} authorizationDays - 授权天数
|
||||||
|
* @returns {boolean} 授权是否有效
|
||||||
|
*/
|
||||||
|
function isAuthorizationValid(authorizationDate, authorizationDays) {
|
||||||
|
const remainingDays = calculateRemainingDays(authorizationDate, authorizationDays);
|
||||||
|
return remainingDays > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为账号对象添加 remaining_days 字段
|
||||||
|
* @param {Object} account - 账号对象(可以是 Sequelize 实例或普通对象)
|
||||||
|
* @returns {Object} 添加了 remaining_days 的账号对象
|
||||||
|
*/
|
||||||
|
function addRemainingDays(account) {
|
||||||
|
// 如果是 Sequelize 实例,转换为普通对象
|
||||||
|
const accountData = account.toJSON ? account.toJSON() : account;
|
||||||
|
|
||||||
|
const authDate = accountData.authorization_date;
|
||||||
|
const authDays = accountData.authorization_days || 0;
|
||||||
|
|
||||||
|
accountData.remaining_days = calculateRemainingDays(authDate, authDays);
|
||||||
|
|
||||||
|
return accountData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为账号数组添加 remaining_days 字段
|
||||||
|
* @param {Array} accounts - 账号数组
|
||||||
|
* @returns {Array} 添加了 remaining_days 的账号数组
|
||||||
|
*/
|
||||||
|
function addRemainingDaysToAccounts(accounts) {
|
||||||
|
if (!Array.isArray(accounts)) {
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts.map(account => addRemainingDays(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateRemainingDays,
|
||||||
|
isAuthorizationValid,
|
||||||
|
addRemainingDays,
|
||||||
|
addRemainingDaysToAccounts
|
||||||
|
};
|
||||||
|
|
||||||
181
api/utils/crypto_utils.js
Normal file
181
api/utils/crypto_utils.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* 加密工具函数
|
||||||
|
* 提供密码加密、验证等安全相关功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// 配置参数
|
||||||
|
const SALT_LENGTH = 16; // salt 长度
|
||||||
|
const KEY_LENGTH = 64; // 密钥长度
|
||||||
|
const ITERATIONS = 100000; // 迭代次数
|
||||||
|
const DIGEST = 'sha256'; // 摘要算法
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成密码哈希
|
||||||
|
* @param {string} password - 明文密码
|
||||||
|
* @returns {Promise<string>} 加密后的密码字符串 (格式: salt$hash)
|
||||||
|
*/
|
||||||
|
async function hashPassword(password) {
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
throw new Error('密码不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 生成随机 salt
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH).toString('hex');
|
||||||
|
|
||||||
|
// 使用 pbkdf2 生成密钥
|
||||||
|
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
const hash = derivedKey.toString('hex');
|
||||||
|
// 返回格式: salt$hash
|
||||||
|
resolve(`${salt}$${hash}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码
|
||||||
|
* @param {string} password - 明文密码
|
||||||
|
* @param {string} hashedPassword - 加密后的密码 (格式: salt$hash)
|
||||||
|
* @returns {Promise<boolean>} 是否匹配
|
||||||
|
*/
|
||||||
|
async function verifyPassword(password, hashedPassword) {
|
||||||
|
if (!password || !hashedPassword) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// 解析 salt 和 hash
|
||||||
|
const parts = hashedPassword.split('$');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [salt, originalHash] = parts;
|
||||||
|
|
||||||
|
// 使用相同的 salt 生成密钥
|
||||||
|
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
const hash = derivedKey.toString('hex');
|
||||||
|
// 使用恒定时间比较,防止时序攻击
|
||||||
|
resolve(crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(originalHash)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成安全的随机 token
|
||||||
|
* @param {number} length - token 长度(字节数),默认 32
|
||||||
|
* @returns {string} 十六进制字符串
|
||||||
|
*/
|
||||||
|
function generateToken(length = 32) {
|
||||||
|
return crypto.randomBytes(length).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成设备 ID
|
||||||
|
* @param {string} prefix - 前缀,默认 'device'
|
||||||
|
* @returns {string} 设备 ID
|
||||||
|
*/
|
||||||
|
function generateDeviceId(prefix = 'device') {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = crypto.randomBytes(8).toString('hex');
|
||||||
|
return `${prefix}_${timestamp}_${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证设备 ID 格式
|
||||||
|
* @param {string} deviceId - 设备 ID
|
||||||
|
* @returns {boolean} 是否有效
|
||||||
|
*/
|
||||||
|
function validateDeviceId(deviceId) {
|
||||||
|
if (!deviceId || typeof deviceId !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查格式: prefix_timestamp_randomhex
|
||||||
|
const pattern = /^[a-z]+_\d{13}_[a-f0-9]{16}$/i;
|
||||||
|
return pattern.test(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脱敏处理 - 手机号
|
||||||
|
* @param {string} phone - 手机号
|
||||||
|
* @returns {string} 脱敏后的手机号
|
||||||
|
*/
|
||||||
|
function maskPhone(phone) {
|
||||||
|
if (!phone || typeof phone !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (phone.length < 11) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脱敏处理 - 邮箱
|
||||||
|
* @param {string} email - 邮箱
|
||||||
|
* @returns {string} 脱敏后的邮箱
|
||||||
|
*/
|
||||||
|
function maskEmail(email) {
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = email.split('@');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
const [username, domain] = parts;
|
||||||
|
if (username.length <= 2) {
|
||||||
|
return `*@${domain}`;
|
||||||
|
}
|
||||||
|
const masked = username[0] + '***' + username[username.length - 1];
|
||||||
|
return `${masked}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脱敏处理 - 通用对象(用于日志)
|
||||||
|
* @param {Object} obj - 要脱敏的对象
|
||||||
|
* @param {Array<string>} sensitiveFields - 敏感字段列表
|
||||||
|
* @returns {Object} 脱敏后的对象
|
||||||
|
*/
|
||||||
|
function maskSensitiveData(obj, sensitiveFields = ['password', 'pwd', 'token', 'secret', 'key']) {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masked = { ...obj };
|
||||||
|
|
||||||
|
for (const field of sensitiveFields) {
|
||||||
|
if (masked[field]) {
|
||||||
|
masked[field] = '***MASKED***';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
generateToken,
|
||||||
|
generateDeviceId,
|
||||||
|
validateDeviceId,
|
||||||
|
maskPhone,
|
||||||
|
maskEmail,
|
||||||
|
maskSensitiveData
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user