package parking import ( "errors" "fmt" "net/url" "strings" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/model/parking/request" ) // CameraStreamOption 是工作台使用的安全摄像头信息,不包含用户名和密码。 type CameraStreamOption struct { DeviceCode string `json:"device_code"` DeviceName string `json:"device_name"` ChannelID uint `json:"channel_id"` ChannelCode string `json:"channel_code"` ChannelName string `json:"channel_name"` Protocol string `json:"protocol"` PlayURL string `json:"play_url"` SnapshotURL string `json:"snapshot_url"` Status string `json:"status"` Online bool `json:"online"` } // CreateCamera 创建摄像头配置,并校验它绑定的通道。 func (s *PassageService) CreateCamera(req request.CameraCreate) error { if err := validateCameraConfig(req); err != nil { return err } var existing dao.Camera if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&existing).RowsAffected > 0 { return errors.New("摄像头编码已存在") } return global.GVA_DB.Create(&dao.Camera{ DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, ChannelID: req.ChannelID, IPAddress: req.IPAddress, Port: req.Port, Username: req.Username, Password: req.Password, Channel: req.Channel, IsActive: req.IsActive, StreamProtocol: req.StreamProtocol, SourceURL: req.SourceURL, StreamURL: req.StreamURL, SnapshotURL: req.SnapshotURL, Description: req.Description, }).Error } // UpdateCamera 更新摄像头配置;为了避免浏览器回显凭据,空密码不会覆盖数据库已有密码。 func (s *PassageService) UpdateCamera(req request.CameraUpdate) error { if err := validateCameraConfig(req.CameraCreate); err != nil { return err } var camera dao.Camera if err := global.GVA_DB.First(&camera, req.ID).Error; err != nil { return errors.New("摄像头不存在") } var existing dao.Camera if global.GVA_DB.Where("device_code = ? AND id != ?", req.DeviceCode, req.ID).First(&existing).RowsAffected > 0 { return errors.New("摄像头编码已被其他设备使用") } previousDeviceCode := camera.DeviceCode camera.DeviceCode, camera.DeviceName, camera.ChannelID = req.DeviceCode, req.DeviceName, req.ChannelID camera.IPAddress, camera.Port = req.IPAddress, req.Port if strings.TrimSpace(req.Username) != "" { camera.Username = req.Username } if strings.TrimSpace(req.Password) != "" { camera.Password = req.Password } camera.Channel, camera.IsActive, camera.StreamProtocol = req.Channel, req.IsActive, req.StreamProtocol // 前端编辑时密码会被掩码;此时保留数据库中的完整 RTSP 地址。 if !strings.Contains(req.SourceURL, "***") { camera.SourceURL = req.SourceURL } camera.StreamURL, camera.SnapshotURL, camera.Description = req.StreamURL, req.SnapshotURL, req.Description if err := global.GVA_DB.Save(&camera).Error; err != nil { return err } cameraMJPEGHub.stop(previousDeviceCode) if camera.DeviceCode != previousDeviceCode { cameraMJPEGHub.stop(camera.DeviceCode) } return nil } func (s *PassageService) ListCameras(req request.CameraQuery) (int64, []dao.Camera, error) { var total int64 var cameras []dao.Camera query := global.GVA_DB.Model(&dao.Camera{}).Preload("BoundChannel") if req.ParkingLotID != 0 { query = query.Joins("JOIN channel ON channel.id = camera.channel_id AND channel.deleted_at IS NULL").Where("channel.parking_lot_id = ?", req.ParkingLotID) } if err := query.Count(&total).Error; err != nil { return 0, nil, err } if err := query.Order("camera.device_name ASC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&cameras).Error; err != nil { return 0, nil, err } // 播放地址可能含凭据,配置列表不向浏览器返回它。 for i := range cameras { cameras[i].Status = cameraDisplayStatus(cameras[i]) cameras[i].Username = "" cameras[i].Password = "" cameras[i].SourceURL = redactCameraURL(cameras[i].SourceURL) } return total, cameras, nil } func (s *PassageService) DeleteCamera(id uint) error { var camera dao.Camera if err := global.GVA_DB.First(&camera, id).Error; err != nil { return errors.New("摄像头不存在") } result := global.GVA_DB.Delete(&camera) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("摄像头不存在") } cameraMJPEGHub.stop(camera.DeviceCode) return nil } func validateCameraConfig(req request.CameraCreate) error { if global.GVA_DB == nil { return errors.New("数据库未初始化") } if req.StreamProtocol == "system-mjpeg" && strings.TrimSpace(req.SourceURL) == "" { return errors.New("系统转换模式必须填写原始 RTSP/HTTP 地址") } if req.StreamProtocol != "system-mjpeg" && strings.TrimSpace(req.StreamURL) == "" { return errors.New("边缘播放模式必须填写浏览器播放地址") } var channel dao.Channel if err := global.GVA_DB.First(&channel, req.ChannelID).Error; err != nil { return errors.New("绑定通道不存在") } return nil } // ListCameraStreams 返回启用摄像头的浏览器播放配置,并按通道绑定到道闸设备。 func (s *PassageService) ListCameraStreams() ([]CameraStreamOption, error) { if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } var cameras []dao.Camera if err := global.GVA_DB.Preload("BoundChannel").Where("is_active = ?", true). Order("device_name ASC").Find(&cameras).Error; err != nil { return nil, err } options := make([]CameraStreamOption, 0, len(cameras)) for _, camera := range cameras { protocol := camera.StreamProtocol if protocol == "" { protocol = "webrtc" } playURL := camera.StreamURL systemConversionEnabled := strings.ToLower(strings.TrimSpace(global.GVA_CONFIG.Camera.ConversionMode)) != "edge" if systemConversionEnabled && isSystemMJPEG(protocol) { token := issueCameraStreamToken(camera.DeviceCode) prefix := global.GVA_CONFIG.System.RouterPrefix if prefix == "" { prefix = "/api" } playURL = fmt.Sprintf("%s/parking/camera/stream/%s/%s", strings.TrimRight(prefix, "/"), url.PathEscape(camera.DeviceCode), token) protocol = "mjpeg" } else if isSystemMJPEG(protocol) { // 切到 edge 模式后,沿用数据库中的 StreamURL(由边缘或外部媒体网关提供)。 protocol = "webrtc" } status := cameraDisplayStatus(camera) options = append(options, CameraStreamOption{ DeviceCode: camera.DeviceCode, DeviceName: camera.DeviceName, ChannelID: camera.ChannelID, ChannelCode: cameraChannelCode(camera.BoundChannel), ChannelName: cameraChannelName(camera.BoundChannel), Protocol: protocol, PlayURL: playURL, SnapshotURL: camera.SnapshotURL, Status: status, Online: status == "online" || status == "connected", }) } return options, nil } // cameraDisplayStatus returns the observed stream state for system-side // conversion. Camera.Status is not updated by FFmpeg, so using it there made // an actively playing RTSP stream appear offline indefinitely. func cameraDisplayStatus(camera dao.Camera) string { if isSystemMJPEG(camera.StreamProtocol) && strings.ToLower(strings.TrimSpace(global.GVA_CONFIG.Camera.ConversionMode)) != "edge" { if hasRecentCameraStreamFrame(camera.DeviceCode) { return "online" } return "offline" } if camera.Status == "" { return "offline" } return camera.Status } func cameraChannelCode(channel *dao.Channel) string { if channel == nil { return "" } return channel.ChannelCode } func cameraChannelName(channel *dao.Channel) string { if channel == nil { return "" } return channel.ChannelName }