doum
2026-06-11 d9c657aa78cf0ebe31933a87e63ca92edd8a8da3
数据采集站
已添加54个文件
已修改15个文件
86423 ■■■■■ 文件已修改
admin/.env.development 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/api/business/collectionDockDevice.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/api/business/collectionMedia.js 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/api/business/collectionStation.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/business/OperaCollectionStationWindow.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/common/Menu.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/common/RichEditor.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/common/tagsview.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/main.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/store/index.js 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/utils/menuRoute.js 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/collectionDockDevice.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/collectionMedia.vue 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/collectionStation.vue 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/system/menu.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_dock_device.permissions.sql 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_dock_device.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_media.alter.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_media.download_status.alter.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_media.permissions.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_media.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.alter.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.dict.alter.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.dict.sql 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.menu.sql 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.permissions.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.role_permissions.sql 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.collection_station.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/doc/hik/ISAPI开发指南_手持穿戴产品_行业穿戴产品w.pdf 补丁 | 查看 | 原始文档 | blame | 历史
server/doc/hik/_pdf_extract.txt 80941 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/doc/hik/字典信息.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
server/doc/hik/日志类型.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
server/doc/hik/错误码.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/config/cloudfilter/LoginHandlerInterceptor.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/annotation/trace/TraceInterceptor.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/utils/Constants.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/utils/FtpUtil.java 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/utils/HttpsUtil.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/utils/IsapiHttpUtil.java 267 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_service/src/main/java/com/doumee/core/utils/VideoTranscodeUtil.java 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/system_timer/src/main/java/com/doumee/jobs/fegin/VisitServiceFegin.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/admin_timer/src/main/java/com/doumee/api/HkCollectionStationTimerController.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_admin/src/main/resources/bootstrap.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiClient.java 434 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiConstants.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiJsonParser.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiRequestHelper.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiStationContext.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiXmlParser.java 762 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DeviceInfoDTO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DockDeviceDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DockStationBasicInfoDTO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/RecordTrackDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/SearchPageResult.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/StorageInfoDTO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/CollectionMediaSyncRequest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionDockDeviceMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionMediaMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionStationMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionDockDevice.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionStation.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/CollectionMediaSyncService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/CollectionStationService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java 667 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionStationServiceImpl.java 633 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/resources/application-dev.yml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/.env.development
@@ -5,9 +5,9 @@
#VUE_APP_API_URL  = 'http://192.168.1.82:10010'
VUE_APP_API_URL  = 'http://192.168.0.7/system_gateway'
#VUE_APP_API_URL  = 'http://192.168.0.7/system_gateway'
#VUE_APP_API_URL  = 'http://localhost:10010'
VUE_APP_API_URL  = 'http://localhost:10010'
#key:045542fc5f436b75e6c911c5c84ff8cd
#密钥:8bd38497f9aee2b75e7a888a4dfd1e6c
admin/src/api/business/collectionDockDevice.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
import request from '../../utils/request'
export function fetchList (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/dockDevices/page', data, { trim: true })
}
admin/src/api/business/collectionMedia.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
import request from '../../utils/request'
import axios from 'axios'
import Cookies from 'js-cookie'
export function fetchList (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/media/page', data, { trim: true })
}
/** é¢„览接口 URL */
export function buildPreviewUrl (id) {
  const token = Cookies.get('dm_user_token') || ''
  const base = process.env.VUE_APP_API_PREFIX + '/visitsAdmin/cloudService/business/collectionStation/media/preview/' + id
  return token ? `${base}?dm_user_token=${encodeURIComponent(token)}` : base
}
/** ä¸‹è½½åˆ°æœ¬åœ°æŽ¥å£ URL */
export function buildDownloadUrl (id) {
  const token = Cookies.get('dm_user_token') || ''
  const base = process.env.VUE_APP_API_PREFIX + '/visitsAdmin/cloudService/business/collectionStation/media/download/' + id
  return token ? `${base}?dm_user_token=${encodeURIComponent(token)}` : base
}
function authHeaders () {
  const token = Cookies.get('dm_user_token')
  return token ? { dm_user_token: token } : {}
}
function rejectBlobError (blob) {
  if (!blob || blob.size === 0) {
    return Promise.reject(new Error('文件为空'))
  }
  if (blob.type && blob.type.includes('json')) {
    return blob.text().then(text => {
      let message = '操作失败'
      try {
        const json = JSON.parse(text)
        message = json.message || message
      } catch (e) {
        message = text || message
      }
      return Promise.reject(new Error(message))
    })
  }
  return Promise.resolve(blob)
}
/** é¢„览接口拉取文本(txt/log) */
export function fetchPreviewText (id) {
  return axios({
    url: buildPreviewUrl(id),
    method: 'get',
    responseType: 'text',
    headers: authHeaders()
  }).then(res => {
    const data = res.data
    if (typeof data === 'string' && data.trim().startsWith('{') && data.includes('"success"')) {
      try {
        const json = JSON.parse(data)
        if (json.success === false) {
          return Promise.reject(new Error(json.message || '预览失败'))
        }
      } catch (e) {
        // éž JSON å“åº”,按文本展示
      }
    }
    return data || ''
  })
}
export function fetchPreviewBlob (id) {
  return axios({
    url: buildPreviewUrl(id),
    method: 'get',
    responseType: 'blob',
    headers: authHeaders()
  }).then(res => rejectBlobError(res.data))
}
/** ä¸‹è½½å·²å…¥åº“媒体到本地 */
export function fetchMediaFile (id) {
  return axios({
    url: buildDownloadUrl(id),
    method: 'get',
    responseType: 'blob',
    headers: authHeaders()
  }).then(res => {
    if (res.headers['content-type'] === 'application/json') {
      return rejectBlobError(res.data).then(() => res)
    }
    return res
  })
}
/** æ ¡éªŒ Blob æ˜¯å¦ä¸º MP4 å®¹å™¨ */
export function ensureMp4Blob (blob) {
  return blob.slice(0, 12).arrayBuffer().then(buf => {
    const arr = new Uint8Array(buf)
    const isMp4 = arr.length >= 8 && arr[4] === 0x66 && arr[5] === 0x74 && arr[6] === 0x79 && arr[7] === 0x70
    if (!isMp4) {
      return Promise.reject(new Error('文件不是有效的 MP4 æ ¼å¼ï¼Œè¯·é‡æ–°ä¸‹è½½è¯¥åª’体'))
    }
    return new Blob([blob], { type: 'video/mp4' })
  })
}
admin/src/api/business/collectionStation.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
import request from '../../utils/request'
export function fetchList (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/page', data, { trim: true })
}
export function list (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/list', data, { trim: true })
}
export function fetchMediaList (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/media/page', data, { trim: true })
}
export function create (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/create', data)
}
export function updateById (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/updateById', data)
}
export function deleteById (id) {
  return request.get(`/visitsAdmin/cloudService/business/collectionStation/delete/${id}`)
}
export function deleteByIdInBatch (ids) {
  return request.get('/visitsAdmin/cloudService/business/collectionStation/delete/batch', { params: { ids } })
}
export function syncDevices () {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/syncDevices', {})
}
export function syncDevice (id) {
  return request.post(`/visitsAdmin/cloudService/business/collectionStation/syncDevice/${id}`, {})
}
export function syncMedia (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/syncMedia', data)
}
export function downloadMedia (id) {
  return request.post(`/visitsAdmin/cloudService/business/collectionStation/downloadMedia/${id}`, {})
}
export function batchDownloadMedia (data) {
  return request.post('/visitsAdmin/cloudService/business/collectionStation/batchDownloadMedia', data)
}
export function probeIsapi (id) {
  return request.get(`/visitsAdmin/cloudService/business/collectionStation/probe/${id}`)
}
export function fetchDockDevices (stationId) {
  return request.get(`/visitsAdmin/cloudService/business/collectionStation/dockDevices/${stationId}`)
}
admin/src/components/business/OperaCollectionStationWindow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
<template>
  <GlobalWindow
    :title="title"
    :visible.sync="visible"
    :confirm-working="isWorking"
    @confirm="confirm"
  >
    <el-form :model="form" ref="form" :rules="rules" label-width="100px">
      <el-form-item label="名称" prop="name">
        <el-input v-model="form.name" placeholder="采集站名称" v-trim />
      </el-form-item>
      <el-form-item label="IP" prop="ip">
        <el-input v-model="form.ip" placeholder="设备IP" v-trim />
      </el-form-item>
      <el-form-item label="端口" prop="port">
        <el-input-number v-model="form.port" :min="1" :max="65535" />
      </el-form-item>
      <el-form-item label="HTTPS" prop="useHttps">
        <el-select v-model="form.useHttps" placeholder="请选择">
          <el-option label="否" :value="0" />
          <el-option label="是" :value="1" />
        </el-select>
      </el-form-item>
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" placeholder="ISAPI用户名" v-trim />
      </el-form-item>
      <el-form-item label="密码" prop="password">
        <el-input v-model="form.password" type="password" placeholder="ISAPI密码" show-password />
      </el-form-item>
      <el-form-item label="型号" prop="model">
        <el-input v-model="form.model" placeholder="UD39625B" v-trim />
      </el-form-item>
      <el-form-item label="状态" prop="status">
        <el-select v-model="form.status" placeholder="请选择">
          <el-option label="启用" :value="1" />
          <el-option label="禁用" :value="0" />
        </el-select>
      </el-form-item>
      <el-form-item label="备注" prop="remark">
        <el-input v-model="form.remark" type="textarea" placeholder="备注" />
      </el-form-item>
    </el-form>
  </GlobalWindow>
</template>
<script>
import BaseOpera from '@/components/base/BaseOpera'
import GlobalWindow from '@/components/common/GlobalWindow'
export default {
  name: 'OperaCollectionStationWindow',
  extends: BaseOpera,
  components: { GlobalWindow },
  data () {
    return {
      form: {
        id: null,
        name: '',
        ip: '',
        port: 80,
        useHttps: 0,
        username: 'admin',
        password: '',
        model: 'UD39625B',
        status: 1,
        remark: ''
      },
      rules: {
        ip: [{ required: true, message: '请输入IP', trigger: 'blur' }],
        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
        password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
      }
    }
  },
  created () {
    this.config({
      api: '/business/collectionStation',
      'field.id': 'id'
    })
  }
}
</script>
admin/src/components/common/Menu.vue
@@ -3,11 +3,12 @@
    <scrollbar>
      <el-menu
        ref="menu"
        :default-active="activeIndex"
        :key="menuRenderKey"
        :default-active="activeMenuIndex"
        text-color="#333333"
        active-text-color="#207FF7"
        :collapse="menuData.collapse"
        :default-openeds="defaultOpeneds"
        :default-openeds="openMenuIndexes"
        :collapse-transition="false"
        unique-opened
        @select="handleSelect"
