From 10aff2f2662c286627e689f86f8e45ee72470a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Fri, 19 Dec 2025 22:24:23 +0800 Subject: [PATCH] 1 --- api/controller_admin/pla_account.js | 14 +- api/controller_front/user.js | 246 ++++++++++++++-------- api/middleware/mqtt/mqttClient.js | 94 ++++++++- api/middleware/schedule/scheduledJobs.js | 60 +++--- api/middleware/schedule/taskQueue.js | 70 +++++++ api/model/pla_account.js | 20 -- api/services/authorization_service.js | 4 +- api/services/pla_account_service.js | 21 +- api/tests/account_utils.test.js | 249 +++++++++++++++++++++++ api/tests/crypto_utils.test.js | 207 +++++++++++++++++++ api/utils/account_utils.js | 82 ++++++++ api/utils/crypto_utils.js | 181 ++++++++++++++++ 12 files changed, 1101 insertions(+), 147 deletions(-) create mode 100644 api/tests/account_utils.test.js create mode 100644 api/tests/crypto_utils.test.js create mode 100644 api/utils/account_utils.js create mode 100644 api/utils/crypto_utils.js diff --git a/api/controller_admin/pla_account.js b/api/controller_admin/pla_account.js index 2ea882a..d250093 100644 --- a/api/controller_admin/pla_account.js +++ b/api/controller_admin/pla_account.js @@ -586,17 +586,14 @@ module.exports = { const authDays = accountData.authorization_days || 0; // 计算剩余天数 - let remaining_days = 0; - let is_expired = false; + const { calculateRemainingDays } = require('../utils/account_utils'); + const remaining_days = calculateRemainingDays(authDate, authDays); + const is_expired = remaining_days <= 0; let end_date = null; if (authDate && authDays > 0) { const startDate = dayjs(authDate); 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({ @@ -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 now = dayjs(); - const remaining = dayjs(end_date).diff(now, 'day', true); - const remaining_days = Math.max(0, Math.ceil(remaining)); return ctx.success({ message: '授权信息更新成功', diff --git a/api/controller_front/user.js b/api/controller_front/user.js index 901e661..be12803 100644 --- a/api/controller_front/user.js +++ b/api/controller_front/user.js @@ -77,92 +77,163 @@ module.exports = { "POST /user/login": async (ctx) => { const { phone, password, device_id: client_device_id } = ctx.getBody(); const dayjs = require('dayjs'); + const { verifyPassword, validateDeviceId, maskPhone } = require('../utils/crypto_utils'); + const { isAuthorizationValid } = require('../utils/account_utils'); - // 验证参数 + // 参数验证 if (!phone || !password) { 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(); - // 根据手机号(login_name)和密码查找用户 - const user = await pla_account.findOne({ - where: { - login_name: phone, - pwd: password + try { + // 根据手机号查找用户(使用参数化查询防止SQL注入) + const user = await pla_account.findOne({ + where: { + login_name: phone + } + }); + + if (!user) { + // 防止用户枚举攻击:统一返回相同的错误信息 + return ctx.fail('手机号或密码错误'); } - }); - if (!user) { - return ctx.fail('手机号或密码错误'); - } + // 验证密码(支持新旧格式) + let isPasswordValid = false; - // 检查账号是否启用 - if (!user.is_enabled) { - return ctx.fail('账号已被禁用'); - } + if (user.pwd && user.pwd.includes('$')) { + // 新格式:加密密码 + isPasswordValid = await verifyPassword(password, user.pwd); + } else { + // 旧格式:明文密码(临时兼容,建议尽快迁移) + isPasswordValid = (user.pwd === password); - // 检查授权状态 - const userData = user.toJSON(); - const authDate = userData.authorization_date; - const authDays = userData.authorization_days || 0; - - if (authDate && authDays > 0) { - const startDate = dayjs(authDate); - const endDate = startDate.add(authDays, 'day'); - const now = dayjs(); - const remaining = endDate.diff(now, 'day', true); - const remaining_days = Math.max(0, Math.ceil(remaining)); - - if (remaining_days <= 0) { - return ctx.fail('账号授权已过期,请联系管理员续费'); + // 如果验证通过,自动升级为加密密码 + 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('手机号或密码错误'); + } + + // 检查账号是否启用 + if (!user.is_enabled) { + return ctx.fail('账号已被禁用,请联系管理员'); + } + + // 检查授权状态(拒绝过期账号登录) + const userData = user.toJSON(); + const authDate = userData.authorization_date; + const authDays = userData.authorization_days || 0; + + // 验证授权有效性 + if (!isAuthorizationValid(authDate, authDays)) { + console.warn(`[授权过期] 用户 ${maskPhone(phone)} 尝试登录但授权已过期`); + return ctx.fail('账号授权已过期,请续费后使用'); + } + + // 处理设备ID + let device_id = client_device_id; + + // 验证客户端提供的 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( + { device_id: client_device_id }, + { where: { id: user.id } } + ); + } + } else { + // 使用数据库中的设备ID + device_id = user.device_id; + } + + // 如果仍然没有设备ID,返回错误 + if (!device_id) { + return ctx.fail('设备ID不能为空,请重新登录'); + } + + // 创建token + const token = Framework.getServices().tokenService.create({ + sn_code: user.sn_code, + device_id: device_id, + id: user.id + }); + + // 构建安全的用户信息响应(白名单模式) + const { calculateRemainingDays } = require('../utils/account_utils'); + const remaining_days = calculateRemainingDays(authDate, authDays); + + const safeUserInfo = { + id: user.id, + sn_code: user.sn_code, + 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({ + token, + device_id, + user: safeUserInfo + }); + } catch (error) { + // 记录详细错误但不暴露给客户端 + console.error('[登录异常]', { + error: error.message, + stack: error.stack, + phone: maskPhone(phone) + }); + return ctx.fail('登录失败,请稍后重试'); } - - // 处理设备ID:优先使用客户端传递的 device_id,如果没有则使用数据库中的 - let device_id = client_device_id || user.device_id; - - // 如果客户端提供了 device_id 且与数据库中的不同,则更新数据库 - if (client_device_id && client_device_id !== user.device_id) { - await pla_account.update( - { device_id: client_device_id }, - { where: { id: user.id } } - ); - device_id = client_device_id; - } - - // 如果既没有客户端传递的,数据库中也为空,则返回错误(不应该发生,因为客户端会生成) - if (!device_id) { - return ctx.fail('设备ID不能为空,请重新登录'); - } - - // 创建token - const token = Framework.getServices().tokenService.create({ - sn_code: user.sn_code, - device_id: device_id, - id: user.id - }); - - // 计算剩余天数 - let remaining_days = 0; - if (authDate && authDays > 0) { - 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(); - userInfo.remaining_days = remaining_days; - // 不返回密码 - delete userInfo.pwd; - - return ctx.success({ - token, - device_id, - user: userInfo - }); }, /** @@ -208,10 +279,8 @@ module.exports = { */ 'POST /user/delivery-config/get': async (ctx) => { try { - console.log('[User Controller] 收到获取投递配置请求'); const body = ctx.getBody(); const { sn_code } = body; - console.log('[User Controller] sn_code:', sn_code); if (!sn_code) { return ctx.fail('请提供设备SN码'); @@ -233,8 +302,11 @@ module.exports = { return ctx.success({ deliver_config }); } catch (error) { - console.error('获取投递配置失败:', error); - return ctx.fail('获取投递配置失败: ' + error.message); + console.error('[获取投递配置失败]', { + error: error.message, + timestamp: new Date().toISOString() + }); + return ctx.fail('获取投递配置失败'); } }, @@ -301,10 +373,8 @@ module.exports = { */ 'POST /user/delivery-config/save': async (ctx) => { try { - console.log('[User Controller] 收到保存投递配置请求'); const body = ctx.getBody(); const { sn_code, deliver_config } = body; - console.log('[User Controller] sn_code:', sn_code, 'deliver_config:', JSON.stringify(deliver_config)); if (!sn_code) { return ctx.fail('请提供设备SN码'); @@ -314,6 +384,11 @@ module.exports = { return ctx.fail('请提供 deliver_config 配置对象'); } + // 验证 deliver_config 的基本结构 + if (typeof deliver_config !== 'object') { + return ctx.fail('deliver_config 必须是对象'); + } + const { pla_account } = await Framework.getModels(); // 根据 sn_code 查找账号 @@ -334,10 +409,19 @@ module.exports = { { where: { id: user.id } } ); + console.log('[保存投递配置成功]', { + sn_code, + auto_delivery: deliver_config.auto_delivery, + timestamp: new Date().toISOString() + }); + return ctx.success({ message: '配置保存成功' }); } catch (error) { - console.error('保存投递配置失败:', error); - return ctx.fail('保存投递配置失败: ' + error.message); + console.error('[保存投递配置失败]', { + error: error.message, + timestamp: new Date().toISOString() + }); + return ctx.fail('保存投递配置失败'); } } } \ No newline at end of file diff --git a/api/middleware/mqtt/mqttClient.js b/api/middleware/mqtt/mqttClient.js index b291bad..2d04cac 100644 --- a/api/middleware/mqtt/mqttClient.js +++ b/api/middleware/mqtt/mqttClient.js @@ -2,13 +2,16 @@ const mqtt = require('mqtt') const { v4: uuidv4 } = require('uuid'); // 顶部添加 const Framework = require('../../../framework/node-core-framework'); const logs = require('../logProxy'); + // 获取logsService class MqttSyncClient { constructor(brokerUrl, options = {}) { this.client = mqtt.connect(brokerUrl, options) this.isConnected = false - this.messageListeners = [] + // 使用 Map 结构优化消息监听器,按 topic 分组 + this.messageListeners = new Map(); // Map> + this.globalListeners = new Set(); // 全局监听器(监听所有 topic) this.client.on('connect', () => { this.isConnected = true @@ -18,11 +21,42 @@ class MqttSyncClient { 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('监听器必须是函数'); + } + + if (topic) { + // 添加到特定 topic 的监听器 + if (!this.messageListeners.has(topic)) { + this.messageListeners.set(topic, new Set()); + } + this.messageListeners.get(topic).add(fn); + } else { + // 添加到全局监听器 + this.globalListeners.add(fn); + } } - removeMessageListener(fn) { - this.messageListeners = this.messageListeners.filter(f => f !== 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); + } + } + } } /** diff --git a/api/middleware/schedule/scheduledJobs.js b/api/middleware/schedule/scheduledJobs.js index 080f0c8..61babce 100644 --- a/api/middleware/schedule/scheduledJobs.js +++ b/api/middleware/schedule/scheduledJobs.js @@ -4,6 +4,8 @@ const config = require('./config.js'); const deviceManager = require('./deviceManager.js'); const command = require('./command.js'); const db = require('../dbProxy'); +const authorizationService = require('../../services/authorization_service.js'); + const Framework = require("../../../framework/node-core-framework.js"); /** * 检查当前时间是否在指定的时间范围内 @@ -111,7 +113,7 @@ class ScheduledJobs { // 执行自动投递任务 - const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => { + const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => { this.autoDeliverTask(); }); @@ -197,7 +199,7 @@ class ScheduledJobs { for (const account of accounts) { const sn_code = account.sn_code; const device = deviceManager.devices.get(sn_code); - + if (!device) { // 设备从未发送过心跳,视为离线 offlineSnCodes.push(sn_code); @@ -250,7 +252,7 @@ class ScheduledJobs { { status: 'cancelled', endTime: new Date(), - result: JSON.stringify({ + result: JSON.stringify({ reason: '设备离线超过10分钟,任务已自动取消', offlineTime: deviceInfo?.lastHeartbeatTime }) @@ -292,7 +294,7 @@ class ScheduledJobs { async syncTaskStatusSummary() { try { const { pla_account } = await Framework.getModels(); - + // 获取所有启用的账号 const accounts = await pla_account.findAll({ where: { @@ -313,19 +315,19 @@ class ScheduledJobs { // 为每个在线设备发送任务状态摘要 for (const account of accounts) { const sn_code = account.sn_code; - + // 检查设备是否在线 const device = deviceManager.devices.get(sn_code); - + if (!device) { // 设备从未发送过心跳,视为离线,跳过 continue; } - + // 检查最后心跳时间 const lastHeartbeat = device.lastHeartbeat || 0; const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold); - + if (!isOnline) { // 设备离线,跳过 continue; @@ -355,7 +357,7 @@ class ScheduledJobs { try { const Sequelize = require('sequelize'); const { task_status, op } = db.models; - + // 查询所有运行中的任务 const runningTasks = await task_status.findAll({ where: { @@ -374,7 +376,7 @@ class ScheduledJobs { for (const task of runningTasks) { const taskData = task.toJSON(); const startTime = taskData.startTime ? new Date(taskData.startTime) : (taskData.create_time ? new Date(taskData.create_time) : null); - + if (!startTime) { continue; } @@ -424,9 +426,9 @@ class ScheduledJobs { deviceStatus.currentTask = null; deviceStatus.runningCount = Math.max(0, deviceStatus.runningCount - 1); this.taskQueue.globalRunningCount = Math.max(0, this.taskQueue.globalRunningCount - 1); - + console.log(`[任务超时检查] 已重置设备 ${taskData.sn_code} 的状态,可以继续执行下一个任务`); - + // 尝试继续处理该设备的队列 setTimeout(() => { this.taskQueue.processQueue(taskData.sn_code).catch(error => { @@ -466,7 +468,7 @@ class ScheduledJobs { // 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动投递的账号 const models = db.models; const { pla_account, op } = models; - + // 直接从 pla_account 查询启用且开启自动投递的账号 // 注意:不再检查在线状态,因为 device_status 已移除 const pla_users = await pla_account.findAll({ @@ -477,7 +479,7 @@ class ScheduledJobs { } }); - + if (!pla_users || pla_users.length === 0) { console.log('[自动投递] 没有启用且开启自动投递的账号'); @@ -497,17 +499,29 @@ class ScheduledJobs { const offlineThreshold = 3 * 60 * 1000; // 3分钟 const now = Date.now(); 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) { // 设备从未发送过心跳,视为离线 console.log(`[自动投递] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`); continue; } - + + + + // 检查最后心跳时间 const lastHeartbeat = device.lastHeartbeat || 0; const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold); - + if (!isOnline) { const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知'; console.log(`[自动投递] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`); @@ -569,7 +583,7 @@ class ScheduledJobs { const lastDeliverTime = new Date(lastDeliverTask.endTime); const elapsedTime = new Date().getTime() - lastDeliverTime.getTime(); - + if (elapsedTime < interval_ms) { const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000)); const elapsedMinutes = Math.round(elapsedTime / (60 * 1000)); @@ -651,7 +665,7 @@ class ScheduledJobs { // 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动沟通的账号 const models = db.models; const { pla_account, op } = models; - + // 直接从 pla_account 查询启用且开启自动沟通的账号 // 注意:不再检查在线状态,因为 device_status 已移除 const pla_users = await pla_account.findAll({ @@ -680,17 +694,17 @@ class ScheduledJobs { const offlineThreshold = 3 * 60 * 1000; // 3分钟 const now = Date.now(); const device = deviceManager.devices.get(userData.sn_code); - + if (!device) { // 设备从未发送过心跳,视为离线 console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`); continue; } - + // 检查最后心跳时间 const lastHeartbeat = device.lastHeartbeat || 0; const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold); - + if (!isOnline) { const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知'; console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`); @@ -740,7 +754,7 @@ class ScheduledJobs { if (lastChatTask && lastChatTask.endTime) { const lastChatTime = new Date(lastChatTask.endTime); const elapsedTime = now.getTime() - lastChatTime.getTime(); - + if (elapsedTime < interval_ms) { const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000)); console.log(`[自动沟通] 设备 ${userData.sn_code} 距离上次沟通仅 ${Math.round(elapsedTime / (60 * 1000))} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${chat_interval} 分钟)`); diff --git a/api/middleware/schedule/taskQueue.js b/api/middleware/schedule/taskQueue.js index ba5845b..85f03b1 100644 --- a/api/middleware/schedule/taskQueue.js +++ b/api/middleware/schedule/taskQueue.js @@ -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码 @@ -390,6 +454,12 @@ class TaskQueue { 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); if (existingTask) { diff --git a/api/model/pla_account.js b/api/model/pla_account.js index b23aecd..244ab18 100644 --- a/api/model/pla_account.js +++ b/api/model/pla_account.js @@ -294,26 +294,6 @@ module.exports = (db) => { type: Sequelize.INTEGER, allowNull: false, 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)); - } } }); diff --git a/api/services/authorization_service.js b/api/services/authorization_service.js index 96c7384..b80b256 100644 --- a/api/services/authorization_service.js +++ b/api/services/authorization_service.js @@ -38,7 +38,7 @@ class AuthorizationService { return { is_authorized: false, remaining_days: 0, - message: '账号未授权,请联系管理员' + message: '账号未授权,请购买使用权限后使用' }; } @@ -53,7 +53,7 @@ class AuthorizationService { return { is_authorized: false, remaining_days: 0, - message: '账号授权已过期,请联系管理员续费' + message: '账号使用权限已到期,请充值续费后使用' }; } diff --git a/api/services/pla_account_service.js b/api/services/pla_account_service.js index 476941a..f0bc637 100644 --- a/api/services/pla_account_service.js +++ b/api/services/pla_account_service.js @@ -7,6 +7,7 @@ const db = require('../middleware/dbProxy'); const scheduleManager = require('../middleware/schedule/index.js'); const locationService = require('./locationService'); const authorizationService = require('./authorization_service'); +const { addRemainingDays, addRemainingDaysToAccounts } = require('../utils/account_utils'); class PlaAccountService { /** @@ -33,7 +34,8 @@ class PlaAccountService { accountData.is_logged_in = false; } - return accountData; + // 添加 remaining_days 字段 + return addRemainingDays(accountData); } /** @@ -71,7 +73,8 @@ class PlaAccountService { accountData.is_logged_in = false; } - return accountData; + // 添加 remaining_days 字段 + return addRemainingDays(accountData); } /** @@ -125,9 +128,12 @@ class PlaAccountService { return accountData; }); + // 为所有账号添加 remaining_days 字段 + const rowsWithRemainingDays = addRemainingDaysToAccounts(rows); + return { count: result.count, - rows + rows: rowsWithRemainingDays }; } @@ -139,7 +145,7 @@ class PlaAccountService { async createAccount(data) { 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) { throw new Error('账户名、设备SN码、平台和登录名为必填项'); @@ -157,6 +163,9 @@ class PlaAccountService { ...otherData }; + // 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库) + delete processedData.remaining_days; + booleanFields.forEach(field => { if (processedData[field] !== undefined && processedData[field] !== null) { processedData[field] = processedData[field] ? 1 : 0; @@ -189,6 +198,10 @@ class PlaAccountService { // 将布尔字段从 true/false 转换为 0/1,确保数据库兼容性 const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active']; const processedData = { ...updateData }; + + // 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库) + delete processedData.remaining_days; + booleanFields.forEach(field => { if (processedData[field] !== undefined && processedData[field] !== null) { processedData[field] = processedData[field] ? 1 : 0; diff --git a/api/tests/account_utils.test.js b/api/tests/account_utils.test.js new file mode 100644 index 0000000..948c5ca --- /dev/null +++ b/api/tests/account_utils.test.js @@ -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 +}; diff --git a/api/tests/crypto_utils.test.js b/api/tests/crypto_utils.test.js new file mode 100644 index 0000000..143b199 --- /dev/null +++ b/api/tests/crypto_utils.test.js @@ -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 +}; diff --git a/api/utils/account_utils.js b/api/utils/account_utils.js new file mode 100644 index 0000000..42e4971 --- /dev/null +++ b/api/utils/account_utils.js @@ -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 +}; + diff --git a/api/utils/crypto_utils.js b/api/utils/crypto_utils.js new file mode 100644 index 0000000..2123e5b --- /dev/null +++ b/api/utils/crypto_utils.js @@ -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} 加密后的密码字符串 (格式: 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} 是否匹配 + */ +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} 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 +};