更新页面标题和内容,优化萤石云接口处理逻辑,增加摄像头告警事件保存功能
4个文件已修改
357 ■■■■■ 已修改文件
public/index.html 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/config.default.ts 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/controller/app/open.ts 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/service/open.ts 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/index.html
@@ -5,14 +5,14 @@
    <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">
src/config/config.default.ts
@@ -5,20 +5,15 @@
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: {
@@ -32,12 +27,11 @@
      },
    },
  },
  // 文件上传
  upload: {
    fileSize: '200mb',
    whitelist: null,
    ignore: ['/open/push/yingshiyun', "/open/push/cameraEvent"],
  },
  // 缓存 可切换成其他缓存如:redis http://www.midwayjs.org/docs/extensions/caching
  cacheManager: {
    clients: {
      default: {
@@ -49,45 +43,19 @@
      },
    },
  },
  /*
  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,
} as MidwayConfig;
} as MidwayConfig;
src/modules/push/controller/app/open.ts
@@ -1,11 +1,10 @@
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'; //
/**
 * 开放无需验证的上报数据接口
@@ -14,6 +13,9 @@
  prefix: '/open/push',
})
export class AppPushOpenController extends BaseController {
  @Inject()
  ctx: Context;
  @Inject()
  pushOpenService: PushOpenService;
@@ -88,16 +90,146 @@
    }
  }
  @Post('/yingshiyun', { summary: '萤石云Webhook上报接口' })
  async saveYingshiyunMessage(
    @Body() body: any
  ) {
    const result = await this.pushOpenService.saveYingshiyunMessage(body);
    //return this.ok(result);
    return { "messageId": result }
  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 || '' };
  }
}
  /**
   * 摄像机主动推送告警事件(移动侦测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' };
    }
  }
}
src/modules/push/service/open.ts
@@ -115,78 +115,98 @@
    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({
            deviceId:body.header.deviceId,
            channelNo: body.header.channelNo,
          const onoffData: any = {
            deviceId: body.header.deviceId,
            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 localUrls: Record<string, string>[] = [];
          if (body.body.pictureList && Array.isArray(body.body.pictureList)) {
            const filePlugin = await this.pluginService.getInstance('upload');
            for (const item of body.body.pictureList) {
              try {
                const result = await filePlugin.downAndUpload(item.url);
                localUrls.push({url: result, id: item.id});
              } catch (err) {
                console.error(`图片下载失败: ${item.url}`, err);
              }
            }
          }
          await this.pushDevicealarmEntity.save({
          const alarmData: any = {
            devSerial: body.body.devSerial,
            channelNo: body.body.channelNo || String(body.body.channel),
            alarmId: body.body.alarmId,
            alarmTime: body.body.alarmTime,
            alarmTime: body.body.alarmTime ? new Date(body.body.alarmTime.replace('T', ' ')) : new Date(),
            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 || []
          });
            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?.length) {
            try {
              const filePlugin = await this.pluginService.getInstance('upload');
              for (const item of body.body.pictureList) {
                try {
                  const result = await filePlugin.downAndUpload(item.url);
                  localUrls.push({ url: result, id: item.id });
                } catch (err) {
                  console.error(`图片下载失败: ${item.url}`, err);
                }
              }
            } catch (err) {
              console.error('filePlugin init failed:', err);
            }
          }
          alarmData.pictureList = localUrls;
          await this.pushDevicealarmEntity.save(alarmData);
          break;
        }
        // 海康 ISAPI
        //case 'ys.open.isapi': {
          //processCameraGpsAsync(body)
          //await this.pushIsapiRepo.save({
          //  messageId,
          //  raw: JSON.stringify(body),
          //  createTime: new Date(),
          //});
          //break;
        //}
        //海康 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;
        }
        default:
          console.warn('Unknown yingshi webhook type:', type);
      }
@@ -197,11 +217,9 @@
        errorMessage: err,
      });
    }
    // 不管成功与否,删除缓存中的任务队列
    await this.midwayCache.del(`push:yingshiyun:queue:${messageId}`);
}
  }
@@ -209,8 +227,6 @@
   * 异步解析车载监控的GPS数据,存储并添加业务表
   */
  async processCameraGpsAsync(body: any) {
    try {
      let vehicleNo = '';
      if (body.sn) {
@@ -292,8 +308,19 @@
  }
  /**
   * 保存摄像头主动上报告警事件
   * @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();
  }
}
}