更新页面标题和内容,优化萤石云接口处理逻辑,增加摄像头告警事件保存功能
| | |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <meta http-equiv="X-UA-Compatible" content="ie=edge" /> |
| | | <title>COOL-ADMIN 一个很酷的后台权限管理系统</title> |
| | | <title>后台权限管理系统</title> |
| | | <meta name="keywords" content="cool-admin,后台管理系统,vue,element-ui,nodejs" /> |
| | | <meta name="description" content="element-ui、midway.js、mysql、redis、node.js、前后端分离、权限管理、快速开发, COOL-AMIND 一个很酷的后台权限管理系统" /> |
| | | <link rel="stylesheet" href="css/welcome.css"> |
| | | <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> |
| | | |
| | | <body> |
| | | <div class="reveal">HELLO COOL-ADMIN AI快速开发框架</div> |
| | | <div class="reveal">无废校园运营管理平台</div> |
| | | |
| | | <!-- 添加底部说明 --> |
| | | <div class="footer-bar"> |
| | |
| | | import { pCachePath, pUploadPath } from '../comm/path'; |
| | | import { availablePort } from '../comm/port'; |
| | | |
| | | // redis缓存 |
| | | //import { redisStore } from 'cache-manager-ioredis-yet'; |
| | | |
| | | export default { |
| | | // 确保每个项目唯一,项目首次启动会自动生成 |
| | | keys: 'f65bcafd-b72c-4f01-a451-333155097148', |
| | | koa: { |
| | | port: availablePort(8001), |
| | | rawBody: true, |
| | | }, |
| | | // 开启异步上下文管理 |
| | | asyncContextManager: { |
| | | enable: true, |
| | | }, |
| | | // 静态文件配置 |
| | | staticFile: { |
| | | buffer: true, |
| | | dirs: { |
| | |
| | | }, |
| | | }, |
| | | }, |
| | | // 文件上传 |
| | | upload: { |
| | | fileSize: '200mb', |
| | | whitelist: null, |
| | | ignore: ['/open/push/yingshiyun', "/open/push/cameraEvent"], |
| | | }, |
| | | // 缓存 可切换成其他缓存如:redis http://www.midwayjs.org/docs/extensions/caching |
| | | cacheManager: { |
| | | clients: { |
| | | default: { |
| | |
| | | }, |
| | | }, |
| | | }, |
| | | /* |
| | | cacheManager: { |
| | | clients: { |
| | | default: { |
| | | store: redisStore, |
| | | options: { |
| | | port: 6379, |
| | | host: '127.0.0.1', |
| | | password: '', |
| | | ttl: 0, |
| | | db: 0, |
| | | }, |
| | | }, |
| | | }, |
| | | }, |
| | | */ |
| | | cool: { |
| | | // 已经插件化,本地文件上传查看 plugin/config.ts,其他云存储查看对应插件的使用 |
| | | file: {}, |
| | | // 是否开启多租户 |
| | | tenant: { |
| | | // 是否开启多租户 |
| | | enable: true, |
| | | // 需要过滤多租户的url, 支持通配符, 如/admin/**/* 表示admin模块下的所有接口都进行多租户过滤 |
| | | urls: [], |
| | | }, |
| | | // 国际化配置 |
| | | i18n: { |
| | | // 是否开启 |
| | | enable: true, |
| | | // 语言 |
| | | languages: ['zh-cn', 'zh-tw', 'en'], |
| | | }, |
| | | // crud配置 |
| | | crud: { |
| | | // 插入模式,save不会校验字段(允许传入不存在的字段),insert会校验字段 |
| | | upsert: 'save', |
| | | // 软删除 |
| | | softDelete: true, |
| | | }, |
| | | } as CoolConfig, |
| | |
| | | import { Inject, Post, Body, Headers } from '@midwayjs/core'; |
| | | import { Inject, Post, Get, Body, Headers, Fields, Files } from '@midwayjs/core'; |
| | | import { CoolController, BaseController } from '@cool-midway/core'; |
| | | import { PushOpenService } from '../../service/open'; |
| | | import { WeightUploadDto } from '../../dto/weight.dto'; |
| | | import * as crypto from 'crypto'; |
| | | import { BaseSysParamService } from '../../../base/service/sys/param'; |
| | | |
| | | |
| | | import { Context } from '@midwayjs/koa'; // |
| | | |
| | | /** |
| | | * 开放无需验证的上报数据接口 |
| | |
| | | prefix: '/open/push', |
| | | }) |
| | | export class AppPushOpenController extends BaseController { |
| | | @Inject() |
| | | ctx: Context; |
| | | |
| | | @Inject() |
| | | pushOpenService: PushOpenService; |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | |
| | | @Post('/yingshiyun', { summary: '萤石云Webhook上报接口' }) |
| | | async saveYingshiyunMessage( |
| | | @Body() body: any |
| | | ) { |
| | | async saveYingshiyunMessage() { |
| | | const req = this.ctx.req; |
| | | const rawBody = await new Promise<string>((resolve, reject) => { |
| | | let data = ''; |
| | | const timer = setTimeout(() => reject(new Error('读取流超时')), 5000); |
| | | req.on('data', chunk => data += chunk); |
| | | req.on('end', () => { |
| | | clearTimeout(timer); |
| | | resolve(data); |
| | | }); |
| | | req.on('error', err => { |
| | | clearTimeout(timer); |
| | | reject(err); |
| | | }); |
| | | }).catch(err => { |
| | | console.error('读取stream异常:', err); |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { messageId: '' }; |
| | | return ''; |
| | | }); |
| | | if (!rawBody) { |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { messageId: '' }; |
| | | return; |
| | | } |
| | | console.log('【萤石云原始报文】:', rawBody.slice(0, 500)); |
| | | let msg: any; |
| | | try { |
| | | msg = JSON.parse(rawBody); |
| | | } catch (e) { |
| | | console.error('JSON解析失败:', e); |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { messageId: '' }; |
| | | return; |
| | | } |
| | | const messageId = msg?.header?.messageId; |
| | | const msgType = msg?.header?.type; // "ys.onoffline" / "ys.alarm" / ... |
| | | console.log('消息类型:', msgType, '| messageId:', messageId); |
| | | if (msgType === 'ys.alarm') { |
| | | console.log('告警类型:', msg.body?.alarmType, '| 描述:', msg.body?.describe); |
| | | } else if (msgType === 'ys.onoffline') { |
| | | console.log('设备上下线:', msg.body?.msgType, '| 设备:', msg.body?.deviceName); |
| | | } |
| | | try { |
| | | await this.pushOpenService.saveYingshiyunMessage(msg); |
| | | } catch (e) { |
| | | console.error('数据处理失败:', e); |
| | | // 即使存库失败,也要回 200 + messageId,避免萤石重试 |
| | | } |
| | | this.ctx.status = 200; |
| | | this.ctx.set('Content-Type', 'application/json'); |
| | | this.ctx.body = { messageId: messageId || '' }; |
| | | } |
| | | |
| | | const result = await this.pushOpenService.saveYingshiyunMessage(body); |
| | | //return this.ok(result); |
| | | |
| | | return { "messageId": result } |
| | | /** |
| | | * 摄像机主动推送告警事件(移动侦测VMD、人体侦测,XML multipart格式) |
| | | * 地址:POST /open/push/cameraEvent |
| | | */ |
| | | @Post('/cameraEvent', { summary: '摄像头主动上报告警事件' }) |
| | | async receiveCameraEvent() { |
| | | const req = this.ctx.req; |
| | | let rawBody = ''; |
| | | |
| | | try { |
| | | rawBody = await new Promise<string>((resolve, reject) => { |
| | | let data = ''; |
| | | const timer = setTimeout(() => reject(new Error('读取流超时')), 5000); |
| | | req.on('data', (chunk) => (data += chunk)); |
| | | req.on('end', () => { |
| | | clearTimeout(timer); |
| | | resolve(data); |
| | | }); |
| | | req.on('error', (err) => { |
| | | clearTimeout(timer); |
| | | reject(err); |
| | | }); |
| | | }); |
| | | } catch (err) { |
| | | console.error('【摄像头事件】读取stream异常:', err); |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { code: -1, msg: 'read error' }; |
| | | return; |
| | | } |
| | | if (!rawBody) { |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { code: -1, msg: 'empty body' }; |
| | | return; |
| | | } |
| | | |
| | | // 截取完整XML文本 |
| | | const xmlReg = /<\?xml[\s\S]*?<\/EventNotificationAlert>/; |
| | | const xmlMatch = rawBody.match(xmlReg); |
| | | if (!xmlMatch) { |
| | | console.warn('【摄像头事件】未匹配到告警XML内容'); |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { code: -1 }; |
| | | return; |
| | | } |
| | | const xmlStr = xmlMatch[0]; |
| | | |
| | | // 通用XML节点提取(支持标签前后换行空格) |
| | | const getVal = (xml: string, tag: string) => { |
| | | const reg = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*<\\/${tag}>`); |
| | | const res = xml.match(reg); |
| | | return res ? res[1].trim() : ''; |
| | | }; |
| | | |
| | | // 组装告警实体 |
| | | const cameraEvent = { |
| | | ipAddress: getVal(xmlStr, 'ipAddress'), |
| | | macAddress: getVal(xmlStr, 'macAddress'), |
| | | portNo: getVal(xmlStr, 'portNo'), |
| | | channelID: getVal(xmlStr, 'channelID'), |
| | | channelName: getVal(xmlStr, 'channelName'), |
| | | dateTime: getVal(xmlStr, 'dateTime'), |
| | | eventType: getVal(xmlStr, 'eventType'), // VMD=移动侦测 |
| | | eventState: getVal(xmlStr, 'eventState'), |
| | | eventDescription: getVal(xmlStr, 'eventDescription'), |
| | | targetType: getVal(xmlStr, 'targetType'), // human 人体 |
| | | targetID: getVal(xmlStr, 'targetID'), |
| | | // 目标坐标框 |
| | | rectX: getVal(xmlStr, 'X'), |
| | | rectY: getVal(xmlStr, 'Y'), |
| | | rectWidth: getVal(xmlStr, 'width'), |
| | | rectHeight: getVal(xmlStr, 'height'), |
| | | }; |
| | | |
| | | try { |
| | | // 调用service处理摄像头告警事件,建议新建service方法 |
| | | const result = await this.pushOpenService.saveCameraAlarmEvent(cameraEvent); |
| | | |
| | | this.ctx.status = 200; |
| | | this.ctx.set('Content-Type', 'application/json'); |
| | | this.ctx.body = { success: true, messageId: result }; |
| | | } catch (serviceErr) { |
| | | console.error('【摄像头事件】业务处理失败:', serviceErr); |
| | | this.ctx.status = 200; |
| | | this.ctx.body = { code: -1, msg: 'handle fail' }; |
| | | } |
| | | } |
| | | } |
| | |
| | | const dataStr = await this.midwayCache.get(`push:yingshiyun:queue:${messageId}`) as string | null; |
| | | if (!dataStr) return; |
| | | const body = JSON.parse(dataStr); |
| | | |
| | | const type = body?.header?.type; |
| | | |
| | | console.log(`---------------------------------${type}`); |
| | | try { |
| | | switch (type) { |
| | | |
| | | // 设备上下线 |
| | | case 'ys.onoffline': { |
| | | await this.pushDeviceOnoffEntity.save({ |
| | | const onoffData: any = { |
| | | deviceId:body.header.deviceId, |
| | | channelNo: body.header.channelNo, |
| | | channelNo: Number(body.header.channelNo) || 0, |
| | | messageId: body.header.messageId, |
| | | messageTime: new Date(body.header.messageTime), |
| | | messageTime: new Date(Number(body.header.messageTime)), |
| | | msgType: body.body.msgType, |
| | | devType: body.body.devType, |
| | | deviceName: body.body.deviceName, |
| | | subSerial: body.body.subSerial, |
| | | dasId: body.body.dasId, |
| | | natIp: body.body.natIp, |
| | | occurTime: body.body.occurTime, |
| | | regTime: body.body.regTime |
| | | }); |
| | | devType: body.body.devType || '', |
| | | deviceName: body.body.deviceName || '', |
| | | subSerial: body.body.subSerial || '', |
| | | dasId: body.body.dasId || '', |
| | | natIp: body.body.natIp || '', |
| | | occurTime: body.body.occurTime ? new Date(body.body.occurTime.replace(' ', 'T')) : null, |
| | | regTime: body.body.regTime ? new Date(body.body.regTime.replace(' ', 'T')) : null, |
| | | }; |
| | | await this.pushDeviceOnoffEntity.save(onoffData); |
| | | break; |
| | | } |
| | | |
| | | // 萤石告警 |
| | | case 'ys.alarm': { |
| | | // 图片下载并上传到本地/对象存储 pictureList: Array<{ url: string; id: string }>; |
| | | const alarmData: any = { |
| | | devSerial: body.body.devSerial, |
| | | alarmId: body.body.alarmId, |
| | | alarmTime: body.body.alarmTime ? new Date(body.body.alarmTime.replace('T', ' ')) : new Date(), |
| | | alarmType: body.body.alarmType, |
| | | channelName: body.body.channelName, |
| | | channelType: Number(body.body.channelType) || 1, |
| | | customInfo: body.body.customInfo || '', |
| | | customType: body.body.customType || '', |
| | | describe: body.body.describe || '', |
| | | location: body.body.location || '', |
| | | relationId: body.body.relationId || '', |
| | | status: Number(body.body.status) || 1, |
| | | channelNo: String(body.body.channel ?? body.body.channelNo ?? ''), |
| | | }; |
| | | if (body.body.checksum) alarmData.checksum = body.body.checksum; |
| | | if (body.body.crypt !== undefined) alarmData.crypt = Number(body.body.crypt); |
| | | // 图片处理(有则处理,无则跳过) |
| | | const localUrls: Record<string, string>[] = []; |
| | | if (body.body.pictureList && Array.isArray(body.body.pictureList)) { |
| | | if (body.body.pictureList?.length) { |
| | | try { |
| | | const filePlugin = await this.pluginService.getInstance('upload'); |
| | | for (const item of body.body.pictureList) { |
| | | try { |
| | |
| | | console.error(`图片下载失败: ${item.url}`, err); |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('filePlugin init failed:', err); |
| | | } |
| | | await this.pushDevicealarmEntity.save({ |
| | | devSerial: body.body.devSerial, |
| | | channelNo: body.body.channelNo || String(body.body.channel), |
| | | alarmId: body.body.alarmId, |
| | | alarmTime: body.body.alarmTime, |
| | | alarmType: body.body.alarmType, |
| | | channelName: body.body.channelName, |
| | | channelType: body.body.channelType ? Number(body.body.channelType) : 1, |
| | | checksum: body.body.checksum, |
| | | crypt: body.body.crypt ? Number(body.body.crypt) : 0, |
| | | customInfo: body.body.customInfo, |
| | | customType: body.body.customType, |
| | | describe: body.body.describe, |
| | | location: body.body.location, |
| | | relationId: body.body.relationId, |
| | | status: body.body.status ? Number(body.body.status) : 1, |
| | | pictureList: localUrls || [] |
| | | }); |
| | | } |
| | | alarmData.pictureList = localUrls; |
| | | await this.pushDevicealarmEntity.save(alarmData); |
| | | break; |
| | | } |
| | | |
| | | // 海康 ISAPI |
| | | //case 'ys.open.isapi': { |
| | | case 'ys.open.isapi': { |
| | | console.log(`---------start ys.isapi-------------------------------`); |
| | | console.log(body); |
| | | //TODO: 解析海康 ISAPI 消息,入库 |
| | | // 消息类型: ys.open.isapi | messageId: 6a69a482d05a3d0d7cdb5c83 打印内容如下: |
| | | // ---------------------------------ys.open.isapi |
| | | // ---------start ys.isapi------------------------------- |
| | | // { |
| | | // body: { |
| | | // payload: '{"ipAddress": "10.83.239.158", "protocol": "HTTP", "dateTime": "2026-07-29T14:58:07+08:00", "activePostCount": 1, "eventType": "deviceStatus", "deviceID": "RkYmx96HhzkQHnK+te7tNB89AAHahWc=", "eventState": "active", "eventDescription": "device Status", "DeviceStatus": {"sleepStatus": "sleep", "batteryList": [{"id": 1, "voltage": 3595, "current": 137, "batteryPercentage": 33, "temperature": 36, "remainingBattery": 16.829, "state": "charing", "protocolType": "ADC", "lowTemperatureHeatingStatus": "stop", "protocolVersion": "1.0"}], "solarPanelStatus": {"voltage": 4.919, "current": 918, "power": 4.509, "powerGeneration": 3.790}, "powerConsumptionStatus": {"voltage": 0.000, "current": 0, "power": 0.000, "powerConsumption": 0.220}, "dialStatusList": [{"dialSignalStrength": 5, "band": "B39", "PCI": 63}]}}' |
| | | // }, |
| | | // header: { |
| | | // channelNo: 0, |
| | | // deviceId: 'GN3665733', |
| | | // messageId: '6a69a482d05a3d0d7cdb5c83', |
| | | // messageTime: 1785308290000, |
| | | // type: 'ys.open.isapi' |
| | | // } |
| | | // } |
| | | //processCameraGpsAsync(body) |
| | | //await this.pushIsapiRepo.save({ |
| | | // messageId, |
| | | // raw: JSON.stringify(body), |
| | | // createTime: new Date(), |
| | | //}); |
| | | //break; |
| | | //} |
| | | |
| | | break; |
| | | } |
| | | default: |
| | | console.warn('Unknown yingshi webhook type:', type); |
| | | } |
| | |
| | | errorMessage: err, |
| | | }); |
| | | } |
| | | |
| | | // 不管成功与否,删除缓存中的任务队列 |
| | | await this.midwayCache.del(`push:yingshiyun:queue:${messageId}`); |
| | | |
| | | } |
| | | |
| | | |
| | |
| | | * 异步解析车载监控的GPS数据,存储并添加业务表 |
| | | */ |
| | | async processCameraGpsAsync(body: any) { |
| | | |
| | | |
| | | try { |
| | | let vehicleNo = ''; |
| | | if (body.sn) { |
| | |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | /** |
| | | * 保存摄像头主动上报告警事件 |
| | | * @param alarmData |
| | | */ |
| | | async saveCameraAlarmEvent(alarmData: any) { |
| | | const { eventType, targetType, ipAddress, dateTime } = alarmData; |
| | | if (eventType === 'VMD') { |
| | | console.log(`【移动侦测告警】设备${ipAddress},时间:${dateTime},目标类型:${targetType}`); |
| | | // TODO: 完成摄像机侦测数据保存 |
| | | } else { |
| | | console.log(`【未知摄像头事件类型】type:${eventType}`); |
| | | } |
| | | return Date.now(); |
| | | } |
| | | |
| | | } |