客流体验环境指标弹窗

parent 44f637ff
......@@ -33,6 +33,7 @@ import {
assessLightEnvironment, assessSoundEnvironment,
calcComplianceRate, calcOverallComfort,
} from "./customsIndicators";
import { dasb } from "tencentcloud-sdk-nodejs";
const Op = Sequelize.Op;
......@@ -49,16 +50,22 @@ function formatLocalDateTime(d: Date): string {
}
/**
* 从 IEQ 传感器获取最新的环境数据
* 使用子查询绕过 ONLY_FULL_GROUP_BY 限制
* 【性能优化】客流体验分析公共数据加载
* 原先 7 个 helper 各自重复执行:IEQ 设备表查询 ×6、近24h device_data 全量 ×4、
* "每设备最新数据"子查询 ×2、region 名称查询 ×1,且全部串行。
* 现收敛为:一次设备表查询 → 3 个互不依赖的查询并行(region 名称 / 近24h记录 / 每设备最新)。
* 返回 null 表示当前没有 IEQ 传感器设备(调用方按空数据展示)。
*/
async function getLatestIEQData(): Promise<{
temperature: number; humidity: number;
co2: number; pm25: number; tvoc: number;
a31001: number; lightLevel: number; lightUniformity: number; a14263: number;
pm10?: number; pressure?: number;
}> {
async function fetchCustomsBaseData(): Promise<{
ieqDevices: any[];
records24h: any[];
latestData: any[];
regionNameMap: Record<string, string>;
regionAvailable: boolean;
} | null> {
const deviceModel = mysqlModelMap['device'];
const dataModel = mysqlModelMap['device_data'];
const regionModel = mysqlModelMap['region'];
if (!deviceModel) {
throw new Error('device 表模型未初始化');
}
......@@ -70,33 +77,68 @@ async function getLatestIEQData(): Promise<{
raw: true,
}) as any[];
if (!ieqDevices.length) {
return {
temperature: 0, humidity: 0, co2: 0, pm25: 0,
tvoc: 0, a31001: 0, lightLevel: 0, lightUniformity: 0, a14263: 0,
};
}
if (!ieqDevices.length) return null;
const deviceIds = ieqDevices.map(d => d.device_id);
const deviceIds = ieqDevices.map((d: any) => d.device_id);
const regionKeys = [...new Set(ieqDevices.map((d: any) => d.region_key).filter(Boolean))];
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// 使用子查询获取每台设备的最新数据,避免 ONLY_FULL_GROUP_BY 报错
const placeholders = deviceIds.map(() => '?').join(',');
const sql = `
// 三个互不依赖的查询并行执行
const [regions, records24h, latestData] = await Promise.all([
// 1) 区域名称映射
(regionModel && regionKeys.length)
? regionModel.findAll({
where: { region_key: { [Op.in]: regionKeys } },
attributes: ['region_key', 'region_name'],
raw: true,
}) as any[]
: Promise.resolve([]),
// 2) 近 24h 全部 IEQ 明细(原 4 个 trend + maxNoise 各自全量查询的合并)
(dataModel
? dataModel.findAll({
attributes: ['device_data', 'device_time'],
where: {
device_id: { [Op.in]: deviceIds },
device_time: { [Op.gte]: yesterday },
},
order: [['device_time', 'ASC']],
raw: true,
})
: Promise.resolve([])) as any[],
// 3) 每台设备的最新数据(子查询绕过 ONLY_FULL_GROUP_BY;热湿/空气/光/声现状与区域数据共用)
mysqlDB.query(`
SELECT dd.device_id, dd.device_data
FROM device_data dd
INNER JOIN (
SELECT device_id, MAX(device_time) AS max_time
FROM device_data
WHERE device_id IN (${placeholders})
WHERE device_id IN (${deviceIds.map(() => '?').join(',')})
GROUP BY device_id
) latest ON dd.device_id = latest.device_id AND dd.device_time = latest.max_time
`;
const latestData = await mysqlDB.query(sql, {
`, {
replacements: deviceIds,
type: Sequelize.QueryTypes.SELECT,
}) as any[];
}) as any[],
]);
const regionNameMap: Record<string, string> = {};
for (const r of regions) {
regionNameMap[r.region_key] = r.region_name;
}
return { ieqDevices, records24h, latestData, regionNameMap, regionAvailable: !!regionModel };
}
// 汇总所有设备的最新数据
/** IEQ 汇总后的传感器数据(pm10/pressure 为可选,设备可能不上报) */
type SensorSummary = {
temperature: number; humidity: number; co2: number; pm25: number; tvoc: number;
a31001: number; lightLevel: number; lightUniformity: number; a14263: number;
pm10?: number; pressure?: number;
};
/** 汇总所有设备的最新数据(原 getLatestIEQData 聚合逻辑,行为不变) */
function summarizeLatestData(latestData: any[]): SensorSummary {
const sums: Record<string, number> = {};
const fieldCounts: Record<string, number> = {};
let minLight: number | null = null;
......@@ -154,35 +196,8 @@ async function getLatestIEQData(): Promise<{
};
}
/**
* 获取近 24 小时的 PMV 趋势数据(每小时一个点)
*/
async function getPMVTrend(): Promise<Array<{ key: string; value: string }>> {
const dataModel = mysqlModelMap['device_data'];
const deviceModel = mysqlModelMap['device'];
if (!dataModel || !deviceModel) return [];
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['device_id'],
raw: true,
}) as any[];
const deviceIds = ieqDevices.map(d => d.device_id);
if (!deviceIds.length) return [];
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const records = await dataModel.findAll({
attributes: ['device_data', 'device_time'],
where: {
device_id: { [Op.in]: deviceIds },
device_time: { [Op.gte]: yesterday },
},
order: [['device_time', 'ASC']],
raw: true,
}) as any[];
/** 近 24 小时 PMV 趋势(原 getPMVTrend 聚合逻辑,行为不变) */
function buildPMVTrend(records: any[]): Array<{ key: string; value: string }> {
// 按小时分组取平均
const hourlyMap: Record<string, { tempSum: number; humSum: number; count: number }> = {};
for (const r of records) {
......@@ -209,34 +224,8 @@ async function getPMVTrend(): Promise<Array<{ key: string; value: string }>> {
});
}
/**
* 获取近 24 小时的空气质量趋势
*/
async function getAirQualityTrend(): Promise<Array<{ key: string; co2: string; pm25: string }>> {
const dataModel = mysqlModelMap['device_data'];
const deviceModel = mysqlModelMap['device'];
if (!dataModel || !deviceModel) return [];
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['device_id'],
raw: true,
}) as any[];
const deviceIds = ieqDevices.map(d => d.device_id);
if (!deviceIds.length) return [];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const records = await dataModel.findAll({
attributes: ['device_data', 'device_time'],
where: {
device_id: { [Op.in]: deviceIds },
device_time: { [Op.gte]: yesterday },
},
order: [['device_time', 'ASC']],
raw: true,
}) as any[];
/** 近 24 小时空气质量趋势(原 getAirQualityTrend 聚合逻辑,行为不变) */
function buildAirQualityTrend(records: any[]): Array<{ key: string; co2: string; pm25: string }> {
const hourlyMap: Record<string, { co2Sum: number; pm25Sum: number; count: number }> = {};
for (const r of records) {
if (!r.device_time || !r.device_data) continue;
......@@ -260,55 +249,45 @@ async function getAirQualityTrend(): Promise<Array<{ key: string; co2: string; p
}));
}
/**
* 获取各区域的最新环境数据
*/
async function getRegionData(): Promise<Array<{
regionName: string; pmv: string; co2: string; lux: string; dB: string; zhssd: string; zb: string;
}>> {
const deviceModel = mysqlModelMap['device'];
const dataModel = mysqlModelMap['device_data'];
const regionModel = mysqlModelMap['region'];
if (!deviceModel || !dataModel || !regionModel) return [];
/** 近 24 小时某字段的每小时均值趋势(原 getFieldTrend 聚合逻辑,行为不变) */
function buildFieldTrend(records: any[], field: string): Array<{ key: string; value: string }> {
const hourlyMap: Record<string, { sum: number; count: number }> = {};
for (const r of records) {
if (!r.device_time || !r.device_data) continue;
const key = formatHourKey(new Date(r.device_time));
const val = r.device_data[field];
if (val === undefined || val === null || typeof val !== 'number') continue;
// 查询所有 IEQ 设备及其区域信息
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['device_id', 'region_key'],
raw: true,
}) as any[];
if (!hourlyMap[key]) hourlyMap[key] = { sum: 0, count: 0 };
hourlyMap[key].sum += val;
hourlyMap[key].count++;
}
if (!ieqDevices.length) return [];
return Object.entries(hourlyMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, val]) => ({
key,
value: (val.sum / val.count).toFixed(2),
}));
}
// 查询区域名称
const regionKeys = [...new Set(ieqDevices.map(d => d.region_key).filter(Boolean))];
const regions = await regionModel.findAll({
where: { region_key: { [Op.in]: regionKeys } },
attributes: ['region_key', 'region_name'],
raw: true,
}) as any[];
const regionNameMap: Record<string, string> = {};
for (const r of regions) {
regionNameMap[r.region_key] = r.region_name;
/** 近 24 小时某字段的最大值(原 getFieldMax24h 聚合逻辑,行为不变) */
function buildFieldMax24h(records: any[], field: string): number {
let maxVal = 0;
for (const r of records) {
if (!r.device_data) continue;
const val = r.device_data[field];
if (val !== undefined && val !== null && typeof val === 'number' && val > maxVal) {
maxVal = val;
}
}
return Math.round(maxVal * 100) / 100;
}
// 按区域分组查询最新数据(使用子查询避免 ONLY_FULL_GROUP_BY)
const deviceIds = ieqDevices.map(d => d.device_id);
const placeholders = deviceIds.map(() => '?').join(',');
const latestData = await mysqlDB.query(`
SELECT dd.device_id, dd.device_data
FROM device_data dd
INNER JOIN (
SELECT device_id, MAX(device_time) AS max_time
FROM device_data
WHERE device_id IN (${placeholders})
GROUP BY device_id
) latest ON dd.device_id = latest.device_id AND dd.device_time = latest.max_time
`, {
replacements: deviceIds,
type: Sequelize.QueryTypes.SELECT,
}) as any[];
/** 各区域最新环境数据聚合(原 getRegionData 聚合逻辑,行为不变) */
function buildRegionData(latestData: any[], ieqDevices: any[], regionNameMap: Record<string, string>): Array<{
regionName: string; pmv: string; co2: string; lux: string; dB: string; zhssd: string; zb: string;
}> {
// 按 region_key 汇总
const regionAgg: Record<string, { temp: number[]; hum: number[]; co2: number[]; lux: number[]; a14263: number[] }> = {};
const deviceRegionMap: Record<string, string> = {};
......@@ -362,103 +341,36 @@ async function getRegionData(): Promise<Array<{
}
/**
* 获取近 24 小时某字段的每小时均值趋势
*/
async function getFieldTrend(field: string): Promise<Array<{ key: string; value: string }>> {
const dataModel = mysqlModelMap['device_data'];
const deviceModel = mysqlModelMap['device'];
if (!dataModel || !deviceModel) return [];
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['device_id'],
raw: true,
}) as any[];
const deviceIds = ieqDevices.map(d => d.device_id);
if (!deviceIds.length) return [];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const records = await dataModel.findAll({
attributes: ['device_data', 'device_time'],
where: {
device_id: { [Op.in]: deviceIds },
device_time: { [Op.gte]: yesterday },
},
order: [['device_time', 'ASC']],
raw: true,
}) as any[];
const hourlyMap: Record<string, { sum: number; count: number }> = {};
for (const r of records) {
if (!r.device_time || !r.device_data) continue;
const key = formatHourKey(new Date(r.device_time));
const val = r.device_data[field];
if (val === undefined || val === null || typeof val !== 'number') continue;
if (!hourlyMap[key]) hourlyMap[key] = { sum: 0, count: 0 };
hourlyMap[key].sum += val;
hourlyMap[key].count++;
}
return Object.entries(hourlyMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, val]) => ({
key,
value: (val.sum / val.count).toFixed(2),
}));
}
/**
* 获取近 24 小时某字段的最大值
* 客流体验分析 - 返回大屏所需的所有真实指标数据
* 【性能优化】共享一次设备/明细/最新数据查询后全部改为内存聚合,DB 往返由 13 次串行降为 4 次(前 3 次并行)
*/
async function getFieldMax24h(field: string): Promise<number> {
const dataModel = mysqlModelMap['device_data'];
const deviceModel = mysqlModelMap['device'];
if (!dataModel || !deviceModel) return 0;
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['device_id'],
raw: true,
}) as any[];
const deviceIds = ieqDevices.map(d => d.device_id);
if (!deviceIds.length) return 0;
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
export async function getCustomsAnalysis() {
const base = await fetchCustomsBaseData();
const records = await dataModel.findAll({
attributes: ['device_data', 'device_time'],
where: {
device_id: { [Op.in]: deviceIds },
device_time: { [Op.gte]: yesterday },
},
order: [['device_time', 'ASC']],
raw: true,
}) as any[];
// IEQ 设备为空时的兜底(与改造前各 helper 空数据处理一致)
const emptySensor: SensorSummary = {
temperature: 0, humidity: 0, co2: 0, pm25: 0,
tvoc: 0, a31001: 0, lightLevel: 0, lightUniformity: 0, a14263: 0,
};
let maxVal = 0;
for (const r of records) {
if (!r.device_data) continue;
const val = r.device_data[field];
if (val !== undefined && val !== null && typeof val === 'number' && val > maxVal) {
maxVal = val;
let sensorData: SensorSummary = emptySensor;
let regionData: any[] = [];
let pmvTrend: any[] = [];
let airTrend: any[] = [];
let lightTrend: any[] = [];
let noiseTrend: any[] = [];
let maxNoise = 0;
if (base) {
const { ieqDevices, records24h, latestData, regionNameMap, regionAvailable } = base;
sensorData = summarizeLatestData(latestData);
pmvTrend = buildPMVTrend(records24h);
airTrend = buildAirQualityTrend(records24h);
lightTrend = buildFieldTrend(records24h, 'lightLevel');
noiseTrend = buildFieldTrend(records24h, 'a14263');
maxNoise = buildFieldMax24h(records24h, 'a14263');
regionData = regionAvailable ? buildRegionData(latestData, ieqDevices, regionNameMap) : [];
}
}
return Math.round(maxVal * 100) / 100;
}
/**
* 客流体验分析 - 返回大屏所需的所有真实指标数据
*/
export async function getCustomsAnalysis() {
const sensorData = await getLatestIEQData();
const pmvTrend = await getPMVTrend();
const airTrend = await getAirQualityTrend();
const lightTrend = await getFieldTrend('lightLevel');
const noiseTrend = await getFieldTrend('a14263');
const maxNoise = await getFieldMax24h('a14263');
const regionData = await getRegionData();
// 计算光环境区域达标率(照度 ≥ 200 lx 的区域占比)
const lightRegions = regionData.filter(r => {
......@@ -557,3 +469,198 @@ export async function getCustomsAnalysis() {
},
};
}
/**
* 客流体验环境指标弹窗
* @param env 环境类型:rshj、kqpz、ghj、shj、ALL
* @res
*/
export async function getCustomsEnvPopup(env:string) {
let result:any = {};
switch (env) {
case 'rshj':
// 热湿环境指标弹窗:温度/湿度取国标舒适区间(展厅),PMV/PPD 取 ISO 7730
result = {
"base": {
"温度": "18℃ ≤ x ≤ 28℃",
"湿度": "40%RH ≤ x ≤ 70%RH",
"PMV": "-0.5 ≤ x ≤ 0.5",
"PPD": "x ≤ 20%"
},
"formulas": [
{
"indicator": "PMV",
"purpose": "热湿环境 - 热舒适",
"formula": "基于 ISO 7730 人体热平衡方程,迭代求解后保留 2 位小数",
"note": "输入空气温度、湿度、辐射温度、风速、代谢率、服装热阻等参数"
},
{
"indicator": "PPD",
"purpose": "热湿环境 - 不满意率",
"formula": "PPD = 100 - 95 × e^(-0.03353 × PMV⁴ - 0.2179 × PMV²),保留 1 位小数",
"note": "由 PMV 计算得出,预测人群对热环境的不满意百分数"
}
],
"standards": [
{
"indicator": "温度",
"standard": "国标舒适区间(展厅)"
},
{
"indicator": "湿度",
"standard": "国标舒适区间(展厅)"
},
{
"indicator": "PMV",
"standard": "ISO 7730 舒适区"
},
{
"indicator": "PPD",
"standard": "ISO 7730 预测不满意率限值"
}
]
};
break;
case 'kqpz':
// 空气品质指标弹窗:依据 GB/T 18883
result = {
"base": {
"CO₂": "0 ≤ x ≤ 1000 ppm",
"PM2.5": "0 ≤ x ≤ 75 μg/m³",
"TVOC": "0 ≤ x ≤ 0.6 mg/m³",
"甲醛": "0 ≤ x ≤ 0.1 mg/m³"
},
"formulas": [],
"standards": [
{
"indicator": "CO₂",
"standard": "GB/T 18883"
},
{
"indicator": "PM2.5",
"standard": "GB/T 18883"
},
{
"indicator": "TVOC",
"standard": "GB/T 18883"
},
{
"indicator": "甲醛",
"standard": "GB/T 18883(取甲醛传感器 a31001 实测值)"
}
]
};
break;
case 'ghj':
// 光环境指标弹窗:照度取展厅标准,UGR 为眩光评价数
result = {
"base": {
"光照": "200 ≤ x ≤ 2000 lx",
"UGR": "x ≤ 22"
},
"formulas": [
{
"indicator": "UGR",
"purpose": "光环境 - 眩光值",
"formula": "简化估算模型:由照度与照度均匀度计算(照度越低 / 均匀度越差则越高;无光照视为 25;结果钳制在 10~28,保留 1 位小数)",
"note": "UGR 为统一眩光评价数,用于展厅眩光限值判定"
}
],
"standards": [
{
"indicator": "照度",
"standard": "展厅 ≥ 200 lx"
},
{
"indicator": "UGR",
"standard": "展厅眩光限值 ≤ 22"
}
]
};
break;
case 'shj':
// 声环境指标弹窗:声级取展厅限值,NR 为噪声评价数
result = {
"base": {
"音量": "0 ≤ x ≤ 60 dB(A)",
"NR": "x ≤ 40",
"峰值": "x > 65 触发告警(仅告警,不计入达标率)"
},
"formulas": [
{
"indicator": "NR",
"purpose": "声环境 - 噪声评价数",
"formula": "NR ≈ max(0, dB(A) - 5),保留 1 位小数",
"note": "NR 由 A 声级简化估算;达标判定 min ≤ 实测值 ≤ max 即达标"
}
],
"standards": [
{
"indicator": "声级",
"standard": "展厅 ≤ 60 dB(A)"
},
{
"indicator": "NR",
"standard": "办公场所 NR 限值 ≤ 40"
},
{
"indicator": "噪音 24h 峰值",
"standard": "24h 峰值告警阈值 65 dB(A)"
}
]
};
break;
case 'ALL':
// 综合环境品质弹窗:四维度达标率加权 → 综合评分 → 等级分档(对应 analysis.sjhz)
result = {
"base": {
"综合评分": "综合评分 ≥ 90 → 优;75 ≤ 评分 < 90 → 良好;60 ≤ 评分 < 75 → 一般;评分 < 60 → 差",
"热湿环境": "热湿达标项数 ÷ 4 × 100%(温度、湿度、PMV、PPD)",
"空气品质": "空气达标项数 ÷ 4 × 100%(CO₂、PM2.5、TVOC、甲醛)",
"光环境": "照度 ≥ 200 lx 区域数 ÷ 区域总数 × 100%",
"声环境": "声环境达标项数 ÷ 2 × 100%(声级、NR)"
},
"formulas": [
{
"indicator": "综合环境品质评分",
"purpose": "综合环境品质 - 加权评分",
"formula": "score = round((热湿率×0.3 + 空气率×0.3 + 光率×0.2 + 声率×0.2) × 100)",
"note": "权重:热湿 0.3 / 空气 0.3 / 光 0.2 / 声 0.2;各维达标率传 0~1 小数"
},
{
"indicator": "rshjdbl / kqpzdbl / shjdbl",
"purpose": "各维达标率(指标项数口径)",
"formula": "Math.round(达标项数 ÷ 总项数 × 100) + '%'",
"note": "热湿固定 4 项、空气固定 4 项、声环境固定 2 项;总数为 0 时返回 0%"
},
{
"indicator": "ghjdbl",
"purpose": "光环境达标率(区域数口径)",
"formula": "照度 ≥ 200 lx 区域数 ÷ 区域总数 × 100%,保留 1 位小数",
"note": "ghj.lux 字段显示值即此占比;区域总数为 0 时返回 0%"
}
],
"standards": [
{
"indicator": "优",
"standard": "综合评分 score ≥ 90"
},
{
"indicator": "良好",
"standard": "75 ≤ 综合评分 < 90"
},
{
"indicator": "一般",
"standard": "60 ≤ 综合评分 < 75"
},
{
"indicator": "差",
"standard": "综合评分 < 60"
}
]
};
break;
}
return {success: true, data: result};
}
\ No newline at end of file
......@@ -19,7 +19,7 @@ import { mysqlModelMap } from "../model/sqlModelBind";
import { acLinkageControl } from "./acLinkage";
import { systemConfig } from "../config/serverConfig";
import Sequelize from "sequelize";
import { DEVICE_STATE, WORK_MODE_TEXT_MAP, FAN_SPEED_TEXT_MAP } from "../config/businessEnum";
import { DEVICE_STATE, WORK_MODE_TEXT_MAP, FAN_SPEED_TEXT_MAP, REGION_NAME } from "../config/businessEnum";
import moment from "moment";
import XLSX from "xlsx";
import path from "path";
......@@ -1230,34 +1230,62 @@ export async function diqinRegion() {
const stations = detail?.stations || [];
for (const st of stations) {
if (!st.id || !st.name) continue;
// 转换后的统一区域名:迪勤监测点与飞奕区域(房间)为同一物理房间时,
// 用飞奕侧 region_name(如 大会议室→亚马逊)入库,使其能与飞奕已建区域按名合并,
// 避免同一房间在 region 表生成两条记录(未配置映射的监测点保持迪勤原始名)。
const mappedName: string =
(REGION_NAME as Record<string, string>)[st.name] || st.name;
// 当前迪勤监测点 id 是否已有入库记录
let stExisting = await regionModel.findOne({ where: { region_key: st.id } });
// mappedName 被谁占用(飞奕已建区域,或历史遗留的迪勤冗余记录)
const nameOwner = await regionModel.findOne({
where: stExisting
? { region_name: mappedName, id: { [Op.ne]: stExisting.id } }
: { region_name: mappedName },
});
if (stExisting) {
if (stExisting.region_name !== st.name
if (nameOwner) {
// 撞名:存量里该监测点曾以原始名建过记录(stExisting),而 mappedName
// 已归属 nameOwner(通常是飞奕房间)→ 把迪勤id并入 nameOwner 后清理冗余记录
if (!nameOwner.region_ad || nameOwner.region_ad !== st.id) {
await regionModel.update(
{ region_ad: st.id, updated_at: new Date() },
{ where: { id: nameOwner.id } },
);
stUpdate++;
console.log(`[迪勤区域集成] 监测点撞名合并: ${st.name}(${st.id}) → ${mappedName}(${nameOwner.region_key})`);
}
await regionModel.destroy({ where: { id: stExisting.id } });
stUpdate++;
} else if (stExisting.region_name !== mappedName
|| stExisting.parent_key !== regionKey
|| stExisting.region_type !== '监测点') {
// 无撞名:按需更新为转换后的名称/归属
await regionModel.update(
{ region_name: st.name, parent_key: regionKey, region_type: '监测点', updated_at: new Date() },
{ region_name: mappedName, parent_key: regionKey, region_type: '监测点', updated_at: new Date() },
{ where: { id: stExisting.id } },
);
stUpdate++;
}
} else {
// 按名称查是否存在同名区域(飞奕或其他平台已创建)
stExisting = await regionModel.findOne({ where: { region_name: st.name } });
if (stExisting) {
// 同名区域已存在,将迪勤内部ID写入 region_ad
if (!stExisting.region_ad || stExisting.region_ad !== st.id) {
if (nameOwner) {
// 同名区域已存在(飞奕或其他平台已创建),将迪勤内部ID写入 region_ad,不新建
if (!nameOwner.region_ad || nameOwner.region_ad !== st.id) {
await regionModel.update(
{ region_ad: st.id, updated_at: new Date() },
{ where: { id: stExisting.id } },
{ where: { id: nameOwner.id } },
);
stUpdate++;
console.log(`[迪勤区域集成] 同名监测点补充 region_ad: ${st.name} → ${st.id}`);
console.log(`[迪勤区域集成] 同名监测点补充 region_ad: ${st.name} → ${mappedName}(${st.id})`);
}
} else {
await regionModel.create({
region_key: st.id,
region_name: st.name,
region_name: mappedName,
parent_id: null,
parent_key: regionKey,
region_type: '监测点',
......@@ -1318,7 +1346,7 @@ export async function diqinDevice() {
if (!deviceId) continue;
const deviceType = DIQIN_DEVICE_TYPE_MAP[ds.data_source_model_name];
const deviceName = ds.name || ds.identifier || deviceId;
const deviceName = `IEQ-${ds.name || ds.identifier || deviceId}`;
const existing = await deviceModel.findOne({ where: { device_id: deviceId } });
if (existing) {
......
......@@ -325,6 +325,7 @@ export async function editAcDeviceControl(params: {
*/
export async function batchEditDevices(params: {
regionKeys: number[];
deviceIds: string[];
deviceName?: string;
power?: string;
state?: string;
......@@ -337,11 +338,11 @@ export async function batchEditDevices(params: {
linkageStart?: string;
};
}): Promise<{ total: number; successCount: number; failCount: number; details: any[] }> {
const { regionKeys, deviceName, power, state, deviceMode, startDate, endDate, edits } = params;
const { regionKeys, deviceIds, deviceName, power, state, deviceMode, startDate, endDate, edits } = params;
// 校验必填参数
if (!regionKeys || !Array.isArray(regionKeys) || regionKeys.length === 0) {
throw new BizError(ERRORENUM.参数错误, "regionKeys 不能为空");
if ((!regionKeys || !Array.isArray(regionKeys) || regionKeys.length === 0) && (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0)) {
throw new BizError(ERRORENUM.参数错误, "regionKeys 和 deviceIds 不能同时为空");
}
if (!edits || typeof edits !== 'object') {
throw new BizError(ERRORENUM.参数错误, "edits 不能为空");
......@@ -353,7 +354,12 @@ export async function batchEditDevices(params: {
// 1. 构建设备查询条件
let whereParam: any = { device_type: '空调' };
if (regionKeys && regionKeys.length > 0) {
whereParam.region_key = { "%in%": regionKeys };
}
if (deviceIds && deviceIds.length > 0) {
whereParam.device_id = { "%in%": deviceIds };
}
if (deviceName) {
whereParam.device_name = { "%like%": deviceName };
......
......@@ -6,7 +6,9 @@ import { BizError } from '../util/bizError';
import { ERRORENUM } from '../config/errorEnum';
import { TABLENAME } from '../config/dbEnum';
import { DEVICE_STATE, powerMap, workModeMap, fanSpeedMap, lockMap, acPopFanSpeedMap } from '../config/businessEnum';
import { selectDataListByParam, selectOneDataByParam, selectDataCountByParam } from '../data/findData';
import { selectDataListByParam, selectOneDataByParam } from '../data/findData';
import Sequelize from 'sequelize';
import { mysqlDB } from '../db/mysqlInit';
import { controlIndoorUnit, getIndoorUnitStatus } from './feiyiClient';
import { INDICATOR_NAME_MAP, ENV_INDICATOR_KEYS } from '../config/businessEnum';
import {
......@@ -249,27 +251,26 @@ async function getDevicesByRegion(regionKey: string, columns: string[]): Promise
async function getLatestDeviceDataMap(deviceIds: string[], startTime: string): Promise<Map<string, any>> {
if (deviceIds.length === 0) return new Map();
// 逐台设备单独查询最新一条(limit=1 + device_time 降序),保证每台设备都取到各自最新的数据。
// 不能合并成 IN + limit=N:多台设备数据量不均时,最新 N 条可能被数据密集的设备占满,其他设备取不到。
// 【性能优化】由"每台设备单独 limit=1 查询"改为单条 SQL 子查询取每台设备最新一条:
// 用 MAX(device_time) 分组保证每台设备都取到各自最新数据(与逐台 limit=1 语义一致),
// DB 往返由 N 次降为 1 次。
const rows = await mysqlDB.query(
`SELECT dd.device_id, dd.device_data
FROM ${TABLENAME.设备数据表} dd
INNER JOIN (
SELECT device_id, MAX(device_time) AS max_time
FROM ${TABLENAME.设备数据表}
WHERE device_id IN (${deviceIds.map(() => '?').join(',')})
AND device_time >= ?
GROUP BY device_id
) latest ON dd.device_id = latest.device_id AND dd.device_time = latest.max_time`,
{ replacements: [...deviceIds, startTime], type: Sequelize.QueryTypes.SELECT }
) as any[];
const latestMap = new Map<string, any>();
await Promise.all(
deviceIds.map(async (deviceId) => {
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: deviceId,
device_time: { '%gte%': startTime },
'%orderDesc%': 'device_time',
'%limit%': 1,
},
['device_id', 'device_data']
);
const row = (dataResult.data || [])[0];
if (row) {
rows.forEach((row: any) => {
latestMap.set(row.device_id, parseDeviceData(row.device_data));
}
})
);
});
return latestMap;
}
......@@ -289,14 +290,18 @@ const ELECTRICITY_TREND_DAYS = 14;
/**
* 查询电表设备在指定时间范围内的电量总和
* 【性能优化】支持外部传入电表设备 ID 列表,避免多次调用时重复查询设备表
*/
async function getElectricitySumInRange(startTime: string, endTime: string): Promise<number> {
async function getElectricitySumInRange(startTime: string, endTime: string, meterDeviceIds?: string[]): Promise<number> {
let deviceIds = meterDeviceIds;
if (!deviceIds) {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id']
);
const deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
}
if (deviceIds.length === 0) return 0;
const dataResult = await selectDataListByParam(
......@@ -322,19 +327,23 @@ async function getElectricitySumInRange(startTime: string, endTime: string): Pro
* @param startTime 开始时间
* @param endTime 结束时间
* @param groupBy 'hour' | 'day' | 'month'
* @param keyFormatter 时间 key 格式化函数
* @param meterDeviceIds 电表设备 ID 列表(可选,外部传入可避免重复查询设备表)
*/
async function getElectricityTrendData(
startTime: string,
endTime: string,
groupBy: 'hour' | 'day' | 'month',
meterDeviceIds?: string[],
): Promise<{ key: string; value: number }[]> {
let deviceIds = meterDeviceIds;
if (!deviceIds) {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id']
);
const deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
}
if (deviceIds.length === 0) return [];
const dataResult = await selectDataListByParam(
......@@ -408,10 +417,22 @@ export async function getRunAnalysis() {
// 上月同期:上月 1 号 → 对标日(短月/本月月底自动兜底到上月最后一天)
const lastMonthSameTimeEnd = getLastMonthSameTimeEnd();
const jrnh = await getElectricitySumInRange(todayStart, todayEnd);
const zrnh = await getElectricitySumInRange(yesterdayStart, yesterdaySameTimeEnd);
const bynh = await getElectricitySumInRange(monthStart, todayEnd);
const synh = await getElectricitySumInRange(lastMonthStart, lastMonthSameTimeEnd);
// 【性能优化】电表设备一次查询,供能耗统计/趋势/区域统计共用(原先 9 次重复查设备表 → 1 次)
const meterDevicesResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id', 'region_key']
);
const meterDevices = meterDevicesResult.data || [];
const meterDeviceIds = meterDevices.map((d: any) => d.device_id);
// 【性能优化】四个时段的能耗统计并行执行(原先串行)
const [jrnh, zrnh, bynh, synh] = await Promise.all([
getElectricitySumInRange(todayStart, todayEnd, meterDeviceIds),
getElectricitySumInRange(yesterdayStart, yesterdaySameTimeEnd, meterDeviceIds),
getElectricitySumInRange(monthStart, todayEnd, meterDeviceIds),
getElectricitySumInRange(lastMonthStart, lastMonthSameTimeEnd, meterDeviceIds),
]);
const zrtb = calcCompareRate(jrnh, zrnh);
const sytb = calcCompareRate(bynh, synh);
......@@ -459,10 +480,13 @@ export async function getRunAnalysis() {
const last30dStart = getDaysAgoStart(29);
const lastYearStart = getMonthsAgoStart(12);
const last24HoursData = await getElectricityTrendData(last24hStart, formatTime(now24h), 'hour');
const last7DaysData = await getElectricityTrendData(last7dStart, getTodayEnd(), 'day');
const last30DaysData = await getElectricityTrendData(last30dStart, getTodayEnd(), 'day');
const lastYearData = await getElectricityTrendData(lastYearStart, getTodayEnd(), 'month');
// 【性能优化】四个时段趋势并行执行(电表设备复用上方一次查询结果)
const [last24HoursData, last7DaysData, last30DaysData, lastYearData] = await Promise.all([
getElectricityTrendData(last24hStart, formatTime(now24h), 'hour', meterDeviceIds),
getElectricityTrendData(last7dStart, getTodayEnd(), 'day', meterDeviceIds),
getElectricityTrendData(last30dStart, getTodayEnd(), 'day', meterDeviceIds),
getElectricityTrendData(lastYearStart, getTodayEnd(), 'month', meterDeviceIds),
]);
// 转为字符串 value(与 mock 一致)
const stringifyValues = (arr: { key: string; value: number }[]) =>
......@@ -547,14 +571,7 @@ export async function getRunAnalysis() {
const allRegionKeys = new Set<string>();
regionDeviceCountMap.forEach((_, rk) => allRegionKeys.add(rk));
// 查询所有电表设备(含 region_key,兼容存量旧电表)
const meterDevicesResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id', 'region_key']
);
const meterDevices = meterDevicesResult.data || [];
const meterDeviceIds = meterDevices.map((d: any) => d.device_id);
// 电表设备已在上方能耗统计前一次性查询(meterDevices / meterDeviceIds)
// 构建电表→区域映射(一对多):优先读 region_device_rel 关联表,存量旧电表 fallback 到 device.region_key
const meterRegionMap = new Map<string, Set<string>>();
......@@ -774,14 +791,26 @@ export async function getRunAnalysis() {
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const resolvedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '已处理' });
const respondedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '处理中' });
const unresolvedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '未处理' });
// 【性能优化+正确性】fault_status 为 INTEGER(0=未处理, 1=处理中, 2=已处理):
// 原先用中文条件与 INTEGER 列比较会被 MySQL 隐式转为 0,导致三个计数相同;
// 现改为一次 GROUP BY 按真实状态统计,三次全表 count 合并为一次。
const statusCountRows = await mysqlDB.query(
`SELECT fault_status, COUNT(*) AS cnt FROM ${TABLENAME.设备故障表} GROUP BY fault_status`,
{ type: Sequelize.QueryTypes.SELECT }
) as any[];
const statusCountMap = new Map<number, number>();
statusCountRows.forEach((r: any) => {
const st = typeof r.fault_status === 'string' ? Number(r.fault_status) : r.fault_status;
statusCountMap.set(st, Number(r.cnt) || 0);
});
const unresolvedCount = statusCountMap.get(0) || 0; // 未处理
const respondedCount = statusCountMap.get(1) || 0; // 处理中
const resolvedCount = statusCountMap.get(2) || 0; // 已处理
let alertStatus = {
resolved: typeof resolvedCount.data === 'number' ? resolvedCount.data : 0,
responded: typeof respondedCount.data === 'number' ? respondedCount.data : 0,
unresolved: typeof unresolvedCount.data === 'number' ? unresolvedCount.data : 0,
resolved: resolvedCount,
responded: respondedCount,
unresolved: unresolvedCount,
};
// 2、预警工单列表
......@@ -797,10 +826,8 @@ export async function getRunAnalysis() {
const faultListResult = await selectDataListByParam(
TABLENAME.设备故障表,
{
'%or%': [
{ fault_status: '未处理' },
{ fault_status: '处理中' }
],
// fault_status 为 INTEGER:0=未处理, 1=处理中(原中文条件会被隐式转为 0)
fault_status: { '%in%': [0, 1] },
'%orderDesc%': 'fault_time',
'%limit%': 20,
},
......@@ -1983,19 +2010,21 @@ export async function getRunMonitoring() {
// ---- 真实查询 ----
// 查询全历史故障记录,按 fault_code 分组统计
const allFaultsResult = await selectDataListByParam(
TABLENAME.设备故障表,
{},
['fault_code']
);
const allFaults = allFaultsResult.data || [];
// 【性能优化】故障类型统计下沉 SQL 端 GROUP BY,避免全历史故障明细拉取到内存再分组
const faultCodeRows = await mysqlDB.query(
`SELECT COALESCE(fault_code, '未知') AS fault_code, COUNT(*) AS fault_count
FROM ${TABLENAME.设备故障表}
GROUP BY COALESCE(fault_code, '未知')`,
{ type: Sequelize.QueryTypes.SELECT }
) as any[];
const faultCodeCountMap = new Map<string, number>();
let totalFaultCount = 0;
allFaults.forEach((f: any) => {
faultCodeRows.forEach((f: any) => {
const code = f.fault_code || '未知';
faultCodeCountMap.set(code, (faultCodeCountMap.get(code) || 0) + 1);
totalFaultCount++;
const cnt = Number(f.fault_count) || 0;
faultCodeCountMap.set(code, (faultCodeCountMap.get(code) || 0) + cnt);
totalFaultCount += cnt;
});
const gzflzb = Array.from(faultCodeCountMap.entries())
......@@ -2024,18 +2053,27 @@ export async function getRunMonitoring() {
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todoCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '未处理' });
const doingCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '处理中' });
const doneCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '已处理' });
// 【性能优化+正确性】fault_status 为 INTEGER(0=未处理, 1=处理中, 2=已处理):
// 原先用中文条件与 INTEGER 列比较会被 MySQL 隐式转为 0,导致三个计数相同,
// 现改为一次 GROUP BY 按真实状态统计,三次全表 count 合并为一次。
const statusCountRows = await mysqlDB.query(
`SELECT fault_status, COUNT(*) AS cnt FROM ${TABLENAME.设备故障表} GROUP BY fault_status`,
{ type: Sequelize.QueryTypes.SELECT }
) as any[];
const faultStatusCountMap = new Map<number, number>();
statusCountRows.forEach((r: any) => {
const st = typeof r.fault_status === 'string' ? Number(r.fault_status) : r.fault_status;
faultStatusCountMap.set(st, Number(r.cnt) || 0);
});
const todoCount = faultStatusCountMap.get(0) || 0; // 未处理
const doingCount = faultStatusCountMap.get(1) || 0; // 处理中
const doneCount = faultStatusCountMap.get(2) || 0; // 已处理
// 查询未处理和处理中的工单列表(含设备信息)
const pendingFaultsResult = await selectDataListByParam(
TABLENAME.设备故障表,
{
'%or%': [
{ fault_status: '未处理' },
{ fault_status: '处理中' }
],
fault_status: { '%in%': [0, 1] },
'%orderDesc%': 'fault_time',
'%limit%': 20,
},
......@@ -2051,25 +2089,19 @@ export async function getRunMonitoring() {
deviceRegionMap.set(d.device_id, regionNameMap.get(d.region_key) || '');
});
// status映射:fault_status → mock中的status值(0=正常?mock中未处理/处理中也是0,保持一致用0)
const faultStatusMap: { [key: string]: number } = {
'未处理': 0,
'处理中': 1,
'已处理': 2,
};
const ywczztDataList = pendingFaults.map((f: any) => ({
faultDescription: f.fault_type || '未知故障',
occurredTime: f.fault_time ? formatTime(f.fault_time) : '',
regionName: deviceRegionMap.get(f.device_id) || '',
deviceId: f.device_id,
status: faultStatusMap[f.fault_status] ?? 0,
// fault_status 本身即 0=未处理 1=处理中 2=已处理,直接透传
status: typeof f.fault_status === 'number' ? f.fault_status : Number(f.fault_status) || 0,
}));
let ywczzt = {
"todo": typeof todoCount.data === 'number' ? todoCount.data : 0,
"doing": typeof doingCount.data === 'number' ? doingCount.data : 0,
"done": typeof doneCount.data === 'number' ? doneCount.data : 0,
"todo": todoCount,
"doing": doingCount,
"done": doneCount,
"titleList": ["风险内容", "告警时间", "告警位置", "设备编号", "状态"],
"dataList": ywczztDataList,
};
......@@ -2487,7 +2519,7 @@ export async function controlAcRunning(params: {}) {
updated_at: now,
}];
await addData(TABLENAME.设备日志表, acLogs);
return await controlIndoorUnit(acPrarms);
return result;
}
......
......@@ -21,14 +21,14 @@ const USER_COLUMNS = ['login_id', 'name', 'phone', 'role', 'department', 'status
* @param pwd
*/
export async function registerUser(loginId:string, pwd:string, name:string) {
let filesList = ["loginId", "id", "pwd", "name"];
let adminUserInfo : any = await selectOneDataByParam(TABLENAME.用户信息表, {loginId}, filesList);
let filesList = ["login_id", "id", "pwd", "name"];
let adminUserInfo : any = await selectOneDataByParam(TABLENAME.用户信息表, {login_id: loginId}, filesList);
if (!adminUserInfo) {
throw new BizError(ERRORENUM.用户已存在);
}
await addData(TABLENAME.用户信息表, {loginId, pwd: getPwdMd5(loginId, pwd), name: name});
await addData(TABLENAME.用户信息表, {login_id: loginId, pwd: getPwdMd5(loginId, pwd), name: name});
return { success: true };
}
......
......@@ -88,6 +88,17 @@ export enum REGION_TYPE {
监测点 = '监测点',
}
/**
* 区域名称映射
*/
export enum REGION_NAME {
/** 迪勤 - 飞奕 */
大会议室 = '亚马逊',
大办公室 = '环球港',
小办公室 = '综合办',
仓库 = '仓库408',
}
// ==================== 关联相关枚举 ====================
/**
......
......@@ -334,6 +334,11 @@ export const TablesConfig = [
comment: '修改时间'
}
},
// 大表查询索引:device_id + device_time 组合覆盖“按设备查时间区间”,device_time 单独覆盖跨设备按时间区间查询
indexes: [
{ name: 'idx_device_data_device_id_time', fields: ['device_id', 'device_time'] },
{ name: 'idx_device_data_device_time', fields: ['device_time'] }
],
association: [
{
type: 'belongsTo',
......@@ -597,6 +602,11 @@ export const TablesConfig = [
comment: '修改时间'
}
},
// 故障统计/列表查询索引:device_id 覆盖单设备维度,fault_status + fault_time 覆盖状态计数与最新故障排序
indexes: [
{ name: 'idx_device_fault_device_id', fields: ['device_id'] },
{ name: 'idx_device_fault_status_time', fields: ['fault_status', 'fault_time'] }
],
association: [
{
type: 'belongsTo',
......
......@@ -25,7 +25,7 @@ export async function initMysqlModel() {
/**第一步:初始化所有表 */
for (let i = 0; i < TablesConfig.length; i++) {
let { tableName, schema } = TablesConfig[i];
let { tableName, schema, indexes } = TablesConfig[i];
if (!tableName) {
console.warn(`⚠️ 第 ${i} 个表配置缺少 tableName,跳过`);
......@@ -39,11 +39,16 @@ export async function initMysqlModel() {
console.log(`🔄 正在初始化表: ${tableName}`);
let schemaConf = {
let schemaConf: any = {
freezeTableName: true,
timestamps: false
};
// 表级索引(新环境 sync 建表时生效;存量库需人工执行对应 DDL)
if (indexes && indexes.length > 0) {
schemaConf.indexes = indexes;
}
try {
let model = mysqlDB.define(tableName, schema, schemaConf);
mysqlModelMap[tableName] = await model.sync({});
......
......@@ -352,6 +352,7 @@ async function editDevice(req, res) {
async function batchEditDevices(req, res) {
let reqConf = {
regionKeys: 'Array',
deviceIds: 'Array',
deviceName: 'String',
power: 'String',
state: 'String',
......@@ -360,12 +361,12 @@ async function batchEditDevices(req, res) {
endDate: 'String',
edits: 'Object',
};
const NotMustHaveKeys = ["deviceName", "power", "state", "deviceMode", "startDate", "endDate"];
let { regionKeys, deviceName, power, state, deviceMode, startDate, endDate, edits } =
const NotMustHaveKeys = ["regionKeys", "deviceIds", "deviceName", "power", "state", "deviceMode", "startDate", "endDate"];
let { regionKeys, deviceIds, deviceName, power, state, deviceMode, startDate, endDate, edits } =
eccReqParamater(reqConf, req.body, NotMustHaveKeys);
if (!regionKeys || !regionKeys.length) {
res.success({success: false, msg: "缺少必填参数:regionKeys"});
if ((!regionKeys || !regionKeys.length) && (!deviceIds || !deviceIds.length)) {
res.success({success: false, msg: "缺少必填参数:regionKeys 或 deviceIds"});
return;
}
if (!edits || typeof edits !== 'object') {
......@@ -394,6 +395,7 @@ async function batchEditDevices(req, res) {
const data = await deviceBiz.batchEditDevices({
regionKeys,
deviceIds: deviceIds,
deviceName: deviceName || undefined,
power: power || undefined,
state: state || undefined,
......@@ -533,14 +535,14 @@ async function getLinkageList(req, res) {
async function getLinkageDetail(req, res) {
const id = Number(req.query?.id);
if (!id) {
res.error("缺少必填参数:id");
res.success({success: false, msg: "缺少必填参数:id"});
return;
}
const data = await deviceLinkageBiz.getLinkageDetail(id);
if (data.isSuccess) {
res.success(data.data);
} else {
res.error(data.msg);
res.success({success: false, msg: data.msg});
}
}
......@@ -571,7 +573,7 @@ async function addLinkage(req, res) {
let { deviceId, linkageType, thresholdMax, thresholdMin, thresholdMid, settingMax, settingMin, settingMid, fanSpeedMax, fanSpeedMid, fanSpeedMin, divideMethod, beginValidity, endValidity } =
eccReqParamater(reqConf, req.body, NotMustHaveKeys);
if (!deviceId || !linkageType) {
res.error("缺少必填参数:deviceId, linkageType");
res.success({success: false, msg: "缺少必填参数:deviceId, linkageType"});
return;
}
const data = await deviceLinkageBiz.addLinkage({
......@@ -620,7 +622,7 @@ async function batchAddLinkage(req, res) {
let { deviceIds, linkageType, thresholdMax, thresholdMin, thresholdMid, settingMax, settingMin, settingMid, fanSpeedMax, fanSpeedMid, fanSpeedMin, divideMethod, beginValidity, endValidity } =
eccReqParamater(reqConf, req.body, NotMustHaveKeys);
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0 || !linkageType) {
res.error("缺少必填参数:deviceIds, linkageType");
res.success({success: false, msg: "缺少必填参数:deviceIds, linkageType"});
return;
}
const data = await deviceLinkageBiz.batchAddLinkage({
......@@ -669,7 +671,7 @@ async function editLinkage(req, res) {
const NotMustHaveKeys = ["deviceId", "linkageType", "thresholdMax", "thresholdMin", "thresholdMid", "settingMax", "settingMin", "settingMid", "fanSpeedMax", "fanSpeedMid", "fanSpeedMin", "isUse", "divideMethod", "beginValidity", "endValidity"];
let { id, deviceId, linkageType, thresholdMax, thresholdMin, thresholdMid, settingMax, settingMin, settingMid, fanSpeedMax, fanSpeedMid, fanSpeedMin, isUse, divideMethod, beginValidity, endValidity } = eccReqParamater(reqConf, req.body, NotMustHaveKeys);
if (!id) {
res.error("缺少必填参数:id");
res.success({success: false, msg: "缺少必填参数:id"});
return;
}
const data = await deviceLinkageBiz.editLinkage({
......@@ -701,7 +703,7 @@ async function editLinkage(req, res) {
async function deleteLinkage(req, res) {
const { id } = req.body || {};
if (!id) {
res.error("缺少必填参数:id");
res.success({success: false, msg: "缺少必填参数:id"});
return;
}
const data = await deviceLinkageBiz.deleteLinkage(Number(id));
......
......@@ -4,18 +4,38 @@
import asyncHandler from 'express-async-handler';
import * as customsBiz from '../biz/customs';
import { eccReqParamater } from '../util/verificationParam';
import { getCache, setCache } from '../util/ttlCache';
export function setRouter(httpServer) {
/** 客流体验分析 */
httpServer.post('/api/dq/customs/analysis', asyncHandler(getCustomsAnalysis));
/** 客流体验环境指标弹窗 */
httpServer.post('/api/dq/customs/env_popup', asyncHandler(getCustomsEnvPopup));
}
/**
* 客流体验分析
* 大屏只读接口:加 30s TTL 缓存兜底,避免高频轮询重复打库
*/
async function getCustomsAnalysis(req, res) {
const CACHE_KEY = 'dq:customs:analysis';
const CACHE_TTL = 30 * 1000;
async function getCustomsAnalysis(req, res) {
const { } = req.body;
const cached = getCache(CACHE_KEY);
if (cached) return res.success(cached);
const result = await customsBiz.getCustomsAnalysis();
setCache(CACHE_KEY, result, CACHE_TTL);
res.success(result);
}
/**
* 客流体验环境指标弹窗
* @param req
* @param res
*/
async function getCustomsEnvPopup(req, res) {
const { env } = req.body;
const result = await customsBiz.getCustomsEnvPopup(env);
res.success(result);
}
\ No newline at end of file
......@@ -8,6 +8,8 @@ const config = {
"/api/public/deviceType":enumBiz.DEVICE_TYPE,//设备类型
"/api/public/deviceState":enumBiz.DEVICE_STATE,//设备状态
"/api/public/regionType":enumBiz.REGION_TYPE,//区域类型
"/api/public/fanSpeed":enumBiz.fanSpeedMap,//风速
"/api/public/workMode":enumBiz.workModeMap,//模式
}
export function setRouter(httpServer) {
......
......@@ -4,6 +4,7 @@
import asyncHandler from 'express-async-handler';
import * as runningBiz from '../biz/running';
import { eccReqParamater } from '../util/verificationParam';
import { getCache, setCache } from '../util/ttlCache';
export function setRouter(httpServer) {
......@@ -29,10 +30,16 @@ export function setRouter(httpServer) {
/**
* 运行分析
* 大屏只读接口:加 30s TTL 缓存兜底,避免高频轮询重复打库
*/
const CACHE_TTL = 30 * 1000;
async function getRunAnalysis(req, res) {
const { } = req.body;
const CACHE_KEY = 'dq:run:analysis';
const cached = getCache(CACHE_KEY);
if (cached) return res.success(cached);
const result = await runningBiz.getRunAnalysis();
setCache(CACHE_KEY, result, CACHE_TTL);
res.success(result);
}
......@@ -67,10 +74,15 @@ async function getRunEnvironmental(req, res) {
/**
* 智能监控
* 大屏只读接口:加 30s TTL 缓存兜底,避免高频轮询重复打库
*/
async function getRunMonitoring(req, res) {
const { } = req.body;
const CACHE_KEY = 'dq:run:monitoring';
const cached = getCache(CACHE_KEY);
if (cached) return res.success(cached);
const result = await runningBiz.getRunMonitoring();
setCache(CACHE_KEY, result, CACHE_TTL);
res.success(result);
}
......
/**
* 进程内 TTL 缓存(用于大屏只读分析接口防抖)
* 注意:仅适合无参或参数极少的只读接口;服务重启后缓存失效。
*/
interface CacheItem {
expire: number;
data: any;
}
const DEFAULT_TTL = 30 * 1000; // 默认 30 秒
const MAX_SIZE = 200; // 缓存条目上限,防止异常 key 膨胀
const store = new Map<string, CacheItem>();
export function getCache<T = any>(key: string): T | undefined {
const item = store.get(key);
if (!item) return undefined;
if (item.expire <= Date.now()) {
store.delete(key);
return undefined;
}
return item.data;
}
export function setCache(key: string, data: any, ttlMs: number = DEFAULT_TTL) {
if (store.size >= MAX_SIZE) {
// 简单淘汰:优先清掉已过期项;都没有过期则整体清空(极少发生)
const now = Date.now();
for (const [k, v] of store) {
if (v.expire <= now) {
store.delete(k);
break;
}
}
if (store.size >= MAX_SIZE) store.clear();
}
store.set(key, { expire: Date.now() + ttlMs, data });
}
/** 主动失效(例如数据被写入后需要立刻反映的场景) */
export function delCache(key: string) {
store.delete(key);
}
......@@ -10,6 +10,7 @@
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false,
"skipLibCheck": true,
"types": ["node"]
},
"exclude": [
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment