package service import ( "bytes" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/gofrs/uuid/v5" "gorm.io/gorm" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/modules/deviceprovisioning/model/request" responseModel "wails-app/internal/modules/deviceprovisioning/model/response" "wails-app/internal/modules/deviceprovisioning/repository" ) const ( defaultConfigPort = 8443 identityPath = "/api/v1/identity" provisionPath = "/api/v1/provision" ) // Service 实现手工 IP 接入流程。所有设备身份都以 HTTPS 证书和永久 ID 为准。 type Service struct{ repo *repository.Repository } func New() *Service { return &Service{repo: repository.New()} } // findProvisionReader finds the row to update for a provisioning request. // Unscoped is intentional: device_code remains unique after a soft delete. func findProvisionReader(db *gorm.DB, deviceID, deviceCode string) (dao.UHFReader, bool, error) { var existing dao.UHFReader var duplicate dao.UHFReader duplicateResult := db.Unscoped().Where("device_code = ?", deviceCode).Order("id DESC").First(&duplicate) if duplicateResult.Error != nil && !errors.Is(duplicateResult.Error, gorm.ErrRecordNotFound) { return existing, false, duplicateResult.Error } if duplicateResult.Error == nil { if duplicate.DeviceID != deviceID { return existing, false, errors.New("设备编码已被其他设备使用") } return duplicate, true, nil } result := db.Unscoped().Where("device_id = ?", deviceID).Order("id DESC").First(&existing) if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { return existing, false, result.Error } return existing, result.Error == nil, nil } type deviceIdentity struct { DeviceID string `json:"device_id"` DeviceCode string `json:"device_code"` DeviceType string `json:"device_type"` DeviceModel string `json:"device_model"` FirmwareVersion string `json:"firmware_version"` CertificateFingerprint string `json:"certificate_fingerprint"` PublicKeyFingerprint string `json:"public_key_fingerprint"` PairingRequired bool `json:"pairing_required"` } func normalizeFingerprint(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.TrimPrefix(value, "sha256:") value = strings.ReplaceAll(value, ":", "") value = strings.ReplaceAll(value, "-", "") return value } // ValidateAddress 只接受字面单播 IP,禁止回环、未指定、广播、组播和主机名。 func ValidateAddress(rawIP string, port int) (net.IP, error) { value := strings.TrimSpace(rawIP) if value == "" || strings.EqualFold(value, "localhost") { return nil, errors.New("设备地址必须是合法 IP,不能使用 localhost") } if net.ParseIP(value) == nil { return nil, errors.New("设备地址必须是字面 IP,不能使用主机名") } ip := net.ParseIP(value) if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() { return nil, errors.New("禁止使用回环、未指定或组播地址") } if port < 1 || port > 65535 { return nil, errors.New("HTTPS 端口必须是 1-65535 的整数") } if ip.To4() != nil { v4 := ip.To4() if v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 { return nil, errors.New("禁止使用广播地址") } if isDirectedBroadcast(v4) { return nil, errors.New("禁止使用当前网卡的定向广播地址") } } return ip, nil } func isDirectedBroadcast(ip net.IP) bool { interfaces, _ := net.Interfaces() for _, iface := range interfaces { addrs, _ := iface.Addrs() for _, addr := range addrs { var ipnet *net.IPNet switch value := addr.(type) { case *net.IPNet: ipnet = value case *net.IPAddr: ipnet = &net.IPNet{IP: value.IP, Mask: net.CIDRMask(32, 32)} } if ipnet == nil || ipnet.IP.To4() == nil || !ipnet.Contains(ip) { continue } network := ipnet.IP.To4() mask := ipnet.Mask return isDirectedBroadcastForNetwork(ip, network, mask) } } return false } func isDirectedBroadcastForNetwork(ip, network net.IP, mask net.IPMask) bool { input := ip.To4() network = network.To4() if input == nil || network == nil || len(mask) != net.IPv4len { return false } broadcast := make(net.IP, net.IPv4len) for i := 0; i < net.IPv4len; i++ { broadcast[i] = network[i] | ^mask[i] } return broadcast.Equal(input) } func (s *Service) httpClient(_ net.IP, fingerprint string) *http.Client { transport := &http.Transport{TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, // 设备出厂可使用自签名证书;VerifyConnection 负责固定指纹。 VerifyConnection: func(state tls.ConnectionState) error { if len(state.PeerCertificates) == 0 { return errors.New("设备未提供 TLS 证书") } cert := state.PeerCertificates[0] got := sha256.Sum256(cert.Raw) actual := hex.EncodeToString(got[:]) if fingerprint != "" && normalizeFingerprint(fingerprint) != actual { return fmt.Errorf("证书指纹不匹配") } return nil }, }} transport.DialContext = (&net.Dialer{Timeout: 5 * time.Second}).DialContext return &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errors.New("禁止 HTTPS 重定向到其他主机") }} } func endpoint(ip net.IP, port int, path string) string { return (&url.URL{Scheme: "https", Host: net.JoinHostPort(ip.String(), strconv.Itoa(port)), Path: path}).String() } func decodeResponse(body []byte, target interface{}) error { if len(body) > 64*1024 { return errors.New("设备响应过大") } if err := json.Unmarshal(body, target); err != nil { return fmt.Errorf("设备响应不是合法 JSON: %w", err) } return nil } // ReadIdentity 连接设备并保存 10 分钟有效的身份记录。 func (s *Service) ReadIdentity(req request.ManualIdentityRequest) (*responseModel.IdentityResponse, error) { port := req.Port if port == 0 { port = defaultConfigPort } ip, err := ValidateAddress(req.IP, port) if err != nil { return nil, err } client := s.httpClient(ip, "") httpReq, _ := http.NewRequest(http.MethodGet, endpoint(ip, port, identityPath), nil) httpReq.Header.Set("Accept", "application/json") resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("HTTPS 身份读取失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1)) if err != nil { return nil, fmt.Errorf("读取设备身份失败: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("设备身份接口返回 HTTP %d", resp.StatusCode) } var identity deviceIdentity if err := decodeResponse(body, &identity); err != nil { return nil, err } if strings.TrimSpace(identity.DeviceID) == "" { return nil, errors.New("设备未返回永久设备 ID") } certFingerprint := "" if len(resp.TLS.PeerCertificates) > 0 { hash := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw) certFingerprint = "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:])) } if identity.CertificateFingerprint != "" && normalizeFingerprint(identity.CertificateFingerprint) != normalizeFingerprint(certFingerprint) { return nil, fmt.Errorf("设备返回的证书指纹与 HTTPS 证书不匹配: received=%q expected=%s", identity.CertificateFingerprint, certFingerprint) } requestID := uuid.Must(uuid.NewV4()).String() expiredAt := time.Now().Add(10 * time.Minute) payload, _ := json.Marshal(identity) record := &dao.DeviceDiscovery{ RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode, DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion, SourceIP: ip.String(), ConfigPort: port, Payload: string(payload), CertificateFingerprint: certFingerprint, PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: identity.PairingRequired, ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified", ProvisionMethod: req.ProvisionMethod, } if record.ProvisionMethod == "" { record.ProvisionMethod = "manual_ip" } if record.ProvisionMethod == "auto_udp" { record.SignatureVerified = true } if err := s.repo.CreateDiscovery(record); err != nil { return nil, err } return &responseModel.IdentityResponse{ RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode, DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion, IP: ip.String(), ConfigPort: port, CertificateFingerprint: certFingerprint, PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: identity.PairingRequired, ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified", }, nil } // VerifyIdentity 固定设备证书指纹并通过设备配对接口确认一次性配对码。 func (s *Service) VerifyIdentity(deviceID string, req request.VerifyRequest) (*responseModel.IdentityResponse, error) { record, err := s.repo.LatestDiscovery(deviceID) if err != nil { return nil, err } if normalizeFingerprint(req.Fingerprint) != normalizeFingerprint(record.CertificateFingerprint) { return nil, errors.New("证书指纹不匹配") } if record.PairingRequired && strings.TrimSpace(req.PairingCode) == "" { return nil, errors.New("设备要求输入一次性配对码") } if record.PairingRequired { ip := net.ParseIP(record.SourceIP) body, _ := json.Marshal(map[string]string{"pairing_code": req.PairingCode}) client := s.httpClient(ip, record.CertificateFingerprint) httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, "/api/v1/pair"), bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/json") resp, callErr := client.Do(httpReq) if callErr != nil { return nil, fmt.Errorf("配对码校验失败: %w", callErr) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("配对码校验失败,设备返回 HTTP %d", resp.StatusCode) } } now := time.Now() record.Verified = true record.VerifiedAt = &now record.ProvisionStatus = record.ProvisionMethod + "_verified" if err := s.repo.SaveDiscovery(record); err != nil { return nil, err } var identity deviceIdentity _ = json.Unmarshal([]byte(record.Payload), &identity) return &responseModel.IdentityResponse{ RequestID: record.RequestID, DeviceID: record.DeviceID, DeviceCode: identity.DeviceCode, DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion, IP: record.SourceIP, ConfigPort: record.ConfigPort, CertificateFingerprint: record.CertificateFingerprint, PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: record.PairingRequired, Verified: true, ExpiredAt: record.ExpiredAt, ProvisionStatus: record.ProvisionStatus, }, nil } type provisionPayload struct { Schema string `json:"schema"` RequestID string `json:"request_id"` DeviceID string `json:"device_id"` DeviceCode string `json:"device_code"` MQTT struct { Host string `json:"host"` Port int `json:"port"` TLS bool `json:"tls"` ClientID string `json:"client_id"` } `json:"mqtt"` Route struct { ParkingLotID uint `json:"parking_lot_id"` BoothID uint `json:"booth_id"` ChannelID uint `json:"channel_id"` Direction string `json:"direction"` } `json:"route"` ImageUpload struct { URL string `json:"url"` MaxBytes int64 `json:"max_bytes"` } `json:"image_upload"` } const deviceImageUploadMaxBytes int64 = 8 * 1024 * 1024 func deviceImageUploadURL(host string, port int) string { return "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/device-images/upload" } // Provision 校验业务绑定后下发配置,并创建/更新 MQTT 设备记录。 func (s *Service) Provision(deviceID string, req request.ProvisionRequest) (*responseModel.ProvisionStatusResponse, error) { record, err := s.repo.LatestDiscovery(deviceID) if err != nil { return nil, err } if !record.Verified { return nil, errors.New("设备身份尚未确认") } if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } var channel dao.Channel if err := global.GVA_DB.Preload("Booth").Where("id = ?", req.ChannelID).First(&channel).Error; err != nil { return nil, errors.New("通道不存在") } if channel.BoothID != req.BoothID || channel.ParkingLotID != req.ParkingLotID || channel.Booth == nil || channel.Booth.ParkingLotID != req.ParkingLotID { return nil, errors.New("停车场、岗亭和通道归属不一致") } // device_code has a database-wide unique index. Include soft-deleted rows in // this lookup; otherwise a deleted record can pass the pre-check and fail // later with SQLite 2067 during INSERT. existing, existingFound, err := findProvisionReader(global.GVA_DB, deviceID, req.DeviceCode) if err != nil { return nil, err } cfg := global.GVA_CONFIG.Mqtt if strings.TrimSpace(cfg.AdvertisedHost) == "" || net.ParseIP(cfg.AdvertisedHost) == nil { return nil, errors.New("未配置可供边缘设备访问的 MQTT advertised-host") } if _, err := ValidateAddress(cfg.AdvertisedHost, cfg.AdvertisedPort); err != nil { return nil, fmt.Errorf("MQTT advertised 地址无效: %w", err) } requestID := uuid.Must(uuid.NewV4()).String() payload := provisionPayload{Schema: "provision.request.v1", RequestID: requestID, DeviceID: deviceID, DeviceCode: req.DeviceCode} payload.MQTT.Host, payload.MQTT.Port, payload.MQTT.TLS, payload.MQTT.ClientID = cfg.AdvertisedHost, cfg.AdvertisedPort, cfg.TLSEnabled, req.DeviceCode payload.Route.ParkingLotID, payload.Route.BoothID, payload.Route.ChannelID, payload.Route.Direction = req.ParkingLotID, req.BoothID, req.ChannelID, req.Direction payload.ImageUpload.URL, payload.ImageUpload.MaxBytes = deviceImageUploadURL(cfg.AdvertisedHost, global.GVA_CONFIG.System.Addr), deviceImageUploadMaxBytes body, _ := json.Marshal(payload) ip := net.ParseIP(record.SourceIP) client := s.httpClient(ip, record.CertificateFingerprint) httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, provisionPath), bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/json") resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("HTTPS 配置下发失败: %w", err) } defer resp.Body.Close() responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("设备拒绝配置,HTTP %d", resp.StatusCode) } var accepted struct { Result string `json:"result"` } if err := decodeResponse(responseBody, &accepted); err != nil { return nil, fmt.Errorf("设备配置响应无效: %w", err) } if accepted.Result != "accepted" { return nil, errors.New("设备未接受 MQTT 配置") } now := time.Now() if !existingFound { existing = dao.UHFReader{DeviceID: deviceID, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, DeviceType: req.DeviceType, ConnectType: dao.ConnectTypeMQTT, ChannelID: req.ChannelID, ParkingLotID: req.ParkingLotID} } // Restore a soft-deleted row before saving the new binding. Save must be // Unscoped so GORM does not silently exclude the historical row. existing.DeletedAt = gorm.DeletedAt{} existing.DeviceCode, existing.DeviceName, existing.DeviceType = req.DeviceCode, req.DeviceName, req.DeviceType existing.ConnectType, existing.ChannelID, existing.ParkingLotID = dao.ConnectTypeMQTT, req.ChannelID, req.ParkingLotID existing.IPAddress, existing.ConfigPort = record.SourceIP, record.ConfigPort existing.DeviceModel, existing.FirmwareVersion = record.DeviceModel, record.FirmwareVersion existing.DevicePublicKey, existing.ProvisionStatus, existing.ProvisionMethod, existing.ProvisionError = record.CertificateFingerprint, "provisioned", record.ProvisionMethod, "" existing.IdentityVerifiedAt = record.VerifiedAt existing.ProvisionedAt = &now if !existingFound { if err := global.GVA_DB.Create(&existing).Error; err != nil { return nil, err } } else if err := global.GVA_DB.Unscoped().Save(&existing).Error; err != nil { return nil, err } return &responseModel.ProvisionStatusResponse{DeviceID: deviceID, DeviceCode: req.DeviceCode, ProvisionStatus: "provisioned", ProvisionMethod: record.ProvisionMethod, DeviceStatus: existing.Status, IP: record.SourceIP, ConfigPort: record.ConfigPort, CertificateFinger: record.CertificateFingerprint, VerifiedAt: record.VerifiedAt, ProvisionedAt: &now}, nil } func (s *Service) Status(deviceID string) (*responseModel.ProvisionStatusResponse, error) { reader, err := s.repo.ReaderByDeviceID(deviceID) if err != nil { return nil, err } if reader == nil { return nil, errors.New("设备尚未完成配置") } return &responseModel.ProvisionStatusResponse{DeviceID: reader.DeviceID, DeviceCode: reader.DeviceCode, ProvisionStatus: reader.ProvisionStatus, ProvisionMethod: reader.ProvisionMethod, DeviceStatus: reader.Status, ProvisionError: reader.ProvisionError, IP: reader.IPAddress, ConfigPort: reader.ConfigPort, CertificateFinger: reader.DevicePublicKey, VerifiedAt: reader.IdentityVerifiedAt, ProvisionedAt: reader.ProvisionedAt}, nil }