This commit is contained in:
张成
2025-11-24 13:23:42 +08:00
commit 5d7444cd65
156 changed files with 50653 additions and 0 deletions

416
api/services/ai_service.js Normal file
View File

@@ -0,0 +1,416 @@
/**
* AI智能服务
* 提供岗位筛选、简历分析、聊天生成等AI功能
*/
const axios = require('axios');
class AIService {
constructor(config = {}) {
this.apiKey = config.apiKey || process.env.AI_API_KEY || '';
this.baseURL = config.baseURL || process.env.AI_BASE_URL || 'https://api.deepseek.com';
this.model = config.model || 'deepseek-chat';
this.timeout = config.timeout || 30000;
// 创建axios实例
this.client = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
}
/**
* 调用AI接口
* @param {Array} messages - 消息数组
* @param {Object} options - 额外选项
* @returns {Promise<String>} AI响应内容
*/
async chat(messages, options = {}) {
try {
const response = await this.client.post('/v1/chat/completions', {
model: this.model,
messages,
temperature: options.temperature || 0.7,
max_tokens: options.max_tokens || 2000,
...options
});
return response.data.choices[0].message.content;
} catch (error) {
console.warn('AI服务调用失败:', error.message);
throw new Error(`AI服务调用失败: ${error.message}`);
}
}
/**
* 分析简历竞争力
* @param {Object} resumeData - 简历数据
* @returns {Promise<Object>} 分析结果
*/
async analyzeResume(resumeData) {
const prompt = `请分析以下简历的竞争力,并提供详细评估:
简历信息:
- 姓名: ${resumeData.fullName || '未知'}
- 工作年限: ${resumeData.workYears || '未知'}
- 教育背景: ${resumeData.education || '未知'}
- 期望职位: ${resumeData.expectedPosition || '未知'}
- 期望薪资: ${resumeData.expectedSalary || '未知'}
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
- 工作经历: ${resumeData.workExperience || '未提供'}
- 项目经历: ${resumeData.projectExperience || '未提供'}
请从以下维度进行评估1-100分
1. 技术能力
2. 项目经验
3. 教育背景
4. 工作年限匹配度
5. 综合竞争力
返回JSON格式
{
"overallScore": 总分(1-100),
"technicalScore": 技术能力分(1-100),
"projectScore": 项目经验分(1-100),
"educationScore": 教育背景分(1-100),
"experienceScore": 工作年限分(1-100),
"strengths": ["优势1", "优势2", "优势3"],
"weaknesses": ["不足1", "不足2"],
"suggestions": ["建议1", "建议2", "建议3"],
"keySkills": ["核心技能1", "核心技能2"],
"marketCompetitiveness": "市场竞争力描述"
}`;
const messages = [
{ role: 'system', content: '你是一个专业的HR和招聘顾问擅长分析简历和评估候选人竞争力。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.3 });
// 提取JSON部分
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('简历分析失败:', error);
// 返回默认值
return {
overallScore: 60,
technicalScore: 60,
projectScore: 60,
educationScore: 60,
experienceScore: 60,
strengths: ['待AI分析'],
weaknesses: ['待AI分析'],
suggestions: ['请稍后重试'],
keySkills: Array.isArray(resumeData.skills) ? resumeData.skills.slice(0, 5) : [],
marketCompetitiveness: '待AI分析'
};
}
}
/**
* 岗位匹配度评估
* @param {Object} jobData - 岗位数据
* @param {Object} resumeData - 简历数据
* @returns {Promise<Object>} 匹配结果
*/
async matchJobWithResume(jobData, resumeData) {
const prompt = `请评估以下岗位与简历的匹配度:
【岗位信息】
- 职位名称: ${jobData.jobTitle || '未知'}
- 公司名称: ${jobData.companyName || '未知'}
- 薪资范围: ${jobData.salary || '未知'}
- 工作地点: ${jobData.location || '未知'}
- 工作经验要求: ${jobData.experienceRequired || '未知'}
- 学历要求: ${jobData.educationRequired || '未知'}
- 岗位描述: ${jobData.jobDescription || '未提供'}
- 技能要求: ${jobData.skillsRequired || '未提供'}
【简历信息】
- 工作年限: ${resumeData.workYears || '未知'}
- 教育背景: ${resumeData.education || '未知'}
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
- 期望职位: ${resumeData.expectedPosition || '未知'}
- 期望薪资: ${resumeData.expectedSalary || '未知'}
请分析:
1. 技能匹配度
2. 经验匹配度
3. 薪资匹配度
4. 是否为外包岗位(根据公司名称、岗位描述判断)
5. 综合推荐度
返回JSON格式
{
"matchScore": 匹配度分数(1-100),
"skillMatch": 技能匹配度(1-100),
"experienceMatch": 经验匹配度(1-100),
"salaryMatch": 薪资匹配度(1-100),
"isOutsourcing": 是否外包(true/false),
"outsourcingConfidence": 外包判断置信度(0-1),
"recommendLevel": "推荐等级(excellent/good/medium/low)",
"matchReasons": ["匹配原因1", "匹配原因2"],
"concerns": ["顾虑点1", "顾虑点2"],
"applyAdvice": "投递建议"
}`;
const messages = [
{ role: 'system', content: '你是一个专业的职业规划师,擅长评估岗位与求职者的匹配度。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.3 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('岗位匹配分析失败:', error);
// 返回默认值
return {
matchScore: 50,
skillMatch: 50,
experienceMatch: 50,
salaryMatch: 50,
isOutsourcing: false,
outsourcingConfidence: 0,
recommendLevel: 'medium',
matchReasons: ['待AI分析'],
concerns: ['待AI分析'],
applyAdvice: '建议人工审核'
};
}
}
/**
* 批量评估岗位(用于智能筛选)
* @param {Array} jobs - 岗位列表
* @param {Object} resumeData - 简历数据
* @returns {Promise<Array>} 评估结果列表
*/
async batchMatchJobs(jobs, resumeData) {
const results = [];
// 限制并发数量避免API限流
const concurrency = 3;
for (let i = 0; i < jobs.length; i += concurrency) {
const batch = jobs.slice(i, i + concurrency);
const batchPromises = batch.map(job =>
this.matchJobWithResume(job, resumeData).catch(err => {
console.warn(`岗位${job.jobId}匹配失败:`, err.message);
return {
jobId: job.jobId,
matchScore: 0,
error: err.message
};
})
);
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
// 避免请求过快,休眠一下
if (i + concurrency < jobs.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
/**
* 生成聊天内容
* @param {Object} context - 聊天上下文
* @returns {Promise<String>} 生成的聊天内容
*/
async generateChatContent(context) {
const { jobInfo, resumeInfo, chatType = 'greeting', previousMessages = [] } = context;
let prompt = '';
switch (chatType) {
case 'greeting':
prompt = `作为求职者向HR发送第一条消息表达对以下岗位的兴趣
岗位: ${jobInfo.jobTitle}
公司: ${jobInfo.companyName}
要求: 简洁、专业、突出自己的优势不超过100字`;
break;
case 'follow_up':
prompt = `HR已查看简历但未回复需要发送一条礼貌的跟进消息
岗位: ${jobInfo.jobTitle}
要求: 礼貌、不唐突、展现持续兴趣不超过80字`;
break;
case 'interview_confirm':
prompt = `HR发出面试邀约需要确认并表达感谢
岗位: ${jobInfo.jobTitle}
面试时间: ${context.interviewTime || '待定'}
要求: 专业、感谢、确认参加不超过60字`;
break;
case 'reply':
prompt = `HR说: "${context.hrMessage}"
请作为求职者回复,要求: 自然、专业、回答问题不超过100字`;
break;
default:
prompt = `生成一条针对${jobInfo.jobTitle}岗位的沟通消息`;
}
const messages = [
{ role: 'system', content: '你是一个求职者需要与HR进行专业、礼貌的沟通。回复要简洁、真诚、突出优势。' },
...previousMessages.map(msg => ({
role: msg.role,
content: msg.content
})),
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.8, max_tokens: 200 });
return response.trim();
} catch (error) {
console.warn('生成聊天内容失败:', error);
// 返回默认模板
switch (chatType) {
case 'greeting':
return `您好,我对贵司的${jobInfo.jobTitle}岗位非常感兴趣,我有相关工作经验,期待能有机会详细沟通。`;
case 'follow_up':
return `您好,不知道您对我的简历是否感兴趣?期待您的回复。`;
case 'interview_confirm':
return `好的,感谢您的面试邀约,我会准时参加。`;
default:
return `您好,期待与您沟通。`;
}
}
}
/**
* 判断是否为面试邀约
* @param {String} message - HR消息内容
* @returns {Promise<Object>} 判断结果
*/
async detectInterviewInvitation(message) {
const prompt = `判断以下HR消息是否为面试邀约并提取关键信息
消息内容: "${message}"
返回JSON格式
{
"isInterview": 是否为面试邀约(true/false),
"confidence": 置信度(0-1),
"interviewType": "面试类型(phone/video/onsite/unknown)",
"interviewTime": "面试时间(如果提到)",
"interviewLocation": "面试地点(如果提到)",
"needReply": 是否需要回复确认(true/false)
}`;
const messages = [
{ role: 'system', content: '你是一个文本分析专家,擅长识别面试邀约信息。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.1 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('面试邀约判断失败:', error);
// 简单的关键词判断作为降级方案
const keywords = ['面试', '邀请', '来面', '约您', '见面', '电话沟通', '视频面试'];
const isInterview = keywords.some(kw => message.includes(kw));
return {
isInterview,
confidence: isInterview ? 0.7 : 0.3,
interviewType: 'unknown',
interviewTime: null,
interviewLocation: null,
needReply: isInterview
};
}
}
/**
* 分析HR反馈情感
* @param {String} message - HR消息内容
* @returns {Promise<Object>} 情感分析结果
*/
async analyzeSentiment(message) {
const prompt = `分析以下HR消息的情感倾向
消息: "${message}"
返回JSON格式
{
"sentiment": "情感倾向(positive/neutral/negative)",
"interest": "兴趣程度(high/medium/low)",
"urgency": "紧急程度(high/medium/low)",
"keywords": ["关键词1", "关键词2"]
}`;
const messages = [
{ role: 'system', content: '你是一个情感分析专家。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.1 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('情感分析失败:', error);
return {
sentiment: 'neutral',
interest: 'medium',
urgency: 'low',
keywords: []
};
}
}
}
// 导出单例
let instance = null;
module.exports = {
/**
* 获取AI服务实例
* @param {Object} config - 配置选项
* @returns {AIService}
*/
getInstance(config) {
if (!instance) {
instance = new AIService(config);
}
return instance;
},
/**
* 创建新的AI服务实例
* @param {Object} config - 配置选项
* @returns {AIService}
*/
createInstance(config) {
return new AIService(config);
}
};

118
api/services/index.js Normal file
View File

@@ -0,0 +1,118 @@
/**
* 服务管理器
* 统一管理所有服务实例
*
* 注意:此文件主要用于统一导出服务,实际调度系统使用 middleware/schedule/
*/
const AIService = require('./ai_service');
const PlaAccountService = require('./pla_account_service');
// TaskScheduler 已废弃,实际使用 middleware/schedule/ 中的调度系统
// MQTTHandler 文件不存在,实际使用 middleware/mqtt/mqttManager.js
// JobService 已合并到 middleware/job/jobManager.js1
class ServiceManager {
constructor(config = {}) {
this.config = config;
this.services = {};
this.initialized = false;
this.logger = config.logger || console;
}
/**
* 初始化所有服务
*/
async initialize() {
if (this.initialized) {
return;
}
this.logger.info('初始化服务管理器...');
try {
// 初始化AI服务
if (this.config.ai?.enabled !== false) {
this.services.ai = AIService.getInstance(this.config.ai);
this.logger.info('✓ AI服务已初始化');
}
// 注意:任务调度器实际使用 middleware/schedule/ 中的 ScheduleManager
// 注意MQTT处理器实际使用 middleware/mqtt/mqttManager.js
this.initialized = true;
this.logger.info('服务管理器初始化完成');
} catch (error) {
this.logger.error('服务管理器初始化失败:', error);
throw error;
}
}
/**
* 获取AI服务
* @returns {AIService}
*/
getAI() {
return this.services.ai;
}
/**
* 检查服务健康状态
* @returns {Object}
*/
getHealthStatus() {
return {
initialized: this.initialized,
services: {
ai: !!this.services.ai
}
};
}
/**
* 关闭所有服务
*/
async shutdown() {
this.logger.info('关闭服务管理器...');
try {
this.initialized = false;
this.logger.info('服务管理器已关闭');
} catch (error) {
this.logger.error('关闭服务管理器失败:', error);
throw error;
}
}
}
// 导出单例
let instance = null;
module.exports = {
/**
* 获取服务管理器实例
* @param {Object} config - 配置选项
* @returns {ServiceManager}
*/
getInstance(config) {
if (!instance) {
instance = new ServiceManager(config);
}
return instance;
},
/**
* 创建新的服务管理器实例
* @param {Object} config - 配置选项
* @returns {ServiceManager}
*/
createInstance(config) {
return new ServiceManager(config);
},
// 导出各个服务类
AIService,
PlaAccountService
// TaskScheduler 已废弃
// MQTTHandler 文件不存在
// JobService 已合并到 middleware/job/jobManager.js
};

View File

@@ -0,0 +1,161 @@
const axios = require('axios');
const config = require('../../config/config');
/**
* 地理位置服务
*/
class LocationService {
/**
* 根据经纬度获取地址信息
* @param {number} latitude 纬度
* @param {number} longitude 经度
* @returns {Promise<Object>} 地址信息
*/
async getAddressByLocation(latitude, longitude) {
try {
const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${latitude},${longitude}&key=${config.qq_map_key}`;
const response = await axios.get(url);
if (response.data.status !== 0) {
throw new Error('地理位置解析失败, ' + response.data.message);
}
let addressComponent = response.data.result.address_component
let formatAddress = this.formatAddressInfo(addressComponent)
return formatAddress;
} catch (error) {
throw error;
}
}
/**
* 根据地址获取经纬度
* @param {string} address 地址
* @returns {Promise<Object>} 经纬度信息
*/
async getLocationByAddress(address) {
const url = `https://apis.map.qq.com/ws/geocoder/v1/?address=${encodeURIComponent(address)}&key=${config.qq_map_key}`;
const response = await axios.get(url);
if (response.data.status !== 0) {
throw new Error('地址解析失败, ' + response.data.message);
}
let addressComponent = response.data.result.location
return addressComponent;
}
/**
* 计算两点之间的距离(公里)
* @param {number} lat1 第一个点的纬度
* @param {number} lng1 第一个点的经度
* @param {number} lat2 第二个点的纬度
* @param {number} lng2 第二个点的经度
* @returns {number} 距离(公里)
*/
calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371; // 地球半径(公里)
const dLat = this.toRadians(lat2 - lat1);
const dLng = this.toRadians(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return Math.round(distance * 100) / 100; // 保留两位小数
}
/**
* 角度转弧度
* @param {number} degrees 角度
* @returns {number} 弧度
*/
toRadians(degrees) {
return degrees * (Math.PI / 180);
}
/**
* 格式化地址信息
* @param {Object} addressComponent 地址组件
* @returns {Object} 格式化后的地址信息
*/
formatAddressInfo(addressComponent) {
// 处理省份名称,去掉"省"、"市"、"自治区"等后缀
let province = addressComponent.province || '';
province = province.replace(/省|市|自治区|特别行政区/g, '');
// 处理城市名称,去掉"市"后缀
let city = addressComponent.city || '';
city = city.replace(/市/g, '');
// 处理区县名称
let district = addressComponent.district || '';
// 检查是否为直辖市(北京、上海、天津、重庆)
const municipalities = ['北京', '上海', '天津', '重庆'];
const isMunicipality = municipalities.includes(province) ||
municipalities.some(m => addressComponent.province && addressComponent.province.includes(m));
// 如果是直辖市,调整省份和城市字段
if (isMunicipality) {
// 直辖市province 保持为直辖市名称city 设置为区名
const municipalityName = municipalities.find(m =>
province.includes(m) || (addressComponent.province && addressComponent.province.includes(m))
) || province;
province = municipalityName;
// 特殊处理:浦东新区、滨海新区等特殊区名,保持完整名称
if (district.includes('新区') || district.includes('开发区') || district.includes('高新区')) {
city = district; // 保持完整的区名作为城市(不去掉后缀)
district = ''; // 清空区字段
} else {
// 普通区名去掉后缀后作为城市
city = district.replace(/区|县/g, '');
district = ''; // 清空区字段
}
} else {
// 非直辖市,按正常逻辑处理
district = district.replace(/区|县/g, '');
}
// 构建简化的地址格式:城市 区县
let formatted_address = '';
if (city && district) {
formatted_address = `${city} ${district}`;
} else if (city) {
formatted_address = city;
} else if (district) {
formatted_address = district;
} else if (province) {
formatted_address = province;
}
return {
province: province,
city: city,
district: district,
street: addressComponent.street || '',
street_number: addressComponent.street_number || '',
formatted_address: formatted_address
};
}
}
module.exports = new LocationService();

View File

@@ -0,0 +1,332 @@
const OSS = require('ali-oss')
const fs = require('fs')
const config = require('../../config/config.js');
const uuid = require('node-uuid')
const logs = require('../middleware/logProxy')
/**
* OSS 文件上传服务
* 统一管理文件上传、存储路径、文件类型等
*/
class OSSToolService {
constructor() {
const { accessKeyId, accessKeySecret, bucket, region } = config.oos;
this.client = new OSS({
region,
accessKeyId,
accessKeySecret,
bucket,
})
// 基础存储路径前缀
this.basePrefix = 'front/work'
// 文件类型映射
this.fileTypeMap = {
// 图片类型
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
// 视频类型
'video/mp4': 'mp4',
'video/avi': 'avi',
'video/mov': 'mov',
'video/wmv': 'wmv',
'video/flv': 'flv',
'video/webm': 'webm',
'video/mkv': 'mkv',
// 音频类型
'audio/mp3': 'mp3',
'audio/wav': 'wav',
'audio/aac': 'aac',
'audio/ogg': 'ogg',
'audio/flac': 'flac',
// 文档类型
'application/pdf': 'pdf',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'text/plain': 'txt',
'text/html': 'html',
'text/css': 'css',
'application/javascript': 'js',
'application/json': 'json'
}
}
/**
* 获取文件后缀名
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @returns {string} 文件后缀名
*/
getFileSuffix(file) {
// 优先使用 MIME 类型判断(兼容 type 和 mimetype
const mimeType = file.mimetype || file.type
if (mimeType && this.fileTypeMap[mimeType]) {
return this.fileTypeMap[mimeType]
}
// 备用方案:从文件名获取(兼容 originalFilename 和 name
const fileName = file.originalFilename || file.name
if (fileName) {
const lastIndex = fileName.lastIndexOf('.')
if (lastIndex > -1) {
return fileName.substring(lastIndex + 1).toLowerCase()
}
}
return 'bin'
}
/**
* 获取文件存储路径
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @param {string} category - 存储分类
* @returns {string} 完整的存储路径
*/
getStoragePath(file, category = 'files') {
const suffix = this.getFileSuffix(file)
const uid = uuid.v4()
// 根据文件类型确定子路径(兼容 mimetype 和 type
let subPath = category
const mimeType = file.mimetype || file.type
if (mimeType) {
if (mimeType.startsWith('image/')) {
subPath = 'images'
} else if (mimeType.startsWith('video/')) {
subPath = 'videos'
} else if (mimeType.startsWith('audio/')) {
subPath = 'audios'
} else if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) {
subPath = 'documents'
}
}
// 完整路径front/ball/{subPath}/{uid}.{suffix}
return `${this.basePrefix}/${subPath}/${uid}.${suffix}`
}
/**
* 核心文件上传方法
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @param {string} category - 存储分类
* @returns {Object} 上传结果
*/
async uploadFile(file, category = 'files') {
try {
// 兼容不同的文件对象格式filepath 或 path
const filePath = file.filepath || file.path
// 验证文件
if (!file || !filePath) {
return { success: false, error: '无效的文件对象' }
}
const stream = fs.createReadStream(filePath)
const storagePath = this.getStoragePath(file, category)
const suffix = this.getFileSuffix(file)
// 设置 content-type兼容 mimetype 和 type
const contentType = file.mimetype || file.type || 'application/octet-stream'
// 上传到 OSS
const result = await this.client.put(storagePath, stream, {
headers: {
'content-disposition': 'inline',
"content-type": contentType
}
})
if (result.res.status === 200) {
const ossPath = config.ossUrl + '/' + result.name
// 上传成功后删除临时文件
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch (unlinkError) {
logs.error('删除临时文件失败:', unlinkError)
}
return {
success: true,
name: result.name,
path: result.url,
ossPath,
fileType: file.mimetype || file.type,
fileSize: file.size,
originalName: file.originalFilename || file.name,
suffix: suffix,
storagePath: storagePath
}
} else {
return { success: false, error: 'OSS 上传失败' }
}
} catch (error) {
logs.error('文件上传错误:', error)
// 上传失败也要清理临时文件
try {
const filePath = file.filepath || file.path
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch (unlinkError) {
logs.error('删除临时文件失败:', unlinkError)
}
return { success: false, error: error.message }
}
}
/**
* 上传流数据
* @param {Stream} stream - 文件流
* @param {string} contentType - 内容类型
* @param {string} suffix - 文件后缀
* @returns {Object} 上传结果
*/
async uploadStream(stream, contentType, suffix) {
try {
const uid = uuid.v4()
const storagePath = `${this.basePrefix}/files/${uid}.${suffix}`
const result = await this.client.put(storagePath, stream, {
headers: {
'content-disposition': 'inline',
"content-type": contentType
}
})
if (result.res.status === 200) {
const ossPath = config.ossUrl + result.name
return {
success: true,
name: result.name,
path: result.url,
ossPath,
storagePath: storagePath
}
} else {
return { success: false, error: 'OSS 上传失败' }
}
} catch (error) {
logs.error('流上传错误:', error)
return { success: false, error: error.message }
}
}
/**
* 删除文件
* @param {string} filePath - 文件路径
* @returns {Object} 删除结果
*/
async deleteFile(filePath) {
try {
if (!filePath) {
return { success: false, error: '文件路径不能为空' }
}
// 从完整 URL 中提取相对路径
const relativePath = filePath.replace(config.ossUrl + '/', '')
const result = await this.client.delete(relativePath)
if (result.res.status === 204) {
return { success: true, message: '文件删除成功' }
} else {
return { success: false, error: '文件删除失败' }
}
} catch (error) {
logs.error('文件删除错误:', error)
return { success: false, error: error.message }
}
}
/**
* 获取文件信息
* @param {string} filePath - 文件路径
* @returns {Object} 文件信息
*/
async getFileInfo(filePath) {
try {
if (!filePath) {
return { success: false, error: '文件路径不能为空' }
}
const relativePath = filePath.replace(config.ossUrl + '/', '')
const result = await this.client.head(relativePath)
return {
success: true,
size: result.res.headers['content-length'],
type: result.res.headers['content-type'],
lastModified: result.res.headers['last-modified'],
etag: result.res.headers['etag']
}
} catch (error) {
logs.error('获取文件信息错误:', error)
return { success: false, error: error.message }
}
}
// ==================== 便捷方法 ====================
/**
* 上传图片文件(保持向后兼容)
* @param {Object} file - 图片文件
* @returns {Object} 上传结果
*/
async putImg(file) {
return await this.uploadFile(file, 'images')
}
/**
* 上传视频文件
* @param {Object} file - 视频文件
* @returns {Object} 上传结果
*/
async uploadVideo(file) {
return await this.uploadFile(file, 'videos')
}
/**
* 上传音频文件
* @param {Object} file - 音频文件
* @returns {Object} 上传结果
*/
async uploadAudio(file) {
return await this.uploadFile(file, 'audios')
}
/**
* 上传文档文件
* @param {Object} file - 文档文件
* @returns {Object} 上传结果
*/
async uploadDocument(file) {
return await this.uploadFile(file, 'documents')
}
}
// 创建单例实例
const ossToolService = new OSSToolService()
// 导出实例(保持向后兼容)
module.exports = ossToolService

View File

@@ -0,0 +1,699 @@
/**
* 平台账号服务
* 处理平台账号相关的业务逻辑
*/
const db = require('../middleware/dbProxy');
const scheduleManager = require('../middleware/schedule/index.js');
const locationService = require('./locationService');
class PlaAccountService {
/**
* 根据ID获取账号信息
* @param {number} id - 账号ID
* @returns {Promise<Object>} 账号信息
*/
async getAccountById(id) {
const pla_account = db.getModel('pla_account');
const device_status = db.getModel('device_status');
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
const accountData = account.get({ plain: true });
// 从 device_status 查询在线状态和登录状态
const deviceStatus = await device_status.findByPk(account.sn_code);
if (deviceStatus) {
accountData.is_online = deviceStatus.isOnline || false;
accountData.is_logged_in = deviceStatus.isLoggedIn || false;
} else {
accountData.is_online = false;
accountData.is_logged_in = false;
}
return accountData;
}
/**
* 获取账号列表
* @param {Object} params - 查询参数
* @returns {Promise<Object>} 账号列表
*/
async getAccountList(params) {
const pla_account = db.getModel('pla_account');
const device_status = db.getModel('device_status');
const op = db.getModel('op');
const { key, value, platform_type, is_online, limit, offset } = params;
const where = { is_delete: 0 };
// 搜索条件
if (key && value) {
where[key] = { [op.like]: `%${value}%` };
}
// 平台筛选
if (platform_type) {
where.platform_type = platform_type;
}
// 如果按在线状态筛选,需要先查询在线设备的 sn_code
let onlineSnCodes = null;
if (is_online !== undefined && is_online !== null) {
const onlineDevices = await device_status.findAll({
where: { isOnline: is_online },
attributes: ['sn_code']
});
onlineSnCodes = onlineDevices.map(device => device.sn_code);
// 如果筛选在线但没有任何在线设备,直接返回空结果
if (is_online && onlineSnCodes.length === 0) {
return {
count: 0,
rows: []
};
}
// 如果筛选离线,需要在 where 中排除在线设备的 sn_code
if (!is_online && onlineSnCodes.length > 0) {
where.sn_code = { [op.notIn]: onlineSnCodes };
}
}
const result = await pla_account.findAndCountAll({
where,
limit,
offset,
order: [['id', 'DESC']]
});
// 批量查询所有账号对应的设备状态
const snCodes = result.rows.map(account => account.sn_code);
const deviceStatuses = await device_status.findAll({
where: { sn_code: { [op.in]: snCodes } },
attributes: ['sn_code', 'isOnline']
});
// 创建 sn_code 到 isOnline 的映射
const statusMap = {};
deviceStatuses.forEach(status => {
statusMap[status.sn_code] = status.isOnline;
});
// 处理返回数据,添加 is_online 字段
const rows = result.rows.map(account => {
const accountData = account.get({ plain: true });
accountData.is_online = statusMap[account.sn_code] || false;
return accountData;
});
return {
count: result.count,
rows
};
}
/**
* 创建账号
* @param {Object} data - 账号数据
* @returns {Promise<Object>} 创建的账号
*/
async createAccount(data) {
const pla_account = db.getModel('pla_account');
const { name, sn_code, platform_type, login_name, pwd, keyword, ...otherData } = data;
if (!name || !sn_code || !platform_type || !login_name) {
throw new Error('账户名、设备SN码、平台和登录名为必填项');
}
// 将布尔字段从 true/false 转换为 0/1
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
const processedData = {
name,
sn_code,
platform_type,
login_name,
pwd: pwd || '',
keyword: keyword || '',
...otherData
};
booleanFields.forEach(field => {
if (processedData[field] !== undefined && processedData[field] !== null) {
processedData[field] = processedData[field] ? 1 : 0;
}
});
const account = await pla_account.create(processedData);
return account;
}
/**
* 更新账号信息
* @param {number} id - 账号ID
* @param {Object} updateData - 更新的数据
* @returns {Promise<void>}
*/
async updateAccount(id, updateData) {
const pla_account = db.getModel('pla_account');
if (!id) {
throw new Error('账号ID不能为空');
}
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 将布尔字段从 true/false 转换为 0/1确保数据库兼容性
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
const processedData = { ...updateData };
booleanFields.forEach(field => {
if (processedData[field] !== undefined && processedData[field] !== null) {
processedData[field] = processedData[field] ? 1 : 0;
}
});
await pla_account.update(processedData, { where: { id } });
}
/**
* 删除账号(软删除)
* @param {number} id - 账号ID
* @returns {Promise<void>}
*/
async deleteAccount(id) {
const pla_account = db.getModel('pla_account');
if (!id) {
throw new Error('账号ID不能为空');
}
// 软删除
const result = await pla_account.update(
{ is_delete: 1 },
{ where: { id } }
);
if (result[0] === 0) {
throw new Error('账号不存在');
}
}
/**
* 获取账号的任务列表
* @param {Object} params - 查询参数
* @returns {Promise<Object>} 任务列表
*/
async getAccountTasks(params) {
const pla_account = db.getModel('pla_account');
const task_status = db.getModel('task_status');
const { id, limit, offset } = params;
if (!id) {
throw new Error('账号ID不能为空');
}
// 先获取账号信息
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 通过 sn_code 查询任务列表
const result = await task_status.findAndCountAll({
where: {
sn_code: account.sn_code
},
limit,
offset,
order: [['id', 'DESC']]
});
return {
count: result.count,
rows: result.rows
};
}
/**
* 获取账号的指令列表
* @param {Object} params - 查询参数
* @returns {Promise<Object>} 指令列表
*/
async getAccountCommands(params) {
const pla_account = db.getModel('pla_account');
const task_commands = db.getModel('task_commands');
const Sequelize = require('sequelize');
const { id, limit, offset } = params;
if (!id) {
throw new Error('账号ID不能为空');
}
// 先获取账号信息
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 获取 sequelize 实例
const sequelize = task_commands.sequelize;
// 使用原生 SQL JOIN 查询
const countSql = `
SELECT COUNT(DISTINCT tc.id) as count
FROM task_commands tc
INNER JOIN task_status ts ON tc.task_id = ts.id
WHERE ts.sn_code = :sn_code
`;
const dataSql = `
SELECT tc.*
FROM task_commands tc
INNER JOIN task_status ts ON tc.task_id = ts.id
WHERE ts.sn_code = :sn_code
ORDER BY tc.id DESC
LIMIT :limit OFFSET :offset
`;
// 并行执行查询和计数
const [countResult, dataResult] = await Promise.all([
sequelize.query(countSql, {
replacements: { sn_code: account.sn_code },
type: Sequelize.QueryTypes.SELECT
}),
sequelize.query(dataSql, {
replacements: {
sn_code: account.sn_code,
limit: limit,
offset: offset
},
type: Sequelize.QueryTypes.SELECT
})
]);
const count = countResult[0]?.count || 0;
// 将原始数据转换为 Sequelize 模型实例
const rows = dataResult.map(row => {
return task_commands.build(row, { isNewRecord: false });
});
return {
count: parseInt(count),
rows: rows
};
}
/**
* 执行账号指令
* @param {Object} params - 指令参数
* @returns {Promise<Object>} 执行结果
*/
async runCommand(params) {
const pla_account = db.getModel('pla_account');
const device_status = db.getModel('device_status');
const task_status = db.getModel('task_status');
const { id, commandType, commandName, commandParams } = params;
if (!id || !commandType) {
throw new Error('账号ID和指令类型不能为空');
}
// 获取账号信息
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 从 device_status 检查账号是否在线
const deviceStatus = await device_status.findByPk(account.sn_code);
if (!deviceStatus || !deviceStatus.isOnline) {
throw new Error('账号不在线,无法执行指令');
}
// 获取调度管理器并执行指令
if (!scheduleManager.mqttClient) {
throw new Error('MQTT客户端未初始化');
}
// 将驼峰格式转换为下划线格式用于jobManager方法调用
const toSnakeCase = (str) => {
// 如果已经是下划线格式,直接返回
if (str.includes('_')) {
return str;
}
// 驼峰转下划线
return str.replace(/([A-Z])/g, '_$1').toLowerCase();
};
// 直接使用commandType转换为下划线格式作为command_type
const commandTypeSnake = toSnakeCase(commandType);
// 构建基础参数
const baseParams = {
sn_code: account.sn_code,
platform: account.platform_type
};
// 合并参数commandParams优先
const finalParams = commandParams
? { ...baseParams, ...commandParams }
: baseParams;
// 如果有关键词相关的操作,添加关键词
if (['search_jobs', 'get_job_list'].includes(commandTypeSnake) && account.keyword) {
finalParams.keyword = account.keyword;
}
// 构建指令对象
const command = {
command_type: commandTypeSnake,
command_name: commandName || commandType,
command_params: JSON.stringify(finalParams)
};
// 创建任务记录
const task = await task_status.create({
sn_code: account.sn_code,
taskType: commandTypeSnake,
taskName: commandName || commandType,
taskParams: JSON.stringify(finalParams)
});
// 直接执行指令
const result = await scheduleManager.command.executeCommand(task.id, command, scheduleManager.mqttClient);
return result;
}
/**
* 获取指令详情
* @param {Object} params - 查询参数
* @returns {Promise<Object>} 指令详情
*/
async getCommandDetail(params) {
const task_commands = db.getModel('task_commands');
const { accountId, commandId } = params;
if (!accountId || !commandId) {
throw new Error('账号ID和指令ID不能为空');
}
// 查询指令详情
const command = await task_commands.findByPk(commandId);
if (!command) {
throw new Error('指令不存在');
}
// 解析JSON字段
const commandDetail = command.toJSON();
try {
if (commandDetail.command_params) {
commandDetail.command_params = JSON.parse(commandDetail.command_params);
}
if (commandDetail.result) {
commandDetail.result = JSON.parse(commandDetail.result);
}
} catch (error) {
console.warn('解析指令详情失败:', error);
}
return commandDetail;
}
/**
* 执行账号任务(旧接口兼容)
* @param {Object} params - 任务参数
* @returns {Promise<Object>} 执行结果
*/
async runTask(params) {
const pla_account = db.getModel('pla_account');
const device_status = db.getModel('device_status');
const { id, taskType, taskName } = params;
if (!id || !taskType) {
throw new Error('账号ID和指令类型不能为空');
}
// 获取账号信息
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 检查账号是否启用
if (!account.is_enabled) {
throw new Error('账号未启用,无法执行指令');
}
// 从 device_status 检查账号是否在线
const deviceStatus = await device_status.findByPk(account.sn_code);
if (!deviceStatus || !deviceStatus.isOnline) {
throw new Error('账号不在线,无法执行指令');
}
// 获取调度管理器并执行指令
if (!scheduleManager.mqttClient) {
throw new Error('MQTT客户端未初始化');
}
await scheduleManager.taskQueue.addTask(account.sn_code, {
taskType: taskType,
taskName: `手动任务 - ${taskName}`,
taskParams: {
keyword: account.keyword,
platform: account.platform_type
}
});
return {
message: '任务已添加到队列',
taskId: task.id
};
}
/**
* 停止账号的所有任务
* @param {Object} params - 参数对象
* @param {number} params.id - 账号ID
* @param {string} params.sn_code - 设备SN码
* @returns {Promise<Object>} 停止结果
*/
async stopTasks(params) {
const { id, sn_code } = params;
if (!id && !sn_code) {
throw new Error('账号ID或设备SN码不能为空');
}
const pla_account = db.getModel('pla_account');
const task_status = db.getModel('task_status');
const scheduleManager = require('../middleware/schedule/index.js');
// 获取账号信息
let account;
if (id) {
account = await pla_account.findByPk(id);
} else if (sn_code) {
account = await pla_account.findOne({ where: { sn_code } });
}
if (!account) {
throw new Error('账号不存在');
}
const deviceSnCode = account.sn_code;
// 1. 从任务队列中取消该设备的所有待执行任务
const taskQueue = scheduleManager.taskQueue;
let cancelledCount = 0;
if (taskQueue && typeof taskQueue.cancelDeviceTasks === 'function') {
cancelledCount = await taskQueue.cancelDeviceTasks(deviceSnCode);
} else {
// 如果没有 cancelDeviceTasks 方法,手动取消
const tasks = await task_status.findAll({
where: {
sn_code: deviceSnCode,
status: ['pending', 'running']
}
});
for (const task of tasks) {
try {
if (taskQueue && typeof taskQueue.cancelTask === 'function') {
await taskQueue.cancelTask(task.id);
cancelledCount++;
} else {
// 直接更新数据库
await task_status.update(
{
status: 'cancelled',
endTime: new Date()
},
{ where: { id: task.id } }
);
cancelledCount++;
}
} catch (error) {
console.error(`[停止任务] 取消任务 ${task.id} 失败:`, error);
}
}
}
return {
success: true,
message: `已停止 ${cancelledCount} 个任务`,
cancelledCount: cancelledCount,
sn_code: deviceSnCode
};
}
/**
* 解析地址并更新经纬度
* @param {Object} params - 参数对象
* @param {number} params.id - 账号ID
* @param {string} params.address - 地址(可选,如果不提供则使用账号中的地址)
* @returns {Promise<Object>} 解析结果
*/
async parseLocation(params) {
const { id, address } = params;
if (!id) {
throw new Error('账号ID不能为空');
}
const pla_account = db.getModel('pla_account');
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 如果提供了地址参数,使用参数中的地址;否则使用账号中的地址
const addressToParse = address || account.user_address;
if (!addressToParse || addressToParse.trim() === '') {
throw new Error('地址不能为空,请先设置用户地址');
}
try {
// 调用位置服务解析地址
const location = await locationService.getLocationByAddress(addressToParse);
if (!location || !location.lat || !location.lng) {
throw new Error('地址解析失败,未获取到经纬度信息');
}
// 更新账号的地址和经纬度
await account.update({
user_address: addressToParse,
user_longitude: String(location.lng),
user_latitude: String(location.lat)
});
return {
success: true,
message: '地址解析成功',
data: {
address: addressToParse,
longitude: location.lng,
latitude: location.lat
}
};
} catch (error) {
console.error('[账号管理] 地址解析失败:', error);
throw new Error('地址解析失败:' + (error.message || '请检查地址是否正确'));
}
}
/**
* 批量解析地址并更新经纬度
* @param {Array<number>} ids - 账号ID数组
* @returns {Promise<Object>} 批量解析结果
*/
async batchParseLocation(ids) {
if (!ids || !Array.isArray(ids) || ids.length === 0) {
throw new Error('账号ID列表不能为空');
}
const pla_account = db.getModel('pla_account');
const op = db.getModel('op');
const accounts = await pla_account.findAll({
where: {
id: { [op.in]: ids }
}
});
if (accounts.length === 0) {
throw new Error('未找到指定的账号');
}
const results = {
success: 0,
failed: 0,
details: []
};
// 逐个解析地址
for (const account of accounts) {
try {
if (!account.user_address || account.user_address.trim() === '') {
results.details.push({
id: account.id,
name: account.name,
success: false,
message: '地址为空,跳过解析'
});
results.failed++;
continue;
}
const result = await this.parseLocation({
id: account.id,
address: account.user_address
});
results.details.push({
id: account.id,
name: account.name,
success: true,
message: '解析成功',
data: result.data
});
results.success++;
} catch (error) {
results.details.push({
id: account.id,
name: account.name,
success: false,
message: error.message || '解析失败'
});
results.failed++;
}
}
return results;
}
}
// 导出单例
module.exports = new PlaAccountService();