1
This commit is contained in:
416
api/services/ai_service.js
Normal file
416
api/services/ai_service.js
Normal 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
118
api/services/index.js
Normal 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
|
||||
};
|
||||
161
api/services/locationService.js
Normal file
161
api/services/locationService.js
Normal 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();
|
||||
332
api/services/oss_tool_service.js
Normal file
332
api/services/oss_tool_service.js
Normal 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
|
||||
|
||||
699
api/services/pla_account_service.js
Normal file
699
api/services/pla_account_service.js
Normal 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();
|
||||
Reference in New Issue
Block a user