video_platform_service.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. from __future__ import annotations
  2. import mimetypes
  3. from collections.abc import Iterator
  4. from datetime import datetime, time, timedelta, timezone
  5. from pathlib import Path
  6. from sqlalchemy import delete, update
  7. from sqlalchemy.orm import Session
  8. from app.core.config import settings
  9. from app.core.errors import APIError
  10. from app.db.models.video import (
  11. VIDEO_MODULE_DOOR,
  12. VIDEO_MODULE_LOCAL_FAN,
  13. VIDEO_MODULE_MAIN_FAN,
  14. VideoAnalysisEvent,
  15. VideoChannel,
  16. VideoRecord,
  17. VideoSnapshot,
  18. )
  19. from app.db.repositories import video_platform as video_repo
  20. from app.schemas.video import (
  21. VideoChannelCreate,
  22. VideoChannelUpdate,
  23. VideoEventUpdate,
  24. )
  25. OPEN_EVENT_STATUSES = ("pending", "processing")
  26. # 前端页面每个模块的固定文案与记录页签定义(列名与页面表格一致)。
  27. # type_filter_options / scope_filter_options / snapshot_types 由数据库数据动态生成。
  28. MODULE_DEFINITIONS: dict[str, dict] = {
  29. VIDEO_MODULE_DOOR: {
  30. "title": "风门视频图像综合解析系统",
  31. "status_title": "风门状态总览",
  32. "selector_label": "风门选择",
  33. "type_filter_label": "告警类型",
  34. "record_tabs": [
  35. ("vehicle_passage", "车辆通行记录", ["序号", "风门名称", "车牌号", "通行时间", "结果"]),
  36. ("person_passage", "人员通行记录", ["序号", "风门名称", "人员信息", "通行时间", "结果"]),
  37. ("door_unclosed", "风门未关严记录", ["序号", "风门名称", "检测时间", "缝隙(mm)", "等级"]),
  38. ],
  39. },
  40. VIDEO_MODULE_LOCAL_FAN: {
  41. "title": "局部风机无人值守演示系统",
  42. "status_title": "风机状态总览",
  43. "selector_label": "风机选择",
  44. "type_filter_label": "操作类型",
  45. "record_tabs": [
  46. ("person_track", "人员轨迹", ["序号", "风机", "区域", "姓名", "区队", "进入", "离开", "时长"]),
  47. ("area_stay", "区域统计", ["序号", "区域", "人数", "最长停留", "最短停留", "平均停留", "累计停留"]),
  48. ("person_warn", "预警记录", ["序号", "风机", "区域", "姓名", "预警类型", "预警时间", "处理状态"]),
  49. ],
  50. },
  51. VIDEO_MODULE_MAIN_FAN: {
  52. "title": "主通风机无人巡检演示系统",
  53. "status_title": "主风机运行状态",
  54. "selector_label": "设备选择",
  55. "type_filter_label": "操作类型",
  56. "record_tabs": [
  57. ("person_detect", "人员检测记录", ["序号", "设备", "位置", "检测时间", "检测结果", "状态"]),
  58. ("device_operation", "设备操作日志", ["序号", "设备", "操作类型", "操作时间", "人员比对", "状态"]),
  59. ("device_warn", "设备预警记录", ["序号", "设备", "位置", "预警类型", "预警时间", "处理状态"]),
  60. ],
  61. },
  62. }
  63. RECORD_TYPE_LABELS: dict[str, str] = {
  64. record_type: label
  65. for definition in MODULE_DEFINITIONS.values()
  66. for record_type, label, _ in definition["record_tabs"]
  67. }
  68. MODULE_TITLES: dict[str, str] = {
  69. key: str(value["title"]) for key, value in MODULE_DEFINITIONS.items()
  70. }
  71. # ---------------------------------------------------------------------------
  72. # 通用工具
  73. # ---------------------------------------------------------------------------
  74. def _now() -> datetime:
  75. return datetime.now(timezone.utc)
  76. def utc_day_bounds(day: datetime | None = None) -> tuple[datetime, datetime]:
  77. """返回本地自然日对应的 UTC 起止时间,用于“今日车辆/今日人员”等统计。"""
  78. local_now = (day or _now()).astimezone()
  79. local_start = datetime.combine(local_now.date(), time.min, tzinfo=local_now.tzinfo)
  80. local_end = local_start + timedelta(days=1)
  81. return local_start.astimezone(timezone.utc), local_end.astimezone(timezone.utc)
  82. def _file_meta(path: str) -> tuple[bool, int]:
  83. if not path:
  84. return False, 0
  85. try:
  86. file_path = Path(path)
  87. if file_path.is_file():
  88. return True, file_path.stat().st_size
  89. except OSError:
  90. return False, 0
  91. return False, 0
  92. def _api_path(path: str) -> str:
  93. return f"{settings.api_v1_prefix}{path}"
  94. def parse_range_header(range_header: str | None, file_size: int) -> tuple[int, int]:
  95. """解析 HTTP Range 请求头,返回闭区间 ``(start, end)``。
  96. 仅支持单段 ``bytes=start-end`` 形式;非法或越界时返回完整区间。
  97. """
  98. if not range_header or not range_header.startswith("bytes=") or file_size <= 0:
  99. return 0, max(file_size - 1, 0)
  100. spec = range_header.split("=", 1)[1].split(",", 1)[0].strip()
  101. if "-" not in spec:
  102. return 0, max(file_size - 1, 0)
  103. start_text, _, end_text = spec.partition("-")
  104. try:
  105. if start_text:
  106. start = int(start_text)
  107. end = int(end_text) if end_text else file_size - 1
  108. else: # bytes=-N 表示最后 N 字节
  109. length = int(end_text or "0")
  110. if length <= 0:
  111. return 0, max(file_size - 1, 0)
  112. start = max(file_size - length, 0)
  113. end = file_size - 1
  114. except ValueError:
  115. return 0, max(file_size - 1, 0)
  116. start = max(start, 0)
  117. end = min(end, file_size - 1)
  118. if start > end:
  119. return 0, max(file_size - 1, 0)
  120. return start, end
  121. def iter_file_range(path: Path, start: int, end: int) -> Iterator[bytes]:
  122. """按块读取磁盘文件的闭区间内容,供视频流式响应使用。"""
  123. chunk_size = max(int(settings.video_stream_chunk_size), 64 * 1024)
  124. remaining = end - start + 1
  125. with path.open("rb") as handle:
  126. handle.seek(start)
  127. while remaining > 0:
  128. chunk = handle.read(min(chunk_size, remaining))
  129. if not chunk:
  130. break
  131. remaining -= len(chunk)
  132. yield chunk
  133. def guess_content_type(path: str) -> str:
  134. content_type, _ = mimetypes.guess_type(path)
  135. return content_type or "application/octet-stream"
  136. # ---------------------------------------------------------------------------
  137. # 序列化
  138. # ---------------------------------------------------------------------------
  139. def _channel_payload(channel: VideoChannel) -> dict:
  140. exists, size = _file_meta(channel.file_path or channel.stream_url)
  141. payload = {
  142. column.name: getattr(channel, column.name)
  143. for column in VideoChannel.__table__.columns
  144. }
  145. payload.update(
  146. {
  147. "file_size": channel.file_size or size,
  148. "has_video": bool(channel.stream_url or channel.file_path),
  149. "media_available": exists,
  150. "playback_url": _api_path(f"/video-platform/channels/{channel.id}/stream"),
  151. "snapshot_url": _api_path(f"/video-platform/channels/{channel.id}/snapshot"),
  152. }
  153. )
  154. return payload
  155. def _snapshot_payload(
  156. snapshot: VideoSnapshot,
  157. *,
  158. channel_name: str = "",
  159. ) -> dict:
  160. exists, size = _file_meta(snapshot.file_path)
  161. payload = {
  162. column.name: getattr(snapshot, column.name)
  163. for column in VideoSnapshot.__table__.columns
  164. }
  165. payload.update(
  166. {
  167. "file_size": snapshot.file_size or size,
  168. "channel_name": channel_name,
  169. "media_available": exists,
  170. "image_url": _api_path(f"/video-platform/snapshots/{snapshot.id}/image"),
  171. "thumbnail_url": _api_path(
  172. f"/video-platform/snapshots/{snapshot.id}/thumbnail"
  173. ),
  174. }
  175. )
  176. return payload
  177. def _event_payload(event: VideoAnalysisEvent, *, channel_name: str = "") -> dict:
  178. payload = {
  179. column.name: getattr(event, column.name)
  180. for column in VideoAnalysisEvent.__table__.columns
  181. }
  182. payload.update(
  183. {
  184. "channel_name": channel_name,
  185. "snapshot_image_url": (
  186. _api_path(f"/video-platform/snapshots/{event.snapshot_id}/image")
  187. if event.snapshot_id
  188. else ""
  189. ),
  190. }
  191. )
  192. return payload
  193. def _record_payload(record: VideoRecord) -> dict:
  194. payload = {
  195. column.name: getattr(record, column.name)
  196. for column in VideoRecord.__table__.columns
  197. }
  198. payload["record_type_label"] = RECORD_TYPE_LABELS.get(record.record_type, "")
  199. payload["snapshot_image_url"] = (
  200. _api_path(f"/video-platform/snapshots/{record.snapshot_id}/image")
  201. if record.snapshot_id
  202. else ""
  203. )
  204. return payload
  205. def _channel_names(db: Session, channels: list[VideoChannel]) -> dict[str, str]:
  206. return {channel.id: channel.name for channel in channels}
  207. # ---------------------------------------------------------------------------
  208. # 页面配置 / 总览 / 状态指标
  209. # ---------------------------------------------------------------------------
  210. def module_configs(db: Session, mine_id: str) -> list[dict]:
  211. channels = video_repo.list_channels(db, mine_id)
  212. configs: list[dict] = []
  213. for module, definition in MODULE_DEFINITIONS.items():
  214. module_channels = [item for item in channels if item.module == module]
  215. event_types = video_repo.distinct_snapshot_event_types(db, mine_id, module)
  216. configs.append(
  217. {
  218. "key": module,
  219. "title": definition["title"],
  220. "status_title": definition["status_title"],
  221. "selector_label": definition["selector_label"],
  222. "type_filter_label": definition["type_filter_label"],
  223. "type_filter_options": ["全部类型", *event_types],
  224. "scope_filter_label": definition["selector_label"],
  225. "scope_filter_options": [
  226. "全部设备",
  227. *[item.name for item in module_channels],
  228. ],
  229. "snapshot_types": event_types,
  230. "record_tabs": [
  231. {
  232. "record_type": record_type,
  233. "label": label,
  234. "columns": columns,
  235. }
  236. for record_type, label, columns in definition["record_tabs"]
  237. ],
  238. }
  239. )
  240. return configs
  241. def platform_overview(db: Session, mine_id: str) -> dict:
  242. channel_total = video_repo.count_channels(db, mine_id)
  243. channel_online = video_repo.count_channels(db, mine_id, status="online")
  244. channel_offline = video_repo.count_channels(db, mine_id, status="offline")
  245. channel_fault = video_repo.count_channels(db, mine_id, status="fault")
  246. event_open_count = video_repo.count_events(
  247. db, mine_id, statuses=OPEN_EVENT_STATUSES
  248. )
  249. snapshot_count = video_repo.count_snapshots(db, mine_id)
  250. summary = [
  251. {
  252. "key": "channels",
  253. "label": "视频通道",
  254. "value": channel_total,
  255. "unit": "路",
  256. "hint": "视频平台接入总量",
  257. "tone": "default",
  258. },
  259. {
  260. "key": "configured",
  261. "label": "已配置通道",
  262. "value": channel_total,
  263. "unit": "路",
  264. "hint": "通道管理可维护通道",
  265. "tone": "online",
  266. },
  267. {
  268. "key": "online",
  269. "label": "在线通道",
  270. "value": channel_online,
  271. "unit": "路",
  272. "hint": "视频连接状态正常",
  273. "tone": "warning",
  274. },
  275. {
  276. "key": "events",
  277. "label": "识别事件",
  278. "value": event_open_count,
  279. "unit": "条",
  280. "hint": "未闭环智能分析事件",
  281. "tone": "ai",
  282. },
  283. ]
  284. return {
  285. "mine_id": mine_id,
  286. "channel_total": channel_total,
  287. "channel_configured": channel_total,
  288. "channel_online": channel_online,
  289. "channel_offline": channel_offline,
  290. "channel_fault": channel_fault,
  291. "event_open_count": event_open_count,
  292. "snapshot_count": snapshot_count,
  293. "modules": module_configs(db, mine_id),
  294. "summary": summary,
  295. }
  296. def module_summary(db: Session, mine_id: str, module: str) -> dict:
  297. _require_module(module)
  298. today_start, today_end = utc_day_bounds()
  299. open_events = video_repo.count_events(
  300. db, mine_id, module=module, statuses=OPEN_EVENT_STATUSES
  301. )
  302. if module == VIDEO_MODULE_DOOR:
  303. normal = video_repo.count_channels(db, mine_id, module=module, status="online")
  304. offline = video_repo.count_channels(db, mine_id, module=module, status="offline")
  305. fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
  306. car_today = video_repo.count_records(
  307. db,
  308. mine_id,
  309. module=module,
  310. record_type="vehicle_passage",
  311. start=today_start,
  312. end=today_end,
  313. )
  314. person_today = video_repo.sum_person_count(
  315. db,
  316. mine_id,
  317. module=module,
  318. record_type="person_passage",
  319. start=today_start,
  320. end=today_end,
  321. )
  322. metrics = [
  323. _metric("normal_door", "正常风门", normal, "扇", "normal"),
  324. _metric("offline_device", "离线设备", offline, "台", "offline"),
  325. _metric("fault_device", "设备故障", fault, "台", "fault"),
  326. _metric("open_warning", "实时预警", open_events, "条", "warning"),
  327. _metric("car_today", "今日车辆", car_today, "辆", "accent"),
  328. _metric("person_today", "今日人员", person_today, "人", "accent"),
  329. ]
  330. summary = [
  331. _card("normal", "正常风门", normal, "扇", tone="normal"),
  332. _card("offline", "离线设备", offline, "台", tone="offline"),
  333. _card("fault", "设备故障", fault, "台", tone="fault"),
  334. _card("warning", "实时预警", open_events, "条", tone="warning"),
  335. _card("car", "今日车辆", car_today, "辆", tone="accent"),
  336. _card("person", "今日人员", person_today, "人", tone="accent"),
  337. ]
  338. elif module == VIDEO_MODULE_LOCAL_FAN:
  339. normal = video_repo.count_channels(db, mine_id, module=module, status="online")
  340. offline = video_repo.count_channels(db, mine_id, module=module, status="offline")
  341. fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
  342. onsite_person = video_repo.count_records(
  343. db, mine_id, module=module, record_type="person_track", in_progress_only=True
  344. )
  345. enter_today = video_repo.count_records(
  346. db,
  347. mine_id,
  348. module=module,
  349. record_type="person_track",
  350. start=today_start,
  351. end=today_end,
  352. )
  353. metrics = [
  354. _metric("normal_fan", "风机正常", normal, "台", "normal"),
  355. _metric("offline_fan", "风机离线", offline, "台", "offline"),
  356. _metric("fault_fan", "风机故障", fault, "台", "fault"),
  357. _metric("open_warning", "实时预警", open_events, "条", "warning"),
  358. _metric("onsite_person", "实时人数", onsite_person, "人", "accent"),
  359. _metric("enter_today", "今日进入", enter_today, "人", "accent"),
  360. ]
  361. summary = [
  362. _card("normal", "风机正常", normal, "台", tone="normal"),
  363. _card("offline", "风机离线", offline, "台", tone="offline"),
  364. _card("fault", "风机故障", fault, "台", tone="fault"),
  365. _card("warning", "实时预警", open_events, "条", tone="warning"),
  366. _card("onsite", "实时人数", onsite_person, "人", tone="accent"),
  367. _card("enter", "今日进入", enter_today, "人", tone="accent"),
  368. ]
  369. else: # VIDEO_MODULE_MAIN_FAN
  370. channels = video_repo.list_channels(db, mine_id, module=module)
  371. running = sum(
  372. 1
  373. for item in channels
  374. if str(item.properties.get("run_state", "")) == "running"
  375. )
  376. stopped = sum(
  377. 1
  378. for item in channels
  379. if str(item.properties.get("run_state", "")) == "stopped"
  380. )
  381. fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
  382. detect_today = video_repo.count_records(
  383. db,
  384. mine_id,
  385. module=module,
  386. record_type="person_detect",
  387. start=today_start,
  388. end=today_end,
  389. )
  390. records_today = video_repo.count_records(
  391. db, mine_id, module=module, start=today_start, end=today_end
  392. )
  393. metrics = [
  394. _metric("running_fan", "运行风机", running, "台", "normal"),
  395. _metric("stopped_fan", "停止风机", stopped, "台", "offline"),
  396. _metric("fault_device", "设备故障", fault, "台", "fault"),
  397. _metric("open_warning", "预警总数", open_events, "条", "warning"),
  398. _metric("person_detect", "人员检测", detect_today, "次", "accent"),
  399. _metric("records_today", "今日记录", records_today, "条", "accent"),
  400. ]
  401. summary = [
  402. _card("running", "运行风机", running, "台", tone="normal"),
  403. _card("stopped", "停止风机", stopped, "台", tone="offline"),
  404. _card("fault", "设备故障", fault, "台", tone="fault"),
  405. _card("warning", "预警总数", open_events, "条", tone="warning"),
  406. _card("detect", "人员检测", detect_today, "次", tone="accent"),
  407. _card("records", "今日记录", records_today, "条", tone="accent"),
  408. ]
  409. return {
  410. "mine_id": mine_id,
  411. "module": module,
  412. "title": MODULE_TITLES[module],
  413. "status_title": MODULE_DEFINITIONS[module]["status_title"],
  414. "metrics": metrics,
  415. "summary": summary,
  416. }
  417. def _metric(key: str, label: str, value: int | float, unit: str, tone: str) -> dict:
  418. return {"key": key, "label": label, "value": value, "unit": unit, "tone": tone}
  419. def _card(key: str, label: str, value: int | float, unit: str, tone: str) -> dict:
  420. return {"key": key, "label": label, "value": value, "unit": unit, "tone": tone}
  421. def _require_module(module: str) -> str:
  422. if module not in MODULE_DEFINITIONS:
  423. raise APIError(400, "invalid_module", f"不支持的视频模块:{module}")
  424. return module
  425. # ---------------------------------------------------------------------------
  426. # 查询接口
  427. # ---------------------------------------------------------------------------
  428. def list_channels(
  429. db: Session,
  430. mine_id: str,
  431. *,
  432. module: str | None = None,
  433. status: str | None = None,
  434. keyword: str | None = None,
  435. limit: int = 200,
  436. ) -> list[dict]:
  437. channels = video_repo.list_channels(
  438. db, mine_id, module=module, status=status, keyword=keyword, limit=limit
  439. )
  440. return [_channel_payload(channel) for channel in channels]
  441. def get_channel(db: Session, channel_id: str) -> dict:
  442. channel = video_repo.get_channel(db, channel_id)
  443. if channel is None:
  444. raise APIError(404, "video_channel_not_found", "视频通道不存在")
  445. return _channel_payload(channel)
  446. def list_snapshots(
  447. db: Session,
  448. mine_id: str,
  449. *,
  450. module: str | None = None,
  451. snapshot_type: str | None = None,
  452. event_type: str | None = None,
  453. channel_id: str | None = None,
  454. start: datetime | None = None,
  455. end: datetime | None = None,
  456. keyword: str | None = None,
  457. page: int = 1,
  458. page_size: int = 24,
  459. ) -> tuple[list[dict], int]:
  460. items, total = video_repo.list_snapshots(
  461. db,
  462. mine_id,
  463. module=module,
  464. snapshot_type=snapshot_type,
  465. event_type=event_type,
  466. channel_id=channel_id,
  467. start=start,
  468. end=end,
  469. keyword=keyword,
  470. page=page,
  471. page_size=page_size,
  472. )
  473. names = _channel_names(db, video_repo.list_channels(db, mine_id))
  474. return (
  475. [_snapshot_payload(item, channel_name=names.get(item.channel_id, "")) for item in items],
  476. total,
  477. )
  478. def get_snapshot(db: Session, snapshot_id: str) -> dict:
  479. snapshot = video_repo.get_snapshot(db, snapshot_id)
  480. if snapshot is None:
  481. raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
  482. channel = video_repo.get_channel(db, snapshot.channel_id)
  483. return _snapshot_payload(
  484. snapshot, channel_name=channel.name if channel else ""
  485. )
  486. def list_events(
  487. db: Session,
  488. mine_id: str,
  489. *,
  490. module: str | None = None,
  491. level: str | None = None,
  492. status: str | None = None,
  493. event_type: str | None = None,
  494. start: datetime | None = None,
  495. end: datetime | None = None,
  496. limit: int = 100,
  497. ) -> list[dict]:
  498. events = video_repo.list_events(
  499. db,
  500. mine_id,
  501. module=module,
  502. level=level,
  503. status=status,
  504. event_type=event_type,
  505. start=start,
  506. end=end,
  507. limit=limit,
  508. )
  509. names = _channel_names(db, video_repo.list_channels(db, mine_id))
  510. return [
  511. _event_payload(event, channel_name=names.get(event.channel_id or "", ""))
  512. for event in events
  513. ]
  514. def update_event(db: Session, event_id: str, payload: VideoEventUpdate) -> dict:
  515. event = video_repo.get_event(db, event_id)
  516. if event is None:
  517. raise APIError(404, "video_event_not_found", "视频分析事件不存在")
  518. data = payload.model_dump(exclude_unset=True, exclude_none=True)
  519. if "status" in data:
  520. event.status = str(data["status"])
  521. if event.status in {"handled", "ignored"}:
  522. event.handled_at = _now()
  523. else:
  524. event.handled_at = None
  525. if "handled_by" in data:
  526. event.handled_by = str(data["handled_by"])
  527. if "handle_remark" in data:
  528. event.handle_remark = str(data["handle_remark"])
  529. db.add(event)
  530. db.commit()
  531. db.refresh(event)
  532. channel = video_repo.get_channel(db, event.channel_id or "")
  533. return _event_payload(event, channel_name=channel.name if channel else "")
  534. def list_records(
  535. db: Session,
  536. mine_id: str,
  537. *,
  538. module: str | None = None,
  539. record_type: str | None = None,
  540. channel_id: str | None = None,
  541. start: datetime | None = None,
  542. end: datetime | None = None,
  543. keyword: str | None = None,
  544. page: int = 1,
  545. page_size: int = 50,
  546. ) -> tuple[list[dict], int]:
  547. items, total = video_repo.list_records(
  548. db,
  549. mine_id,
  550. module=module,
  551. record_type=record_type,
  552. channel_id=channel_id,
  553. start=start,
  554. end=end,
  555. keyword=keyword,
  556. page=page,
  557. page_size=page_size,
  558. )
  559. return [_record_payload(item) for item in items], total
  560. # ---------------------------------------------------------------------------
  561. # 通道管理
  562. # ---------------------------------------------------------------------------
  563. def create_channel(db: Session, payload: VideoChannelCreate) -> dict:
  564. _require_module(payload.module)
  565. if video_repo.get_channel_by_code(db, payload.code) is not None:
  566. raise APIError(409, "video_channel_code_exists", "视频通道编码已存在")
  567. channel = VideoChannel(**payload.model_dump())
  568. channel.file_name = Path(payload.file_path).name if payload.file_path else ""
  569. exists, size = _file_meta(payload.file_path)
  570. channel.file_size = size if exists else 0
  571. channel.last_online_at = _now() if channel.status == "online" else None
  572. db.add(channel)
  573. db.commit()
  574. db.refresh(channel)
  575. return _channel_payload(channel)
  576. def update_channel(
  577. db: Session,
  578. channel_id: str,
  579. payload: VideoChannelUpdate,
  580. ) -> dict:
  581. channel = video_repo.get_channel(db, channel_id)
  582. if channel is None:
  583. raise APIError(404, "video_channel_not_found", "视频通道不存在")
  584. data = payload.model_dump(exclude_unset=True)
  585. if "module" in data and data["module"] is not None:
  586. _require_module(str(data["module"]))
  587. for key, value in data.items():
  588. if value is not None:
  589. setattr(channel, key, value)
  590. if "file_path" in data and data["file_path"] is not None:
  591. channel.file_name = Path(str(data["file_path"])).name if data["file_path"] else ""
  592. exists, size = _file_meta(str(data["file_path"]))
  593. channel.file_size = size if exists else 0
  594. if "status" in data and data["status"] == "online":
  595. channel.last_online_at = _now()
  596. db.add(channel)
  597. db.commit()
  598. db.refresh(channel)
  599. return _channel_payload(channel)
  600. def delete_channel(db: Session, channel_id: str) -> None:
  601. channel = video_repo.get_channel(db, channel_id)
  602. if channel is None:
  603. raise APIError(404, "video_channel_not_found", "视频通道不存在")
  604. # 先断开关联引用,再删除通道及其抓拍/记录,避免事件出现悬空外键。
  605. db.execute(
  606. update(VideoAnalysisEvent)
  607. .where(VideoAnalysisEvent.channel_id == channel_id)
  608. .values(channel_id=None)
  609. )
  610. db.execute(
  611. update(VideoAnalysisEvent)
  612. .where(
  613. VideoAnalysisEvent.snapshot_id.in_(
  614. db.query(VideoSnapshot.id).filter(VideoSnapshot.channel_id == channel_id)
  615. )
  616. )
  617. .values(snapshot_id=None)
  618. )
  619. db.execute(delete(VideoRecord).where(VideoRecord.channel_id == channel_id))
  620. db.execute(delete(VideoSnapshot).where(VideoSnapshot.channel_id == channel_id))
  621. db.delete(channel)
  622. db.commit()
  623. # ---------------------------------------------------------------------------
  624. # 媒体文件
  625. # ---------------------------------------------------------------------------
  626. def resolve_channel_media(channel_id: str, db: Session) -> tuple[Path, str]:
  627. channel = video_repo.get_channel(db, channel_id)
  628. if channel is None:
  629. raise APIError(404, "video_channel_not_found", "视频通道不存在")
  630. path_text = channel.file_path or channel.stream_url
  631. if not path_text:
  632. raise APIError(404, "video_media_missing", "该通道未配置可播放的视频文件")
  633. path = Path(path_text)
  634. if not path.is_file():
  635. raise APIError(
  636. 404,
  637. "video_media_missing",
  638. f"视频文件不存在:{path_text}",
  639. )
  640. return path, guess_content_type(path.name)
  641. def resolve_snapshot_media(snapshot_id: str, db: Session) -> tuple[Path, str]:
  642. snapshot = video_repo.get_snapshot(db, snapshot_id)
  643. if snapshot is None:
  644. raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
  645. path_text = snapshot.file_path
  646. if not path_text:
  647. raise APIError(404, "snapshot_media_missing", "该抓拍记录未保存图像文件")
  648. path = Path(path_text)
  649. if not path.is_file():
  650. raise APIError(404, "snapshot_media_missing", f"图像文件不存在:{path_text}")
  651. return path, guess_content_type(path.name)
  652. def resolve_snapshot_thumbnail(snapshot_id: str, db: Session) -> tuple[Path, str]:
  653. snapshot = video_repo.get_snapshot(db, snapshot_id)
  654. if snapshot is None:
  655. raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
  656. path_text = snapshot.thumbnail_path or snapshot.file_path
  657. if not path_text:
  658. raise APIError(404, "snapshot_media_missing", "该抓拍记录未保存图像文件")
  659. path = Path(path_text)
  660. if not path.is_file():
  661. return resolve_snapshot_media(snapshot_id, db)
  662. return path, guess_content_type(path.name)
  663. def resolve_channel_latest_snapshot(channel_id: str, db: Session) -> tuple[Path, str]:
  664. snapshot = video_repo.latest_snapshot(db, channel_id)
  665. if snapshot is None:
  666. raise APIError(404, "video_snapshot_not_found", "该通道暂无识别抓拍图像")
  667. return resolve_snapshot_media(snapshot.id, db)