@@ -24,47 +25,81 @@
</template>
<script>
import { mapState } from 'vuex'
import { mapState, mapMutations } from 'vuex'
import MenuItems from './MenuItems'
import Scrollbar from './Scrollbar'
import { findMenuByRoute } from '@/utils/menuRoute'
export default {
  name: 'Menu',
  components: { Scrollbar, MenuItems },
  computed: {
    ...mapState(['menuData']),
    // é€‰ä¸­çš„菜单index
    activeIndex() {
      let path = this.$route.path
      if (path.endsWith('/')) {
        path = path.substring(0, path.length - 1)
      }
      const menuConfig = this.__getMenuConfig(path, 'index', this.menuData.list)
      if (menuConfig == null) {
        return null
      } else {
        this.$store.commit('pushtags', menuConfig)
      }
      // console.log(menuConfig.index);
      return menuConfig.index
    },
    // é»˜è®¤å±•开的菜单index
    defaultOpeneds() {
      // return this.menuData.list.map(menu => menu.index)
      return [this.menuData.list[0].index]
  data () {
    return {
      activeMenuIndex: '',
      openMenuIndexes: [],
      menuRenderKey: 0
    }
  },
  computed: {
    ...mapState(['menuData', 'topMenuList'])
  },
  watch: {
   /* $route (to, from) {
      var that =this
      this.$nextTick(() => {
        setTimeout(function(){ that.computeTableHeight()},1000)
      })
    }*/
    '$route' () {
      this.syncMenuFromRoute()
    },
    'menuData.list' () {
      this.syncMenuFromRoute()
    }
  },
  mounted () {
    this.syncMenuFromRoute()
  },
  methods: {
    ...mapMutations(['pushtags', 'syncTopMenuFromRoute']),
    syncMenuFromRoute () {
      const path = this.normalizePath(this.$route.path)
      const menuIndex = this.$route.query.index
      const result = findMenuByRoute(this.topMenuList.list, path, menuIndex)
      if (result == null) {
        return
      }
      if (result.topMenu.id !== this.$store.state.topMenuCurrent.id) {
        this.syncTopMenuFromRoute({
          topMenu: result.topMenu,
          topIndex: result.topIndex
        })
      }
      this.activeMenuIndex = result.menu.index
      this.openMenuIndexes = result.parents.map(item => item.index)
      this.menuRenderKey += 1
      this.pushtags(result.menu)
      this.$nextTick(() => {
        this.openParentMenus()
      })
    },
    openParentMenus () {
      const menuRef = this.$refs.menu
      if (!menuRef || this.openMenuIndexes.length === 0) {
        return
      }
      this.openMenuIndexes.forEach(index => {
        if (typeof menuRef.open === 'function') {
          menuRef.open(index)
        }
      })
    },
    normalizePath (path) {
      if (path == null || path === '') {
        return ''
      }
      return path.endsWith('/') ? path.substring(0, path.length - 1) : path
    },
    // å¤„理菜单选中
    handleSelect(menuIndex) {
    handleSelect (menuIndex) {
      const menuConfig = this.__getMenuConfig(menuIndex, 'index', this.menuData.list)
      if (menuConfig == null) {
        return
      }
      // æ‰¾ä¸åˆ°é¡µé¢
      try {
        require('@/views' + menuConfig.url)
@@ -73,23 +108,18 @@
        return
      }
      // ç‚¹å‡»å½“前菜单不做处理
      if (menuConfig.url === this.$route.path && (menuConfig.params == null || menuConfig.params == undefined || menuConfig.params == '' || menuConfig.params === this.$route.query.param)) {
      if (menuConfig.url === this.$route.path && (menuConfig.params == null || menuConfig.params === undefined || menuConfig.params === '' || menuConfig.params === this.$route.query.param)) {
        return
      }
      if (menuConfig.url == null || menuConfig.url.trim().length === 0) {
        return
      }
      if (menuConfig.params != null && menuConfig.params != '') {
        // this.$router.push({ path: menuConfig.url, query: { index: menuConfig.index, param: menuConfig.params } })
      } else {
        // this.$router.push(menuConfig.url)
      }
      this.$router.push({ path: menuConfig.url, query: { index: menuConfig.index, param: menuConfig.params, time: (Math.random().toString()) } })
      this.$store.commit('pushtags', menuConfig)
      this.pushtags(menuConfig)
    },
    // èŽ·å–èœå•é…ç½®
    __getMenuConfig(value, key, menus) {
    __getMenuConfig (value, key, menus) {
      for (const menu of menus) {
        if (menu[key] === value) {
          return menu
@@ -106,43 +136,35 @@
    computeTableHeight () {
      this.$nextTick(() => {
        const height = window.innerHeight
        // console.log('main_app========================:'+height)
        const height13 = this.getEleHeghtByClassName('common-header',0)
        const height5 = document.getElementsByTagName( 'thead') && document.getElementsByTagName('thead')[0] ? document.getElementsByTagName('thead')[0].clientHeight : 0
        const height13 = this.getEleHeghtByClassName('common-header', 0)
        const height5 = document.getElementsByTagName('thead') && document.getElementsByTagName('thead')[0] ? document.getElementsByTagName('thead')[0].clientHeight : 0
        if (document.getElementsByClassName('main_app') && document.getElementsByClassName('main_app')[0]) {
          // console.log('main_app========================')
          // alert(height)
          const height3 = this.getEleHeghtByClassName('main-header',0)
          const height4 = this.getEleHeghtByClassName('table-pagination',0)
          const height2 = this.getEleHeghtByClassName('toolbar',0)
          const height6 = this.getEleHeghtByClassName('doumee-filter',0,16)
          const height7 = this.getEleHeghtByClassName('pt16',0,0)
          const height9 = this.getEleHeghtByClassName('static_wrap',0)
          const height10 = this.getEleHeghtByClassName('query_btns',0)
          const height11 = this.getEleHeghtByClassName('el-tabs-ele',0)
          const height12 = this.getEleHeghtByClassName('platgroup_tabs',0)
          this.$router.app.$store.commit('setTableHeightNew', height - height13- height3 - height5 - height6 - height2 - height7 - height4 - height9 - height10 - height11 - height12)
          // console.log('gableHeightNew', this.$router.app.$store.state.tableHeightNew)
        } else {
          // console.log('tableLayout========================')
          const height1 = this.getEleHeghtByClassName('table-search-form', 40,16)
          const height3 = this.getEleHeghtByClassName('main-header', 0)
          const height4 = this.getEleHeghtByClassName('table-pagination', 0)
          const height2 = this.getEleHeghtByClassName('toolbar', 0)
          // console.log('defualtlength', document.getElementsByClassName('table-search-form').length)
          const height6 = this.getEleHeghtByClassName('doumee-filter', 0, 16)
          const height7 = this.getEleHeghtByClassName('pt16', 0, 0)
          const height9 = this.getEleHeghtByClassName('static_wrap', 0)
          const height10 = this.getEleHeghtByClassName('query_btns', 0)
          const height11 = this.getEleHeghtByClassName('el-tabs-ele', 0)
          const height12 = this.getEleHeghtByClassName('platgroup_tabs', 0)
          this.$router.app.$store.commit('setTableHeightNew', height - height13 - height3 - height5 - height6 - height2 - height7 - height4 - height9 - height10 - height11 - height12)
        } else {
          const height1 = this.getEleHeghtByClassName('table-search-form', 40, 16)
          const height3 = this.getEleHeghtByClassName('main-header', 0)
          const height4 = this.getEleHeghtByClassName('table-pagination', 0)
          const height2 = this.getEleHeghtByClassName('toolbar', 0)
          this.$router.app.$store.commit('setTableHeightNew', height - height4 - height3 - height2 - height1 - height5 - height13)
          // console.log('gableHeightNew', this.$router.app.$store.state.tableHeightNew)
        }
      })
    },
    getEleHeghtByClassName (name, dv,margin) {
    getEleHeghtByClassName (name, dv, margin) {
      if ((document.getElementsByClassName(name) && document.getElementsByClassName(name)[0])) {
        let t = 0
        document.getElementsByClassName(name).forEach(e => {
          // console.log(name+'========================' + t + ':' + e.clientHeight)
          t++
        })
        return document.getElementsByClassName(name)[document.getElementsByClassName(name).length - 1].clientHeight+(margin||0)
        return document.getElementsByClassName(name)[document.getElementsByClassName(name).length - 1].clientHeight + (margin || 0)
      }
      return dv || 0
    }
admin/src/components/common/RichEditor.vue
@@ -204,9 +204,9 @@
          // è§†é¢‘上传
          uploadVideo: {
            fieldName: 'file',
            server: process.env.VUE_APP_API_PREFIX + '/public/upload?folder=richeditor',
            server: process.env.VUE_APP_API_PREFIX + '/visitsAdmin/cloudService/public/upload?folder=richeditor',
            // å•个文件的最大体积限制,默认为 10M
            maxFileSize: 50 * 1024 * 1024, // 50M
            maxFileSize: 500 * 1024 * 1024, // 50M
            // æœ€å¤šå¯ä¸Šä¼ å‡ ä¸ªæ–‡ä»¶ï¼Œé»˜è®¤ä¸º 5
            maxNumberOfFiles: 3,
            // é€‰æ‹©æ–‡ä»¶æ—¶çš„类型限制,默认为 ['video/*'] ã€‚如不想限制,则设置为 []
admin/src/components/common/tagsview.vue
@@ -12,7 +12,7 @@
        :key="index"
        :id="'tags-box-' + index"
        @contextmenu.prevent="openMenu(item, $event)"
        :class="isActive(item.url, item.index,index) ? 'active' : ''"
        :class="isActive(item.url, item.index) ? 'active' : ''"
        class="tagsview"
        @click="tagsmenu(item, index)"
      >
@@ -134,27 +134,34 @@
          }
        } else {
          // é‚£ä¹ˆï¼Œå¦‚果上面的条件都不成立,没有length=0.也就是说你还有好几个标签,并且你删除的是最后一位标签,那么就往左边挪一位跳转路由
          this.$router.push({ path: this.tags[index - 1].url, query: { param: this.tags[index - 1].params } })
          this.$router.push({
            path: this.tags[index - 1].url,
            query: { index: this.tags[index - 1].index, param: this.tags[index - 1].params }
          })
        }
      } else {
        // å¦‚果你点击不是最后一位标签,点的前面的,那就往右边跳转
        this.$router.push({ path: this.tags[index].url, query: { param: this.tags[index].params } })
        this.$router.push({
          path: this.tags[index].url,
          query: { index: this.tags[index].index, param: this.tags[index].params }
        })
      }
    },
    // ç‚¹å‡»è·³è½¬è·¯ç”±
    tagsmenu (item, index) {
      console.log('tagsmenu')
      // åˆ¤æ–­ï¼šå½“前路由不等于当前选中项的url,也就代表你点击的不是现在选中的标签,是另一个标签就跳转过去,如果你点击的是现在已经选中的标签就不用跳转了,因为你已经在这个路由了还跳什么呢。
      if (this.$route.path !== item.url) {
        // ç”¨path的跳转方法把当前项的url当作地址跳转。
        this.$router.push({ path: item.url, query: { index: this.tags[index].index, param: this.tags[index].params, time: (Math.random().toString())} })
        // this.$router.push( item.url)
      if (this.$route.path !== item.url || this.$route.query.index !== item.index) {
        this.$router.push({
          path: item.url,
          query: {
            index: item.index,
            param: item.params,
            time: (Math.random().toString())
          }
        })
        const tagsDiv = document.getElementById('tags-box')
        if (index) {
        if (tagsDiv && index) {
          tagsDiv.scrollTo(index * 110, 0)
        }
      }
      // this.computeTableHeight()
    },
    computeTableHeight () {
      this.$nextTick(() => {
@@ -201,9 +208,8 @@
      return dv || 0
    },
    // é€šè¿‡åˆ¤æ–­è·¯ç”±ä¸€è‡´è¿”回布尔值添加class,添加高亮效果
    isActive (route, params, index) {
      const res = (route === this.$route.path && params == this.$route.query.index)
      return res
    isActive (route, index) {
      return route === this.$route.path && index === this.$route.query.index
    },
    scrollToStart () {
      const tagsDiv = document.getElementById('tags-box')
admin/src/main.js
@@ -51,6 +51,10 @@
      if (this.topMenuCurrent == null) {
        return
      }
      if (this.$store.state.skipTopMenuNavigation) {
        this.$store.state.skipTopMenuNavigation = false
        return
      }
      await this.chagneRoutes()
    }
  },
admin/src/store/index.js
@@ -31,7 +31,9 @@
  // tagsview标签显示隐藏
  isCollapse: false,
  // é¡¶éƒ¨èœå•索引
  currentIndex: 0
  currentIndex: 0,
  // è·¯ç”±åŒæ­¥é¡¶çº§èœå•时跳过首页跳转
  skipTopMenuNavigation: false
}
const mutations = {
@@ -74,16 +76,27 @@
  // è®¾ç½®é¦–页路由信息
  setTopMenuCurrent (state, current) {
    console.log('setTopMenuCurrent', current)
    state.skipTopMenuNavigation = false
    if (current.id !== state.topMenuCurrent.id) {
      state.topMenuList.list.forEach(item => {
      state.topMenuList.list.forEach((item, index) => {
        console.log(item.id, item.id)
        if (current.id == item.id) {
          state.topMenuCurrent = current
          state.menuData.list = item.children
          state.currentIndex = index
        }
      })
    }
  },
  syncTopMenuFromRoute (state, { topMenu, topIndex }) {
    if (!topMenu || topMenu.id === state.topMenuCurrent.id) {
      return
    }
    state.skipTopMenuNavigation = true
    state.topMenuCurrent = topMenu
    state.menuData.list = topMenu.children || []
    state.currentIndex = topIndex
  },
  // é‡ç½®èœå•
  resetMenus: (state) => {
    state.topMenuId = null
admin/src/utils/menuRoute.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
function normalizePath (path) {
  if (!path) {
    return ''
  }
  return path.endsWith('/') ? path.substring(0, path.length - 1) : path
}
function matchMenuRoute (menu, path, index) {
  if (!menu || !menu.url) {
    return false
  }
  if (normalizePath(menu.url) !== path) {
    return false
  }
  if (index != null && index !== '' && menu.index !== index) {
    return false
  }
  return true
}
function findMenuInTree (menus, path, index, parents = []) {
  if (!menus || menus.length === 0) {
    return null
  }
  for (const menu of menus) {
    if (matchMenuRoute(menu, path, index)) {
      return { menu, parents }
    }
    if (menu.children && menu.children.length > 0) {
      const found = findMenuInTree(menu.children, path, index, parents.concat(menu))
      if (found) {
        return found
      }
    }
  }
  return null
}
export function findMenuByRoute (topMenuList, path, index) {
  const normalizedPath = normalizePath(path)
  if (!normalizedPath || !topMenuList || topMenuList.length === 0) {
    return null
  }
  for (let topIndex = 0; topIndex < topMenuList.length; topIndex++) {
    const topMenu = topMenuList[topIndex]
    if (topMenu.linkType !== 0) {
      continue
    }
    let found = null
    if (index != null && index !== '') {
      found = findMenuInTree(topMenu.children || [], normalizedPath, index)
    }
    if (!found) {
      found = findMenuInTree(topMenu.children || [], normalizedPath, null)
    }
    if (found) {
      return {
        topMenu,
        topIndex,
        menu: found.menu,
        parents: found.parents
      }
    }
  }
  return null
}
admin/src/views/business/collectionDockDevice.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
<template>
  <TableLayout :permissions="['business:collectionDockDevice:query', 'business:collectionStation:query']">
    <div ref="QueryFormRef" slot="search-form">
      <el-form ref="searchForm" :model="searchForm" label-width="100px" inline>
        <el-form-item label="采集站" prop="stationId">
          <el-select v-model="searchForm.stationId" placeholder="全部" clearable filterable @change="search">
            <el-option v-for="item in stationOptions" :key="item.id" :label="item.name" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="设备名称" prop="deviceName">
          <el-input v-model="searchForm.deviceName" placeholder="设备名称" @keypress.enter.native="search" />
        </el-form-item>
        <el-form-item label="序列号" prop="shortSerialNumber">
          <el-input v-model="searchForm.shortSerialNumber" placeholder="短序列号" @keypress.enter.native="search" />
        </el-form-item>
        <section>
          <el-button type="primary" @click="search">搜索</el-button>
          <el-button @click="reset">重置</el-button>
        </section>
      </el-form>
    </div>
    <template v-slot:table-wrap>
      <ul class="toolbar">
        <li>
          <el-button type="primary" v-permissions="['business:collectionStation:sync']" @click="handleSyncAll">同步全部采集站</el-button>
        </li>
        <li>
          <el-button v-permissions="['business:collectionStation:sync']" :disabled="!searchForm.stationId" @click="handleSyncOne">同步当前采集站</el-button>
        </li>
      </ul>
      <el-table :height="tableHeightNew" v-loading="isWorking.search" :data="tableData.list" stripe>
        <el-table-column label="序号" width="55"><template slot-scope="scope">{{ scope.$index + 1 }}</template></el-table-column>
        <el-table-column prop="stationName" label="采集站" min-width="120" />
        <el-table-column prop="deviceName" label="设备名称" min-width="140" show-overflow-tooltip />
        <el-table-column prop="deviceId" label="设备ID" min-width="120" show-overflow-tooltip />
        <el-table-column prop="shortSerialNumber" label="短序列号" min-width="140" />
        <el-table-column prop="accessDeviceId" label="平台注册ID" min-width="140" show-overflow-tooltip />
        <el-table-column prop="networkedDevice" label="联网设备" width="90">
          <template slot-scope="{row}">
            <span v-if="row.networkedDevice === 1">是</span>
            <span v-else-if="row.networkedDevice === 0">否</span>
          </template>
        </el-table-column>
        <el-table-column prop="lastSyncTime" label="最近同步" min-width="160" />
        <el-table-column prop="createDate" label="创建时间" min-width="160" />
      </el-table>
      <pagination @size-change="handleSizeChange" @current-change="handlePageChange" :pagination="tableData.pagination" />
    </template>
  </TableLayout>
</template>
<script>
import BaseTable from '@/components/base/BaseTable'
import TableLayout from '@/layouts/TableLayout'
import Pagination from '@/components/common/Pagination'
import { list as fetchStationList, syncDevices, syncDevice } from '@/api/business/collectionStation'
export default {
  name: 'CollectionDockDevice',
  extends: BaseTable,
  components: { TableLayout, Pagination },
  data () {
    return {
      stationOptions: [],
      searchForm: {
        stationId: null,
        deviceName: '',
        shortSerialNumber: ''
      }
    }
  },
  created () {
    const stationId = this.$route.query.stationId ? parseInt(this.$route.query.stationId) : null
    if (stationId) {
      this.searchForm.stationId = stationId
    }
    this.config({
      module: '执法记录仪',
      api: '/business/collectionDockDevice',
      'field.id': 'id',
      'field.main': 'deviceName'
    })
    this.loadStations()
    this.search()
  },
  methods: {
    loadStations () {
      fetchStationList({ status: 1 }).then(res => {
        this.stationOptions = res || []
      })
    },
    handleSyncAll () {
      syncDevices().then(res => {
        this.$message.success(res.data || '同步完成')
        this.search()
      })
    },
    handleSyncOne () {
      syncDevice(this.searchForm.stationId).then(res => {
        this.$message.success(res.data || '同步成功')
        this.search()
      })
    }
  }
}
</script>
admin/src/views/business/collectionMedia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,372 @@
<template>
  <TableLayout :permissions="['business:collectionMedia:query', 'business:collectionStation:query']">
    <div ref="QueryFormRef" slot="search-form">
      <el-form ref="searchForm" :model="searchForm" label-width="100px" inline>
        <el-form-item label="采集站" prop="stationId">
          <el-select v-model="searchForm.stationId" placeholder="全部" clearable filterable @change="onStationChange">
            <el-option v-for="item in stationOptions" :key="item.id" :label="item.name" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="下载状态" prop="downloadStatus">
          <el-select v-model="searchForm.downloadStatus" placeholder="请选择" clearable>
            <el-option label="待下载" :value="0" />
            <el-option label="已下载" :value="1" />
            <el-option label="失败" :value="2" />
            <el-option label="下载中" :value="3" />
          </el-select>
        </el-form-item>
        <el-form-item label="类型" prop="mediaType">
          <el-select v-model="searchForm.mediaType" placeholder="请选择" clearable>
            <el-option label="视频" :value="0" />
            <el-option label="图片" :value="1" />
            <el-option label="音频" :value="2" />
          </el-select>
        </el-form-item>
        <section>
          <el-button type="primary" @click="search">搜索</el-button>
          <el-button @click="reset">重置</el-button>
        </section>
      </el-form>
    </div>
    <template v-slot:table-wrap>
      <ul class="toolbar">
        <li>
          <el-button v-permissions="['business:collectionMedia:sync', 'business:collectionStation:sync']" @click="handleSyncMedia">同步索引</el-button>
        </li>
        <li>
          <el-button type="primary" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']" @click="handleBatchDownload">批量下载</el-button>
        </li>
      </ul>
      <el-table :height="tableHeightNew" v-loading="isWorking.search" :data="tableData.list" stripe>
        <el-table-column label="序号" width="55"><template slot-scope="scope">{{ scope.$index + 1 }}</template></el-table-column>
        <el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
        <el-table-column prop="mediaType" label="类型" width="80">
          <template slot-scope="{row}">
            <span>{{ mediaTypeLabel(row) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="fileSize" label="大小(字节)" min-width="100" />
        <el-table-column prop="startTime" label="开始时间" min-width="160" />
        <el-table-column prop="endTime" label="结束时间" min-width="160" />
        <el-table-column prop="downloadStatus" label="下载状态" width="90">
          <template slot-scope="{row}">
            <span v-if="row.downloadStatus === 1">已下载</span>
            <span v-else-if="row.downloadStatus === 2">失败</span>
            <span v-else-if="row.downloadStatus === 3">下载中</span>
            <span v-else>待下载</span>
          </template>
        </el-table-column>
        <el-table-column prop="filePathLocal" label="本地路径" min-width="160" show-overflow-tooltip />
        <el-table-column label="操作" width="220" fixed="right">
          <template slot-scope="{row}">
            <el-button type="text" v-if="canPreview(row)" @click="handlePreview(row)">预览</el-button>
            <el-button type="text" v-if="canDownloadToStation(row)" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']"
              @click="handleDownload(row)">下载</el-button>
            <el-button type="text" v-if="row.downloadStatus === 1" @click="handleSaveLocal(row)">下载到本地</el-button>
          </template>
        </el-table-column>
      </el-table>
      <pagination @size-change="handleSizeChange" @current-change="handlePageChange" :pagination="tableData.pagination" />
    </template>
    <el-dialog :title="previewTitle" :visible.sync="previewVisible" width="860px" append-to-body @close="closePreview">
      <div v-loading="previewLoading" class="preview-wrap">
        <video v-if="previewMode === 'video' && previewSrc" :key="previewSrc" :src="previewSrc" controls
          preload="metadata" playsinline class="preview-video" @error="onPreviewMediaError" />
        <audio v-else-if="previewMode === 'audio' && previewSrc" :key="previewSrc" :src="previewSrc" controls class="preview-audio"
          @error="onPreviewMediaError" />
        <el-image v-else-if="previewMode === 'image' && previewSrc" :src="previewSrc" fit="contain" class="preview-image"
          :preview-src-list="[previewSrc]" />
        <pre v-else-if="previewMode === 'text'" class="preview-text">{{ previewText }}</pre>
        <div v-else-if="!previewLoading" class="preview-empty">暂不支持该类型预览</div>
      </div>
    </el-dialog>
  </TableLayout>
</template>
<script>
import BaseTable from '@/components/base/BaseTable'
import TableLayout from '@/layouts/TableLayout'
import Pagination from '@/components/common/Pagination'
import { syncMedia, downloadMedia, batchDownloadMedia, list as fetchStationList } from '@/api/business/collectionStation'
import { fetchPreviewText, fetchPreviewBlob, fetchMediaFile, ensureMp4Blob, buildPreviewUrl } from '@/api/business/collectionMedia'
export default {
  name: 'CollectionMedia',
  extends: BaseTable,
  components: { TableLayout, Pagination },
  data () {
    return {
      stationOptions: [],
      searchForm: {
        stationId: null,
        downloadStatus: null,
        mediaType: null
      },
      previewVisible: false,
      previewLoading: false,
      previewTitle: '媒体预览',
      previewMode: '',
      previewSrc: '',
      previewText: '',
      previewRow: null,
      previewUseDirectUrl: false,
      previewBlobUrl: '',
      downloadPollTimer: null
    }
  },
  created () {
    if (this.$route.query.stationId) {
      this.searchForm.stationId = parseInt(this.$route.query.stationId)
    }
    this.config({
      module: '采集站媒体',
      api: '/business/collectionMedia',
      'field.id': 'id',
      'field.main': 'id'
    })
    this.loadStations()
    this.search()
  },
  beforeDestroy () {
    this.revokePreviewUrl()
    this.stopDownloadPoll()
  },
  methods: {
    loadStations () {
      fetchStationList({ status: 1 }).then(res => {
        this.stationOptions = res || []
      })
    },
    onStationChange () {
      this.search()
    },
    mediaTypeLabel (row) {
      const mode = this.resolvePreviewMode(row)
      if (mode === 'image') return '图片'
      if (mode === 'audio') return '音频'
      if (mode === 'text') return '文本'
      if (row.mediaType === 1) return '图片'
      if (row.mediaType === 2) return '音频'
      return '视频'
    },
    canPreview (row) {
      return row.downloadStatus === 1 && (row.fileUrlFull || row.filePathLocal)
    },
    canDownloadToStation (row) {
      return row.downloadStatus !== 1 && row.downloadStatus !== 3
    },
    resolvePreviewMode (row) {
      const name = (row.fileName || '').toLowerCase()
      if (/\.(jpg|jpeg|png|gif|bmp|webp)$/.test(name) || row.mediaType === 1) {
        return 'image'
      }
      if (/\.(mp3|wav|aac|m4a)$/.test(name) || row.mediaType === 2) {
        return 'audio'
      }
      if (/\.(txt|log)$/.test(name)) {
        return 'text'
      }
      if (/\.(mp4|mov|avi|mkv|webm|flv|m4v)$/.test(name) || row.mediaType === 0) {
        return 'video'
      }
      return 'video'
    },
    handleSyncMedia () {
      syncMedia({ stationId: this.searchForm.stationId }).then(res => {
        this.$message.success(res || '同步完成')
        this.search()
      })
    },
    handleBatchDownload () {
      batchDownloadMedia({ stationId: this.searchForm.stationId, limit: 10 }).then(res => {
        this.$message.success(res || '已提交下载任务')
        this.search()
        this.startDownloadPoll()
      })
    },
    handleDownload (row) {
      downloadMedia(row.id).then(res => {
        this.$message.success(res || '已提交下载任务')
        this.search()
        this.startDownloadPoll()
      })
    },
    handleSaveLocal (row) {
      fetchMediaFile(row.id).then(response => {
        this.download(response)
      }).catch(err => {
        this.$message.error(err.message || '下载到本地失败')
      })
    },
    startDownloadPoll () {
      this.stopDownloadPoll()
      let count = 0
      this.downloadPollTimer = setInterval(() => {
        count++
        if (count > 40) {
          this.stopDownloadPoll()
          return
        }
        this.api.fetchList({
          page: this.tableData.pagination.pageIndex,
          capacity: this.tableData.pagination.pageSize,
          model: this.searchForm,
          sorts: this.tableData.sorts
        }).then(data => {
          this.tableData.list = data.records
          this.tableData.pagination.total = data.total
          if (!(data.records || []).some(item => item.downloadStatus === 3)) {
            this.stopDownloadPoll()
          }
        }).catch(() => {})
      }, 3000)
    },
    stopDownloadPoll () {
      if (this.downloadPollTimer) {
        clearInterval(this.downloadPollTimer)
        this.downloadPollTimer = null
      }
    },
    handlePreview (row) {
      if (row.downloadStatus !== 1) {
        this.$message.warning('请先下载文件后再预览')
        return
      }
      this.previewTitle = row.fileName || '媒体预览'
      this.previewVisible = true
      this.previewLoading = true
      this.previewMode = this.resolvePreviewMode(row)
      this.previewRow = row
      this.previewSrc = ''
      this.previewText = ''
      this.previewUseDirectUrl = false
      this.revokePreviewUrl()
      if (this.previewMode === 'text') {
        fetchPreviewText(row.id).then(text => {
          this.previewText = text || ''
          this.previewLoading = false
        }).catch(err => {
          this.previewLoading = false
          this.$message.error(err.message || '文本预览失败')
        })
        return
      }
      if (this.previewMode === 'video') {
        this.loadVideoPreview(row)
        return
      }
      if (this.previewMode === 'audio') {
        this.previewUseDirectUrl = !!row.fileUrlFull
        this.previewSrc = row.fileUrlFull || buildPreviewUrl(row.id)
        this.previewLoading = false
        return
      }
      if (row.fileUrlFull) {
        this.previewSrc = row.fileUrlFull
        this.previewLoading = false
        return
      }
      this.previewLoading = false
      this.$message.warning('暂无可预览地址')
    },
    loadVideoPreview (row) {
      this.revokePreviewUrl()
      fetchPreviewBlob(row.id)
        .then(blob => ensureMp4Blob(blob))
        .then(blob => {
          this.previewBlobUrl = URL.createObjectURL(blob)
          this.previewSrc = this.previewBlobUrl
          this.previewUseDirectUrl = false
          this.previewLoading = false
        })
        .catch(err => {
          if (row.fileUrlFull) {
            this.previewUseDirectUrl = true
            this.previewSrc = row.fileUrlFull
            this.previewLoading = false
            return
          }
          this.previewLoading = false
          this.$message.error(err.message || '视频预览失败,请重新下载该文件')
        })
    },
    onPreviewMediaError () {
      if (!this.previewRow) {
        this.$message.error('媒体加载失败')
        return
      }
      if (this.previewMode === 'video') {
        if (this.previewUseDirectUrl) {
          this.$message.error('视频无法播放,请重新下载该文件(需采集站转 MP4)')
          return
        }
        if (this.previewRow.fileUrlFull) {
          this.previewUseDirectUrl = true
          this.revokePreviewUrl()
          this.previewSrc = this.previewRow.fileUrlFull
          return
        }
      }
      if (this.previewUseDirectUrl && this.previewMode === 'audio') {
        this.previewUseDirectUrl = false
        this.previewSrc = buildPreviewUrl(this.previewRow.id)
        return
      }
      this.$message.error('视频加载失败,请重新下载该文件或联系管理员检查采集站转码配置')
    },
    closePreview () {
      this.revokePreviewUrl()
      this.previewSrc = ''
      this.previewText = ''
      this.previewMode = ''
      this.previewRow = null
      this.previewUseDirectUrl = false
    },
    revokePreviewUrl () {
      if (this.previewBlobUrl) {
        URL.revokeObjectURL(this.previewBlobUrl)
        this.previewBlobUrl = ''
      }
    }
  }
}
</script>
<style scoped>
.preview-wrap {
  min-height: 200px;
}
.preview-video {
  width: 100%;
  max-height: 520px;
  background: #000;
}
.preview-audio {
  width: 100%;
}
.preview-image {
  width: 100%;
  max-height: 520px;
}
.preview-text {
  max-height: 520px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-all;
  margin: 0;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 4px;
}
.preview-empty {
  text-align: center;
  color: #909399;
  padding: 40px 0;
}
</style>
admin/src/views/business/collectionStation.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
<template>
  <TableLayout :permissions="['business:collectionStation:query']">
    <div ref="QueryFormRef" slot="search-form">
      <el-form ref="searchForm" :model="searchForm" label-width="100px" inline>
        <el-form-item label="名称" prop="name">
          <el-input v-model="searchForm.name" placeholder="采集站名称" @keypress.enter.native="search" />
        </el-form-item>
        <el-form-item label="IP" prop="ip">
          <el-input v-model="searchForm.ip" placeholder="设备IP" @keypress.enter.native="search" />
        </el-form-item>
        <el-form-item label="在线状态" prop="online">
          <el-select v-model="searchForm.online" placeholder="请选择" clearable>
            <el-option label="离线" :value="0" />
            <el-option label="在线" :value="1" />
          </el-select>
        </el-form-item>
        <section>
          <el-button type="primary" @click="search">搜索</el-button>
          <el-button @click="reset">重置</el-button>
        </section>
      </el-form>
    </div>
    <template v-slot:table-wrap>
      <ul class="toolbar">
        <li>
          <el-button type="primary" icon="el-icon-plus" v-permissions="['business:collectionStation:create']"
            @click="$refs.operaWindow.open('新建采集站')">新建</el-button>
        </li>
        <li>
          <el-button type="primary" v-permissions="['business:collectionStation:sync']" @click="handleSyncAll">同步状态</el-button>
        </li>
        <li>
          <el-button type="primary" v-permissions="['business:collectionMedia:sync', 'business:collectionStation:sync']" @click="handleSyncMedia">同步媒体索引</el-button>
        </li>
        <li>
          <el-button type="primary" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']" @click="handleBatchDownload">批量下载</el-button>
        </li>
      </ul>
      <el-table :height="tableHeightNew" v-loading="isWorking.search" :data="tableData.list" stripe>
        <el-table-column label="序号" width="55"><template slot-scope="scope">{{ scope.$index + 1 }}</template></el-table-column>
        <el-table-column prop="name" label="名称" min-width="120" />
        <el-table-column prop="ip" label="IP" min-width="120" />
        <el-table-column prop="port" label="端口" width="80" />
        <el-table-column prop="model" label="型号" min-width="100" />
        <el-table-column prop="serialNo" label="序列号" min-width="140" />
        <el-table-column prop="online" label="在线" width="80">
          <template slot-scope="{row}">
            <el-link type="success" :underline="false" v-if="row.online === 1">在线</el-link>
            <el-link type="danger" :underline="false" v-else>离线</el-link>
          </template>
        </el-table-column>
        <el-table-column label="存储(GB)" min-width="180">
          <template slot-scope="{row}">
            <span v-if="row.totalSpace != null">{{ formatStorage(row.freeSpace) }} / {{ formatStorage(row.totalSpace) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="softwareVersion" label="版本" min-width="100" />
        <el-table-column prop="lastSyncTime" label="最近同步" min-width="160" />
        <el-table-column label="操作" min-width="220" fixed="right">
          <template slot-scope="{row}">
            <el-button type="text" v-permissions="['business:collectionStation:update']"
              @click="$refs.operaWindow.open('编辑采集站', row)">编辑</el-button>
            <el-button type="text" v-permissions="['business:collectionStation:sync']" @click="handleSyncOne(row)">同步</el-button>
            <el-button type="text" v-permissions="['business:collectionStation:sync']" @click="handleProbe(row)">ISAPI探测</el-button>
            <el-button type="text" v-permissions="['business:collectionStation:delete']" @click="deleteById(row)">删除</el-button>
            <el-button type="text" @click="goMedia(row)">媒体</el-button>
            <el-button type="text" @click="goDockDevice(row)">执法记录仪</el-button>
          </template>
        </el-table-column>
      </el-table>
      <pagination @size-change="handleSizeChange" @current-change="handlePageChange" :pagination="tableData.pagination" />
    </template>
    <OperaCollectionStationWindow ref="operaWindow" @success="handlePageChange" />
  </TableLayout>
</template>
<script>
import BaseTable from '@/components/base/BaseTable'
import TableLayout from '@/layouts/TableLayout'
import Pagination from '@/components/common/Pagination'
import OperaCollectionStationWindow from '@/components/business/OperaCollectionStationWindow'
import { syncDevices, syncDevice, syncMedia, batchDownloadMedia, probeIsapi } from '@/api/business/collectionStation'
export default {
  name: 'CollectionStation',
  extends: BaseTable,
  components: { TableLayout, Pagination, OperaCollectionStationWindow },
  data () {
    return {
      searchForm: {
        name: '',
        ip: '',
        online: null
      }
    }
  },
  created () {
    this.config({
      module: '采集站',
      api: '/business/collectionStation',
      'field.id': 'id',
      'field.main': 'id'
    })
    this.search()
  },
  methods: {
    formatStorage (val) {
      if (val == null || val === '') {
        return '0'
      }
      const num = Number(val)
      if (Number.isNaN(num)) {
        return val
      }
      return Number.isInteger(num) ? String(num) : num.toFixed(2)
    },
    goMedia (row) {
      this.$router.push({ path: '/business/collectionMedia', query: { stationId: row.id } })
    },
    goDockDevice (row) {
      this.$router.push({ path: '/business/collectionDockDevice', query: { stationId: row.id } })
    },
    handleSyncAll () {
      syncDevices().then(res => {
        this.$message.success(res.data || '同步完成')
        this.search()
      })
    },
    handleSyncOne (row) {
      syncDevice(row.id).then(res => {
        this.$message.success(res.data || '同步成功')
        this.search()
      })
    },
    handleSyncMedia () {
      syncMedia({}).then(res => {
        this.$message.success(res.data || '同步完成')
      })
    },
    handleBatchDownload () {
      batchDownloadMedia({ limit: 10 }).then(res => {
        this.$message.success(res.data || '下载完成')
      })
    },
    handleProbe (row) {
      probeIsapi(row.id).then(res => {
        this.$alert('<pre style="max-height:400px;overflow:auto;text-align:left">' + (res.data || '') + '</pre>', 'ISAPI探测', {
          dangerouslyUseHTMLString: true,
          customClass: 'isapi-probe-dialog'
        })
      })
    }
  }
}
</script>
admin/src/views/system/menu.vue
@@ -24,7 +24,6 @@
        :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
        row-key="id"
        stripe
        default-expand-all
        @selection-change="handleSelectionChange"
      >
        <el-table-column type="selection" width="55" fixed="left"></el-table-column>
server/db/business.collection_dock_device.permissions.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionDockDevice:query', '查询执法记录仪', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
server/db/business.collection_dock_device.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `collection_dock_device` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `station_id` int(11) NOT NULL COMMENT '采集站ID',
  `device_id` varchar(64) NOT NULL COMMENT 'ISAPI deviceId',
  `device_name` varchar(128) DEFAULT NULL COMMENT '设备名称',
  `short_serial_number` varchar(64) DEFAULT NULL COMMENT '执法记录仪短序列号',
  `access_device_id` varchar(64) DEFAULT NULL COMMENT '平台注册ID',
  `networked_device` int(11) DEFAULT NULL COMMENT '是否联网设备 0否1是',
  `last_sync_time` datetime DEFAULT NULL COMMENT '最近同步时间',
  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
  `isdeleted` int(11) DEFAULT '0' COMMENT '是否删除0否1是',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_station_device` (`station_id`,`device_id`),
  KEY `idx_station_id` (`station_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集站执法记录仪设备';
server/db/business.collection_media.alter.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
ALTER TABLE `collection_media`
  ADD COLUMN `track_id` varchar(32) DEFAULT NULL COMMENT 'ISAPI trackID/mediaID' AFTER `file_index`;
server/db/business.collection_media.download_status.alter.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- ä¸‹è½½çŠ¶æ€ï¼š0待下载 1已下载 2失败 3下载中
ALTER TABLE `collection_media`
  MODIFY COLUMN `download_status` int(11) DEFAULT '0' COMMENT '0待下载 1已下载 2失败 3下载中';
server/db/business.collection_media.permissions.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionMedia:query', '查询采集站媒体', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionMedia:sync', '同步采集站媒体', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionMedia:download', '下载采集站媒体', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
server/db/business.collection_media.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS `collection_media` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `station_id` int(11) NOT NULL COMMENT '采集站ID',
  `file_index` varchar(128) NOT NULL COMMENT '设备侧文件唯一标识(mediaID)',
  `track_id` varchar(32) DEFAULT NULL COMMENT 'ISAPI trackID',
  `file_name` varchar(256) DEFAULT NULL COMMENT '文件名',
  `playback_uri` varchar(1024) DEFAULT NULL COMMENT 'ISAPI search返回的playbackURI',
  `media_type` int(11) DEFAULT '0' COMMENT '0视频 1图片 2音频',
  `content_type` varchar(64) DEFAULT NULL COMMENT 'video/picture/audio',
  `file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
  `start_time` datetime DEFAULT NULL COMMENT '录制开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '录制结束时间',
  `recorder_sn` varchar(64) DEFAULT NULL COMMENT '执法记录仪序列号',
  `user_name` varchar(64) DEFAULT NULL COMMENT '关联用户',
  `file_path_local` varchar(512) DEFAULT NULL COMMENT 'FTP本地相对路径',
  `download_status` int(11) DEFAULT '0' COMMENT '0待下载 1已下载 2失败 3下载中',
  `download_time` datetime DEFAULT NULL COMMENT '下载完成时间',
  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
  `isdeleted` int(11) DEFAULT '0' COMMENT '是否删除0否 1是',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_station_file` (`station_id`,`file_index`),
  KEY `idx_download_status` (`download_status`),
  KEY `idx_station_id` (`station_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采集站媒体文件';
server/db/business.collection_station.alter.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
-- MySQL 5.x:若列已存在请跳过
ALTER TABLE `collection_station` ADD COLUMN `use_https` int(11) DEFAULT '0' COMMENT '是否HTTPS 0否1是' AFTER `port`;
server/db/business.collection_station.dict.alter.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- å·²æœ‰çŽ¯å¢ƒï¼šå°† track æ£€ç´¢æ”¹ä¸ºè‡ªåŠ¨æ¨¡å¼ï¼ˆæ£€ç´¢è®¾å¤‡å…¨éƒ¨ track)
UPDATE `SYSTEM_DICT_DATA` dd
INNER JOIN `SYSTEM_DICT` d ON d.`ID` = dd.`DICT_ID` AND d.`CODE` = 'CS_PARAM' AND d.`DELETED` = 0
SET dd.`CODE` = 'auto', dd.`REMARK` = '媒体检索trackID,auto/空=自动从record/tracks获取全部'
WHERE dd.`LABEL` = 'CS_SEARCH_TRACK_ID' AND dd.`DELETED` = 0 AND dd.`CODE` = '101';
INSERT INTO `SYSTEM_DICT_DATA`(`DICT_ID`, `LABEL`, `CODE`, `REMARK`, `SORT`, `DISABLED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT d.`ID`, 'CS_FFMPEG_PATH', 'ffmpeg', 'FFmpeg可执行文件路径,用于采集站视频转码为浏览器可播MP4', 3, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_DICT` d
WHERE d.`CODE` = 'CS_PARAM' AND d.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_DICT_DATA` dd
  WHERE dd.`DICT_ID` = d.`ID` AND dd.`LABEL` = 'CS_FFMPEG_PATH' AND dd.`DELETED` = 0
);
server/db/business.collection_station.dict.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
-- é‡‡é›†ç«™ ISAPI å‚数(系统字典 CS_PARAM)
-- è¡¨ç»“构:SYSTEM_DICT(字典类型)+ SYSTEM_DICT_DATA(字典项,LABEL=键名,CODE=配置值)
INSERT INTO `SYSTEM_DICT`(`CODE`, `NAME`, `REMARK`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT 'CS_PARAM', '采集站ISAPI参数', '海康采集站ISAPI对接配置', 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `SYSTEM_DICT` WHERE `CODE` = 'CS_PARAM' AND `DELETED` = 0);
INSERT INTO `SYSTEM_DICT_DATA`(`DICT_ID`, `LABEL`, `CODE`, `REMARK`, `SORT`, `DISABLED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT d.`ID`, 'CS_SEARCH_TRACK_ID', 'auto', '媒体检索trackID,auto/空=自动从record/tracks获取全部', 1, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_DICT` d
WHERE d.`CODE` = 'CS_PARAM' AND d.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_DICT_DATA` dd
  WHERE dd.`DICT_ID` = d.`ID` AND dd.`LABEL` = 'CS_SEARCH_TRACK_ID' AND dd.`DELETED` = 0
);
INSERT INTO `SYSTEM_DICT_DATA`(`DICT_ID`, `LABEL`, `CODE`, `REMARK`, `SORT`, `DISABLED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT d.`ID`, 'CS_DOWNLOAD_BATCH_SIZE', '10', '批量下载媒体文件数量', 2, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_DICT` d
WHERE d.`CODE` = 'CS_PARAM' AND d.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_DICT_DATA` dd
  WHERE dd.`DICT_ID` = d.`ID` AND dd.`LABEL` = 'CS_DOWNLOAD_BATCH_SIZE' AND dd.`DELETED` = 0
);
INSERT INTO `SYSTEM_DICT_DATA`(`DICT_ID`, `LABEL`, `CODE`, `REMARK`, `SORT`, `DISABLED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT d.`ID`, 'CS_FFMPEG_PATH', 'ffmpeg', 'FFmpeg可执行文件路径,用于采集站视频转码为浏览器可播MP4', 3, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_DICT` d
WHERE d.`CODE` = 'CS_PARAM' AND d.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_DICT_DATA` dd
  WHERE dd.`DICT_ID` = d.`ID` AND dd.`LABEL` = 'CS_FFMPEG_PATH' AND dd.`DELETED` = 0
);
-- FTP å­—典通常已存在,仅追加采集站媒体存储目录
INSERT INTO `SYSTEM_DICT_DATA`(`DICT_ID`, `LABEL`, `CODE`, `REMARK`, `SORT`, `DISABLED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT d.`ID`, 'COLLECTION_MEDIA', '/collection_media/', '采集站媒体FTP存储目录', 99, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_DICT` d
WHERE d.`CODE` = 'FTP' AND d.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_DICT_DATA` dd
  WHERE dd.`DICT_ID` = d.`ID` AND dd.`LABEL` = 'COLLECTION_MEDIA' AND dd.`DELETED` = 0
);
server/db/business.collection_station.menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
-- äº¤æŽ§ä¸­å¿ƒ > æ•°æ®é‡‡é›†ç«™ > é‡‡é›†ç«™/媒体/执法记录仪 èœå•
-- æ‰§è¡Œå‰è¯·ç¡®è®¤ SYSTEM_MENU ä¸­å·²å­˜åœ¨ NAME='交控中心' ä¸” TYPE=1 çš„顶级菜单
-- æ‰§è¡ŒåŽè¯·åœ¨ã€Œè§’色管理 â†’ æŽˆæƒèœå•」中为相关角色勾选新菜单
-- 1. æ–‡ä»¶å¤¹ï¼šæ•°æ®é‡‡é›†ç«™
INSERT INTO `SYSTEM_MENU`(`PARENT_ID`, `TYPE`, `LINK_TYPE`, `NAME`, `PATH`, `PARAMS`, `ICON`, `DISABLED`, `SORT`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT jk.`ID`, 0, 0, '数据采集站', NULL, NULL, 'el-icon-upload2', 0,
  IFNULL((SELECT MAX(m.`SORT`) FROM `SYSTEM_MENU` m WHERE m.`PARENT_ID` = jk.`ID` AND m.`DELETED` = 0), -1) + 1,
  0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_MENU` jk
WHERE jk.`NAME` = '交控中心' AND jk.`TYPE` = 1 AND jk.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_MENU` m WHERE m.`PARENT_ID` = jk.`ID` AND m.`NAME` = '数据采集站' AND m.`DELETED` = 0
);
-- 2. é‡‡é›†ç«™ç®¡ç†
INSERT INTO `SYSTEM_MENU`(`PARENT_ID`, `TYPE`, `LINK_TYPE`, `NAME`, `PATH`, `PARAMS`, `ICON`, `DISABLED`, `SORT`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT folder.`ID`, 0, 0, '采集站管理', '/business/collectionStation', NULL, 'el-icon-monitor', 0, 1, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_MENU` folder
INNER JOIN `SYSTEM_MENU` jk ON jk.`ID` = folder.`PARENT_ID` AND jk.`DELETED` = 0
WHERE jk.`NAME` = '交控中心' AND jk.`TYPE` = 1
AND folder.`NAME` = '数据采集站' AND folder.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_MENU` m WHERE m.`PARENT_ID` = folder.`ID` AND m.`PATH` = '/business/collectionStation' AND m.`DELETED` = 0
);
-- 3. åª’体文件
INSERT INTO `SYSTEM_MENU`(`PARENT_ID`, `TYPE`, `LINK_TYPE`, `NAME`, `PATH`, `PARAMS`, `ICON`, `DISABLED`, `SORT`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT folder.`ID`, 0, 0, '媒体文件', '/business/collectionMedia', NULL, 'el-icon-video-camera', 0, 2, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_MENU` folder
INNER JOIN `SYSTEM_MENU` jk ON jk.`ID` = folder.`PARENT_ID` AND jk.`DELETED` = 0
WHERE jk.`NAME` = '交控中心' AND jk.`TYPE` = 1
AND folder.`NAME` = '数据采集站' AND folder.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_MENU` m WHERE m.`PARENT_ID` = folder.`ID` AND m.`PATH` = '/business/collectionMedia' AND m.`DELETED` = 0
);
-- 4. æ‰§æ³•记录仪
INSERT INTO `SYSTEM_MENU`(`PARENT_ID`, `TYPE`, `LINK_TYPE`, `NAME`, `PATH`, `PARAMS`, `ICON`, `DISABLED`, `SORT`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT folder.`ID`, 0, 0, '执法记录仪', '/business/collectionDockDevice', NULL, 'el-icon-mobile-phone', 0, 3, 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_MENU` folder
INNER JOIN `SYSTEM_MENU` jk ON jk.`ID` = folder.`PARENT_ID` AND jk.`DELETED` = 0
WHERE jk.`NAME` = '交控中心' AND jk.`TYPE` = 1
AND folder.`NAME` = '数据采集站' AND folder.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_MENU` m WHERE m.`PARENT_ID` = folder.`ID` AND m.`PATH` = '/business/collectionDockDevice' AND m.`DELETED` = 0
);
server/db/business.collection_station.permissions.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionStation:create', '新建采集站', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionStation:delete', '删除采集站', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionStation:update', '修改采集站', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionStation:query', '查询采集站', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:collectionStation:sync', '同步采集站数据', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
server/db/business.collection_station.role_permissions.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
-- ä¸ºå·²æ‹¥æœ‰é‡‡é›†ç«™æƒé™çš„角色,自动补授媒体/执法记录仪权限
-- æ‰§è¡ŒåŽç”¨æˆ·éœ€é‡æ–°ç™»å½•以刷新权限缓存
INSERT INTO `SYSTEM_ROLE_PERMISSION`(`ROLE_ID`, `PERMISSION_ID`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT rp.`ROLE_ID`, p_new.`ID`, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_ROLE_PERMISSION` rp
INNER JOIN `SYSTEM_PERMISSION` p_old ON p_old.`ID` = rp.`PERMISSION_ID` AND p_old.`CODE` = 'business:collectionStation:query' AND p_old.`DELETED` = 0
INNER JOIN `SYSTEM_PERMISSION` p_new ON p_new.`CODE` = 'business:collectionMedia:query' AND p_new.`DELETED` = 0
WHERE rp.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_ROLE_PERMISSION` x
  WHERE x.`ROLE_ID` = rp.`ROLE_ID` AND x.`PERMISSION_ID` = p_new.`ID` AND x.`DELETED` = 0
);
INSERT INTO `SYSTEM_ROLE_PERMISSION`(`ROLE_ID`, `PERMISSION_ID`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT rp.`ROLE_ID`, p_new.`ID`, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_ROLE_PERMISSION` rp
INNER JOIN `SYSTEM_PERMISSION` p_old ON p_old.`ID` = rp.`PERMISSION_ID` AND p_old.`CODE` = 'business:collectionStation:sync' AND p_old.`DELETED` = 0
INNER JOIN `SYSTEM_PERMISSION` p_new ON p_new.`CODE` = 'business:collectionMedia:sync' AND p_new.`DELETED` = 0
WHERE rp.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_ROLE_PERMISSION` x
  WHERE x.`ROLE_ID` = rp.`ROLE_ID` AND x.`PERMISSION_ID` = p_new.`ID` AND x.`DELETED` = 0
);
INSERT INTO `SYSTEM_ROLE_PERMISSION`(`ROLE_ID`, `PERMISSION_ID`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT rp.`ROLE_ID`, p_new.`ID`, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_ROLE_PERMISSION` rp
INNER JOIN `SYSTEM_PERMISSION` p_old ON p_old.`ID` = rp.`PERMISSION_ID` AND p_old.`CODE` = 'business:collectionStation:sync' AND p_old.`DELETED` = 0
INNER JOIN `SYSTEM_PERMISSION` p_new ON p_new.`CODE` = 'business:collectionMedia:download' AND p_new.`DELETED` = 0
WHERE rp.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_ROLE_PERMISSION` x
  WHERE x.`ROLE_ID` = rp.`ROLE_ID` AND x.`PERMISSION_ID` = p_new.`ID` AND x.`DELETED` = 0
);
INSERT INTO `SYSTEM_ROLE_PERMISSION`(`ROLE_ID`, `PERMISSION_ID`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`)
SELECT rp.`ROLE_ID`, p_new.`ID`, 1, CURRENT_TIMESTAMP, NULL, NULL, 0
FROM `SYSTEM_ROLE_PERMISSION` rp
INNER JOIN `SYSTEM_PERMISSION` p_old ON p_old.`ID` = rp.`PERMISSION_ID` AND p_old.`CODE` = 'business:collectionStation:query' AND p_old.`DELETED` = 0
INNER JOIN `SYSTEM_PERMISSION` p_new ON p_new.`CODE` = 'business:collectionDockDevice:query' AND p_new.`DELETED` = 0
WHERE rp.`DELETED` = 0
AND NOT EXISTS (
  SELECT 1 FROM `SYSTEM_ROLE_PERMISSION` x
  WHERE x.`ROLE_ID` = rp.`ROLE_ID` AND x.`PERMISSION_ID` = p_new.`ID` AND x.`DELETED` = 0
);
server/db/business.collection_station.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS `collection_station` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `creator` varchar(64) DEFAULT NULL COMMENT '创建人编码',
  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
  `edirot` varchar(64) DEFAULT NULL COMMENT '更新人编码',
  `edit_date` datetime DEFAULT NULL COMMENT '更新时间',
  `isdeleted` int(11) DEFAULT '0' COMMENT '是否删除0否 1是',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `name` varchar(128) DEFAULT NULL COMMENT '采集站名称',
  `serial_no` varchar(64) DEFAULT NULL COMMENT '设备序列号',
  `ip` varchar(64) DEFAULT NULL COMMENT '设备IP',
  `port` int(11) DEFAULT '80' COMMENT '设备端口(ISAPI默认80)',
  `use_https` int(11) DEFAULT '0' COMMENT '是否HTTPS 0否1是',
  `username` varchar(64) DEFAULT NULL COMMENT '登录用户名',
  `password` varchar(128) DEFAULT NULL COMMENT '登录密码',
  `model` varchar(64) DEFAULT 'UD39625B' COMMENT '设备型号',
  `online` int(11) DEFAULT '0' COMMENT '在线状态 0离线 1在线',
  `total_space` bigint(20) DEFAULT NULL COMMENT '总存储空间(GB)',
  `free_space` bigint(20) DEFAULT NULL COMMENT '剩余存储空间(GB)',
  `software_version` varchar(64) DEFAULT NULL COMMENT '软件版本',
  `last_sync_time` datetime DEFAULT NULL COMMENT '最近同步时间',
  `status` int(11) DEFAULT '1' COMMENT '状态 0禁用 1启用',
  PRIMARY KEY (`id`),
  KEY `idx_serial_no` (`serial_no`),
  KEY `idx_ip` (`ip`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海康桌面采集站设备';
server/doc/hik/ISAPI¿ª·¢Ö¸ÄÏ_ÊÖ³Ö´©´÷²úÆ·_ÐÐÒµ´©´÷²úÆ·w.pdf
Binary files differ
server/doc/hik/_pdf_extract.txt
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
server/doc/hik/×ÖµäÐÅÏ¢.xlsx
Binary files differ
server/doc/hik/ÈÕÖ¾ÀàÐÍ.xlsx
Binary files differ
server/doc/hik/´íÎóÂë.xlsx
Binary files differ
server/system_service/src/main/java/com/doumee/config/cloudfilter/LoginHandlerInterceptor.java
@@ -53,13 +53,17 @@
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Class<?> beanType = handlerMethod.getBeanType();
            if (!beanType.isAnnotationPresent(LoginNoRequired.class) && !handlerMethod.hasMethodAnnotation(LoginNoRequired.class)) {
                //获取token
                Cookie[]  cookies =   request.getCookies();
                String token = request.getHeader(Constants.HEADER_USER_TOKEN);  // ä»Ž http è¯·æ±‚头中取出 token
                if(StringUtils.isBlank(token)){
                    for(Cookie c :cookies){
                        if(StringUtils.equals(c.getName(),Constants.HEADER_USER_TOKEN)){
                            token = c.getValue();
                    token = request.getParameter(Constants.HEADER_USER_TOKEN);
                }
                if(StringUtils.isBlank(token)){
                    Cookie[]  cookies =   request.getCookies();
                    if (cookies != null) {
                        for(Cookie c :cookies){
                            if(StringUtils.equals(c.getName(),Constants.HEADER_USER_TOKEN)){
                                token = c.getValue();
                            }
                        }
                    }
                }
server/system_service/src/main/java/com/doumee/core/annotation/trace/TraceInterceptor.java
@@ -142,8 +142,8 @@
        traceLog.setOperaSpendTime(Integer.valueOf("" + (System.currentTimeMillis() - Long.valueOf(traceTime.toString()))));
        // è®°å½•操作日志状态
        String operaType = response.getHeader("eva-opera-type");
        // - ä¸‹è½½æŽ¥å£å¤„理,无需记录响应内容
        if ("download".equals(operaType)) {
        // - ä¸‹è½½/预览接口处理,无需记录响应内容
        if ("download".equals(operaType) || "preview".equals(operaType)) {
            handleDownloadResponse(traceLog, ex);
            return;
        }
server/system_service/src/main/java/com/doumee/core/utils/Constants.java
@@ -234,6 +234,16 @@
    public static  boolean DEALING_HK_EMPOWER_DETAIL = false;
    public static  boolean DEALING_HK_EMPOWER_RESULT = false;
    public static  boolean DEALING_HK_PARKBOOK = false;
    public static  boolean DEALING_HK_COLLECTION_STATION = false;
    public static  boolean DEALING_HK_COLLECTION_MEDIA = false;
    public static final String CS_PARAM = "CS_PARAM";
    public static final String CS_SEARCH_TRACK_ID = "CS_SEARCH_TRACK_ID";
    public static final String CS_DOWNLOAD_BATCH_SIZE = "CS_DOWNLOAD_BATCH_SIZE";
    public static final String CS_FFMPEG_PATH = "CS_FFMPEG_PATH";
    public static final String COLLECTION_MEDIA_FOLDER = "COLLECTION_MEDIA";
    /** åª’体下载状态:下载中 */
    public static final int COLLECTION_MEDIA_DOWNLOADING = 3;
    public static final String SMS ="SMS" ;
    public static final String SMS_COMNAME = "SMS_COMNAME";
    public static final String SMS_IP ="SMS_IP" ;
server/system_service/src/main/java/com/doumee/core/utils/FtpUtil.java
@@ -304,6 +304,148 @@
        return false;
    }
    /** ä»Ž FTP è¯»å–远程文件并写入输出流(用于在线预览/下载) */
    public boolean streamRemoteFile(String remote, OutputStream outputStream) throws IOException {
        if (streamRemoteFileInternal(remote, outputStream, 0, -1)) {
            return true;
        }
        if (remote.startsWith("/")) {
            return streamRemoteFileInternal(remote.substring(1), outputStream, 0, -1);
        }
        return streamRemoteFileByCwd(remote, outputStream, 0, -1);
    }
    /** æŒ‰å­—节范围从 FTP è¯»å–远程文件(支持 MP4 åˆ†æ®µæ’­æ”¾ï¼‰ */
    public boolean streamRemoteFileRange(String remote, OutputStream outputStream, long start, long length) throws IOException {
        if (streamRemoteFileInternal(remote, outputStream, start, length)) {
            return true;
        }
        if (remote.startsWith("/")) {
            return streamRemoteFileInternal(remote.substring(1), outputStream, start, length);
        }
        return streamRemoteFileByCwd(remote, outputStream, start, length);
    }
    public long getRemoteFileSize(String remote) throws IOException {
        Long size = resolveRemoteFileSize(remote);
        if (size != null) {
            return size;
        }
        if (remote.startsWith("/")) {
            size = resolveRemoteFileSize(remote.substring(1));
        }
        return size != null ? size : -1L;
    }
    private Long resolveRemoteFileSize(String remote) throws IOException {
        String originalCwd = ftpClient.printWorkingDirectory();
        try {
            ftpClient.enterLocalPassiveMode();
            FTPFile[] files = ftpClient.listFiles(new String(remote.getBytes("GBK"), "iso-8859-1"));
            if (files != null && files.length == 1 && files[0].isFile()) {
                return files[0].getSize();
            }
            if (remote.contains("/")) {
                String directory = remote.substring(0, remote.lastIndexOf('/') + 1);
                String fileName = remote.substring(remote.lastIndexOf('/') + 1);
                if (ftpClient.changeWorkingDirectory(new String(directory.getBytes("GBK"), "iso-8859-1"))) {
                    files = ftpClient.listFiles(new String(fileName.getBytes("GBK"), "iso-8859-1"));
                    if (files != null && files.length == 1 && files[0].isFile()) {
                        return files[0].getSize();
                    }
                }
            }
            return null;
        } finally {
            if (originalCwd != null) {
                ftpClient.changeWorkingDirectory(originalCwd);
            }
        }
    }
    private boolean streamRemoteFileInternal(String remote, OutputStream outputStream, long start, long length) throws IOException {
        InputStream in = null;
        try {
            ftpClient.enterLocalPassiveMode();
            ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
            if (start > 0) {
                ftpClient.setRestartOffset(start);
            }
            String encoded = new String(remote.getBytes("GBK"), "iso-8859-1");
            in = ftpClient.retrieveFileStream(encoded);
            if (in == null) {
                return false;
            }
            byte[] buffer = new byte[8192];
            long remaining = length;
            int len;
            while ((len = in.read(buffer)) != -1) {
                if (length >= 0 && remaining <= 0) {
                    break;
                }
                int writeLen = len;
                if (length >= 0 && writeLen > remaining) {
                    writeLen = (int) remaining;
                }
                outputStream.write(buffer, 0, writeLen);
                if (length >= 0) {
                    remaining -= writeLen;
                }
            }
            outputStream.flush();
            return ftpClient.completePendingCommand();
        } finally {
            if (in != null) {
                in.close();
            }
        }
    }
    private boolean streamRemoteFileByCwd(String remote, OutputStream outputStream, long start, long length) throws IOException {
        if (!remote.contains("/")) {
            return false;
        }
        String directory = remote.substring(0, remote.lastIndexOf('/') + 1);
        String fileName = remote.substring(remote.lastIndexOf('/') + 1);
        ftpClient.enterLocalPassiveMode();
        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
        if (!ftpClient.changeWorkingDirectory(new String(directory.getBytes("GBK"), "iso-8859-1"))) {
            return false;
        }
        if (start > 0) {
            ftpClient.setRestartOffset(start);
        }
        InputStream in = null;
        try {
            in = ftpClient.retrieveFileStream(new String(fileName.getBytes("GBK"), "iso-8859-1"));
            if (in == null) {
                return false;
            }
            byte[] buffer = new byte[8192];
            long remaining = length;
            int len;
            while ((len = in.read(buffer)) != -1) {
                if (length >= 0 && remaining <= 0) {
                    break;
                }
                int writeLen = len;
                if (length >= 0 && writeLen > remaining) {
                    writeLen = (int) remaining;
                }
                outputStream.write(buffer, 0, writeLen);
                if (length >= 0) {
                    remaining -= writeLen;
                }
            }
            outputStream.flush();
            return ftpClient.completePendingCommand();
        } finally {
            if (in != null) {
                in.close();
            }
        }
    }
    public boolean uploadInputstreamBatch(InputStream inputStream, String remote, Boolean close , Integer index )  {
        // è®¾ç½®PassiveMode传输
        try {
server/system_service/src/main/java/com/doumee/core/utils/HttpsUtil.java
@@ -105,6 +105,22 @@
        return null;
    }
    /** ISAPI GET(Digest è®¤è¯ï¼‰ */
    public static String doIsapiGet(String host, int port, String username, String password, String uri) {
        return IsapiHttpUtil.doGet(host, port, username, password, uri);
    }
    /** ISAPI POST(Digest è®¤è¯ï¼‰ */
    public static String doIsapiPost(String host, int port, String username, String password, String uri, String xmlBody) {
        return IsapiHttpUtil.doPost(host, port, username, password, uri, xmlBody, "application/xml");
    }
    /** ISAPI åª’体下载 */
    public static InputStream doIsapiDownload(String host, int port, String username, String password,
                                              String uri, String downloadBody) {
        return IsapiHttpUtil.doDownload(host, port, username, password, uri, downloadBody);
    }
    public static String connection(String url,String method,String data,String contentType,boolean ignoreSSL){
        HttpsURLConnection connection = null;
        try {
server/system_service/src/main/java/com/doumee/core/utils/IsapiHttpUtil.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,267 @@
package com.doumee.core.utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.DigestScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
/**
 * æµ·åº· ISAPI HTTP å®¢æˆ·ç«¯ï¼ˆDigest è®¤è¯ï¼Œæ”¯æŒ HTTP/HTTPS)
 */
@Slf4j
public class IsapiHttpUtil {
    private static final int CONNECT_TIMEOUT = 10000;
    private static final int SOCKET_TIMEOUT = 30000;
    private static final int DOWNLOAD_SOCKET_TIMEOUT = 300000;
    private IsapiHttpUtil() {
    }
    public static String doGet(String host, int port, String username, String password, String uri) {
        return doGet(host, port, false, username, password, uri);
    }
    public static String doGet(String host, int port, boolean https, String username, String password, String uri) {
        String url = buildUrl(host, port, https, uri);
        HttpGet request = new HttpGet(url);
        return executeString(request, host, port, https, username, password, SOCKET_TIMEOUT);
    }
    public static String doPost(String host, int port, String username, String password, String uri, String body, String contentType) {
        return doPost(host, port, false, username, password, uri, body, contentType);
    }
    public static String doPost(String host, int port, boolean https, String username, String password,
                                String uri, String body, String contentType) {
        String url = buildUrl(host, port, https, uri);
        HttpPost request = new HttpPost(url);
        if (body != null) {
            request.setEntity(new StringEntity(body, StandardCharsets.UTF_8));
        }
        if (contentType != null) {
            request.setHeader("Content-Type", contentType);
        }
        return executeString(request, host, port, https, username, password, SOCKET_TIMEOUT);
    }
    public static String doPut(String host, int port, boolean https, String username, String password,
                               String uri, String body, String contentType) {
        String url = buildUrl(host, port, https, uri);
        HttpPut request = new HttpPut(url);
        if (body != null) {
            request.setEntity(new StringEntity(body, StandardCharsets.UTF_8));
        }
        if (contentType != null) {
            request.setHeader("Content-Type", contentType);
        }
        return executeString(request, host, port, https, username, password, SOCKET_TIMEOUT);
    }
    /**
     * ISAPI æ–‡ä»¶ä¸‹è½½ï¼šGET /ISAPI/ContentMgmt/download?token=xxx,Body å« playbackURI
     */
    public static InputStream doDownload(String host, int port, String username, String password,
                                         String uri, String downloadBody) {
        return doDownload(host, port, false, username, password, uri, downloadBody);
    }
    public static InputStream doDownload(String host, int port, boolean https, String username, String password,
                                         String uri, String downloadBody) {
        String url = buildUrl(host, port, https, uri);
        HttpGetWithEntity request = new HttpGetWithEntity(url);
        if (downloadBody != null) {
            request.setEntity(new StringEntity(downloadBody, StandardCharsets.UTF_8));
            request.setHeader("Content-Type", "application/xml");
        }
        return executeStream(request, host, port, https, username, password, DOWNLOAD_SOCKET_TIMEOUT);
    }
    /** æµ·åº· ISAPI ä¸‹è½½æŽ¥å£ä½¿ç”¨ GET + Body */
    static class HttpGetWithEntity extends HttpEntityEnclosingRequestBase {
        HttpGetWithEntity(String uri) {
            setURI(URI.create(uri));
        }
        @Override
        public String getMethod() {
            return "GET";
        }
    }
    private static String buildUrl(String host, int port, boolean https, String uri) {
        String scheme = https ? "https" : "http";
        if (!uri.startsWith("/")) {
            uri = "/" + uri;
        }
        return scheme + "://" + host + ":" + port + uri;
    }
    private static String executeString(HttpRequestBase request, String host, int port, boolean https,
                                        String username, String password, int socketTimeout) {
        try (CloseableHttpClient httpClient = buildClient(https, buildCredentials(host, port, https, username, password));
             CloseableHttpResponse response = execute(httpClient, request, host, port, https, username, password, socketTimeout)) {
            HttpEntity entity = response.getEntity();
            if (entity == null) {
                return null;
            }
            return EntityUtils.toString(entity, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("ISAPI请求失败 {}: {}", request.getURI(), e.getMessage(), e);
            return null;
        }
    }
    private static InputStream executeStream(HttpRequestBase request, String host, int port, boolean https,
                                             String username, String password, int socketTimeout) {
        try {
            CloseableHttpClient httpClient = buildClient(https, buildCredentials(host, port, https, username, password));
            CloseableHttpResponse response = execute(httpClient, request, host, port, https, username, password, socketTimeout);
            int status = response.getStatusLine().getStatusCode();
            if (status < 200 || status >= 300) {
                log.warn("ISAPI下载HTTP失败 {} status={}", request.getURI(), status);
                EntityUtils.consumeQuietly(response.getEntity());
                response.close();
                httpClient.close();
                return null;
            }
            HttpEntity entity = response.getEntity();
            if (entity == null) {
                response.close();
                httpClient.close();
                return null;
            }
            return entity.getContent();
        } catch (Exception e) {
            log.error("ISAPI下载失败 {}: {}", request.getURI(), e.getMessage(), e);
            return null;
        }
    }
    private static CredentialsProvider buildCredentials(String host, int port, boolean https,
                                                        String username, String password) {
        CredentialsProvider credsProvider = new BasicCredentialsProvider();
        String scheme = https ? "https" : "http";
        credsProvider.setCredentials(new AuthScope(host, port, AuthScope.ANY_REALM, scheme),
                new UsernamePasswordCredentials(username, password));
        credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));
        return credsProvider;
    }
    private static CloseableHttpResponse execute(CloseableHttpClient httpClient, HttpRequestBase request,
                                                 String host, int port, boolean https,
                                                 String username, String password, int socketTimeout) throws Exception {
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectTimeout(CONNECT_TIMEOUT)
                .setSocketTimeout(socketTimeout)
                .setConnectionRequestTimeout(CONNECT_TIMEOUT)
                .build();
        request.setConfig(requestConfig);
        CredentialsProvider credsProvider = buildCredentials(host, port, https, username, password);
        HttpHost httpHost = new HttpHost(host, port, https ? "https" : "http");
        HttpClientContext context = HttpClientContext.create();
        context.setCredentialsProvider(credsProvider);
        log.info("ISAPI请求: {}://{}:{}{}", https ? "https" : "http", host, port, request.getURI().getRawPath());
        CloseableHttpResponse response = httpClient.execute(httpHost, request, context);
        int status = response.getStatusLine().getStatusCode();
        if (status != 401) {
            return response;
        }
        Header authHeader = findDigestAuthHeader(response);
        if (authHeader == null) {
            log.warn("ISAPI 401 ä½†æœªè¿”回 Digest æŒ‘战头: {} status={}", request.getURI(), status);
            return response;
        }
        EntityUtils.consumeQuietly(response.getEntity());
        response.close();
        DigestScheme digestScheme = new DigestScheme();
        digestScheme.processChallenge(authHeader);
        BasicAuthCache authCache = new BasicAuthCache();
        authCache.put(httpHost, digestScheme);
        HttpClientContext retryContext = HttpClientContext.create();
        retryContext.setCredentialsProvider(credsProvider);
        retryContext.setAuthCache(authCache);
        CloseableHttpResponse retryResponse = httpClient.execute(httpHost, request, retryContext);
        log.info("ISAPI Digest é‡è¯•: {} status={}", request.getURI(), retryResponse.getStatusLine().getStatusCode());
        return retryResponse;
    }
    private static Header findDigestAuthHeader(CloseableHttpResponse response) {
        Header[] headers = response.getHeaders("WWW-Authenticate");
        if (headers == null || headers.length == 0) {
            return null;
        }
        for (Header header : headers) {
            if (header.getValue() != null && header.getValue().toLowerCase().contains("digest")) {
                return header;
            }
        }
        return headers[0];
    }
    private static CloseableHttpClient buildClient(boolean https, CredentialsProvider credsProvider) throws Exception {
        HttpClientBuilder builder = HttpClients.custom().setDefaultCredentialsProvider(credsProvider);
        if (!https) {
            return builder.build();
        }
        TrustManager[] trustManagers = new TrustManager[]{
                new X509TrustManager() {
                    @Override
                    public void checkClientTrusted(X509Certificate[] chain, String authType) {
                    }
                    @Override
                    public void checkServerTrusted(X509Certificate[] chain, String authType) {
                    }
                    @Override
                    public X509Certificate[] getAcceptedIssuers() {
                        return new X509Certificate[0];
                    }
                }
        };
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, trustManagers, new SecureRandom());
        SSLConnectionSocketFactory sslFactory = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
        return builder.setSSLSocketFactory(sslFactory).build();
    }
}
server/system_service/src/main/java/com/doumee/core/utils/VideoTranscodeUtil.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,333 @@
package com.doumee.core.utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
 * å°†æµ·åº·ç­‰è®¾å¤‡ä¸‹è½½çš„媒体流转为浏览器 video æ ‡ç­¾å¯æ’­çš„ MP4(H.264 + AAC)。
 * æµ·åº· MP4 å¸¸è§æŸåçš„ MP2 éŸ³é¢‘轨,默认丢弃异常音频仅保留视频。
 */
@Slf4j
public final class VideoTranscodeUtil {
    private static final long TRANSCODE_TIMEOUT_MINUTES = 30;
    private VideoTranscodeUtil() {
    }
    /**
     * å°† MP4 è§†é¢‘(含海康 .mp4 æ‰©å±•名但内容为 MPEG-PS çš„æƒ…况)转为浏览器可播 MP4。
     * è°ƒç”¨æ–¹éœ€å·²é€šè¿‡æ–‡ä»¶åç­‰æ¡ä»¶ç¡®è®¤æ˜¯ MP4 è§†é¢‘,本方法不再因缺少 ftyp å¤´è€Œè·³è¿‡ã€‚
     */
    public static boolean transcodeToBrowserMp4(String ffmpegPath, File source, File target) {
        if (source == null || !source.exists() || source.length() <= 0) {
            return false;
        }
        String ffmpeg = resolveExecutable(ffmpegPath, "ffmpeg");
        String ffprobe = resolveFfprobe(ffmpegPath);
        boolean mpegPs = false;
        boolean mp4Container = false;
        try {
            mpegPs = isMpegPs(source);
            mp4Container = hasMp4Header(source);
        } catch (IOException e) {
            log.warn("视频文件头检测失败,继续尝试 FFmpeg è½¬ç : {}", e.getMessage());
        }
        if (mpegPs) {
            log.info("检测到 MPEG-PS æµï¼ˆæ‰©å±•名可能为 .mp4),将转码为标准 MP4: size={}", source.length());
        } else if (!mp4Container) {
            log.info("文件无 MP4 å®¹å™¨å¤´(无 ftyp),尝试 FFmpeg æŒ‰è§†é¢‘流转码: size={}", source.length());
        }
        try {
            if (mp4Container && isBrowserPlayableMp4(ffmpeg, source)) {
                Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
                log.info("视频已是浏览器可播 MP4,跳过转码 size={}", source.length());
                return target.exists() && target.length() > 0;
            }
        } catch (Exception e) {
            log.warn("检测视频编码失败,将尝试转码: {}", e.getMessage());
        }
        String inputPath = source.getAbsolutePath();
        String outputPath = target.getAbsolutePath();
        String videoCodec = probeStreamCodec(ffprobe, source, "v:0");
        // 1. MP4 å®¹å™¨ + H.264:重封装并丢弃损坏音频
        if (mp4Container && "h264".equalsIgnoreCase(videoCodec)) {
            if (runFfmpeg(buildRemuxCommand(ffmpeg, source, inputPath, outputPath), "重封装(H.264 copy, æ— éŸ³é¢‘)")) {
                return isValidOutput(target);
            }
        }
        // 2. è½¬ç è§†é¢‘轨(适用于 MPEG-PS、损坏 MP4 ç­‰ï¼‰
        if (runFfmpeg(buildEncodeVideoNoAudioCommand(ffmpeg, source, inputPath, outputPath), "转码(仅视频)")) {
            return isValidOutput(target);
        }
        // 3. éŸ³é¢‘轨正常时才尝试带上音频
        String audioCodec = probeStreamCodec(ffprobe, source, "a:0");
        if (isDecodableAudio(audioCodec)) {
            if (runFfmpeg(buildEncodeVideoWithAudioCommand(ffmpeg, source, inputPath, outputPath), "转码(视频+音频)")) {
                return isValidOutput(target);
            }
        }
        log.error("视频转码全部策略失败 source={}", source.getAbsolutePath());
        return false;
    }
    public static boolean isBrowserPlayableMp4(String ffmpegPath, File file) throws IOException {
        if (!hasMp4Header(file)) {
            return false;
        }
        if (isMpegPs(file)) {
            return false;
        }
        String ffprobe = resolveFfprobe(ffmpegPath);
        String videoCodec = probeStreamCodec(ffprobe, file, "v:0");
        if (!"h264".equalsIgnoreCase(videoCodec)) {
            return false;
        }
        String audioCodec = probeStreamCodec(ffprobe, file, "a:0");
        if (StringUtils.isBlank(audioCodec)) {
            return true;
        }
        return "aac".equalsIgnoreCase(audioCodec);
    }
    public static boolean hasMp4Header(File file) throws IOException {
        byte[] head = readHead(file, 12);
        return head.length >= 8 && head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p';
    }
    public static boolean isMpegPs(File file) throws IOException {
        byte[] head = readHead(file, 4);
        return head.length >= 4 && head[0] == 0 && head[1] == 0 && head[2] == 1 && (head[3] & 0xFF) == 0xBA;
    }
    private static boolean isDecodableAudio(String codec) {
        if (StringUtils.isBlank(codec)) {
            return false;
        }
        return "aac".equalsIgnoreCase(codec)
                || "mp3".equalsIgnoreCase(codec)
                || codec.toLowerCase().startsWith("pcm");
    }
    private static boolean isValidOutput(File target) {
        return target != null && target.exists() && target.length() > 0;
    }
    private static List<String> buildInputArgs(String ffmpeg, File source, String inputPath) {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpeg);
        cmd.add("-y");
        cmd.add("-fflags");
        cmd.add("+discardcorrupt");
        cmd.add("-err_detect");
        cmd.add("ignore_err");
        try {
            if (isMpegPs(source)) {
                cmd.add("-f");
                cmd.add("mpeg");
            }
        } catch (IOException ignored) {
        }
        cmd.add("-i");
        cmd.add(inputPath);
        return cmd;
    }
    /** é‡å°è£…:复制 H.264 è§†é¢‘,丢弃损坏音频 */
    private static List<String> buildRemuxCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-an");
        cmd.add("-c:v");
        cmd.add("copy");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
    /** è½¬ç ï¼šä»…视频轨,无音频 */
    private static List<String> buildEncodeVideoNoAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-an");
        cmd.add("-c:v");
        cmd.add("libx264");
        cmd.add("-preset");
        cmd.add("fast");
        cmd.add("-crf");
        cmd.add("23");
        cmd.add("-pix_fmt");
        cmd.add("yuv420p");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
    /** è½¬ç ï¼šè§†é¢‘ + å¯è§£ç éŸ³é¢‘ */
    private static List<String> buildEncodeVideoWithAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-map");
        cmd.add("0:a:0?");
        cmd.add("-c:v");
        cmd.add("libx264");
        cmd.add("-preset");
        cmd.add("fast");
        cmd.add("-crf");
        cmd.add("23");
        cmd.add("-pix_fmt");
        cmd.add("yuv420p");
        cmd.add("-c:a");
        cmd.add("aac");
        cmd.add("-b:a");
        cmd.add("128k");
        cmd.add("-ac");
        cmd.add("2");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
    private static boolean runFfmpeg(List<String> command, String label) {
        try {
            int exitCode = runCommand(command, TRANSCODE_TIMEOUT_MINUTES);
            if (exitCode == 0) {
                log.info("FFmpeg {} æˆåŠŸ", label);
                return true;
            }
            log.warn("FFmpeg {} å¤±è´¥ exitCode={}", label, exitCode);
        } catch (Exception e) {
            log.warn("FFmpeg {} å¼‚常: {}", label, e.getMessage());
        }
        return false;
    }
    private static byte[] readHead(File file, int len) throws IOException {
        byte[] head = new byte[len];
        try (FileInputStream in = new FileInputStream(file)) {
            int n = in.read(head);
            if (n <= 0) {
                return new byte[0];
            }
            if (n < len) {
                byte[] actual = new byte[n];
                System.arraycopy(head, 0, actual, 0, n);
                return actual;
            }
        }
        return head;
    }
    private static String probeStreamCodec(String ffprobe, File file, String stream) {
        List<String> command = Arrays.asList(
                ffprobe,
                "-v", "error",
                "-select_streams", stream,
                "-show_entries", "stream=codec_name",
                "-of", "default=noprint_wrappers=1:nokey=1",
                file.getAbsolutePath()
        );
        try {
            ProcessBuilder builder = new ProcessBuilder(command);
            builder.redirectErrorStream(true);
            Process process = builder.start();
            String output = readStream(process.getInputStream());
            boolean finished = process.waitFor(2, TimeUnit.MINUTES);
            if (!finished) {
                process.destroyForcibly();
                return null;
            }
            if (process.exitValue() != 0) {
                return null;
            }
            return output.trim();
        } catch (Exception e) {
            log.warn("ffprobe æ£€æµ‹å¤±è´¥ stream={}: {}", stream, e.getMessage());
            return null;
        }
    }
    private static String resolveExecutable(String configuredPath, String defaultName) {
        if (StringUtils.isNotBlank(configuredPath)) {
            return configuredPath.trim();
        }
        return defaultName;
    }
    private static String resolveFfprobe(String ffmpegPath) {
        if (StringUtils.isNotBlank(ffmpegPath)) {
            String path = ffmpegPath.trim();
            if (path.toLowerCase().endsWith("ffmpeg.exe") || path.toLowerCase().endsWith("ffmpeg")) {
                int idx = path.lastIndexOf('/');
                if (idx < 0) {
                    idx = path.lastIndexOf('\\');
                }
                String dir = idx >= 0 ? path.substring(0, idx + 1) : "";
                String name = path.substring(idx + 1);
                if (name.toLowerCase().endsWith(".exe")) {
                    return dir + name.replaceAll("(?i)ffmpeg\\.exe$", "ffprobe.exe");
                }
                return dir + name.replaceAll("(?i)ffmpeg$", "ffprobe");
            }
            return path.replaceAll("(?i)ffmpeg", "ffprobe");
        }
        return "ffprobe";
    }
    private static int runCommand(List<String> command, long timeoutMinutes) throws IOException, InterruptedException {
        ProcessBuilder builder = new ProcessBuilder(command);
        builder.redirectErrorStream(true);
        Process process = builder.start();
        String output = readStream(process.getInputStream());
        boolean finished = process.waitFor(timeoutMinutes, TimeUnit.MINUTES);
        if (!finished) {
            process.destroyForcibly();
            log.error("FFmpeg æ‰§è¡Œè¶…æ—¶: {}", String.join(" ", command));
            return -1;
        }
        if (StringUtils.isNotBlank(output)) {
            if (process.exitValue() != 0) {
                log.warn("FFmpeg è¾“出: {}", output.length() > 2000 ? output.substring(0, 2000) + "..." : output);
            } else {
                log.debug("FFmpeg è¾“出: {}", output.length() > 2000 ? output.substring(0, 2000) + "..." : output);
            }
        }
        return process.exitValue();
    }
    private static String readStream(java.io.InputStream inputStream) throws IOException {
        StringBuilder sb = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append('\n');
            }
        }
        return sb.toString().trim();
    }
}
server/system_timer/src/main/java/com/doumee/jobs/fegin/VisitServiceFegin.java
@@ -119,4 +119,12 @@
    @ApiOperation("【断路器】开启定时远程控制断路器分闸")
    @PostMapping("/timer/duanluqi/autoCloseCmd")
    ApiResponse autoCloseCmd();
    @ApiOperation("【采集站】定时同步采集站在线状态")
    @PostMapping("/timer/collectionStation/syncStations")
    ApiResponse syncCollectionStations();
    @ApiOperation("【采集站】定时同步媒体索引并下载")
    @PostMapping("/timer/collectionStation/syncMediaAndDownload")
    ApiResponse syncCollectionMediaAndDownload();
}
server/visits/admin_timer/src/main/java/com/doumee/api/HkCollectionStationTimerController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package com.doumee.api;
import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
import com.doumee.service.business.CollectionMediaSyncService;
import com.doumee.service.business.CollectionStationService;
import com.doumee.service.business.third.model.ApiResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Calendar;
import java.util.Date;
@Api(tags = "海康采集站定时器接口")
@Slf4j
@RestController
@RequestMapping("/timer/collectionStation")
public class HkCollectionStationTimerController extends BaseController {
    @Autowired
    private CollectionStationService collectionStationService;
    @Autowired
    private CollectionMediaSyncService collectionMediaSyncService;
    @ApiOperation("定时同步采集站在线状态")
    @PostMapping("/syncStations")
    public ApiResponse<String> syncStations() {
        log.info("定时任务:同步采集站状态");
        return ApiResponse.success(collectionStationService.syncAllStations());
    }
    @ApiOperation("定时同步采集站媒体索引并批量下载")
    @PostMapping("/syncMediaAndDownload")
    public ApiResponse<String> syncMediaAndDownload() {
        log.info("定时任务:同步采集站媒体索引");
        CollectionMediaSyncRequest request = new CollectionMediaSyncRequest();
        Calendar cal = Calendar.getInstance();
        request.setEndTime(cal.getTime());
        cal.add(Calendar.HOUR_OF_DAY, -24);
        request.setStartTime(cal.getTime());
        String syncResult = collectionMediaSyncService.syncMediaList(request);
        String downloadResult = collectionMediaSyncService.batchDownload(request);
        return ApiResponse.success(syncResult + ";" + downloadResult);
    }
}
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,178 @@
package com.doumee.cloud.admin;
import com.doumee.api.BaseController;
import com.doumee.config.annotation.CloudRequiredPermission;
import com.doumee.core.annotation.pr.PreventRepeat;
import com.doumee.core.utils.Constants;
import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
import com.doumee.dao.business.model.CollectionMedia;
import com.doumee.dao.business.model.CollectionDockDevice;
import com.doumee.dao.business.model.CollectionStation;
import com.doumee.service.business.CollectionMediaSyncService;
import com.doumee.service.business.CollectionStationService;
import com.doumee.service.business.third.model.ApiResponse;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@Api(tags = "海康采集站")
@RestController
@RequestMapping(Constants.CLOUD_SERVICE_URL_INDEX + "/business/collectionStation")
public class CollectionStationCloudController extends BaseController {
    @Autowired
    private CollectionStationService collectionStationService;
    @Autowired
    private CollectionMediaSyncService collectionMediaSyncService;
    @PreventRepeat
    @ApiOperation("新建采集站")
    @PostMapping("/create")
    @CloudRequiredPermission("business:collectionStation:create")
    public ApiResponse<Integer> create(@RequestBody CollectionStation station,
                                       @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        station.setLoginUserInfo(getLoginUser(token));
        return ApiResponse.success(collectionStationService.create(station));
    }
    @ApiOperation("根据ID删除采集站")
    @GetMapping("/delete/{id}")
    @CloudRequiredPermission("business:collectionStation:delete")
    public ApiResponse<Void> deleteById(@PathVariable Integer id,
                                        @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        collectionStationService.deleteById(id, getLoginUser(token));
        return ApiResponse.success(null);
    }
    @ApiOperation("批量删除采集站")
    @GetMapping("/delete/batch")
    @CloudRequiredPermission("business:collectionStation:delete")
    public ApiResponse<Void> deleteByIdInBatch(@RequestParam String ids,
                                               @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        String[] idArray = ids.split(",");
        List<Integer> idList = new ArrayList<>();
        for (String id : idArray) {
            idList.add(Integer.valueOf(id));
        }
        collectionStationService.deleteByIdInBatch(idList, getLoginUser(token));
        return ApiResponse.success(null);
    }
    @ApiOperation("修改采集站")
    @PostMapping("/updateById")
    @CloudRequiredPermission("business:collectionStation:update")
    public ApiResponse<Void> updateById(@RequestBody CollectionStation station,
                                        @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        station.setLoginUserInfo(getLoginUser(token));
        collectionStationService.updateById(station);
        return ApiResponse.success(null);
    }
    @ApiOperation("分页查询采集站")
    @PostMapping("/page")
    @CloudRequiredPermission("business:collectionStation:query")
    public ApiResponse<PageData<CollectionStation>> findPage(@RequestBody PageWrap<CollectionStation> pageWrap,
                                                             @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(collectionStationService.findPage(pageWrap));
    }
    @ApiOperation("查询全部采集站")
    @PostMapping("/list")
    @CloudRequiredPermission("business:collectionStation:query")
    public ApiResponse<List<CollectionStation>> findList(@RequestBody CollectionStation model,
                                                         @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(collectionStationService.findList(model));
    }
    @PreventRepeat
    @ApiOperation("同步所有采集站状态")
    @PostMapping("/syncDevices")
    @CloudRequiredPermission("business:collectionStation:sync")
    public ApiResponse<String> syncDevices() {
        return ApiResponse.success(collectionStationService.syncAllStations());
    }
    @PreventRepeat
    @ApiOperation("同步单个采集站状态")
    @PostMapping("/syncDevice/{id}")
    @CloudRequiredPermission("business:collectionStation:sync")
    public ApiResponse<String> syncDevice(@PathVariable Integer id) {
        return ApiResponse.success(collectionStationService.syncStationStatus(id));
    }
    @ApiOperation("ISAPI联调探测(返回原始XML/JSON)")
    @GetMapping("/probe/{id}")
    @CloudRequiredPermission("business:collectionStation:sync")
    public ApiResponse<String> probe(@PathVariable Integer id) {
        return ApiResponse.success(collectionStationService.probeIsapi(id));
    }
    @ApiOperation("查询采集站执法记录仪列表")
    @GetMapping("/dockDevices/{stationId}")
    @CloudRequiredPermission({"business:collectionDockDevice:query", "business:collectionStation:query"})
    public ApiResponse<List<CollectionDockDevice>> dockDevices(@PathVariable Integer stationId) {
        return ApiResponse.success(collectionStationService.findDockDevices(stationId));
    }
    @ApiOperation("分页查询执法记录仪")
    @PostMapping("/dockDevices/page")
    @CloudRequiredPermission({"business:collectionDockDevice:query", "business:collectionStation:query"})
    public ApiResponse<PageData<CollectionDockDevice>> findDockDevicePage(@RequestBody PageWrap<CollectionDockDevice> pageWrap,
                                                                          @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(collectionStationService.findDockDevicePage(pageWrap));
    }
    @PreventRepeat
    @ApiOperation("同步媒体文件索引")
    @PostMapping("/syncMedia")
    @CloudRequiredPermission({"business:collectionMedia:sync", "business:collectionStation:sync"})
    public ApiResponse<String> syncMedia(@RequestBody CollectionMediaSyncRequest request) {
        return ApiResponse.success(collectionMediaSyncService.syncMediaList(request));
    }
    @PreventRepeat
    @ApiOperation("下载单个媒体文件")
    @PostMapping("/downloadMedia/{id}")
    @CloudRequiredPermission({"business:collectionMedia:download", "business:collectionStation:sync"})
    public ApiResponse<String> downloadMedia(@PathVariable Integer id) {
        return ApiResponse.success(collectionMediaSyncService.downloadMedia(id));
    }
    @PreventRepeat
    @ApiOperation("批量下载媒体文件")
    @PostMapping("/batchDownloadMedia")
    @CloudRequiredPermission({"business:collectionMedia:download", "business:collectionStation:sync"})
    public ApiResponse<String> batchDownloadMedia(@RequestBody CollectionMediaSyncRequest request) {
        return ApiResponse.success(collectionMediaSyncService.batchDownload(request));
    }
    @ApiOperation("分页查询媒体文件")
    @PostMapping("/media/page")
    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
    public ApiResponse<PageData<CollectionMedia>> findMediaPage(@RequestBody PageWrap<CollectionMedia> pageWrap,
                                                               @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(collectionMediaSyncService.findPage(pageWrap));
    }
    @ApiOperation("预览已下载媒体文件")
    @GetMapping("/media/preview/{id}")
    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
    public void previewMedia(@PathVariable Integer id, javax.servlet.http.HttpServletRequest request,
                             javax.servlet.http.HttpServletResponse response) {
        collectionMediaSyncService.previewMedia(id, request, response);
    }
    @ApiOperation("下载已下载媒体文件到本地")
    @GetMapping("/media/download/{id}")
    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
    public void downloadMediaFile(@PathVariable Integer id, javax.servlet.http.HttpServletRequest request,
                                  javax.servlet.http.HttpServletResponse response) {
        collectionMediaSyncService.downloadMediaFile(id, request, response);
    }
}
server/visits/dmvisit_admin/src/main/resources/bootstrap.yml
@@ -1,6 +1,6 @@
spring:
  profiles:
    active: pro
    active: dev
  application:
    name: visitsAdmin
    # å®‰å…¨é…ç½®
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiClient.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,434 @@
package com.doumee.core.haikang.isapi;
import com.alibaba.fastjson.JSONObject;
import com.doumee.core.haikang.isapi.model.DeviceInfoDTO;
import com.doumee.core.haikang.isapi.model.DockDeviceDTO;
import com.doumee.core.haikang.isapi.model.DockStationBasicInfoDTO;
import com.doumee.core.haikang.isapi.model.MediaItemDTO;
import com.doumee.core.haikang.isapi.model.RecordTrackDTO;
import com.doumee.core.haikang.isapi.model.SearchPageResult;
import com.doumee.core.haikang.isapi.model.StorageInfoDTO;
import com.doumee.dao.business.model.CollectionStation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.InputStream;
import java.io.PushbackInputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
/**
 * æµ·åº·é‡‡é›†ç«™ ISAPI å®¢æˆ·ç«¯
 * å‚考:ISAPI开发指南_手持穿戴产品_行业穿戴产品
 */
@Slf4j
public class IsapiClient {
    public DeviceInfoDTO getDeviceInfo(CollectionStation station) {
        String xml = IsapiRequestHelper.doGet(station, IsapiConstants.DEVICE_INFO);
        return IsapiXmlParser.parseDeviceInfo(xml);
    }
    public StorageInfoDTO getStorageInfo(CollectionStation station) {
        String xml = IsapiRequestHelper.doGet(station, IsapiConstants.STORAGE);
        return IsapiXmlParser.parseStorage(xml);
    }
    public DockStationBasicInfoDTO getDockBasicInfo(CollectionStation station) {
        String json = IsapiRequestHelper.doGet(station, IsapiConstants.DOCK_BASIC_INFO);
        return IsapiJsonParser.parseDockBasicInfo(json);
    }
    public List<DockDeviceDTO> getDockDevices(CollectionStation station) {
        String json = IsapiRequestHelper.doGet(station, IsapiConstants.DOCK_DEVICE_MANAGEMENT);
        return IsapiJsonParser.parseDockDeviceList(json);
    }
    public List<RecordTrackDTO> getRecordTracks(CollectionStation station) {
        String xml = IsapiRequestHelper.doGet(station, IsapiConstants.RECORD_TRACKS);
        List<RecordTrackDTO> tracks = IsapiXmlParser.parseRecordTracks(xml);
        if (tracks.isEmpty()) {
            RecordTrackDTO video = new RecordTrackDTO();
            video.setId(IsapiConstants.DEFAULT_TRACK_ID);
            video.setStreamType(0);
            RecordTrackDTO picture = new RecordTrackDTO();
            picture.setId(IsapiConstants.DEFAULT_PICTURE_TRACK_ID);
            picture.setStreamType(2);
            tracks.add(video);
            tracks.add(picture);
        }
        return tracks;
    }
    public boolean isOnline(CollectionStation station) {
        String xml = IsapiRequestHelper.doGet(station, IsapiConstants.DEVICE_INFO);
        return StringUtils.isNotBlank(xml)
                && !xml.contains("Unauthorized")
                && !xml.contains("Not Found")
                && xml.contains("deviceName");
    }
    /**
     * æ£€ç´¢åª’体(单 track、单页)
     */
    public List<MediaItemDTO> searchMedia(CollectionStation station, Date startTime, Date endTime,
                                          String trackId, int maxResults) {
        return searchMediaPage(station, startTime, endTime, trackId, 0, maxResults).getItems();
    }
    /**
     * æ£€ç´¢åª’体(多 track + åˆ†é¡µï¼Œç¬¦åˆ ISAPI æŒ‡å—集成流程)
     */
    public List<MediaItemDTO> searchMediaAll(CollectionStation station, Date startTime, Date endTime,
                                             String trackId, int maxResults) {
        List<String> trackIds = resolveTrackIds(station, trackId);
        List<MediaItemDTO> all = new ArrayList<>();
        Set<String> seen = new LinkedHashSet<>();
        for (String tid : trackIds) {
            int position = 0;
            int pageSize = Math.min(maxResults > 0 ? maxResults : IsapiConstants.DEFAULT_MAX_RESULTS,
                    IsapiConstants.MAX_PAGE_RESULTS);
            while (true) {
                SearchPageResult page = searchMediaPage(station, startTime, endTime, tid, position, pageSize);
                for (MediaItemDTO item : page.getItems()) {
                    String key = tid + ":" + item.getFileIndex();
                    if (seen.add(key)) {
                        all.add(item);
                    }
                }
                if (!page.isMore() || page.getItems().isEmpty()) {
                    break;
                }
                position += page.getItems().size();
            }
        }
        return all;
    }
    public SearchPageResult searchMediaPage(CollectionStation station, Date startTime, Date endTime,
                                            String trackId, int searchResultPosition, int maxResults) {
        String body = buildSearchXml(startTime, endTime, trackId, searchResultPosition, maxResults);
        String xml = IsapiRequestHelper.doPost(station, IsapiConstants.SEARCH_STD, body, IsapiConstants.CONTENT_TYPE_XML);
        SearchPageResult result = IsapiXmlParser.parseSearchPage(xml);
        if (result.getItems().isEmpty()) {
            log.warn("ISAPI search æ— åª’体 trackId={} pos={} status={} body={} response={}",
                    trackId, searchResultPosition, result.getResponseStatusStrg(), body,
                    StringUtils.abbreviate(StringUtils.defaultString(xml), 800));
        }
        return result;
    }
    public String getDownloadToken(CollectionStation station) {
        String json = IsapiRequestHelper.doGet(station, IsapiConstants.SECURITY_TOKEN_JSON);
        if (StringUtils.isBlank(json)) {
            return null;
        }
        try {
            JSONObject obj = JSONObject.parseObject(json);
            if (obj.containsKey("Token")) {
                return obj.getJSONObject("Token").getString("value");
            }
        } catch (Exception ignored) {
        }
        return IsapiXmlParser.parseSecurityToken(json);
    }
    public InputStream downloadMedia(CollectionStation station, String playbackUri) {
        return downloadMedia(station, playbackUri, null, null, null, null, null, null, null);
    }
    /**
     * ä¸‹è½½åª’体:优先 playbackURI,采集站无 URI æ—¶ä½¿ç”¨ mediaID / æŒ‰æ–‡ä»¶åæž„造 URI
     */
    public InputStream downloadMedia(CollectionStation station, String playbackUri, String mediaId,
                                     String fileName, String trackId, Date startTime, Date endTime,
                                     Long fileSize, Integer mediaType) {
        if (StringUtils.isNotBlank(playbackUri)) {
            InputStream stream = downloadByPlaybackUri(station, playbackUri);
            if (stream != null) {
                return stream;
            }
        }
        if (StringUtils.isNotBlank(mediaId)) {
            InputStream stream = downloadByMediaId(station, mediaId, fileName, trackId, startTime, endTime, fileSize, mediaType);
            if (stream != null) {
                log.info("ISAPI æŒ‰ mediaID ä¸‹è½½æˆåŠŸ mediaID={} fileName={}", mediaId, fileName);
                return stream;
            }
        }
        String builtUri = buildPlaybackUri(station, fileName, trackId, startTime, endTime, fileSize);
        if (StringUtils.isNotBlank(builtUri)) {
            InputStream stream = downloadByPlaybackUri(station, builtUri);
            if (stream != null) {
                log.info("ISAPI æŒ‰æž„造 playbackURI ä¸‹è½½æˆåŠŸ fileName={}", fileName);
                return stream;
            }
        }
        log.warn("ISAPI ä¸‹è½½å¤±è´¥ playbackUri={} mediaID={} fileName={}", playbackUri, mediaId, fileName);
        return null;
    }
    private InputStream downloadByMediaId(CollectionStation station, String mediaId, String fileName,
                                          String trackId, Date startTime, Date endTime, Long fileSize,
                                          Integer mediaType) {
        boolean video = isVideoFile(fileName) || isVideoMediaType(mediaType);
        boolean[][] strategies = video
                ? new boolean[][]{{true, true}, {true, false}, {false, true}, {false, false}}
                : new boolean[][]{{false, true}, {false, false}};
        for (boolean[] strategy : strategies) {
            boolean useMp4Encode = strategy[0];
            boolean useTime = strategy[1];
            InputStream stream = executeMediaIdDownload(station, mediaId, fileName, startTime, endTime, useMp4Encode, useTime);
            if (stream != null) {
                log.info("ISAPI mediaID下载成功 mediaID={} encodeMp4={} withTime={}", mediaId, useMp4Encode, useTime);
                return stream;
            }
        }
        return null;
    }
    private InputStream executeMediaIdDownload(CollectionStation station, String mediaId, String fileName,
                                               Date startTime, Date endTime, boolean useMp4Encode, boolean useTime) {
        StringBuilder query = new StringBuilder(IsapiConstants.DOWNLOAD)
                .append("?mediaID=").append(urlEncode(mediaId.trim()))
                .append("&downType=").append(IsapiConstants.DOWNLOAD_DOWN_TYPE_FILE);
        if (useMp4Encode) {
            query.append("&encodeType=").append(IsapiConstants.DOWNLOAD_ENCODE_MP4);
        }
        if (useTime) {
            appendDownloadTimeParams(query, startTime, endTime);
        }
        String queryString = query.toString();
        String token = getDownloadToken(station);
        if (StringUtils.isNotBlank(token)) {
            String uri = queryString + "&token=" + urlEncode(token);
            String body = buildDownloadRequestXml(null, mediaId, fileName);
            InputStream stream = validateDownloadStream(IsapiRequestHelper.doDownload(station, uri, body));
            if (stream != null) {
                return stream;
            }
        }
        InputStream stream = validateDownloadStream(IsapiRequestHelper.doDownload(station, queryString, null));
        if (stream != null) {
            return stream;
        }
        String body = buildDownloadRequestXml(null, mediaId, fileName);
        return validateDownloadStream(IsapiRequestHelper.doDownload(station, queryString, body));
    }
    private static InputStream validateDownloadStream(InputStream raw) {
        if (raw == null) {
            return null;
        }
        try {
            PushbackInputStream pb = new PushbackInputStream(raw, 512);
            byte[] head = new byte[512];
            int n = 0;
            int read;
            while (n < head.length && (read = pb.read(head, n, head.length - n)) != -1) {
                n += read;
            }
            if (n <= 0) {
                pb.close();
                return null;
            }
            if (isErrorPayload(head, n)) {
                log.warn("ISAPI下载返回错误内容: {}", new String(head, 0, Math.min(n, 200), StandardCharsets.UTF_8));
                pb.close();
                return null;
            }
            pb.unread(head, 0, n);
            return pb;
        } catch (Exception e) {
            log.warn("ISAPI下载流校验失败: {}", e.getMessage());
            try {
                raw.close();
            } catch (Exception ignored) {
            }
            return null;
        }
    }
    private static boolean isErrorPayload(byte[] head, int n) {
        String snippet = new String(head, 0, Math.min(n, 64), StandardCharsets.UTF_8).trim();
        return snippet.startsWith("<?xml") || snippet.startsWith("{")
                || (snippet.startsWith("<") && snippet.contains("ResponseStatus"));
    }
    private InputStream downloadByPlaybackUri(CollectionStation station, String playbackUri) {
        String token = getDownloadToken(station);
        if (StringUtils.isNotBlank(token)) {
            String uri = IsapiConstants.DOWNLOAD + "?token=" + urlEncode(token);
            String body = buildDownloadRequestXml(playbackUri, null, null);
            InputStream is = validateDownloadStream(IsapiRequestHelper.doDownload(station, uri, body));
            if (is != null) {
                return is;
            }
        }
        try {
            String uri = IsapiConstants.DOWNLOAD + "?playbackURI=" + urlEncode(playbackUri);
            return validateDownloadStream(IsapiRequestHelper.doDownload(station, uri, null));
        } catch (Exception e) {
            return null;
        }
    }
    private static String buildDownloadRequestXml(String playbackUri, String mediaId, String fileName) {
        StringBuilder xml = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\"?><downloadRequest>");
        if (StringUtils.isNotBlank(playbackUri)) {
            xml.append("<playbackURI>").append(escapeXml(playbackUri)).append("</playbackURI>");
        }
        if (StringUtils.isNotBlank(mediaId)) {
            xml.append("<mediaID>").append(escapeXml(mediaId.trim())).append("</mediaID>");
        }
        if (StringUtils.isNotBlank(fileName)) {
            xml.append("<name>").append(escapeXml(fileName)).append("</name>");
        }
        xml.append("</downloadRequest>");
        return xml.toString();
    }
    /** é‡‡é›†ç«™ search æ—  playbackURI æ—¶ï¼ŒæŒ‰æ–‡ä»¶å+时间段构造下载 URI */
    private static String buildPlaybackUri(CollectionStation station, String fileName, String trackId,
                                           Date startTime, Date endTime, Long fileSize) {
        if (StringUtils.isBlank(fileName) || startTime == null || endTime == null) {
            return null;
        }
        String host = station.getIp();
        if (StringUtils.isBlank(host)) {
            return null;
        }
        String track = StringUtils.isNotBlank(trackId) ? trackId.trim() : IsapiConstants.DEFAULT_TRACK_ID;
        StringBuilder uri = new StringBuilder("rtsp://").append(host)
                .append("/Streaming/tracks/").append(track)
                .append("?starttime=").append(formatPlaybackUriTime(startTime))
                .append("&endtime=").append(formatPlaybackUriTime(endTime))
                .append("&name=").append(fileName);
        if (fileSize != null && fileSize > 0) {
            uri.append("&size=").append(fileSize);
        }
        uri.append("&timeType=STD");
        return uri.toString();
    }
    private static void appendDownloadTimeParams(StringBuilder query, Date startTime, Date endTime) {
        if (startTime != null) {
            query.append("&startTime=").append(urlEncode(formatDownloadTime(startTime)));
        }
        if (endTime != null) {
            query.append("&endTime=").append(urlEncode(formatDownloadTime(endTime)));
        }
    }
    /** download æŽ¥å£æ—¶é—´ï¼šISO8601 å¸¦æ—¶åŒº */
    private static String formatDownloadTime(Date time) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
        return sdf.format(time);
    }
    /** playbackURI å†…时间:UTC ç´§å‡‘格式 */
    private static String formatPlaybackUriTime(Date time) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
        sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
        return sdf.format(time);
    }
    private static boolean isVideoFile(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            return true;
        }
        String lower = fileName.toLowerCase();
        return lower.endsWith(".mp4") || lower.endsWith(".avi") || lower.endsWith(".mov")
                || lower.endsWith(".mkv") || lower.endsWith(".264") || lower.endsWith(".h264")
                || lower.endsWith(".m4v");
    }
    private static boolean isVideoMediaType(Integer mediaType) {
        return mediaType == null || mediaType == 0;
    }
    private List<String> resolveTrackIds(CollectionStation station, String trackId) {
        if (StringUtils.isNotBlank(trackId) && !isAutoTrack(trackId)) {
            return Collections.singletonList(trackId.trim());
        }
        List<String> ids = new ArrayList<>();
        for (RecordTrackDTO track : getRecordTracks(station)) {
            if (track.getStreamType() == 0 || track.getStreamType() == 1 || track.getStreamType() == 2) {
                ids.add(track.getId());
            }
        }
        if (ids.isEmpty()) {
            ids.add(IsapiConstants.DEFAULT_TRACK_ID);
            ids.add(IsapiConstants.DEFAULT_PICTURE_TRACK_ID);
        }
        return ids;
    }
    private static boolean isAutoTrack(String trackId) {
        String val = trackId.trim();
        return "auto".equalsIgnoreCase(val) || "*".equals(val) || "0".equals(val);
    }
    private static String buildSearchXml(Date startTime, Date endTime, String trackId,
                                         int searchResultPosition, int maxResults) {
        String start = formatSearchTime(startTime, true);
        String end = formatSearchTime(endTime, true);
        StringBuilder trackXml = new StringBuilder();
        if (StringUtils.isNotBlank(trackId) && !isAutoTrack(trackId)) {
            trackXml.append("<trackID>").append(trackId.trim()).append("</trackID>");
        } else {
            trackXml.append("<trackID>").append(IsapiConstants.DEFAULT_TRACK_ID).append("</trackID>");
            trackXml.append("<trackID>").append(IsapiConstants.DEFAULT_PICTURE_TRACK_ID).append("</trackID>");
        }
        int pageSize = maxResults > 0 ? maxResults : IsapiConstants.DEFAULT_MAX_RESULTS;
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
                + "<CMSearchDescription>"
                + "<searchID>" + UUID.randomUUID() + "</searchID>"
                + "<trackList>" + trackXml + "</trackList>"
                + "<timeSpanList><timeSpan>"
                + "<startTime>" + start + "</startTime>"
                + "<endTime>" + end + "</endTime>"
                + "</timeSpan></timeSpanList>"
                + "<maxResults>" + pageSize + "</maxResults>"
                + "<searchResultPostion>" + searchResultPosition + "</searchResultPostion>"
                + "</CMSearchDescription>";
    }
    /** timeType=STD æ—¶ä½¿ç”¨è®¾å¤‡æœ¬åœ°æ ‡å‡†æ—¶é—´ï¼Œä¸å¸¦ Z åŽç¼€ */
    private static String formatSearchTime(Date time, boolean stdTime) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
        if (!stdTime) {
            sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
            return sdf.format(time) + "Z";
        }
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
        return sdf.format(time);
    }
    private static String escapeXml(String val) {
        if (val == null) {
            return "";
        }
        return val.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }
    private static String urlEncode(String val) {
        try {
            return URLEncoder.encode(val, StandardCharsets.UTF_8.name());
        } catch (UnsupportedEncodingException e) {
            return val;
        }
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package com.doumee.core.haikang.isapi;
/**
 * ISAPI URI å¸¸é‡ï¼ˆæ‰‹æŒç©¿æˆ´/行业穿戴/采集站)
 * å‚考:ISAPI开发指南_手持穿戴产品_行业穿戴产品
 */
public final class IsapiConstants {
    private IsapiConstants() {
    }
    // --- ç³»ç»Ÿ ---
    public static final String DEVICE_INFO = "/ISAPI/System/deviceInfo";
    public static final String DEVICE_CAPABILITIES = "/ISAPI/System/capabilities";
    public static final String SYSTEM_TIME_TYPE = "/ISAPI/System/time/timeType?format=json";
    // --- å†…容管理:录像/图片检索与下载 ---
    public static final String STORAGE = "/ISAPI/ContentMgmt/Storage";
    public static final String RECORD_TRACKS = "/ISAPI/ContentMgmt/record/tracks";
    public static final String SEARCH_CAPABILITIES = "/ISAPI/ContentMgmt/search/capabilities";
    public static final String SEARCH_PROFILE = "/ISAPI/ContentMgmt/search/profile";
    public static final String SEARCH = "/ISAPI/ContentMgmt/search";
    public static final String SEARCH_STD = "/ISAPI/ContentMgmt/search?timeType=STD";
    public static final String DOWNLOAD = "/ISAPI/ContentMgmt/download";
    public static final String DOWNLOAD_CAPABILITIES = "/ISAPI/ContentMgmt/download/capabilities";
    // --- å®‰å…¨ä»¤ç‰Œ ---
    public static final String SECURITY_TOKEN_JSON = "/ISAPI/Security/token?format=json";
    // --- é‡‡é›†ç«™ï¼ˆæ‰§æ³•记录/桌面采集站)---
    public static final String DOCK_BASIC_INFO = "/ISAPI/Traffic/dockStation/basicInfo?format=json";
    public static final String DOCK_DEVICE_MANAGEMENT = "/ISAPI/Traffic/dockStation/deviceManagement?format=json";
    public static final String DOCK_DEVICE_CAPABILITIES = "/ISAPI/Traffic/dockStation/deviceManagement/capabilities?format=json";
    public static final String DOCK_PERSON_MANAGEMENT = "/ISAPI/Traffic/dockStation/personManagement?format=json";
    public static final String DOCK_PLATFORM_SCHEDULE = "/ISAPI/Traffic/dockStation/platformConfig/schedule?format=json";
    public static final String CONTENT_TYPE_XML = "application/xml";
    public static final String CONTENT_TYPE_JSON = "application/json";
    /** é»˜è®¤ track:通道1主码流(录像) */
    public static final String DEFAULT_TRACK_ID = "101";
    /** é»˜è®¤ track:通道1抓图(图片) */
    public static final String DEFAULT_PICTURE_TRACK_ID = "103";
    public static final int DEFAULT_MAX_RESULTS = 100;
    public static final int MAX_PAGE_RESULTS = 500;
    /** ä¸‹è½½æ–‡ä»¶ï¼ˆdownType=2) */
    public static final String DOWNLOAD_DOWN_TYPE_FILE = "2";
    /** è§†é¢‘封装 mp4 */
    public static final String DOWNLOAD_ENCODE_MP4 = "mp4";
    /** åˆ†é¡µçŠ¶æ€ï¼šè¿˜æœ‰æ›´å¤šç»“æžœ */
    public static final String SEARCH_STATUS_MORE = "MORE";
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiJsonParser.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package com.doumee.core.haikang.isapi;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.doumee.core.haikang.isapi.model.DockDeviceDTO;
import com.doumee.core.haikang.isapi.model.DockStationBasicInfoDTO;
import java.util.ArrayList;
import java.util.List;
/**
 * ISAPI JSON å“åº”解析(采集站 dockStation æŽ¥å£ï¼‰
 */
public final class IsapiJsonParser {
    private IsapiJsonParser() {
    }
    public static DockStationBasicInfoDTO parseDockBasicInfo(String json) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        try {
            JSONObject root = JSONObject.parseObject(json);
            JSONObject basic = root.getJSONObject("BasicInfo");
            if (basic == null) {
                return null;
            }
            DockStationBasicInfoDTO dto = new DockStationBasicInfoDTO();
            dto.setDockStationId(basic.getString("dockStationID"));
            dto.setDockStationType(basic.getString("dockStationType"));
            return dto;
        } catch (Exception e) {
            return null;
        }
    }
    public static List<DockDeviceDTO> parseDockDeviceList(String json) {
        List<DockDeviceDTO> list = new ArrayList<>();
        if (json == null || json.isEmpty()) {
            return list;
        }
        try {
            JSONObject root = JSONObject.parseObject(json);
            JSONArray deviceInfoList = root.getJSONArray("DeviceInfoList");
            if (deviceInfoList == null) {
                return list;
            }
            for (int i = 0; i < deviceInfoList.size(); i++) {
                JSONObject item = deviceInfoList.getJSONObject(i);
                if (item == null) {
                    continue;
                }
                JSONObject info = item.getJSONObject("DeviceInfo");
                if (info == null) {
                    continue;
                }
                DockDeviceDTO dto = new DockDeviceDTO();
                dto.setDeviceId(info.getString("deviceId"));
                dto.setDeviceName(info.getString("deviceName"));
                dto.setShortSerialNumber(info.getString("shortSerialNumber"));
                dto.setAccessDeviceId(info.getString("accessDeviceID"));
                dto.setNetworkedDevice(info.getBoolean("isNetworkedDevice"));
                list.add(dto);
            }
        } catch (Exception ignored) {
        }
        return list;
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiRequestHelper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.doumee.core.haikang.isapi;
import com.doumee.core.utils.IsapiHttpUtil;
import com.doumee.dao.business.model.CollectionStation;
import java.io.InputStream;
/**
 * ISAPI è¯·æ±‚辅助:从采集站配置提取连接参数,避免 system_service åå‘依赖业务模块。
 */
final class IsapiRequestHelper {
    private IsapiRequestHelper() {
    }
    static String doGet(CollectionStation station, String uri) {
        IsapiStationContext ctx = context(station);
        return IsapiHttpUtil.doGet(ctx.getHost(), ctx.getPort(), ctx.isHttps(),
                ctx.getUsername(), ctx.getPassword(), uri);
    }
    static String doPost(CollectionStation station, String uri, String body, String contentType) {
        IsapiStationContext ctx = context(station);
        return IsapiHttpUtil.doPost(ctx.getHost(), ctx.getPort(), ctx.isHttps(),
                ctx.getUsername(), ctx.getPassword(), uri, body, contentType);
    }
    static InputStream doDownload(CollectionStation station, String uri, String downloadBody) {
        IsapiStationContext ctx = context(station);
        return IsapiHttpUtil.doDownload(ctx.getHost(), ctx.getPort(), ctx.isHttps(),
                ctx.getUsername(), ctx.getPassword(), uri, downloadBody);
    }
    static IsapiStationContext context(CollectionStation station) {
        return new IsapiStationContext(station);
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiStationContext.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package com.doumee.core.haikang.isapi;
import com.doumee.dao.business.model.CollectionStation;
import lombok.Getter;
/**
 * é‡‡é›†ç«™ ISAPI è¿žæŽ¥ä¸Šä¸‹æ–‡
 */
@Getter
public class IsapiStationContext {
    private final String host;
    private final int port;
    private final boolean https;
    private final String username;
    private final String password;
    public IsapiStationContext(CollectionStation station) {
        this.host = station.getIp();
        this.port = station.getPort() != null ? station.getPort() : 80;
        this.https = station.getUseHttps() != null && station.getUseHttps() == 1;
        this.username = station.getUsername();
        this.password = station.getPassword();
    }
    public IsapiStationContext(String host, int port, boolean https, String username, String password) {
        this.host = host;
        this.port = port;
        this.https = https;
        this.username = username;
        this.password = password;
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/IsapiXmlParser.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,762 @@
package com.doumee.core.haikang.isapi;
import com.doumee.core.haikang.isapi.model.DeviceInfoDTO;
import com.doumee.core.haikang.isapi.model.MediaItemDTO;
import com.doumee.core.haikang.isapi.model.RecordTrackDTO;
import com.doumee.core.haikang.isapi.model.SearchPageResult;
import com.doumee.core.haikang.isapi.model.StorageInfoDTO;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * ISAPI XML å“åº”解析
 */
public class IsapiXmlParser {
    private static final Logger log = LoggerFactory.getLogger(IsapiXmlParser.class);
    private IsapiXmlParser() {
    }
    public static DeviceInfoDTO parseDeviceInfo(String xml) {
        if (StringUtils.isBlank(xml)) {
            return null;
        }
        DeviceInfoDTO dto = new DeviceInfoDTO();
        dto.setDeviceName(extractTag(xml, "deviceName"));
        dto.setDeviceId(extractTag(xml, "deviceID"));
        dto.setModel(extractTag(xml, "model"));
        dto.setSerialNumber(extractTag(xml, "serialNumber"));
        dto.setFirmwareVersion(extractTag(xml, "firmwareVersion"));
        dto.setDeviceType(extractTag(xml, "deviceType"));
        return dto;
    }
    public static StorageInfoDTO parseStorage(String xml) {
        if (StringUtils.isBlank(xml)) {
            return null;
        }
        StorageInfoDTO dto = new StorageInfoDTO();
        long total = 0;
        long free = 0;
        try {
            Document doc = parseDocument(xml);
            NodeList hddNodes = doc.getElementsByTagName("hdd");
            for (int i = 0; i < hddNodes.getLength(); i++) {
                Element hdd = (Element) hddNodes.item(i);
                total += parseLong(getChildText(hdd, "capacity"));
                free += parseLong(getChildText(hdd, "freeSpace"));
            }
        } catch (Exception e) {
            total = parseLong(extractTag(xml, "capacity"));
            free = parseLong(extractTag(xml, "freeSpace"));
        }
        if (total > 0) {
            dto.setTotalSpaceGb(total);
            dto.setFreeSpaceGb(free);
        }
        return dto;
    }
    public static List<RecordTrackDTO> parseRecordTracks(String xml) {
        List<RecordTrackDTO> list = new ArrayList<>();
        if (StringUtils.isBlank(xml)) {
            return list;
        }
        try {
            Document doc = parseDocument(xml);
            NodeList trackNodes = doc.getElementsByTagName("Track");
            if (trackNodes.getLength() == 0) {
                trackNodes = doc.getElementsByTagName("track");
            }
            for (int i = 0; i < trackNodes.getLength(); i++) {
                Element track = (Element) trackNodes.item(i);
                String id = getChildText(track, "id");
                if (StringUtils.isBlank(id)) {
                    id = getChildText(track, "trackID");
                }
                if (StringUtils.isBlank(id)) {
                    continue;
                }
                RecordTrackDTO dto = new RecordTrackDTO();
                dto.setId(id.trim());
                dto.setChannel(getChildText(track, "channel"));
                dto.setStreamType(resolveStreamType(id));
                list.add(dto);
            }
        } catch (Exception ignored) {
        }
        if (list.isEmpty()) {
            Matcher m = Pattern.compile("<id>(\\d+)</id>").matcher(xml);
            while (m.find()) {
                RecordTrackDTO dto = new RecordTrackDTO();
                dto.setId(m.group(1));
                dto.setStreamType(resolveStreamType(dto.getId()));
                list.add(dto);
            }
        }
        return list;
    }
    public static SearchPageResult parseSearchPage(String xml) {
        SearchPageResult page = new SearchPageResult();
        if (StringUtils.isBlank(xml)) {
            return page;
        }
        String normalized = normalizeXml(xml);
        if (normalized.contains("statusCode") && normalized.contains("<statusCode>")) {
            String statusCode = firstNonBlank(extractTagIgnoreCase(normalized, "statusCode"), extractTag(normalized, "statusCode"));
            if (StringUtils.isNotBlank(statusCode) && !"1".equals(statusCode.trim()) && !"0".equals(statusCode.trim())) {
                log.warn("ISAPI search è¿”回错误 statusCode={} statusString={} subStatusCode={}",
                        statusCode,
                        firstNonBlank(extractTagIgnoreCase(normalized, "statusString"), extractTag(normalized, "statusString")),
                        firstNonBlank(extractTagIgnoreCase(normalized, "subStatusCode"), extractTag(normalized, "subStatusCode")));
            }
        }
        page.setResponseStatusStrg(firstNonBlank(
                extractTagIgnoreCase(normalized, "responseStatusStrg"),
                extractTag(normalized, "responseStatusStrg")));
        page.setNumOfMatches((int) parseLong(firstNonBlank(
                extractTagIgnoreCase(normalized, "numOfMatches"),
                extractTag(normalized, "numOfMatches"),
                extractTagIgnoreCase(normalized, "totalMatches"),
                extractTag(normalized, "totalMatches"))));
        page.setHasMore(IsapiConstants.SEARCH_STATUS_MORE.equalsIgnoreCase(page.getResponseStatusStrg()));
        page.setItems(parseSearchResult(normalized));
        if (page.getItems().isEmpty() && page.getNumOfMatches() > 0) {
            log.warn("ISAPI search numOfMatches={} ä½†è§£æž items ä¸ºç©º", page.getNumOfMatches());
        }
        return page;
    }
    public static List<MediaItemDTO> parseSearchResult(String xml) {
        List<MediaItemDTO> list = new ArrayList<>();
        if (StringUtils.isBlank(xml)) {
            return list;
        }
        String normalized = normalizeXml(xml);
        if (normalized.startsWith("{")) {
            return parseSearchResultJson(normalized);
        }
        try {
            Document doc = parseDocument(normalized);
            collectSearchMatchItems(doc.getDocumentElement(), list);
        } catch (Exception e) {
            log.warn("ISAPI search DOM解析失败,尝试正则: {}", e.getMessage());
        }
        if (list.isEmpty()) {
            collectSearchMatchItemsByRegex(normalized, list);
        }
        if (list.isEmpty() && (normalized.toLowerCase().contains("searchmatchitem")
                || normalized.toLowerCase().contains("matchelement"))) {
            log.warn("ISAPI search å«åŒ¹é…é¡¹ä½†è§£æžä¸ºç©ºï¼Œresponse={}",
                    StringUtils.abbreviate(normalized, 500));
        }
        return list;
    }
    private static final String[] MATCH_ITEM_TAGS = {"searchMatchItem", "matchElement", "matchItem"};
    private static void collectSearchMatchItems(Element root, List<MediaItemDTO> list) {
        if (root == null) {
            return;
        }
        for (String tag : MATCH_ITEM_TAGS) {
            NodeList nodes = getElementsByLocalName(root, tag);
            for (int i = 0; i < nodes.getLength(); i++) {
                Node node = nodes.item(i);
                if (node.getNodeType() != Node.ELEMENT_NODE) {
                    continue;
                }
                addParsedMedia(list, parseMatchItem((Element) node));
            }
            if (!list.isEmpty()) {
                return;
            }
        }
    }
    private static void collectSearchMatchItemsByRegex(String xml, List<MediaItemDTO> list) {
        for (String tag : MATCH_ITEM_TAGS) {
            Matcher matcher = Pattern.compile(
                    "(?is)<(?:\\w+:)?" + tag + "\\b[^>]*>(.*?)</(?:\\w+:)?" + tag + "\\s*>").matcher(xml);
            while (matcher.find()) {
                addParsedMedia(list, parseMatchItemFromBlock(matcher.group(1)));
            }
            if (!list.isEmpty()) {
                return;
            }
        }
    }
    private static void addParsedMedia(List<MediaItemDTO> list, MediaItemDTO media) {
        if (media == null) {
            return;
        }
        if (StringUtils.isBlank(media.getFileIndex())) {
            String fallback = buildFileIndexFallback(media);
            if (StringUtils.isNotBlank(fallback)) {
                media.setFileIndex(fallback);
                if (StringUtils.isBlank(media.getFileName())) {
                    media.setFileName(fallback);
                }
            }
        }
        if (StringUtils.isNotBlank(media.getFileIndex()) || StringUtils.isNotBlank(media.getPlaybackUri())) {
            list.add(media);
        }
    }
    private static List<MediaItemDTO> parseSearchResultJson(String json) {
        List<MediaItemDTO> list = new ArrayList<>();
        try {
            com.alibaba.fastjson.JSONObject root = com.alibaba.fastjson.JSONObject.parseObject(json);
            com.alibaba.fastjson.JSONObject searchResult = root.getJSONObject("CMSearchResult");
            if (searchResult != null) {
                root = searchResult;
            }
            com.alibaba.fastjson.JSONArray matchList = extractJsonMatchList(root);
            if (matchList == null || matchList.isEmpty()) {
                return list;
            }
            for (int i = 0; i < matchList.size(); i++) {
                com.alibaba.fastjson.JSONObject item = matchList.getJSONObject(i);
                if (item == null) {
                    continue;
                }
                com.alibaba.fastjson.JSONObject desc = item.getJSONObject("MediaSegmentDescriptor");
                if (desc == null) {
                    desc = item.getJSONObject("mediaSegmentDescriptor");
                }
                if (desc == null) {
                    desc = item.getJSONObject("recordSegmentDescriptor");
                }
                if (desc == null) {
                    desc = item;
                }
                MediaItemDTO dto = new MediaItemDTO();
                dto.setTrackId(firstNonBlank(item.getString("trackID"), item.getString("trackId")));
                com.alibaba.fastjson.JSONObject timeSpan = item.getJSONObject("timeSpan");
                dto.setStartTime(parseIsapiTime(firstNonBlank(item.getString("startTime"),
                        timeSpan != null ? timeSpan.getString("startTime") : null)));
                dto.setEndTime(parseIsapiTime(firstNonBlank(item.getString("endTime"),
                        timeSpan != null ? timeSpan.getString("endTime") : null)));
                dto.setPlaybackUri(firstNonBlank(desc.getString("playbackURI"), desc.getString("playbackUri"),
                        desc.getString("downloadURI"), desc.getString("fileUrl")));
                dto.setContentType(firstNonBlank(desc.getString("contentType"), desc.getString("contenType")));
                dto.setFileSize(desc.getLong("size"));
                dto.setFileName(firstNonBlank(desc.getString("name"), desc.getString("fileName"),
                        desc.getString("mediaID"), item.getString("sourceID")));
                dto.setUserName(firstNonBlank(desc.getString("policeName"), item.getString("policeName")));
                dto.setRecorderSn(firstNonBlank(desc.getString("bodyCameraShortSN"),
                        desc.getString("recorderCode"), desc.getString("bodyCameraShortSN")));
                if (StringUtils.isBlank(dto.getFileName())) {
                    dto.setFileName(extractNameFromUri(dto.getPlaybackUri()));
                }
                applyFileIdentity(dto, desc.getString("mediaID"));
                dto.setMediaType(resolveMediaType(dto.getContentType(), dto.getTrackId(), dto.getFileName()));
                addParsedMedia(list, dto);
            }
        } catch (Exception e) {
            log.warn("ISAPI search JSON解析失败: {}", e.getMessage());
        }
        return list;
    }
    private static com.alibaba.fastjson.JSONArray extractJsonMatchList(com.alibaba.fastjson.JSONObject root) {
        com.alibaba.fastjson.JSONArray matchList = root.getJSONArray("MatchList");
        if (matchList == null) {
            matchList = root.getJSONArray("matchList");
        }
        if (matchList != null) {
            return matchList;
        }
        com.alibaba.fastjson.JSONObject matchObj = root.getJSONObject("MatchList");
        if (matchObj == null) {
            matchObj = root.getJSONObject("matchList");
        }
        if (matchObj == null) {
            return null;
        }
        com.alibaba.fastjson.JSONArray items = matchObj.getJSONArray("SearchMatchItem");
        if (items == null) {
            items = matchObj.getJSONArray("searchMatchItem");
        }
        if (items == null) {
            items = matchObj.getJSONArray("matchElement");
        }
        if (items == null) {
            items = matchObj.getJSONArray("MatchElement");
        }
        if (items != null) {
            return items;
        }
        com.alibaba.fastjson.JSONObject single = matchObj.getJSONObject("SearchMatchItem");
        if (single == null) {
            single = matchObj.getJSONObject("searchMatchItem");
        }
        if (single == null) {
            single = matchObj.getJSONObject("matchElement");
        }
        if (single == null) {
            single = matchObj.getJSONObject("MatchElement");
        }
        if (single != null) {
            com.alibaba.fastjson.JSONArray arr = new com.alibaba.fastjson.JSONArray();
            arr.add(single);
            return arr;
        }
        return null;
    }
    private static MediaItemDTO parseMatchItemFromBlock(String block) {
        MediaItemDTO dto = new MediaItemDTO();
        dto.setTrackId(extractTagIgnoreCase(block, "trackID"));
        dto.setStartTime(parseIsapiTime(firstNonBlank(
                extractTagIgnoreCase(block, "startTime"),
                extractNestedTagIgnoreCase(block, "timeSpan", "startTime"))));
        dto.setEndTime(parseIsapiTime(firstNonBlank(
                extractTagIgnoreCase(block, "endTime"),
                extractNestedTagIgnoreCase(block, "timeSpan", "endTime"))));
        String descBlock = firstNonBlank(
                extractBlockIgnoreCase(block, "mediaSegmentDescriptor"),
                extractBlockIgnoreCase(block, "recordSegmentDescriptor"),
                block);
        dto.setPlaybackUri(firstNonBlank(
                extractTagIgnoreCase(descBlock, "playbackURI"),
                extractTagIgnoreCase(descBlock, "playbackUri"),
                extractTagIgnoreCase(descBlock, "downloadURI"),
                extractTagIgnoreCase(descBlock, "fileUrl")));
        dto.setContentType(firstNonBlank(
                extractTagIgnoreCase(descBlock, "contentType"),
                extractTagIgnoreCase(descBlock, "contenType")));
        dto.setFileSize(parseLong(firstNonBlank(
                extractTagIgnoreCase(descBlock, "size"),
                extractSizeFromUri(dto.getPlaybackUri()))));
        String mediaId = extractTagIgnoreCase(descBlock, "mediaID");
        dto.setFileName(firstNonBlank(
                extractTagIgnoreCase(descBlock, "name"),
                extractTagIgnoreCase(descBlock, "fileName"),
                mediaId,
                extractTagIgnoreCase(block, "sourceID")));
        dto.setUserName(firstNonBlank(
                extractTagIgnoreCase(descBlock, "policeName"),
                extractTagIgnoreCase(block, "policeName")));
        dto.setRecorderSn(firstNonBlank(
                extractTagIgnoreCase(descBlock, "bodyCameraShortSN"),
                extractTagIgnoreCase(descBlock, "recorderCode"),
                extractTagIgnoreCase(block, "bodyCameraShortSN"),
                extractTagIgnoreCase(block, "recorderCode"),
                extractTagIgnoreCase(descBlock, "shortSerialNumber")));
        if (StringUtils.isBlank(dto.getFileName())) {
            dto.setFileName(extractNameFromUri(dto.getPlaybackUri()));
        }
        applyFileIdentity(dto, mediaId);
        dto.setMediaType(resolveMediaType(dto.getContentType(), dto.getTrackId(), dto.getFileName()));
        return dto;
    }
    private static void applyFileIdentity(MediaItemDTO dto, String mediaId) {
        if (StringUtils.isBlank(dto.getFileIndex())) {
            dto.setFileIndex(firstNonBlank(
                    mediaId,
                    StringUtils.isNoneBlank(dto.getTrackId(), dto.getFileName())
                            ? dto.getTrackId() + "_" + dto.getFileName() : null,
                    dto.getFileName()));
        }
    }
    private static String resolveContentType(Element desc) {
        return firstNonBlank(getChildText(desc, "contentType"), getChildText(desc, "contenType"));
    }
    public static String parseSecurityToken(String json) {
        if (StringUtils.isBlank(json)) {
            return null;
        }
        Matcher m = Pattern.compile("\"value\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
        if (m.find()) {
            return m.group(1);
        }
        return null;
    }
    private static MediaItemDTO parseMatchItem(Element item) {
        MediaItemDTO dto = new MediaItemDTO();
        dto.setTrackId(firstNonBlank(
                getChildText(item, "trackID"),
                getChildText(item, "trackId"),
                getChildText(item, "trackid")));
        Element timeSpan = findFirstChildElement(item, "timeSpan");
        if (timeSpan != null) {
            dto.setStartTime(parseIsapiTime(getChildText(timeSpan, "startTime")));
            dto.setEndTime(parseIsapiTime(getChildText(timeSpan, "endTime")));
        } else {
            dto.setStartTime(parseIsapiTime(getChildText(item, "startTime")));
            dto.setEndTime(parseIsapiTime(getChildText(item, "endTime")));
        }
        Element desc = findDescriptorElement(item);
        dto.setPlaybackUri(firstNonBlank(
                getChildText(desc, "playbackURI"),
                getChildText(desc, "playbackUri"),
                getChildText(desc, "downloadURI"),
                getChildText(desc, "fileUrl"),
                getChildText(item, "playbackURI"),
                getChildText(item, "downloadURI")));
        dto.setContentType(resolveContentType(desc));
        dto.setFileSize(parseLong(firstNonBlank(
                getChildText(desc, "size"),
                getChildText(desc, "fileSize"),
                extractSizeFromUri(dto.getPlaybackUri()))));
        String mediaId = getChildText(desc, "mediaID");
        String name = firstNonBlank(
                getChildText(desc, "name"),
                getChildText(desc, "fileName"),
                getChildText(item, "name"),
                getChildText(item, "fileName"),
                mediaId,
                getChildText(item, "sourceID"));
        if (StringUtils.isBlank(name)) {
            name = extractNameFromUri(dto.getPlaybackUri());
        }
        dto.setFileName(name);
        applyFileIdentity(dto, mediaId);
        dto.setUserName(firstNonBlank(
                getChildText(desc, "policeName"),
                getChildText(item, "policeName"),
                getChildText(desc, "userName")));
        dto.setRecorderSn(firstNonBlank(
                getChildText(desc, "bodyCameraShortSN"),
                getChildText(desc, "recorderCode"),
                getChildText(item, "bodyCameraShortSN"),
                getChildText(item, "recorderCode"),
                getChildText(desc, "shortSerialNumber"),
                getChildText(item, "shortSerialNumber")));
        dto.setMediaType(resolveMediaType(dto.getContentType(), dto.getTrackId(), dto.getFileName()));
        return dto;
    }
    private static Element findDescriptorElement(Element item) {
        NodeList descriptors = getElementsByLocalName(item, "mediaSegmentDescriptor");
        if (descriptors.getLength() == 0) {
            descriptors = getElementsByLocalName(item, "recordSegmentDescriptor");
        }
        if (descriptors.getLength() == 0) {
            descriptors = getElementsByLocalName(item, "segmentDescriptor");
        }
        return descriptors.getLength() > 0 ? (Element) descriptors.item(0) : item;
    }
    private static Element findFirstChildElement(Element parent, String localName) {
        if (parent == null) {
            return null;
        }
        NodeList nodes = parent.getChildNodes();
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if (node.getNodeType() != Node.ELEMENT_NODE) {
                continue;
            }
            Element el = (Element) node;
            String name = el.getLocalName() != null ? el.getLocalName() : el.getNodeName();
            if (localName.equalsIgnoreCase(name)) {
                return el;
            }
        }
        return null;
    }
    private static int resolveStreamType(String trackId) {
        if (StringUtils.isBlank(trackId) || trackId.length() < 3) {
            return 0;
        }
        char last = trackId.charAt(trackId.length() - 1);
        if (last == '3') {
            return 2;
        }
        if (last == '2') {
            return 1;
        }
        return 0;
    }
    private static int resolveMediaType(String contentType, String trackId, String fileName) {
        if (StringUtils.isNotBlank(contentType)) {
            String lower = contentType.toLowerCase();
            if (lower.contains("picture") || lower.contains("image") || lower.equals("metadata")) {
                return 1;
            }
            if (lower.contains("audio")) {
                return 2;
            }
            if (lower.contains("video")) {
                return 0;
            }
        }
        if (StringUtils.isNotBlank(fileName)) {
            String lower = fileName.toLowerCase();
            if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")
                    || lower.endsWith(".bmp")) {
                return 1;
            }
            if (lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".aac")) {
                return 2;
            }
            if (lower.endsWith(".mp4") || lower.endsWith(".avi") || lower.endsWith(".mov")
                    || lower.endsWith(".mkv")) {
                return 0;
            }
        }
        if (StringUtils.isNotBlank(trackId) && trackId.endsWith("3")) {
            return 1;
        }
        return 0;
    }
    private static int resolveMediaType(String contentType, String trackId) {
        return resolveMediaType(contentType, trackId, null);
    }
    private static String extractNameFromUri(String uri) {
        if (StringUtils.isBlank(uri)) {
            return null;
        }
        String normalized = uri.replace("&amp;", "&");
        Matcher m = Pattern.compile("[?&](?:name|filename|fileName)=([^&\\s]+)", Pattern.CASE_INSENSITIVE).matcher(normalized);
        if (m.find()) {
            try {
                return URLDecoder.decode(m.group(1), StandardCharsets.UTF_8.name());
            } catch (Exception e) {
                return m.group(1);
            }
        }
        int slash = normalized.lastIndexOf('/');
        if (slash >= 0 && slash < normalized.length() - 1) {
            String tail = normalized.substring(slash + 1);
            int q = tail.indexOf('?');
            if (q > 0) {
                tail = tail.substring(0, q);
            }
            if (StringUtils.isNotBlank(tail) && !tail.equals("tracks")) {
                return tail;
            }
        }
        return null;
    }
    private static String buildFileIndexFallback(MediaItemDTO dto) {
        if (StringUtils.isNotBlank(dto.getPlaybackUri())) {
            return String.valueOf(Math.abs(dto.getPlaybackUri().hashCode()));
        }
        if (dto.getStartTime() != null) {
            return StringUtils.defaultString(dto.getTrackId(), "track") + "_" + dto.getStartTime().getTime();
        }
        return null;
    }
    private static String getDirectChildText(Element parent, String tag) {
        if (parent == null) {
            return null;
        }
        NodeList nodes = parent.getChildNodes();
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE && tag.equalsIgnoreCase(node.getNodeName())) {
                return node.getTextContent();
            }
        }
        return null;
    }
    private static NodeList getElementsByLocalName(Element root, String localName) {
        NodeList nodes = root.getElementsByTagNameNS("*", localName);
        if (nodes.getLength() > 0) {
            return nodes;
        }
        nodes = root.getElementsByTagName(localName);
        if (nodes.getLength() > 0) {
            return nodes;
        }
        return findElementsByLocalNameIgnoreCase(root, localName);
    }
    private static NodeList findElementsByLocalNameIgnoreCase(Element root, String localName) {
        java.util.List<Element> matched = new ArrayList<>();
        collectElementsByLocalNameIgnoreCase(root, localName.toLowerCase(), matched);
        return new NodeListWrapper(matched);
    }
    private static void collectElementsByLocalNameIgnoreCase(Element element, String localNameLower,
                                                             java.util.List<Element> matched) {
        if (localNameLower.equals(element.getLocalName() != null
                ? element.getLocalName().toLowerCase()
                : element.getNodeName().toLowerCase())) {
            matched.add(element);
        }
        NodeList children = element.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node child = children.item(i);
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                collectElementsByLocalNameIgnoreCase((Element) child, localNameLower, matched);
            }
        }
    }
    private static String normalizeXml(String xml) {
        String text = stripBom(StringUtils.defaultString(xml).trim());
        if (text.startsWith("\"") && text.endsWith("\"")) {
            text = text.substring(1, text.length() - 1);
        }
        if (text.startsWith("&lt;") || text.startsWith("&LT;")) {
            text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"");
        }
        return text;
    }
    private static String extractSizeFromUri(String uri) {
        if (StringUtils.isBlank(uri)) {
            return null;
        }
        Matcher matcher = Pattern.compile("[?&;]size=([0-9]+)", Pattern.CASE_INSENSITIVE)
                .matcher(uri.replace("&amp;", "&"));
        return matcher.find() ? matcher.group(1) : null;
    }
    private static String stripBom(String text) {
        if (text != null && text.startsWith("\uFEFF")) {
            return text.substring(1);
        }
        return text;
    }
    private static String extractTagIgnoreCase(String xml, String tag) {
        Pattern pattern = Pattern.compile(
                "(?is)<(?:\\w+:)?" + Pattern.quote(tag) + "\\b[^>]*>([^<]*)</(?:\\w+:)?" + Pattern.quote(tag) + "\\s*>");
        Matcher matcher = pattern.matcher(xml);
        return matcher.find() ? matcher.group(1).trim() : null;
    }
    private static String extractNestedTagIgnoreCase(String xml, String parentTag, String childTag) {
        String parentBlock = extractBlockIgnoreCase(xml, parentTag);
        if (StringUtils.isBlank(parentBlock)) {
            return null;
        }
        return extractTagIgnoreCase(parentBlock, childTag);
    }
    private static String extractBlockIgnoreCase(String xml, String tag) {
        Pattern pattern = Pattern.compile(
                "(?is)<(?:\\w+:)?" + Pattern.quote(tag) + "\\b[^>]*>(.*?)</(?:\\w+:)?" + Pattern.quote(tag) + "\\s*>");
        Matcher matcher = pattern.matcher(xml);
        return matcher.find() ? matcher.group(1) : null;
    }
    /** å…¼å®¹å‘½åç©ºé—´ XML çš„ NodeList */
    private static class NodeListWrapper implements NodeList {
        private final java.util.List<Element> elements;
        NodeListWrapper(java.util.List<Element> elements) {
            this.elements = elements;
        }
        @Override
        public Node item(int index) {
            return elements.get(index);
        }
        @Override
        public int getLength() {
            return elements.size();
        }
    }
    private static Document parseDocument(String xml) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setNamespaceAware(true);
        return factory.newDocumentBuilder()
                .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
    }
    private static String getChildText(Element parent, String tag) {
        if (parent == null) {
            return null;
        }
        NodeList nodes = getElementsByLocalName(parent, tag);
        if (nodes.getLength() == 0) {
            return null;
        }
        return nodes.item(0).getTextContent();
    }
    private static String extractTag(String xml, String tag) {
        Matcher m = Pattern.compile("<" + tag + ">([^<]*)</" + tag + ">").matcher(xml);
        if (m.find()) {
            return m.group(1).trim();
        }
        return null;
    }
    private static long parseLong(String val) {
        if (StringUtils.isBlank(val)) {
            return 0;
        }
        try {
            return Long.parseLong(val.trim());
        } catch (NumberFormatException e) {
            return 0;
        }
    }
    private static Date parseIsapiTime(String timeStr) {
        if (StringUtils.isBlank(timeStr)) {
            return null;
        }
        String[] patterns = {
                "yyyy-MM-dd'T'HH:mm:ss'Z'",
                "yyyy-MM-dd'T'HH:mm:ssXXX",
                "yyyy-MM-dd'T'HH:mm:ss",
                "yyyyMMdd'T'HHmmss'Z'",
                "yyyyMMdd'T'HHmmss"
        };
        for (String pattern : patterns) {
            try {
                SimpleDateFormat sdf = new SimpleDateFormat(pattern);
                if (pattern.endsWith("'Z'")) {
                    sdf.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
                }
                return sdf.parse(timeStr.trim());
            } catch (Exception ignored) {
            }
        }
        return null;
    }
    private static String firstNonBlank(String... values) {
        for (String v : values) {
            if (StringUtils.isNotBlank(v)) {
                return v;
            }
        }
        return null;
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DeviceInfoDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
@Data
public class DeviceInfoDTO {
    private String deviceName;
    private String deviceId;
    private String model;
    private String serialNumber;
    private String firmwareVersion;
    private String deviceType;
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DockDeviceDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
@Data
public class DockDeviceDTO {
    private String deviceId;
    private String deviceName;
    private String shortSerialNumber;
    private String accessDeviceId;
    private Boolean networkedDevice;
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/DockStationBasicInfoDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
@Data
public class DockStationBasicInfoDTO {
    private String dockStationId;
    private String dockStationType;
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
import java.util.Date;
@Data
public class MediaItemDTO {
    private String fileIndex;
    private String fileName;
    private String playbackUri;
    private String contentType;
    private int mediaType;
    private Long fileSize;
    private Date startTime;
    private Date endTime;
    private String recorderSn;
    private String userName;
    /** ISAPI trackID,如 101/103 */
    private String trackId;
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/RecordTrackDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
@Data
public class RecordTrackDTO {
    /** track ID,如 101=通道1主码流,103=通道1抓图 */
    private String id;
    private String channel;
    /** 0=video 1=substream 2=picture */
    private int streamType;
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/SearchPageResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class SearchPageResult {
    private List<MediaItemDTO> items = new ArrayList<>();
    /** OK / MORE / NO MATCHES */
    private String responseStatusStrg;
    private int numOfMatches;
    private boolean hasMore;
    public boolean isMore() {
        return hasMore || "MORE".equalsIgnoreCase(responseStatusStrg);
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/StorageInfoDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package com.doumee.core.haikang.isapi.model;
import lombok.Data;
@Data
public class StorageInfoDTO {
    /** æ€»å®¹é‡ GB */
    private Long totalSpaceGb;
    /** å‰©ä½™å®¹é‡ GB */
    private Long freeSpaceGb;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/CollectionMediaSyncRequest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.doumee.dao.admin.request;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
@Data
public class CollectionMediaSyncRequest {
    @ApiModelProperty(value = "采集站ID")
    private Integer stationId;
    @ApiModelProperty(value = "开始时间")
    private Date startTime;
    @ApiModelProperty(value = "结束时间")
    private Date endTime;
    @ApiModelProperty(value = "批量下载数量限制")
    private Integer limit;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionDockDeviceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.doumee.dao.business;
import com.doumee.dao.business.model.CollectionDockDevice;
import com.github.yulichang.base.MPJBaseMapper;
public interface CollectionDockDeviceMapper extends MPJBaseMapper<CollectionDockDevice> {
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionMediaMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.doumee.dao.business;
import com.doumee.dao.business.model.CollectionMedia;
import com.github.yulichang.base.MPJBaseMapper;
public interface CollectionMediaMapper extends MPJBaseMapper<CollectionMedia> {
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/CollectionStationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.doumee.dao.business;
import com.doumee.dao.business.model.CollectionStation;
import com.github.yulichang.base.MPJBaseMapper;
public interface CollectionStationMapper extends MPJBaseMapper<CollectionStation> {
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionDockDevice.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * é‡‡é›†ç«™æ§½ä½æ‰§æ³•记录仪
 */
@Data
@ApiModel("采集站执法设备")
@TableName("`collection_dock_device`")
public class CollectionDockDevice {
    @TableId(type = IdType.AUTO)
    private Integer id;
    @ApiModelProperty(value = "采集站ID")
    private Integer stationId;
    @TableField(exist = false)
    @ApiModelProperty(value = "采集站名称")
    private String stationName;
    private String deviceId;
    private String deviceName;
    private String shortSerialNumber;
    private String accessDeviceId;
    private Integer networkedDevice;
    private Date lastSyncTime;
    private Date createDate;
    private Integer isdeleted;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * é‡‡é›†ç«™åª’体文件
 */
@Data
@ApiModel("采集站媒体文件")
@TableName("`collection_media`")
public class CollectionMedia {
    @TableId(type = IdType.AUTO)
    private Integer id;
    @ApiModelProperty(value = "采集站ID")
    private Integer stationId;
    @ApiModelProperty(value = "设备侧文件唯一标识")
    private String fileIndex;
    @ApiModelProperty(value = "ISAPI trackID")
    private String trackId;
    private String fileName;
    private String playbackUri;
    @ApiModelProperty(value = "0视频 1图片 2音频")
    private Integer mediaType;
    private String contentType;
    private Long fileSize;
    private Date startTime;
    private Date endTime;
    private String recorderSn;
    private String userName;
    private String filePathLocal;
    @TableField(exist = false)
    @ApiModelProperty(value = "FTP完整访问地址")
    private String fileUrlFull;
    @ApiModelProperty(value = "0待下载 1已下载 2失败 3下载中")
    private Integer downloadStatus;
    private Date downloadTime;
    private Date createDate;
    private Integer isdeleted;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionStation.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.doumee.core.annotation.excel.ExcelColumn;
import com.doumee.service.business.third.model.LoginUserModel;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * æµ·åº·æ¡Œé¢é‡‡é›†ç«™è®¾å¤‡
 */
@Data
@ApiModel("采集站设备")
@TableName("`collection_station`")
public class CollectionStation extends LoginUserModel {
    @TableId(type = IdType.AUTO)
    @ApiModelProperty(value = "主键")
    private Integer id;
    private String creator;
    private Date createDate;
    private String edirot;
    private Date editDate;
    private Integer isdeleted;
    private String remark;
    @ApiModelProperty(value = "采集站名称")
    @ExcelColumn(name = "采集站名称")
    private String name;
    @ApiModelProperty(value = "设备序列号")
    private String serialNo;
    @ApiModelProperty(value = "设备IP")
    private String ip;
    @ApiModelProperty(value = "设备端口")
    private Integer port;
    @ApiModelProperty(value = "是否HTTPS 0否1是")
    private Integer useHttps;
    @ApiModelProperty(value = "登录用户名")
    private String username;
    @ApiModelProperty(value = "登录密码")
    private String password;
    @ApiModelProperty(value = "设备型号")
    private String model;
    @ApiModelProperty(value = "在线状态 0离线 1在线")
    private Integer online;
    @ApiModelProperty(value = "总存储空间(KB)")
    private Long totalSpace;
    @ApiModelProperty(value = "剩余存储空间(KB)")
    private Long freeSpace;
    @ApiModelProperty(value = "软件版本")
    private String softwareVersion;
    @ApiModelProperty(value = "最近同步时间")
    private Date lastSyncTime;
    @ApiModelProperty(value = "状态 0禁用 1启用")
    private Integer status;
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/CollectionMediaSyncService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package com.doumee.service.business;
import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
import com.doumee.dao.business.model.CollectionMedia;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface CollectionMediaSyncService {
    String syncMediaList(CollectionMediaSyncRequest request);
    String downloadMedia(Integer mediaId);
    String batchDownload(CollectionMediaSyncRequest request);
    PageData<CollectionMedia> findPage(PageWrap<CollectionMedia> pageWrap);
    void previewMedia(Integer mediaId, HttpServletRequest request, HttpServletResponse response);
    void downloadMediaFile(Integer mediaId, HttpServletRequest request, HttpServletResponse response);
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/CollectionStationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.doumee.service.business;
import com.doumee.dao.business.model.CollectionDockDevice;
import com.doumee.dao.business.model.CollectionStation;
import com.doumee.service.business.third.model.LoginUserInfo;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import java.util.List;
public interface CollectionStationService {
    Integer create(CollectionStation station);
    void updateById(CollectionStation station);
    void deleteById(Integer id, LoginUserInfo user);
    void deleteByIdInBatch(List<Integer> ids, LoginUserInfo user);
    PageData<CollectionStation> findPage(PageWrap<CollectionStation> pageWrap);
    List<CollectionStation> findList(CollectionStation query);
    CollectionStation findById(Integer id);
    String syncAllStations();
    String syncStationStatus(Integer stationId);
    String probeIsapi(Integer stationId);
    List<CollectionDockDevice> findDockDevices(Integer stationId);
    PageData<CollectionDockDevice> findDockDevicePage(PageWrap<CollectionDockDevice> pageWrap);
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,667 @@
package com.doumee.service.business.impl.collection;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.biz.system.SystemDictDataBiz;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.haikang.isapi.IsapiClient;
import com.doumee.core.haikang.isapi.IsapiConstants;
import com.doumee.core.haikang.isapi.model.MediaItemDTO;
import com.doumee.core.utils.Constants;
import com.doumee.core.utils.DateUtil;
import com.doumee.core.utils.FtpUtil;
import com.doumee.core.utils.VideoTranscodeUtil;
import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
import com.doumee.dao.business.CollectionMediaMapper;
import com.doumee.dao.business.CollectionStationMapper;
import com.doumee.dao.business.model.CollectionMedia;
import com.doumee.dao.business.model.CollectionStation;
import com.doumee.service.business.CollectionMediaSyncService;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.Executor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Service
@Slf4j
public class CollectionMediaSyncServiceImpl implements CollectionMediaSyncService {
    @Autowired
    private CollectionMediaMapper collectionMediaMapper;
    @Autowired
    private CollectionStationMapper collectionStationMapper;
    @Autowired
    private SystemDictDataBiz systemDictDataBiz;
    @Resource(name = "asyncExecutor")
    private Executor asyncExecutor;
    private final IsapiClient isapiClient = new IsapiClient();
    private static FtpUtil ftp;
    @Override
    public String syncMediaList(CollectionMediaSyncRequest request) {
        if (Constants.DEALING_HK_COLLECTION_MEDIA) {
            throw new BusinessException(ResponseStatus.NOT_ALLOWED.getCode(), "媒体同步任务正在执行,请稍后");
        }
        Constants.DEALING_HK_COLLECTION_MEDIA = true;
        try {
            Date endTime = request.getEndTime() != null ? request.getEndTime() : new Date();
            Date startTime = request.getStartTime();
            if (startTime == null) {
                Calendar cal = Calendar.getInstance();
                cal.setTime(endTime);
                cal.add(Calendar.DAY_OF_MONTH, -7);
                startTime = cal.getTime();
            }
            String trackId = getTrackId();
            int totalNew = 0;
            if (request.getStationId() != null) {
                totalNew += syncStationMedia(request.getStationId(), startTime, endTime, trackId);
            } else {
                List<CollectionStation> stations = collectionStationMapper.selectList(new QueryWrapper<CollectionStation>().lambda()
                        .eq(CollectionStation::getIsdeleted, Constants.ZERO)
                        .eq(CollectionStation::getStatus, Constants.ONE));
                for (CollectionStation station : stations) {
                    try {
                        totalNew += syncStationMedia(station.getId(), startTime, endTime, trackId);
                    } catch (Exception e) {
                        log.error("采集站媒体索引同步失败 stationId={}: {}", station.getId(), e.getMessage());
                    }
                }
            }
            return "同步完成,新增索引【" + totalNew + "】条";
        } finally {
            Constants.DEALING_HK_COLLECTION_MEDIA = false;
        }
    }
    private int syncStationMedia(Integer stationId, Date startTime, Date endTime, String trackId) {
        CollectionStation station = collectionStationMapper.selectById(stationId);
        if (station == null || Constants.equalsInteger(station.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        List<MediaItemDTO> items = isapiClient.searchMediaAll(station, startTime, endTime, trackId, IsapiConstants.MAX_PAGE_RESULTS);
        log.info("采集站媒体检索 stationId={} ip={} range={}~{} track={} found={}",
                stationId, station.getIp(), startTime, endTime, trackId, items.size());
        int count = 0;
        Date now = new Date();
        for (MediaItemDTO item : items) {
            if (StringUtils.isBlank(item.getFileIndex())) {
                continue;
            }
            Long exists = collectionMediaMapper.selectCount(new QueryWrapper<CollectionMedia>().lambda()
                    .eq(CollectionMedia::getStationId, stationId)
                    .eq(CollectionMedia::getFileIndex, item.getFileIndex())
                    .eq(CollectionMedia::getIsdeleted, Constants.ZERO));
            if (exists != null && exists > 0) {
                continue;
            }
            CollectionMedia media = new CollectionMedia();
            media.setStationId(stationId);
            media.setFileIndex(item.getFileIndex());
            media.setTrackId(item.getTrackId());
            media.setFileName(item.getFileName());
            media.setPlaybackUri(item.getPlaybackUri());
            media.setMediaType(item.getMediaType());
            media.setContentType(item.getContentType());
            media.setFileSize(item.getFileSize());
            media.setStartTime(item.getStartTime());
            media.setEndTime(item.getEndTime());
            media.setRecorderSn(item.getRecorderSn());
            media.setUserName(item.getUserName());
            media.setDownloadStatus(Constants.ZERO);
            media.setCreateDate(now);
            media.setIsdeleted(Constants.ZERO);
            collectionMediaMapper.insert(media);
            count++;
        }
        return count;
    }
    @Override
    public String downloadMedia(Integer mediaId) {
        CollectionMedia media = collectionMediaMapper.selectById(mediaId);
        if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        if (Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件已下载,无需重复下载");
        }
        if (Constants.equalsInteger(media.getDownloadStatus(), Constants.COLLECTION_MEDIA_DOWNLOADING)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件正在下载中,请稍后刷新");
        }
        CollectionStation station = collectionStationMapper.selectById(media.getStationId());
        if (station == null) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        CollectionMedia downloading = new CollectionMedia();
        downloading.setDownloadStatus(Constants.COLLECTION_MEDIA_DOWNLOADING);
        int updated = collectionMediaMapper.update(downloading, new QueryWrapper<CollectionMedia>().lambda()
                .eq(CollectionMedia::getId, mediaId)
                .in(CollectionMedia::getDownloadStatus, Constants.ZERO, 2));
        if (updated <= 0) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "无法提交下载,请刷新后重试");
        }
        asyncExecutor.execute(() -> executeDownloadAsync(mediaId));
        return "已提交下载任务,请稍后刷新查看状态";
    }
    private void executeDownloadAsync(Integer mediaId) {
        try {
            CollectionMedia media = collectionMediaMapper.selectById(mediaId);
            if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
                return;
            }
            CollectionStation station = collectionStationMapper.selectById(media.getStationId());
            if (station == null) {
                markDownloadFailed(mediaId);
                return;
            }
            String path = downloadToFtp(station, media);
            if (StringUtils.isBlank(path)) {
                markDownloadFailed(mediaId);
                log.error("异步下载失败 mediaId={}", mediaId);
                return;
            }
            CollectionMedia update = new CollectionMedia();
            update.setId(mediaId);
            update.setFilePathLocal(path);
            update.setDownloadStatus(Constants.ONE);
            update.setDownloadTime(new Date());
            collectionMediaMapper.updateById(update);
            log.info("异步下载成功 mediaId={} path={}", mediaId, path);
        } catch (Exception e) {
            markDownloadFailed(mediaId);
            log.error("异步下载异常 mediaId={}: {}", mediaId, e.getMessage(), e);
        }
    }
    private void markDownloadFailed(Integer mediaId) {
        CollectionMedia fail = new CollectionMedia();
        fail.setId(mediaId);
        fail.setDownloadStatus(2);
        collectionMediaMapper.updateById(fail);
    }
    @Override
    public String batchDownload(CollectionMediaSyncRequest request) {
        int limit = request.getLimit() != null ? request.getLimit() : getBatchSize();
        QueryWrapper<CollectionMedia> wrapper = new QueryWrapper<>();
        wrapper.lambda()
                .eq(CollectionMedia::getIsdeleted, Constants.ZERO)
                .eq(CollectionMedia::getDownloadStatus, Constants.ZERO)
                .eq(request.getStationId() != null, CollectionMedia::getStationId, request.getStationId())
                .orderByAsc(CollectionMedia::getId)
                .last("limit " + limit);
        List<CollectionMedia> list = collectionMediaMapper.selectList(wrapper);
        int submitted = 0;
        int skip = 0;
        for (CollectionMedia media : list) {
            try {
                downloadMedia(media.getId());
                submitted++;
            } catch (BusinessException e) {
                skip++;
                log.warn("批量下载跳过 mediaId={}: {}", media.getId(), e.getMessage());
            } catch (Exception e) {
                skip++;
                log.error("批量下载提交失败 mediaId={}: {}", media.getId(), e.getMessage());
            }
        }
        return "已提交下载【" + submitted + "】条,跳过【" + skip + "】条";
    }
    @Override
    public PageData<CollectionMedia> findPage(PageWrap<CollectionMedia> pageWrap) {
        CollectionMedia model = pageWrap.getModel() != null ? pageWrap.getModel() : new CollectionMedia();
        QueryWrapper<CollectionMedia> wrapper = new QueryWrapper<>();
        wrapper.lambda()
                .eq(CollectionMedia::getIsdeleted, Constants.ZERO)
                .eq(model.getStationId() != null, CollectionMedia::getStationId, model.getStationId())
                .eq(model.getDownloadStatus() != null, CollectionMedia::getDownloadStatus, model.getDownloadStatus())
                .eq(model.getMediaType() != null, CollectionMedia::getMediaType, model.getMediaType())
                .orderByDesc(CollectionMedia::getId);
        IPage<CollectionMedia> page = collectionMediaMapper.selectPage(
                new Page<>(pageWrap.getPage(), pageWrap.getCapacity()), wrapper);
        page.getRecords().forEach(this::fillMediaAccessUrl);
        return PageData.from(page);
    }
    @Override
    public void previewMedia(Integer mediaId, HttpServletRequest request, HttpServletResponse response) {
        streamMediaFile(mediaId, request, response, false);
    }
    @Override
    public void downloadMediaFile(Integer mediaId, HttpServletRequest request, HttpServletResponse response) {
        streamMediaFile(mediaId, request, response, true);
    }
    private void streamMediaFile(Integer mediaId, HttpServletRequest request, HttpServletResponse response, boolean attachment) {
        CollectionMedia media = collectionMediaMapper.selectById(mediaId);
        if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "文件尚未下载,无法访问");
        }
        String remotePath = getMediaFolder() + media.getFilePathLocal();
        FtpUtil ftpClient = null;
        try {
            ftpClient = createFtpClient();
            if (!ftpClient.connect()) {
                throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "FTP连接失败");
            }
            String contentType = resolvePreviewContentType(media);
            String fileName = StringUtils.defaultIfBlank(media.getFileName(), "media" + resolveExt(media));
            long fileSize = ftpClient.getRemoteFileSize(remotePath);
            if (fileSize <= 0 && media.getFileSize() != null && media.getFileSize() > 0) {
                fileSize = media.getFileSize();
            }
            if (fileSize <= 0) {
                log.warn("媒体文件无法获取大小 mediaId={} remotePath={}", mediaId, remotePath);
            }
            response.setContentType(contentType);
            String disposition = (attachment ? "attachment" : "inline") + ";filename="
                    + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name());
            response.setHeader("Content-Disposition", disposition);
            if (attachment) {
                response.setHeader("eva-download-filename", URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()));
            }
            response.setHeader("Accept-Ranges", "bytes");
            response.setHeader("eva-opera-type", attachment ? "download" : "preview");
            response.setHeader("Cache-Control", "private, max-age=3600");
            RangeSpec range = fileSize > 0
                    ? parseRangeHeader(request != null ? request.getHeader("Range") : null, fileSize) : null;
            OutputStream out = response.getOutputStream();
            boolean streamed;
            if (range != null) {
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
                response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + fileSize);
                response.setHeader("Content-Length", String.valueOf(range.length()));
                streamed = ftpClient.streamRemoteFileRange(remotePath, out, range.start, range.length());
            } else {
                if (fileSize > 0) {
                    response.setHeader("Content-Length", String.valueOf(fileSize));
                }
                streamed = ftpClient.streamRemoteFile(remotePath, out);
            }
            if (!streamed) {
                log.warn("媒体文件 FTP è¯»å–失败 mediaId={} remotePath={}", mediaId, remotePath);
                if (!response.isCommitted()) {
                    response.resetBuffer();
                    response.setStatus(HttpServletResponse.SC_NOT_FOUND);
                    response.setContentType("text/plain;charset=UTF-8");
                    out.write("文件读取失败".getBytes(StandardCharsets.UTF_8));
                }
                return;
            }
            out.flush();
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("媒体文件输出失败 mediaId={}: {}", mediaId, e.getMessage());
            if (!response.isCommitted()) {
                try {
                    response.resetBuffer();
                    response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                    response.setContentType("text/plain;charset=UTF-8");
                    response.getOutputStream().write("文件读取失败".getBytes(StandardCharsets.UTF_8));
                } catch (Exception ignored) {
                }
            }
        } finally {
            try {
                if (ftpClient != null) {
                    ftpClient.disconnect();
                }
            } catch (Exception ignored) {
            }
        }
    }
    private void fillMediaAccessUrl(CollectionMedia media) {
        if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE) || StringUtils.isBlank(media.getFilePathLocal())) {
            return;
        }
        try {
            String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode();
            media.setFileUrlFull(prefix + getMediaFolder() + media.getFilePathLocal());
        } catch (Exception e) {
            log.warn("构建媒体访问URL失败 mediaId={}: {}", media.getId(), e.getMessage());
        }
    }
    private FtpUtil createFtpClient() throws IOException {
        return new FtpUtil(
                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(),
                Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()),
                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(),
                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode());
    }
    private String resolvePreviewContentType(CollectionMedia media) {
        String name = StringUtils.defaultString(media.getFileName()).toLowerCase();
        if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
            return "image/jpeg";
        }
        if (name.endsWith(".png")) {
            return "image/png";
        }
        if (name.endsWith(".gif")) {
            return "image/gif";
        }
        if (name.endsWith(".bmp")) {
            return "image/bmp";
        }
        if (name.endsWith(".mp3")) {
            return "audio/mpeg";
        }
        if (name.endsWith(".mp4") || name.endsWith(".m4v")) {
            return "video/mp4";
        }
        if (name.endsWith(".wav")) {
            return "audio/wav";
        }
        if (name.endsWith(".txt") || name.endsWith(".log")) {
            return "text/plain;charset=UTF-8";
        }
        if (media.getMediaType() != null && media.getMediaType() == 1) {
            return "image/jpeg";
        }
        if (media.getMediaType() != null && media.getMediaType() == 2) {
            return "audio/mpeg";
        }
        return "video/mp4";
    }
    private String downloadToFtp(CollectionStation station, CollectionMedia media) {
        InputStream is = null;
        File tempSource = null;
        File tempTarget = null;
        try {
            is = isapiClient.downloadMedia(station, media.getPlaybackUri(), media.getFileIndex(),
                    media.getFileName(), media.getTrackId(), media.getStartTime(), media.getEndTime(),
                    media.getFileSize(), media.getMediaType());
            if (is == null) {
                log.error("ISAPI下载无响应 mediaId={} mediaID={} fileName={}",
                        media.getId(), media.getFileIndex(), media.getFileName());
                return null;
            }
            tempSource = File.createTempFile("hk_media_src_", resolveTempSuffix(media));
            Files.copy(is, tempSource.toPath(), StandardCopyOption.REPLACE_EXISTING);
            is.close();
            is = null;
            if (!validateDownloadFile(tempSource, media)) {
                log.error("ISAPI下载内容无效 mediaId={} fileName={} size={}", media.getId(), media.getFileName(), tempSource.length());
                return null;
            }
            File uploadFile = tempSource;
            String ext = resolveUploadExt(media, tempSource);
            if (shouldTranscodeMp4(media, tempSource)) {
                tempTarget = File.createTempFile("hk_media_out_", ".mp4");
                log.info("开始 MP4 è§†é¢‘转码 mediaId={} fileName={} size={}",
                        media.getId(), media.getFileName(), tempSource.length());
                if (VideoTranscodeUtil.transcodeToBrowserMp4(getFfmpegPath(), tempSource, tempTarget)) {
                    uploadFile = tempTarget;
                    ext = ".mp4";
                } else {
                    log.warn("MP4 è§†é¢‘转码未成功,上传原始文件 mediaId={} fileName={} size={}",
                            media.getId(), media.getFileName(), tempSource.length());
                    uploadFile = tempSource;
                    ext = resolveUploadExt(media, tempSource);
                }
            }
            if (ftp == null) {
                ftp = new FtpUtil(
                        systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(),
                        Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()),
                        systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(),
                        systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode());
            } else {
                ftp.connect();
            }
            String folder = getMediaFolder();
            String date = DateUtil.getNowShortDate();
            String fName = date + "/" + UUID.randomUUID() + ext;
            String fileName = folder + fName;
            try (InputStream uploadStream = new FileInputStream(uploadFile)) {
                boolean uploaded = ftp.uploadInputstream(uploadStream, fileName);
                if (uploaded) {
                    log.info("采集站媒体上传FTP成功 stationId={} file={} size={}", station.getId(), fName, uploadFile.length());
                    return fName;
                }
            }
        } catch (Exception e) {
            log.error("采集站媒体下载上传失败 mediaId={} fileName={}: {}", media.getId(), media.getFileName(), e.getMessage(), e);
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (Exception ignored) {
                }
            }
            deleteQuietly(tempSource);
            deleteQuietly(tempTarget);
        }
        return null;
    }
    private String resolveTempSuffix(CollectionMedia media) {
        if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) {
            return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase();
        }
        return resolveExt(media);
    }
    /** ä»…扩展名为 .mp4 çš„视频走 FFmpeg è½¬ç ï¼ˆæµ·åº·å¸¸è§ .mp4 å†…为 MPEG-PS,仍在此处理) */
    private boolean shouldTranscodeMp4(CollectionMedia media, File sourceFile) {
        return isVideoMedia(media) && isMp4FileName(media.getFileName());
    }
    private static boolean isMp4FileName(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            return false;
        }
        String lower = fileName.toLowerCase();
        return lower.endsWith(".mp4");
    }
    private String resolveUploadExt(CollectionMedia media, File sourceFile) {
        if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) {
            return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase();
        }
        return resolveExt(media);
    }
    private void deleteQuietly(File file) {
        if (file != null && file.exists() && !file.delete()) {
            log.warn("临时文件删除失败: {}", file.getAbsolutePath());
        }
    }
    private String getFfmpegPath() {
        try {
            return systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_FFMPEG_PATH).getCode();
        } catch (Exception e) {
            return "ffmpeg";
        }
    }
    private boolean validateDownloadFile(File file, CollectionMedia media) throws IOException {
        if (file.length() <= 0) {
            log.warn("ISAPI下载内容为空 mediaId={}", media.getId());
            return false;
        }
        byte[] head = new byte[4096];
        int n;
        try (InputStream in = new FileInputStream(file)) {
            n = in.read(head);
        }
        if (n <= 0) {
            log.warn("ISAPI下载内容为空 mediaId={}", media.getId());
            return false;
        }
        if (isErrorPayload(head, n)) {
            log.error("ISAPI下载返回错误 mediaId={} snippet={}", media.getId(),
                    new String(head, 0, Math.min(n, 200), StandardCharsets.UTF_8));
            return false;
        }
        if (isVideoMedia(media) && isMpegPs(head, n)) {
            log.info("检测到MPEG-PS视频流 mediaId={}(非 MP4,不转码)", media.getId());
        }
        if (isVideoMedia(media) && !isLikelyPlayableVideo(head, n) && !isMp4FileName(media.getFileName())) {
            log.info("下载内容非 MP4 è§†é¢‘ mediaId={} fileName={}(不转码)", media.getId(), media.getFileName());
        }
        return true;
    }
    private String getMediaFolder() {
        try {
            return systemDictDataBiz.queryByCode(Constants.FTP, Constants.COLLECTION_MEDIA_FOLDER).getCode();
        } catch (Exception e) {
            return "/collection_media/";
        }
    }
    private String resolveExt(CollectionMedia media) {
        if (StringUtils.isNotBlank(media.getFileName())) {
            String lower = media.getFileName().toLowerCase();
            if (lower.endsWith(".txt") || lower.endsWith(".log")) {
                return lower.substring(lower.lastIndexOf('.'));
            }
            if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png")) {
                return lower.substring(lower.lastIndexOf('.'));
            }
            if (lower.endsWith(".mp3") || lower.endsWith(".wav")) {
                return lower.substring(lower.lastIndexOf('.'));
            }
            if (lower.contains(".")) {
                return lower.substring(lower.lastIndexOf('.'));
            }
        }
        if (media.getMediaType() != null && media.getMediaType() == 1) {
            return ".jpg";
        }
        if (media.getMediaType() != null && media.getMediaType() == 2) {
            return ".mp3";
        }
        return ".dat";
    }
    private String getTrackId() {
        try {
            String val = systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_SEARCH_TRACK_ID).getCode();
            if (StringUtils.isBlank(val) || "auto".equalsIgnoreCase(val.trim()) || "*".equals(val.trim())) {
                return null;
            }
            return val.trim();
        } catch (Exception e) {
            return null;
        }
    }
    private int getBatchSize() {
        try {
            return Integer.parseInt(systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_DOWNLOAD_BATCH_SIZE).getCode());
        } catch (Exception e) {
            return 10;
        }
    }
    private static boolean isVideoMedia(CollectionMedia media) {
        if (media.getMediaType() != null) {
            return media.getMediaType() == 0;
        }
        String name = StringUtils.defaultString(media.getFileName()).toLowerCase();
        return name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".avi")
                || name.endsWith(".mkv") || name.endsWith(".m4v");
    }
    private static boolean isErrorPayload(byte[] head, int n) {
        String snippet = new String(head, 0, Math.min(n, 64), StandardCharsets.UTF_8).trim();
        return snippet.startsWith("<?xml") || snippet.startsWith("{")
                || (snippet.startsWith("<") && snippet.contains("ResponseStatus"));
    }
    private static boolean isMpegPs(byte[] head, int n) {
        return n >= 4 && head[0] == 0 && head[1] == 0 && head[2] == 1 && (head[3] & 0xFF) == 0xBA;
    }
    private static boolean isLikelyPlayableVideo(byte[] head, int n) {
        if (n >= 8 && head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p') {
            return true;
        }
        return n >= 12 && head[0] == 'R' && head[1] == 'I' && head[2] == 'F' && head[3] == 'F'
                && head[8] == 'A' && head[9] == 'V' && head[10] == 'I';
    }
    private static RangeSpec parseRangeHeader(String rangeHeader, long fileSize) {
        if (StringUtils.isBlank(rangeHeader) || fileSize <= 0 || !rangeHeader.startsWith("bytes=")) {
            return null;
        }
        String spec = rangeHeader.substring("bytes=".length()).trim();
        int dash = spec.indexOf('-');
        if (dash < 0) {
            return null;
        }
        try {
            long start = Long.parseLong(spec.substring(0, dash));
            String endPart = spec.substring(dash + 1).trim();
            long end = endPart.isEmpty() ? fileSize - 1 : Long.parseLong(endPart);
            if (start < 0 || end < start || start >= fileSize) {
                return null;
            }
            if (end >= fileSize) {
                end = fileSize - 1;
            }
            return new RangeSpec(start, end);
        } catch (NumberFormatException e) {
            return null;
        }
    }
    private static final class RangeSpec {
        private final long start;
        private final long end;
        private RangeSpec(long start, long end) {
            this.start = start;
            this.end = end;
        }
        private long length() {
            return end - start + 1;
        }
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionStationServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,633 @@
package com.doumee.service.business.impl.collection;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.haikang.isapi.IsapiClient;
import com.doumee.core.haikang.isapi.IsapiConstants;
import com.doumee.core.haikang.isapi.model.DeviceInfoDTO;
import com.doumee.core.haikang.isapi.model.DockDeviceDTO;
import com.doumee.core.haikang.isapi.model.DockStationBasicInfoDTO;
import com.doumee.core.haikang.isapi.model.StorageInfoDTO;
import com.doumee.core.utils.Constants;
import com.doumee.core.utils.IsapiHttpUtil;
import com.doumee.dao.business.CollectionDockDeviceMapper;
import com.doumee.dao.business.CollectionStationMapper;
import com.doumee.dao.business.model.CollectionDockDevice;
import com.doumee.dao.business.model.CollectionStation;
import com.doumee.service.business.CollectionStationService;
import com.doumee.service.business.third.model.LoginUserInfo;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CollectionStationServiceImpl implements CollectionStationService {
    @Autowired
    private CollectionStationMapper collectionStationMapper;
    @Autowired
    private CollectionDockDeviceMapper collectionDockDeviceMapper;
    private final IsapiClient isapiClient = new IsapiClient();
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer create(CollectionStation station) {
        validateStation(station);
        Date now = new Date();
        station.setCreateDate(now);
        station.setEditDate(now);
        station.setIsdeleted(Constants.ZERO);
        if (station.getStatus() == null) {
            station.setStatus(Constants.ONE);
        }
        if (station.getPort() == null) {
            station.setPort(defaultPort(station));
        }
        if (station.getUseHttps() == null) {
            station.setUseHttps(Constants.ZERO);
        }
        if (StringUtils.isBlank(station.getModel())) {
            station.setModel("UD39625B");
        }
        if (station.getLoginUserInfo() != null) {
            station.setCreator(station.getLoginUserInfo().getUsername());
            station.setEdirot(station.getLoginUserInfo().getUsername());
        }
        collectionStationMapper.insert(station);
        return station.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateById(CollectionStation station) {
        if (station.getId() == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST);
        }
        station.setEditDate(new Date());
        if (station.getLoginUserInfo() != null) {
            station.setEdirot(station.getLoginUserInfo().getUsername());
        }
        collectionStationMapper.updateById(station);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteById(Integer id, LoginUserInfo user) {
        CollectionStation station = new CollectionStation();
        station.setId(id);
        station.setIsdeleted(Constants.ONE);
        station.setEditDate(new Date());
        if (user != null) {
            station.setEdirot(user.getUsername());
        }
        collectionStationMapper.updateById(station);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteByIdInBatch(List<Integer> ids, LoginUserInfo user) {
        if (ids == null) {
            return;
        }
        for (Integer id : ids) {
            deleteById(id, user);
        }
    }
    @Override
    public PageData<CollectionStation> findPage(PageWrap<CollectionStation> pageWrap) {
        CollectionStation model = pageWrap.getModel() != null ? pageWrap.getModel() : new CollectionStation();
        QueryWrapper<CollectionStation> wrapper = buildQueryWrapper(model);
        IPage<CollectionStation> page = collectionStationMapper.selectPage(
                new Page<>(pageWrap.getPage(), pageWrap.getCapacity()), wrapper);
        return PageData.from(page);
    }
    @Override
    public List<CollectionStation> findList(CollectionStation query) {
        return collectionStationMapper.selectList(buildQueryWrapper(query != null ? query : new CollectionStation()));
    }
    @Override
    public CollectionStation findById(Integer id) {
        CollectionStation station = collectionStationMapper.selectById(id);
        if (station == null || Constants.equalsInteger(station.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        return station;
    }
    @Override
    public String syncAllStations() {
        if (Constants.DEALING_HK_COLLECTION_STATION) {
            throw new BusinessException(ResponseStatus.NOT_ALLOWED.getCode(), "采集站同步任务正在执行,请稍后");
        }
        Constants.DEALING_HK_COLLECTION_STATION = true;
        try {
            List<CollectionStation> list = collectionStationMapper.selectList(new QueryWrapper<CollectionStation>().lambda()
                    .eq(CollectionStation::getIsdeleted, Constants.ZERO)
                    .eq(CollectionStation::getStatus, Constants.ONE));
            int success = 0;
            int fail = 0;
            for (CollectionStation station : list) {
                try {
                    syncStationStatusInternal(station);
                    success++;
                } catch (Exception e) {
                    fail++;
                    log.error("采集站同步失败 id={}: {}", station.getId(), e.getMessage());
                }
            }
            return "同步完成:成功【" + success + "】台,失败【" + fail + "】台";
        } finally {
            Constants.DEALING_HK_COLLECTION_STATION = false;
        }
    }
    @Override
    public String syncStationStatus(Integer stationId) {
        CollectionStation station = findById(stationId);
        syncStationStatusInternal(station);
        return "同步成功";
    }
    @Override
    public String probeIsapi(Integer stationId) {
        CollectionStation station = findById(stationId);
        boolean https = station.getUseHttps() != null && station.getUseHttps() == 1;
        String deviceInfo = IsapiHttpUtil.doGet(station.getIp(), station.getPort(), https,
                station.getUsername(), station.getPassword(), IsapiConstants.DEVICE_INFO);
        String storage = IsapiHttpUtil.doGet(station.getIp(), station.getPort(), https,
                station.getUsername(), station.getPassword(), IsapiConstants.STORAGE);
        String tracks = IsapiHttpUtil.doGet(station.getIp(), station.getPort(), https,
                station.getUsername(), station.getPassword(), IsapiConstants.RECORD_TRACKS);
        String dockBasic = IsapiHttpUtil.doGet(station.getIp(), station.getPort(), https,
                station.getUsername(), station.getPassword(), IsapiConstants.DOCK_BASIC_INFO);
        String dockDevices = IsapiHttpUtil.doGet(station.getIp(), station.getPort(), https,
                station.getUsername(), station.getPassword(), IsapiConstants.DOCK_DEVICE_MANAGEMENT);
        return "deviceInfo:\n" + StringUtils.defaultString(deviceInfo)
                + "\n\nstorage:\n" + StringUtils.defaultString(storage)
                + "\n\nrecord/tracks:\n" + StringUtils.defaultString(tracks)
                + "\n\ndockStation/basicInfo:\n" + StringUtils.defaultString(dockBasic)
                + "\n\ndockStation/deviceManagement:\n" + StringUtils.defaultString(dockDevices);
    }
    @Override
    public List<CollectionDockDevice> findDockDevices(Integer stationId) {
        findById(stationId);
        return collectionDockDeviceMapper.selectList(new QueryWrapper<CollectionDockDevice>().lambda()
                .eq(CollectionDockDevice::getStationId, stationId)
                .eq(CollectionDockDevice::getIsdeleted, Constants.ZERO)
                .orderByAsc(CollectionDockDevice::getId));
    }
    @Override
    public PageData<CollectionDockDevice> findDockDevicePage(PageWrap<CollectionDockDevice> pageWrap) {
        CollectionDockDevice model = pageWrap.getModel() != null ? pageWrap.getModel() : new CollectionDockDevice();
        QueryWrapper<CollectionDockDevice> wrapper = new QueryWrapper<>();
        wrapper.lambda()
                .eq(CollectionDockDevice::getIsdeleted, Constants.ZERO)
                .eq(model.getStationId() != null, CollectionDockDevice::getStationId, model.getStationId())
                .like(StringUtils.isNotBlank(model.getDeviceName()), CollectionDockDevice::getDeviceName, model.getDeviceName())
                .like(StringUtils.isNotBlank(model.getShortSerialNumber()), CollectionDockDevice::getShortSerialNumber, model.getShortSerialNumber())
                .orderByDesc(CollectionDockDevice::getId);
        IPage<CollectionDockDevice> page = collectionDockDeviceMapper.selectPage(
                new Page<>(pageWrap.getPage(), pageWrap.getCapacity()), wrapper);
        fillStationName(page.getRecords());
        return PageData.from(page);
    }
    private void fillStationName(List<CollectionDockDevice> records) {
        if (records == null || records.isEmpty()) {
            return;
        }
        List<Integer> stationIds = records.stream()
                .map(CollectionDockDevice::getStationId)
                .filter(id -> id != null)
                .distinct()
                .collect(Collectors.toList());
        if (stationIds.isEmpty()) {
            return;
        }
        List<CollectionStation> stations = collectionStationMapper.selectBatchIds(stationIds);
        Map<Integer, String> nameMap = stations.stream()
                .collect(Collectors.toMap(CollectionStation::getId, CollectionStation::getName, (a, b) -> a));
        records.forEach(row -> row.setStationName(nameMap.get(row.getStationId())));
    }
    private void syncStationStatusInternal(CollectionStation station) {
        Date now = new Date();
        CollectionStation update = new CollectionStation();
        update.setId(station.getId());
        update.setLastSyncTime(now);
        update.setEditDate(now);
        boolean online = isapiClient.isOnline(station);
        update.setOnline(online ? Constants.ONE : Constants.ZERO);
        if (online) {
            DeviceInfoDTO deviceInfo = isapiClient.getDeviceInfo(station);
            if (deviceInfo != null) {
                if (StringUtils.isNotBlank(deviceInfo.getSerialNumber())) {
                    update.setSerialNo(deviceInfo.getSerialNumber());
                }
                if (StringUtils.isNotBlank(deviceInfo.getFirmwareVersion())) {
                    update.setSoftwareVersion(deviceInfo.getFirmwareVersion());
                }
                if (StringUtils.isBlank(station.getName()) && StringUtils.isNotBlank(deviceInfo.getDeviceName())) {
                    update.setName(deviceInfo.getDeviceName());
                }
                if (StringUtils.isNotBlank(deviceInfo.getModel())) {
                    update.setModel(deviceInfo.getModel());
                }
            }
            DockStationBasicInfoDTO basicInfo = isapiClient.getDockBasicInfo(station);
            if (basicInfo != null && StringUtils.isNotBlank(basicInfo.getDockStationId())
                    && StringUtils.isBlank(station.getSerialNo())) {
                update.setSerialNo(basicInfo.getDockStationId());
            }
            StorageInfoDTO storage = isapiClient.getStorageInfo(station);
            if (storage != null) {
                update.setTotalSpace(storage.getTotalSpaceGb());
                update.setFreeSpace(storage.getFreeSpaceGb());
            }
            syncDockDevices(station.getId(), now);
        }
        collectionStationMapper.updateById(update);
    }
    private void syncDockDevices(Integer stationId, Date now) {
        CollectionStation station = collectionStationMapper.selectById(stationId);
        if (station == null) {
            return;
        }
        List<DockDeviceDTO> devices = isapiClient.getDockDevices(station);
        for (DockDeviceDTO src : devices) {
            if (StringUtils.isBlank(src.getDeviceId())) {
                continue;
            }
            CollectionDockDevice existed = collectionDockDeviceMapper.selectOne(new QueryWrapper<CollectionDockDevice>().lambda()
                    .eq(CollectionDockDevice::getStationId, stationId)
                    .eq(CollectionDockDevice::getDeviceId, src.getDeviceId())
                    .eq(CollectionDockDevice::getIsdeleted, Constants.ZERO));
            if (existed == null) {
                CollectionDockDevice row = new CollectionDockDevice();
                row.setStationId(stationId);
                row.setDeviceId(src.getDeviceId());
                row.setDeviceName(src.getDeviceName());
                row.setShortSerialNumber(src.getShortSerialNumber());
                row.setAccessDeviceId(src.getAccessDeviceId());
                row.setNetworkedDevice(Boolean.TRUE.equals(src.getNetworkedDevice()) ? Constants.ONE : Constants.ZERO);
                row.setLastSyncTime(now);
                row.setCreateDate(now);
                row.setIsdeleted(Constants.ZERO);
                collectionDockDeviceMapper.insert(row);
            } else {
                CollectionDockDevice row = new CollectionDockDevice();
                row.setId(existed.getId());
                row.setDeviceName(src.getDeviceName());
                row.setShortSerialNumber(src.getShortSerialNumber());
                row.setAccessDeviceId(src.getAccessDeviceId());
                row.setNetworkedDevice(Boolean.TRUE.equals(src.getNetworkedDevice()) ? Constants.ONE : Constants.ZERO);
                row.setLastSyncTime(now);
                collectionDockDeviceMapper.updateById(row);
            }
        }
    }
    private QueryWrapper<CollectionStation> buildQueryWrapper(CollectionStation model) {
        QueryWrapper<CollectionStation> wrapper = new QueryWrapper<>();
        wrapper.lambda()
                .eq(CollectionStation::getIsdeleted, Constants.ZERO)
                .like(StringUtils.isNotBlank(model.getName()), CollectionStation::getName, model.getName())
                .like(StringUtils.isNotBlank(model.getIp()), CollectionStation::getIp, model.getIp())
                .eq(model.getOnline() != null, CollectionStation::getOnline, model.getOnline())
                .eq(model.getStatus() != null, CollectionStation::getStatus, model.getStatus())
                .orderByDesc(CollectionStation::getId);
        return wrapper;
    }
    private void validateStation(CollectionStation station) {
        if (StringUtils.isBlank(station.getIp()) || StringUtils.isBlank(station.getUsername())
                || StringUtils.isBlank(station.getPassword())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "IP、用户名、密码不能为空");
        }
    }
    private int defaultPort(CollectionStation station) {
        if (station.getUseHttps() != null && station.getUseHttps() == 1) {
            return 443;
        }
        return 80;
    }
}
server/visits/dmvisit_service/src/main/resources/application-dev.yml
@@ -21,8 +21,8 @@
########################同步数据模式  ########################
data-sync:
  org-user-data-origin: 3 #组织数据 0自建 2以海康为主 1华晟ERP系统 3简道云 4钉钉
  visitor-data-origin: 2 #访客数据 0自建 2以海康为主 1华晟ERP系统 2简道云
  org-user-data-origin: 0 #组织数据 0自建 2以海康为主 1华晟ERP系统
  visitor-data-origin: 0 #访客数据 0自建 2以海康为主 1华晟ERP系统
  need-deal-img: true #是否需要处理图片数据