camera.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. package parking
  2. import (
  3. "errors"
  4. "fmt"
  5. "net/url"
  6. "strings"
  7. "wails-app/internal/dao"
  8. "wails-app/internal/global"
  9. "wails-app/internal/model/parking/request"
  10. )
  11. // CameraStreamOption 是工作台使用的安全摄像头信息,不包含用户名和密码。
  12. type CameraStreamOption struct {
  13. DeviceCode string `json:"device_code"`
  14. DeviceName string `json:"device_name"`
  15. ChannelID uint `json:"channel_id"`
  16. ChannelCode string `json:"channel_code"`
  17. ChannelName string `json:"channel_name"`
  18. Protocol string `json:"protocol"`
  19. PlayURL string `json:"play_url"`
  20. SnapshotURL string `json:"snapshot_url"`
  21. Status string `json:"status"`
  22. Online bool `json:"online"`
  23. }
  24. // CreateCamera 创建摄像头配置,并校验它绑定的通道。
  25. func (s *PassageService) CreateCamera(req request.CameraCreate) error {
  26. if err := validateCameraConfig(req); err != nil {
  27. return err
  28. }
  29. var existing dao.Camera
  30. if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&existing).RowsAffected > 0 {
  31. return errors.New("摄像头编码已存在")
  32. }
  33. return global.GVA_DB.Create(&dao.Camera{
  34. DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, ChannelID: req.ChannelID,
  35. IPAddress: req.IPAddress, Port: req.Port, Username: req.Username, Password: req.Password,
  36. Channel: req.Channel, IsActive: req.IsActive, StreamProtocol: req.StreamProtocol,
  37. SourceURL: req.SourceURL, StreamURL: req.StreamURL, SnapshotURL: req.SnapshotURL, Description: req.Description,
  38. }).Error
  39. }
  40. // UpdateCamera 更新摄像头配置;为了避免浏览器回显凭据,空密码不会覆盖数据库已有密码。
  41. func (s *PassageService) UpdateCamera(req request.CameraUpdate) error {
  42. if err := validateCameraConfig(req.CameraCreate); err != nil {
  43. return err
  44. }
  45. var camera dao.Camera
  46. if err := global.GVA_DB.First(&camera, req.ID).Error; err != nil {
  47. return errors.New("摄像头不存在")
  48. }
  49. var existing dao.Camera
  50. if global.GVA_DB.Where("device_code = ? AND id != ?", req.DeviceCode, req.ID).First(&existing).RowsAffected > 0 {
  51. return errors.New("摄像头编码已被其他设备使用")
  52. }
  53. previousDeviceCode := camera.DeviceCode
  54. camera.DeviceCode, camera.DeviceName, camera.ChannelID = req.DeviceCode, req.DeviceName, req.ChannelID
  55. camera.IPAddress, camera.Port = req.IPAddress, req.Port
  56. if strings.TrimSpace(req.Username) != "" {
  57. camera.Username = req.Username
  58. }
  59. if strings.TrimSpace(req.Password) != "" {
  60. camera.Password = req.Password
  61. }
  62. camera.Channel, camera.IsActive, camera.StreamProtocol = req.Channel, req.IsActive, req.StreamProtocol
  63. // 前端编辑时密码会被掩码;此时保留数据库中的完整 RTSP 地址。
  64. if !strings.Contains(req.SourceURL, "***") {
  65. camera.SourceURL = req.SourceURL
  66. }
  67. camera.StreamURL, camera.SnapshotURL, camera.Description = req.StreamURL, req.SnapshotURL, req.Description
  68. if err := global.GVA_DB.Save(&camera).Error; err != nil {
  69. return err
  70. }
  71. cameraMJPEGHub.stop(previousDeviceCode)
  72. if camera.DeviceCode != previousDeviceCode {
  73. cameraMJPEGHub.stop(camera.DeviceCode)
  74. }
  75. return nil
  76. }
  77. func (s *PassageService) ListCameras(req request.CameraQuery) (int64, []dao.Camera, error) {
  78. var total int64
  79. var cameras []dao.Camera
  80. query := global.GVA_DB.Model(&dao.Camera{}).Preload("BoundChannel")
  81. if req.ParkingLotID != 0 {
  82. query = query.Joins("JOIN channel ON channel.id = camera.channel_id AND channel.deleted_at IS NULL").Where("channel.parking_lot_id = ?", req.ParkingLotID)
  83. }
  84. if err := query.Count(&total).Error; err != nil {
  85. return 0, nil, err
  86. }
  87. if err := query.Order("camera.device_name ASC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&cameras).Error; err != nil {
  88. return 0, nil, err
  89. }
  90. // 播放地址可能含凭据,配置列表不向浏览器返回它。
  91. for i := range cameras {
  92. cameras[i].Status = cameraDisplayStatus(cameras[i])
  93. cameras[i].Username = ""
  94. cameras[i].Password = ""
  95. cameras[i].SourceURL = redactCameraURL(cameras[i].SourceURL)
  96. }
  97. return total, cameras, nil
  98. }
  99. func (s *PassageService) DeleteCamera(id uint) error {
  100. var camera dao.Camera
  101. if err := global.GVA_DB.First(&camera, id).Error; err != nil {
  102. return errors.New("摄像头不存在")
  103. }
  104. result := global.GVA_DB.Delete(&camera)
  105. if result.Error != nil {
  106. return result.Error
  107. }
  108. if result.RowsAffected == 0 {
  109. return errors.New("摄像头不存在")
  110. }
  111. cameraMJPEGHub.stop(camera.DeviceCode)
  112. return nil
  113. }
  114. func validateCameraConfig(req request.CameraCreate) error {
  115. if global.GVA_DB == nil {
  116. return errors.New("数据库未初始化")
  117. }
  118. if req.StreamProtocol == "system-mjpeg" && strings.TrimSpace(req.SourceURL) == "" {
  119. return errors.New("系统转换模式必须填写原始 RTSP/HTTP 地址")
  120. }
  121. if req.StreamProtocol != "system-mjpeg" && strings.TrimSpace(req.StreamURL) == "" {
  122. return errors.New("边缘播放模式必须填写浏览器播放地址")
  123. }
  124. var channel dao.Channel
  125. if err := global.GVA_DB.First(&channel, req.ChannelID).Error; err != nil {
  126. return errors.New("绑定通道不存在")
  127. }
  128. return nil
  129. }
  130. // ListCameraStreams 返回启用摄像头的浏览器播放配置,并按通道绑定到道闸设备。
  131. func (s *PassageService) ListCameraStreams() ([]CameraStreamOption, error) {
  132. if global.GVA_DB == nil {
  133. return nil, errors.New("数据库未初始化")
  134. }
  135. var cameras []dao.Camera
  136. if err := global.GVA_DB.Preload("BoundChannel").Where("is_active = ?", true).
  137. Order("device_name ASC").Find(&cameras).Error; err != nil {
  138. return nil, err
  139. }
  140. options := make([]CameraStreamOption, 0, len(cameras))
  141. for _, camera := range cameras {
  142. protocol := camera.StreamProtocol
  143. if protocol == "" {
  144. protocol = "webrtc"
  145. }
  146. playURL := camera.StreamURL
  147. systemConversionEnabled := strings.ToLower(strings.TrimSpace(global.GVA_CONFIG.Camera.ConversionMode)) != "edge"
  148. if systemConversionEnabled && isSystemMJPEG(protocol) {
  149. token := issueCameraStreamToken(camera.DeviceCode)
  150. prefix := global.GVA_CONFIG.System.RouterPrefix
  151. if prefix == "" {
  152. prefix = "/api"
  153. }
  154. playURL = fmt.Sprintf("%s/parking/camera/stream/%s/%s", strings.TrimRight(prefix, "/"), url.PathEscape(camera.DeviceCode), token)
  155. protocol = "mjpeg"
  156. } else if isSystemMJPEG(protocol) {
  157. // 切到 edge 模式后,沿用数据库中的 StreamURL(由边缘或外部媒体网关提供)。
  158. protocol = "webrtc"
  159. }
  160. status := cameraDisplayStatus(camera)
  161. options = append(options, CameraStreamOption{
  162. DeviceCode: camera.DeviceCode,
  163. DeviceName: camera.DeviceName,
  164. ChannelID: camera.ChannelID,
  165. ChannelCode: cameraChannelCode(camera.BoundChannel),
  166. ChannelName: cameraChannelName(camera.BoundChannel),
  167. Protocol: protocol,
  168. PlayURL: playURL,
  169. SnapshotURL: camera.SnapshotURL,
  170. Status: status,
  171. Online: status == "online" || status == "connected",
  172. })
  173. }
  174. return options, nil
  175. }
  176. // cameraDisplayStatus returns the observed stream state for system-side
  177. // conversion. Camera.Status is not updated by FFmpeg, so using it there made
  178. // an actively playing RTSP stream appear offline indefinitely.
  179. func cameraDisplayStatus(camera dao.Camera) string {
  180. if isSystemMJPEG(camera.StreamProtocol) && strings.ToLower(strings.TrimSpace(global.GVA_CONFIG.Camera.ConversionMode)) != "edge" {
  181. if hasRecentCameraStreamFrame(camera.DeviceCode) {
  182. return "online"
  183. }
  184. return "offline"
  185. }
  186. if camera.Status == "" {
  187. return "offline"
  188. }
  189. return camera.Status
  190. }
  191. func cameraChannelCode(channel *dao.Channel) string {
  192. if channel == nil {
  193. return ""
  194. }
  195. return channel.ChannelCode
  196. }
  197. func cameraChannelName(channel *dao.Channel) string {
  198. if channel == nil {
  199. return ""
  200. }
  201. return channel.ChannelName
  202. }