wangrong
10 天以前 c4d7079ac30b4ed0393c2dc831627825c7a545d6
增加监控点每日抓图
13个文件已添加
3个文件已修改
858 ■■■■■ 已修改文件
src/modules/basicdata/service/iot.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/controller/admin/capturetask.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/entity/capturetask.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/service/capturetask.ts 382 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/service/ys7.ts 314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/entity/isapientity.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/entity/isapieventtype.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/service/open.ts 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GT7568358_1786354561815.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GT7568358_1786354599954.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GU7398831_1786354575450.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GV6662505_1786354596817.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GV6662514_1786354589019.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GV7474259_1786354585367.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GV7474275_1786354579899.jpg 补丁 | 查看 | 原始文档 | blame | 历史
uploads/capture/20260810/GV7474291_1786354592342.jpg 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/basicdata/service/iot.ts
@@ -114,5 +114,4 @@
    return result;
  }
}
src/modules/monitor/controller/admin/capturetask.ts
New file
@@ -0,0 +1,20 @@
import { Inject } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { PushCaptureTaskEntity } from '../../entity/capturetask';
import { CapturetaskService } from '../../service/capturetask';
@CoolController({
  api: ['add', 'delete', 'update', 'info', 'list', 'page'],
  entity: PushCaptureTaskEntity,
  service: CapturetaskService,
  pageQueryOp: {
    keyWordLikeFields: ['a.deviceSerial', 'a.captureTaskId'],
    fieldEq: ['a.status', 'a.captureDate']
  }
})
export class AdminPushCaptureTaskController extends BaseController {
  @Inject()
  capturetaskService: CapturetaskService;
}
src/modules/monitor/entity/capturetask.ts
New file
@@ -0,0 +1,34 @@
// src/modules/push/entity/capturetask.ts
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
@Entity('t_push_capture_task')
export class PushCaptureTaskEntity extends BaseEntity {
  @Index()
  @Column({ comment: '设备序列号' })
  deviceSerial: string;
  @Column({ comment: '通道号', type: 'int', default: 0 })
  channelNo: number;
  @Column({ comment: '抓图任务ID(萤石返回)', nullable: true })
  captureTaskId: string;
  @Column({ comment: '云空间spaceId', nullable: true })
  spaceId: string;
  @Column({ comment: '图片在云空间的URL', type: 'text', nullable: true })
  cloudUrl: string;
  @Column({ comment: '图片在本地的URL', type: 'text', nullable: true })
  localUrl: string;
  @Column({ comment: '本地存储路径', type: 'text', nullable: true })
  localPath: string;
  @Column({ comment: '抓拍状态', dict: ['PENDING','SUCCESS','FAILED'], default: 'PENDING' })
  status: string;
  @Column({ comment: '任务执行日期', nullable: true })
  captureDate: string;
}
src/modules/monitor/service/capturetask.ts
New file
@@ -0,0 +1,382 @@
import { Inject, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { BasicdataIotEntity } from '../../basicdata/entity/iot';
import { BaseSysParamEntity } from '../../base/entity/sys/param';
import * as _ from 'lodash';
import { PushCaptureTaskEntity } from '../../monitor/entity/capturetask';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as https from 'https';
import { Readable } from 'stream';
/**
 * 数据源定义信息服务
 */
@Provide()
export class CapturetaskService extends BaseService {
    @InjectEntityModel(BasicdataIotEntity)
    basicdataIotEntity: Repository<BasicdataIotEntity>;
    @InjectEntityModel(BaseSysParamEntity)
    baseSysParamEntity: Repository<BaseSysParamEntity>;
    @InjectEntityModel(PushCaptureTaskEntity)
    pushCaptureTaskEntity: Repository<PushCaptureTaskEntity>;
    /**
     * 给所有萤石云固定监控设备:即时抓图(captureType=1) → 下载到本地 → 落库 →  删除云端图片
     */
    async captureForAllDevices() {
        console.log('[Capture] ===== 开始 captureForAllDevices =====');
        // 1. token
        const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
        console.log('[Capture] tokenEntity=', tokenEntity?.id, tokenEntity?.keyName);
        if (!tokenEntity?.data) {
            console.error('[Capture] ❌ 萤石 accessToken 不存在,请先获取 Token');
            return;
        }
        const accessToken = tokenEntity.data as string;
        console.log('[Capture] accessToken 存在,长度=', accessToken.length);
        // 2. projectId
        let projectId: string;
        try {
            projectId = await this.getProjectId();
            console.log('[Capture] projectId=', projectId);
        } catch (e) {
            console.error('[Capture] ❌ 获取 projectId 失败', e);
            return;
        }
        // 3. 查设备
        const devices = await this.basicdataIotEntity.find({
            where: { iotTypeCode: '萤石云固定监控' },
        });
        console.log(`[Capture] 查询到设备数量=${devices.length}`);
        if (devices.length === 0) {
            console.warn('[Capture] ⚠️ 没有萤石云固定监控设备,直接结束');
            return;
        }
        for (const [index, device] of devices.entries()) {
            console.log(`\n[Capture] ===== 设备 ${index + 1}/${devices.length} =====`);
            const deviceSerial = device.iotCode;
            const fileId = `cap_${deviceSerial}_${Date.now()}`;
            try {
                console.log('[Capture] 准备调用萤石抓图接口');
                const params = new URLSearchParams({
                    accessToken,
                    deviceSerial,
                    channelNo: '1',
                    projectId,
                    fileId,
                    captureType: '1',
                });
                console.log('[Capture] 请求URL=https://open.ys7.com/api/open/cloud/v1/capture/save');
                console.log('[Capture] 请求参数=', params.toString());
                const resp = await fetch('https://open.ys7.com/api/open/cloud/v1/capture/save', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: params.toString(),
                });
                console.log('[Capture] HTTP STATUS=', resp.status);
                const json: any = await resp.json();
                console.log('[Capture] 萤石返回=', JSON.stringify(json));
                if (json.meta?.code !== 200) {
                    console.error(
                        `[Capture] ❌ 抓图失败 ${deviceSerial} code=${json.meta?.code} msg=${json.meta?.message}`
                    );
                    continue;
                }
                const picUrl: string = json.data;
                if (!picUrl) {
                    console.error('[Capture] ❌ 萤石返回 picUrl 为空');
                    continue;
                }
                console.log('[Capture] 抓图成功 picUrl=', picUrl);
                // 4. 本地目录
                const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '');
                const localDir = path.join(process.cwd(), 'uploads', 'capture', dateDir);
                fs.mkdirSync(localDir, { recursive: true });
                console.log('[Capture] 本地目录=', localDir);
                const ext = path.extname(new URL(picUrl).pathname) || '.jpg';
                const fileName = `${deviceSerial}_${Date.now()}${ext}`;
                const dest = path.join(localDir, fileName);
                console.log('[Capture] 开始下载图片 dest=', dest);
                await this.downloadToFile(picUrl, dest);
                console.log('[Capture] 图片下载完成');
                const relativePath = path
                    .relative(path.join(process.cwd(), 'uploads'), dest)
                    .replace(/\\/g, '/');
                const localUrl = `/uploads/${relativePath}`;
                console.log('[Capture] localUrl=', localUrl);
                const entity = {
                    deviceSerial,
                    channelNo: '1',
                    captureTaskId: fileId,
                    cloudUrl: picUrl,
                    localPath: dest,
                    localUrl,
                    spaceId: projectId,
                    status: 'SUCCESS',
                    captureDate: dateDir,
                } as any;
                console.log('[Capture] 准备落库 entity=', JSON.stringify(entity));
                await this.pushCaptureTaskEntity.save(entity);
                console.log('[Capture] ✅ 落库成功');
                // 5. 删除云端
                try {
                    await this.deleteCloudCapture(projectId, fileId);
                    console.log('[Capture] ✅ 云端图片已删除');
                } catch (delErr) {
                    console.warn('[Capture] ⚠️ 云端删除失败', delErr);
                }
                console.log('[Capture] 等待 1500ms 后处理下一台设备');
                await new Promise(r => setTimeout(r, 1500));
            } catch (err) {
                console.error(`[Capture] ❌ 设备异常 ${deviceSerial}`, err);
            }
        }
        console.log('[Capture] ===== captureForAllDevices 执行结束 =====');
    }
    async deleteCloudCapture(projectId: string, fileId: string) {
        const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
        if (!tokenEntity?.data) {
            throw new Error('萤石 accessToken 不存在,请先获取 Token');
        }
        const accessToken = tokenEntity.data as string;
        const url = `https://open.ys7.com/api/open/cloud/v1/file?accessToken=${accessToken}&projectId=${projectId}&fileId=${encodeURIComponent(fileId)}`;
        const resp = await fetch(url, { method: 'DELETE' });
        const json: any = await resp.json();
        if (json.meta?.code !== 200) {
            throw new Error('删除云抓拍失败: ' + JSON.stringify(json));
        }
    }
    /**
     * 1. 获取所有萤石云固定监控设备的iotCode(iotCode就是deviceSerial) 2. 给每一个设备创建抓图任务(下发设备抓拍) 3. 下载到本地并落库
     * 在整个函数中完成所有的操作,确保每个步骤都能正确执行,并在出现错误时进行适当的处理和日志记录。
     */
    async dailyCaptureForAllDevices() {
        const devices = await this.basicdataIotEntity.find({
            where: {
                iotTypeCode: '萤石云固定监控',
            },
        });
        for (const device of devices) {
            const deviceSerial = device.iotCode;
            const channelNo = Number(device.iotChanel ?? 1);
            try {
                const existing = await this.pushCaptureTaskEntity.findOne({
                    where: {
                        deviceSerial,
                        status: 'TASK_CREATED',
                    },
                });
                let taskId: string;
                if (existing) {
                    taskId = existing.captureTaskId;
                    console.log(`[Capture] 任务已存在 ${deviceSerial} taskId=${taskId}`);
                } else {
                    const { taskId: newTaskId } = await this.createDeviceCaptureTask(
                        deviceSerial,
                        channelNo,
                    );
                    taskId = newTaskId;
                    await this.pushCaptureTaskEntity.save({
                        deviceSerial,
                        channelNo,
                        captureTaskId: taskId,
                        spaceId: await this.getSpaceID(),
                        status: 'TASK_CREATED',
                        captureDate: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
                    });
                    console.log(`[Capture] 创建任务成功 ${deviceSerial} taskId=${taskId}`);
                }
                await this.downloadAndSaveCapture(deviceSerial, channelNo);
            } catch (err) {
                console.error(`[Capture] 设备失败 ${deviceSerial}`, err);
            }
        }
    }
    // ---------- 1. 创建抓图任务(下发设备抓拍) ----------
    async createDeviceCaptureTask(deviceSerial: string, channelNo = 1) {
        const spaceId = await this.getSpaceID();
        const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
        if (!tokenEntity?.data) {
            throw new Error('萤石 accessToken 不存在,请先获取 Token');
        }
        const accessToken = tokenEntity.data as string;
        const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        const taskName = `daily_${deviceSerial}_${today}`;
        // 萤石「批量抓图任务」接口(结果写云空间)
        const params = new URLSearchParams({
            accessToken,
            taskName,
            deviceSerial,
            channelNo: String(channelNo),
            captureType: '1',
            resultSpaceId: spaceId,
            schedule: '0 0 8,12,18,20 * * ?',
            expireDays: '7',
        });
        const resp = await fetch('https://open.ys7.com/api/service/cloudrecord/pic/capture/task', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: params.toString(),
        });
        const json: any = await resp.json();
        if (json.meta?.code !== 200) throw new Error('create capture task fail: ' + JSON.stringify(json));
        return { taskId: json.data?.taskId, spaceId };
    }
    // ---------- 2. 查询任务产生的图片(媒资列表) ----------
    async queryCaptureImages(deviceSerial: string, spaceId: string) {
        const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
        if (!tokenEntity?.data) {
            throw new Error('萤石 accessToken 不存在,请先获取 Token');
        }
        const accessToken = tokenEntity.data as string;
        const params = new URLSearchParams({
            accessToken,
            deviceSerial,
            spaceId,
            startTime: this.dayStartTimestamp(),
            endTime: this.dayEndTimestamp(),
        });
        const resp = await fetch('https://open.ys7.com/api/open/cloud/v1/capture/list?' + params.toString());
        const json: any = await resp.json();
        return (json.data?.list || []) as Array<{ picUrl: string; captureTime: number; picId: string }>;
    }
    // ---------- 3. 下载到本地并落库 ----------
    async downloadAndSaveCapture(deviceSerial: string, channelNo = 0) {
        const spaceId = await this.getSpaceID();
        const imgs = await this.queryCaptureImages(deviceSerial, spaceId);
        const dateDir = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        const localDir = path.join(process.cwd(), 'uploads', 'capture', dateDir);
        fs.mkdirSync(localDir, { recursive: true });
        for (const img of imgs) {
            const ext = path.extname(new URL(img.picUrl).pathname) || '.jpg';
            const fileName = `${deviceSerial}_${img.captureTime}${ext}`;
            const dest = path.join(localDir, fileName);
            try {
                await this.downloadToFile(img.picUrl, dest);
                await this.pushCaptureTaskEntity.save({
                    deviceSerial,
                    channelNo,
                    cloudUrl: img.picUrl,
                    localPath: dest,
                    spaceId,
                    status: 'SUCCESS',
                    captureDate: dateDir,
                } as any);
            } catch (e) {
                console.error('下载/落库失败:', img.picUrl, e);
            }
        }
    }
    async getProjectId(): Promise<string> {
        const e = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.captureProjectId' });
        if (e?.data) return e.data as string;
        // 没有就查列表取第一个,别自动建
        const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
        if (!tokenEntity?.data) {
            throw new Error('萤石 accessToken 不存在,请先获取 Token');
        }
        const accessToken = tokenEntity.data as string;
        const url = `https://open.ys7.com/api/open/cloud/v1/projects?accessToken=${encodeURIComponent(accessToken)}&pageNumber=0&pageSize=10`;
        console.log('[Capture] 请求 projects 接口');
        const r = await fetch(url);
        const j: any = await r.json();
        console.log('[Capture] projects 返回=', JSON.stringify(j));
        const pid = j.data?.[0]?.projectId;
        if (!pid) throw new Error('云抓拍项目不存在,请控制台创建');
        await this.baseSysParamEntity.save({ keyName: 'ys7.captureProjectId', data: pid, updateTime: new Date() });
        return pid;
    }
    async getSpaceID(): Promise<string> {
        const entity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.storageSpaceID' });
        if (!entity ||
            !entity.data) {
            console.error('[Ys7Service] ys7.spaceID 未配置');
            throw new Error('ys7.spaceID 未配置');
        }
        return entity!.data as string;
    }
    // ---------- 工具:GET 下载文件到本地 ----------
    private downloadToFile(url: string, destPath: string): Promise<void> {
        return new Promise(async (resolve, reject) => {
            try {
                const resp = await fetch(url, { redirect: 'follow' });
                if (!resp.ok || !resp.body) {
                    reject(new Error(`download HTTP ${resp.status}`));
                    return;
                }
                const fileStream = fs.createWriteStream(destPath);
                const nodeStream = Readable.from(resp.body as any);
                nodeStream.on('error', reject);
                fileStream.on('finish', () => fileStream.close(() => resolve()));
                fileStream.on('error', reject);
                nodeStream.pipe(fileStream);
            } catch (err) {
                reject(err);
            }
        });
    }
    private dayStartTimestamp(): string {
        const now = new Date();
        const start = new Date(Date.UTC(
            now.getFullYear(),
            now.getMonth(),
            now.getDate(),
            0,
            0,
            0,
        ));
        return Math.floor((start.getTime() + 8 * 60 * 60 * 1000) / 1000).toString();
    }
    private dayEndTimestamp(): string {
        const now = new Date();
        const end = new Date(Date.UTC(
            now.getFullYear(),
            now.getMonth(),
            now.getDate(),
            23,
            59,
            59,
        ));
        return Math.floor((end.getTime() + 8 * 60 * 60 * 1000) / 1000).toString();
    }
}
src/modules/monitor/service/ys7.ts
@@ -5,6 +5,7 @@
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
interface Ys7TokenData {
  accessToken: string;
  /** 萤石返回的毫秒级过期时间戳 */
@@ -37,13 +38,11 @@
  private async fetchAndSaveRemote(): Promise<Ys7TokenData> {
    const appKey_result = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.appKey' });
    const appSecret_result = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.appSecret' });
    if (!appKey_result || !appSecret_result) {
      throw new Error(
        '萤石云 appKey/appSecret 未配置,请在【系统参数】中维护 ys7.appKey / ys7.appSecret'
      );
    }
    const { data: res } = await axios.post(
      'https://open.ys7.com/api/lapp/token/get',
      new URLSearchParams({ appKey: appKey_result.data, appSecret: appSecret_result.data }),
@@ -52,7 +51,6 @@
        timeout: 8000,
      }
    );
    if (String(res.code) !== '200') {
      throw new Error(`萤石云获取 Token 失败:${res.msg}(code: ${res.code})`);
    }
@@ -61,8 +59,6 @@
      accessToken: res.data.accessToken,
      expireTime: res.data.expireTime,
    };
    // 写回系统参数表(Cool-Admin 自带 upsert)
    const token = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
    this.baseSysParamEntity.save({
      ...token,
@@ -70,7 +66,313 @@
      updateTime: new Date(),
    }).catch(() => {
      // 忽略写回失败
    });
    });
    return tokenData;
  }
  async getSpaceID(): Promise<string> {
    const entity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.storageSpaceID' });
    if (!entity ||
      !entity.updateTime ||
      Date.now() - new Date(entity.updateTime).getTime() >
      6 * 24 * 60 * 60 * 1000) {
      const spaceId = await this.createCaptureSpace();
      await this.baseSysParamEntity.save({
        id: entity?.id,
        keyName: 'ys7.storageSpaceID',
        data: spaceId,
        updateTime: new Date(),
      });
      return spaceId;
    }
    return entity!.data as string;
  }
  /**
   * 创建萤石云【设备抓图】存储空间
   * - spaceName 取自系统参数 ys7.spaceName
   * - 过期天数默认 7 天
   * - 创建成功后 storageSpaceID 写入 ys7.storageSpaceID
   * - 调用萤石云空间创建接口:https://open.ys7.com/api/service/open/storage/engine/space
   * - 接口说明地址:https://open.ys7.com/help/5236
   * - 将创建好的存储空间ID存入参数:ys7.storageSpaceID
   */
  async createCaptureSpace(expireDays = 7): Promise<string> {
    // 1. accessToken
    const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
    if (!tokenEntity?.data) {
      console.error('[Ys7Service] ys7.accessToken 为空');
      throw new Error('ys7.accessToken 为空');
    }
    const accessToken = String(tokenEntity.data).trim();
    // 2. spaceName
    const spaceEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.spaceName' });
    if (!spaceEntity?.data) {
      console.error('[Ys7Service] ys7.spaceName 未配置');
      throw new Error('ys7.spaceName 未配置');
    }
    const spaceName = String(spaceEntity.data).trim();
    // 4. 萤石侧是否已存在同名空间
    const existId = await this.getSpaceIdByName(accessToken, spaceName);
    if (existId) {
      console.log('[Ys7Service] 萤石侧已存在同名空间,spaceId=%s', existId);
      await this.saveStorageSpaceID(existId);
      return existId;
    }
    // 5. 真正创建
    const params = new URLSearchParams({
      bizType: 'capture',
      expireDays: String(expireDays),
      spaceName,
      storageType: '1',
    });
    let res: any;
    try {
      const resp = await axios.post(
        'https://open.ys7.com/api/service/open/storage/engine/space',
        params,
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            accessToken,
          },
          timeout: 8000,
        }
      );
      res = resp.data;
    } catch (err: any) {
      console.error('[Ys7Service] 萤石接口请求失败');
      if (err.response) {
        console.error('[Ys7Service] RESPONSE BODY=%s', JSON.stringify(err.response.data));
      } else {
        console.error('[Ys7Service] ERROR MSG=%s', err.message);
      }
      throw err;
    }
    // 6. 校验萤石业务返回
    if (!res || res.meta?.code !== 200) {
      console.error('[Ys7Service] 萤石创建空间业务失败: %s', JSON.stringify(res?.meta));
      throw new Error(`萤石创建空间失败: ${JSON.stringify(res?.meta)}`);
    }
    const storageSpaceID = String(res.data); // data 就是 spaceId
    // 7. 保存到系统参数
    await this.saveStorageSpaceID(storageSpaceID);
    return storageSpaceID;
  }
  /**
   * 修改萤石云【设备抓图】存储空间
   * - spaceId 取自 ys7.storageSpaceID
   * - accessToken 取自 ys7.accessToken
   * - 默认过期天数 7 天
   */
  async updateCaptureSpace(expireDays = 7): Promise<boolean> {
    // 1. accessToken
    const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
    if (!tokenEntity?.data) {
      throw new Error('萤石 accessToken 不存在,请先获取 Token');
    }
    const accessToken = tokenEntity.data as string;
    // 2. spaceId
    const spaceEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.storageSpaceID' });
    if (!spaceEntity?.data) {
      throw new Error('萤石 storageSpaceID 不存在,请先创建抓图空间');
    }
    const spaceId = Number(spaceEntity.data);
    // 3. 组装参数(PUT 使用 URLSearchParams)
    const params = new URLSearchParams();
    params.append('spaceId', String(spaceId));
    params.append('expireDays', String(expireDays));
    const url = 'https://open.ys7.com/api/service/open/storage/engine/space';
    const { data: res } = await axios.put(url, params, {
      headers: {
        accessToken,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      timeout: 8000,
    });
    if (res.meta?.code !== 200) {
      throw new Error(
        `萤石云修改存储空间失败:${res.meta?.message}(code: ${res.meta?.code})`
      );
    }
    if (res.data) {
      this.baseSysParamEntity.save({
        ...spaceEntity,
        updateTime: new Date(),
      }).catch(() => {
        // 忽略写回失败
      });
    }
    return !!res.data;
  }
  /**
   * 删除萤石云【设备抓图】存储空间
   * - spaceId 取自 ys7.storageSpaceID
   * - accessToken 取自 ys7.accessToken
   * - 删除成功后,清空 ys7.storageSpaceID
   */
  async deleteCaptureSpace(): Promise<boolean> {
    // 1. accessToken
    const tokenEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.accessToken' });
    if (!tokenEntity?.data) {
      throw new Error('萤石 accessToken 不存在,请先获取 Token');
    }
    const accessToken = tokenEntity.data as string;
    // 2. spaceId
    const spaceEntity = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.storageSpaceID' });
    if (!spaceEntity?.data) {
      throw new Error('萤石 storageSpaceID 不存在,无法删除');
    }
    const spaceId = Number(spaceEntity.data);
    // 3. DELETE 请求(body 使用 URLSearchParams)
    const params = new URLSearchParams();
    params.append('spaceId', String(spaceId));
    const url = 'https://open.ys7.com/api/service/open/storage/engine/space';
    const { data: res } = await axios.delete(url, {
      headers: {
        accessToken,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      data: params.toString(),
      timeout: 8000,
    });
    if (res.meta?.code !== 200) {
      throw new Error(
        `萤石云删除存储空间失败:${res.meta?.message}(code: ${res.meta?.code})`
      );
    }
    // 4. 清理本地参数
    spaceEntity.data = '';
    spaceEntity.updateTime = new Date();
    await this.baseSysParamEntity.save(spaceEntity);
    return !!res.data;
  }
  /**
   * 根据 spaceName 查询萤石是否已存在【设备抓图】空间
   * 使用官方 listById 接口,支持翻页
   * 返回 storageSpaceID 或 null
   */
  private async getSpaceIdByName(
    accessToken: string,
    spaceName: string
  ): Promise<string | null> {
    const url = 'https://open.ys7.com/api/service/open/storage/engine/space/listById';
    // 查询时间窗口:最近 30 天(萤石强制要求)
    const endTime = new Date();
    const startTime = new Date(endTime.getTime() - 30 * 24 * 60 * 60 * 1000);
    const format = (d: Date) =>
      d.getFullYear() +
      '-' +
      String(d.getMonth() + 1).padStart(2, '0') +
      '-' +
      String(d.getDate()).padStart(2, '0') +
      ' ' +
      String(d.getHours()).padStart(2, '0') +
      ':' +
      String(d.getMinutes()).padStart(2, '0') +
      ':' +
      String(d.getSeconds()).padStart(2, '0');
    const queryParams = new URLSearchParams({
      startTime: format(startTime),
      endTime: format(endTime),
      pageSize: '50',
      bizTypeList: 'capture', // 只查设备抓图空间
    });
    let lastId: number | null = null;
    let page = 1;
    try {
      while (true) {
        if (lastId !== null) {
          queryParams.set('lastSpaceId', String(lastId));
        }
        const fullUrl = `${url}?${queryParams.toString()}`;
        const resp = await axios.get(fullUrl, {
          headers: {
            accessToken,
          },
          timeout: 8000,
        });
        const res = resp.data;
        if (res?.meta?.code !== 200) {
          console.error('[Ys7Service] 查询空间列表失败: %s', JSON.stringify(res.meta));
          throw new Error(`查询萤石空间失败: ${JSON.stringify(res.meta)}`);
        }
        const data = res.data || {};
        const list = data.result || [];
        // 精确匹配 spaceName
        const match = list.find(
          (item: any) => item.spaceName === spaceName && item.bizType === 'capture'
        );
        if (match) {
          console.log(
            '[Ys7Service] ✅ 找到已存在的空间:spaceId=%s, spaceName=%s, bizType=%s',
            match.spaceId,
            match.spaceName,
            match.bizType
          );
          return String(match.spaceId);
        }
        // 没有下一页,终止
        if (!data.hasNext) {
          break;
        }
        lastId = data.lastId;
        page++;
      }
      return null;
    } catch (err: any) {
      console.error('[Ys7Service] 查询萤石空间异常');
      if (err.response) {
        console.error('[Ys7Service] HTTP STATUS=%d', err.response.status);
        console.error('[Ys7Service] RESPONSE=%s', JSON.stringify(err.response.data));
      } else {
        console.error('[Ys7Service] ERROR=%s', err.message);
      }
      throw err;
    }
  }
  /** 公共保存方法 */
  private async saveStorageSpaceID(spaceId: string) {
    let e = await this.baseSysParamEntity.findOneBy({ keyName: 'ys7.storageSpaceID' });
    if (!e) {
      e = this.baseSysParamEntity.create({
        keyName: 'ys7.storageSpaceID',
        name: '萤石云抓图空间ID',
        dataType: 1,
        data: spaceId,
        updateTime: new Date(),
      });
    } else {
      e.data = spaceId;
      e.updateTime = new Date();
    }
    await this.baseSysParamEntity.save(e);
  }
}
src/modules/push/entity/isapientity.ts
New file
@@ -0,0 +1,50 @@
// entity/push/isapi.raw.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
import { IsapiEventType } from './isapieventtype';
@Entity('t_push_isapi_raw')
export class PushIsapiRawEntity {
  @PrimaryGeneratedColumn()
  id: number;
  /** 萤石 webhook messageId(幂等) */
  @Column({ unique: true, comment: '消息ID' })
  messageId: string;
  /** 设备序列号 */
  @Column({ comment: '设备序列号' })
  deviceId: string;
  /** 通道号 */
  @Column({ comment: '通道号' })
  channelNo: number;
  /** ISAPI 事件类型(枚举约束) */
  @Column({
    type: 'enum',
    enum: IsapiEventType,
    default: IsapiEventType.UNKNOWN,
    comment: 'ISAPI 事件类型',
  })
  eventType: IsapiEventType;
  /** 事件状态:active / inactive */
  @Column({ comment: '事件状态' })
  eventState: string;
  /** 海康 ISAPI 原始 payload(字符串) */
  @Column({ type: 'text', comment: '原始 ISAPI payload' })
  rawPayload: string;
  /** 解析后的 JSON(方便后期扩展) */
  @Column({ type: 'json', nullable: true, comment: '解析后的 payload' })
  parsed: any;
  /** 设备上报时间 */
  @Column({ type: 'datetime', comment: '事件时间' })
  eventTime: Date;
  /** 入库时间 */
  @CreateDateColumn({ comment: '创建时间' })
  createTime: Date;
}
src/modules/push/entity/isapieventtype.ts
New file
@@ -0,0 +1,17 @@
// enum/isapi-event.enum.ts
export enum IsapiEventType {
  /** 设备状态(电量 / 太阳能 / 休眠) */
  DEVICE_STATUS = 'deviceStatus',
  /** GPS 位置 */
  GPS = 'GPS',
  /** IO 输入 */
  IO = 'IO',
  /** 智能事件 */
  SMART = 'smart',
  /** 未知 */
  UNKNOWN = 'unknown',
}
src/modules/push/service/open.ts
@@ -9,6 +9,8 @@
import { PushVehicleGpsEntity } from '../entity/vehiclegps';
import { PushDeviceOnoffEntity } from '../entity/deviceonoff';
import { PushDevicealarmEntity } from '../entity/devicealarm';
import { PushIsapiRawEntity } from '../entity/isapientity';
import { IsapiEventType } from '../entity/isapieventtype';
import { PluginService } from '../../plugin/service/info';
import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager';
@@ -37,6 +39,9 @@
  @InjectEntityModel(PushAuditlogEntity)
  pushAuditlogEntity: Repository<PushAuditlogEntity>;
  @InjectEntityModel(PushIsapiRawEntity)
  pushIsapiRawEntity: Repository<PushIsapiRawEntity>;
  @Inject()
  pluginService: PluginService;
@@ -199,12 +204,35 @@
//     type: 'ys.open.isapi'
//   }
// }          
          // processCameraGpsAsync(body)
          // await this.pushIsapiRepo.save({
          // messageId,
          // raw: JSON.stringify(body),
          // createTime: new Date(),
          // });
          const header = body.header;
          const payloadStr = body.body?.payload;
          if (!payloadStr) {
            console.warn('ISAPI payload missing');
            break;
          }
          let parsed: any;
          try {
            parsed = JSON.parse(payloadStr);
          } catch (e) {
            console.error('ISAPI payload parse error', e);
            break;
          }
          const eventType =
            Object.values(IsapiEventType).includes(parsed.eventType)
              ? parsed.eventType
              : IsapiEventType.UNKNOWN;
          await this.pushIsapiRawEntity.save({
            messageId: header.messageId,
            deviceId: header.deviceId,
            channelNo: header.channelNo ?? 0,
            eventType,
            eventState: parsed.eventState,
            rawPayload: payloadStr,
            parsed,
            eventTime: parsed.dateTime
              ? new Date(parsed.dateTime)
              : new Date(header.messageTime),
          });
          break;
        }
        default:
uploads/capture/20260810/GT7568358_1786354561815.jpg
uploads/capture/20260810/GT7568358_1786354599954.jpg
uploads/capture/20260810/GU7398831_1786354575450.jpg
uploads/capture/20260810/GV6662505_1786354596817.jpg
uploads/capture/20260810/GV6662514_1786354589019.jpg
uploads/capture/20260810/GV7474259_1786354585367.jpg
uploads/capture/20260810/GV7474275_1786354579899.jpg
uploads/capture/20260810/GV7474291_1786354592342.jpg