瀏覽代碼

feat: 完善设备接入与RFID免密通行

lq 2 周之前
父節點
當前提交
ebbe057424
共有 90 個文件被更改,包括 7662 次插入332 次删除
  1. 10 0
      .gitignore
  2. 408 0
      cmd/mock-edge-device/main.go
  3. 1 0
      cmd_backend_only/main.go
  4. 18 1
      config.yaml
  5. 286 0
      doc/多岗亭改造方案.md
  6. 52 0
      doc/摄像头系统侧转换实施说明.md
  7. 412 0
      doc/系统端设备与摄像头对接配置说明.md
  8. 561 0
      doc/设备接入层设计.md
  9. 386 0
      doc/设备端对接文档.md
  10. 523 0
      doc/边缘端自动与手工接入对接指南.md
  11. 487 0
      doc/边缘设备自动接入实施方案.md
  12. 6 0
      frontend/src/api/camera.js
  13. 44 2
      frontend/src/api/device.js
  14. 0 6
      frontend/src/api/shift.js
  15. 8 1
      frontend/src/lang/en.js
  16. 8 1
      frontend/src/lang/zh-CN.js
  17. 8 1
      frontend/src/lang/zh-HK.js
  18. 8 1
      frontend/src/lang/zh-TW.js
  19. 28 16
      frontend/src/utils/request.js
  20. 24 1
      frontend/src/view/parking/carInfo.vue
  21. 9 3
      frontend/src/view/parking/carOwner.vue
  22. 52 15
      frontend/src/view/parking/monthlyCard.vue
  23. 302 6
      frontend/src/view/parking/parkingInfo/device.vue
  24. 0 78
      frontend/src/view/parking/shiftRecord.vue
  25. 13 2
      frontend/src/view/report/enter.vue
  26. 4 0
      frontend/vite.config.js
  27. 3 0
      go.mod
  28. 8 0
      go.sum
  29. 27 0
      internal/api/v1/parking/camera.go
  30. 10 0
      internal/config/camera.go
  31. 5 0
      internal/config/config.go
  32. 17 0
      internal/config/mqtt.go
  33. 3 1
      internal/core/server_other.go
  34. 6 4
      internal/core/server_win.go
  35. 16 10
      internal/dao/camera.go
  36. 33 0
      internal/dao/device_discovery.go
  37. 21 0
      internal/dao/device_image.go
  38. 26 7
      internal/dao/uhf_reader.go
  39. 18 0
      internal/initialize/gorm.go
  40. 96 0
      internal/initialize/mqtt.go
  41. 23 0
      internal/initialize/operation_permission_seed.go
  42. 5 52
      internal/initialize/reader_init.go
  43. 5 0
      internal/initialize/router.go
  44. 1 3
      internal/initialize/seed.go
  45. 1 1
      internal/initialize/uhf.go
  46. 1 0
      internal/main.go
  47. 3 2
      internal/middleware/operation.go
  48. 11 11
      internal/model/owner/request/owner.go
  49. 2 0
      internal/model/vehicle/request/vehicle.go
  50. 1 0
      internal/model/vehicle/response/vehicle.go
  51. 138 0
      internal/modules/deviceimage/api.go
  52. 147 0
      internal/modules/deviceprovisioning/api.go
  53. 30 0
      internal/modules/deviceprovisioning/model/request/request.go
  54. 53 0
      internal/modules/deviceprovisioning/model/response/response.go
  55. 83 0
      internal/modules/deviceprovisioning/repository/repo.go
  56. 329 0
      internal/modules/deviceprovisioning/service/discovery.go
  57. 420 0
      internal/modules/deviceprovisioning/service/service.go
  58. 167 0
      internal/modules/deviceprovisioning/service/service_test.go
  59. 10 0
      internal/modules/deviceprovisioning/service/udp_socket_other.go
  60. 17 0
      internal/modules/deviceprovisioning/service/udp_socket_windows.go
  61. 19 8
      internal/modules/digital-ticket/service/service.go
  62. 36 9
      internal/modules/monthly/api.go
  63. 38 9
      internal/modules/monthly/service/service.go
  64. 36 0
      internal/modules/monthly/service/service_test.go
  65. 11 0
      internal/router/parking/parking.go
  66. 145 0
      internal/service/devicebus/device_bus.go
  67. 34 0
      internal/service/devicebus/device_bus_test.go
  68. 231 0
      internal/service/devicebus/mqtt_integration_test.go
  69. 59 0
      internal/service/mqttbroker/broker.go
  70. 50 0
      internal/service/mqttbroker/broker_test.go
  71. 10 2
      internal/service/owner/owner.go
  72. 90 0
      internal/service/parking/camera.go
  73. 167 0
      internal/service/parking/camera_stream.go
  74. 24 0
      internal/service/parking/gate.go
  75. 315 0
      internal/service/parking/mqtt_gate_controller.go
  76. 119 0
      internal/service/parking/mqtt_gate_controller_test.go
  77. 4 2
      internal/service/parking/passage.go
  78. 34 0
      internal/service/parking/routing_gate_controller.go
  79. 214 35
      internal/service/uhf/reader.go
  80. 8 7
      internal/service/uhf/reader_interface.go
  81. 27 3
      internal/service/uhf/serial_reader.go
  82. 81 11
      internal/service/uhf/tcp_reader.go
  83. 133 17
      internal/service/uhf/uhf_service.go
  84. 23 0
      internal/service/vehicle/parking_session_test.go
  85. 35 0
      internal/service/vehicle/payment_edge_test.go
  86. 26 4
      internal/service/vehicle/vehicle.go
  87. 8 0
      main.go
  88. 219 0
      scripts/manage-project.ps1
  89. 65 0
      scripts/mock-edge-device.ps1
  90. 7 0
      scripts/项目管理.bat

+ 10 - 0
.gitignore

@@ -49,6 +49,7 @@ web/pnpm-lock.yaml
 # Go build/test cache redirected to the workspace
 /.gocache/
 /.gocache-*/
+/.run/
 
 # 工具私有目录
 /.zcode/
@@ -63,6 +64,15 @@ backend-only.stdout.log
 backend-dev.stderr.log
 backend-dev.stdout.log
 wails-restart.log
+*.stdout.log
+*.stderr.log
+
+# 本机运行产物
+/*.exe
+/.mqtt-status-debug.db
+/scripts/.run/
+/scripts/scripts/
+/uploads/file/*
 
 # 测试运行产物
 /test/testutil/log/

+ 408 - 0
cmd/mock-edge-device/main.go

@@ -0,0 +1,408 @@
+// mock-edge-device is a local HTTPS edge-device stub for manual provisioning tests.
+package main
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"flag"
+	"fmt"
+	"log"
+	"math/big"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+)
+
+const (
+	deviceID    = "MOCK-EDGE-001"
+	pairingCode = "246810"
+)
+
+type mqttConfig struct {
+	Host     string `json:"host"`
+	Port     int    `json:"port"`
+	TLS      bool   `json:"tls"`
+	ClientID string `json:"client_id"`
+}
+
+type routeConfig struct {
+	ParkingLotID uint   `json:"parking_lot_id"`
+	BoothID      uint   `json:"booth_id"`
+	ChannelID    uint   `json:"channel_id"`
+	Direction    string `json:"direction"`
+}
+
+type provisionRequest struct {
+	Schema     string      `json:"schema"`
+	RequestID  string      `json:"request_id"`
+	DeviceID   string      `json:"device_id"`
+	DeviceCode string      `json:"device_code"`
+	MQTT       mqttConfig  `json:"mqtt"`
+	Route      routeConfig `json:"route"`
+}
+
+type state struct {
+	mu          sync.RWMutex
+	provisioned bool
+	paired      bool
+	config      provisionRequest
+	client      mqtt.Client
+	certPEM     []byte
+	keyPEM      []byte
+	certFP      string
+	publicKeyFP string
+	signingKey  ed25519.PrivateKey
+	dataPath    string
+}
+
+func main() {
+	listen := flag.String("listen", "0.0.0.0:18443", "HTTPS listen address")
+	dataDir := flag.String("data-dir", ".run/mock-edge-device", "directory for certificate and provision data")
+	mqttFallbackPlain := flag.Bool("mqtt-fallback-plain", false, "test-only: use plain MQTT even when provision says tls=true")
+	flag.Parse()
+
+	if err := os.MkdirAll(*dataDir, 0700); err != nil {
+		log.Fatal(err)
+	}
+	certPEM, keyPEM, certFP, err := loadOrCreateCertificate(*dataDir)
+	if err != nil {
+		log.Fatal(err)
+	}
+	publicKey, signingKey, err := loadOrCreateSigningKey(*dataDir)
+	if err != nil {
+		log.Fatal(err)
+	}
+	publicHash := sha256.Sum256(publicKey)
+
+	s := &state{certPEM: certPEM, keyPEM: keyPEM, certFP: certFP,
+		publicKeyFP: "SHA256:" + strings.ToUpper(hex.EncodeToString(publicHash[:])),
+		signingKey:  signingKey,
+		dataPath:    filepath.Join(*dataDir, "provision.json")}
+	if raw, err := os.ReadFile(s.dataPath); err == nil {
+		_ = json.Unmarshal(raw, &s.config)
+		s.provisioned = s.config.DeviceCode != ""
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/api/v1/identity", s.identity)
+	mux.HandleFunc("/api/v1/pair", s.pair)
+	mux.HandleFunc("/api/v1/provision", func(w http.ResponseWriter, r *http.Request) {
+		s.provision(w, r, *mqttFallbackPlain)
+	})
+	mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
+		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+	})
+
+	server := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second}
+	cert, err := tls.X509KeyPair(certPEM, keyPEM)
+	if err != nil {
+		log.Fatal(err)
+	}
+	server.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}
+	log.Printf("mock edge device %s listening on https://%s", deviceID, *listen)
+	log.Printf("pairing code: %s; certificate fingerprint: %s", pairingCode, certFP)
+	log.Printf("identity endpoint: https://<this-host>:%s/api/v1/identity", portOf(*listen))
+	go s.serveDiscovery()
+	if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
+		log.Fatal(err)
+	}
+}
+
+type discoveryRequest struct {
+	Schema    string `json:"schema"`
+	RequestID string `json:"request_id"`
+	IssuedAt  int64  `json:"issued_at"`
+	ExpiresAt int64  `json:"expires_at"`
+	SystemID  string `json:"system_id"`
+	Nonce     string `json:"nonce"`
+}
+
+func (s *state) serveDiscovery() {
+	conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 31001})
+	if err != nil {
+		log.Printf("UDP discovery disabled: %v", err)
+		return
+	}
+	defer conn.Close()
+	log.Printf("UDP discovery listening on 0.0.0.0:31001")
+	buffer := make([]byte, 4*1024)
+	for {
+		n, remote, err := conn.ReadFromUDP(buffer)
+		if err != nil {
+			log.Printf("UDP discovery read failed: %v", err)
+			return
+		}
+		if n == len(buffer) {
+			continue
+		}
+		var req discoveryRequest
+		if json.Unmarshal(buffer[:n], &req) != nil || req.Schema != "discovery.request.v1" || strings.TrimSpace(req.RequestID) == "" || strings.TrimSpace(req.Nonce) == "" || req.ExpiresAt <= time.Now().Unix() || req.ExpiresAt > time.Now().Add(2*time.Minute).Unix() {
+			continue
+		}
+		ip := discoverySourceIP(remote.IP)
+		if ip == nil {
+			continue
+		}
+		configURL := fmt.Sprintf("https://%s:18443/api/v1/provision", ip.String())
+		packet := map[string]string{
+			"schema": "discovery.response.v1", "request_id": req.RequestID, "device_id": deviceID,
+			"device_code": s.currentDeviceCode(), "device_type": "lpr-gate", "device_model": "MOCK-LPR-GATE",
+			"firmware_version": "mock-1.0.0", "ip": ip.String(), "config_url": configURL,
+			"public_key_fingerprint": s.publicKeyFP, "signing_public_key": base64.StdEncoding.EncodeToString(s.signingKey.Public().(ed25519.PublicKey)), "nonce": req.Nonce,
+		}
+		payload := strings.Join([]string{"discovery.response.v1", req.RequestID, deviceID, ip.String(), configURL, req.Nonce}, "\n")
+		packet["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(s.signingKey, []byte(payload)))
+		body, _ := json.Marshal(packet)
+		if _, err := conn.WriteToUDP(body, remote); err != nil {
+			log.Printf("UDP discovery response failed: %v", err)
+		}
+	}
+}
+
+func (s *state) currentDeviceCode() string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.config.DeviceCode
+}
+
+func discoverySourceIP(remote net.IP) net.IP {
+	conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: remote, Port: 31001})
+	if err != nil {
+		return nil
+	}
+	defer conn.Close()
+	local, ok := conn.LocalAddr().(*net.UDPAddr)
+	if !ok || local.IP == nil || local.IP.IsUnspecified() || local.IP.IsLoopback() {
+		return nil
+	}
+	return local.IP.To4()
+}
+
+func (s *state) identity(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+		return
+	}
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	writeJSON(w, http.StatusOK, map[string]interface{}{
+		"device_id": deviceID, "device_code": s.config.DeviceCode, "device_type": "lpr-gate",
+		"device_model": "MOCK-LPR-GATE", "firmware_version": "mock-1.0.0",
+		"certificate_fingerprint": s.certFP, "public_key_fingerprint": s.publicKeyFP,
+		"pairing_required": !s.paired,
+	})
+}
+
+func (s *state) pair(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+		return
+	}
+	var req struct {
+		PairingCode string `json:"pairing_code"`
+	}
+	if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req) != nil || req.PairingCode != pairingCode {
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": "invalid pairing code"})
+		return
+	}
+	s.mu.Lock()
+	s.paired = true
+	s.mu.Unlock()
+	writeJSON(w, http.StatusOK, map[string]interface{}{"result": "paired", "device_id": deviceID, "expires_at": time.Now().Add(24 * time.Hour).Unix()})
+}
+
+func (s *state) provision(w http.ResponseWriter, r *http.Request, mqttFallbackPlain bool) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+		return
+	}
+	var req provisionRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	s.mu.RLock()
+	paired := s.paired
+	s.mu.RUnlock()
+	if !paired {
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": "device is not paired"})
+		return
+	}
+	if req.Schema != "provision.request.v1" || req.DeviceID != deviceID || req.DeviceCode == "" || req.MQTT.Host == "" || req.MQTT.Port < 1 || req.MQTT.Port > 65535 || req.Route.ParkingLotID == 0 || req.Route.BoothID == 0 || req.Route.ChannelID == 0 {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provision fields"})
+		return
+	}
+	raw, _ := json.MarshalIndent(req, "", "  ")
+	if err := atomicWrite(s.dataPath, raw); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	s.mu.Lock()
+	s.config, s.provisioned = req, true
+	old := s.client
+	s.client = nil
+	s.mu.Unlock()
+	if old != nil {
+		old.Disconnect(250)
+	}
+	go s.connectMQTT(req, mqttFallbackPlain)
+	writeJSON(w, http.StatusOK, map[string]string{"result": "accepted", "request_id": req.RequestID, "message": "configuration saved; mqtt reconnecting"})
+}
+
+func (s *state) connectMQTT(req provisionRequest, mqttFallbackPlain bool) {
+	server := fmt.Sprintf("tcp://%s:%d", req.MQTT.Host, req.MQTT.Port)
+	useTLS := req.MQTT.TLS && !mqttFallbackPlain
+	if useTLS {
+		log.Printf("MQTT test stub received tls=true; embedded broker is plain TCP, connection may fail")
+	}
+	opts := mqtt.NewClientOptions().AddBroker(server).SetClientID(req.MQTT.ClientID).
+		SetKeepAlive(30*time.Second).SetAutoReconnect(true).
+		SetWill(topic(req.Route, "gate/lwt"), fmt.Sprintf(`{"schema":"gate.lwt.v1","device_code":%q}`, req.DeviceCode), 1, true)
+	if useTLS {
+		opts.SetTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true})
+	}
+	opts.SetOnConnectHandler(func(client mqtt.Client) {
+		client.Subscribe(topic(req.Route, "gate/cmd"), 1, func(c mqtt.Client, msg mqtt.Message) { s.handleCommand(c, msg, req) })
+		client.Publish(topic(req.Route, "gate/lwt"), 1, true, []byte{})
+		payload, _ := json.Marshal(map[string]interface{}{"schema": "gate.state.v1", "device_code": req.DeviceCode, "state": "closed", "ts": time.Now().Unix()})
+		client.Publish(topic(req.Route, "gate/state"), 1, true, payload)
+		log.Printf("MQTT online; published gate.state.v1 to %s", topic(req.Route, "gate/state"))
+	})
+	client := mqtt.NewClient(opts)
+	s.mu.Lock()
+	s.client = client
+	s.mu.Unlock()
+	if token := client.Connect(); token.Wait() && token.Error() != nil {
+		log.Printf("MQTT connect failed: %v", token.Error())
+	}
+}
+
+func (s *state) handleCommand(client mqtt.Client, msg mqtt.Message, req provisionRequest) {
+	var command struct{ Schema, CmdID, DeviceCode, Action string }
+	if json.Unmarshal(msg.Payload(), &command) != nil || command.Schema != "gate.cmd.v1" || command.DeviceCode != req.DeviceCode || command.CmdID == "" {
+		return
+	}
+	payload, _ := json.Marshal(map[string]string{"schema": "gate.ack.v1", "cmd_id": command.CmdID, "device_code": req.DeviceCode, "result": "success", "error": ""})
+	client.Publish(topic(req.Route, "gate/cmd/ack"), 1, false, payload)
+}
+
+func topic(route routeConfig, suffix string) string {
+	return fmt.Sprintf("parking/lot/%d/booth/%d/channel/%d/%s", route.ParkingLotID, route.BoothID, route.ChannelID, suffix)
+}
+
+func writeJSON(w http.ResponseWriter, status int, value interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(value)
+}
+
+func atomicWrite(path string, data []byte) error {
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, data, 0600); err != nil {
+		return err
+	}
+	return os.Rename(tmp, path)
+}
+
+func portOf(addr string) string {
+	_, port, err := net.SplitHostPort(addr)
+	if err != nil {
+		return "18443"
+	}
+	return port
+}
+
+func loadOrCreateCertificate(dir string) ([]byte, []byte, string, error) {
+	certPath, keyPath := filepath.Join(dir, "device.crt.pem"), filepath.Join(dir, "device.key.pem")
+	if certPEM, err := os.ReadFile(certPath); err == nil {
+		if keyPEM, keyErr := os.ReadFile(keyPath); keyErr == nil {
+			fp, fpErr := certificateFingerprint(certPEM)
+			return certPEM, keyPEM, fp, fpErr
+		}
+	}
+	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, nil, "", err
+	}
+	serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
+	now := time.Now()
+	template := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: deviceID}, NotBefore: now.Add(-time.Minute), NotAfter: now.AddDate(10, 0, 0), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, DNSNames: []string{"localhost"}}
+	for _, ip := range localIPs() {
+		template.IPAddresses = append(template.IPAddresses, ip)
+	}
+	der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
+	if err != nil {
+		return nil, nil, "", err
+	}
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyBytes, _ := x509.MarshalPKCS8PrivateKey(privateKey)
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
+	if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
+		return nil, nil, "", err
+	}
+	if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
+		return nil, nil, "", err
+	}
+	hash := sha256.Sum256(der)
+	return certPEM, keyPEM, "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:])), nil
+}
+
+func certificateFingerprint(certPEM []byte) (string, error) {
+	block, _ := pem.Decode(certPEM)
+	if block == nil {
+		return "", fmt.Errorf("invalid certificate PEM")
+	}
+	hash := sha256.Sum256(block.Bytes)
+	return "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:])), nil
+}
+
+func loadOrCreateSigningKey(dir string) (ed25519.PublicKey, ed25519.PrivateKey, error) {
+	path := filepath.Join(dir, "signing-key.bin")
+	if raw, err := os.ReadFile(path); err == nil && len(raw) == ed25519.PrivateKeySize {
+		privateKey := ed25519.PrivateKey(raw)
+		return privateKey.Public().(ed25519.PublicKey), privateKey, nil
+	}
+	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, nil, err
+	}
+	if err := os.WriteFile(path, privateKey, 0600); err != nil {
+		return nil, nil, err
+	}
+	return publicKey, privateKey, nil
+}
+
+func localIPs() []net.IP {
+	var result []net.IP
+	interfaces, _ := net.Interfaces()
+	for _, iface := range interfaces {
+		addrs, _ := iface.Addrs()
+		for _, addr := range addrs {
+			var ip net.IP
+			switch value := addr.(type) {
+			case *net.IPNet:
+				ip = value.IP
+			case *net.IPAddr:
+				ip = value.IP
+			}
+			if ip != nil && ip.To4() != nil && !ip.IsLoopback() {
+				result = append(result, ip.To4())
+			}
+		}
+	}
+	return result
+}

+ 1 - 0
cmd_backend_only/main.go

@@ -18,6 +18,7 @@ func main() {
 		initialize.SeedSystemData()
 	}
 	initialize.InitGateController()
+	initialize.InitDeviceBus()
 
 	// 只启动 Gin 后端,不启动 Wails
 	core.RunWindowsServer()

+ 18 - 1
config.yaml

@@ -34,6 +34,23 @@ cors:
           allow-headers: content-type
           expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
           allow-credentials: true
+mqtt:
+    enabled: true
+    embed-broker: true
+    broker-listen: ":1883"
+    broker: tcp://127.0.0.1:1883
+    advertised-host: "192.168.110.164"
+    advertised-port: 1883
+    # 内嵌 Broker 当前仅监听普通 TCP;启用外部 TLS Broker 时再改为 true
+    tls-enabled: false
+    client-id: smart-parking
+    username: ""
+    password: ""
+camera:
+    conversion-mode: system
+    ffmpeg-path: D:\迅雷下载\ffmpeg-9.0.1-full_build\ffmpeg-9.0.1-full_build\bin\ffmpeg.exe
+    mjpeg-fps: 8
+    jpeg-quality: 5
 db-list:
     - type: ""
       alias-name: ""
@@ -188,7 +205,7 @@ system:
     use-multipoint: false
     use-redis: false
     use-mongo: false
-    gate-simulator: true # 本地无硬件联调;生产环境必须关闭
+    gate-simulator: false # 本地无硬件联调;生产环境必须关闭
     debug-routes: false # 默认不注册票机和通道调试接口
 tencent-cos:
     bucket: xxxxx-10005608

+ 286 - 0
doc/多岗亭改造方案.md

@@ -0,0 +1,286 @@
+# 多岗亭停车场改造方案(Smart Parking)
+
+> 状态:待实施
+> 适用范围:`lc_garage/` 后端 `internal/`(旧分层 + `modules/` 新模块)与前端 `frontend/`
+
+## 1. 改造目标与原则
+
+**目标**:让系统从「单岗亭/单区域记账」升级为「多岗亭、多区域、分岗亭核算」的架构,贯通「岗亭」这一维度到支付、交接班、报表、权限四条主线。
+
+**原则**:
+
+- 不推翻现有 `internal/` 旧分层 + `internal/modules/` 新模块的混合架构,以**新增字段/新表 + 渐进改造**为主。
+- 所有新增维度一律以「快照 + 冗余外键」方式落库,保证历史数据可读、报表稳定。
+- 改造按 P0→P3 分阶段交付,每阶段可独立上线、可独立验证。
+
+---
+
+## 2. 数据模型改造(核心)
+
+### 2.1 `payment_record` 增加岗亭/通道/区域维度
+
+```go
+// internal/dao/payment_record.go 新增字段
+type PaymentRecord struct {
+    // ...原有字段...
+    BoothID      uint   `gorm:"index" json:"booth_id"`        // 收款岗亭ID(快照)
+    BoothName    string `gorm:"size:100" json:"booth_name"`   // 岗亭名称快照
+    ChannelID    uint   `gorm:"index" json:"channel_id"`      // 收款通道ID(快照)
+    ParkingLotID uint   `gorm:"index" json:"parking_lot_id"`  // 停车场ID(快照)
+}
+```
+
+**写入时机**:`ProcessPaymentTxAtEntry`、`ensurePrepaidPaymentTx`、月卡/续费的 `PaymentRecord` 创建处,统一由调用方传入岗亭上下文(见 §4)。
+
+### 2.2 `shift_record` 增加岗亭维度
+
+```go
+type ShiftRecord struct {
+    // ...原有字段...
+    BoothID      uint   `gorm:"index" json:"booth_id"`
+    BoothName    string `gorm:"size:100" json:"booth_name"`
+    ParkingLotID uint   `gorm:"index" json:"parking_lot_id"`
+}
+```
+
+- `Start`/`End` 改为按 `(BoothID, OperatorID)` 约束,禁止同一岗亭同时两个当班、同一操作员跨岗亭同时当班。
+- 交接班对账 SQL 改为 `WHERE booth_id=? AND operator_id=?`。
+
+### 2.3 `fee_config` 增加区域维度(区域差异化定价)
+
+```go
+type FeeConfig struct {
+    // ...原有字段...
+    ParkingLotID uint `gorm:"index;default:0" json:"parking_lot_id"` // 0 = 全局默认费率
+}
+```
+
+- 查重约束从「一车型一配置」放宽为「一车型 + 一区域一配置」,`ParkingLotID=0` 作为缺省兜底。
+- `CalculateFee(vehicle, stayTime, parkingLotID)`:优先命中 `(vehicle_type_id, parking_lot_id)`,未命中回退 `(vehicle_type_id, 0)`。
+
+### 2.4 月卡区域化
+
+新增关联表(而非直接在 monthly_card 加字段,支持一张卡绑定多区域):
+
+```go
+// internal/dao/monthly_card_lot.go
+type MonthlyCardLot struct {
+    global.GVA_MODEL
+    MonthlyCardID uint `gorm:"index" json:"monthly_card_id"`
+    ParkingLotID  uint `gorm:"index" json:"parking_lot_id"`
+}
+```
+
+- `buildEntryAccessProfileTx` 与 `CalculateFee` 中月卡免费判断,改为「月卡有效 且 当前区域在卡绑定范围内」。
+
+### 2.5 新增「操作员-岗亭」绑定模型
+
+```go
+// internal/dao/booth_operator.go
+type BoothOperator struct {
+    global.GVA_MODEL
+    UserID       uint `gorm:"index" json:"user_id"`        // 对应 sys_users.id
+    BoothID      uint `gorm:"index" json:"booth_id"`       // 0 = 允许区域下全部岗亭
+    ParkingLotID uint `gorm:"index" json:"parking_lot_id"`
+}
+```
+
+- 支持「绑定到具体岗亭」或「绑定到区域(该区域下所有岗亭)」,`BoothID=0` 表示区域级授权。
+
+### 2.6 通道事件持久化
+
+```go
+// internal/dao/passage_event.go
+type PassageEvent struct {
+    global.GVA_MODEL
+    DeviceCode   string `gorm:"index"`
+    RFIDTag      string
+    PlateNumber  string
+    Direction    string
+    ChannelID    uint `gorm:"index"`
+    BoothID      uint `gorm:"index"`
+    ParkingLotID uint `gorm:"index"`
+    Status       string
+    Message      string
+    Fee          float64
+    StayTime     int64
+    EventAt      time.Time `gorm:"index"`
+}
+```
+
+- 保留内存队列做实时推送(配合 `plugin/ws`),但**每次 Push 同时落库**,`GetChannelEvents` 支持按 `booth_id/parking_lot_id` 过滤,重启后可回放。
+
+---
+
+## 3. 权限模型改造
+
+| 角色 | 现有 | 改造后 |
+|------|------|--------|
+| 管理员(888) | 全量 | 全量 + 可配置岗亭绑定管理 |
+| 操作员(618) | 停车管理+报表+统计 | **限定可见/可操作的岗亭与通道** |
+
+改造点:
+
+1. `ListGateDevices`(`internal/service/parking/gate.go`)按当前登录用户过滤:查询 `BoothOperator`,只返回授权岗亭/区域下的设备。
+2. `report`、`shift`、`payment` 查询接口统一注入岗亭作用域(数据权限),操作员只能看自己岗亭的数据,管理员可看全部。
+3. 交接班 `start/end` 校验操作员是否有该岗亭的绑定授权。
+
+---
+
+## 4. 服务层改造(关键调用链)
+
+### 4.1 岗亭上下文贯穿支付链
+
+定义统一上下文:
+
+```go
+// internal/model/common/passage.go 扩展
+type BoothContext struct {
+    BoothID      uint
+    BoothName    string
+    ChannelID    uint
+    ParkingLotID uint
+}
+```
+
+- `VehicleEntry` / `ExitConfirm` 的 Request 增加 `BoothContext`,入场时从 `Channel → Booth → ParkingLot` 派生并快照到 `vehicle_record`(可选冗余 `entry_booth_id`)。
+- `ProcessPaymentTxAtEntry`、`ensurePrepaidPaymentTx`、月卡/续费支付流水写入时,把 `BoothContext` 写入 `payment_record`。
+- `handleEntry`/`handleExit`(`internal/service/parking/passage.go`)在调用 `vehicleSvc` 前先根据 `device.Channel.Booth` 组装 `BoothContext`。
+
+### 4.2 交接班改造
+
+```
+Start(operatorID, boothID, startingCash)
+  ├─ 校验操作员对 boothID 有授权
+  ├─ 校验该 boothID 无 active 班次
+  └─ 写入 shift_record(含 booth 快照)
+
+End(operatorID, boothID, actualCash, remark)
+  ├─ collected = SUM(amount) WHERE payment_method=cash
+  │              AND booth_id=boothID AND operator_id=operatorID
+  │              AND paid_at BETWEEN 当班区间
+  └─ 计算 expected/diff
+```
+
+### 4.3 计费改造
+
+```go
+func CalculateFee(vehicle *dao.Vehicle, stayTime int64, parkingLotID uint) (float64, error)
+```
+
+- 跨区域出场规则:**默认按入场区域费率计费**(与「释放入场区域车位」的现有语义一致);若后续需要,再引入「出场区域费率」配置开关。
+
+### 4.4 报表改造
+
+`RevenueReport` 增加维度,SQL 支持 `GROUP BY` 组合:
+
+```go
+type RevenueQuery struct {
+    StartDate    string
+    EndDate      string
+    ParkingLotID uint
+    BoothID      uint   // 新增
+    OperatorID   uint   // 新增
+    GroupBy      string // lot | booth | operator | day
+}
+```
+
+新增报表接口:
+
+- 岗亭营收报表(现金/免密/POS/扫码/退款,按岗亭聚合)
+- 班次对账报表(关联 shift_record 与 payment_record)
+- 月卡办理/续费/退款报表
+
+---
+
+## 5. 前端改造点
+
+| 页面 | 改动 |
+|------|------|
+| `entryExit.vue` 工作台 | ① 顶部增加「当前岗亭」标识与切换(受权限过滤);② `getGateDevices` 只返回本岗亭/授权设备;③ 通道事件面板按岗亭过滤 |
+| `shiftRecord.vue` | 交接班增加「岗亭」选择,列表按岗亭筛选 |
+| `paymentRecord.vue` / `revenue.vue` | 增加「岗亭」「操作员」筛选与聚合维度 |
+| `parkingInfo/booth.vue` | 岗亭下挂「授权操作员」配置页签 |
+| `pricing.vue` | 费率配置增加「停车场区域」维度 |
+| `monthlyCard.vue` | 月卡办理增加「适用区域」多选 |
+| 新增 `incident.vue` | 独立异常处置工作台(当前仅复用 sheet.vue) |
+
+---
+
+## 6. 缺陷修复清单(随改造一并落地)
+
+| # | 位置 | 修复内容 |
+|---|------|---------|
+| F1 | `parking/booth.go:61` | 读写器区域更新主键 `channel.ID` → `reader.ID` |
+| F2 | `parking/channel.go:26` | 岗亭不存在时返回错误,禁止静默 `ParkingLotID=0` |
+| F3 | `parking/channel.go:61` | 改 `BoothID` 时同步刷新 `ParkingLotID` |
+| F4 | `parking/channel.go`/`booth.go` | 删除前校验:岗亭有通道、通道有读写器/在场会话时禁止删除 |
+| F5 | `parking/passage.go:249-251` | 修复 UHF 免密放行漏洞:`fee>0` 且非白名单时(无论临时/注册)禁止 `free` |
+| F6 | `monthly/service/service.go:137` | 退卡写负向退款 `payment_record`(计入账务与交接班) |
+| F7 | `vehicle.go GetInParkVehicles` | join 改为 `rfid_tag` 兜底,避免无牌/RFID 临时车信息丢失 |
+| F8 | `passage.go` 内存 map | 重复入场判断以 DB `ResolveActiveSession` 为唯一权威,内存 map 仅作防抖 |
+
+---
+
+## 7. 数据迁移方案
+
+采用 GORM `AutoMigrate` + 显式迁移步骤:
+
+1. 新增字段 `payment_record.booth_id/channel_id/parking_lot_id` 等,先允许为空,历史数据留空(报表对空值归入「未归类」)。
+2. 新增表:`monthly_card_lot`、`booth_operator`、`passage_event`、`shift_record` 新字段。
+3. **回填脚本**:对历史 `payment_record`,通过 `record_id → vehicle_record → entry_channel_id → channel.booth_id` 回填 `BoothID/ParkingLotID`;对历史 `shift_record`,若无岗亭信息则标 `BoothID=0` 并提示人工补录。
+4. 迁移逻辑放入 `internal/initialize/` 下新增 `migrate_booth.go`,在 `seed`/启动流程中执行,保证幂等。
+
+---
+
+## 8. 分阶段实施计划
+
+### 阶段 P0(骨架,1 个迭代)
+
+- 2.1 / 2.2 数据模型 + 4.2 交接班岗亭化 + 4.4 报表岗亭维度
+- F1~F4 数据一致性 bug 修复
+- 验证:能按岗亭出营收报表、按岗亭交接班对账
+
+### 阶段 P1(区域化,1 个迭代)
+
+- 2.3 区域费率 + 2.4 月卡区域化 + 4.3 计费改造
+- F5、F6 漏洞修复
+- 验证:不同区域不同费率、月卡限区生效
+
+### 阶段 P2(权限与设备,1 个迭代)
+
+- 2.5 操作员-岗亭绑定 + 3 权限改造 + 5 工作台岗亭隔离
+- 2.6 通道事件持久化
+- 验证:操作员只见本岗亭设备与数据
+
+### 阶段 P3(补齐与体验,1 个迭代)
+
+- 摄像头/车牌识别模块
+- 异常独立工作台 + 实时告警
+- 报表体系补全(车型/支付方式/退款/月卡)
+
+---
+
+## 9. 验收标准
+
+1. 一个操作员在岗亭 A 收款、交接班时,系统能精确统计岗亭 A 本班次的现金应收/实收/差异。
+2. 操作员登录后只能看到被授权岗亭的通道设备与数据,无法跨岗亭开闸/收费。
+3. 同一车辆类型在不同区域可按不同费率计费,跨区出场按入场区域正确计费并释放入场区域车位。
+4. 月卡只在绑定区域内免费,满位准入按区域校验。
+5. UHF 自动通道对有费车辆(含注册车辆)不再免密放行。
+6. 月卡退卡产生负向流水,可被报表与交接班捕获。
+7. 删除岗亭/通道前有引用校验,不存在悬空通道/设备。
+8. 通道事件重启后可回放,且按岗亭隔离。
+
+---
+
+## 10. 设备接入层(摄像头 / 道闸通信)
+
+结论:**MQTT 做消息总线(识别事件 / 状态 / 在线心跳 / 独立道闸指令),HTTP 传图片与兜底只支持 HTTP 回调的设备,继电器/串口直连做挂在读写器上的道闸开闸。**
+
+- Topic 按 `parking/lot/{lot}/booth/{booth}/channel/{ch}/...` 分区,直接呼应多岗亭维度。
+- `MQTTGateController` 实现现有 `GateController` 接口(`OpenGate/CloseGate/IsGateConnected`),经 `SetGateController` 注入,与 `SimulatedGateController` 并存。
+- `DeviceBus` 订阅相机识别事件,统一桥接 `PassageService.HandlePassage` + `uhf.PushChannelEvent` + `incident.device_offline` + `plugin/ws`。
+- 道闸 LWT 遗嘱消息对接既有 `incident` 离线异常去重逻辑。
+
+完整设计(Topic 表、JSON schema、`mqtt_gate_controller.go` 与 `device_bus.go` 代码草案、配置与装配)见:**`doc/设备接入层设计.md`**。

+ 52 - 0
doc/摄像头系统侧转换实施说明.md

@@ -0,0 +1,52 @@
+# 摄像头系统侧转换实施说明
+
+当前版本由管理系统负责把边缘端的原始视频流转换为浏览器可播放的 MJPEG,后续可以只改配置切换为边缘端转换。
+
+## 1. 系统侧转换
+
+在 `camera` 表中配置:
+
+| 字段 | 示例 | 说明 |
+| --- | --- | --- |
+| `stream_protocol` | `system-mjpeg` | 使用系统侧 FFmpeg 转换 |
+| `source_url` | `rtsp://user:pass@192.168.1.50:554/live` | 原始 RTSP/HTTP 地址,仅后端使用 |
+| `stream_url` | 空 | 系统侧模式下为空即可 |
+| `is_active` | `true` | 启用摄像头 |
+
+管理端调用 `GET /api/parking/camera/streams` 时,会返回短期令牌地址,例如:
+
+```text
+/api/parking/camera/stream/CAM-IN-01/<token>
+```
+
+页面使用该地址显示实时画面。后端启动 FFmpeg,参数为 RTSP TCP 拉流、限帧、MJPEG 输出;浏览器断开后进程自动退出。
+
+系统配置位于 `config.yaml`:
+
+```yaml
+camera:
+  conversion-mode: system
+  ffmpeg-path: ffmpeg
+  mjpeg-fps: 8
+  jpeg-quality: 5
+```
+
+`ffmpeg-path` 可以填写 FFmpeg 的绝对路径。生产环境应限制摄像头源地址和数据库管理权限,避免把带密码的 `source_url` 返回前端。
+
+## 2. 切换到边缘端转换
+
+边缘端准备好 WHEP/HLS 地址后,将:
+
+1. `camera.conversion-mode` 改为 `edge`;
+2. 将摄像头的 `stream_protocol` 改为 `webrtc` 或 `hls`;
+3. 将边缘端播放地址写入 `stream_url`;
+4. 重启后端或重新打开进出场操作页面。
+
+页面和通道绑定关系不变,MQTT 道闸控制、车辆识别和图片 HTTP 上传也不受影响。
+
+## 3. 故障定位
+
+- 下拉框没有摄像头:检查摄像头 `is_active=true`、`channel_id` 与道闸通道一致。
+- 画面显示“视频连接失败”:检查 FFmpeg 是否在系统 PATH 中,或配置绝对路径。
+- FFmpeg 启动失败:检查 `source_url` 是否可从管理系统主机访问,RTSP 用户名密码是否正确。
+- 画面延迟较高:降低 `mjpeg-fps` 或将转换切换到边缘端 WHEP。

+ 412 - 0
doc/系统端设备与摄像头对接配置说明.md

@@ -0,0 +1,412 @@
+# 系统端设备与摄像头对接配置说明
+
+本文是当前项目的部署配置手册,面向管理系统部署人员和边缘端联调人员。内容覆盖:
+
+- 自动 UDP 发现和手工 IP 接入;
+- MQTT 道闸控制、状态上报和图片 HTTP 上传;
+- 系统端将 RTSP/HTTP 摄像头流转换为浏览器可播放的 MJPEG;
+- 数据库、Windows 防火墙、启动、验证和故障排查。
+
+边缘端协议字段和 Topic 的完整定义见 [`doc/边缘端自动与手工接入对接指南.md`](边缘端自动与手工接入对接指南.md)。本文只说明管理系统需要改哪些文件、填什么值。
+
+## 1. 配置文件位置
+
+### 1.1 开发运行
+
+开发运行使用项目根目录的 `config.yaml`:
+
+```text
+D:\lq\Smart Parking\lc_garage\config.yaml
+```
+
+执行 `wails dev` 或 `go run` 时,程序优先从当前工作目录读取该文件。
+
+### 1.2 生产运行
+
+生产程序使用启动参数 `-c` 指定配置;未指定时,程序从 Gin 运行模式对应的配置文件读取,并在当前目录找不到时尝试可执行文件目录。因此生产部署必须把配置文件放在 exe 同目录:
+
+```text
+build\bin\smart-parking.exe
+build\bin\config.yaml
+```
+
+也可以明确指定:
+
+```powershell
+.\build\bin\smart-parking.exe -c .\build\bin\config.yaml
+```
+
+`internal/config.yaml` 是旧模板/服务端兼容配置,不是 Wails 桌面程序默认编辑的文件。除非启动参数明确指定它,否则不要在两个文件之间来回修改。
+
+### 1.3 代码配置结构
+
+`internal/config/mqtt.go`、`internal/config/camera.go` 只是 YAML 映射结构,通常不需要填写。只有新增配置字段或协议变化时才修改 Go 代码;日常部署只改 `config.yaml`。
+
+## 2. 可直接复制的基础配置
+
+下面配置适用于“管理系统和内嵌 MQTT Broker 在同一台 Windows 主机、边缘设备通过局域网连接、摄像头由系统端转换”的场景。把 `192.168.110.164` 换成边缘设备实际能访问的管理主机 IPv4 地址。
+
+```yaml
+system:
+    db-type: sqlite
+    oss-type: local
+    router-prefix: ""
+    addr: 8888
+    gate-simulator: false
+    debug-routes: false
+
+sqlite:
+    db-name: lc_garage
+    path: ""
+
+mqtt:
+    enabled: true
+    embed-broker: true
+    broker-listen: ":1883"
+    broker: tcp://127.0.0.1:1883
+    advertised-host: "192.168.110.164"
+    advertised-port: 1883
+    tls-enabled: false
+    client-id: smart-parking
+    username: ""
+    password: ""
+
+camera:
+    conversion-mode: system
+    ffmpeg-path: ffmpeg
+    mjpeg-fps: 8
+    jpeg-quality: 5
+
+local:
+    path: uploads/file
+    store-path: uploads/file
+```
+
+说明:
+
+| 配置 | 当前实现中的含义 |
+| --- | --- |
+| `system.addr` | 管理系统 HTTP 端口;图片上传地址会使用该端口。 |
+| `mqtt.broker-listen` | 内嵌 Broker 监听地址。`:1883` 表示监听本机所有 IPv4 接口。 |
+| `mqtt.broker` | 管理系统自身连接 Broker 的地址;内嵌 Broker 时保留 `127.0.0.1`。 |
+| `mqtt.advertised-host` | 下发给边缘端的 Broker 主机地址。内嵌 Broker 时必须是管理主机局域网 IP,不能是 `127.0.0.1`、`localhost` 或 `0.0.0.0`。 |
+| `mqtt.advertised-port` | 边缘端连接 Broker 的端口;必须与 `broker-listen` 的端口一致。 |
+| `mqtt.tls-enabled` | 下发给设备的 MQTT TLS 标志。当前内嵌 Broker 没有 TLS listener,因此内嵌模式必须填 `false`。 |
+| `mqtt.username/password` | 当前内嵌 Broker 允许匿名连接,可留空。若改用带认证的外部 Broker,系统连接和边缘端凭据必须由两端按同一版本协议配置。 |
+| `camera.conversion-mode` | `system` 表示管理端调用 FFmpeg;`edge` 表示使用数据库中的边缘播放地址。 |
+
+## 3. MQTT Broker 配置
+
+### 3.1 内嵌 Broker(当前默认方案)
+
+只需要在 `config.yaml` 填:
+
+```yaml
+mqtt:
+    enabled: true
+    embed-broker: true
+    broker-listen: ":1883"
+    broker: tcp://127.0.0.1:1883
+    advertised-host: "192.168.110.164"
+    advertised-port: 1883
+    tls-enabled: false
+```
+
+设备收到配置后连接:
+
+```text
+tcp://192.168.110.164:1883
+```
+
+`advertised-host` 的填写规则:
+
+1. 在管理主机执行 `ipconfig`,选择与边缘设备同一网络、且边缘设备可路由到的 IPv4 地址;
+2. 不要填写浏览器地址栏中的 `localhost`;
+3. 不要填写设备自身 IP;
+4. 设备跨 VLAN 时填写管理网段的可达地址,并在三层防火墙放行 TCP 1883;
+5. 管理主机 IP 变化时必须同步修改该值并重启/重新下发设备配置。
+
+### 3.2 外部 Broker
+
+外部 Broker 场景下,`broker` 是管理系统连接地址,`advertised-host/port` 是边缘端连接地址,两者可以相同,也可以不同:
+
+```yaml
+mqtt:
+    enabled: true
+    embed-broker: false
+    broker-listen: ":1883"       # 不启动内嵌 Broker 时该项不生效
+    broker: tcp://192.168.110.20:1883
+    advertised-host: "192.168.110.20"
+    advertised-port: 1883
+    tls-enabled: false
+    client-id: smart-parking
+    username: parking-system
+    password: "替换为外部 Broker 密码"
+```
+
+外部 Broker 管理员还必须配置:
+
+- 允许管理主机和边缘设备访问对应 TCP 端口;
+- 为系统客户端和每台设备配置唯一 Client ID;
+- 授权系统订阅 `parking/lot/+/booth/+/channel/+/gate/state`、`gate/lwt`、`gate/cmd/ack` 和 `camera/event`;
+- 授权每台设备只发布自己的 `gate/state`、`gate/lwt`、`gate/cmd/ack`、`camera/event`,只订阅自己的 `gate/cmd`;
+- 若 Broker 使用 TLS,应使用外部 Broker 的 TLS 地址和证书策略。当前项目的内嵌 Broker 未配置证书,不能仅把 `tls-enabled` 改成 `true` 就获得 TLS。
+
+### 3.3 防火墙
+
+在管理主机以管理员 PowerShell 执行(仅在确认端口未被其他服务占用时):
+
+```powershell
+New-NetFirewallRule -DisplayName "Smart Parking HTTP 8888" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8888
+New-NetFirewallRule -DisplayName "Smart Parking MQTT 1883" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 1883
+```
+
+自动发现不需要管理系统固定 UDP 监听端口。系统从临时 UDP 端口向边缘端 `UDP/31001` 发送广播,边缘端响应到请求源 IP/源端口。跨 VLAN 或无线隔离时不要依赖该流程,改用手工 IP。
+
+验证监听:
+
+```powershell
+Get-NetTCPConnection -LocalPort 8888,1883 -State Listen
+Test-NetConnection 192.168.110.164 -Port 1883
+```
+
+## 4. 自动和手工接入时系统端需要做什么
+
+### 4.1 自动 UDP 发现
+
+系统端没有额外的 YAML 开关。管理员在“设备接入”页面点击自动发现,系统会:
+
+1. 向每个启用网卡的 IPv4 定向广播地址发送 `discovery.request.v1`,目标端口固定 `31001`,发送 3 次;
+2. 接收并校验 `request_id`、`nonce`、设备永久 ID、设备签名、公钥指纹、响应来源 IP 和 HTTPS 配置地址;
+3. 通过 HTTPS `GET /api/v1/identity` 再次读取设备身份和服务器证书指纹;
+4. 管理员确认指纹/配对码后,选择停车场、岗亭、通道和方向;
+5. 通过 HTTPS `POST /api/v1/provision` 下发 MQTT、业务路由和图片上传地址;
+6. 等待设备发布 retained `gate.state.v1`,收到后才显示“在线”。
+
+自动发现响应被丢弃时,重点看后端日志中的 `收到 UDP 发现响应` 和 `丢弃 UDP 发现响应`,常见原因是公钥指纹不匹配、报文来源 IP 与响应 `ip` 不一致、签名错误或 `config_url` 不是 HTTPS。
+
+### 4.2 手工 IP 接入
+
+管理员在页面输入边缘设备 IP 和 HTTPS 端口(端口为空时后端使用 `8443`)。系统端请求:
+
+```text
+GET https://<设备IP>:<端口>/api/v1/identity
+POST https://<设备IP>:<端口>/api/v1/pair       # pairing_required=true 时
+POST https://<设备IP>:<端口>/api/v1/provision
+```
+
+手工 IP 是正式接入路径,适用于跨 VLAN、UDP 广播被禁、无线隔离、VPN 和不支持发现协议的设备。系统拒绝 `127.0.0.1`、`localhost`、`0.0.0.0`、IPv4 广播、IPv4/IPv6 组播、主机名和非法端口。
+
+### 4.3 管理 API(用于联调)
+
+以下 API 均在 JWT、Casbin 和操作审计保护下,只有管理员权限(authority `888`)可调用。前端开发服务器地址为 `http://localhost:5173` 时,前缀仍是 `/api`:
+
+| 方法 | URL | 用途 |
+| --- | --- | --- |
+| `POST` | `/api/device-provisioning/discover` | 发起自动发现,可选 `{"broadcast_address":"192.168.110.255"}` |
+| `GET` | `/api/device-provisioning/discoveries` | 查询 10 分钟内的发现结果 |
+| `POST` | `/api/device-provisioning/manual/identity` | 手工 IP 读取身份,Body `{"ip":"192.168.110.218","port":8443}` |
+| `POST` | `/api/device-provisioning/{device_id}/identity` | 读取自动发现候选的 HTTPS 身份 |
+| `POST` | `/api/device-provisioning/{device_id}/verify` | Body `{"fingerprint":"SHA256:...","pairing_code":"..."}` |
+| `POST` | `/api/device-provisioning/{device_id}/provision` | 绑定并下发配置 |
+| `GET` | `/api/device-provisioning/{device_id}/status` | 查询 `provisioned/online/failed` 等状态 |
+
+注意:`/api/device-provisioning/discoveries` 返回 404 时,通常是后端未重启、请求打到了错误端口,或请求 URL 少了 `/api` 代理前缀;先访问 `http://127.0.0.1:8888/health` 验证后端。
+
+## 5. 图片 HTTP 上传配置
+
+设备绑定成功后,系统下发的图片地址由 `mqtt.advertised-host` 和 `system.addr` 生成:
+
+```text
+http://<advertised-host>:<system.addr>/device-images/upload
+```
+
+当前版本按用户要求不使用上传令牌,依赖内网隔离和设备绑定校验。边缘端必须使用 `multipart/form-data`,字段为 `device_code`、`image_id`、可选 `event_id`/`direction` 和文件字段 `image`。单张图片限制 8 MB;图片上传成功后再发布 MQTT `camera.event.v1`,事件只引用 `image_id`。
+
+因此必须放行管理主机 TCP `8888`,且 `mqtt.advertised-host` 不能填 `127.0.0.1`。图片上传路由是 `/device-images/upload`,不是 `/api/device-images/upload`。
+
+## 6. 摄像头配置(系统端转换)
+
+### 6.1 `config.yaml`
+
+```yaml
+camera:
+    conversion-mode: system
+    ffmpeg-path: C:\\ffmpeg\\bin\\ffmpeg.exe
+    mjpeg-fps: 8
+    jpeg-quality: 5
+```
+
+`ffmpeg-path` 可以填 `ffmpeg`(要求已加入 PATH),也可以填绝对路径。`mjpeg-fps` 建议 5-10;`jpeg-quality` 是 FFmpeg 的质量值,数值越小画质越好、带宽和 CPU 占用越高。
+
+### 6.2 安装和验证 FFmpeg
+
+```powershell
+ffmpeg -version
+Test-Path 'C:\ffmpeg\bin\ffmpeg.exe'
+```
+
+如果系统找不到 FFmpeg,把绝对路径写入 `ffmpeg-path`,修改后重启后端。摄像头原始地址必须能从管理主机访问,例如:
+
+```text
+rtsp://用户名:密码@192.168.1.50:554/live
+```
+
+### 6.3 `camera` 表字段
+
+摄像头表由启动时 `AutoMigrate` 自动创建,不需要手工建表。当前没有独立摄像头配置页面,使用 SQLite 客户端写入/更新记录:
+
+| 字段 | 填写要求 |
+| --- | --- |
+| `device_code` | 摄像头唯一编码,例如 `CAM-IN-01`。 |
+| `device_name` | 页面显示名称。 |
+| `channel_id` | 与摄像头对应的道闸通道 ID;摄像头、道闸必须是同一通道。 |
+| `ip_address`/`port` | 摄像头管理地址和 RTSP/HTTP 端口。 |
+| `username`/`password` | 原始流凭据,仅后端使用,不返回前端。 |
+| `stream_protocol` | 系统转换填 `system-mjpeg`;边缘转换填 `webrtc` 或 `hls`。 |
+| `source_url` | 系统转换时填 RTSP/HTTP 原始地址。 |
+| `stream_url` | 系统转换留空;边缘转换填 WHEP/HLS 播放地址。 |
+| `is_active` | `1` 启用,`0` 停用。 |
+| `status` | `online`/`offline`,仅用于页面状态显示。 |
+
+查询停车场、岗亭、通道 ID:
+
+```sql
+SELECT id, lot_code, lot_name FROM parking_lot WHERE deleted_at IS NULL;
+SELECT id, booth_code, booth_name, parking_lot_id FROM booth WHERE deleted_at IS NULL;
+SELECT id, channel_code, channel_name, parking_lot_id, booth_id, direction FROM channel WHERE deleted_at IS NULL;
+```
+
+插入一台系统转换摄像头(把 `8` 换成实际 `channel_id`):
+
+```sql
+INSERT INTO camera
+    (created_at, updated_at, device_code, device_name, channel_id, ip_address, port,
+     username, password, channel, status, is_active, stream_protocol, source_url,
+     stream_url, snapshot_url, description)
+VALUES
+    (datetime('now'), datetime('now'), 'CAM-IN-01', '入口摄像头', 8, '192.168.1.50', 554,
+     'admin', '替换为摄像头密码', 1, 'offline', 1, 'system-mjpeg',
+     'rtsp://admin:替换为摄像头密码@192.168.1.50:554/live', '', '', '系统端转换');
+```
+
+更新已有记录:
+
+```sql
+UPDATE camera
+SET channel_id = 8,
+    stream_protocol = 'system-mjpeg',
+    source_url = 'rtsp://admin:替换为摄像头密码@192.168.1.50:554/live',
+    stream_url = '',
+    is_active = 1,
+    updated_at = datetime('now')
+WHERE device_code = 'CAM-IN-01';
+```
+
+系统转换时,接口 `GET /api/parking/camera/streams` 返回短期播放地址:
+
+```text
+/api/parking/camera/stream/CAM-IN-01/<token>
+```
+
+浏览器以 `<img>` 显示 MJPEG;每次页面重新加载会获得新的短期令牌。后端断开浏览器连接后自动结束 FFmpeg 进程。
+
+### 6.4 切换边缘端转换
+
+```yaml
+camera:
+    conversion-mode: edge
+```
+
+然后更新数据库:
+
+```sql
+UPDATE camera
+SET stream_protocol = 'webrtc',
+    source_url = '',
+    stream_url = 'https://边缘端或媒体网关地址/实时播放接口',
+    updated_at = datetime('now')
+WHERE device_code = 'CAM-IN-01';
+```
+
+系统端当前不负责把 RTSP 转成 WHEP;在 `system` 模式下统一输出 MJPEG,未来切换到 `edge` 模式时由边缘端/媒体网关提供 WHEP 或 HLS 地址。
+
+## 7. SQLite 数据库位置和设备绑定
+
+默认 `sqlite.path` 为空,数据库实际位置为:
+
+```text
+C:\Users\<当前 Windows 用户>\AppData\Roaming\smart-parking\lc_garage.db
+```
+
+启动时会自动执行 `AutoMigrate`,包括 `uhf_reader`、`device_discoveries`、`device_images` 和 `camera` 表。
+
+检查已绑定设备:
+
+```powershell
+sqlite3 "$env:APPDATA\smart-parking\lc_garage.db" `
+  "SELECT id, device_code, device_id, connect_type, channel_id, provision_status, status, deleted_at FROM uhf_reader ORDER BY id;"
+```
+
+MQTT 设备必须满足:`connect_type='mqtt'`、`is_active=1`、`channel_id` 指向正确通道。`device_code` 为数据库全局唯一字段;软删除记录仍占用唯一索引,重新绑定同一设备时系统会恢复原记录,不要直接重复 `INSERT`。
+
+## 8. 启动、重启和验证
+
+项目已提供统一管理脚本:
+
+```powershell
+cd 'D:\lq\Smart Parking\lc_garage'
+.\scripts\manage-project.ps1 start -Mode split
+.\scripts\manage-project.ps1 status
+.\scripts\manage-project.ps1 restart -Mode split
+.\scripts\manage-project.ps1 stop
+```
+
+`split` 模式后端是 `8888`、前端是 `5173`;Wails 桌面开发模式使用:
+
+```powershell
+.\scripts\manage-project.ps1 start -Mode wails
+```
+
+基础验证:
+
+```powershell
+Invoke-WebRequest http://127.0.0.1:8888/health
+Test-NetConnection <advertised-host> -Port 1883
+```
+
+管理员登录后验证自动/手工接入 API,再查询状态接口。日志重点关注:
+
+- `内嵌 MQTT broker 已启动`、`MQTT 连接已恢复`;
+- `收到 UDP 发现响应`、`丢弃 UDP 发现响应`;
+- `HTTPS 身份读取失败`、`证书指纹不匹配`;
+- `设备配置下发失败`;
+- `边缘设备图片上传结果`、`边缘设备图片上传失败`;
+- `收到车辆识别 MQTT 事件`、`车辆识别事件图片引用校验成功`。
+
+## 9. 常见问题
+
+| 现象 | 检查项 |
+| --- | --- |
+| `advertised-host` 配置错误 | 必须是边缘端可达的单播 IP;修改后重启并重新下发。 |
+| 设备收到配置但 MQTT 不上线 | 检查 TCP 1883、防火墙、Broker 地址,以及 `tls-enabled` 是否与实际 listener 一致。内嵌 Broker 当前填 `false`。 |
+| 自动发现无结果 | 检查边缘端 UDP `31001`、Windows 网络类型/防火墙;跨 VLAN、无线隔离时使用手工 IP。 |
+| 日志提示公钥指纹不匹配 | `public_key_fingerprint` 必须是 `signing_public_key` 原始 32 字节 Ed25519 公钥的 SHA-256。 |
+| 日志提示 IP 与报文来源不一致 | UDP 响应 JSON 的 `ip` 必须等于实际发送报文的源 IP。 |
+| HTTPS 身份读取失败 | 检查设备 HTTPS 端口(默认 `8443`)、TLS 1.2+、设备路由和证书指纹。 |
+| 绑定时报 `UNIQUE constraint failed` | 用上面的 `Unscoped` 查询检查同 `device_code` 的软删除行;当前代码会恢复同一永久 `device_id` 的历史行,其他设备不能复用编码。 |
+| 图片上传 404 | 使用 `/device-images/upload`,不是 `/api/device-images/upload`;确认后端端口为 `8888` 且 `local.store-path`/对象存储可写。 |
+| 摄像头没有画面 | 检查 `is_active=1`、`channel_id`、FFmpeg 路径和 `source_url`;RTSP 地址必须能从管理主机访问。 |
+
+## 10. 部署前检查清单
+
+- [ ] 根目录或 exe 同目录的 `config.yaml` 已填写 MQTT、系统端口和摄像头模式。
+- [ ] `mqtt.advertised-host` 是边缘设备可达的管理主机或外部 Broker IP。
+- [ ] 内嵌 Broker 使用 `tls-enabled: false`;外部 TLS Broker 已由运维完成证书和监听配置。
+- [ ] TCP `8888`、内嵌 MQTT TCP `1883` 和设备 HTTPS `8443` 路由/防火墙已放行。
+- [ ] 自动发现设备监听 UDP `31001`;不满足网络条件时已准备手工 IP 接入。
+- [ ] 每个设备有稳定的永久 `device_id`、证书指纹和配对码。
+- [ ] 设备、道闸和摄像头绑定同一 `channel_id`。
+- [ ] 边缘端先上传图片,再发布带 `image_id` 的 MQTT 事件。
+- [ ] 设备收到 `provision` 后能连接 MQTT,并发布 retained `gate.state.v1`。
+- [ ] 已用日志和状态 API 验证“配置已保存”和“设备在线”两个阶段。

+ 561 - 0
doc/设备接入层设计.md

@@ -0,0 +1,561 @@
+# 设备接入层设计(摄像头 / 道闸 · MQTT + HTTP)
+
+> 状态:已落地(MQTT 客户端 + 内嵌 broker 均已实现并编译通过)
+> 落地源码:`internal/service/mqttbroker/broker.go`、`internal/service/parking/mqtt_gate_controller.go`、`internal/service/parking/routing_gate_controller.go`、`internal/service/devicebus/device_bus.go`、`internal/initialize/mqtt.go`
+> 配套文档:`doc/多岗亭改造方案.md`
+> 结论:**MQTT 做消息总线(识别事件 / 状态 / 在线心跳 / 独立道闸指令),HTTP 传图片与兜底只支持 HTTP 回调的设备,继电器/串口直连做挂在读写器上的道闸开闸。**
+
+---
+
+## 1. 总体架构
+
+```
+┌────────────┐  MQTT(event)  ┌────────────────────────────────────┐
+│ LPR 相机    │──────────────▶│  DeviceBus(订阅识别事件)          │
+│ (车牌识别)  │               │    │                               │
+└────────────┘               │    ├─ 构造 PassageRequest           │
+                             │    ├─ PassageService.HandlePassage  │──▶ 进场/出场 + 计费 + 数字票
+┌────────────┐  HTTP(图片)   │    ├─ PushChannelEvent + 落库        │──▶ 前端工作台(ws)
+│ 相机抓拍    │──────────────▶│    └─ 异常埋点(incident)            │
+└────────────┘               └────────────────────────────────────┘
+
+┌────────────┐  MQTT(state/LWT)  ┌──────────────────────────────┐
+│ 道闸控制器  │──────────────────▶│  MQTTGateController           │
+│ (独立网络)  │                   │    ├─ 订阅 state / ack / lwt   │
+│            │◀──────────────────│    ├─ IsGateConnected()        │
+│            │  MQTT(cmd, QoS1)  │    └─ 指令超时/失败 → incident  │
+└────────────┘                   └──────────────────────────────┘
+
+┌────────────┐  继电器/串口/TCP 直连(保持现状)
+│ UHF 读写器  │──────────────────▶ 现有 Reader.CloseRelay1/2 + GateController
+└────────────┘
+```
+
+- **MQTT Broker**:已实现内嵌 `mochi-mqtt`(`internal/service/mqttbroker`,随进程启动,无需额外进程);也可 `embed-broker=false` 连接外部 `mosquitto`。
+- **MQTT Client**:Go 侧用 `github.com/eclipse/paho.mqtt.golang`(事实标准,需新增依赖)。
+
+---
+
+## 2. Topic 规划(按岗亭/通道分区,呼应多岗亭改造)
+
+统一前缀 `parking/`,路由坐标 `lot/{lot_id}/booth/{booth_id}/channel/{channel_id}`。
+
+| 方向 | Topic | QoS | Retained | 用途 |
+|------|-------|-----|----------|------|
+| 相机 → 系统 | `parking/lot/{lot}/booth/{booth}/channel/{ch}/camera/event` | 1 | 否 | 车牌识别事件 |
+| 道闸 → 系统 | `parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/state` | 1 | **是** | 道闸状态(开/关/在线) |
+| 系统 → 道闸 | `parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/cmd` | 1 | 否 | 开/关闸指令 |
+| 道闸 → 系统 | `parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/cmd/ack` | 1 | 否 | 指令执行回执 |
+| 道闸 → 系统 | `parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/lwt` | 1 | 是 | 遗嘱消息(离线通知) |
+
+> 说明:`GateController` 接口只有 `deviceCode`,系统需在启动时从 DB 反查 `UHFReader → Channel → Booth → ParkingLot` 得到路由坐标(见 §4 `LoadRoutes`)。
+
+---
+
+## 3. 消息结构(JSON)
+
+### 3.1 识别事件(camera/event)
+
+```json
+{
+  "schema": "camera.event.v1",
+  "device_code": "CAM-A01",
+  "plate_number": "粤A12345",
+  "rfid_tag": "E28068940000500ABCDEF",
+  "direction": "in",
+  "ts": 1730000000,
+  "image_path": "/capture/1730000000_A01.jpg",
+  "confidence": 0.98
+}
+```
+
+### 3.2 道闸状态(gate/state,retained)
+
+```json
+{
+  "schema": "gate.state.v1",
+  "device_code": "GATE-A01",
+  "state": "open",
+  "ts": 1730000000
+}
+```
+
+`state` 取值:`open` / `closed` / `moving`。retained 消息保证系统重启后能立即恢复各道闸当前状态。
+
+### 3.3 开闸指令(gate/cmd)
+
+```json
+{
+  "schema": "gate.cmd.v1",
+  "cmd_id": "3f2a1b9c-...",
+  "device_code": "GATE-A01",
+  "action": "open",
+  "valid_time": 2
+}
+```
+
+### 3.4 指令回执(gate/cmd/ack)
+
+```json
+{
+  "schema": "gate.ack.v1",
+  "cmd_id": "3f2a1b9c-...",
+  "device_code": "GATE-A01",
+  "result": "success",
+  "error": ""
+}
+```
+
+---
+
+## 4. 代码草案 A:`internal/service/parking/mqtt_gate_controller.go`
+
+实现现有 `GateController` 接口,可经 `parking.SetGateController(...)` 注入,与当前 `SimulatedGateController` 并存。
+
+```go
+package parking
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"github.com/google/uuid"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+const (
+	mqttQoS        = 1
+	mqttCmdTimeout = 5 * time.Second
+)
+
+// gateTopicRoute 设备在 MQTT 主题树中的路由坐标。
+type gateTopicRoute struct {
+	DeviceCode string
+	LotID      uint
+	BoothID    uint
+	ChannelID  uint
+}
+
+func (r gateTopicRoute) cmdTopic() string {
+	return fmt.Sprintf("parking/lot/%d/booth/%d/channel/%d/gate/cmd", r.LotID, r.BoothID, r.ChannelID)
+}
+
+// MQTTGateController 通过 MQTT 下发道闸指令并等待设备 ack。
+type MQTTGateController struct {
+	client mqtt.Client
+
+	mu        sync.RWMutex
+	routes    map[string]gateTopicRoute // device_code -> route
+	gateState map[string]bool           // device_code -> 最近上报 online/open
+	pending   map[string]chan gateAck   // cmd_id -> ack channel
+}
+
+type gateCmd struct {
+	Schema     string `json:"schema"`
+	CmdID      string `json:"cmd_id"`
+	DeviceCode string `json:"device_code"`
+	Action     string `json:"action"`
+	ValidTime  byte   `json:"valid_time"`
+}
+
+type gateAck struct {
+	Schema     string `json:"schema"`
+	CmdID      string `json:"cmd_id"`
+	DeviceCode string `json:"device_code"`
+	Result     string `json:"result"` // success / failed
+	Error      string `json:"error"`
+}
+
+func NewMQTTGateController(client mqtt.Client) *MQTTGateController {
+	return &MQTTGateController{
+		client:    client,
+		routes:    make(map[string]gateTopicRoute),
+		gateState: make(map[string]bool),
+		pending:   make(map[string]chan gateAck),
+	}
+}
+
+// LoadRoutes 从 DB 反查 device_code -> lot/booth/channel,用于构造主题。
+func (c *MQTTGateController) LoadRoutes() error {
+	type row struct {
+		DeviceCode   string
+		ChannelID    uint
+		BoothID      uint
+		ParkingLotID uint
+	}
+	var rows []row
+	err := global.GVA_DB.Model(&dao.UHFReader{}).
+		Select("uhf_reader.device_code, uhf_reader.channel_id, channel.booth_id, channel.parking_lot_id").
+		Joins("LEFT JOIN channel ON channel.id = uhf_reader.channel_id").
+		Where("uhf_reader.channel_id > 0").
+		Scan(&rows).Error
+	if err != nil {
+		return err
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	for _, r := range rows {
+		c.routes[r.DeviceCode] = gateTopicRoute{
+			DeviceCode: r.DeviceCode, ChannelID: r.ChannelID,
+			BoothID: r.BoothID, LotID: r.ParkingLotID,
+		}
+	}
+	return nil
+}
+
+func (c *MQTTGateController) routeOf(deviceCode string) (gateTopicRoute, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	r, ok := c.routes[deviceCode]
+	return r, ok
+}
+
+// OpenGate 实现 GateController 接口。
+func (c *MQTTGateController) OpenGate(deviceCode string, validTime byte) error {
+	return c.run(deviceCode, "open", validTime)
+}
+
+// CloseGate 实现 GateController 接口。
+func (c *MQTTGateController) CloseGate(deviceCode string, validTime byte) error {
+	return c.run(deviceCode, "close", validTime)
+}
+
+func (c *MQTTGateController) run(deviceCode, action string, validTime byte) error {
+	route, ok := c.routeOf(deviceCode)
+	if !ok {
+		return fmt.Errorf("设备未配置 MQTT 路由: %s", deviceCode)
+	}
+	cmdID := uuid.NewString()
+	ackCh := make(chan gateAck, 1)
+	c.mu.Lock()
+	c.pending[cmdID] = ackCh
+	c.mu.Unlock()
+	defer func() {
+		c.mu.Lock()
+		delete(c.pending, cmdID)
+		c.mu.Unlock()
+	}()
+
+	payload, _ := json.Marshal(gateCmd{
+		Schema: "gate.cmd.v1", CmdID: cmdID, DeviceCode: deviceCode,
+		Action: action, ValidTime: validTime,
+	})
+	token := c.client.Publish(route.cmdTopic(), mqttQoS, false, payload)
+	if token.Wait() && token.Error() != nil {
+		return fmt.Errorf("MQTT 指令下发失败: %w", token.Error())
+	}
+
+	select {
+	case ack := <-ackCh:
+		if ack.Result != "success" {
+			return fmt.Errorf("道闸执行失败: %s", ack.Error)
+		}
+		return nil
+	case <-time.After(mqttCmdTimeout):
+		return errors.New("道闸指令超时未收到 ack")
+	}
+}
+
+// IsGateConnected 实现 GateController 接口(以最近 state/lwt 为准)。
+func (c *MQTTGateController) IsGateConnected(deviceCode string) bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.gateState[deviceCode]
+}
+
+// Subscribe 订阅 state / ack / lwt 主题。
+func (c *MQTTGateController) Subscribe() error {
+	for _, topic := range []string{
+		"parking/lot/+/booth/+/channel/+/gate/state",
+		"parking/lot/+/booth/+/channel/+/gate/cmd/ack",
+		"parking/lot/+/booth/+/channel/+/gate/lwt",
+	} {
+		token := c.client.Subscribe(topic, mqttQoS, c.onMessage)
+		if token.Wait() && token.Error() != nil {
+			return token.Error()
+		}
+	}
+	return nil
+}
+
+// onMessage 分派 state / ack / lwt。
+func (c *MQTTGateController) onMessage(_ mqtt.Client, msg mqtt.Message) {
+	payload := msg.Payload()
+	// 先尝试按 schema 解析;ack 携带 cmd_id,state/lwt 携带 device_code + state。
+	var probe struct {
+		Schema     string `json:"schema"`
+		CmdID      string `json:"cmd_id"`
+		DeviceCode string `json:"device_code"`
+		State      string `json:"state"`
+	}
+	_ = json.Unmarshal(payload, &probe)
+
+	switch probe.Schema {
+	case "gate.ack.v1":
+		var ack gateAck
+		if json.Unmarshal(payload, &ack) == nil {
+			c.mu.RLock()
+			ch := c.pending[ack.CmdID]
+			c.mu.RUnlock()
+			if ch != nil {
+				ch <- ack
+			}
+		}
+	case "gate.state.v1":
+		c.mu.Lock()
+		c.gateState[probe.DeviceCode] = probe.State == "open" || probe.State == "moving"
+		c.mu.Unlock()
+	case "gate.lwt.v1", "gate.lwt":
+		// 遗嘱消息:设备离线。state 取 "offline"。
+		c.mu.Lock()
+		c.gateState[probe.DeviceCode] = false
+		c.mu.Unlock()
+	}
+}
+```
+
+---
+
+## 5. 代码草案 B:`internal/service/parking/device_bus.go`
+
+订阅相机识别事件,统一桥接到现有进出场链路、通道事件、异常与前端推送。
+
+```go
+package parking
+
+import (
+	"encoding/json"
+	"fmt"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"wails-app/internal/global"
+	common "wails-app/internal/model/common"
+	incidentService "wails-app/internal/modules/incident/service"
+	"wails-app/internal/service/uhf"
+)
+
+// DeviceBus 订阅相机/道闸的 MQTT 消息,桥接到业务层。
+type DeviceBus struct {
+	passage    *PassageService
+	incident   *incidentService.IncidentService
+}
+
+func NewDeviceBus() *DeviceBus {
+	return &DeviceBus{
+		passage:  &PassageService{},
+		incident: incidentService.NewIncidentService(),
+	}
+}
+
+type cameraEvent struct {
+	Schema      string  `json:"schema"`
+	DeviceCode  string  `json:"device_code"`
+	PlateNumber string  `json:"plate_number"`
+	RFIDTag     string  `json:"rfid_tag"`
+	Direction   string  `json:"direction"`
+	TS          int64   `json:"ts"`
+	ImagePath   string  `json:"image_path"`
+	Confidence  float64 `json:"confidence"`
+}
+
+// Subscribe 订阅识别事件与道闸 LWT。
+func (b *DeviceBus) Subscribe(client mqtt.Client) error {
+	for _, topic := range []string{
+		"parking/lot/+/booth/+/channel/+/camera/event",
+		"parking/lot/+/booth/+/channel/+/gate/lwt",
+	} {
+		token := client.Subscribe(topic, 1, b.onMessage)
+		if token.Wait() && token.Error() != nil {
+			return token.Error()
+		}
+	}
+	return nil
+}
+
+func (b *DeviceBus) onMessage(_ mqtt.Client, msg mqtt.Message) {
+	payload := msg.Payload()
+	var probe struct {
+		Schema string `json:"schema"`
+	}
+	_ = json.Unmarshal(payload, &probe)
+
+	switch probe.Schema {
+	case "camera.event.v1":
+		b.onCameraEvent(payload)
+	case "gate.lwt.v1", "gate.lwt":
+		b.onGateOffline(payload)
+	}
+}
+
+func (b *DeviceBus) onCameraEvent(payload []byte) {
+	var ev cameraEvent
+	if err := json.Unmarshal(payload, &ev); err != nil {
+		return
+	}
+
+	req := common.PassageRequest{
+		DeviceCode:    ev.DeviceCode,
+		PlateNumber:   ev.PlateNumber,
+		RFIDTag:       ev.RFIDTag,
+		Direction:     ev.Direction,
+		Image:         ev.ImagePath,
+		TriggerSource: "camera",
+	}
+
+	// 复用统一进出场入口:黑名单 / 临时车规则 / 满位 / 计费 全在这里。
+	result, err := b.passage.HandlePassage(req)
+
+	status := "completed"
+	message := "识别通过"
+	fee := 0.0
+	stayTime := int64(0)
+	if err != nil {
+		status = "failed"
+		message = err.Error()
+	} else if result != nil {
+		fee = result.Fee
+		stayTime = result.StayTime
+		if result.Message != "" {
+			message = result.Message
+		}
+	}
+
+	// 1) 推送到现有通道事件队列(工作台实时刷新),并可按岗亭/区域过滤。
+	//    多岗亭改造后建议在此同时落库 passage_event。
+	uhf.PushChannelEvent(uhf.ChannelEvent{
+		DeviceCode:  ev.DeviceCode,
+		RFIDTag:     ev.RFIDTag,
+		PlateNumber: ev.PlateNumber,
+		Direction:   ev.Direction,
+		Timestamp:   ev.TS,
+		Status:      status,
+		Message:     message,
+		Fee:         fee,
+		StayTime:    stayTime,
+	})
+}
+
+// onGateOffline 道闸遗嘱消息 → 设备离线异常(对接 incident 既有去重)。
+func (b *DeviceBus) onGateOffline(payload []byte) {
+	var probe struct {
+		DeviceCode string `json:"device_code"`
+	}
+	if json.Unmarshal(payload, &probe) != nil || probe.DeviceCode == "" {
+		return
+	}
+	b.incident.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:    incidentService.CategoryDeviceOffline,
+		Source:      incidentService.SourceDevice,
+		DeviceCode:  probe.DeviceCode,
+		Description: "道闸 MQTT 遗嘱触发,设备离线",
+	})
+}
+```
+
+> 补充:设备恢复在线时,由 `gate/state`(retained)上报触发,调用 `incident.ResolveDeviceOffline(deviceCode)` 关闭未处理的离线异常;`MQTTGateController.onMessage` 的 `gate.state.v1` 分支可顺带调用一次(best-effort)。
+
+---
+
+## 6. 启动装配(伪代码)
+
+```go
+// internal/initialize/mqtt.go(新增)
+func InitDeviceBus() {
+	opts := mqtt.NewClientOptions().
+		AddBroker(global.GVA_CONFIG.Mqtt.Broker).
+		SetClientID("smart-parking").
+		SetWill("parking/device/lwt", `{"device_code":"smart-parking","state":"offline"}`, 1, true).
+		SetAutoReconnect(true)
+
+	client := mqtt.NewClient(opts)
+	if token := client.Connect(); token.Wait() && token.Error() != nil {
+		global.GVA_LOG.Error("MQTT 连接失败", zap.Error(token.Error()))
+		return
+	}
+
+	gate := parking.NewMQTTGateController(client)
+	_ = gate.LoadRoutes()
+	_ = gate.Subscribe()
+	parking.SetGateController(gate) // 与现有 SimulatedGateController 互斥注入
+
+	bus := parking.NewDeviceBus()
+	_ = bus.Subscribe(client)
+}
+```
+
+---
+
+## 7. 配置项(`config.yaml` 新增)
+
+```yaml
+mqtt:
+  enabled: false                    # 总开关
+  embed-broker: true                # 是否启动内嵌 mochi-mqtt broker
+  broker-listen: ":1883"            # 内嵌 broker 监听地址
+  broker: "tcp://127.0.0.1:1883"    # 客户端连接地址(内嵌/外部 broker 均连此地址)
+  client-id: "smart-parking"
+  username: ""
+  password: ""
+```
+
+对应 `internal/config/` 新增 `mqtt.go` 结构体,并挂到 `config.yaml` 加载。
+
+---
+
+## 8. 与现有代码的对接点
+
+| 现有代码 | 对接方式 |
+|----------|---------|
+| `GateController` 接口(`service/parking/gate.go`) | `MQTTGateController` 直接实现,`SetGateController` 注入 |
+| `GetGateRuntimeStatus` / `isSimulator()` | MQTT 控制器不实现 `isSimulator()`,`Simulated=false` |
+| `PassageService.HandlePassage` | 相机事件统一走此入口,黑名单/临时车/满位/计费复用 |
+| `uhf.PushChannelEvent` / `GetChannelEvents` | 识别结果推送;多岗亭改造后补落库 + 按岗亭过滤 |
+| `incident.RecordIncident`(`device_offline`) | 道闸 LWT 离线埋点,复用既有去重 |
+| `incident.ResolveDeviceOffline` | 道闸恢复在线时关闭离线异常 |
+| `plugin/ws` | 识别事件经 ws 推给工作台 `entryExit.vue` |
+
+---
+
+## 9. HTTP 兜底通道(只支持 HTTP 回调的设备)
+
+部分国产 LPR 相机只支持「HTTP POST 回调」,为其新增一个接收端点,内部转成同一条链路:
+
+```go
+// internal/api/v1/parking/camera_http.go(新增)
+func CameraEventCallback(c *gin.Context) {
+	var ev cameraEvent // 复用 §5 结构
+	if err := c.ShouldBindJSON(&ev); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	// 转成统一 PassageRequest 走 DeviceBus 同一条链路
+	// (图片本体走 multipart 上传或 image_url 由系统 HTTP 拉取)
+	response.OkWithMessage("ok", c)
+}
+```
+
+图片本体一律走 HTTP(POST multipart 上传或按 `image_path`/URL 拉取),**不塞进 MQTT**,避免撑爆 broker。
+
+---
+
+## 10. 依赖与实施步骤
+
+1. 依赖已引入:`github.com/eclipse/paho.mqtt.golang`(客户端)+ `github.com/mochi-mqtt/server/v2`(内嵌 broker)。
+2. 新增 `internal/config/mqtt.go` + `config.yaml` 配置。
+3. 新增 `internal/service/parking/mqtt_gate_controller.go`、`device_bus.go`。
+4. 新增 `internal/initialize/mqtt.go`,在 `InitBackend`/`seed` 流程调用 `InitDeviceBus()`。
+5. 相机/道闸厂商按 §2、§3 的 topic 与 schema 接入。
+6. 联调顺序:模拟道闸 → 相机事件 → LWT 离线 → 独立网络道闸指令回执。
+
+---
+
+## 11. 落地边界(避免过度设计)
+
+- **单进程、局域网、设备数量有限**:不引入 Kafka/时序库,MQTT broker 已内嵌 mochi-mqtt,随应用启动。
+- **开闸硬实时优先**:挂在读写器继电器上的道闸继续走串口/TCP 直连,MQTT 只做「独立网络道闸」和「状态/审计上报」,不强制改造现有硬件链路。
+- **图片不进 MQTT**:元数据走 MQTT、图片走 HTTP,两者用 `device_code + ts` 或 `image_path` 关联。

+ 386 - 0
doc/设备端对接文档.md

@@ -0,0 +1,386 @@
+# 智慧停车系统 · 设备端对接文档
+
+> 版本:v1.0
+> 适用对象:硬件端(车牌识别相机 / LPR 相机、UHF 读写器、道闸控制器)工程师
+> 配套文档:`doc/设备接入层设计.md`、`doc/多岗亭改造方案.md`
+
+---
+
+## 1. 通信架构概述
+
+系统与硬件设备之间采用 **MQTT 消息总线 + HTTP 图片传输** 两种通道:
+
+```
+┌─────────────┐  MQTT 发布(识别事件/状态/回执)  ┌──────────────────┐
+│  硬件设备     │ ─────────────────────────────▶ │  智慧停车系统      │
+│ (相机/道闸)  │                                │  (内嵌 MQTT Broker)│
+│             │ ◀───────────────────────────── │                  │
+└─────────────┘  MQTT 订阅(道闸指令)            └──────────────────┘
+      │
+      │ HTTP(抓拍图片,图片不进 MQTT)
+      ▼
+  图片存储/URL 引用
+```
+
+核心原则:
+
+- **MQTT 只传「元数据」**(车牌、RFID、状态、指令、回执),数据量小、实时性高。
+- **图片走 HTTP**,绝不塞进 MQTT,避免撑爆 broker。
+- 系统内嵌了一个 MQTT Broker(`mochi-mqtt`),设备**主动连接**它,而不是系统连接设备。
+
+---
+
+## 2. MQTT 连接规范
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| 协议 | MQTT 3.1.1 | 标准 MQTT |
+| Broker 地址 | `tcp://<系统主机IP>:1883` | 系统内嵌 broker,默认端口 1883 |
+| ClientID | **设备编码(device_code)** | 必须全局唯一,建议用系统里配置的设备编码 |
+| 用户名 / 密码 | 留空 | 当前版本匿名接入(`allow-all-auth`) |
+| QoS | 1(至少一次) | 所有消息统一 QoS 1 |
+| Keep Alive | 30 秒 | 建议 30s,超时 broker 会判定离线 |
+| 遗嘱(LWT) | 必须配置 | 见 §2.1,用于系统感知设备离线 |
+
+### 2.1 遗嘱(Last Will)配置(必配)
+
+设备连接时必须设置 LWT 遗嘱消息,否则设备断电/断网后系统无法感知离线。
+
+| 遗嘱参数 | 值 |
+|----------|-----|
+| Topic | `parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/lwt` |
+| Payload | `{"schema":"gate.lwt.v1","device_code":"<设备编码>"}` |
+| QoS | 1 |
+| Retained | true |
+
+### 2.2 心跳 / 在线状态(必发)
+
+设备连接成功后,**立即发布一条 retained 的状态消息**,让系统知道设备上线:
+
+- Topic:`parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/state`
+- Retained:`true`
+- Payload:见 §4.2 `gate.state.v1`
+
+---
+
+## 3. Topic 规范
+
+### 3.1 命名规则
+
+统一前缀 `parking/`,中间为「停车场 → 岗亭 → 通道」三级坐标,末尾为消息类型:
+
+```
+parking/lot/{lot_id}/booth/{booth_id}/channel/{channel_id}/{类型}
+```
+
+> `{lot_id}`、`{booth_id}`、`{channel_id}` 是**系统分配的数字 ID**,不是编码(code)。设备端需从系统获取这三元组并配置到设备(见 §7)。
+
+### 3.2 Topic 总览
+
+| 方向 | Topic | QoS | Retained | 说明 |
+|------|-------|-----|----------|------|
+| 设备 → 系统 | `.../channel/{ch}/camera/event` | 1 | 否 | 车牌/RFID 识别事件 |
+| 设备 → 系统 | `.../channel/{ch}/gate/state` | 1 | **是** | 道闸状态/在线心跳 |
+| 系统 → 设备 | `.../channel/{ch}/gate/cmd` | 1 | 否 | 开/关闸指令 |
+| 设备 → 系统 | `.../channel/{ch}/gate/cmd/ack` | 1 | 否 | 指令执行回执 |
+| 设备 → 系统 | `.../channel/{ch}/gate/lwt` | 1 | **是** | 遗嘱消息(离线通知) |
+
+### 3.3 设备需要订阅的 Topic
+
+设备只需订阅**自己坐标下的指令**:
+
+```
+parking/lot/{lot}/booth/{booth}/channel/{ch}/gate/cmd
+```
+
+---
+
+## 4. 消息格式定义(JSON)
+
+所有消息为 JSON 文本,UTF-8 编码。每个消息都含 `schema` 字段用于区分类型。
+
+### 4.1 识别事件 `camera.event.v1`(设备 → 系统)
+
+车牌识别(或 RFID 读取)成功后上报,触发系统执行进场/出场流程。
+
+```json
+{
+  "schema": "camera.event.v1",
+  "device_code": "CAM-A01",
+  "plate_number": "粤A12345",
+  "rfid_tag": "E28068940000500ABCDEF",
+  "direction": "in",
+  "ts": 1730000000,
+  "image_path": "http://192.168.1.50/capture/20240814_001.jpg",
+  "confidence": 0.98
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| schema | string | 是 | 固定 `camera.event.v1` |
+| device_code | string | 是 | 设备编码,**必须与系统设备管理中配置的一致** |
+| plate_number | string | 否* | 车牌号(无牌车可空) |
+| rfid_tag | string | 否* | RFID 标签 EPC(无 RFID 可空) |
+| direction | string | 是 | 通行方向 `in`(进场)/ `out`(出场) |
+| ts | int64 | 是 | 事件时间戳,**Unix 秒** |
+| image_path | string | 否 | 抓拍图 URL 或路径(图片不走 MQTT) |
+| confidence | float | 否 | 识别置信度,0~1 |
+
+> `*` `plate_number` 与 `rfid_tag` 至少一个非空。
+
+### 4.2 道闸状态 `gate.state.v1`(设备 → 系统,retained)
+
+道闸状态变化或上线时上报,系统据此维护设备在线状态。
+
+```json
+{
+  "schema": "gate.state.v1",
+  "device_code": "GATE-A01",
+  "state": "closed",
+  "ts": 1730000000
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| schema | string | 是 | 固定 `gate.state.v1` |
+| device_code | string | 是 | 设备编码 |
+| state | string | 是 | `open` / `closed` / `moving` |
+| ts | int64 | 是 | Unix 秒时间戳 |
+
+### 4.3 道闸指令 `gate.cmd.v1`(系统 → 设备)
+
+系统在进出场放行时下发开/关闸指令,设备收到后执行并回执。
+
+```json
+{
+  "schema": "gate.cmd.v1",
+  "cmd_id": "3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c",
+  "device_code": "GATE-A01",
+  "action": "open",
+  "valid_time": 2
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| schema | string | 是 | 固定 `gate.cmd.v1` |
+| cmd_id | string | 是 | 指令唯一编号(UUID),回执需原样带回 |
+| device_code | string | 是 | 目标设备编码 |
+| action | string | 是 | `open`(开闸)/ `close`(关闸) |
+| valid_time | int | 是 | 道闸动作保持时长(秒),默认 2,范围 1~255 |
+
+### 4.4 指令回执 `gate.ack.v1`(设备 → 系统)
+
+设备执行完指令后立即回执。**必须在 5 秒内回执**,否则系统判定超时。
+
+```json
+{
+  "schema": "gate.ack.v1",
+  "cmd_id": "3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c",
+  "device_code": "GATE-A01",
+  "result": "success",
+  "error": ""
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| schema | string | 是 | 固定 `gate.ack.v1` |
+| cmd_id | string | 是 | 对应指令的 `cmd_id`,**原样带回** |
+| device_code | string | 是 | 设备编码 |
+| result | string | 是 | `success`(成功)/ `failed`(失败) |
+| error | string | 否 | 失败原因(result=failed 时填写) |
+
+### 4.5 遗嘱消息 `gate.lwt.v1`(设备 → 系统,retained)
+
+设备异常断开时,由 broker 自动发布遗嘱消息。设备**无需主动发布**,只需在连接时配置好 LWT(见 §2.1)。
+
+```json
+{
+  "schema": "gate.lwt.v1",
+  "device_code": "GATE-A01"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| schema | string | 是 | `gate.lwt.v1`(系统兼容 `gate.lwt`) |
+| device_code | string | 是 | 设备编码 |
+
+---
+
+## 5. 典型交互流程
+
+### 5.1 设备上线
+
+```
+设备                          Broker(系统)
+ │ 1. CONNECT(ClientID=device_code, LWT配置)  │
+ │────────────────────────────────────────────▶│
+ │ 2. CONNACK                                  │
+ │◀────────────────────────────────────────────│
+ │ 3. SUBSCRIBE .../gate/cmd                   │
+ │────────────────────────────────────────────▶│
+ │ 4. PUBLISH .../gate/state (retained, closed)│
+ │────────────────────────────────────────────▶│  ← 系统回写设备 online
+```
+
+### 5.2 车牌识别 → 入场放行
+
+```
+相机                          系统                          道闸
+ │ PUBLISH camera/event(in)   │                             │
+ │────────────────────────────▶│ 黑名单/满位/临时车校验        │
+ │                             │ PUBLISH gate/cmd(open)      │
+ │                             │────────────────────────────▶│
+ │                             │ PUBLISH gate/cmd/ack        │
+ │                             │◀────────────────────────────│
+ │                             │ (5秒内未回执则超时)          │
+```
+
+### 5.3 车牌识别 → 出场放行
+
+同 §5.2,区别是 `camera/event` 的 `direction` 为 `out`。系统会先做计费结算,再下发开闸指令。
+
+### 5.4 设备离线
+
+```
+道闸异常断开
+    │
+    ▼
+Broker 自动发布 retained 的 gate/lwt 遗嘱
+    │
+    ▼
+系统收到 → 回写设备 offline + 记录「设备离线」异常
+```
+
+---
+
+## 6. 字段枚举汇总
+
+| 字段 | 取值 | 说明 |
+|------|------|------|
+| direction | `in` / `out` | 进场 / 出场 |
+| state | `open` / `closed` / `moving` | 道闸开启 / 关闭 / 动作中 |
+| action | `open` / `close` | 开闸 / 关闸 |
+| result | `success` / `failed` | 指令成功 / 失败 |
+| schema | 见 §4 | 消息类型标识 |
+
+---
+
+## 7. 设备部署配置(重要)
+
+### 7.1 设备需要配置的 4 个参数
+
+设备端部署时,需要从系统获取并配置以下参数:
+
+| 参数 | 来源 | 说明 |
+|------|------|------|
+| `device_code` | 系统「设备管理」 | 设备编码,全局唯一 |
+| `lot_id` | 系统「停车场管理」 | 所属停车场数字 ID |
+| `booth_id` | 系统「岗亭管理」 | 所属岗亭数字 ID |
+| `channel_id` | 系统「通道管理」 | 绑定通道数字 ID |
+
+### 7.2 坐标层级关系
+
+```
+停车场 ParkingLot (lot_id)
+   └── 岗亭 Booth (booth_id)
+         └── 通道 Channel (channel_id)
+               └── 设备 UHFReader (device_code)
+```
+
+设备必须「绑定通道」后,坐标三元组 `(lot_id, booth_id, channel_id)` 才完整。MQTT 设备必须绑定通道(系统已强制校验)。
+
+### 7.3 获取方式
+
+在系统界面「停车管理 → 停车场信息」中,依次查看停车场、岗亭、通道、设备的 ID;或通过后端接口查询(`/parking/lot/list`、`/parking/booth/list`、`/parking/channel/list`、`/parking/device/list`)。
+
+> 注意:topic 中使用的是**数字 ID**(数据库主键),不是 lot_code/booth_code/channel_code 编码字符串。
+
+---
+
+## 8. 图片关联(HTTP)
+
+### 8.1 关联方式
+
+抓拍图片**不通过 MQTT 传输**。识别事件中通过 `image_path` 字段引用图片,系统前端直接加载该 URL 显示。
+
+### 8.2 推荐方案(当前版本)
+
+设备端在识别时抓拍图片,并**由设备自身提供 HTTP 访问**(或存到约定的图片服务器),然后在 `camera.event.v1` 的 `image_path` 里带上该图片的完整 URL:
+
+```json
+"image_path": "http://192.168.1.50/capture/20240814_001.jpg"
+```
+
+系统会把该 URL 存到车辆记录的入场/出场图片字段,工作台直接显示。
+
+> 若后续需要「设备主动上传图片到系统」,可协商增加系统侧 HTTP 上传端点(POST multipart),当前版本未实现。
+
+---
+
+## 9. 错误处理与重试约定
+
+| 场景 | 约定 |
+|------|------|
+| 指令下发失败(Publish 失败) | 系统记录异常,本次放行标记失败 |
+| 指令超时(5 秒未收到 ack) | 系统判定「开闸失败」,记录道闸异常 |
+| ack result=failed | 系统记录「道闸执行失败」,保留 error 信息 |
+| 设备断连 | 靠 LWT 遗嘱触发,系统自动标记离线 |
+| 设备重连 | 重新 CONNECT 后,先向 `gate/lwt` 发布 retained 空载荷清除旧遗嘱,再立即重发 retained 的 `gate.state.v1` |
+| 识别事件重复 | 系统有 3 秒防抖,同一标识 3 秒内重复事件会被丢弃 |
+
+---
+
+## 10. 联调自检清单
+
+- [ ] 设备能连上 `tcp://<系统IP>:1883`(匿名)
+- [ ] ClientID 设置为系统里的 `device_code`
+- [ ] 已配置 LWT 遗嘱(QoS 1 + retained)
+- [ ] 上线后先清除了 `gate/lwt` 的 retained 遗嘱,再发布 retained 的 `gate.state.v1`
+- [ ] 已订阅自己的 `gate/cmd` 指令 Topic
+- [ ] 识别到车牌后能发 `camera.event.v1`(direction 正确)
+- [ ] 收到 `gate.cmd.v1` 后 5 秒内能回 `gate.ack.v1`
+- [ ] 拔网线/断电后,系统设备列表状态变为「离线」
+
+---
+
+## 附录 A:消息示例汇总
+
+**A1 识别事件(进场)**
+```json
+{"schema":"camera.event.v1","device_code":"CAM-A01","plate_number":"粤A12345","rfid_tag":"","direction":"in","ts":1730000000,"image_path":"http://192.168.1.50/cap/001.jpg","confidence":0.97}
+```
+
+**A2 识别事件(出场,仅 RFID)**
+```json
+{"schema":"camera.event.v1","device_code":"CAM-B02","plate_number":"","rfid_tag":"E28068940000500ABCDEF","direction":"out","ts":1730000010,"image_path":"","confidence":0}
+```
+
+**A3 道闸状态(上线)**
+```json
+{"schema":"gate.state.v1","device_code":"GATE-A01","state":"closed","ts":1730000000}
+```
+
+**A4 开闸指令(系统下发)**
+```json
+{"schema":"gate.cmd.v1","cmd_id":"3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c","device_code":"GATE-A01","action":"open","valid_time":2}
+```
+
+**A5 开闸回执(成功)**
+```json
+{"schema":"gate.ack.v1","cmd_id":"3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c","device_code":"GATE-A01","result":"success","error":""}
+```
+
+**A6 开闸回执(失败)**
+```json
+{"schema":"gate.ack.v1","cmd_id":"3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c","device_code":"GATE-A01","result":"failed","error":"motor timeout"}
+```
+
+**A7 遗嘱(离线)**
+```json
+{"schema":"gate.lwt.v1","device_code":"GATE-A01"}
+```

+ 523 - 0
doc/边缘端自动与手工接入对接指南.md

@@ -0,0 +1,523 @@
+# 智慧停车边缘端自动与手工接入对接指南
+
+> 版本:v1.0
+>
+> 适用对象:车牌识别相机、道闸控制器、边缘网关固件和设备代理开发人员
+>
+> 配套文档:[设备端对接文档.md](./设备端对接文档.md)、[边缘设备自动接入实施方案.md](./边缘设备自动接入实施方案.md)
+
+## 1. 对接目标
+
+边缘端在首次安装、恢复出厂或更换管理系统地址后,需要支持以下两种接入方式:
+
+1. **UDP 自动发现**:设备不知道管理系统地址时,在局域网监听发现请求,返回自己的身份和 HTTPS 配置地址。管理系统随后通过 HTTPS 完成身份确认和配置下发。
+2. **手工 IP 接入**:UDP 广播不可用或设备不支持发现协议时,管理员在管理系统页面输入设备 IP 和 HTTPS 端口。设备仍通过相同的 HTTPS 身份和配置接口完成接入,不得因为地址是手工输入而跳过身份确认。
+
+两种方式完成配置后,边缘端都必须主动连接 MQTT Broker,并发布 retained 的 `gate.state.v1`。管理系统只有收到该状态消息后,才把设备标记为在线。
+
+发现和配置通道只用于接入,不承载车牌、图片、开闸指令等正常业务数据。设备上线、状态、识别事件、开闸指令和回执使用 MQTT;抓拍图片由边缘端通过 HTTPS 主动上传到管理系统,MQTT 事件只引用上传成功返回的 `image_id`。
+
+## 2. 边缘端必须实现的能力
+
+### 2.1 固定身份
+
+设备出厂时必须生成并永久保存以下信息:
+
+| 字段 | 要求 |
+| --- | --- |
+| `device_id` | 永久硬件 ID,不能由管理员输入、修改或因 IP 变化而改变;建议使用安全芯片序列号或出厂序列号 |
+| `device_code` | 初始可以为空或使用出厂临时编码;管理员绑定业务通道后会下发正式编码 |
+| `device_type` | 例如 `lpr-gate`、`gate-controller`、`camera` |
+| `device_model` | 固件型号 |
+| `firmware_version` | 当前固件版本 |
+| 设备签名密钥 | 每台设备独立的 Ed25519 私钥,私钥不得通过 UDP、日志或 MQTT 暴露 |
+
+`device_id` 是设备身份主键。设备发现响应里的 `device_id`、HTTPS 身份接口返回的 `device_id` 和 MQTT 状态对应的设备编码必须能够关联;发现阶段和 HTTPS 阶段出现不同永久 ID 时必须拒绝继续配置。
+
+### 2.2 HTTPS 配置服务
+
+设备必须在配置端口监听 HTTPS,默认端口为 `8443`,实际端口可由固件配置。证书可以是出厂自签名证书,但证书必须稳定保存,不能每次启动随机生成。
+
+必须提供以下接口:
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `GET` | `/api/v1/identity` | 返回设备公开身份、证书/公钥指纹和是否需要配对码 |
+| `POST` | `/api/v1/pair` | 校验一次性配对码,建立本次管理系统信任关系 |
+| `POST` | `/api/v1/provision` | 原子保存 MQTT 和停车场通道配置 |
+
+当前管理系统会先读取 `/api/v1/identity`,再由管理员核对证书指纹并调用 `/api/v1/pair`,最后调用 `/api/v1/provision`。接口必须只返回必要信息,不返回私钥、MQTT 长期密码或其他设备密钥。
+
+### 2.3 MQTT 客户端
+
+设备必须支持 MQTT 3.1.1、QoS 1、Keep Alive 30 秒、LWT 和 retained 消息。设备是 MQTT 客户端,管理系统是 Broker;设备不得等待管理系统反向连接设备。
+
+## 3. UDP 自动发现对接
+
+### 3.1 监听规则
+
+- 传输协议:IPv4 UDP。
+- 监听地址:`0.0.0.0:31001`。
+- 单个 UDP 包最大处理长度:4KB;超过长度直接丢弃。
+- 设备只响应合法、未过期的 `discovery.request.v1`。
+- 响应必须单播回请求报文的源 IP 和源端口,不要再次广播。
+- 不要在 UDP 请求或响应中携带 MQTT 密码、私钥、配对码或停车场业务数据。
+- UDP 监听应在设备尚未配置 MQTT 时工作;设备已经在线时是否响应由设备策略决定,但不能自动覆盖已生效配置。
+
+管理系统会在本机 IPv4 网卡的定向广播地址上发送 3 次请求,间隔 500ms,收集窗口约 3 秒。设备不能假设管理系统使用固定源端口。
+
+### 3.2 请求格式 `discovery.request.v1`
+
+```json
+{
+  "schema": "discovery.request.v1",
+  "request_id": "7ba3a5ea-3ca4-45fe-b59d-1dc47b6ab5a1",
+  "issued_at": 1760000000,
+  "expires_at": 1760000030,
+  "system_id": "smart-parking-HOST01",
+  "nonce": "base64-32-random-bytes"
+}
+```
+
+设备处理要求:
+
+1. `schema` 必须等于 `discovery.request.v1`。
+2. `request_id` 必须作为本次请求关联 ID 原样带回。
+3. `expires_at` 必须大于当前时间;建议允许不超过 30 秒的时钟误差。
+4. `nonce` 必须原样带回,不能生成新的 nonce 替换它。
+5. 同一 `request_id + nonce` 的重复请求可以重复响应,但不能触发配置或重启 MQTT。
+6. 非法 JSON、过期请求和包长度超过 4KB 直接丢弃,不返回错误广播。
+
+### 3.3 响应格式 `discovery.response.v1`
+
+```json
+{
+  "schema": "discovery.response.v1",
+  "request_id": "7ba5a3ea-3ca4-45fe-b59d-1dc47b6ab5a1",
+  "device_id": "SN-20260819-0001",
+  "device_code": "EDGE-A01",
+  "device_type": "lpr-gate",
+  "device_model": "LPR-GW-200",
+  "firmware_version": "2.1.0",
+  "ip": "192.168.10.26",
+  "config_url": "https://192.168.10.26:8443/api/v1/provision",
+  "public_key_fingerprint": "SHA256:4A8F...",
+  "signing_public_key": "base64-ed25519-public-key",
+  "nonce": "base64-32-random-bytes",
+  "signature": "base64-ed25519-signature"
+}
+```
+
+字段要求:
+
+| 字段 | 要求 |
+| --- | --- |
+| `ip` | 必须是设备当前 IPv4 地址,并且等于设备发送 UDP 响应时的本地地址;不要填管理系统地址或旧地址 |
+| `config_url` | 必须使用 `https`,主机必须等于 `ip`;端口必须是设备实际 HTTPS 配置端口 |
+| `public_key_fingerprint` | `signing_public_key` 原始 32 字节 Ed25519 公钥的 SHA-256;支持 `SHA256:` 前缀,推荐十六进制大写或小写均可 |
+| `signing_public_key` | Base64 编码的 Ed25519 公钥;每台设备固定,不因每次响应变化 |
+| `signature` | Base64 编码的 Ed25519 签名 |
+
+当前管理系统验证的签名原文是以下 6 个字段按顺序以单个换行符连接,不能添加空格、JSON 格式或末尾换行:
+
+```text
+schema
+request_id
+device_id
+ip
+config_url
+nonce
+```
+
+例如:
+
+```text
+discovery.response.v1
+7ba5a3ea-3ca4-45fe-b59d-1dc47b6ab5a1
+SN-20260819-0001
+192.168.10.26
+https://192.168.10.26:8443/api/v1/provision
+base64-32-random-bytes
+```
+
+管理系统会校验 schema、request_id、nonce、UDP 源 IP、`config_url` 主机、签名和公钥指纹。设备不得把发现响应当作已信任或已配置;自动发现后仍必须通过 HTTPS 身份读取和管理员确认。
+
+## 4. HTTPS 身份和配对对接
+
+### 4.1 身份接口
+
+管理系统请求:
+
+```http
+GET /api/v1/identity HTTP/1.1
+Host: <device-ip>:<https-port>
+Accept: application/json
+```
+
+成功响应 HTTP 200:
+
+```json
+{
+  "device_id": "SN-20260819-0001",
+  "device_code": "EDGE-A01",
+  "device_type": "lpr-gate",
+  "device_model": "LPR-GW-200",
+  "firmware_version": "2.1.0",
+  "certificate_fingerprint": "SHA256:ABCDEF...",
+  "public_key_fingerprint": "SHA256:4A8F...",
+  "pairing_required": true
+}
+```
+
+字段处理:
+
+- `device_id` 必填且永久不变;为空时管理系统会拒绝设备。
+- `certificate_fingerprint` 应为当前 HTTPS 服务器证书 DER 原文的 SHA-256。设备返回值必须与实际 TLS 证书一致。
+- `public_key_fingerprint` 是设备签名公钥指纹,可与 UDP 响应中的值一致。
+- `pairing_required=true` 时,管理系统会要求管理员输入一次性配对码。
+- 身份接口不得因为未配置 MQTT 而失败;身份接口是首次接入的前置接口。
+
+### 4.2 证书和 TLS 要求
+
+设备必须:
+
+1. 只接受 HTTPS,不提供把配置数据明文放在 HTTP 或 UDP 中的兼容路径。
+2. 使用 TLS 1.2 或更高版本。
+3. 在整个设备生命周期内保持证书稳定;证书更换必须有明确的重新配对流程。
+4. 正确返回证书链和服务器名称/IP 对应信息。出厂自签名证书可以使用,但必须让管理员能够从设备标签、二维码或受控维护界面取得指纹。
+5. 对配置接口进行请求大小限制、超时限制和并发限制,防止配置服务被当作通用代理。
+
+管理系统首次读取身份时会获取 TLS 对端证书指纹;管理员必须把页面指纹与设备标签或二维码核对。管理系统不会因为 IP 可达就自动信任设备。
+
+### 4.3 配对接口
+
+当身份响应 `pairing_required=true` 时,管理系统请求:
+
+```http
+POST /api/v1/pair HTTP/1.1
+Content-Type: application/json
+
+{"pairing_code":"ONE-TIME-CODE"}
+```
+
+成功返回任意 2xx,建议返回:
+
+```json
+{
+  "result": "paired",
+  "device_id": "SN-20260819-0001",
+  "expires_at": 1760003600
+}
+```
+
+设备实现要求:
+
+- 配对码必须是一次性或短时有效凭据,比较时使用常量时间比较。
+- 错误次数必须限流;连续失败不能擦除既有 MQTT 配置。
+- 成功后将本次管理系统或证书指纹标记为已配对,配对码不能再次使用。
+- 错误、过期、重复使用统一返回 4xx,并在设备安全日志中记录,不把配对码明文写入日志。
+- `pairing_required=false` 时,设备仍必须完成 HTTPS 证书指纹核对;不能仅凭此字段跳过管理员确认。
+
+## 5. HTTPS 配置下发对接
+
+### 5.1 请求格式
+
+管理系统请求:
+
+```http
+POST /api/v1/provision HTTP/1.1
+Content-Type: application/json
+```
+
+当前管理系统实际发送的 JSON 字段如下:
+
+```json
+{
+  "schema": "provision.request.v1",
+  "request_id": "f0f2f011-cc14-4965-8a61-9a65b3ec2f0b",
+  "device_id": "SN-20260819-0001",
+  "device_code": "GATE-A01",
+	"mqtt": {
+    "host": "192.168.10.10",
+    "port": 1883,
+    "tls": false,
+    "client_id": "GATE-A01"
+	},
+	"image_upload": {
+	  "url": "http://192.168.10.10:8888/device-images/upload",
+	  "max_bytes": 8388608
+	},
+	"route": {
+    "parking_lot_id": 2,
+    "booth_id": 5,
+    "channel_id": 8,
+    "direction": "in"
+  }
+}
+```
+
+字段要求:
+
+| 字段 | 设备端处理 |
+| --- | --- |
+| `schema` | 必须为 `provision.request.v1` |
+| `request_id` | 保存最近处理结果;相同请求重试应幂等返回,不重复生成凭据或反复重启 |
+| `device_id` | 必须与本机永久 ID 完全一致,不一致返回 409 或 403 |
+| `device_code` | 成功配置后作为 MQTT ClientID、消息 `device_code` 和业务设备编码;不能为空 |
+| `mqtt.host` | 管理系统下发给设备的可达地址;不能把它替换为设备自身 IP |
+| `mqtt.port` | 1-65535;按 `mqtt.tls` 选择 TLS 或普通 MQTT |
+| `mqtt.tls` | 为 true 时必须校验 Broker 证书;不能降级为明文连接 |
+| `mqtt.client_id` | 当前应与 `device_code` 相同,必须全局唯一 |
+| `image_upload.url` | 边缘端主动上传抓拍图片的管理系统内网地址 |
+| `image_upload.max_bytes` | 单张图片最大字节数;当前为 8MB |
+| `route.*` | 保存停车场、岗亭、通道数字 ID 和方向;Topic 使用这些数字 ID,不是编码字符串 |
+
+当前版本管理系统没有在 JSON 中发送 MQTT 用户名、密码、`topic_prefix`、`config_version`、`nonce` 或请求签名字段。设备端必须忽略未知字段,但不能自行要求当前版本不存在的必填字段,否则会导致接入失败。生产环境启用设备级账号、双向 TLS 或配置签名时,应通过协议版本升级增加这些字段,并保持旧版本的明确兼容策略。
+
+### 5.2 原子保存和响应
+
+设备收到合法配置后必须按以下顺序处理:
+
+1. 校验 HTTPS 会话已通过证书指纹/配对信任,校验 `device_id`、`device_code`、端口、Topic 坐标和方向。
+2. 校验 `mqtt.host` 是设备实际可达的单播地址;禁止 `127.0.0.1`、`localhost`、`0.0.0.0`、广播和组播地址。
+3. 将新配置写入临时文件或事务存储,完成校验和后原子替换正式配置。
+4. 配置写入失败时保留旧配置,并返回 4xx/5xx;不能只写入 Broker 地址而丢失通道坐标。
+5. 成功保存后返回 HTTP 200(或 202):
+
+```json
+{
+  "result": "accepted",
+  "request_id": "f0f2f011-cc14-4965-8a61-9a65b3ec2f0b",
+  "message": "configuration saved; mqtt reconnecting"
+}
+```
+
+`accepted` 只表示设备已经保存配置,不表示已经在线。返回响应后设备应异步重启 MQTT 客户端,不要让 HTTPS 请求等待 Broker 连接完成。
+
+建议错误响应:
+
+| HTTP | 场景 | 设备行为 |
+| --- | --- | --- |
+| 400 | JSON、字段、端口或 Topic 坐标无效 | 不改配置,返回字段错误 |
+| 401/403 | 未配对或权限不足 | 不改配置,记录安全事件 |
+| 409 | `device_id`、`request_id` 或配置版本冲突 | 返回现有结果或冲突原因,不覆盖配置 |
+| 429 | 配置/配对请求过于频繁 | 延迟重试,不修改配置 |
+| 500 | 本地存储失败 | 保留旧配置,返回失败 |
+
+## 6. 两种接入方式的设备行为差异
+
+### 6.1 自动发现
+
+设备需要额外实现 UDP 监听和签名响应。完整过程:
+
+```text
+启动设备
+  └─ 监听 UDP :31001、启动 HTTPS 身份服务
+管理系统广播 discovery.request.v1
+  └─ 设备校验 request_id/nonce/过期时间
+  └─ 设备单播 discovery.response.v1
+管理系统通过 HTTPS GET /identity
+  └─ 管理员核对证书指纹和配对码
+管理系统 HTTPS POST /provision
+  └─ 设备原子保存 MQTT 配置并返回 accepted
+设备连接 MQTT
+  └─ 清除旧 LWT retained
+  └─ 发布 retained gate.state.v1
+```
+
+UDP 响应不等于配置授权。设备不能收到发现请求后自动修改 `device_code`、业务通道或 MQTT 配置,所有修改必须来自已配对的 HTTPS 配置请求。
+
+### 6.2 手工 IP 接入
+
+设备不需要收到 UDP 请求,也不需要知道管理系统 IP;只要管理员能够从管理系统访问设备的 HTTPS IP 和端口即可:
+
+```text
+设备启动 HTTPS :8443
+管理员在系统输入设备 IP/端口
+  └─ 管理系统 GET /api/v1/identity
+  └─ 管理员核对证书指纹,输入配对码
+  └─ 管理系统 POST /api/v1/pair(需要时)
+  └─ 管理系统 POST /api/v1/provision
+设备连接 MQTT
+  └─ 清除旧 LWT retained
+  └─ 发布 retained gate.state.v1
+```
+
+手工 IP 常用于跨 VLAN、UDP 广播被禁、无线隔离、VPN 或设备不支持发现协议的网络。设备端无需为这些场景降低 TLS、永久 ID 或配对校验要求。
+
+## 7. MQTT 配置完成后的行为
+
+### 7.1 连接和 Topic
+
+设备从 `provision` 中读取:
+
+```text
+Broker: mqtt[ s ]://<mqtt.host>:<mqtt.port>
+ClientID: <mqtt.client_id>,当前必须等于 device_code
+Keep Alive: 30 秒
+QoS: 1
+```
+
+Topic 模板:
+
+```text
+parking/lot/{parking_lot_id}/booth/{booth_id}/channel/{channel_id}/gate/state
+parking/lot/{parking_lot_id}/booth/{booth_id}/channel/{channel_id}/gate/lwt
+parking/lot/{parking_lot_id}/booth/{booth_id}/channel/{channel_id}/gate/cmd
+parking/lot/{parking_lot_id}/booth/{booth_id}/channel/{channel_id}/gate/cmd/ack
+parking/lot/{parking_lot_id}/booth/{booth_id}/channel/{channel_id}/camera/event
+```
+
+设备只订阅自己的 `gate/cmd`。不要订阅 `#` 或其他停车场的 Topic。
+
+### 7.2 上线顺序
+
+连接 MQTT 后必须:
+
+1. CONNECT 时设置 LWT Topic 为自己的 `gate/lwt`,Payload 为:
+
+   ```json
+   {"schema":"gate.lwt.v1","device_code":"GATE-A01"}
+   ```
+
+   LWT 使用 QoS 1、retained=true。
+2. CONNACK 成功后订阅自己的 `gate/cmd`。
+3. 向自己的 `gate/lwt` 发布**空 retained payload**,清除旧的离线遗嘱。
+4. 立即发布 retained 的 `gate.state.v1`:
+
+   ```json
+   {
+     "schema":"gate.state.v1",
+     "device_code":"GATE-A01",
+     "state":"closed",
+     "ts":1760000000
+   }
+   ```
+
+5. 后续状态变化继续发布 `gate.state.v1`;断开连接时由 Broker 发布 LWT。
+
+管理系统等待最多约 30 秒接收 `gate.state.v1`。只有收到状态消息,设备才会从“配置已保存”变成“在线”。
+
+### 7.3 道闸指令和回执
+
+收到 `gate.cmd.v1` 后,设备必须校验 `device_code`、`action` 和 `valid_time`,执行动作后在 5 秒内向 `gate/cmd/ack` 发布:
+
+```json
+{
+  "schema":"gate.ack.v1",
+  "cmd_id":"3f2a1b9c-4d5e-4f6a-8b9c-0d1e2f3a4b5c",
+  "device_code":"GATE-A01",
+  "result":"success",
+  "error":""
+}
+```
+
+失败时 `result` 为 `failed`,`error` 使用可读但不包含密钥的诊断信息。相同 `cmd_id` 的重复命令应幂等处理,不能因为 MQTT QoS 1 重投而连续开闸。
+
+### 7.4 抓拍图片 HTTP 主动上传
+
+每次识别后,边缘端必须先通过 `image_upload.url` 上传图片,成功获得 `image_id` 后再发布 MQTT 识别事件。不得在 MQTT 中发送 Base64 图片或图片二进制数据。
+
+```http
+POST /device-images/upload HTTP/1.1
+Content-Type: multipart/form-data
+```
+
+表单字段:`device_code`(当前 MQTT 设备编码)、`image_id`(每张图片全局唯一 UUID)、`event_id`(同一次识别 UUID)、`direction`(`in` / `out`)、`image`(JPEG/PNG/WebP 图片,最大 8MB)。成功响应的 `data.image_id` 与请求值相同。网络超时后可使用相同的 `device_code + image_id` 重试,管理系统会返回已有图片记录,不重复保存。
+
+联调时管理端会记录以下结构化日志(不记录图片内容):
+
+- `边缘设备图片上传结果`:包含 `result`、`remote_addr`、`device_code`、`image_id`、`event_id`、`content_type`、`size`、`idempotent` 和 `duration`。
+- `边缘设备图片上传失败`:包含失败阶段、来源地址、设备编码、图片 ID、耗时和错误原因。常见阶段包括设备未配置、缺少文件、大小超限、格式校验、对象存储和数据库保存。
+- `收到车辆识别 MQTT 事件`、`车辆识别事件字段已解析`:确认 MQTT 事件已经到达并展示 `event_id`、`image_id`、车牌和方向。
+- `车辆识别事件图片引用校验成功`:确认 MQTT 引用已经匹配到该设备上传的图片;若失败会记录 `车辆识别事件引用的图片不可用`。
+- `处理车辆识别事件`:包含通行处理结果和总耗时。
+
+上传成功后发布:
+
+```json
+{
+  "schema": "camera.event.v1",
+  "event_id": "3e0d2bb5-74bd-4a2c-a5d8-6c3ac6472a1b",
+  "image_id": "447a82ca-7e02-4dd4-9e78-d865f84e3c1d",
+  "device_code": "GATE-A01",
+  "plate_number": "粤A12345",
+  "direction": "in",
+  "ts": 1760000000
+}
+```
+
+管理系统只接受已由同一 `device_code` 上传的 `image_id`;图片尚未上传、上传失败或属于其他设备时,该识别事件不进入通行流程。为兼容存量固件,当前管理系统暂时仍接受旧 `image_path` 字段,但新固件必须迁移到 `image_id`。
+
+## 8. 设备端状态机和重试
+
+建议实现以下状态:
+
+| 状态 | 进入条件 | 退出条件 |
+| --- | --- | --- |
+| `unprovisioned` | 出厂或恢复出厂 | 收到合法并已配对的配置 |
+| `identity_ready` | HTTPS 身份服务可用 | 配置成功或重新配对 |
+| `provisioning` | 正在校验/保存配置 | 原子保存成功或失败 |
+| `provisioned` | 已保存 MQTT 配置 | 发布在线状态或配置失败 |
+| `online` | 收到 MQTT CONNACK 并发布 `gate.state.v1` | MQTT 断开 |
+| `offline` | LWT、连接失败或心跳超时 | MQTT 重连并重新发布状态 |
+| `failed` | 配置或本地存储失败 | 管理员修正后重新配置 |
+
+重试要求:
+
+- MQTT 连接失败使用指数退避,建议 1s、2s、4s、8s、15s,上限 60s。
+- HTTPS 配置请求失败时保留旧的可运行 MQTT 配置;不要把设备置于无配置状态。
+- `request_id` 重试必须返回同一处理结果,不能重复生成凭据或重复清理数据。
+- 设备重启后从持久化配置恢复 MQTT、Topic 和业务坐标,并重新发布 retained state。
+- 恢复出厂必须显式清除 MQTT 凭据、业务坐标、配对信任和旧的请求幂等记录,但不能清除制造商永久 `device_id` 和签名密钥。
+
+## 9. 安全要求
+
+边缘端必须满足:
+
+1. `device_id` 永久且不可由页面输入覆盖。
+2. HTTPS 配置接口只能由已建立信任的管理系统调用;设备不能仅凭来源 IP 信任请求。
+3. 配对码一次性、短时有效、限流,日志不记录明文。
+4. 签名私钥存储在受保护区域;UDP 响应只暴露公钥和指纹。
+5. MQTT 凭据不能写入 UDP 响应、普通日志或识别事件。
+6. `mqtt.tls=true` 时必须验证 Broker 证书,禁止自动降级为明文。
+7. 只处理自己的 MQTT Topic;拒绝 Topic 路由坐标与本地配置不一致的指令。
+8. 配置更新必须原子化并可回滚;断电不能产生半份配置。
+9. HTTPS、UDP 和 MQTT 输入都要限制包体、字段长度和请求频率。
+10. 记录审计所需的 request ID、结果和错误码,但不得记录配对码、私钥或长期密码。
+
+## 10. 联调验收清单
+
+### 自动发现
+
+- [ ] 设备在 `0.0.0.0:31001` 监听并能收到定向广播。
+- [ ] 设备能校验 schema、过期时间、request ID 和 nonce。
+- [ ] 响应单播回管理系统 UDP 源地址和源端口。
+- [ ] `ip`、`config_url`、签名和公钥指纹满足格式要求。
+- [ ] 错误签名、过期请求、伪造 request ID 和超过 4KB 的包被丢弃。
+- [ ] 自动发现后仍必须通过 HTTPS 身份、证书指纹和配对确认。
+
+### 手工 IP
+
+- [ ] 不依赖 UDP 也能通过 HTTPS 读取身份。
+- [ ] HTTPS 端口可配置,默认 `8443`,证书指纹稳定。
+- [ ] 永久 ID 与设备标签/二维码一致。
+- [ ] 正确配对码成功,错误/过期/重复配对码失败并限流。
+- [ ] 未配对或 `device_id` 不一致的 `/provision` 不修改配置。
+
+### 配置和 MQTT
+
+- [ ] `/provision` 成功时配置原子保存并返回 `result=accepted`。
+- [ ] 配置失败时旧配置仍可运行。
+- [ ] 设备按下发坐标生成自己的 Topic 并只订阅自己的命令 Topic。
+- [ ] MQTT 重连后先清除 retained LWT,再发布 retained `gate.state.v1`。
+- [ ] 管理系统在 30 秒内收到状态并显示在线。
+- [ ] `gate.cmd.v1` 在 5 秒内返回匹配 `cmd_id` 的 `gate.ack.v1`。
+- [ ] 断电/断网可触发 LWT,恢复后可重新上线。
+- [ ] 同一 `request_id` 和 `cmd_id` 重试不会造成重复配置或重复开闸。
+
+## 11. 版本兼容约定
+
+当前管理系统实现以本文第 5.1 节的 `provision.request.v1` 字段为准,MQTT 默认可能使用匿名连接,具体地址由管理系统 `mqtt.advertised-host` 和 `mqtt.advertised-port` 下发。边缘端应忽略未知 JSON 字段并保留向后兼容能力。
+
+后续启用设备级 MQTT 账号、双向 TLS、配置请求签名、`expires_at`、`nonce` 或 `config_version` 时,管理系统会增加协议版本或字段约束。设备端应将协议版本、已处理的 `request_id` 和配置版本持久化,以便平滑升级和幂等重试。

+ 487 - 0
doc/边缘设备自动接入实施方案.md

@@ -0,0 +1,487 @@
+# 边缘设备自动接入实施方案
+
+> 状态:设计待实施
+>
+> 适用对象:车牌识别相机、道闸控制器、边缘网关等支持局域网配置的设备
+>
+> 关联文档:doc/设备端对接文档.md、doc/设备接入层设计.md
+
+---
+
+## 1. 目标与边界
+
+当前 MQTT 接入方式要求边缘设备预先知道管理系统的 Broker 地址,再由设备主动建立 MQTT 连接。该前置配置对现场部署不友好,尤其是在管理系统 IP 变化、设备批量安装或设备恢复出厂设置时。
+
+本方案增加一条只用于首次接入和重新配置的局域网通道,使管理系统可以发现待接入设备,并向设备下发 MQTT 连接参数。设备完成配置后,仍按既有 MQTT 协议工作;车辆识别、开关闸、状态上报和支付等正常业务流程不使用发现通道。
+
+本期范围:
+
+- 管理系统发现局域网内未配置或待重新配置的边缘设备。
+- 当 UDP 自动发现不可用时,管理员可以通过设备 IP 和 HTTPS 端口走正式的手工接入路径;该路径属于生产接入能力,不是临时排障开关。
+- 管理系统向经管理员确认的设备下发 Broker 地址、设备编码、通道坐标和认证信息。
+- 设备保存配置,主动连接 MQTT Broker,并通过 gate.state.v1 上报在线状态。
+- 管理系统根据 MQTT 状态完成接入确认。
+
+不在本期范围:
+
+- 跨网段、互联网或 NAT 环境下的 UDP 自动发现。该场景可以使用本方案的手工 IP 接入,也可以使用边缘网关、VPN 或云端设备管理平台。
+- 对不支持 UDP 发现或不提供配置接口的既有设备进行强制接入。管理系统不能控制完全未知的设备。
+- 用 UDP 传输车牌、图片、道闸控制等业务消息。UDP 只承担发现与引导配置。
+
+## 2. 核心结论
+
+设备在不知道 Broker 地址时尚未连接 MQTT。此时管理系统无法通过 MQTT 获得设备 IP,也无法通过 MQTT 下发 Broker 配置。因此,系统主动获取 IP 并下发连接参数,必须建立在边缘端预置接入代理的前提上。
+
+完整过程如下:
+
+1. 边缘设备监听固定的局域网发现端口。
+2. 管理系统通过 UDP 定向广播发送发现请求。
+3. 设备以 UDP 单播回复设备身份、源 IP、配置端口和证书指纹。
+4. 管理系统要求管理员完成设备身份确认并选择业务通道。
+5. 管理系统通过设备的 HTTPS 配置接口下发 MQTT 参数。
+6. 设备原子保存配置,主动连入 MQTT Broker。
+7. 设备清除旧离线遗嘱,发布 retained 的 gate.state.v1。
+8. 管理系统收到状态消息后将设备标记为已接入并在线。
+
+UDP 广播优于扫描整个 IP 网段:不需要假设网段范围,不会主动连接无关设备,响应速度快,也便于交换机和防火墙按固定端口放行。
+
+UDP 发现失败时,系统不能把“没有发现设备”解释为“设备不存在”。管理员应切换到手工 IP 接入,通过 HTTPS 直接读取设备身份并完成同一套核验、绑定、配置和 MQTT 上线确认。
+
+## 3. 总体架构
+
+~~~text
+管理系统                                      边缘设备
+    |                                              |
+    | UDP 广播 discovery.request.v1                |
+    |--------------------------------------------->|
+    |                                              |
+    | UDP 单播 discovery.response.v1               |
+    |<---------------------------------------------|
+    |                                              |
+    | 管理员确认设备、停车场、岗亭和通道             |
+    |                                              |
+    | HTTPS POST /api/v1/provision                 |
+    |--------------------------------------------->|
+    |                                              | 保存配置、重启 MQTT 客户端
+    |                                              |
+    | MQTT CONNECT                                 |
+    |<---------------------------------------------|
+    |                                              |
+    | MQTT retained gate.state.v1                  |
+    |<---------------------------------------------|
+    |                                              |
+    | 接入状态更新为 已接入/在线                     |
+~~~
+
+| 通道 | 用途 | 生命周期 | 可靠性要求 |
+| --- | --- | --- | --- |
+| UDP 31001 | 发现待接入设备 | 仅配置前或人工重新配置 | 可重试,不承载敏感配置 |
+| HTTPS 8443 | 下发、查询和撤销设备配置 | 配置阶段 | 必须鉴权、签名、幂等 |
+| MQTT 1883 或 TLS 8883 | 状态、识别事件、指令和回执 | 正常运营阶段 | QoS 1、LWT、retained state |
+
+端口是建议默认值,最终允许设备固件和系统配置覆盖。管理系统侧端口必须在 Windows 防火墙和现场网络策略中放通。
+
+## 4. 状态模型
+
+设备接入状态与设备在线状态是两个不同维度,不能继续只使用现有设备表中的 online 或 offline。
+
+| 接入状态 provision_status | 含义 | 允许操作 |
+| --- | --- | --- |
+| unprovisioned | 已发现但未绑定业务通道 | 查看详情、发起配置 |
+| provisioning | 管理系统正在下发配置 | 查询进度、取消或重试 |
+| provisioned | 设备已确认保存配置,尚未收到 MQTT 在线状态 | 修改配置、等待上线 |
+| online | MQTT gate.state.v1 已确认 | 正常通行、重新配置 |
+| offline | 已接入设备当前无 MQTT 状态 | 查看原因、重新配置 |
+| failed | 最近一次配置失败 | 查看错误、修正后重试 |
+
+在线状态仍由 MQTT 维护。管理系统启动时会将启用 MQTT 设备暂置为 offline,收到 retained 的 gate.state.v1 后恢复 online。配置接口返回成功不等于设备已在线,必须收到 MQTT 状态消息才能认为接入完成。
+
+## 5. 数据设计
+
+建议在现有 uhf_reader 中补充以下字段,避免新建一张与设备主数据重复的表。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| provision_status | varchar(20) | 接入状态,默认 unprovisioned |
+| provision_method | varchar(20) | 接入方式,取 auto_udp 或 manual_ip |
+| provision_error | varchar(500) | 最近一次失败原因 |
+| provisioned_at | datetime | 设备确认保存配置的时间 |
+| last_discovered_at | datetime | 最近一次发现响应时间 |
+| discovered_ip | varchar(50) | 最近一次发现到的 IP,仅作辅助信息 |
+| config_port | int | 设备 HTTPS 配置端口 |
+| device_model | varchar(100) | 设备型号 |
+| firmware_version | varchar(100) | 固件版本 |
+| device_public_key | text | 设备身份公钥或证书指纹 |
+
+现有 ip_address 保留为管理员确认后的设备管理地址。discovered_ip 是一次发现结果,未经管理员确认时不能覆盖已生效的管理地址。
+
+建议增加 device_discovery 临时表保存发现结果,字段至少包括 request_id、device_id、source_ip、payload、verified、expired_at 和 created_at。发现记录默认 10 分钟过期,管理员确认绑定后再创建或更新 uhf_reader。
+
+## 6. 发现协议
+
+### 6.1 UDP 约束
+
+- 传输:IPv4 UDP。
+- 默认端口:31001。
+- 广播地址:优先使用本机网卡的定向广播地址,例如 192.168.10.255;允许管理员选择目标网卡或指定广播网段。
+- 请求重试:连续发送 3 次,间隔 500ms;收集响应窗口为 3 秒。
+- 单包上限:4KB;超出限制或 JSON 非法时直接丢弃。
+- 响应仅用于展示和建立 HTTPS 配置连接,不能据此直接开闸或修改业务数据。
+
+### 6.2 广播请求 discovery.request.v1
+
+~~~json
+{
+  "schema": "discovery.request.v1",
+  "request_id": "7ba3a5ea-3ca4-45fe-b59d-1dc47b6abda5",
+  "issued_at": 1760000000,
+  "expires_at": 1760000030,
+  "system_id": "parking-system-001",
+  "nonce": "base64-random-32-bytes"
+}
+~~~
+
+| 字段 | 必填 | 说明 |
+| --- | --- | --- |
+| schema | 是 | 固定 discovery.request.v1 |
+| request_id | 是 | UUID,用于关联响应 |
+| issued_at / expires_at | 是 | Unix 秒,最长有效期 30 秒 |
+| system_id | 是 | 管理系统安装实例标识 |
+| nonce | 是 | 32 字节随机值,防止响应重放 |
+
+### 6.3 单播响应 discovery.response.v1
+
+~~~json
+{
+  "schema": "discovery.response.v1",
+  "request_id": "7ba3a5ea-3ca4-45fe-b59d-1dc47b6abda5",
+  "device_id": "SN-20260819-0001",
+  "device_code": "EDGE-A01",
+  "device_type": "lpr-gate",
+  "device_model": "LPR-GW-200",
+  "firmware_version": "2.1.0",
+  "ip": "192.168.10.26",
+  "config_url": "https://192.168.10.26:8443/api/v1/provision",
+  "public_key_fingerprint": "SHA256:...",
+  "signing_public_key": "base64-ed25519-public-key",
+  "nonce": "base64-random-32-bytes",
+  "signature": "base64-signature"
+}
+~~~
+
+管理系统校验规则:
+
+1. request_id 和 nonce 必须与当前未过期发现任务一致。
+2. 响应中的 ip 必须等于 UDP 报文源 IP,防止响应将系统引导到第三方地址。
+3. config_url 的主机必须等于 UDP 源 IP,且只允许 HTTPS。
+4. device_id 是永久硬件身份;device_code 是业务编码,管理员确认后可覆盖。
+5. 系统必须校验 signature。`signing_public_key` 必须是 Base64 编码的 Ed25519 公钥,其 SHA256 必须与 `public_key_fingerprint` 一致;签名载荷以换行符连接 `schema`、`request_id`、`device_id`、`ip`、`config_url`、`nonce`,顺序固定。首次信任仍须通过扫描设备标签二维码或输入一次性配对码完成。
+
+## 7. 配置下发协议
+
+### 7.1 传输方式
+
+管理系统使用 HTTPS 调用设备的 POST /api/v1/provision。不能使用裸 TCP 或 UDP 承载配置命令,因为 Broker 地址、设备凭据和通道坐标均属于敏感配置。
+
+首次部署可采用设备出厂自签名证书,但管理系统必须将响应中的公钥指纹与设备标签或二维码中的指纹比对。生产环境推荐每台设备预置证书并使用双向 TLS。
+
+### 7.2 配置请求 provision.request.v1
+
+~~~json
+{
+  "schema": "provision.request.v1",
+  "request_id": "f0f2f011-cc14-4965-8a61-9a65b3ec2f0b",
+  "issued_at": 1760000000,
+  "expires_at": 1760000060,
+  "device_id": "SN-20260819-0001",
+  "device_code": "CAM-A01",
+  "mqtt": {
+    "host": "192.168.10.10",
+    "port": 8883,
+    "tls": true,
+    "client_id": "CAM-A01",
+    "username": "device/CAM-A01",
+    "password": "one-time-bootstrap-token",
+    "keep_alive_seconds": 30
+  },
+  "route": {
+    "parking_lot_id": 2,
+    "booth_id": 2,
+    "channel_id": 2,
+    "direction": "in"
+  },
+  "topic_prefix": "parking",
+  "config_version": 1,
+  "nonce": "base64-random-32-bytes",
+  "signature": "base64-signature"
+}
+~~~
+
+字段及处理要求:
+
+- mqtt.host 必须是设备可访问的局域网 IP 或 DNS 名称。系统必须拒绝下发 127.0.0.1、localhost 和 0.0.0.0。
+- 管理系统新增 mqtt.advertised-host、mqtt.advertised-port 和 mqtt.tls-enabled 配置,用于下发给设备;不能从 mqtt.broker 自动推导,因为后者可能包含系统本机地址。
+- mqtt.client_id、device_code 和 MQTT 用户名一一对应。
+- password 为一次性引导令牌;设备首次成功连接后应换取长期设备凭据,不能复用管理账户、数据库密码或公共密码。
+- route 仅能由管理员在页面上确认的停车场、岗亭和通道生成。设备不得自行声明业务坐标。
+- 相同 request_id 重复提交时,设备必须返回第一次处理结果,保证幂等。
+- 设备必须原子写入配置:写入失败时保留旧配置,不能只更新 Broker 地址而遗漏通道坐标。
+
+### 7.3 配置响应 provision.response.v1
+
+~~~json
+{
+  "schema": "provision.response.v1",
+  "request_id": "f0f2f011-cc14-4965-8a61-9a65b3ec2f0b",
+  "result": "accepted",
+  "message": "configuration saved; mqtt reconnecting",
+  "config_version": 1,
+  "applied_at": 1760000002,
+  "signature": "base64-signature"
+}
+~~~
+
+accepted 只表示设备已保存并准备重连,不代表 MQTT 已在线。管理系统在 30 秒内等待目标设备的 retained gate.state.v1;收到后标记 online,超时则标记 failed 并记录诊断原因。
+
+## 8. 配置完成后的 MQTT 行为
+
+设备保存配置并连接 Broker 后,必须按现有设备端对接文档执行以下动作:
+
+1. 使用 device_code 作为 MQTT ClientID。
+2. 配置 QoS 1、Keep Alive 30 秒和 retained LWT。
+3. 订阅自身通道的 gate/cmd Topic。
+4. 向 gate/lwt 发布 retained 空载荷,清除上一次异常断开遗留的离线遗嘱。
+5. 立即发布 retained 的 gate.state.v1。
+6. 相机使用 camera.event.v1 上报识别事件;道闸使用 gate.cmd.v1 和 gate.ack.v1 收发命令和回执。
+
+第 4 步不可省略。设备发生过异常断线时,旧的 retained LWT 可能在管理系统重启订阅后覆盖新的在线状态。
+
+## 9. 管理系统改造设计
+
+### 9.1 后端模块边界
+
+新功能按领域放入 internal/modules/device-provisioning,不直接塞入旧 internal/service/uhf。
+
+~~~text
+internal/modules/device-provisioning/
+├── api.go
+├── service/
+│   ├── discovery.go
+│   ├── provisioning.go
+│   ├── signature.go
+│   └── service.go
+├── repository/
+│   └── repo.go
+└── model/
+    ├── request/
+    └── response/
+~~~
+
+| 组件 | 职责 |
+| --- | --- |
+| discovery.go | 枚举本机 IPv4 网卡,发送定向广播,校验响应,缓存发现结果 |
+| provisioning.go | 校验管理员授权和业务绑定,调用设备 HTTPS 接口,等待 MQTT 确认 |
+| signature.go | HMAC 或 Ed25519 签名、Nonce、过期时间和证书指纹验证 |
+| repository | 保存临时发现记录、接入状态及审计信息 |
+| 既有 MQTT 模块 | 继续处理正常业务消息与在线状态,不处理 UDP 和 HTTPS 配置细节 |
+
+### 9.2 管理 API
+
+以下 API 必须只授予管理员角色:
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| POST | /device-provisioning/discover | 开始一次 3 秒局域网发现 |
+| GET | /device-provisioning/discoveries | 查询未过期发现结果 |
+| POST | /device-provisioning/:device_id/verify | 校验二维码、配对码或证书指纹 |
+| POST | /device-provisioning/:device_id/provision | 绑定停车场、岗亭、通道并下发 MQTT 配置 |
+| GET | /device-provisioning/:device_id/status | 查询配置、MQTT 确认和错误详情 |
+| POST | /device-provisioning/:device_id/revoke | 让设备删除 MQTT 凭据并回到待配置状态 |
+
+所有接口记录操作日志,至少包含操作人、目标硬件 ID、发现源 IP、配置前后路由、执行结果和失败原因。
+
+### 9.3 前端页面流程
+
+设备管理页面增加自动发现设备入口,但不替换既有 TCP 或串口手工新增方式。
+
+1. 管理员选择停车场后点击发现设备。
+2. 页面展示设备型号、序列号、IP、固件版本、信任状态和上次发现时间。
+3. 管理员核对设备物理标签或扫描二维码,完成首次信任。
+4. 管理员填写或确认设备名称、设备编码、设备类型、岗亭和通道。
+5. 页面展示将下发的 Broker 地址、端口、TLS 状态和 Topic,不显示密码明文。
+6. 管理员确认后开始配置,页面显示配置中。
+7. 收到 MQTT 状态后显示在线;超时显示配置已保存但未上线,并提供诊断和重试。
+
+设备编码冲突、通道已经绑定独占设备、设备型号不支持所选功能时,必须在配置下发前阻断。
+
+### 9.4 手工输入设备 IP 的正式备用接入
+
+手工 IP 接入与 UDP 自动发现是两条并列的生产接入路径。它不绕过身份核验、管理员授权、业务绑定或 MQTT 上线确认;唯一差别是设备地址由管理员输入,系统不依赖 UDP 广播获得地址。
+
+#### 适用场景
+
+以下任一情况成立时,管理员可以选择“手工输入 IP”入口:
+
+- 管理系统与设备跨 VLAN,路由器不转发 UDP 广播;
+- 交换机或防火墙禁止 UDP 广播;
+- 无线网络启用了客户端隔离,设备不能接收管理系统的广播;
+- 管理员通过 VPN 访问现场网络,广播域无法穿透 VPN;
+- 设备固件不支持本方案的 UDP 发现协议,但提供 HTTPS 配置接口。
+
+手工 IP 接入应在发现任务无结果、发现明确失败或管理员确认网络不支持广播时使用。系统应在接入记录中保存 `provision_method=manual_ip`,便于审计和后续运维统计。
+
+#### 页面流程
+
+1. 管理员在设备管理页面选择“手工输入 IP”,填写设备 IPv4 地址和 HTTPS 端口(默认 8443);端口必须是设备实际配置接口端口,不能把 MQTT 端口当作配置端口。
+2. 页面先在客户端和服务端校验 IP、端口及当前管理员权限,通过校验后才允许发起连接。页面明确提示这是正式接入流程,不能用于跳过设备身份确认。
+3. 系统使用 HTTPS `GET /api/v1/identity`(或设备端对接文档约定的身份查询接口)读取设备永久 ID、型号、固件版本、当前设备编码、证书公钥指纹和配对状态;读取失败不得创建或覆盖设备主数据。
+4. 管理员核对设备机身标签或二维码,确认 HTTPS 证书指纹,并输入一次性配对码(设备没有配对码时必须由受控的标签或初始化凭据替代)。证书指纹或配对码不匹配时停止流程,不能仅凭 IP 继续。
+5. 身份确认成功后,管理员选择并确认停车场、岗亭、通道、方向和设备业务编码。系统检查设备编码冲突、通道独占约束和设备能力,不满足时在下发前阻断。
+6. 页面展示待下发的 Broker 地址、端口、TLS、ClientID、Topic 和业务坐标,密码或一次性引导令牌只显示掩码。管理员二次确认后,系统通过同一 HTTPS 配置接口下发 MQTT 配置并记录请求 ID。
+7. 页面进入“配置中/等待 MQTT 上线”,在 30 秒内等待目标设备发布 retained `gate.state.v1`。收到且设备永久 ID、业务坐标和配置版本一致后标记“已接入/在线”;超时显示“配置已保存但未上线”,保留诊断和重试入口。
+
+#### IP 与 HTTPS 端口校验
+
+校验必须在前端和后端各执行一次,后端校验结果为准。输入框只接受字面 IP,不解析主机名或通过 DNS 重定向到其他地址。
+
+| 输入 | 处理 |
+| --- | --- |
+| `127.0.0.1`、任意 IPv4 `127.0.0.0/8` | 拒绝,禁止连接管理系统本机回环地址 |
+| `localhost`(不区分大小写)及其他主机名 | 拒绝,手工入口只接受 IP |
+| `0.0.0.0`、IPv4 未指定地址 `0.0.0.0/32` | 拒绝,不允许把监听地址当作设备地址 |
+| IPv4 定向广播地址(按管理系统网卡子网计算的主机位全 1)和 `255.255.255.255` | 拒绝,禁止把广播地址当作单台设备 |
+| IPv4 组播 `224.0.0.0/4`、IPv6 组播 `ff00::/8` | 拒绝,禁止通过组播地址连接配置接口 |
+| IPv6 回环 `::1`、未指定地址 `::` | 拒绝;若设备支持 IPv6,必须使用字面地址并按 HTTPS URL 规则加方括号 |
+| 其他合法单播 IP | 允许进入 HTTPS 身份读取阶段,但仍须通过证书指纹、永久 ID 和配对码确认 |
+| HTTPS 端口 | 仅允许 1-65535 的整数;连接必须使用 HTTPS,端口不可为空、不可为负数或超范围 |
+
+检测定向广播地址时使用管理系统当前网卡和路由表计算,不把任意普通主机地址误判为广播。连接期间禁止跟随 HTTP 重定向到不同主机;最终连接地址必须仍是管理员输入的 IP。
+
+#### 手工接入状态和异常处理
+
+手工流程沿用 `provision_status`,并增加 `provision_method` 和阶段错误码,避免把“身份未确认”和“MQTT 未上线”混为同一种失败:
+
+| 状态/阶段 | 含义与处理 |
+| --- | --- |
+| manual_input | 已提交 IP 和端口,正在做本地及服务端格式校验 |
+| manual_connecting | 正在建立 HTTPS 连接和读取设备身份;超时可重试,不改变既有设备配置 |
+| identity_unverified | 已读取身份,等待证书指纹和配对码确认;此状态禁止下发配置 |
+| manual_verified | 身份已确认,等待管理员完成停车场/岗亭/通道绑定和二次确认 |
+| provisioning | 已通过 HTTPS 下发 MQTT 配置,等待设备回执 |
+| provisioned | 设备已确认保存配置,尚未收到 `gate.state.v1` |
+| online | 收到并校验目标设备的 `gate.state.v1` |
+| failed | 任一阶段失败;保存阶段、错误码、原始 IP、端口和 request_id,修正后可重试 |
+
+IP 不合法、端口不可用、HTTPS 证书不匹配、设备永久 ID 与既有记录不一致、配对码错误、设备拒绝配置、Broker 不可达或 30 秒未上线,都必须显示可操作的原因。重试只能从失败阶段重新开始;身份未确认或身份冲突时,必须重新执行身份确认,不能复用旧的信任结果。
+
+## 10. 安全要求
+
+自动发现降低部署成本,也扩大局域网攻击面。以下是上线前最低要求:
+
+1. 发现请求不得携带 Broker 密码、设备密钥和停车场业务数据。
+2. 发现响应与配置请求必须带 request_id、nonce、签发时间和过期时间。
+3. 设备使用预置私钥对响应和配置结果签名;管理系统使用设备公钥或证书指纹验证。
+4. 管理系统配置请求同样必须由安装密钥签名;设备拒绝未签名、过期或重放请求。
+5. 配置接口必须使用 HTTPS;生产环境使用双向 TLS。
+6. MQTT 应从当前匿名接入升级为设备级账号或证书,并按 device_code 限制 Topic ACL。
+7. 配置失败、设备身份不一致、证书变化和重复配对都记录异常或审计日志。
+8. 发现接口不能覆盖已在线设备的配置;重新配置必须由管理员显式发起。
+9. 不得将 MQTT 管理账户、数据库密码和长期系统 JWT 下发给边缘端。
+10. 手工 IP 接入必须使用 HTTPS;系统固定校验证书公钥指纹,并禁止跟随到其他主机的重定向。
+11. 每次手工接入都必须核对设备永久 ID 与证书/配对码,永久 ID 不得由管理员输入,也不能只以 IP 作为设备身份。
+12. 手工接入入口和重新配置入口只授予管理员;操作审计至少记录操作人、输入 IP、HTTPS 端口、证书指纹、永久 ID、配对结果、绑定前后路由、request_id、结果和失败原因,敏感令牌不得写入日志。
+
+## 11. 异常与恢复策略
+
+| 场景 | 系统处理 |
+| --- | --- |
+| 未发现设备 | 显示未发现,提示检查供电、同网段、防火墙和 UDP 端口 |
+| 响应 IP 与 UDP 源 IP 不一致 | 丢弃响应并记录安全日志 |
+| 设备证书指纹不匹配 | 禁止配置,要求管理员重新确认物理身份 |
+| HTTPS 配置超时 | 标记 failed,不修改既有已生效设备路由 |
+| 设备返回已保存但 30 秒未 MQTT 上线 | 标记 provisioned 或 failed,提示检查 Broker IP、防火墙和凭据 |
+| MQTT 连上后再次离线 | 复用既有 LWT、重连和状态恢复机制,更新 offline |
+| 管理系统重启 | MQTT 设备先保守 offline,收到 retained gate.state.v1 后恢复 online;发现任务不恢复 |
+| 网络 IP 变化 | 重新发现后展示新的 discovered_ip,管理员确认后可以重新下发配置 |
+| UDP 自动发现不可用 | 转入手工 IP 入口;不降低 HTTPS、证书指纹、永久 ID、配对码和管理员授权要求 |
+| 手工输入 IP 不合法或为回环/广播/组播地址 | 本地和服务端均拒绝,不能发起网络连接,并提示修正地址 |
+| 手工 IP 的 HTTPS 端口拒绝连接或超时 | 保持既有配置和绑定不变,状态为 failed,记录端口、错误码并提供重试 |
+| 手工 IP 身份接口返回的永久 ID 已绑定其他设备 | 阻断流程并记录安全审计,不得覆盖原设备或按新设备创建 |
+| 手工 IP 证书指纹或配对码不匹配 | 状态为 identity_unverified/failed,禁止下发 MQTT 配置,要求重新核对现场标签 |
+
+## 12. 分阶段实施计划
+
+### 阶段 1:协议与基础数据
+
+- 确认边缘端固件能提供 UDP 发现监听和 HTTPS 配置 API。
+- 确认永久设备 ID、设备证书或密钥的出厂写入方式。
+- 为 uhf_reader 和临时发现记录增加接入状态字段。
+- 新增 mqtt.advertised-host、mqtt.advertised-port、mqtt.tls-enabled 配置。
+- 冻结 discovery.*.v1 与 provision.*.v1 的 JSON Schema。
+
+验收:模拟边缘端能响应发现请求,管理系统能校验并保存发现记录。
+
+### 阶段 2:管理系统发现与页面
+
+- 实现 device-provisioning 后端模块、管理 API 与权限。
+- 实现多网卡定向广播,不使用全网段端口扫描。
+- 在设备管理页面增加待接入设备列表、手工 IP 接入、身份核验和通道绑定流程。
+- 增加操作日志与异常记录。
+
+验收:管理员可发现多个模拟设备;在跨 VLAN 或 UDP 被禁时,可通过手工 IP 完成身份核验和通道绑定;未核验设备不能下发配置。
+
+### 阶段 3:安全配置下发
+
+- 实现 HTTPS 配置请求、证书指纹校验、签名、Nonce 与幂等处理。
+- 实现一次性 MQTT 引导令牌和设备级 MQTT 账号或 ACL。
+- 实现配置回执、30 秒 MQTT 确认等待和失败诊断。
+
+验收:未配置设备可绑定到指定通道并上线;错误 Broker 地址能诊断为配置已保存但未上线。
+
+### 阶段 4:现场联调与恢复演练
+
+- 覆盖管理系统重启、Broker 重启、设备断电、设备换 IP、重复配置与恶意响应。
+- 确认边缘端清除旧 retained LWT 后再发布 retained state。
+- 完成防火墙、交换机 VLAN、TLS 证书和备份恢复检查。
+
+验收:发现、身份确认、配置、MQTT 在线、车牌入场、开闸回执、设备离线和重连恢复全流程可重复通过。
+
+## 13. 测试清单
+
+| 分类 | 用例 | 预期结果 |
+| --- | --- | --- |
+| 发现 | 同一网段 1 台设备 | 3 秒内出现 1 条待接入记录 |
+| 发现 | 同一网段 20 台设备同时响应 | 按 device_id 去重展示,无阻塞 |
+| 发现 | 伪造 request_id 或过期响应 | 被丢弃并记录安全日志 |
+| 发现 | 响应载荷大于 4KB | 被丢弃,不影响服务 |
+| 配置 | 正确签名和证书 | 设备保存配置并 MQTT 上线 |
+| 配置 | 错误签名、过期时间或硬件 ID | 设备拒绝,系统显示失败原因 |
+| 配置 | 相同 request_id 重试 | 设备幂等返回,不重复重启或创建凭据 |
+| MQTT | Broker 地址为 127.0.0.1 | 下发前拒绝 |
+| MQTT | Broker 防火墙未放行 | 配置保存后 30 秒未上线,给出诊断 |
+| 恢复 | 管理系统重启 | retained state 使设备恢复在线 |
+| 恢复 | 设备断电再上线 | LWT 离线,清除旧 LWT 并发布 state 后恢复在线 |
+| 权限 | 操作员调用配置 API | 无权限,不能下发配置 |
+| 手工接入 | 输入 `127.0.0.1`、`localhost`、`0.0.0.0` | 前后端均拒绝,不发起 HTTPS 连接 |
+| 手工接入 | 输入 IPv4 定向广播、`255.255.255.255` 或 IPv4/IPv6 组播地址 | 拒绝并提示地址不能指向广播或组播 |
+| 手工接入 | 输入合法单播 IP 和有效 HTTPS 端口 | 成功读取身份,进入证书/配对码确认,不自动下发配置 |
+| 手工接入 | 端口为空、非整数、0、负数或大于 65535 | 拒绝提交,不能建立连接 |
+| 手工接入 | HTTPS 证书指纹与标签不一致 | 阻断配置并写入安全审计,设备保持原配置 |
+| 手工接入 | 永久 ID 与已有设备记录不一致或已被绑定 | 阻断配置,不创建重复设备,不覆盖原绑定 |
+| 手工接入 | 配对码错误、过期或重复使用 | 身份保持未确认,限制重试并记录审计 |
+| 手工接入 | 正确身份确认后绑定停车场/岗亭/通道并下发 | 设备返回 accepted,等待 `gate.state.v1` 后才标记 online |
+| 手工接入 | HTTPS 配置成功但 30 秒未收到 `gate.state.v1` | 标记 provisioned 或 failed,展示 Broker、防火墙和凭据诊断,可重试 |
+| 手工接入 | 非管理员访问手工接入、身份确认或配置 API | 返回无权限,不读取身份、不下发配置,并记录审计 |
+| 手工接入 | 管理员完成一次成功接入 | 审计包含操作人、IP、端口、证书指纹、永久 ID、绑定和结果,不记录令牌明文 |
+
+## 14. 上线前决策
+
+实施前必须确认以下事项:
+
+1. 边缘端是否由本项目团队控制固件,能否实现 UDP 与 HTTPS 接入代理。
+2. 现场网络是否允许 UDP 广播;若不允许,单台或少量设备使用本方案手工 IP 接入,批量设备再评估边缘网关、VPN 或 mDNS 方案。
+3. 管理系统是否有稳定的局域网 IP 或 DNS 名称。这是 mqtt.advertised-host 的前提。
+4. MQTT 是否在本期升级到 TLS 和设备级账号。生产环境不建议延续匿名 Broker。
+5. 设备与通道是否一对一。若一台网关代理多台相机或道闸,永久设备 ID 与业务设备编码必须分开建模。
+
+在上述前提未确认前,不应将主动获取 IP、下发 Broker 地址的逻辑直接写入既有 MQTT 开关闸代码。该能力属于设备首次接入和配置管理,不属于正常通行控制。

+ 6 - 0
frontend/src/api/camera.js

@@ -0,0 +1,6 @@
+import service from '@/utils/request'
+
+export const getCameraStreams = () => service({
+  url: '/parking/camera/streams',
+  method: 'get'
+})

+ 44 - 2
frontend/src/api/device.js

@@ -35,11 +35,12 @@ export const obtainDeviceCode = (code) => {
 }
 
 // 获取设备列表
-export const obtainDeviceList = (params) => {
+export const obtainDeviceList = (params, requestOptions = {}) => {
   return service({
     url: '/parking/device/list',
     method: 'get',
-    params
+    params,
+    ...requestOptions,
   })
 }
 
@@ -58,3 +59,44 @@ export const joinDevice = (id) => {
     method: 'get'
   })
 }
+
+// UDP 自动发现:候选设备仍需通过 HTTPS 身份核验后才能配置。
+export const discoverDevices = (data = {}) => service({
+  url: '/device-provisioning/discover',
+  method: 'post',
+  data,
+})
+
+export const getDiscoveredDevices = () => service({
+  url: '/device-provisioning/discoveries',
+  method: 'get',
+})
+
+export const readDiscoveredDeviceIdentity = (deviceId) => service({
+  url: `/device-provisioning/${encodeURIComponent(deviceId)}/identity`,
+  method: 'post',
+})
+
+// 手工 IP 接入:读取身份、确认指纹/配对码、下发配置和查询状态
+export const readManualDeviceIdentity = (data) => service({
+  url: '/device-provisioning/manual/identity',
+  method: 'post',
+  data,
+})
+
+export const verifyManualDeviceIdentity = (deviceId, data) => service({
+  url: `/device-provisioning/${encodeURIComponent(deviceId)}/verify`,
+  method: 'post',
+  data,
+})
+
+export const provisionManualDevice = (deviceId, data) => service({
+  url: `/device-provisioning/${encodeURIComponent(deviceId)}/provision`,
+  method: 'post',
+  data,
+})
+
+export const getDeviceProvisionStatus = (deviceId) => service({
+  url: `/device-provisioning/${encodeURIComponent(deviceId)}/status`,
+  method: 'get',
+})

+ 0 - 6
frontend/src/api/shift.js

@@ -1,6 +0,0 @@
-import service from '@/utils/request'
-
-export const startShift = (data) => service({ url: '/shift/start', method: 'post', data })
-export const endShift = (data) => service({ url: '/shift/end', method: 'post', data })
-export const getCurrentShift = () => service({ url: '/shift/current', method: 'get' })
-export const getShiftList = (params) => service({ url: '/shift/list', method: 'get', params })

+ 8 - 1
frontend/src/lang/en.js

@@ -266,6 +266,14 @@ export default {
 	workbenchGateOpenSuccess: 'Open command sent',
 	workbenchGateCloseSuccess: 'Close command sent',
 	workbenchGateOperationFailed: 'Gate operation failed',
+	workbenchCamera: 'Live monitor',
+	workbenchCameraSelectGate: 'Select a gate to view its camera',
+	workbenchCameraNoStream: 'No playback URL configured for this camera',
+	workbenchCameraPlaying: 'Video connected',
+	workbenchCameraConnecting: 'Connecting',
+	workbenchCameraOffline: 'Camera offline',
+	workbenchCameraError: 'Video connection failed',
+	workbenchCameraRetry: 'Reconnect',
 	workbenchBusinessDoneGateFailed: 'Business completed, but the gate did not open',
 	workbenchPaymentUnavailable: 'No payment method is available at this counter',
 	workbenchExactAmount: 'Use amount due',
@@ -284,7 +292,6 @@ export default {
 	workbenchMinute: 'm',
   paymentRecord: 'Payment Record',
   monthlyCard: 'Monthly Card',
-  shiftRecord: 'Shift Record',
 	revenueReport: 'Revenue Report',
 	paymentMethodConfig: 'Payment Methods',
 	paymentEntryConfig: 'Payment Entries',

+ 8 - 1
frontend/src/lang/zh-CN.js

@@ -266,6 +266,14 @@ export default {
 	workbenchGateOpenSuccess: '开闸指令已发送',
 	workbenchGateCloseSuccess: '关闸指令已发送',
 	workbenchGateOperationFailed: '道闸操作失败',
+	workbenchCamera: '实时监控',
+	workbenchCameraSelectGate: '请选择道闸后查看对应摄像头',
+	workbenchCameraNoStream: '当前摄像头未配置播放地址',
+	workbenchCameraPlaying: '视频已连接',
+	workbenchCameraConnecting: '正在连接',
+	workbenchCameraOffline: '摄像头离线',
+	workbenchCameraError: '视频连接失败',
+	workbenchCameraRetry: '重新连接',
 	workbenchBusinessDoneGateFailed: '业务已完成,但道闸未打开',
 	workbenchPaymentUnavailable: '收费岗亭没有可用的支付方式',
 	workbenchExactAmount: '按应收金额',
@@ -284,7 +292,6 @@ export default {
 	workbenchMinute: '分钟',
   paymentRecord: '收费记录',
   monthlyCard: '包月车管理',
-  shiftRecord: '交接班管理',
 	revenueReport: '收入报表',
 	paymentMethodConfig: '支付方式',
 	paymentEntryConfig: '支付入口',

+ 8 - 1
frontend/src/lang/zh-HK.js

@@ -262,6 +262,14 @@ export default {
 	workbenchGateOpenSuccess: '開閘指令已傳送',
 	workbenchGateCloseSuccess: '關閘指令已傳送',
 	workbenchGateOperationFailed: '道閘操作失敗',
+	workbenchCamera: '即時監控',
+	workbenchCameraSelectGate: '請先選擇道閘以查看對應攝像頭',
+	workbenchCameraNoStream: '目前攝像頭未設定播放地址',
+	workbenchCameraPlaying: '影片已連線',
+	workbenchCameraConnecting: '正在連線',
+	workbenchCameraOffline: '攝像頭離線',
+	workbenchCameraError: '影片連線失敗',
+	workbenchCameraRetry: '重新連線',
 	workbenchBusinessDoneGateFailed: '業務已完成,但道閘未開啟',
 	workbenchPaymentUnavailable: '收費崗亭沒有可用的付款方式',
 	workbenchExactAmount: '按應收金額',
@@ -280,7 +288,6 @@ export default {
 	workbenchMinute: '分鐘',
   paymentRecord: '收費記錄',
   monthlyCard: '包月車管理',
-  shiftRecord: '交接班管理',
 	revenueReport: '收入報表',
 	paymentMethodConfig: '付款方式',
 	paymentEntryConfig: '付款入口',

+ 8 - 1
frontend/src/lang/zh-TW.js

@@ -262,6 +262,14 @@ export default {
 	workbenchGateOpenSuccess: '開啟指令已傳送',
 	workbenchGateCloseSuccess: '關閉指令已傳送',
 	workbenchGateOperationFailed: '柵欄機操作失敗',
+	workbenchCamera: '即時監控',
+	workbenchCameraSelectGate: '請先選擇柵欄機以查看對應攝影機',
+	workbenchCameraNoStream: '目前攝影機未設定播放地址',
+	workbenchCameraPlaying: '影片已連線',
+	workbenchCameraConnecting: '正在連線',
+	workbenchCameraOffline: '攝影機離線',
+	workbenchCameraError: '影片連線失敗',
+	workbenchCameraRetry: '重新連線',
 	workbenchBusinessDoneGateFailed: '業務已完成,但柵欄機未開啟',
 	workbenchPaymentUnavailable: '收費崗亭沒有可用的付款方式',
 	workbenchExactAmount: '按應收金額',
@@ -280,7 +288,6 @@ export default {
 	workbenchMinute: '分鐘',
   paymentRecord: '收費記錄',
   monthlyCard: '包月車管理',
-  shiftRecord: '交接班管理',
 	revenueReport: '收入報表',
 	paymentMethodConfig: '付款方式',
 	paymentEntryConfig: '付款入口',

+ 28 - 16
frontend/src/utils/request.js

@@ -16,28 +16,46 @@ const showLoading = (option = {
 }) => {
   const loadDom = document.getElementById('gva-base-load-dom')
   activeAxios++
-  if (timer) {
-    clearTimeout(timer)
-  }
+  // One delayed timer is shared by concurrent requests. Restarting it for
+  // every request lets an earlier response cancel the timer for a later one.
+  if (timer) return
   timer = setTimeout(() => {
+    timer = null
     if (activeAxios > 0) {
       if (!option.target) option.target = loadDom
-      loadingInstance = ElLoading.service(option)
+      if (!loadingInstance) loadingInstance = ElLoading.service(option)
     }
   }, 400)
 }
 
 const closeLoading = () => {
-  activeAxios--
+  activeAxios = Math.max(0, activeAxios - 1)
   if (activeAxios <= 0) {
-    clearTimeout(timer)
-    loadingInstance && loadingInstance.close()
+    if (timer) {
+      clearTimeout(timer)
+      timer = null
+    }
+    if (loadingInstance) {
+      loadingInstance.close()
+      loadingInstance = null
+    }
+  }
+}
+
+// Axios may invoke an error interceptor without a config (for example for a
+// cancelled request). Track the increment on the request config so every
+// request is decremented at most once and missing configs are harmless.
+const finishLoading = (config) => {
+  if (config && config.__loadingTracked) {
+    config.__loadingTracked = false
+    closeLoading()
   }
 }
 // http request 拦截器
 service.interceptors.request.use(
   config => {
     if (!config.donNotShowLoading) {
+      config.__loadingTracked = true
       showLoading(config.loadingOption)
     }
     const userStore = useUserStore()
@@ -50,9 +68,7 @@ service.interceptors.request.use(
     return config
   },
   error => {
-    if (!error.config.donNotShowLoading) {
-      closeLoading()
-    }
+    finishLoading(error?.config)
     ElMessage({
       showClose: true,
       message: error,
@@ -66,9 +82,7 @@ service.interceptors.request.use(
 service.interceptors.response.use(
   response => {
     const userStore = useUserStore()
-    if (!response.config.donNotShowLoading) {
-      closeLoading()
-    }
+    finishLoading(response?.config)
     if (response.headers['new-token']) {
       userStore.setToken(response.headers['new-token'])
     }
@@ -87,9 +101,7 @@ service.interceptors.response.use(
     }
   },
   error => {
-    if (!error.config.donNotShowLoading) {
-      closeLoading()
-    }
+    finishLoading(error?.config)
 
     if (!error.response) {
       ElMessageBox.confirm(`

+ 24 - 1
frontend/src/view/parking/carInfo.vue

@@ -169,7 +169,15 @@
           <el-input
             v-model="formData.rfid_tag"
             :placeholder="$t.value.rfidLabel"
-          />
+          >
+            <template #append>
+              <el-button
+                :icon="MagicStick"
+                title="移除空格并转换为小写"
+                @click="convertRFIDTag"
+              >转换</el-button>
+            </template>
+          </el-input>
         </el-form-item>
         <el-form-item :label="$t.value.VehicleType">
           <el-select
@@ -327,6 +335,7 @@
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { MagicStick } from '@element-plus/icons-vue'
 import { t } from '@/lang/index.js'
 
 import {
@@ -371,6 +380,19 @@ const formData = reactive({
   owner_id: null
 })
 
+// 统一电子标签格式:移除空白并转换为小写,便于与读卡器上报值精确匹配。
+const normalizeRFIDTag = (value) => String(value || '').replace(/\s+/g, '').toLowerCase()
+
+const convertRFIDTag = () => {
+  const normalized = normalizeRFIDTag(formData.rfid_tag)
+  if (!normalized) {
+    ElMessage.warning('请先输入电子标签')
+    return
+  }
+  formData.rfid_tag = normalized
+  ElMessage.success('电子标签格式已转换')
+}
+
 const getVehicleList = async() => {
   try {
     const res = await obtainVehicleList(queryForm)
@@ -410,6 +432,7 @@ const submitForm = async() => {
     ElMessage.warning(t.value.carPlateInput)
     return
   }
+  formData.rfid_tag = normalizeRFIDTag(formData.rfid_tag)
   try {
     if (formData.ID > 0) {
       await editVehicle(formData).then(res => {

+ 9 - 3
frontend/src/view/parking/carOwner.vue

@@ -201,7 +201,8 @@ const changeVip = (val) => {
 const obtainData = reactive({
   owner_name: '',
   owner_phone: '',
-  is_vip: true,
+  // 默认不限制 VIP 类型,查询全部车主。
+  is_vip: null,
   page: 1,
   pageSize: 10
 })
@@ -282,8 +283,13 @@ const openCarOwner = (head, data) => {
 }
 
 const carOwnerOperation = async() => {
+  const payload = {
+    ...carOwnerData,
+    // 未设置 VIP 过期时间时传 null,避免后端 time.Time 解析空字符串失败。
+    vip_expire_time: carOwnerData.vip_expire_time || null
+  }
   if (carOwnerTitle.value === t.value.AddCarOwner) {
-    await addCarOwner(carOwnerData).then(res => {
+    await addCarOwner(payload).then(res => {
       if (res.code === 0) {
         ElMessage.success(t.value.addSuccess)
         carOwnerShow.value = false
@@ -293,7 +299,7 @@ const carOwnerOperation = async() => {
       }
     })
   } else {
-    await editCarOwner(carOwnerData).then(res => {
+    await editCarOwner(payload).then(res => {
       if (res.code === 0) {
         ElMessage.success(t.value.editSuccess)
         carOwnerShow.value = false

+ 52 - 15
frontend/src/view/parking/monthlyCard.vue

@@ -26,11 +26,11 @@
       <el-table-column label="生效日期" prop="start_date" align="center" width="120" />
       <el-table-column label="到期日期" prop="end_date" align="center" width="120" />
       <el-table-column label="金额" align="center" width="100">
-        <template #default="{ row }">¥{{ row.fee.toFixed(2) }}</template>
+        <template #default="{ row }">¥{{ Number(row.fee || 0).toFixed(2) }}</template>
       </el-table-column>
       <el-table-column label="状态" align="center" width="80">
         <template #default="{ row }">
-          <el-tag :type="row.payment_status === 'paid' ? 'success' : 'danger'">{{ row.payment_status === 'paid' ? '有效' : '已退' }}</el-tag>
+          <el-tag :type="row.payment_status === 'paid' ? 'success' : 'danger'">{{ row.payment_status === 'paid' ? '有效' : '已退' }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column :label="$t.value.Action" align="center" width="160">
@@ -59,7 +59,10 @@
             <el-option label="年卡 (365天)" value="year" />
           </el-select>
         </el-form-item>
-        <el-form-item label="到期日期">{{ computeEnd(createForm.card_type) }}</el-form-item>
+        <el-form-item label="生效日期" required>
+          <el-date-picker v-model="createForm.effective_date" type="date" value-format="YYYY-MM-DD" format="YYYY-MM-DD" :disabled-date="disableBeforeToday" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="到期日期">{{ computeEnd(createForm.effective_date, createForm.card_type) }}</el-form-item>
         <el-form-item label="金额" required>
           <el-input-number v-model="createForm.fee" :min="0" :precision="2" style="width:100%" />
         </el-form-item>
@@ -79,6 +82,10 @@
             <el-option label="年卡 (365天)" value="year" />
           </el-select>
         </el-form-item>
+        <el-form-item label="生效日期" required>
+          <el-date-picker v-model="renewForm.effective_date" type="date" value-format="YYYY-MM-DD" format="YYYY-MM-DD" :disabled-date="disableBeforeToday" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="到期日期">{{ computeEnd(renewForm.effective_date, renewForm.card_type) }}</el-form-item>
         <el-form-item label="金额" required>
           <el-input-number v-model="renewForm.fee" :min="0" :precision="2" style="width:100%" />
         </el-form-item>
@@ -100,28 +107,57 @@ import { queryAllVehicles } from '@/api/vehicle'
 const days = { month: 30, quarter: 90, year: 365 }
 const labels = { month: '月卡', quarter: '季卡', year: '年卡' }
 const typeLabel = (t) => labels[t] || t
-
 const queryForm = reactive({ plate_number: '', card_type: '', page: 1, page_size: 10 })
-const list = ref([]), total = ref(0), vehicleList = ref([])
-const createVisible = ref(false), renewVisible = ref(false), submitting = ref(false)
-const createForm = reactive({ vehicle_id: null, card_type: 'month', fee: 0 })
-const renewForm = reactive({ card_id: null, card_type: 'month', fee: 0 })
+const list = ref([])
+const total = ref(0)
+const vehicleList = ref([])
+const createVisible = ref(false)
+const renewVisible = ref(false)
+const submitting = ref(false)
+
+function todayString() {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+const createForm = reactive({ vehicle_id: null, card_type: 'month', effective_date: todayString(), fee: 0 })
+const renewForm = reactive({ card_id: null, card_type: 'month', effective_date: todayString(), fee: 0 })
 
-function computeEnd(t) {
-  const d = new Date(); d.setDate(d.getDate() + (days[t] || 30))
+function disableBeforeToday(date) {
+  const today = new Date()
+  today.setHours(0, 0, 0, 0)
+  return date < today
+}
+
+function computeEnd(start, type) {
+  const d = new Date(start || todayString())
+  d.setDate(d.getDate() + (days[type] || 30))
   return d.toLocaleDateString('zh-CN')
 }
 
-onMounted(() => { fetchList(); queryAllVehicles().then(r => { if (r.code === 0) vehicleList.value = r.data || [] }) })
+onMounted(() => {
+  fetchList()
+  queryAllVehicles().then(r => { if (r.code === 0) vehicleList.value = r.data || [] })
+})
 
-function fetchList() { getMonthlyCardList(queryForm).then(r => { if (r.code === 0) { list.value = r.data.list || []; total.value = r.data.total || 0 } }) }
-function resetQuery() { queryForm.plate_number = ''; queryForm.card_type = ''; queryForm.page = 1; fetchList() }
+function fetchList() {
+  getMonthlyCardList(queryForm).then(r => {
+    if (r.code === 0) { list.value = r.data.list || []; total.value = r.data.total || 0 }
+  })
+}
 
-function openCreate() { createForm.vehicle_id = null; createForm.card_type = 'month'; createForm.fee = 0; createVisible.value = true }
-function openRenew(row) { renewForm.card_id = row.id; renewForm.card_type = 'month'; renewForm.fee = 0; renewVisible.value = true }
+function resetQuery() { queryForm.plate_number = ''; queryForm.card_type = ''; queryForm.page = 1; fetchList() }
+function openCreate() { Object.assign(createForm, { vehicle_id: null, card_type: 'month', effective_date: todayString(), fee: 0 }); createVisible.value = true }
+function openRenew(row) {
+  const endDate = row.end_date ? row.end_date.slice(0, 10) : ''
+  Object.assign(renewForm, { card_id: row.id, card_type: 'month', effective_date: endDate && endDate > todayString() ? endDate : todayString(), fee: 0 })
+  renewVisible.value = true
+}
 
 async function doCreate() {
   if (!createForm.vehicle_id) { ElMessage.warning('请选择车辆'); return }
+  if (!createForm.effective_date) { ElMessage.warning('请选择生效日期'); return }
   submitting.value = true
   try {
     const r = await createMonthlyCard(createForm)
@@ -130,6 +166,7 @@ async function doCreate() {
 }
 
 async function doRenew() {
+  if (!renewForm.effective_date) { ElMessage.warning('请选择生效日期'); return }
   submitting.value = true
   try {
     const r = await renewMonthlyCard(renewForm)

+ 302 - 6
frontend/src/view/parking/parkingInfo/device.vue

@@ -4,6 +4,12 @@
       <el-form-item>
         <el-button type="success" icon="Plus" @click="openDialog('add')">新增</el-button>
       </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="Search" @click="openAutoDiscovery">自动发现</el-button>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="warning" icon="Connection" @click="openManualProvision">手工接入</el-button>
+      </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="Refresh" @click="fetchData">刷新</el-button>
       </el-form-item>
@@ -14,8 +20,8 @@
       <el-table-column label="名称" align="center" width="180" prop="device_name" />
       <el-table-column label="连接方式" align="center" width="120">
         <template #default="scope">
-          <el-tag :type="scope.row.connect_type === 'tcp' ? 'primary' : 'warning'">
-            {{ scope.row.connect_type === 'tcp' ? 'TCP' : '串口' }}
+          <el-tag :type="scope.row.connect_type === 'tcp' ? 'primary' : scope.row.connect_type === 'mqtt' ? 'success' : 'warning'">
+            {{ scope.row.connect_type === 'tcp' ? 'TCP' : scope.row.connect_type === 'mqtt' ? 'MQTT' : '串口' }}
           </el-tag>
         </template>
       </el-table-column>
@@ -37,7 +43,7 @@
       </el-table-column>
       <el-table-column label="操作" align="center" width="280" fixed="right">
         <template #default="scope">
-          <el-button text type="success" icon="Link" @click="handleConnect(scope.row)">连接</el-button>
+          <el-button v-if="scope.row.connect_type !== 'mqtt'" text type="success" icon="Link" @click="handleConnect(scope.row)">连接</el-button>
           <el-button text type="primary" icon="Edit" @click="openDialog('edit', scope.row)">修改</el-button>
           <el-button text type="primary" icon="Delete" @click="handleDelete(scope.row.ID)">删除</el-button>
         </template>
@@ -82,6 +88,7 @@
           <el-select v-model="form.connect_type">
             <el-option label="TCP" value="tcp" />
             <el-option label="串口" value="serial" />
+            <el-option label="MQTT" value="mqtt" />
           </el-select>
         </el-form-item>
         <el-form-item v-if="form.connect_type === 'tcp'" label="IP地址:">
@@ -108,17 +115,122 @@
         <el-button type="primary" @click="submitForm">确认</el-button>
       </template>
     </el-dialog>
+
+    <el-dialog v-model="discoveryVisible" title="自动发现局域网设备" width="860" :close-on-click-modal="false">
+      <el-alert
+        title="系统将在可用 IPv4 网卡上发送三次 UDP 定向广播。发现结果必须完成 HTTPS 证书和配对确认后才能绑定和下发配置。"
+        type="info"
+        :closable="false"
+        show-icon
+      />
+      <el-form inline style="margin: 16px 0 8px">
+        <el-form-item label="指定广播地址">
+          <el-input v-model="discoveryBroadcastAddress" placeholder="留空自动使用网卡定向广播,例如 192.168.10.255" style="width: 360px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="Search" :loading="discoveryLoading" @click="runAutoDiscovery">开始发现</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="discoveryList" v-loading="discoveryLoading" size="small" max-height="360" border stripe>
+        <el-table-column prop="device_id" label="永久设备 ID" min-width="160" />
+        <el-table-column prop="device_model" label="型号" min-width="120" />
+        <el-table-column prop="firmware_version" label="固件" width="100" />
+        <el-table-column prop="ip" label="IP" width="140" />
+        <el-table-column prop="config_port" label="HTTPS 端口" width="110" />
+        <el-table-column label="信任状态" width="120">
+          <template #default="scope"><el-tag type="warning">待 HTTPS 核验</el-tag></template>
+        </el-table-column>
+        <el-table-column label="操作" width="130" fixed="right">
+          <template #default="scope"><el-button text type="primary" :loading="discoveryIdentityLoading === scope.row.device_id" @click="useDiscoveredDevice(scope.row)">核验并接入</el-button></template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-if="!discoveryLoading && discoveryList.length === 0" description="尚未发现设备,可检查 VLAN、UDP 广播和防火墙,或使用手工接入。" :image-size="72" />
+      <template #footer><el-button @click="discoveryVisible = false">关闭</el-button></template>
+    </el-dialog>
+
+    <el-dialog v-model="manualVisible" title="手工输入设备 IP 接入" width="680" :close-on-click-modal="false">
+      <el-steps :active="manualStep" finish-status="success" simple>
+        <el-step title="读取身份" />
+        <el-step title="确认身份" />
+        <el-step title="绑定并下发" />
+        <el-step title="等待上线" />
+      </el-steps>
+
+      <el-alert
+        v-if="manualStep === 0"
+        title="适用于跨 VLAN、UDP 广播被禁、无线隔离、VPN 或设备不支持发现协议的正式接入。"
+        type="info"
+        :closable="false"
+        show-icon
+        style="margin: 18px 0"
+      />
+      <el-form v-if="manualStep === 0" label-width="130" style="margin-top: 20px">
+        <el-form-item label="设备 IP" required>
+          <el-input v-model="manual.ip" placeholder="仅支持字面 IPv4/IPv6 地址" />
+        </el-form-item>
+        <el-form-item label="HTTPS 端口" required>
+          <el-input-number v-model="manual.port" :min="1" :max="65535" controls-position="right" />
+        </el-form-item>
+      </el-form>
+
+      <el-descriptions v-if="manualIdentity" :column="2" border style="margin-top: 18px">
+        <el-descriptions-item label="永久设备 ID">{{ manualIdentity.device_id }}</el-descriptions-item>
+        <el-descriptions-item label="设备编码">{{ manualIdentity.device_code || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="型号">{{ manualIdentity.device_model || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="固件">{{ manualIdentity.firmware_version || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="HTTPS 指纹" :span="2">{{ manualIdentity.certificate_fingerprint }}</el-descriptions-item>
+      </el-descriptions>
+
+      <el-form v-if="manualStep === 1" label-width="130" style="margin-top: 20px">
+        <el-form-item label="证书指纹" required>
+          <el-input v-model="manual.fingerprint" placeholder="核对设备标签或二维码中的 SHA256 指纹" />
+        </el-form-item>
+        <el-form-item v-if="manualIdentity?.pairing_required" label="一次性配对码" required>
+          <el-input v-model="manual.pairing_code" show-password />
+        </el-form-item>
+      </el-form>
+
+      <el-form v-if="manualStep === 2" label-width="130" style="margin-top: 20px">
+        <el-form-item label="设备编码" required><el-input v-model="manual.device_code" /></el-form-item>
+        <el-form-item label="设备名称" required><el-input v-model="manual.device_name" /></el-form-item>
+        <el-form-item label="设备类型"><el-input v-model="manual.device_type" /></el-form-item>
+        <el-form-item label="绑定通道" required>
+          <el-select v-model="manual.channel_id" placeholder="请选择停车场下的通道" style="width: 100%">
+            <el-option v-for="item in channelList" :key="item.ID" :label="item.channel_name" :value="item.ID" />
+          </el-select>
+        </el-form-item>
+        <el-alert title="下发内容包含 MQTT 地址、TLS 状态、ClientID 和通道坐标,密码不会显示在页面。" type="warning" :closable="false" show-icon />
+      </el-form>
+
+      <el-result v-if="manualStep === 3" :icon="manualOnline ? 'success' : 'info'" :title="manualOnline ? '设备已接入并在线' : '配置已保存,等待 MQTT 上线'">
+        <template #sub-title>{{ manualStatusText }}</template>
+      </el-result>
+
+      <template #footer>
+        <el-button @click="closeManualProvision">关闭</el-button>
+        <el-button v-if="manualStep === 0" type="primary" :loading="manualLoading" @click="readManualIdentity">读取设备身份</el-button>
+        <el-button v-if="manualStep === 1" type="primary" :loading="manualLoading" @click="verifyManualIdentity">确认身份</el-button>
+        <el-button v-if="manualStep === 2" type="primary" :loading="manualLoading" @click="submitManualProvision">确认绑定并下发</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { ref, reactive, watch } from 'vue'
+import { onBeforeUnmount, onMounted, ref, reactive, watch } from 'vue'
 import {
   obtainDeviceList,
   addDevice,
   editDevice,
   deleteDevice,
   joinDevice,
+  discoverDevices,
+  getDiscoveredDevices,
+  readDiscoveredDeviceIdentity,
+  readManualDeviceIdentity,
+  verifyManualDeviceIdentity,
+  provisionManualDevice,
+  getDeviceProvisionStatus,
 } from '@/api/device'
 import { obtainChannelList } from '@/api/channel'
 import { ElMessage, ElMessageBox } from 'element-plus'
@@ -134,13 +246,20 @@ const loading = ref(false)
 const list = ref([])
 const total = ref(0)
 const channelList = ref([])
+let statusRefreshTimer = null
+let manualStatusTimer = null
+const discoveryVisible = ref(false)
+const discoveryLoading = ref(false)
+const discoveryIdentityLoading = ref('')
+const discoveryList = ref([])
+const discoveryBroadcastAddress = ref('')
 const query = reactive({
   parking_lot_id: 0,
   page: 1,
   page_size: 10,
 })
 
-const fetchData = async () => {
+const fetchData = async ({ background = false } = {}) => {
   if (!props.parkId || props.parkId === 0) {
     list.value = []
     total.value = 0
@@ -149,7 +268,7 @@ const fetchData = async () => {
   loading.value = true
   query.parking_lot_id = props.parkId
   try {
-    const res = await obtainDeviceList({ ...query })
+    const res = await obtainDeviceList({ ...query }, background ? { donNotShowLoading: true } : {})
     if (res.code === 0) {
       list.value = res.data.list || []
       total.value = res.data.total || 0
@@ -190,6 +309,162 @@ const form = reactive({
   channel_id: null,
 })
 
+const manualVisible = ref(false)
+const manualLoading = ref(false)
+const manualStep = ref(0)
+const manualIdentity = ref(null)
+const manualOnline = ref(false)
+const manualStatusText = ref('')
+const manual = reactive({
+  ip: '', port: 8443, fingerprint: '', pairing_code: '', device_code: '', device_name: '', device_type: 'lpr-gate', channel_id: null, device_id: '',
+})
+
+const resetManual = () => {
+  Object.assign(manual, { ip: '', port: 8443, fingerprint: '', pairing_code: '', device_code: '', device_name: '', device_type: 'lpr-gate', channel_id: null, device_id: '' })
+  manualIdentity.value = null
+  manualOnline.value = false
+  manualStatusText.value = ''
+  manualStep.value = 0
+}
+
+const openManualProvision = async () => {
+  resetManual()
+  await fetchChannelList()
+  manualVisible.value = true
+}
+
+const openAutoDiscovery = async () => {
+  discoveryVisible.value = true
+  discoveryLoading.value = true
+  try {
+    const res = await getDiscoveredDevices()
+    if (res.code === 0) discoveryList.value = res.data || []
+  } catch (error) { ElMessage.error(error.message || '读取发现记录失败') } finally { discoveryLoading.value = false }
+}
+
+const runAutoDiscovery = async () => {
+  discoveryLoading.value = true
+  try {
+    const res = await discoverDevices({ broadcast_address: discoveryBroadcastAddress.value.trim() })
+    if (res.code !== 0) throw new Error(res.msg || '自动发现失败')
+    discoveryList.value = res.data || []
+    if (discoveryList.value.length === 0) ElMessage.info('未收到设备响应,请检查网络或使用手工接入')
+  } catch (error) { ElMessage.error(error.message || '自动发现失败') } finally { discoveryLoading.value = false }
+}
+
+const useDiscoveredDevice = async (device) => {
+  discoveryIdentityLoading.value = device.device_id
+  try {
+    const res = await readDiscoveredDeviceIdentity(device.device_id)
+    if (res.code !== 0) throw new Error(res.msg || 'HTTPS 身份读取失败')
+    await fetchChannelList()
+    resetManual()
+    manualIdentity.value = res.data
+    manual.device_id = res.data.device_id
+    manual.ip = res.data.ip
+    manual.port = res.data.config_port
+    manual.device_code = res.data.device_code || ''
+    manual.device_type = res.data.device_type || manual.device_type
+    manualStep.value = 1
+    discoveryVisible.value = false
+    manualVisible.value = true
+  } catch (error) { ElMessage.error(error.message || 'HTTPS 身份读取失败') } finally { discoveryIdentityLoading.value = '' }
+}
+
+const closeManualProvision = () => {
+  manualVisible.value = false
+  if (manualStatusTimer) { window.clearInterval(manualStatusTimer); manualStatusTimer = null }
+}
+
+const validateManualAddress = () => {
+  const ip = String(manual.ip || '').trim()
+  const port = Number(manual.port)
+  const lowerIP = ip.toLowerCase()
+  if (!Number.isInteger(port) || port < 1 || port > 65535) {
+    ElMessage.error('HTTPS 端口必须是 1-65535 的整数')
+    return false
+  }
+  if (!ip || lowerIP === 'localhost' || lowerIP === '0.0.0.0' || lowerIP === '::' || lowerIP === '::1') {
+    ElMessage.error('设备地址不能使用本机回环或未指定地址')
+    return false
+  }
+  const ipv4 = ip.split('.')
+  if (ipv4.length === 4 && ipv4.every(part => /^\d+$/.test(part) && Number(part) <= 255)) {
+    const first = Number(ipv4[0])
+    if (first === 127 || first >= 224 || ip === '255.255.255.255') {
+      ElMessage.error('设备地址不能使用回环、广播或组播地址')
+      return false
+    }
+    return true
+  }
+  if (/^[0-9a-f:]+$/i.test(ip) && ip.includes(':') && !lowerIP.startsWith('ff')) return true
+  ElMessage.error('请输入合法的字面 IPv4 或 IPv6 地址')
+  return false
+}
+
+const readManualIdentity = async () => {
+  if (!validateManualAddress()) return
+  manualLoading.value = true
+  try {
+    const res = await readManualDeviceIdentity({ ip: manual.ip, port: Number(manual.port) })
+    if (res.code !== 0) throw new Error(res.msg || '读取设备身份失败')
+    manualIdentity.value = res.data
+    manual.device_id = res.data.device_id
+    // 指纹必须由管理员按设备标签或二维码手工确认,不能以读取结果自动确认自身。
+    manual.fingerprint = ''
+    manual.device_code = res.data.device_code || ''
+    manual.device_type = res.data.device_type || manual.device_type
+    manualStep.value = 1
+  } catch (error) { ElMessage.error(error.message || '读取设备身份失败') } finally { manualLoading.value = false }
+}
+
+const verifyManualIdentity = async () => {
+  if (!manual.fingerprint) { ElMessage.error('请填写并核对证书指纹'); return }
+  manualLoading.value = true
+  try {
+    const res = await verifyManualDeviceIdentity(manual.device_id, { fingerprint: manual.fingerprint, pairing_code: manual.pairing_code })
+    if (res.code !== 0) throw new Error(res.msg || '设备身份确认失败')
+    manualStep.value = 2
+  } catch (error) { ElMessage.error(error.message || '设备身份确认失败') } finally { manualLoading.value = false }
+}
+
+const submitManualProvision = async () => {
+  const channel = channelList.value.find(item => item.ID === manual.channel_id)
+  if (!manual.device_code || !manual.device_name || !channel) { ElMessage.error('请完整填写设备并选择通道'); return }
+  manualLoading.value = true
+  try {
+    const res = await provisionManualDevice(manual.device_id, {
+      device_code: manual.device_code, device_name: manual.device_name, device_type: manual.device_type,
+      parking_lot_id: props.parkId, booth_id: channel.booth_id, channel_id: channel.ID, direction: channel.direction,
+    })
+    if (res.code !== 0) throw new Error(res.msg || '设备配置下发失败')
+    manualStep.value = 3
+    manualStatusText.value = '正在等待设备发布 gate.state.v1,最多等待 30 秒。'
+    pollManualStatus()
+  } catch (error) { ElMessage.error(error.message || '设备配置下发失败') } finally { manualLoading.value = false }
+}
+
+const pollManualStatus = () => {
+  if (manualStatusTimer) window.clearInterval(manualStatusTimer)
+  let attempts = 0
+  const check = async () => {
+    attempts += 1
+    const res = await getDeviceProvisionStatus(manual.device_id)
+    if (res.code === 0 && res.data) {
+      if (res.data.device_status === 'online' || res.data.provision_status === 'online') {
+        manualOnline.value = true
+        manualStatusText.value = '已收到 gate.state.v1,设备可以进入正常运营。'
+        window.clearInterval(manualStatusTimer); manualStatusTimer = null; fetchData()
+        return
+      }
+      manualStatusText.value = res.data.provision_error || '配置已保存,尚未收到 MQTT 在线状态。'
+    }
+    if (attempts >= 10) { window.clearInterval(manualStatusTimer); manualStatusTimer = null; manualStatusText.value = '30 秒内未上线,请检查 Broker 地址、防火墙和设备凭据。' }
+  }
+  check()
+  manualStatusTimer = window.setInterval(check, 3000)
+}
+
 const openDialog = (mode, row) => {
   isEdit.value = mode === 'edit'
   fetchChannelList()
@@ -258,6 +533,15 @@ const handleConnect = async (row) => {
   }
 }
 
+const startStatusRefresh = () => {
+  if (statusRefreshTimer !== null) return
+  statusRefreshTimer = window.setInterval(() => {
+    if (props.parkId && !loading.value) {
+      fetchData({ background: true })
+    }
+  }, 5000)
+}
+
 const handleDelete = (id) => {
   ElMessageBox.confirm('确定删除该设备吗?', '提示', {
     confirmButtonText: '确定',
@@ -284,4 +568,16 @@ watch(
   },
   { immediate: true }
 )
+
+onMounted(startStatusRefresh)
+onBeforeUnmount(() => {
+  if (statusRefreshTimer !== null) {
+    window.clearInterval(statusRefreshTimer)
+    statusRefreshTimer = null
+  }
+  if (manualStatusTimer !== null) {
+    window.clearInterval(manualStatusTimer)
+    manualStatusTimer = null
+  }
+})
 </script>

+ 0 - 78
frontend/src/view/parking/shiftRecord.vue

@@ -1,78 +0,0 @@
-<template>
-  <div>
-    <el-card v-if="current" class="active-shift">
-      <template #header>当班中</template>
-      <el-descriptions :column="2" border>
-        <el-descriptions-item label="接班时间">{{ current.start_time }}</el-descriptions-item>
-        <el-descriptions-item label="接班现金">¥{{ current.starting_cash.toFixed(2) }}</el-descriptions-item>
-        <el-descriptions-item label="当班收款">¥{{ (current.collected_cash || 0).toFixed(2) }}</el-descriptions-item>
-        <el-descriptions-item label="应交总额"><b>¥{{ ((current.starting_cash || 0) + (current.collected_cash || 0)).toFixed(2) }}</b></el-descriptions-item>
-      </el-descriptions>
-      <div style="margin-top:16px">
-        <el-input-number v-model="endForm.actual_cash" :min="0" :precision="2" placeholder="实交现金" style="width:200px" />
-        <el-button type="danger" style="margin-left:12px" @click="doEnd">交班</el-button>
-      </div>
-    </el-card>
-
-    <el-card v-else>
-      <el-form inline>
-        <el-form-item label="接班现金">
-          <el-input-number v-model="startForm.starting_cash" :min="0" :precision="2" style="width:200px" />
-        </el-form-item>
-        <el-form-item><el-button type="primary" @click="doStart">接班</el-button></el-form-item>
-      </el-form>
-    </el-card>
-
-    <el-table :data="list" border style="margin-top:16px">
-      <el-table-column label="操作员" prop="operator_name" width="100" />
-      <el-table-column label="接班时间" prop="start_time" width="170" />
-      <el-table-column label="交班时间" prop="end_time" width="170" />
-      <el-table-column label="接班现金" width="100"><template #default="{row}">¥{{ row.starting_cash.toFixed(2) }}</template></el-table-column>
-      <el-table-column label="当班收款" width="100"><template #default="{row}">¥{{ (row.collected_cash || 0).toFixed(2) }}</template></el-table-column>
-      <el-table-column label="应交" width="100"><template #default="{row}">¥{{ row.expected_total.toFixed(2) }}</template></el-table-column>
-      <el-table-column label="实交" width="100"><template #default="{row}">¥{{ row.actual_total.toFixed(2) }}</template></el-table-column>
-      <el-table-column label="差异" width="100">
-        <template #default="{row}">
-          <span :style="{color: row.difference < 0 ? '#f56c6c' : '#67c23a'}">¥{{ row.difference.toFixed(2) }}</span>
-        </template>
-      </el-table-column>
-    </el-table>
-  </div>
-</template>
-
-<script setup>
-import { ref, reactive, onMounted, onUnmounted } from 'vue'
-import { ElMessage } from 'element-plus'
-import { startShift, endShift, getCurrentShift, getShiftList } from '@/api/shift'
-
-const current = ref(null), list = ref([])
-const startForm = reactive({ starting_cash: 0 })
-const endForm = reactive({ actual_cash: 0 })
-
-async function fetch() {
-  try {
-    const r = await getCurrentShift()
-    current.value = r.code === 0 ? r.data : null
-  } catch (e) { current.value = null }
-  const l = await getShiftList({ page: 1, page_size: 20 })
-  if (l.code === 0) { list.value = l.data.list || []; list.value.forEach(s => s.collected_cash = s.collected_cash || 0) }
-}
-
-async function doStart() {
-  const r = await startShift(startForm)
-  if (r.code === 0) { ElMessage.success('接班成功'); fetch() } else ElMessage.error(r.msg)
-}
-
-async function doEnd() {
-  const r = await endShift(endForm)
-  if (r.code === 0) { ElMessage.success(`交班完成,差异¥${r.data.difference.toFixed(2)}`); fetch() } else ElMessage.error(r.msg)
-}
-
-let timer = null
-onMounted(() => { fetch(); timer = setInterval(fetch, 10000) })
-onUnmounted(() => clearInterval(timer))
-</script>
-
-<style scoped>
-.active-shift { border-left: 4px solid #67c23a; }
-</style>

+ 13 - 2
frontend/src/view/report/enter.vue

@@ -156,20 +156,28 @@
       <div style="display: flex; gap:10px; margin-top:10px">
         <el-form-item :label="$t.value.entryImg">
           <el-image
+            v-if="detail.entry_image"
             style="width: 200px; height:120px; object-fit:cover"
-            :src="detail.entry_image"
+            :src="getImageUrl(detail.entry_image)"
             fit="cover"
+            :preview-src-list="[getImageUrl(detail.entry_image)]"
+            preview-teleported
             :alt="$t.value.entryImg"
           />
+          <span v-else>-</span>
         </el-form-item>
 
         <el-form-item :label="$t.value.exitImg">
           <el-image
+            v-if="detail.exit_image"
             style="width: 200px; height:120px; object-fit:cover"
-            :src="detail.exit_image"
+            :src="getImageUrl(detail.exit_image)"
             fit="cover"
+            :preview-src-list="[getImageUrl(detail.exit_image)]"
+            preview-teleported
             :alt="$t.value.exitImg"
           />
+          <span v-else>-</span>
         </el-form-item>
       </div>
     </el-dialog>
@@ -180,6 +188,7 @@
 import { ref, reactive, onMounted } from 'vue'
 import { queryVehicleRecordList } from '@/api/vehicle' // 你的真实接口
 import { ElMessage } from 'element-plus'
+import { getUrl } from '@/utils/image'
 
 // 查询条件
 const queryForm = reactive({
@@ -194,6 +203,8 @@ const total = ref(0)
 const detailShow = ref(false)
 const detail = ref({})
 
+const getImageUrl = (url) => getUrl(url)
+
 // 时间格式化
 const formatTime = (time) => {
   if (!time) return ''

+ 4 - 0
frontend/vite.config.js

@@ -68,6 +68,10 @@ export default ({
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/api/, ''),
         },
+        '/uploads': {
+          target: 'http://127.0.0.1:8888',
+          changeOrigin: true,
+        },
       },
     },
     build: {

+ 3 - 0
go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/aws/aws-sdk-go v1.55.8
 	github.com/casbin/casbin/v3 v3.10.0
 	github.com/casbin/gorm-adapter/v3 v3.41.0
+	github.com/eclipse/paho.mqtt.golang v1.5.1
 	github.com/flipped-aurora/gin-vue-admin/server v0.0.0-20260630093409-4445d0e583ec
 	github.com/flipped-aurora/ws v1.0.2
 	github.com/fsnotify/fsnotify v1.9.0
@@ -18,6 +19,7 @@ require (
 	github.com/golang-jwt/jwt/v4 v4.5.0
 	github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.9+incompatible
 	github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
+	github.com/mochi-mqtt/server/v2 v2.7.9
 	github.com/mojocn/base64Captcha v1.3.8
 	github.com/pkg/errors v0.9.1
 	github.com/qiniu/api.v7/v7 v7.4.1
@@ -120,6 +122,7 @@ require (
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/rs/xid v1.6.0 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
 	github.com/samber/lo v1.49.1 // indirect

+ 8 - 0
go.sum

@@ -73,6 +73,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
 github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
+github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
 github.com/flipped-aurora/gin-vue-admin/server v0.0.0-20260630093409-4445d0e583ec h1:+ivMuz5jF4GlFjCK6H3vWMJhsy9G9KhV56Rpblv91bE=
 github.com/flipped-aurora/gin-vue-admin/server v0.0.0-20260630093409-4445d0e583ec/go.mod h1:QyF7IUIhF/mVJ1mXo3Yb22M21MF8BoBDWBZ21oRyyE8=
 github.com/flipped-aurora/ws v1.0.2 h1:oEUz7sgrbPENvgli7Q4QpC0NIEbJucgR4yjcDMg/AjY=
@@ -193,6 +195,8 @@ github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nD
 github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
 github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg=
+github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -267,6 +271,8 @@ github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2
 github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI=
+github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -317,6 +323,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
 github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=

+ 27 - 0
internal/api/v1/parking/camera.go

@@ -0,0 +1,27 @@
+package parking
+
+import (
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+)
+
+// ListCameraStreams 返回进出场工作台可用的摄像头播放地址。
+func ListCameraStreams(c *gin.Context) {
+	streams, err := passageService.ListCameraStreams()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(streams, c)
+}
+
+// CameraMJPEG 输出系统侧 FFmpeg 转换的摄像头流。
+func CameraMJPEG(c *gin.Context) {
+	c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
+	c.Header("Pragma", "no-cache")
+	err := passageService.StreamCameraMJPEG(c.Param("device_code"), c.Param("token"), c.Writer, c.Request.Context())
+	if err != nil {
+		c.Status(404)
+		return
+	}
+}

+ 10 - 0
internal/config/camera.go

@@ -0,0 +1,10 @@
+package config
+
+// Camera 是系统侧摄像头流转换配置。
+// 设备完成转换后,将摄像头的 stream-protocol 改为 webrtc/hls 即可切换到边缘侧播放地址。
+type Camera struct {
+	ConversionMode string `mapstructure:"conversion-mode" json:"conversion-mode" yaml:"conversion-mode"` // system 或 edge
+	FFmpegPath     string `mapstructure:"ffmpeg-path" json:"ffmpeg-path" yaml:"ffmpeg-path"`
+	MJPEGFPS       int    `mapstructure:"mjpeg-fps" json:"mjpeg-fps" yaml:"mjpeg-fps"`
+	JPEGQuality    int    `mapstructure:"jpeg-quality" json:"jpeg-quality" yaml:"jpeg-quality"`
+}

+ 5 - 0
internal/config/config.go

@@ -27,4 +27,9 @@ type Server struct {
 
 	// 跨域配置
 	Cors CORS `mapstructure:"cors" json:"cors" yaml:"cors"`
+
+	// 设备消息总线(MQTT)
+	Mqtt Mqtt `mapstructure:"mqtt" json:"mqtt" yaml:"mqtt"`
+	// 摄像头系统侧转换配置
+	Camera Camera `mapstructure:"camera" json:"camera" yaml:"camera"`
 }

+ 17 - 0
internal/config/mqtt.go

@@ -0,0 +1,17 @@
+package config
+
+// Mqtt 设备消息总线(MQTT)配置。默认关闭。
+// 启用后可选「内嵌 broker」(embed-broker=true,随应用进程启动,无需外部 mosquitto),
+// 也可连接外部 broker(embed-broker=false,broker 指向外部地址)。
+type Mqtt struct {
+	Enabled        bool   `mapstructure:"enabled" json:"enabled" yaml:"enabled"`
+	EmbedBroker    bool   `mapstructure:"embed-broker" json:"embed-broker" yaml:"embed-broker"`          // 是否启动内嵌 broker
+	BrokerListen   string `mapstructure:"broker-listen" json:"broker-listen" yaml:"broker-listen"`       // 内嵌 broker 监听地址,如 :1883
+	Broker         string `mapstructure:"broker" json:"broker" yaml:"broker"`                            // 客户端连接地址,如 tcp://127.0.0.1:1883
+	AdvertisedHost string `mapstructure:"advertised-host" json:"advertised-host" yaml:"advertised-host"` // 下发给边缘设备的可达地址
+	AdvertisedPort int    `mapstructure:"advertised-port" json:"advertised-port" yaml:"advertised-port"` // 下发给边缘设备的端口
+	TLSEnabled     bool   `mapstructure:"tls-enabled" json:"tls-enabled" yaml:"tls-enabled"`             // 下发 MQTT 是否启用 TLS
+	ClientID       string `mapstructure:"client-id" json:"client-id" yaml:"client-id"`
+	Username       string `mapstructure:"username" json:"username" yaml:"username"`
+	Password       string `mapstructure:"password" json:"password" yaml:"password"`
+}

+ 3 - 1
internal/core/server_other.go

@@ -13,7 +13,9 @@ import (
 func initServer(address string, router *gin.Engine) server {
 	s := endless.NewServer(address, router)
 	s.ReadHeaderTimeout = 20 * time.Second
-	s.WriteTimeout = 20 * time.Second
+	// Keep streaming responses alive; individual handlers still honor request
+	// cancellation when the browser closes the connection.
+	s.WriteTimeout = 0
 	s.MaxHeaderBytes = 1 << 20
 	return s
 }

+ 6 - 4
internal/core/server_win.go

@@ -12,10 +12,12 @@ import (
 
 func initServer(address string, router *gin.Engine) server {
 	return &http.Server{
-		Addr:           address,
-		Handler:        router,
-		ReadTimeout:    20 * time.Second,
-		WriteTimeout:   20 * time.Second,
+		Addr:        address,
+		Handler:     router,
+		ReadTimeout: 20 * time.Second,
+		// Streaming endpoints (for example camera MJPEG) are long-lived. A
+		// server-wide write deadline would terminate an otherwise healthy stream.
+		WriteTimeout:   0,
 		MaxHeaderBytes: 1 << 20,
 	}
 }

+ 16 - 10
internal/dao/camera.go

@@ -5,16 +5,22 @@ import "wails-app/internal/global"
 // Camera 摄像头模型
 type Camera struct {
 	global.GVA_MODEL
-	DeviceCode  string `gorm:"size:50;not null;uniqueIndex" json:"device_code"` // 设备编码
-	DeviceName  string `gorm:"size:100;not null" json:"device_name"`            // 设备名称
-	IPAddress   string `gorm:"size:50;not null" json:"ip_address"`              // IP地址
-	Port        int    `gorm:"not null" json:"port"`                            // 端口
-	Username    string `gorm:"size:50" json:"username"`                         // 用户名
-	Password    string `gorm:"size:50" json:"password"`                         // 密码
-	Channel     int    `gorm:"default:1" json:"channel"`                        // 通道号
-	Status      string `gorm:"size:20;default:'offline'" json:"status"`         // 连接状态
-	IsActive    bool   `gorm:"default:true" json:"is_active"`                   // 是否启用
-	Description string `gorm:"size:200" json:"description"`                     // 备注
+	DeviceCode     string   `gorm:"size:50;not null;uniqueIndex" json:"device_code"` // 设备编码
+	DeviceName     string   `gorm:"size:100;not null" json:"device_name"`            // 设备名称
+	ChannelID      uint     `gorm:"index;comment:绑定通道ID" json:"channel_id"`
+	BoundChannel   *Channel `gorm:"foreignKey:ChannelID;references:ID" json:"channel_binding"`
+	IPAddress      string   `gorm:"size:50;not null" json:"ip_address"`              // IP地址
+	Port           int      `gorm:"not null" json:"port"`                            // 端口
+	Username       string   `gorm:"size:50" json:"username"`                         // 用户名
+	Password       string   `gorm:"size:50" json:"password"`                         // 密码
+	Channel        int      `gorm:"default:1" json:"channel"`                        // 通道号
+	Status         string   `gorm:"size:20;default:'offline'" json:"status"`         // 连接状态
+	IsActive       bool     `gorm:"default:true" json:"is_active"`                   // 是否启用
+	StreamProtocol string   `gorm:"size:20;default:'webrtc'" json:"stream_protocol"` // system-mjpeg / webrtc / hls / mp4
+	SourceURL      string   `gorm:"size:500" json:"source_url"`                      // 系统转换模式下的原始 RTSP/HTTP 地址
+	StreamURL      string   `gorm:"size:500" json:"stream_url"`                      // 边缘转换后的浏览器播放地址,不应包含设备凭据
+	SnapshotURL    string   `gorm:"size:500" json:"snapshot_url"`                    // 可选抓拍地址
+	Description    string   `gorm:"size:200" json:"description"`                     // 备注
 }
 
 func (Camera) TableName() string {

+ 33 - 0
internal/dao/device_discovery.go

@@ -0,0 +1,33 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+// DeviceDiscovery 保存一次手工 IP 身份读取结果和管理员确认状态。
+// payload 只保存设备公开身份,不保存配对码、MQTT 密码或其他敏感令牌。
+type DeviceDiscovery struct {
+	global.GVA_MODEL
+	RequestID              string     `gorm:"size:64;index:idx_device_discovery_request_id;not null" json:"request_id"`
+	DeviceID               string     `gorm:"size:100;index;not null" json:"device_id"`
+	DeviceCode             string     `gorm:"size:50" json:"device_code"`
+	DeviceType             string     `gorm:"size:50" json:"device_type"`
+	DeviceModel            string     `gorm:"size:100" json:"device_model"`
+	FirmwareVersion        string     `gorm:"size:100" json:"firmware_version"`
+	SourceIP               string     `gorm:"size:50;not null" json:"source_ip"`
+	ConfigPort             int        `gorm:"not null" json:"config_port"`
+	Payload                string     `gorm:"type:text" json:"payload"`
+	CertificateFingerprint string     `gorm:"size:128" json:"certificate_fingerprint"`
+	PublicKeyFingerprint   string     `gorm:"size:128" json:"public_key_fingerprint"`
+	Signature              string     `gorm:"type:text" json:"signature"`
+	SignatureVerified      bool       `gorm:"not null;default:false" json:"signature_verified"`
+	ProvisionMethod        string     `gorm:"size:20;index;default:'manual_ip'" json:"provision_method"`
+	PairingRequired        bool       `gorm:"not null;default:false" json:"pairing_required"`
+	Verified               bool       `gorm:"not null;default:false" json:"verified"`
+	VerifiedAt             *time.Time `json:"verified_at"`
+	ExpiredAt              time.Time  `gorm:"index;not null" json:"expired_at"`
+	ProvisionStatus        string     `gorm:"size:20;index;default:'identity_unverified'" json:"provision_status"`
+}
+
+func (DeviceDiscovery) TableName() string { return "device_discovery" }

+ 21 - 0
internal/dao/device_image.go

@@ -0,0 +1,21 @@
+package dao
+
+import "time"
+
+// DeviceImage maps an edge-uploaded image ID to the managed image path.
+// Image bytes are stored by the configured OSS provider.
+type DeviceImage struct {
+	ID          uint      `gorm:"primaryKey" json:"id"`
+	DeviceCode  string    `gorm:"size:50;not null;index:idx_device_image_lookup,priority:1" json:"device_code"`
+	ImageID     string    `gorm:"size:128;not null;index:idx_device_image_lookup,priority:2;uniqueIndex:uk_device_image" json:"image_id"`
+	EventID     string    `gorm:"size:128;index" json:"event_id"`
+	Direction   string    `gorm:"size:10" json:"direction"`
+	URL         string    `gorm:"type:text;not null" json:"url"`
+	Key         string    `gorm:"size:512" json:"key"`
+	ContentType string    `gorm:"size:100" json:"content_type"`
+	Size        int64     `json:"size"`
+	SHA256      string    `gorm:"size:64" json:"sha256"`
+	CreatedAt   time.Time `json:"created_at"`
+}
+
+func (DeviceImage) TableName() string { return "device_image" }

+ 26 - 7
internal/dao/uhf_reader.go

@@ -1,8 +1,15 @@
 package dao
 
 import (
-	"wails-app/internal/global"
 	"time"
+	"wails-app/internal/global"
+)
+
+// 连接类型
+const (
+	ConnectTypeTCP    = "tcp"
+	ConnectTypeSerial = "serial"
+	ConnectTypeMQTT   = "mqtt"
 )
 
 type UHFReader struct {
@@ -11,6 +18,7 @@ type UHFReader struct {
 	DeviceCode string `gorm:"size:50;not null;uniqueIndex" json:"device_code"`
 	DeviceName string `gorm:"size:100;not null" json:"device_name"`
 	DeviceType string `gorm:"size:100;not null" json:"device_type"`
+	DeviceID   string `gorm:"size:100;index" json:"device_id"` // 永久硬件身份,不能由管理员随意修改
 
 	// ========== 连接方式配置 ==========
 	ConnectType string `gorm:"size:20;not null;default:'tcp'" json:"connect_type"` // tcp / serial
@@ -23,12 +31,23 @@ type UHFReader struct {
 	COMPort  string `gorm:"size:50" json:"com_port"`       // COM3 /dev/ttyUSB0
 	BaudRate int    `gorm:"default:9600" json:"baud_rate"` // 波特率
 
-	Status         string     `gorm:"size:20;default:'offline'" json:"status"`
-	IsActive       bool       `gorm:"default:true" json:"is_active"`
-	Description    string     `gorm:"size:200" json:"description"`
-	ParkingLotID   uint       `json:"parking_lot_id"`
-	ParkingLot     ParkingLot `gorm:"foreignkey:ParkingLotID" json:"parking_lot"`
-	LastOnlineTime *time.Time `json:"last_online_time" gorm:"comment:最后上线时间;default:null"`
+	Status             string     `gorm:"size:20;default:'offline'" json:"status"`
+	ProvisionStatus    string     `gorm:"size:20;default:'unprovisioned';index" json:"provision_status"`
+	ProvisionMethod    string     `gorm:"size:20;default:'legacy'" json:"provision_method"`
+	ProvisionError     string     `gorm:"size:500" json:"provision_error"`
+	IdentityVerifiedAt *time.Time `json:"identity_verified_at"`
+	ProvisionedAt      *time.Time `json:"provisioned_at"`
+	LastDiscoveredAt   *time.Time `json:"last_discovered_at"`
+	DiscoveredIP       string     `gorm:"size:50" json:"discovered_ip"`
+	ConfigPort         int        `gorm:"default:0" json:"config_port"`
+	DeviceModel        string     `gorm:"size:100" json:"device_model"`
+	FirmwareVersion    string     `gorm:"size:100" json:"firmware_version"`
+	DevicePublicKey    string     `gorm:"type:text" json:"device_public_key"`
+	IsActive           bool       `gorm:"default:true" json:"is_active"`
+	Description        string     `gorm:"size:200" json:"description"`
+	ParkingLotID       uint       `json:"parking_lot_id"`
+	ParkingLot         ParkingLot `gorm:"foreignkey:ParkingLotID" json:"parking_lot"`
+	LastOnlineTime     *time.Time `json:"last_online_time" gorm:"comment:最后上线时间;default:null"`
 
 	ChannelID uint     `gorm:"comment:绑定通道ID" json:"channel_id"`
 	Channel   *Channel `gorm:"foreignKey:ChannelID;references:ID" json:"channel"`

+ 18 - 0
internal/initialize/gorm.go

@@ -60,6 +60,8 @@ func RegisterTables() {
 		dao.PaymentEntryConfig{},
 		dao.PaymentEntryMethod{},
 		dao.UHFReader{},
+		dao.DeviceDiscovery{},
+		dao.DeviceImage{},
 		dao.Camera{},
 		dao.Shortlist{},
 		dao.MonthlyCard{},
@@ -73,6 +75,10 @@ func RegisterTables() {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))
 		os.Exit(0)
 	}
+	if err := migrateDeviceDiscoveryIndexes(db); err != nil {
+		global.GVA_LOG.Error("migrate device discovery indexes failed", zap.Error(err))
+		os.Exit(0)
+	}
 	if err := reconcileParkingLotOccupancy(db); err != nil {
 		global.GVA_LOG.Error("reconcile parking lot occupancy failed", zap.Error(err))
 	}
@@ -84,6 +90,18 @@ func RegisterTables() {
 	global.GVA_LOG.Info("register table success")
 }
 
+// migrateDeviceDiscoveryIndexes converts the early unique request_id index into a normal index.
+// A discovery request legitimately receives multiple device responses.
+func migrateDeviceDiscoveryIndexes(db *gorm.DB) error {
+	const requestIDIndex = "idx_device_discovery_request_id"
+	if db.Migrator().HasIndex(&dao.DeviceDiscovery{}, requestIDIndex) {
+		if err := db.Migrator().DropIndex(&dao.DeviceDiscovery{}, requestIDIndex); err != nil {
+			return err
+		}
+	}
+	return db.Migrator().CreateIndex(&dao.DeviceDiscovery{}, requestIDIndex)
+}
+
 // reconcileParkingLotOccupancy 在服务启动时以未出场停车会话为准校准占用数和余位。
 func reconcileParkingLotOccupancy(db *gorm.DB) error {
 	return db.Transaction(func(tx *gorm.DB) error {

+ 96 - 0
internal/initialize/mqtt.go

@@ -0,0 +1,96 @@
+package initialize
+
+import (
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"go.uber.org/zap"
+	"wails-app/internal/global"
+	"wails-app/internal/service/devicebus"
+	"wails-app/internal/service/mqttbroker"
+	parkingService "wails-app/internal/service/parking"
+	"wails-app/internal/service/uhf"
+)
+
+// InitDeviceBus 在启用 MQTT 时(可选)启动内嵌 broker、连接并装配道闸路由控制器与设备总线。
+// 默认关闭(mqtt.enabled=false),不影响现有 UHF 继电器/模拟道闸链路。
+func InitDeviceBus() {
+	cfg := global.GVA_CONFIG.Mqtt
+	if !cfg.Enabled {
+		return
+	}
+	// 模拟道闸模式下禁止建立真实 MQTT 控制链路,避免测试指令误发到现场设备。
+	if global.GVA_CONFIG.System.GateSimulator {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("道闸模拟模式已启用,跳过 MQTT 设备总线初始化")
+		}
+		return
+	}
+
+	// 内嵌 broker:随应用进程启动,设备直连本地端口,无需外部 mosquitto。
+	if cfg.EmbedBroker {
+		if _, err := mqttbroker.Start(cfg.BrokerListen); err != nil {
+			global.GVA_LOG.Error("启动内嵌 MQTT broker 失败", zap.Error(err))
+			return
+		}
+		global.GVA_LOG.Info("内嵌 MQTT broker 已启动", zap.String("listen", cfg.BrokerListen))
+	}
+
+	brokerAddr := cfg.Broker
+	if brokerAddr == "" {
+		brokerAddr = "tcp://127.0.0.1:1883"
+	}
+	clientID := cfg.ClientID
+	if clientID == "" {
+		clientID = "smart-parking"
+	}
+
+	opts := mqtt.NewClientOptions().
+		AddBroker(brokerAddr).
+		SetClientID(clientID).
+		SetAutoReconnect(true).
+		SetConnectRetry(true).
+		SetConnectRetryInterval(3*time.Second).
+		SetMaxReconnectInterval(15*time.Second).
+		SetWill("parking/device/lwt", `{"schema":"gate.lwt.v1","device_code":"smart-parking"}`, 1, true)
+	if cfg.Username != "" {
+		opts.SetUsername(cfg.Username)
+		opts.SetPassword(cfg.Password)
+	}
+
+	// OnConnect 会在首次连接和自动重连后执行,因此订阅不会因 CleanSession
+	// 或 Broker 重启而丢失。
+	mqttGate := parkingService.NewMQTTGateController(nil)
+	bus := devicebus.NewDeviceBus()
+	opts.SetOnConnectHandler(func(connected mqtt.Client) {
+		if err := mqttGate.LoadRoutes(); err != nil {
+			global.GVA_LOG.Error("加载 MQTT 道闸路由失败", zap.Error(err))
+			return
+		}
+		if err := mqttGate.Subscribe(); err != nil {
+			global.GVA_LOG.Error("订阅 MQTT 道闸主题失败", zap.Error(err))
+			return
+		}
+		if err := bus.Subscribe(connected); err != nil {
+			global.GVA_LOG.Error("订阅设备总线主题失败", zap.Error(err))
+			return
+		}
+		global.GVA_LOG.Info("MQTT 连接已恢复,设备主题订阅完成", zap.String("broker", brokerAddr))
+	})
+	opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
+		global.GVA_LOG.Warn("MQTT 连接中断,等待自动重连", zap.Error(err))
+	})
+
+	client := mqtt.NewClient(opts)
+	mqttGate.SetClient(client)
+	if err := mqttGate.MarkConfiguredDevicesOffline(); err != nil {
+		global.GVA_LOG.Error("重置 MQTT 设备状态失败", zap.Error(err))
+	}
+
+	// 初次连接不可用时保持后台重试,避免一次启动失败后永久失去设备状态同步。
+	client.Connect()
+
+	// 组合默认后端(模拟/UHF 继电器)与 MQTT 网络道闸,按设备路由。
+	parkingService.SetGateController(parkingService.NewRoutingGateController(uhf.DeviceManager, mqttGate))
+	global.GVA_LOG.Info("MQTT 设备总线已启用", zap.String("broker", brokerAddr))
+}

+ 23 - 0
internal/initialize/operation_permission_seed.go

@@ -18,6 +18,14 @@ func EnsureOperationPermissions() error {
 	}
 
 	apis := []dao.SysApi{
+		{Path: "/device-provisioning/discover", Description: "自动发现局域网设备", ApiGroup: "设备接入", Method: "POST"},
+		{Path: "/device-provisioning/discoveries", Description: "查询自动发现设备", ApiGroup: "设备接入", Method: "GET"},
+		{Path: "/device-provisioning/:device_id/identity", Description: "读取自动发现设备身份", ApiGroup: "设备接入", Method: "POST"},
+		{Path: "/device-provisioning/manual/identity", Description: "读取手工IP设备身份", ApiGroup: "设备接入", Method: "POST"},
+		{Path: "/device-provisioning/:device_id/verify", Description: "确认设备证书和配对码", ApiGroup: "设备接入", Method: "POST"},
+		{Path: "/device-provisioning/:device_id/provision", Description: "下发设备MQTT配置", ApiGroup: "设备接入", Method: "POST"},
+		{Path: "/device-provisioning/:device_id/status", Description: "查询设备接入状态", ApiGroup: "设备接入", Method: "GET"},
+		{Path: "/device-images/upload", Description: "边缘设备上传车辆抓拍图片", ApiGroup: "设备接入", Method: "POST"},
 		{Path: "/vehicle/entry", Description: "车辆入场", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/vehicle/operation/context", Description: "查询车辆操作上下文", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/vehicle/passage", Description: "统一车辆通行", ApiGroup: "进出场操作", Method: "POST"},
@@ -25,6 +33,7 @@ func EnsureOperationPermissions() error {
 		{Path: "/parking/gate/devices", Description: "查询道闸设备", ApiGroup: "进出场操作", Method: "GET"},
 		{Path: "/parking/gate/open", Description: "人工开闸", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/parking/gate/close", Description: "人工关闸", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/parking/camera/streams", Description: "查询摄像头实时流", ApiGroup: "进出场操作", Method: "GET"},
 		{Path: "/ticket-machine/button", Description: "票机按钮入场", ApiGroup: "进出场操作", Method: "POST"},
 	}
 	for _, api := range apis {
@@ -54,6 +63,7 @@ func EnsureOperationPermissions() error {
 			{"/parking/gate/devices", "GET"},
 			{"/parking/gate/open", "POST"},
 			{"/parking/gate/close", "POST"},
+			{"/parking/camera/streams", "GET"},
 			{"/ticket-machine/button", "POST"},
 		} {
 			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
@@ -61,6 +71,19 @@ func EnsureOperationPermissions() error {
 			}
 		}
 	}
+	for _, rule := range [][2]string{
+		{"/device-provisioning/discover", "POST"},
+		{"/device-provisioning/discoveries", "GET"},
+		{"/device-provisioning/:device_id/identity", "POST"},
+		{"/device-provisioning/manual/identity", "POST"},
+		{"/device-provisioning/:device_id/verify", "POST"},
+		{"/device-provisioning/:device_id/provision", "POST"},
+		{"/device-provisioning/:device_id/status", "GET"},
+	} {
+		if err := ensureCasbinRule("888", rule[0], rule[1]); err != nil {
+			return fmt.Errorf("初始化管理员设备接入权限失败: %w", err)
+		}
+	}
 
 	debugAPIs := []dao.SysApi{
 		{Path: "/ticket-machine/test", Description: "票机业务链路测试", ApiGroup: "开发调试", Method: "POST"},

+ 5 - 52
internal/initialize/reader_init.go

@@ -1,57 +1,10 @@
 package initialize
 
-import (
-	"log"
-	"wails-app/internal/dao"
-	"wails-app/internal/global"
-	"wails-app/internal/service/uhf"
-)
+import "log"
 
-// InitUHFReaders 批量初始化所有UHF设备(多设备)
+// InitUHFReaders 保留旧入口,统一委托到当前 handler 生命周期,避免启动第二套连接栈。
+// Deprecated: 新代码应调用 InitUHFDevices。
 func InitUHFReaders() {
-	log.Println("开始批量初始化UHF设备连接...")
-
-	// 1. 查询所有启用的设备
-	var devices []dao.UHFReader
-	if err := global.GVA_DB.Where("is_active = ?", true).Find(&devices).Error; err != nil {
-		log.Printf("查询UHF设备失败:%v", err)
-		return
-	}
-
-	if len(devices) == 0 {
-		log.Println("无启用的UHF设备,跳过初始化")
-		return
-	}
-
-	// 2. 转换为指针切片(适配批量连接)
-	devicePtrs := make([]*dao.UHFReader, 0, len(devices))
-	for i := range devices {
-		devicePtrs = append(devicePtrs, &devices[i])
-	}
-
-	// 3. 批量连接
-	errMap := uhf.BatchConnect(devicePtrs)
-
-	// 4. 处理连接结果
-	successCount := 0
-	for deviceCode, err := range errMap {
-		if err != nil {
-			log.Printf("设备[%s]批量连接失败:%v", deviceCode, err)
-			// 更新离线状态
-			global.GVA_DB.Model(&dao.UHFReader{}).Where("device_code = ?", deviceCode).Update("status", "offline")
-		} else {
-			successCount++
-			global.GVA_DB.Model(&dao.UHFReader{}).Where("device_code = ?", deviceCode).Update("status", "online")
-		}
-	}
-
-	log.Printf("UHF设备批量初始化完成:成功[%d],失败[%d],总计[%d]",
-		successCount, len(errMap)-successCount, len(devices))
-
-	// 5. 设置全局消息回调(处理所有设备的接收数据)
-	uhf.ConnManager.SetRecvCallback(func(deviceCode string, data []byte) {
-		// 这里写你的业务逻辑:比如解析UHF标签数据、入库、推送等
-		log.Printf("业务层处理设备[%s]数据:%s", deviceCode, string(data))
-		// 示例:保存到数据库/推送到消息队列/调用其他服务
-	})
+	log.Println("InitUHFReaders 已废弃旧 ConnManager,转由 InitUHFDevices 管理")
+	InitUHFDevices()
 }

+ 5 - 0
internal/initialize/router.go

@@ -6,6 +6,8 @@ import (
 	"wails-app/internal/docs"
 	"wails-app/internal/global"
 	"wails-app/internal/middleware"
+	"wails-app/internal/modules/deviceimage"
+	"wails-app/internal/modules/deviceprovisioning"
 	"wails-app/internal/modules/digital-ticket"
 	"wails-app/internal/modules/incident"
 	"wails-app/internal/modules/monthly"
@@ -84,6 +86,8 @@ func Routers() *gin.Engine {
 	{
 		systemRouter.InitBaseRouter(PublicGroup)         // 注册基础功能路由 不做鉴权
 		dashboardRouter.InitDashboardRouter(PublicGroup) // 监控仪表盘 不做鉴权
+		PublicGroup.POST("/device-images/upload", deviceimage.Upload)
+		parking.SetupParkingStreamPublicRouter(PublicGroup)
 	}
 	PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
 	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()).Use(middleware.OperationRecord())
@@ -133,6 +137,7 @@ func Routers() *gin.Engine {
 
 		// 异常处置路由
 		incident.SetupIncidentRouter(PrivateGroup)
+		deviceprovisioning.SetupRouter(PrivateGroup)
 
 		// UHF读写器路由
 		//uhf.SetupUHFRouter(PrivateGroup)

+ 1 - 3
internal/initialize/seed.go

@@ -137,7 +137,6 @@ func SeedSystemData() {
 		{MenuLevel: 0, ParentId: 20, Path: "pricing", Name: "pricing", Hidden: false, Component: "view/parking/pricing.vue", Sort: 4, Meta: dao.Meta{Title: "收费配置", Icon: "office-building"}},
 		{MenuLevel: 0, ParentId: 20, Path: "entryExit", Name: "entryExit", Hidden: false, Component: "view/parking/entryExit.vue", Sort: 5, Meta: dao.Meta{Title: "进出场操作", Icon: "switch"}},
 		{MenuLevel: 0, ParentId: 20, Path: "monthlyCard", Name: "monthlyCard", Hidden: false, Component: "view/parking/monthlyCard.vue", Sort: 6, Meta: dao.Meta{Title: "包月车管理", Icon: "calendar"}},
-		{MenuLevel: 0, ParentId: 20, Path: "shiftRecord", Name: "shiftRecord", Hidden: false, Component: "view/parking/shiftRecord.vue", Sort: 7, Meta: dao.Meta{Title: "交接班管理", Icon: "clock"}},
 		// 报表查询 (ID=26)
 		{MenuLevel: 0, ParentId: 0, Path: "report", Name: "report", Hidden: false, Component: "", Sort: 3, Meta: dao.Meta{Title: "报表查询", Icon: "data-analysis"}},
 		{MenuLevel: 0, ParentId: 25, Path: "enter", Name: "enter", Hidden: false, Component: "view/report/enter.vue", Sort: 1, Meta: dao.Meta{Title: "进出记录", Icon: "sort"}},
@@ -197,8 +196,7 @@ func SeedSystemData() {
 		{MenuId: "31", AuthorityId: "618"}, {MenuId: "31", AuthorityId: "888"}, {MenuId: "31", AuthorityId: "9527"},
 		// 包月车管理 (33) — admin + operator
 		{MenuId: "33", AuthorityId: "618"}, {MenuId: "33", AuthorityId: "888"}, {MenuId: "33", AuthorityId: "9527"},
-		// 交接班 (34) / 收入报表 (35) — admin + operator
-		{MenuId: "34", AuthorityId: "618"}, {MenuId: "34", AuthorityId: "888"}, {MenuId: "34", AuthorityId: "9527"},
+		// 收入报表 (35) — admin + operator
 		{MenuId: "35", AuthorityId: "618"}, {MenuId: "35", AuthorityId: "888"}, {MenuId: "35", AuthorityId: "9527"},
 	}
 	db.Create(&authMenus)

+ 1 - 1
internal/initialize/uhf.go

@@ -31,7 +31,7 @@ func InitGateController() {
 func InitUHFDevices() {
 	var devices []dao.UHFReader
 	// 从数据库查询所有激活的设备
-	if err := global.GVA_DB.Where("is_active = ?", true).Find(&devices).Error; err != nil {
+	if err := global.GVA_DB.Where("is_active = ? AND connect_type != ?", true, dao.ConnectTypeMQTT).Find(&devices).Error; err != nil {
 		return
 	}
 

+ 1 - 0
internal/main.go

@@ -25,5 +25,6 @@ func InitBackend() {
 		initialize.SeedSystemData()
 	}
 	initialize.InitGateController()
+	initialize.InitDeviceBus()
 	go core.RunWindowsServer()
 }

+ 3 - 2
internal/middleware/operation.go

@@ -6,10 +6,10 @@ import (
 	"io"
 	"net/http"
 	"net/url"
-	"wails-app/internal/dao"
 	"strconv"
 	"strings"
 	"time"
+	"wails-app/internal/dao"
 
 	"wails-app/internal/pkg"
 
@@ -27,7 +27,8 @@ var bufferSize = 1024
 var sensitiveFields = map[string]bool{
 	"password": true, "old_password": true, "new_password": true,
 	"confirm_password": true, "pwd": true, "pass": true,
-	"token": true, "access_token": true, "refresh_token": true, "jwt": true,
+	"pairing_code": true,
+	"token":        true, "access_token": true, "refresh_token": true, "jwt": true,
 	"secret": true, "private_key": true,
 }
 

+ 11 - 11
internal/model/owner/request/owner.go

@@ -3,20 +3,20 @@ package request
 import "time"
 
 type OwnerCreate struct {
-	OwnerName     string    `json:"owner_name"`
-	OwnerSurname  string    `json:"owner_surname"`
-	OwnerPhone    string    `json:"owner_phone"`
-	IsVip         bool      `json:"is_vip"`
-	VipExpireTime time.Time `json:"vip_expire_time"`
+	OwnerName     string     `json:"owner_name"`
+	OwnerSurname  string     `json:"owner_surname"`
+	OwnerPhone    string     `json:"owner_phone"`
+	IsVip         bool       `json:"is_vip"`
+	VipExpireTime *time.Time `json:"vip_expire_time"`
 }
 
 type OwnerUpdate struct {
-	ID            uint      `json:"id"`
-	OwnerName     string    `json:"owner_name"`
-	OwnerSurname  string    `json:"owner_surname"`
-	OwnerPhone    string    `json:"owner_phone"`
-	IsVip         bool      `json:"is_vip"`
-	VipExpireTime time.Time `json:"vip_expire_time"`
+	ID            uint       `json:"id"`
+	OwnerName     string     `json:"owner_name"`
+	OwnerSurname  string     `json:"owner_surname"`
+	OwnerPhone    string     `json:"owner_phone"`
+	IsVip         bool       `json:"is_vip"`
+	VipExpireTime *time.Time `json:"vip_expire_time"`
 }
 
 type OwnerQuery struct {

+ 2 - 0
internal/model/vehicle/request/vehicle.go

@@ -111,4 +111,6 @@ type ExitConfirmRequest struct {
 	ExitChannelName string  `json:"exit_channel_name"`
 	ExitDeviceCode  string  `json:"exit_device_code"`
 	ExitDeviceName  string  `json:"exit_device_name"`
+	// TriggerSource 仅由服务内部传递,用于区分 RFID 自动免密结算与人工免密放行。
+	TriggerSource string `json:"-"`
 }

+ 1 - 0
internal/model/vehicle/response/vehicle.go

@@ -65,6 +65,7 @@ type OperationContextResponse struct {
 }
 
 type ExitConfirmResponse struct {
+	SessionID        uint    `json:"session_id"`
 	PlateNumber      string  `json:"plate_number"`
 	EntryTime        int64   `json:"entry_time"`
 	ExitTime         int64   `json:"exit_time"`

+ 138 - 0
internal/modules/deviceimage/api.go

@@ -0,0 +1,138 @@
+package deviceimage
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/utils/upload"
+)
+
+const maxImageBytes int64 = 8 * 1024 * 1024
+
+// Upload accepts images pushed by an enabled, provisioned edge device on the
+// trusted parking LAN. It deliberately does not require a management JWT.
+func Upload(c *gin.Context) {
+	startedAt := time.Now()
+	remoteAddr := c.Request.RemoteAddr
+	deviceCode := strings.TrimSpace(c.PostForm("device_code"))
+	imageID := strings.TrimSpace(c.PostForm("image_id"))
+	if deviceCode == "" || imageID == "" || len(deviceCode) > 50 || len(imageID) > 128 {
+		logUploadFailure("参数无效", remoteAddr, deviceCode, imageID, "", startedAt, nil)
+		response.FailWithMessage("device_code 和 image_id 必填且长度无效", c)
+		return
+	}
+	if global.GVA_DB == nil {
+		logUploadFailure("数据库未初始化", remoteAddr, deviceCode, imageID, "", startedAt, nil)
+		response.FailWithMessage("数据库未初始化", c)
+		return
+	}
+	var reader dao.UHFReader
+	if err := global.GVA_DB.Where("device_code = ? AND is_active = ? AND connect_type = ?", deviceCode, true, dao.ConnectTypeMQTT).First(&reader).Error; err != nil {
+		logUploadFailure("设备未配置或已停用", remoteAddr, deviceCode, imageID, "", startedAt, err)
+		response.FailWithMessage("设备未配置或已停用", c)
+		return
+	}
+
+	var existing dao.DeviceImage
+	if err := global.GVA_DB.Where("device_code = ? AND image_id = ?", deviceCode, imageID).First(&existing).Error; err == nil {
+		logUploadSuccess("重复上传幂等返回", remoteAddr, deviceCode, imageID, existing.EventID, existing.ContentType, existing.Size, existing.URL, existing.Key, startedAt, true)
+		response.OkWithData(gin.H{"image_id": existing.ImageID, "url": existing.URL, "key": existing.Key}, c)
+		return
+	} else if !errors.Is(err, gorm.ErrRecordNotFound) {
+		logUploadFailure("查询图片记录失败", remoteAddr, deviceCode, imageID, "", startedAt, err)
+		response.FailWithMessage("查询图片记录失败", c)
+		return
+	}
+
+	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageBytes+1024*1024)
+	file, header, err := c.Request.FormFile("image")
+	if err != nil {
+		logUploadFailure("缺少图片文件", remoteAddr, deviceCode, imageID, "", startedAt, err)
+		response.FailWithMessage("缺少 image 图片文件", c)
+		return
+	}
+	_ = file.Close()
+	if header.Size <= 0 || header.Size > maxImageBytes {
+		logUploadFailure("图片大小超限", remoteAddr, deviceCode, imageID, "", startedAt, fmt.Errorf("size=%d", header.Size))
+		response.FailWithMessage("图片大小必须在 1B 到 8MB 之间", c)
+		return
+	}
+	contentType, digest, err := inspectImage(header)
+	if err != nil {
+		logUploadFailure("图片格式或内容校验失败", remoteAddr, deviceCode, imageID, "", startedAt, err)
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	url, key, err := upload.NewOss().UploadFile(header)
+	if err != nil {
+		logUploadFailure("对象存储保存图片失败", remoteAddr, deviceCode, imageID, contentType, startedAt, err)
+		response.FailWithMessage("保存图片失败", c)
+		return
+	}
+	record := dao.DeviceImage{DeviceCode: deviceCode, ImageID: imageID, EventID: strings.TrimSpace(c.PostForm("event_id")), Direction: strings.TrimSpace(c.PostForm("direction")), URL: url, Key: key, ContentType: contentType, Size: header.Size, SHA256: digest}
+	if err := global.GVA_DB.Create(&record).Error; err != nil {
+		logUploadFailure("保存图片记录失败", remoteAddr, deviceCode, imageID, contentType, startedAt, err)
+		response.FailWithMessage("保存图片记录失败", c)
+		return
+	}
+	if global.GVA_LOG != nil {
+		logUploadSuccess("图片上传成功", remoteAddr, deviceCode, imageID, record.EventID, contentType, header.Size, record.URL, record.Key, startedAt, false)
+	}
+	response.OkWithData(gin.H{"image_id": imageID, "url": url, "key": key, "sha256": digest}, c)
+}
+
+func logUploadFailure(reason, remoteAddr, deviceCode, imageID, contentType string, startedAt time.Time, err error) {
+	if global.GVA_LOG == nil {
+		return
+	}
+	fields := []zap.Field{zap.String("remote_addr", remoteAddr), zap.String("device_code", deviceCode), zap.String("image_id", imageID), zap.String("content_type", contentType), zap.Duration("duration", time.Since(startedAt)), zap.String("reason", reason)}
+	if err != nil {
+		fields = append(fields, zap.Error(err))
+	}
+	global.GVA_LOG.Warn("边缘设备图片上传失败", fields...)
+}
+
+func logUploadSuccess(message, remoteAddr, deviceCode, imageID, eventID, contentType string, size int64, storageURL, storageKey string, startedAt time.Time, idempotent bool) {
+	if global.GVA_LOG == nil {
+		return
+	}
+	global.GVA_LOG.Info("边缘设备图片上传结果", zap.String("result", message), zap.String("remote_addr", remoteAddr), zap.String("device_code", deviceCode), zap.String("image_id", imageID), zap.String("event_id", eventID), zap.String("content_type", contentType), zap.Int64("size", size), zap.String("storage_url", storageURL), zap.String("storage_key", storageKey), zap.Bool("idempotent", idempotent), zap.Duration("duration", time.Since(startedAt)))
+}
+
+func inspectImage(header *multipart.FileHeader) (string, string, error) {
+	f, err := header.Open()
+	if err != nil {
+		return "", "", errors.New("读取图片失败")
+	}
+	defer f.Close()
+	buf := make([]byte, 512)
+	n, err := io.ReadFull(f, buf)
+	if err != nil && err != io.ErrUnexpectedEOF {
+		return "", "", errors.New("读取图片失败")
+	}
+	contentType := http.DetectContentType(buf[:n])
+	if !strings.HasPrefix(contentType, "image/") {
+		return "", "", fmt.Errorf("不支持的图片格式: %s", contentType)
+	}
+	if _, err := f.Seek(0, io.SeekStart); err != nil {
+		return "", "", errors.New("读取图片失败")
+	}
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", "", errors.New("计算图片摘要失败")
+	}
+	return contentType, hex.EncodeToString(h.Sum(nil)), nil
+}

+ 147 - 0
internal/modules/deviceprovisioning/api.go

@@ -0,0 +1,147 @@
+package deviceprovisioning
+
+import (
+	"io"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/deviceprovisioning/model/request"
+	provisionService "wails-app/internal/modules/deviceprovisioning/service"
+	utils "wails-app/internal/pkg"
+)
+
+var svc = provisionService.New()
+
+func requireAdmin(c *gin.Context) bool {
+	if utils.GetUserAuthorityId(c) != 888 {
+		response.FailWithMessage("仅管理员可以执行设备接入配置", c)
+		c.Abort()
+		return false
+	}
+	return true
+}
+
+// ReadManualIdentity 读取管理员输入 IP 设备的公开身份。
+func ReadManualIdentity(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	var req request.ManualIdentityRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	identity, err := svc.ReadIdentity(req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(identity, c)
+}
+
+// DiscoverDevices broadcasts a bounded UDP discovery request and returns candidates.
+func DiscoverDevices(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	var req request.DiscoverRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		if err != io.EOF {
+			response.FailWithMessage(err.Error(), c)
+			return
+		}
+	}
+	items, err := svc.Discover(req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(items, c)
+}
+
+// ListDiscoveries returns unexpired UDP candidates for the discovery dialog.
+func ListDiscoveries(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	items, err := svc.Discoveries()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(items, c)
+}
+
+// ReadDiscoveredIdentity upgrades a UDP candidate to an HTTPS identity record.
+func ReadDiscoveredIdentity(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	identity, err := svc.ReadDiscoveredIdentity(c.Param("device_id"))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(identity, c)
+}
+
+// VerifyIdentity 确认证书指纹和设备配对码。
+func VerifyIdentity(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	var req request.VerifyRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	identity, err := svc.VerifyIdentity(c.Param("device_id"), req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(identity, c)
+}
+
+// ProvisionDevice 绑定业务坐标并下发 MQTT 配置。
+func ProvisionDevice(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	var req request.ProvisionRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	status, err := svc.Provision(c.Param("device_id"), req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(status, c)
+}
+
+// GetStatus 查询接入状态和 MQTT 在线状态。
+func GetStatus(c *gin.Context) {
+	if !requireAdmin(c) {
+		return
+	}
+	status, err := svc.Status(c.Param("device_id"))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(status, c)
+}
+
+// SetupRouter 注册手工 IP 接入 API。路由挂在 PrivateGroup,仍受 JWT/Casbin/操作审计保护。
+func SetupRouter(router *gin.RouterGroup) {
+	group := router.Group("/device-provisioning")
+	group.POST("/discover", DiscoverDevices)
+	group.GET("/discoveries", ListDiscoveries)
+	group.POST("/manual/identity", ReadManualIdentity)
+	group.POST("/:device_id/identity", ReadDiscoveredIdentity)
+	group.POST("/:device_id/verify", VerifyIdentity)
+	group.POST("/:device_id/provision", ProvisionDevice)
+	group.GET("/:device_id/status", GetStatus)
+}

+ 30 - 0
internal/modules/deviceprovisioning/model/request/request.go

@@ -0,0 +1,30 @@
+package request
+
+// ManualIdentityRequest 发起一次手工 IP 身份读取。
+type ManualIdentityRequest struct {
+	IP              string `json:"ip" binding:"required"`
+	Port            int    `json:"port"`
+	ProvisionMethod string `json:"provision_method"`
+}
+
+// DiscoverRequest starts a bounded UDP discovery task.
+type DiscoverRequest struct {
+	BroadcastAddress string `json:"broadcast_address"`
+}
+
+// VerifyRequest 确认设备证书指纹和一次性配对码。
+type VerifyRequest struct {
+	Fingerprint string `json:"fingerprint" binding:"required"`
+	PairingCode string `json:"pairing_code"`
+}
+
+// ProvisionRequest 将设备绑定到停车场、岗亭和通道并下发 MQTT 配置。
+type ProvisionRequest struct {
+	DeviceCode   string `json:"device_code" binding:"required"`
+	DeviceName   string `json:"device_name" binding:"required"`
+	DeviceType   string `json:"device_type"`
+	ParkingLotID uint   `json:"parking_lot_id" binding:"required"`
+	BoothID      uint   `json:"booth_id" binding:"required"`
+	ChannelID    uint   `json:"channel_id" binding:"required"`
+	Direction    string `json:"direction"`
+}

+ 53 - 0
internal/modules/deviceprovisioning/model/response/response.go

@@ -0,0 +1,53 @@
+package response
+
+import "time"
+
+// IdentityResponse 是设备公开身份,不包含 MQTT 密码或配对码。
+type IdentityResponse struct {
+	RequestID              string    `json:"request_id"`
+	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"`
+	IP                     string    `json:"ip"`
+	ConfigPort             int       `json:"config_port"`
+	CertificateFingerprint string    `json:"certificate_fingerprint"`
+	PublicKeyFingerprint   string    `json:"public_key_fingerprint"`
+	PairingRequired        bool      `json:"pairing_required"`
+	Verified               bool      `json:"verified"`
+	ExpiredAt              time.Time `json:"expired_at"`
+	ProvisionStatus        string    `json:"provision_status"`
+}
+
+// DiscoveryResponse is an untrusted UDP discovery result awaiting HTTPS identity verification.
+type DiscoveryResponse struct {
+	RequestID            string    `json:"request_id"`
+	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"`
+	IP                   string    `json:"ip"`
+	ConfigPort           int       `json:"config_port"`
+	PublicKeyFingerprint string    `json:"public_key_fingerprint"`
+	SignatureVerified    bool      `json:"signature_verified"`
+	ProvisionMethod      string    `json:"provision_method"`
+	ProvisionStatus      string    `json:"provision_status"`
+	ExpiredAt            time.Time `json:"expired_at"`
+}
+
+// ProvisionStatusResponse 返回设备接入和 MQTT 在线状态。
+type ProvisionStatusResponse struct {
+	DeviceID          string     `json:"device_id"`
+	DeviceCode        string     `json:"device_code"`
+	ProvisionStatus   string     `json:"provision_status"`
+	ProvisionMethod   string     `json:"provision_method"`
+	DeviceStatus      string     `json:"device_status"`
+	ProvisionError    string     `json:"provision_error"`
+	IP                string     `json:"ip"`
+	ConfigPort        int        `json:"config_port"`
+	CertificateFinger string     `json:"certificate_fingerprint"`
+	VerifiedAt        *time.Time `json:"verified_at"`
+	ProvisionedAt     *time.Time `json:"provisioned_at"`
+}

+ 83 - 0
internal/modules/deviceprovisioning/repository/repo.go

@@ -0,0 +1,83 @@
+package repository
+
+import (
+	"errors"
+	"fmt"
+	"time"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// Repository 封装设备接入记录读写,避免 API 层直接拼接 GORM 条件。
+type Repository struct{}
+
+func New() *Repository { return &Repository{} }
+
+func (r *Repository) db() *gorm.DB { return global.GVA_DB }
+
+func (r *Repository) CreateDiscovery(record *dao.DeviceDiscovery) error {
+	if r.db() == nil {
+		return errors.New("数据库未初始化")
+	}
+	return r.db().Create(record).Error
+}
+
+func (r *Repository) LatestDiscovery(deviceID string) (*dao.DeviceDiscovery, error) {
+	return r.latestDiscovery(deviceID, "")
+}
+
+func (r *Repository) LatestDiscoveryByMethod(deviceID, method string) (*dao.DeviceDiscovery, error) {
+	return r.latestDiscovery(deviceID, method)
+}
+
+func (r *Repository) latestDiscovery(deviceID, method string) (*dao.DeviceDiscovery, error) {
+	if r.db() == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var record dao.DeviceDiscovery
+	query := r.db().Where("device_id = ?", deviceID)
+	if method != "" {
+		query = query.Where("provision_method = ?", method)
+	}
+	result := query.Order("created_at DESC").First(&record)
+	if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+		return nil, fmt.Errorf("设备身份不存在或已过期")
+	}
+	if result.Error != nil {
+		return nil, result.Error
+	}
+	if time.Now().After(record.ExpiredAt) {
+		return nil, fmt.Errorf("设备身份记录已过期,请重新读取")
+	}
+	return &record, nil
+}
+
+func (r *Repository) SaveDiscovery(record *dao.DeviceDiscovery) error {
+	if r.db() == nil {
+		return errors.New("数据库未初始化")
+	}
+	return r.db().Save(record).Error
+}
+
+func (r *Repository) ActiveDiscoveries() ([]dao.DeviceDiscovery, error) {
+	if r.db() == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var records []dao.DeviceDiscovery
+	result := r.db().Where("expired_at > ? AND provision_method = ?", time.Now(), "auto_udp").Order("created_at DESC").Find(&records)
+	return records, result.Error
+}
+
+func (r *Repository) ReaderByDeviceID(deviceID string) (*dao.UHFReader, error) {
+	if r.db() == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var reader dao.UHFReader
+	result := r.db().Where("device_id = ?", deviceID).First(&reader)
+	if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+		return nil, nil
+	}
+	return &reader, result.Error
+}

+ 329 - 0
internal/modules/deviceprovisioning/service/discovery.go

@@ -0,0 +1,329 @@
+package service
+
+import (
+	"context"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofrs/uuid/v5"
+	"go.uber.org/zap"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/deviceprovisioning/model/request"
+	responseModel "wails-app/internal/modules/deviceprovisioning/model/response"
+)
+
+const (
+	discoveryPort      = 31001
+	discoveryPacketMax = 4 * 1024
+	discoveryWindow    = 3 * time.Second
+)
+
+type discoveryRequestPacket struct {
+	Schema    string `json:"schema"`
+	RequestID string `json:"request_id"`
+	IssuedAt  int64  `json:"issued_at"`
+	ExpiresAt int64  `json:"expires_at"`
+	SystemID  string `json:"system_id"`
+	Nonce     string `json:"nonce"`
+}
+
+// discoveryResponsePacket intentionally remains an untrusted transport envelope.
+// HTTPS certificate pinning and administrator confirmation establish the device trust.
+type discoveryResponsePacket struct {
+	Schema               string `json:"schema"`
+	RequestID            string `json:"request_id"`
+	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"`
+	IP                   string `json:"ip"`
+	ConfigURL            string `json:"config_url"`
+	PublicKeyFingerprint string `json:"public_key_fingerprint"`
+	SigningPublicKey     string `json:"signing_public_key"`
+	Nonce                string `json:"nonce"`
+	Signature            string `json:"signature"`
+}
+
+// Discover broadcasts discovery.request.v1 and stores deduplicated candidates for ten minutes.
+func (s *Service) Discover(req request.DiscoverRequest) ([]responseModel.DiscoveryResponse, error) {
+	targets, err := discoveryBroadcastTargets(req.BroadcastAddress)
+	if err != nil {
+		return nil, err
+	}
+	requestID := uuid.Must(uuid.NewV4()).String()
+	nonceBytes := make([]byte, 32)
+	if _, err := rand.Read(nonceBytes); err != nil {
+		return nil, fmt.Errorf("生成发现随机数失败: %w", err)
+	}
+	now := time.Now()
+	packet := discoveryRequestPacket{
+		Schema: "discovery.request.v1", RequestID: requestID, IssuedAt: now.Unix(),
+		ExpiresAt: now.Add(30 * time.Second).Unix(), SystemID: discoverySystemID(),
+		Nonce: base64.StdEncoding.EncodeToString(nonceBytes),
+	}
+	body, err := json.Marshal(packet)
+	if err != nil {
+		return nil, err
+	}
+	packetConn, err := (&net.ListenConfig{Control: enableBroadcastSocketOption}).ListenPacket(context.Background(), "udp4", ":0")
+	if err != nil {
+		return nil, fmt.Errorf("创建 UDP 发现监听失败: %w", err)
+	}
+	conn, ok := packetConn.(*net.UDPConn)
+	if !ok {
+		_ = packetConn.Close()
+		return nil, errors.New("创建 UDP 发现监听失败: 套接字类型异常")
+	}
+	defer conn.Close()
+
+	send := func() error {
+		for _, target := range targets {
+			if _, err := conn.WriteToUDP(body, &net.UDPAddr{IP: target, Port: discoveryPort}); err != nil {
+				return fmt.Errorf("发送 UDP 发现请求失败: %w", err)
+			}
+		}
+		return nil
+	}
+	if err := send(); err != nil {
+		return nil, err
+	}
+	deadline := now.Add(discoveryWindow)
+	nextSend := now.Add(500 * time.Millisecond)
+	sendCount := 1
+	results := make(map[string]responseModel.DiscoveryResponse)
+	buffer := make([]byte, discoveryPacketMax+1)
+	for time.Now().Before(deadline) {
+		readUntil := deadline
+		if sendCount < 3 && nextSend.Before(readUntil) {
+			readUntil = nextSend
+		}
+		_ = conn.SetReadDeadline(readUntil)
+		n, source, readErr := conn.ReadFromUDP(buffer)
+		if readErr != nil {
+			if netErr, ok := readErr.(net.Error); !ok || !netErr.Timeout() {
+				return nil, fmt.Errorf("读取 UDP 发现响应失败: %w", readErr)
+			}
+			if sendCount < 3 && !time.Now().Before(nextSend) {
+				if err := send(); err != nil {
+					return nil, err
+				}
+				nextSend = nextSend.Add(500 * time.Millisecond)
+				sendCount++
+			}
+			continue
+		}
+		if n > discoveryPacketMax {
+			discoveryLogWarn("丢弃过大的 UDP 发现响应", zap.String("source_ip", source.IP.String()), zap.Int("packet_bytes", n))
+			continue
+		}
+		discoveryLogInfo("收到 UDP 发现响应", zap.String("request_id", requestID), zap.String("source_ip", source.IP.String()), zap.Int("packet_bytes", n))
+		candidate, err := validateDiscoveryResponse(buffer[:n], source.IP, packet)
+		if err != nil {
+			discoveryLogWarn("丢弃 UDP 发现响应", zap.String("request_id", requestID), zap.String("source_ip", source.IP.String()), zap.String("reason", err.Error()))
+			continue
+		}
+		expiresAt := time.Now().Add(10 * time.Minute)
+		payload := string(buffer[:n])
+		if _, exists := results[candidate.DeviceID]; exists {
+			discoveryLogInfo("忽略重复 UDP 发现响应", zap.String("request_id", requestID), zap.String("source_ip", source.IP.String()), zap.String("device_id", candidate.DeviceID))
+			continue
+		}
+		record := &dao.DeviceDiscovery{
+			RequestID: requestID,
+			DeviceID:  candidate.DeviceID, DeviceCode: candidate.DeviceCode, DeviceType: candidate.DeviceType,
+			DeviceModel: candidate.DeviceModel, FirmwareVersion: candidate.FirmwareVersion,
+			SourceIP: candidate.IP, ConfigPort: candidate.ConfigPort, Payload: payload,
+			PublicKeyFingerprint: candidate.PublicKeyFingerprint, Signature: candidateSignature(buffer[:n]),
+			SignatureVerified: candidate.SignatureVerified,
+			ProvisionMethod:   "auto_udp", ProvisionStatus: "unprovisioned", ExpiredAt: expiresAt,
+		}
+		if err := s.repo.CreateDiscovery(record); err != nil {
+			discoveryLogWarn("保存 UDP 发现结果失败", zap.String("request_id", requestID), zap.String("source_ip", source.IP.String()), zap.String("device_id", candidate.DeviceID), zap.Error(err))
+			return nil, err
+		}
+		discoveryLogInfo("UDP 发现响应校验并保存成功", zap.String("request_id", requestID), zap.String("source_ip", source.IP.String()), zap.String("device_id", candidate.DeviceID), zap.String("device_code", candidate.DeviceCode), zap.Int("config_port", candidate.ConfigPort))
+		candidate.RequestID = requestID
+		candidate.ExpiredAt = expiresAt
+		results[candidate.DeviceID] = candidate
+	}
+	output := make([]responseModel.DiscoveryResponse, 0, len(results))
+	for _, result := range results {
+		output = append(output, result)
+	}
+	return output, nil
+}
+
+func (s *Service) Discoveries() ([]responseModel.DiscoveryResponse, error) {
+	records, err := s.repo.ActiveDiscoveries()
+	if err != nil {
+		return nil, err
+	}
+	items := make([]responseModel.DiscoveryResponse, 0, len(records))
+	seen := map[string]bool{}
+	for _, record := range records {
+		if seen[record.DeviceID] {
+			continue
+		}
+		seen[record.DeviceID] = true
+		items = append(items, responseModel.DiscoveryResponse{
+			RequestID: record.RequestID, DeviceID: record.DeviceID, DeviceCode: record.DeviceCode,
+			DeviceType: record.DeviceType, DeviceModel: record.DeviceModel, FirmwareVersion: record.FirmwareVersion,
+			IP: record.SourceIP, ConfigPort: record.ConfigPort, PublicKeyFingerprint: record.PublicKeyFingerprint,
+			SignatureVerified: record.SignatureVerified, ProvisionMethod: record.ProvisionMethod,
+			ProvisionStatus: record.ProvisionStatus, ExpiredAt: record.ExpiredAt,
+		})
+	}
+	return items, nil
+}
+
+// ReadDiscoveredIdentity establishes the HTTPS certificate identity for a UDP candidate.
+func (s *Service) ReadDiscoveredIdentity(deviceID string) (*responseModel.IdentityResponse, error) {
+	record, err := s.repo.LatestDiscoveryByMethod(deviceID, "auto_udp")
+	if err != nil {
+		return nil, err
+	}
+	if record.ProvisionMethod != "auto_udp" {
+		return nil, errors.New("该设备不是自动发现记录")
+	}
+	return s.ReadIdentity(request.ManualIdentityRequest{IP: record.SourceIP, Port: record.ConfigPort, ProvisionMethod: "auto_udp"})
+}
+
+func validateDiscoveryResponse(raw []byte, sourceIP net.IP, request discoveryRequestPacket) (responseModel.DiscoveryResponse, error) {
+	var packet discoveryResponsePacket
+	if err := json.Unmarshal(raw, &packet); err != nil {
+		return responseModel.DiscoveryResponse{}, err
+	}
+	if packet.Schema != "discovery.response.v1" || packet.RequestID != request.RequestID || packet.Nonce != request.Nonce || strings.TrimSpace(packet.DeviceID) == "" {
+		return responseModel.DiscoveryResponse{}, errors.New("UDP 响应关联信息无效")
+	}
+	if strings.TrimSpace(packet.Signature) == "" || strings.TrimSpace(packet.SigningPublicKey) == "" {
+		return responseModel.DiscoveryResponse{}, errors.New("UDP 响应缺少签名")
+	}
+	publicKey, err := base64.StdEncoding.DecodeString(packet.SigningPublicKey)
+	if err != nil || len(publicKey) != ed25519.PublicKeySize {
+		return responseModel.DiscoveryResponse{}, errors.New("UDP 响应签名公钥无效")
+	}
+	publicKeyHash := sha256.Sum256(publicKey)
+	expectedFingerprint := fmt.Sprintf("%x", publicKeyHash)
+	if normalizeFingerprint(packet.PublicKeyFingerprint) != expectedFingerprint {
+		return responseModel.DiscoveryResponse{}, fmt.Errorf("UDP 响应公钥指纹不匹配: received=%q expected=SHA256:%s", packet.PublicKeyFingerprint, expectedFingerprint)
+	}
+	signature, err := base64.StdEncoding.DecodeString(packet.Signature)
+	if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), discoverySigningPayload(packet), signature) {
+		return responseModel.DiscoveryResponse{}, errors.New("UDP 响应签名校验失败")
+	}
+	claimedIP := net.ParseIP(packet.IP)
+	if claimedIP == nil || !claimedIP.Equal(sourceIP) {
+		return responseModel.DiscoveryResponse{}, fmt.Errorf("UDP 响应 IP 与报文来源不一致: claimed_ip=%q source_ip=%q", packet.IP, sourceIP.String())
+	}
+	configURL, err := url.Parse(packet.ConfigURL)
+	if err != nil || configURL.Scheme != "https" || configURL.Hostname() != sourceIP.String() {
+		return responseModel.DiscoveryResponse{}, errors.New("UDP 响应配置地址无效")
+	}
+	port := defaultConfigPort
+	if configURL.Port() != "" {
+		port, err = strconv.Atoi(configURL.Port())
+		if err != nil {
+			return responseModel.DiscoveryResponse{}, errors.New("UDP 响应配置端口无效")
+		}
+	}
+	if _, err := ValidateAddress(sourceIP.String(), port); err != nil {
+		return responseModel.DiscoveryResponse{}, err
+	}
+	return responseModel.DiscoveryResponse{
+		DeviceID: packet.DeviceID, DeviceCode: packet.DeviceCode, DeviceType: packet.DeviceType,
+		DeviceModel: packet.DeviceModel, FirmwareVersion: packet.FirmwareVersion, IP: sourceIP.String(),
+		ConfigPort: port, PublicKeyFingerprint: packet.PublicKeyFingerprint, SignatureVerified: true, ProvisionMethod: "auto_udp",
+		ProvisionStatus: "unprovisioned",
+	}, nil
+}
+
+// Discovery operates without the full backend bootstrap in unit tests, so logging is optional there.
+func discoveryLogInfo(message string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info(message, fields...)
+	}
+}
+
+func discoveryLogWarn(message string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn(message, fields...)
+	}
+}
+
+func discoverySigningPayload(packet discoveryResponsePacket) []byte {
+	return []byte(strings.Join([]string{
+		packet.Schema, packet.RequestID, packet.DeviceID, packet.IP, packet.ConfigURL, packet.Nonce,
+	}, "\n"))
+}
+
+func candidateSignature(raw []byte) string {
+	var packet discoveryResponsePacket
+	if json.Unmarshal(raw, &packet) != nil {
+		return ""
+	}
+	return packet.Signature
+}
+
+func discoveryBroadcastTargets(rawAddress string) ([]net.IP, error) {
+	if value := strings.TrimSpace(rawAddress); value != "" {
+		ip := net.ParseIP(value)
+		if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
+			return nil, errors.New("广播地址必须是合法 IPv4 定向广播地址")
+		}
+		return []net.IP{ip.To4()}, nil
+	}
+	interfaces, err := net.Interfaces()
+	if err != nil {
+		return nil, fmt.Errorf("枚举网卡失败: %w", err)
+	}
+	seen := map[string]bool{}
+	var targets []net.IP
+	for _, iface := range interfaces {
+		if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
+			continue
+		}
+		addrs, _ := iface.Addrs()
+		for _, address := range addrs {
+			ipNet, ok := address.(*net.IPNet)
+			if !ok || ipNet.IP.To4() == nil || len(ipNet.Mask) != net.IPv4len {
+				continue
+			}
+			broadcast := make(net.IP, net.IPv4len)
+			ip := ipNet.IP.To4()
+			for i := range broadcast {
+				broadcast[i] = ip[i] | ^ipNet.Mask[i]
+			}
+			if broadcast.Equal(ip) || seen[broadcast.String()] {
+				continue
+			}
+			seen[broadcast.String()] = true
+			targets = append(targets, broadcast)
+		}
+	}
+	if len(targets) == 0 {
+		return nil, errors.New("未找到可用 IPv4 网卡,请指定广播地址或使用手工 IP 接入")
+	}
+	return targets, nil
+}
+
+func discoverySystemID() string {
+	name, err := os.Hostname()
+	if err != nil || strings.TrimSpace(name) == "" {
+		return "smart-parking"
+	}
+	return "smart-parking-" + name
+}

+ 420 - 0
internal/modules/deviceprovisioning/service/service.go

@@ -0,0 +1,420 @@
+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
+}

+ 167 - 0
internal/modules/deviceprovisioning/service/service_test.go

@@ -0,0 +1,167 @@
+package service
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+)
+
+func TestValidateAddressRejectsUnsafeInputs(t *testing.T) {
+	tests := []struct {
+		name string
+		ip   string
+		port int
+	}{
+		{name: "ipv4 loopback", ip: "127.0.0.1", port: 8443},
+		{name: "localhost", ip: "localhost", port: 8443},
+		{name: "ipv4 unspecified", ip: "0.0.0.0", port: 8443},
+		{name: "limited broadcast", ip: "255.255.255.255", port: 8443},
+		{name: "ipv4 multicast", ip: "239.1.2.3", port: 8443},
+		{name: "ipv6 multicast", ip: "ff02::1", port: 8443},
+		{name: "ipv6 loopback", ip: "::1", port: 8443},
+		{name: "ipv4 mapped loopback", ip: "::ffff:127.0.0.1", port: 8443},
+		{name: "ipv4 mapped unspecified", ip: "::ffff:0.0.0.0", port: 8443},
+		{name: "port zero", ip: "192.0.2.10", port: 0},
+		{name: "port exceeds range", ip: "192.0.2.10", port: 65536},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if _, err := ValidateAddress(test.ip, test.port); err == nil {
+				t.Fatalf("ValidateAddress(%q, %d) should be rejected", test.ip, test.port)
+			}
+		})
+	}
+}
+
+func TestFindProvisionReaderReusesSoftDeletedSameDevice(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := db.AutoMigrate(&dao.UHFReader{}); err != nil {
+		t.Fatal(err)
+	}
+	row := &dao.UHFReader{DeviceCode: "MOCK-GATE-001", DeviceID: "MOCK-EDGE-001", DeviceName: "old", DeviceType: "gate"}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Delete(row).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	found, ok, err := findProvisionReader(db, "MOCK-EDGE-001", "MOCK-GATE-001")
+	if err != nil || !ok || found.ID != row.ID || !found.DeletedAt.Valid {
+		t.Fatalf("expected soft-deleted row to be reused, row=%+v found=%v ok=%v err=%v", row, found, ok, err)
+	}
+	found.DeletedAt = gorm.DeletedAt{}
+	found.DeviceName = "restored"
+	if err := db.Unscoped().Save(&found).Error; err != nil {
+		t.Fatal(err)
+	}
+	var active dao.UHFReader
+	if err := db.First(&active, row.ID).Error; err != nil {
+		t.Fatal(err)
+	}
+	if active.DeviceName != "restored" {
+		t.Fatalf("expected restored row, got %+v", active)
+	}
+}
+
+func TestFindProvisionReaderRejectsCodeOwnedByOtherDevice(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := db.AutoMigrate(&dao.UHFReader{}); err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&dao.UHFReader{DeviceCode: "MOCK-GATE-001", DeviceID: "OTHER-EDGE", DeviceName: "other", DeviceType: "gate"}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if _, ok, err := findProvisionReader(db, "MOCK-EDGE-001", "MOCK-GATE-001"); err == nil || ok {
+		t.Fatalf("expected code conflict, ok=%v err=%v", ok, err)
+	}
+}
+
+func TestValidateDiscoveryResponse(t *testing.T) {
+	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	keyHash := sha256.Sum256(publicKey)
+	request := discoveryRequestPacket{Schema: "discovery.request.v1", RequestID: "request-1", Nonce: "nonce-1"}
+	packet := discoveryResponsePacket{
+		Schema: "discovery.response.v1", RequestID: request.RequestID, Nonce: request.Nonce,
+		DeviceID: "SN-1", DeviceCode: "EDGE-1", DeviceType: "lpr-gate", DeviceModel: "LPR-200",
+		FirmwareVersion: "2.1.0", IP: "192.0.2.10", ConfigURL: "https://192.0.2.10:8443/api/v1/provision",
+		PublicKeyFingerprint: "SHA256:" + fmt.Sprintf("%x", keyHash), SigningPublicKey: base64.StdEncoding.EncodeToString(publicKey),
+	}
+	packet.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, discoverySigningPayload(packet)))
+	raw, err := json.Marshal(packet)
+	if err != nil {
+		t.Fatal(err)
+	}
+	result, err := validateDiscoveryResponse(raw, net.ParseIP("192.0.2.10"), request)
+	if err != nil {
+		t.Fatalf("expected valid discovery response: %v", err)
+	}
+	if !result.SignatureVerified || result.ConfigPort != 8443 || result.DeviceID != "SN-1" {
+		t.Fatalf("unexpected discovery result: %#v", result)
+	}
+
+	packet.Signature = base64.StdEncoding.EncodeToString([]byte("invalid"))
+	raw, _ = json.Marshal(packet)
+	if _, err := validateDiscoveryResponse(raw, net.ParseIP("192.0.2.10"), request); err == nil {
+		t.Fatal("invalid signature must be rejected")
+	}
+
+	packet.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, discoverySigningPayload(packet)))
+	packet.Nonce = "unexpected-nonce"
+	raw, _ = json.Marshal(packet)
+	if _, err := validateDiscoveryResponse(raw, net.ParseIP("192.0.2.10"), request); err == nil {
+		t.Fatal("response with a different nonce must be rejected")
+	}
+}
+
+func TestDiscoveryBroadcastTargetsAcceptsExplicitIPv4(t *testing.T) {
+	targets, err := discoveryBroadcastTargets("192.168.10.255")
+	if err != nil || len(targets) != 1 || !targets[0].Equal(net.ParseIP("192.168.10.255")) {
+		t.Fatalf("unexpected broadcast targets: %#v, %v", targets, err)
+	}
+	if _, err := discoveryBroadcastTargets("ff02::1"); err == nil {
+		t.Fatal("IPv6 multicast must not be accepted as an IPv4 UDP discovery target")
+	}
+}
+
+func TestValidateAddressAllowsUnicastIPs(t *testing.T) {
+	for _, rawIP := range []string{"192.0.2.10", "2001:db8::10"} {
+		ip, err := ValidateAddress(rawIP, 8443)
+		if err != nil {
+			t.Fatalf("ValidateAddress(%q) returned error: %v", rawIP, err)
+		}
+		if !ip.Equal(net.ParseIP(rawIP)) {
+			t.Fatalf("ValidateAddress(%q) returned %v", rawIP, ip)
+		}
+	}
+}
+
+func TestDirectedBroadcastCheck(t *testing.T) {
+	network := net.ParseIP("192.168.50.12")
+	mask := net.CIDRMask(24, 32)
+	if !isDirectedBroadcastForNetwork(net.ParseIP("192.168.50.255"), network, mask) {
+		t.Fatal("expected subnet broadcast address to be rejected")
+	}
+	if isDirectedBroadcastForNetwork(net.ParseIP("192.168.50.26"), network, mask) {
+		t.Fatal("ordinary unicast address must not be classified as broadcast")
+	}
+}

+ 10 - 0
internal/modules/deviceprovisioning/service/udp_socket_other.go

@@ -0,0 +1,10 @@
+//go:build !windows
+
+package service
+
+import "syscall"
+
+// Non-Windows UDP implementations permit broadcast without an explicit socket option.
+func enableBroadcastSocketOption(_, _ string, _ syscall.RawConn) error {
+	return nil
+}

+ 17 - 0
internal/modules/deviceprovisioning/service/udp_socket_windows.go

@@ -0,0 +1,17 @@
+//go:build windows
+
+package service
+
+import "syscall"
+
+// enableBroadcastSocketOption is required by Winsock before a UDP socket can
+// send to a directed broadcast address.
+func enableBroadcastSocketOption(_, _ string, rawConn syscall.RawConn) error {
+	var socketErr error
+	if err := rawConn.Control(func(fd uintptr) {
+		socketErr = syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
+	}); err != nil {
+		return err
+	}
+	return socketErr
+}

+ 19 - 8
internal/modules/digital-ticket/service/service.go

@@ -124,7 +124,11 @@ func validatePaymentExtra(extra map[string]interface{}) error {
 			return ErrInvalidTicketPayment
 		}
 	}
-	if hasFee && hasPaid && paid < fee {
+	// RFID 自动免密结算不产生实收金额。费用仍需保留在数字票中用于收费统计,
+	// 因此仅在调用方显式授权时允许 paid_amount 为 0;其他支付方式必须足额支付。
+	paymentMethod, _ := extra["payment_method"].(string)
+	allowUnpaidFree, _ := extra["allow_unpaid_free"].(bool)
+	if hasFee && hasPaid && paid < fee && !(paymentMethod == "free" && allowUnpaidFree) {
 		return errors.New("paid amount insufficient")
 	}
 	return nil
@@ -198,8 +202,14 @@ func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra
 // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's
 // transaction. Payment entry and method are stored as payment metadata only;
 // they do not define the ticket itself.
-func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64) error {
-	if err := validatePaymentExtra(map[string]interface{}{"paid_amount": paidAmount, "fee": fee}); err != nil {
+func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64, allowUnpaidFreeOption ...bool) error {
+	allowUnpaidFree := len(allowUnpaidFreeOption) > 0 && allowUnpaidFreeOption[0]
+	if err := validatePaymentExtra(map[string]interface{}{
+		"paid_amount":       paidAmount,
+		"fee":               fee,
+		"payment_method":    paymentMethod,
+		"allow_unpaid_free": allowUnpaidFree,
+	}); err != nil {
 		return err
 	}
 	ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
@@ -209,11 +219,12 @@ func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentE
 
 	if ticket.State == StatePendingPayment {
 		extra := map[string]interface{}{
-			"payment_entry":    paymentEntry,
-			"payment_method":   paymentMethod,
-			"payment_order_no": paymentOrderNo,
-			"paid_amount":      paidAmount,
-			"fee":              fee,
+			"payment_entry":     paymentEntry,
+			"payment_method":    paymentMethod,
+			"payment_order_no":  paymentOrderNo,
+			"paid_amount":       paidAmount,
+			"fee":               fee,
+			"allow_unpaid_free": allowUnpaidFree,
 		}
 		if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil {
 			return err

+ 36 - 9
internal/modules/monthly/api.go

@@ -1,7 +1,10 @@
 package monthly
 
 import (
+	"fmt"
 	"strconv"
+	"strings"
+	"time"
 
 	"github.com/gin-gonic/gin"
 	"wails-app/internal/model/common/response"
@@ -13,10 +16,23 @@ import (
 var monthlySvc = service.NewMonthlyCardService()
 
 type createReq struct {
-	VehicleID uint    `json:"vehicle_id"`
-	CardType  string  `json:"card_type"`
-	Fee       float64 `json:"fee"`
-	Remark    string  `json:"remark"`
+	VehicleID     uint    `json:"vehicle_id"`
+	CardType      string  `json:"card_type"`
+	Fee           float64 `json:"fee"`
+	EffectiveDate string  `json:"effective_date"`
+	Remark        string  `json:"remark"`
+}
+
+func parseEffectiveDate(value string) (time.Time, error) {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return time.Time{}, fmt.Errorf("生效日期不能为空")
+	}
+	date, err := time.ParseInLocation("2006-01-02", value, time.Local)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("生效日期格式错误,应为 YYYY-MM-DD")
+	}
+	return date, nil
 }
 
 func CreateCard(c *gin.Context) {
@@ -25,7 +41,12 @@ func CreateCard(c *gin.Context) {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	card, err := monthlySvc.Create(req.VehicleID, req.CardType, req.Fee, utils.GetUserID(c), req.Remark)
+	effectiveDate, err := parseEffectiveDate(req.EffectiveDate)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	card, err := monthlySvc.Create(req.VehicleID, req.CardType, req.Fee, utils.GetUserID(c), req.Remark, effectiveDate)
 	if err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
@@ -34,9 +55,10 @@ func CreateCard(c *gin.Context) {
 }
 
 type renewReq struct {
-	CardID   uint    `json:"card_id"`
-	CardType string  `json:"card_type"`
-	Fee      float64 `json:"fee"`
+	CardID        uint    `json:"card_id"`
+	CardType      string  `json:"card_type"`
+	Fee           float64 `json:"fee"`
+	EffectiveDate string  `json:"effective_date"`
 }
 
 func RenewCard(c *gin.Context) {
@@ -45,7 +67,12 @@ func RenewCard(c *gin.Context) {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	if err := monthlySvc.Renew(req.CardID, req.CardType, req.Fee, utils.GetUserID(c)); err != nil {
+	effectiveDate, err := parseEffectiveDate(req.EffectiveDate)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := monthlySvc.Renew(req.CardID, req.CardType, req.Fee, utils.GetUserID(c), effectiveDate); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}

+ 38 - 9
internal/modules/monthly/service/service.go

@@ -34,7 +34,8 @@ func cardDays(cardType string) int {
 }
 
 // Create 办理月卡:月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
-func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string) (*dao.MonthlyCard, error) {
+// Create 办理月卡。effectiveDate 可选,未传入时兼容旧调用并使用当天。
+func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string, effectiveDate ...time.Time) (*dao.MonthlyCard, error) {
 	existing, err := s.repo.GetByVehicleID(vehicleID)
 	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, fmt.Errorf("查询有效月卡失败: %w", err)
@@ -49,11 +50,18 @@ func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64
 	}
 
 	now := time.Now()
-	endDate := now.AddDate(0, 0, cardDays(cardType))
+	startDate := normalizeDate(now)
+	if len(effectiveDate) > 0 && !effectiveDate[0].IsZero() {
+		startDate = normalizeDate(effectiveDate[0])
+	}
+	if startDate.Before(normalizeDate(now)) {
+		return nil, errors.New("生效日期不能早于今天")
+	}
+	endDate := startDate.AddDate(0, 0, cardDays(cardType))
 
 	card := &dao.MonthlyCard{
 		VehicleID: vehicleID, CardType: cardType,
-		StartDate: now, EndDate: endDate,
+		StartDate: startDate, EndDate: endDate,
 		Fee: fee, PaymentStatus: "paid", OperatorID: operatorID, Remark: remark,
 	}
 
@@ -90,7 +98,8 @@ func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64
 
 // Renew 续费:未过期卡从原到期日顺延,已过期卡从今天起算;
 // 月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
-func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint) error {
+// Renew 续费。生效日期必须不早于今天,且有效月卡不能早于原到期日续费。
+func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint, effectiveDate ...time.Time) error {
 	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
 		var card dao.MonthlyCard
 		if err := tx.First(&card, cardID).Error; err != nil {
@@ -101,14 +110,29 @@ func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, op
 		}
 
 		now := time.Now()
-		// 过期卡从今天起算,避免"续费后仍处于过期状态"
-		base := card.EndDate
-		if base.Before(now) {
-			base = now
+		startDate := normalizeDate(now)
+		hasEffectiveDate := len(effectiveDate) > 0 && !effectiveDate[0].IsZero()
+		if hasEffectiveDate {
+			startDate = normalizeDate(effectiveDate[0])
+			if startDate.Before(normalizeDate(now)) {
+				return errors.New("生效日期不能早于今天")
+			}
+			if card.EndDate.After(startDate) && card.EndDate.After(now) {
+				return errors.New("有效月卡的生效日期不能早于原到期日")
+			}
+		} else {
+			// 兼容旧的内部调用:有效卡从原到期日顺延,过期卡从当前时间起算。
+			startDate = card.EndDate
+			if startDate.Before(now) {
+				startDate = now
+			}
 		}
-		newEnd := base.AddDate(0, 0, cardDays(cardType))
+		newEnd := startDate.AddDate(0, 0, cardDays(cardType))
 
 		card.EndDate = newEnd
+		if hasEffectiveDate {
+			card.StartDate = startDate
+		}
 		card.Fee += fee
 		if err := tx.Save(&card).Error; err != nil {
 			return fmt.Errorf("更新月卡失败: %w", err)
@@ -134,6 +158,11 @@ func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, op
 }
 
 // Refund 退卡:标记退款状态与移除白名单在同一事务中,任一步失败整体回滚。
+func normalizeDate(value time.Time) time.Time {
+	local := value.In(time.Local)
+	return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
+}
+
 func (s *MonthlyCardService) Refund(cardID uint) error {
 	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
 		var card dao.MonthlyCard

+ 36 - 0
internal/modules/monthly/service/service_test.go

@@ -35,6 +35,11 @@ func countTable(t *testing.T, model interface{}) int64 {
 	return count
 }
 
+func requireSameDate(t *testing.T, expected, actual time.Time) {
+	t.Helper()
+	require.Equal(t, expected.Format("2006-01-02"), actual.Format("2006-01-02"))
+}
+
 func TestCreateWritesCardWhitelistAndPayment(t *testing.T) {
 	svc := setupMonthlyServiceTest(t)
 	vehicle := createVehicle(t, "MC-001")
@@ -64,6 +69,23 @@ func TestCreateWritesCardWhitelistAndPayment(t *testing.T) {
 	require.Equal(t, uint(7), pay.OperatorID)
 }
 
+func TestCreateUsesEffectiveDate(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-DATE-001")
+	effectiveDate := time.Now().AddDate(0, 0, 5)
+	card, err := svc.Create(vehicle.ID, "month", 300, 7, "", effectiveDate)
+	require.NoError(t, err)
+	requireSameDate(t, normalizeDate(effectiveDate), card.StartDate)
+	requireSameDate(t, normalizeDate(effectiveDate).AddDate(0, 0, 30), card.EndDate)
+}
+
+func TestCreateRejectsPastEffectiveDate(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-DATE-002")
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "", time.Now().AddDate(0, 0, -1))
+	require.ErrorContains(t, err, "生效日期不能早于今天")
+}
+
 func TestCreateRollsBackWhenWhitelistWriteFails(t *testing.T) {
 	svc := setupMonthlyServiceTest(t)
 	vehicle := createVehicle(t, "MC-002")
@@ -148,6 +170,20 @@ func TestRenewExtendsFromEndDateWhenActive(t *testing.T) {
 	require.True(t, shortlist.ExpirationTime.Equal(renewed.EndDate))
 }
 
+func TestRenewUsesExplicitEffectiveDate(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-DATE-003")
+	now := time.Now()
+	card := &dao.MonthlyCard{VehicleID: vehicle.ID, CardType: "month", StartDate: now.AddDate(0, -1, 0), EndDate: now.AddDate(0, 0, 2), Fee: 300, PaymentStatus: "paid"}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	effectiveDate := time.Now().AddDate(0, 0, 3)
+	require.NoError(t, svc.Renew(card.ID, "month", 300, 7, effectiveDate))
+	var renewed dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&renewed, card.ID).Error)
+	requireSameDate(t, normalizeDate(effectiveDate), renewed.StartDate)
+	requireSameDate(t, normalizeDate(effectiveDate).AddDate(0, 0, 30), renewed.EndDate)
+}
+
 func TestRenewStartsFromNowWhenCardExpired(t *testing.T) {
 	svc := setupMonthlyServiceTest(t)
 	vehicle := createVehicle(t, "MC-006")

+ 11 - 0
internal/router/parking/parking.go

@@ -17,6 +17,10 @@ func SetupParkingRouter(router *gin.RouterGroup) {
 		gateGroup.POST("/open", parking.OpenGate)
 		gateGroup.POST("/close", parking.CloseGate)
 	}
+	cameraGroup := parkingGroup.Group("/camera")
+	{
+		cameraGroup.GET("/streams", parking.ListCameraStreams)
+	}
 	parkLotGroup := parkingGroup.Group("/lot")
 	{
 		parkLotGroup.POST("/create", parking.CreateParkingLot)
@@ -59,6 +63,13 @@ func SetupParkingRouter(router *gin.RouterGroup) {
 	}
 }
 
+// SetupParkingStreamPublicRouter 注册带短期令牌的摄像头流输出地址。
+// 浏览器的 <img>/<video> 请求无法携带 x-token 请求头,因此该地址放在公开组,
+// 由服务层校验一次性短期令牌和设备状态。
+func SetupParkingStreamPublicRouter(router *gin.RouterGroup) {
+	router.GET("/parking/camera/stream/:device_code/:token", parking.CameraMJPEG)
+}
+
 // SetupParkingDebugRouter 仅在显式开发配置开启时注册模拟事件接口。
 func SetupParkingDebugRouter(router *gin.RouterGroup, enabled bool) {
 	if !enabled {

+ 145 - 0
internal/service/devicebus/device_bus.go

@@ -0,0 +1,145 @@
+package devicebus
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"go.uber.org/zap"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	common "wails-app/internal/model/common"
+	parking "wails-app/internal/service/parking"
+	"wails-app/internal/service/uhf"
+)
+
+// DeviceBus 订阅相机识别事件,并将事件转发到统一通行入口。
+// 道闸状态、ACK 和 LWT 由 MQTTGateController 统一处理,避免重叠订阅覆盖回调。
+type DeviceBus struct {
+	passage *parking.PassageService
+}
+
+func NewDeviceBus() *DeviceBus {
+	return &DeviceBus{passage: &parking.PassageService{}}
+}
+
+// CameraEvent 是相机识别事件的 MQTT 数据结构。
+type CameraEvent struct {
+	Schema      string  `json:"schema"`
+	DeviceCode  string  `json:"device_code"`
+	PlateNumber string  `json:"plate_number"`
+	RFIDTag     string  `json:"rfid_tag"`
+	Direction   string  `json:"direction"`
+	TS          int64   `json:"ts"`
+	ImagePath   string  `json:"image_path"`
+	ImageID     string  `json:"image_id"`
+	EventID     string  `json:"event_id"`
+	Confidence  float64 `json:"confidence"`
+}
+
+// Subscribe 只订阅相机事件,设备状态类消息由道闸控制器订阅。
+func (b *DeviceBus) Subscribe(client mqtt.Client) error {
+	topic := "parking/lot/+/booth/+/channel/+/camera/event"
+	token := client.Subscribe(topic, 1, b.onMessage)
+	if token.Wait() && token.Error() != nil {
+		return token.Error()
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("MQTT 设备主题订阅成功", zap.String("topic", topic))
+	}
+	return nil
+}
+
+func (b *DeviceBus) onMessage(_ mqtt.Client, msg mqtt.Message) {
+	var probe struct {
+		Schema string `json:"schema"`
+	}
+	if err := json.Unmarshal(msg.Payload(), &probe); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("MQTT 设备消息 JSON 无效", zap.String("topic", msg.Topic()), zap.Error(err))
+		}
+		return
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("收到设备 MQTT 消息", zap.String("topic", msg.Topic()), zap.String("schema", probe.Schema))
+	}
+	if probe.Schema == "camera.event.v1" {
+		// 识别处理包含数据库事务和等待道闸 ACK,异步执行避免阻塞 MQTT 回调。
+		// 回调返回后 MQTT 客户端可以复用消息缓冲区,因此协程必须使用副本。
+		payload := append([]byte(nil), msg.Payload()...)
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Info("收到车辆识别 MQTT 事件", zap.String("topic", msg.Topic()), zap.Int("payload_bytes", len(payload)))
+		}
+		go b.onCameraEvent(payload)
+	}
+}
+
+func (b *DeviceBus) onCameraEvent(payload []byte) {
+	startedAt := time.Now()
+	var ev CameraEvent
+	if err := json.Unmarshal(payload, &ev); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("车辆识别 MQTT 事件解析失败", zap.Int("payload_bytes", len(payload)), zap.Error(err))
+		}
+		return
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("车辆识别事件字段已解析", zap.String("device_code", ev.DeviceCode), zap.String("event_id", ev.EventID), zap.String("image_id", ev.ImageID), zap.String("plate_number", ev.PlateNumber), zap.String("direction", ev.Direction), zap.Int64("ts", ev.TS))
+	}
+	imagePath, err := resolveImagePath(ev.DeviceCode, ev.ImageID, ev.ImagePath)
+	if err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("车辆识别事件引用的图片不可用", zap.String("device_code", ev.DeviceCode), zap.String("image_id", ev.ImageID), zap.String("event_id", ev.EventID), zap.Error(err))
+		}
+		return
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("车辆识别事件图片引用校验成功", zap.String("device_code", ev.DeviceCode), zap.String("event_id", ev.EventID), zap.String("image_id", ev.ImageID), zap.Bool("legacy_image_path", ev.ImageID == ""))
+	}
+	req := common.PassageRequest{
+		DeviceCode: ev.DeviceCode, PlateNumber: ev.PlateNumber, RFIDTag: ev.RFIDTag,
+		Direction: ev.Direction, Image: imagePath, TriggerSource: "camera",
+	}
+	result, err := b.passage.HandlePassage(req)
+	status := "completed"
+	message := "识别通过"
+	fee := 0.0
+	stayTime := int64(0)
+	if err != nil {
+		status = "failed"
+		message = err.Error()
+	} else if result != nil {
+		fee = result.Fee
+		stayTime = result.StayTime
+		if result.Message != "" {
+			message = result.Message
+		}
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("处理车辆识别事件",
+			zap.String("device_code", ev.DeviceCode), zap.String("plate_number", ev.PlateNumber),
+			zap.String("direction", ev.Direction), zap.String("event_id", ev.EventID), zap.String("image_id", ev.ImageID), zap.String("status", status), zap.String("message", message), zap.Duration("duration", time.Since(startedAt)))
+	}
+	uhf.PushChannelEvent(uhf.ChannelEvent{
+		DeviceCode: ev.DeviceCode, RFIDTag: ev.RFIDTag, PlateNumber: ev.PlateNumber,
+		Direction: ev.Direction, Timestamp: ev.TS, Status: status, Message: message,
+		Fee: fee, StayTime: stayTime,
+	})
+}
+
+// resolveImagePath accepts the new uploaded-image reference. image_path stays
+// supported for deployed legacy cameras until their firmware is upgraded.
+func resolveImagePath(deviceCode, imageID, legacyPath string) (string, error) {
+	if imageID == "" {
+		return legacyPath, nil
+	}
+	if global.GVA_DB == nil {
+		return "", fmt.Errorf("数据库未初始化")
+	}
+	var image dao.DeviceImage
+	if err := global.GVA_DB.Where("device_code = ? AND image_id = ?", deviceCode, imageID).First(&image).Error; err != nil {
+		return "", fmt.Errorf("图片未上传或不属于该设备")
+	}
+	return image.URL, nil
+}

+ 34 - 0
internal/service/devicebus/device_bus_test.go

@@ -0,0 +1,34 @@
+package devicebus
+
+import (
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func TestResolveImagePathUsesOnlySameDeviceUpload(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.DeviceImage{}))
+	previous := global.GVA_DB
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = previous })
+	require.NoError(t, db.Create(&dao.DeviceImage{DeviceCode: "CAM-A01", ImageID: "img-1", URL: "uploads/file/img-1.jpg"}).Error)
+
+	path, err := resolveImagePath("CAM-A01", "img-1", "")
+	require.NoError(t, err)
+	require.Equal(t, "uploads/file/img-1.jpg", path)
+
+	_, err = resolveImagePath("CAM-A02", "img-1", "")
+	require.Error(t, err)
+}
+
+func TestResolveImagePathKeepsLegacyPath(t *testing.T) {
+	path, err := resolveImagePath("CAM-A01", "", "https://legacy-device/image.jpg")
+	require.NoError(t, err)
+	require.Equal(t, "https://legacy-device/image.jpg", path)
+}

+ 231 - 0
internal/service/devicebus/mqtt_integration_test.go

@@ -0,0 +1,231 @@
+package devicebus
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"testing"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	incidentService "wails-app/internal/modules/incident/service"
+	"wails-app/internal/service/mqttbroker"
+	parkingService "wails-app/internal/service/parking"
+)
+
+func mqttE2EClient(t *testing.T, addr, id string) mqtt.Client {
+	t.Helper()
+	client := mqtt.NewClient(mqtt.NewClientOptions().AddBroker(addr).SetClientID(id).SetConnectTimeout(3 * time.Second))
+	token := client.Connect()
+	require.True(t, token.Wait())
+	require.NoError(t, token.Error())
+	t.Cleanup(func() { client.Disconnect(100) })
+	return client
+}
+
+func mqttE2EPublish(t *testing.T, client mqtt.Client, topic string, retained bool, payload map[string]interface{}) {
+	t.Helper()
+	data, err := json.Marshal(payload)
+	require.NoError(t, err)
+	token := client.Publish(topic, 1, retained, data)
+	require.True(t, token.Wait())
+	require.NoError(t, token.Error())
+}
+
+func mqttE2EWait(t *testing.T, check func() bool) {
+	t.Helper()
+	require.Eventually(t, check, 8*time.Second, 25*time.Millisecond)
+}
+
+// TestMQTTFullPassageLifecycle 验证道闸状态和车牌识别消息经过 MQTT 后的完整业务链路。
+func TestMQTTFullPassageLifecycle(t *testing.T) {
+	const (
+		plate         = "MQTT-E2E-01"
+		inDeviceCode  = "MQTT-IN-01"
+		outDeviceCode = "MQTT-OUT-01"
+	)
+
+	dsn := fmt.Sprintf("file:mqtt-full-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	sqlDB.SetMaxOpenConns(5)
+	global.GVA_DB = db
+	global.GVA_LOG = zap.NewNop()
+	t.Cleanup(func() {
+		parkingService.SetGateController(nil)
+		global.GVA_DB = nil
+		global.GVA_LOG = nil
+		_ = sqlDB.Close()
+	})
+	require.NoError(t, db.AutoMigrate(
+		&dao.ParkingLot{}, &dao.Booth{}, &dao.Channel{}, &dao.UHFReader{},
+		&dao.VehicleType{}, &dao.Vehicle{}, &dao.VehicleRecord{}, &dao.DigitalTicket{},
+		&dao.FeeConfig{}, &dao.PaymentRecord{}, &dao.PaymentEntryConfig{}, &dao.MonthlyCard{},
+		&dao.Shortlist{}, &dao.IncidentRecord{}, &dao.DeviceCommandLog{},
+	))
+
+	lot := dao.ParkingLot{LotCode: "MQTT-E2E-LOT", LotName: "MQTT全链路停车场", Capacity: 10, Available: 10}
+	require.NoError(t, db.Create(&lot).Error)
+	booth := dao.Booth{BoothCode: "MQTT-E2E-BOOTH", BoothName: "MQTT测试岗亭", ParkingLotID: lot.ID}
+	require.NoError(t, db.Create(&booth).Error)
+	inChannel := dao.Channel{ChannelCode: "MQTT-E2E-IN", ChannelName: "MQTT入口通道", Direction: "in", AllowTemporary: true, ParkingLotID: lot.ID, BoothID: booth.ID}
+	outChannel := dao.Channel{ChannelCode: "MQTT-E2E-OUT", ChannelName: "MQTT出口通道", Direction: "out", AllowTemporary: true, ParkingLotID: lot.ID, BoothID: booth.ID}
+	require.NoError(t, db.Create(&inChannel).Error)
+	require.NoError(t, db.Create(&outChannel).Error)
+	require.NoError(t, db.Create(&dao.UHFReader{DeviceCode: inDeviceCode, DeviceName: "MQTT入口道闸", DeviceType: "gate", ConnectType: dao.ConnectTypeMQTT, IsActive: true, ParkingLotID: lot.ID, ChannelID: inChannel.ID}).Error)
+	require.NoError(t, db.Create(&dao.UHFReader{DeviceCode: outDeviceCode, DeviceName: "MQTT出口道闸", DeviceType: "gate", ConnectType: dao.ConnectTypeMQTT, IsActive: true, ParkingLotID: lot.ID, ChannelID: outChannel.ID}).Error)
+	vehicleType := dao.VehicleType{Name: "普通车", IsSystem: false}
+	require.NoError(t, db.Create(&vehicleType).Error)
+	require.NoError(t, db.Create(&dao.FeeConfig{VehicleTypeID: vehicleType.ID, UnitTime: 60, StartTime: 0, StartFee: 0, UnitFee: 0, DailyMaxFee: 0, FreeExitMinutes: 15, VIPDiscount: 1}).Error)
+	require.NoError(t, db.Create(&dao.Vehicle{PlateNumber: plate, VehicleTypeID: vehicleType.ID}).Error)
+	require.NoError(t, db.Create(&dao.PaymentEntryConfig{Code: "counter", Name: "收费岗亭", Enabled: true, IsSystem: true}).Error)
+
+	// 先申请一个空闲 TCP 端口,再启动内嵌 Broker,避免和开发环境端口冲突。
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+	listenAddr := listener.Addr().String()
+	require.NoError(t, listener.Close())
+	broker, err := mqttbroker.Start(listenAddr)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = broker.Close() })
+	brokerAddr := "tcp://" + listenAddr
+	systemClient := mqttE2EClient(t, brokerAddr, "mqtt-e2e-system")
+	deviceClient := mqttE2EClient(t, brokerAddr, "mqtt-e2e-devices")
+	observerClient := mqttE2EClient(t, brokerAddr, "mqtt-e2e-observer")
+	topic := func(channelID uint, suffix string) string {
+		return fmt.Sprintf("parking/lot/%d/booth/%d/channel/%d/%s", lot.ID, booth.ID, channelID, suffix)
+	}
+	mqttGate := parkingService.NewMQTTGateController(systemClient)
+	require.NoError(t, mqttGate.LoadRoutes())
+	require.NoError(t, mqttGate.Subscribe())
+	parkingService.SetGateController(mqttGate)
+	bus := NewDeviceBus()
+	require.NoError(t, bus.Subscribe(systemClient))
+	cameraEvents := make(chan string, 4)
+	observerToken := observerClient.Subscribe("parking/lot/+/booth/+/channel/+/camera/event", 1, func(_ mqtt.Client, msg mqtt.Message) {
+		cameraEvents <- msg.Topic()
+	})
+	require.True(t, observerToken.Wait())
+	require.NoError(t, observerToken.Error())
+
+	// 模拟设备:收到系统命令后,向同一通道发布成功 ACK。
+	ackReceived := make(chan string, 4)
+	ack := func(_ mqtt.Client, msg mqtt.Message) {
+		var command struct {
+			CmdID      string `json:"cmd_id"`
+			DeviceCode string `json:"device_code"`
+		}
+		if json.Unmarshal(msg.Payload(), &command) != nil || command.CmdID == "" {
+			return
+		}
+		deviceCode := command.DeviceCode
+		select {
+		case ackReceived <- deviceCode:
+		default:
+		}
+		channelID := inChannel.ID
+		if deviceCode == outDeviceCode {
+			channelID = outChannel.ID
+		}
+		data, _ := json.Marshal(map[string]interface{}{"schema": "gate.ack.v1", "cmd_id": command.CmdID, "device_code": deviceCode, "result": "success", "error": ""})
+		token := deviceClient.Publish(topic(channelID, "gate/cmd/ack"), 1, false, data)
+		token.Wait()
+	}
+	subscribeToken := deviceClient.SubscribeMultiple(map[string]byte{
+		topic(inChannel.ID, "gate/cmd"):  1,
+		topic(outChannel.ID, "gate/cmd"): 1,
+	}, ack)
+	require.True(t, subscribeToken.Wait())
+	require.NoError(t, subscribeToken.Error())
+
+	// 预检 MQTT 命令和 ACK 通路,避免把协议失败误判为车辆业务失败。
+	openDone := make(chan error, 1)
+	go func() { openDone <- mqttGate.OpenGate(inDeviceCode, 1) }()
+	mqttE2EWait(t, func() bool {
+		select {
+		case deviceCode := <-ackReceived:
+			return deviceCode == inDeviceCode
+		default:
+			return false
+		}
+	})
+	require.NoError(t, <-openDone)
+
+	// 1. 两个道闸上线,验证内存状态和设备表状态均更新。
+	mqttE2EPublish(t, deviceClient, topic(inChannel.ID, "gate/state"), true, map[string]interface{}{"schema": "gate.state.v1", "device_code": inDeviceCode, "state": "closed"})
+	mqttE2EPublish(t, deviceClient, topic(outChannel.ID, "gate/state"), true, map[string]interface{}{"schema": "gate.state.v1", "device_code": outDeviceCode, "state": "closed"})
+	mqttE2EWait(t, func() bool {
+		var count int64
+		return db.Model(&dao.UHFReader{}).Where("status = ?", "online").Count(&count).Error == nil && count == 2
+	})
+	require.True(t, mqttGate.IsGateConnected(inDeviceCode))
+	require.True(t, mqttGate.IsGateConnected(outDeviceCode))
+
+	// 2. 入口道闸离线后恢复上线,同时确认产生离线异常记录。
+	mqttE2EPublish(t, deviceClient, topic(inChannel.ID, "gate/lwt"), true, map[string]interface{}{"schema": "gate.lwt.v1", "device_code": inDeviceCode})
+	mqttE2EWait(t, func() bool {
+		var device dao.UHFReader
+		return db.Where("device_code = ?", inDeviceCode).First(&device).Error == nil && device.Status == "offline"
+	})
+	require.False(t, mqttGate.IsGateConnected(inDeviceCode))
+	var offlineIncidents int64
+	mqttE2EWait(t, func() bool {
+		return db.Model(&dao.IncidentRecord{}).Where("category = ? AND device_code = ?", incidentService.CategoryDeviceOffline, inDeviceCode).Count(&offlineIncidents).Error == nil && offlineIncidents >= 1
+	})
+	mqttE2EPublish(t, deviceClient, topic(inChannel.ID, "gate/state"), true, map[string]interface{}{"schema": "gate.state.v1", "device_code": inDeviceCode, "state": "closed"})
+	mqttE2EWait(t, func() bool {
+		var device dao.UHFReader
+		return db.Where("device_code = ?", inDeviceCode).First(&device).Error == nil && device.Status == "online"
+	})
+
+	// 3. 车牌识别入场,验证停车会话、数字票、余位和入口开闸流水。
+	mqttE2EPublish(t, deviceClient, topic(inChannel.ID, "camera/event"), false, map[string]interface{}{"schema": "camera.event.v1", "device_code": inDeviceCode, "plate_number": plate, "direction": "in", "ts": time.Now().Unix()})
+	mqttE2EWait(t, func() bool { return len(cameraEvents) >= 1 })
+	var record dao.VehicleRecord
+	mqttE2EWait(t, func() bool {
+		return db.Where("plate_number = ? AND exit_time IS NULL", plate).First(&record).Error == nil
+	})
+	var ticket dao.DigitalTicket
+	require.NoError(t, db.Where("vehicle_record_id = ?", record.ID).First(&ticket).Error)
+	require.Equal(t, "pending_payment", ticket.State)
+	require.Equal(t, inChannel.ID, record.EntryChannelID)
+	require.Equal(t, inDeviceCode, record.EntryDeviceCode)
+	require.Equal(t, 9, lotCapacity(t, db, lot.ID))
+	var entryCommand dao.DeviceCommandLog
+	require.Eventually(t, func() bool {
+		return db.Where("device_code = ?", inDeviceCode).Order("id DESC").First(&entryCommand).Error == nil && entryCommand.Result == incidentService.CommandSuccess
+	}, 8*time.Second, 25*time.Millisecond)
+	require.Equal(t, record.ID, entryCommand.SessionID)
+
+	// 4. 车牌识别出场,验证支付状态、会话关闭、余位恢复和出口开闸流水关联。
+	// 统一通行入口按秒记录三秒防抖;等待四秒确保跨过时间边界。
+	time.Sleep(4 * time.Second)
+	mqttE2EPublish(t, deviceClient, topic(outChannel.ID, "camera/event"), false, map[string]interface{}{"schema": "camera.event.v1", "device_code": outDeviceCode, "plate_number": plate, "direction": "out", "ts": time.Now().Unix()})
+	mqttE2EWait(t, func() bool { return len(cameraEvents) >= 2 })
+	mqttE2EWait(t, func() bool { return db.First(&record, record.ID).Error == nil && record.ExitTime != nil })
+	require.Equal(t, "paid", record.PaymentStatus)
+	require.Equal(t, outChannel.ID, record.ExitChannelID)
+	require.Equal(t, outDeviceCode, record.ExitDeviceCode)
+	require.NoError(t, db.First(&ticket, ticket.ID).Error)
+	require.Equal(t, "exited", ticket.State)
+	require.Equal(t, 10, lotCapacity(t, db, lot.ID))
+	var exitCommand dao.DeviceCommandLog
+	require.NoError(t, db.Where("device_code = ?", outDeviceCode).Order("id DESC").First(&exitCommand).Error)
+	require.Equal(t, incidentService.CommandSuccess, exitCommand.Result)
+	require.Equal(t, record.ID, exitCommand.SessionID)
+}
+
+func lotCapacity(t *testing.T, db *gorm.DB, id uint) int {
+	t.Helper()
+	var lot dao.ParkingLot
+	require.NoError(t, db.First(&lot, id).Error)
+	return lot.Available
+}

+ 59 - 0
internal/service/mqttbroker/broker.go

@@ -0,0 +1,59 @@
+package mqttbroker
+
+import (
+	"time"
+
+	mqtt "github.com/mochi-mqtt/server/v2"
+	"github.com/mochi-mqtt/server/v2/hooks/auth"
+	"github.com/mochi-mqtt/server/v2/listeners"
+)
+
+// Broker 内嵌 MQTT broker,随应用进程启动,无需外部 mosquitto。
+// 局域网设备(相机/道闸)直连本 broker 的监听端口即可。
+type Broker struct {
+	server   *mqtt.Server
+	serveErr chan error
+}
+
+// Start 启动内嵌 broker 并监听 addr(形如 ":1883" 或 "127.0.0.1:1883")。
+// 短暂等待 Serve 以捕获端口被占用等立即失败。
+func Start(addr string) (*Broker, error) {
+	if addr == "" {
+		addr = ":1883"
+	}
+
+	server := mqtt.New(&mqtt.Options{})
+	// 局域网设备接入,允许匿名连接;如需鉴权再替换为 auth 鉴权 hook。
+	if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
+		return nil, err
+	}
+
+	tcp := listeners.NewTCP(listeners.Config{
+		ID:      "t1",
+		Address: addr,
+	})
+	if err := server.AddListener(tcp); err != nil {
+		return nil, err
+	}
+
+	b := &Broker{server: server, serveErr: make(chan error, 1)}
+	go func() {
+		b.serveErr <- server.Serve()
+	}()
+
+	// 短暂等待,捕获 Serve 的立即失败(如端口被占用);正常运行不会返回。
+	select {
+	case err := <-b.serveErr:
+		return nil, err
+	case <-time.After(300 * time.Millisecond):
+		return b, nil
+	}
+}
+
+// Close 停止内嵌 broker。
+func (b *Broker) Close() error {
+	if b == nil || b.server == nil {
+		return nil
+	}
+	return b.server.Close()
+}

+ 50 - 0
internal/service/mqttbroker/broker_test.go

@@ -0,0 +1,50 @@
+package mqttbroker
+
+import (
+	"testing"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+)
+
+// TestEmbedBrokerEcho 验证内嵌 broker 能启动,且 paho 客户端能连接、订阅、发布并收到回环消息。
+func TestEmbedBrokerEcho(t *testing.T) {
+	const (
+		listenAddr = ":18830"
+		brokerAddr = "tcp://127.0.0.1:18830"
+	)
+
+	broker, err := Start(listenAddr)
+	if err != nil {
+		t.Fatalf("启动内嵌 broker 失败: %v", err)
+	}
+	defer func() { _ = broker.Close() }()
+
+	opts := mqtt.NewClientOptions().AddBroker(brokerAddr).SetClientID("test-client")
+	client := mqtt.NewClient(opts)
+	if token := client.Connect(); token.Wait() && token.Error() != nil {
+		t.Fatalf("客户端连接失败: %v", token.Error())
+	}
+	defer client.Disconnect(100)
+
+	received := make(chan string, 1)
+	if token := client.Subscribe("parking/test/echo", 1, func(_ mqtt.Client, msg mqtt.Message) {
+		received <- string(msg.Payload())
+	}); token.Wait() && token.Error() != nil {
+		t.Fatalf("订阅失败: %v", token.Error())
+	}
+
+	if token := client.Publish("parking/test/echo", 1, false, "hello-broker"); token.Wait() && token.Error() != nil {
+		t.Fatalf("发布失败: %v", token.Error())
+	}
+
+	select {
+	case got := <-received:
+		if got != "hello-broker" {
+			t.Fatalf("回环内容不符: %s", got)
+		}
+		t.Logf("内嵌 broker 回环成功: %s", got)
+	case <-time.After(5 * time.Second):
+		t.Fatal("等待回环消息超时")
+	}
+}

+ 10 - 2
internal/service/owner/owner.go

@@ -3,6 +3,7 @@ package owner
 import (
 	"errors"
 	"fmt"
+	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/owner/request"
@@ -23,12 +24,16 @@ func (s *ownerService) CreateOwner(req request.OwnerCreate) error {
 		return errors.New("手机号已存在")
 	}
 
+	vipExpireTime := time.Time{}
+	if req.VipExpireTime != nil {
+		vipExpireTime = *req.VipExpireTime
+	}
 	owner := dao.Owner{
 		OwnerName:     req.OwnerName,
 		OwnerSurname:  req.OwnerSurname,
 		OwnerPhone:    req.OwnerPhone,
 		IsVip:         req.IsVip,
-		VipExpireTime: req.VipExpireTime,
+		VipExpireTime: vipExpireTime,
 	}
 
 	return global.GVA_DB.Create(&owner).Error
@@ -53,7 +58,10 @@ func (s *ownerService) UpdateOwner(req request.OwnerUpdate) error {
 	owner.OwnerSurname = req.OwnerSurname
 	owner.OwnerPhone = req.OwnerPhone
 	owner.IsVip = req.IsVip
-	owner.VipExpireTime = req.VipExpireTime
+	owner.VipExpireTime = time.Time{}
+	if req.VipExpireTime != nil {
+		owner.VipExpireTime = *req.VipExpireTime
+	}
 
 	return global.GVA_DB.Save(&owner).Error
 }

+ 90 - 0
internal/service/parking/camera.go

@@ -0,0 +1,90 @@
+package parking
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// 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"`
+}
+
+// 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 := camera.Status
+		if status == "" {
+			status = "offline"
+		}
+		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:      camera.Status == "online" || camera.Status == "connected",
+		})
+	}
+	return options, nil
+}
+
+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
+}

+ 167 - 0
internal/service/parking/camera_stream.go

@@ -0,0 +1,167 @@
+package parking
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/gofrs/uuid/v5"
+	"go.uber.org/zap"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+const cameraStreamTokenTTL = 2 * time.Minute
+
+type cameraStreamToken struct {
+	DeviceCode string
+	ExpiresAt  time.Time
+}
+
+var cameraStreamTokens sync.Map
+
+func isSystemMJPEG(protocol string) bool {
+	protocol = strings.ToLower(strings.TrimSpace(protocol))
+	return protocol == "system-mjpeg" || protocol == "rtsp" || protocol == "system"
+}
+
+func issueCameraStreamToken(deviceCode string) string {
+	for {
+		id, err := uuid.NewV4()
+		if err != nil {
+			continue
+		}
+		token := id.String()
+		cameraStreamTokens.Store(token, cameraStreamToken{
+			DeviceCode: deviceCode,
+			ExpiresAt:  time.Now().Add(cameraStreamTokenTTL),
+		})
+		return token
+	}
+}
+
+func validateCameraStreamToken(deviceCode, token string) bool {
+	value, ok := cameraStreamTokens.Load(token)
+	if !ok {
+		return false
+	}
+	entry := value.(cameraStreamToken)
+	if entry.DeviceCode != deviceCode || time.Now().After(entry.ExpiresAt) {
+		cameraStreamTokens.Delete(token)
+		return false
+	}
+	return true
+}
+
+// StreamCameraMJPEG 在系统端把摄像头原始 RTSP/HTTP 流转换为 multipart MJPEG。
+// 该输出可由 WebView 的 <img> 直接播放,不要求边缘端实现 WHEP。
+func (s *PassageService) StreamCameraMJPEG(deviceCode, token string, writer http.ResponseWriter, ctx context.Context) error {
+	if !validateCameraStreamToken(deviceCode, token) {
+		return errors.New("摄像头播放令牌无效或已过期")
+	}
+	if global.GVA_DB == nil {
+		return errors.New("数据库未初始化")
+	}
+	var camera dao.Camera
+	if err := global.GVA_DB.Where("device_code = ? AND is_active = ?", deviceCode, true).First(&camera).Error; err != nil {
+		return fmt.Errorf("摄像头不存在或未启用: %w", err)
+	}
+	if !isSystemMJPEG(camera.StreamProtocol) {
+		return errors.New("当前摄像头未启用系统侧转换")
+	}
+	sourceURL := strings.TrimSpace(camera.SourceURL)
+	if sourceURL == "" {
+		sourceURL = strings.TrimSpace(camera.StreamURL)
+	}
+	if sourceURL == "" {
+		return errors.New("摄像头未配置原始视频地址")
+	}
+
+	ffmpegPath := strings.TrimSpace(global.GVA_CONFIG.Camera.FFmpegPath)
+	if ffmpegPath == "" {
+		ffmpegPath = "ffmpeg"
+	} else if info, statErr := os.Stat(ffmpegPath); statErr == nil && info.IsDir() {
+		// Accept a configured FFmpeg bin directory for compatibility with older config files.
+		ffmpegPath = filepath.Join(ffmpegPath, "ffmpeg.exe")
+	}
+	fps := global.GVA_CONFIG.Camera.MJPEGFPS
+	if fps < 1 || fps > 25 {
+		fps = 8
+	}
+	quality := global.GVA_CONFIG.Camera.JPEGQuality
+	if quality < 2 || quality > 31 {
+		quality = 5
+	}
+	args := []string{"-hide_banner", "-loglevel", "error"}
+	if strings.HasPrefix(strings.ToLower(sourceURL), "rtsp://") {
+		args = append(args, "-rtsp_transport", "tcp")
+	}
+	args = append(args, "-i", sourceURL, "-an", "-vf", fmt.Sprintf("fps=%d", fps), "-q:v", fmt.Sprintf("%d", quality), "-f", "mpjpeg", "pipe:1")
+
+	cmd := exec.CommandContext(ctx, ffmpegPath, args...)
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("启动摄像头 FFmpeg 转码", zap.String("device_code", deviceCode), zap.String("ffmpeg", ffmpegPath), zap.String("source", redactCameraURL(sourceURL)))
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return fmt.Errorf("创建视频转换输出失败: %w", err)
+	}
+	cmd.Stderr = io.Discard
+	if err := cmd.Start(); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("摄像头 FFmpeg 启动失败", zap.String("device_code", deviceCode), zap.String("ffmpeg", ffmpegPath), zap.Error(err))
+		}
+		return fmt.Errorf("启动 FFmpeg 失败(%s): %w", ffmpegPath, err)
+	}
+
+	writer.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=ffmpeg")
+	writer.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
+	writer.Header().Set("X-Content-Type-Options", "nosniff")
+	if flusher, ok := writer.(http.Flusher); ok {
+		flusher.Flush()
+	}
+	_, copyErr := io.Copy(&flushWriter{writer: writer}, stdout)
+	waitErr := cmd.Wait()
+	if copyErr != nil && !errors.Is(copyErr, context.Canceled) {
+		return fmt.Errorf("视频流传输失败: %w", copyErr)
+	}
+	if ctx.Err() != nil {
+		return nil
+	}
+	if waitErr != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("摄像头 FFmpeg 转码进程退出", zap.String("device_code", deviceCode), zap.Error(waitErr))
+		}
+		return fmt.Errorf("FFmpeg 进程退出: %w", waitErr)
+	}
+	return nil
+}
+
+func redactCameraURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || u.User == nil {
+		return raw
+	}
+	return strings.Replace(raw, u.User.String()+"@", "***@", 1)
+}
+
+type flushWriter struct {
+	writer http.ResponseWriter
+}
+
+func (w *flushWriter) Write(data []byte) (int, error) {
+	n, err := w.writer.Write(data)
+	if flusher, ok := w.writer.(http.Flusher); ok {
+		flusher.Flush()
+	}
+	return n, err
+}

+ 24 - 0
internal/service/parking/gate.go

@@ -169,6 +169,15 @@ func (s *PassageService) runGateCommand(ctx GateCommandContext, action, deviceCo
 		ctx.Source = "passage"
 	}
 	start := time.Now()
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("道闸指令开始执行",
+			zap.String("action", action),
+			zap.String("device_code", deviceCode),
+			zap.String("source", ctx.Source),
+			zap.Uint("session_id", ctx.SessionID),
+			zap.Uint8("valid_time_seconds", validTime),
+		)
+	}
 	var err error
 	if action == "open" {
 		err = s.OpenGateByDeviceCode(deviceCode, validTime)
@@ -187,8 +196,23 @@ func (s *PassageService) runGateCommand(ctx GateCommandContext, action, deviceCo
 	if err != nil {
 		rec.Result = incidentService.CommandFailed
 		rec.ErrorMessage = err.Error()
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("道闸指令执行失败",
+				zap.String("action", action),
+				zap.String("device_code", deviceCode),
+				zap.Int64("duration_ms", rec.DurationMs),
+				zap.Error(err),
+			)
+		}
 	} else {
 		rec.Result = incidentService.CommandSuccess
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Info("道闸指令执行成功",
+				zap.String("action", action),
+				zap.String("device_code", deviceCode),
+				zap.Int64("duration_ms", rec.DurationMs),
+			)
+		}
 	}
 	// 设备名称快照(查询失败不影响指令结果)
 	var device dao.UHFReader

+ 315 - 0
internal/service/parking/mqtt_gate_controller.go

@@ -0,0 +1,315 @@
+package parking
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"github.com/gofrs/uuid/v5"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	incidentService "wails-app/internal/modules/incident/service"
+)
+
+const (
+	mqttQoS        = 1
+	mqttCmdTimeout = 5 * time.Second
+)
+
+// gateTopicRoute 设备在 MQTT 主题树中的路由坐标。
+type gateTopicRoute struct {
+	DeviceCode string
+	LotID      uint
+	BoothID    uint
+	ChannelID  uint
+}
+
+func (r gateTopicRoute) cmdTopic() string {
+	return fmt.Sprintf("parking/lot/%d/booth/%d/channel/%d/gate/cmd", r.LotID, r.BoothID, r.ChannelID)
+}
+
+// MQTTGateController 通过 MQTT 下发道闸指令并等待设备 ack,实现 GateController 接口。
+// 用于「独立网络道闸控制器」;挂在 UHF 读写器继电器上的道闸仍走 DeviceManager。
+type MQTTGateController struct {
+	client mqtt.Client
+
+	mu              sync.RWMutex
+	routes          map[string]gateTopicRoute // device_code -> route
+	connectionState map[string]bool           // device_code -> MQTT 连接状态
+	gateState       map[string]string         // device_code -> open/closed/moving/fault
+	pending         map[string]pendingGateCommand
+}
+
+type pendingGateCommand struct {
+	deviceCode string
+	ackCh      chan gateAck
+}
+
+type gateCmd struct {
+	Schema     string `json:"schema"`
+	CmdID      string `json:"cmd_id"`
+	DeviceCode string `json:"device_code"`
+	Action     string `json:"action"`
+	ValidTime  byte   `json:"valid_time"`
+}
+
+type gateAck struct {
+	Schema     string `json:"schema"`
+	CmdID      string `json:"cmd_id"`
+	DeviceCode string `json:"device_code"`
+	Result     string `json:"result"` // success / failed
+	Error      string `json:"error"`
+}
+
+func NewMQTTGateController(client mqtt.Client) *MQTTGateController {
+	return &MQTTGateController{
+		client:          client,
+		routes:          make(map[string]gateTopicRoute),
+		connectionState: make(map[string]bool),
+		gateState:       make(map[string]string),
+		pending:         make(map[string]pendingGateCommand),
+	}
+}
+
+// SetClient 在 MQTT 客户端完成选项配置并创建后注入控制器。
+// 初始化阶段尚未开始订阅或下发指令,因此这里不需要额外同步。
+func (c *MQTTGateController) SetClient(client mqtt.Client) {
+	c.mu.Lock()
+	c.client = client
+	c.mu.Unlock()
+}
+
+// LoadRoutes 从 DB 反查 device_code -> lot/booth/channel,用于构造主题。
+func (c *MQTTGateController) LoadRoutes() error {
+	if global.GVA_DB == nil {
+		return errors.New("数据库未初始化")
+	}
+	type row struct {
+		DeviceCode   string
+		ChannelID    uint
+		BoothID      uint
+		ParkingLotID uint
+	}
+	var rows []row
+	err := global.GVA_DB.Model(&dao.UHFReader{}).
+		Select("uhf_reader.device_code, uhf_reader.channel_id, channel.booth_id, channel.parking_lot_id").
+		Joins("LEFT JOIN channel ON channel.id = uhf_reader.channel_id").
+		Where("uhf_reader.channel_id > 0 AND channel.booth_id > 0 AND channel.parking_lot_id > 0 AND uhf_reader.connect_type = ?", dao.ConnectTypeMQTT).
+		Scan(&rows).Error
+	if err != nil {
+		return err
+	}
+	c.mu.Lock()
+	// 配置变更后重新加载,不能保留已删除设备的旧路由。
+	c.routes = make(map[string]gateTopicRoute)
+	defer c.mu.Unlock()
+	for _, r := range rows {
+		if r.DeviceCode == "" || r.ChannelID == 0 || r.BoothID == 0 || r.ParkingLotID == 0 {
+			continue
+		}
+		c.routes[r.DeviceCode] = gateTopicRoute{
+			DeviceCode: r.DeviceCode, ChannelID: r.ChannelID,
+			BoothID: r.BoothID, LotID: r.ParkingLotID,
+		}
+	}
+	return nil
+}
+
+// HasRoute 判断设备是否配置了 MQTT 路由(供路由控制器决定走哪个后端)。
+func (c *MQTTGateController) HasRoute(deviceCode string) bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	_, ok := c.routes[deviceCode]
+	return ok
+}
+
+func (c *MQTTGateController) routeOf(deviceCode string) (gateTopicRoute, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	r, ok := c.routes[deviceCode]
+	return r, ok
+}
+
+// OpenGate 实现 GateController 接口。
+func (c *MQTTGateController) OpenGate(deviceCode string, validTime byte) error {
+	return c.run(deviceCode, "open", validTime)
+}
+
+// CloseGate 实现 GateController 接口。
+func (c *MQTTGateController) CloseGate(deviceCode string, validTime byte) error {
+	return c.run(deviceCode, "close", validTime)
+}
+
+func (c *MQTTGateController) run(deviceCode, action string, validTime byte) error {
+	route, ok := c.routeOf(deviceCode)
+	if !ok {
+		return fmt.Errorf("设备未配置 MQTT 路由: %s", deviceCode)
+	}
+	cmdID := uuid.Must(uuid.NewV4()).String()
+	ackCh := make(chan gateAck, 1)
+	c.mu.Lock()
+	c.pending[cmdID] = pendingGateCommand{deviceCode: deviceCode, ackCh: ackCh}
+	c.mu.Unlock()
+	defer func() {
+		c.mu.Lock()
+		delete(c.pending, cmdID)
+		c.mu.Unlock()
+	}()
+
+	payload, _ := json.Marshal(gateCmd{
+		Schema: "gate.cmd.v1", CmdID: cmdID, DeviceCode: deviceCode,
+		Action: action, ValidTime: validTime,
+	})
+	token := c.client.Publish(route.cmdTopic(), mqttQoS, false, payload)
+	if token.Wait() && token.Error() != nil {
+		return fmt.Errorf("MQTT 指令下发失败: %w", token.Error())
+	}
+
+	select {
+	case ack := <-ackCh:
+		if ack.Result != "success" {
+			return fmt.Errorf("道闸执行失败: %s", ack.Error)
+		}
+		return nil
+	case <-time.After(mqttCmdTimeout):
+		return errors.New("道闸指令超时未收到 ack")
+	}
+}
+
+// IsGateConnected 实现 GateController 接口(以最近 state/lwt 为准)。
+func (c *MQTTGateController) IsGateConnected(deviceCode string) bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.connectionState[deviceCode]
+}
+
+// Subscribe 订阅 state / ack / lwt 主题。
+func (c *MQTTGateController) Subscribe() error {
+	for _, topic := range []string{
+		"parking/lot/+/booth/+/channel/+/gate/state",
+		"parking/lot/+/booth/+/channel/+/gate/cmd/ack",
+		"parking/lot/+/booth/+/channel/+/gate/lwt",
+	} {
+		token := c.client.Subscribe(topic, mqttQoS, c.onMessage)
+		if token.Wait() && token.Error() != nil {
+			return token.Error()
+		}
+	}
+	return nil
+}
+
+// MarkConfiguredDevicesOffline 在服务启动或 MQTT 总线重连期间清除旧进程留下的在线状态。
+// 后续收到设备的 retained state 或实时状态上报后,会立即恢复为在线。
+// 仅处理启用的 MQTT 设备,不能影响 TCP、串口设备自身的连接状态。
+func (c *MQTTGateController) MarkConfiguredDevicesOffline() error {
+	if global.GVA_DB == nil {
+		return errors.New("数据库未初始化")
+	}
+	return global.GVA_DB.Model(&dao.UHFReader{}).
+		Where("connect_type = ? AND is_active = ?", dao.ConnectTypeMQTT, true).
+		Where("status <> ?", "offline").
+		Update("status", "offline").Error
+}
+
+// onMessage 分派 state / ack / lwt。
+func (c *MQTTGateController) onMessage(_ mqtt.Client, msg mqtt.Message) {
+	payload := msg.Payload()
+	var probe struct {
+		Schema     string `json:"schema"`
+		CmdID      string `json:"cmd_id"`
+		DeviceCode string `json:"device_code"`
+		State      string `json:"state"`
+	}
+	if err := json.Unmarshal(payload, &probe); err != nil {
+		return
+	}
+
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("收到道闸 MQTT 消息",
+			zap.String("topic", msg.Topic()),
+			zap.String("schema", probe.Schema),
+			zap.String("device_code", probe.DeviceCode),
+			zap.String("state", probe.State))
+	}
+
+	switch probe.Schema {
+	case "gate.ack.v1":
+		var ack gateAck
+		if json.Unmarshal(payload, &ack) == nil && ack.CmdID != "" && ack.DeviceCode != "" {
+			c.mu.RLock()
+			pending, ok := c.pending[ack.CmdID]
+			c.mu.RUnlock()
+			if ok && pending.deviceCode == ack.DeviceCode && ack.Schema == "gate.ack.v1" {
+				select {
+				case pending.ackCh <- ack:
+				default:
+				}
+			}
+		}
+	case "gate.state.v1":
+		if probe.DeviceCode == "" || probe.State == "" {
+			return
+		}
+		c.mu.Lock()
+		c.connectionState[probe.DeviceCode] = true
+		c.gateState[probe.DeviceCode] = probe.State
+		c.mu.Unlock()
+		// 回写设备在线状态并关闭历史离线异常。
+		// 设备编码必须与设备管理中的 device_code 一致,否则只能更新内存状态。
+		c.persistDeviceStatus(probe.DeviceCode, "online")
+		if global.GVA_DB != nil {
+			incidentSvc.ResolveDeviceOffline(probe.DeviceCode)
+		}
+	case "gate.lwt.v1", "gate.lwt":
+		c.mu.Lock()
+		c.connectionState[probe.DeviceCode] = false
+		c.mu.Unlock()
+		// 回写设备离线状态并埋点离线异常(复用既有去重)
+		c.persistDeviceStatus(probe.DeviceCode, "offline")
+		if global.GVA_DB != nil {
+			incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+				Category:    incidentService.CategoryDeviceOffline,
+				Source:      incidentService.SourceDevice,
+				DeviceCode:  probe.DeviceCode,
+				Description: "道闸 MQTT 遗嘱触发,设备离线",
+			})
+		}
+	}
+}
+
+// persistDeviceStatus 将 MQTT 设备状态写回设备表,并显式暴露编码不匹配问题。
+// MQTT 订阅本身是按主题成功的,即使设备尚未在设备管理中配置,消息仍会进入这里;
+// 此时 GORM 不会报错,只会返回 RowsAffected=0,不能继续静默处理。
+func (c *MQTTGateController) persistDeviceStatus(deviceCode, status string) {
+	if global.GVA_DB == nil || deviceCode == "" {
+		return
+	}
+	updates := map[string]interface{}{"status": status}
+	if status == "online" {
+		updates["provision_status"] = gorm.Expr("CASE WHEN connect_type = ? AND provision_status IN (?, ?, ?, ?) THEN ? ELSE provision_status END", dao.ConnectTypeMQTT, "provisioned", "online", "offline", "failed", "online")
+	} else if status == "offline" {
+		updates["provision_status"] = gorm.Expr("CASE WHEN connect_type = ? AND provision_status IN (?, ?) THEN ? ELSE provision_status END", dao.ConnectTypeMQTT, "provisioned", "online", "offline")
+	}
+	if status == "online" {
+		updates["last_online_time"] = time.Now()
+	}
+	result := global.GVA_DB.Model(&dao.UHFReader{}).
+		Where("device_code = ?", deviceCode).
+		Updates(updates)
+	if result.Error != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("回写 MQTT 设备状态失败",
+				zap.String("device_code", deviceCode), zap.String("status", status), zap.Error(result.Error))
+		}
+		return
+	}
+	if result.RowsAffected == 0 && global.GVA_LOG != nil {
+		global.GVA_LOG.Warn("收到 MQTT 设备状态,但设备编码未配置或不匹配",
+			zap.String("device_code", deviceCode), zap.String("status", status))
+	}
+}

+ 119 - 0
internal/service/parking/mqtt_gate_controller_test.go

@@ -0,0 +1,119 @@
+package parking
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type testMQTTMessage struct {
+	payload []byte
+}
+
+func (m testMQTTMessage) Duplicate() bool   { return false }
+func (m testMQTTMessage) Qos() byte         { return 1 }
+func (m testMQTTMessage) Retained() bool    { return false }
+func (m testMQTTMessage) Topic() string     { return "parking/test/gate/state" }
+func (m testMQTTMessage) MessageID() uint16 { return 1 }
+func (m testMQTTMessage) Payload() []byte   { return m.payload }
+func (m testMQTTMessage) Ack()              {}
+
+var _ mqtt.Message = testMQTTMessage{}
+
+func TestMQTTGateControllerClosedStateRemainsConnected(t *testing.T) {
+	controller := NewMQTTGateController(nil)
+	controller.onMessage(nil, testMQTTMessage{payload: []byte(`{"schema":"gate.state.v1","device_code":"gate-1","state":"closed"}`)})
+
+	if !controller.IsGateConnected("gate-1") {
+		t.Fatal("closed 状态应表示设备在线")
+	}
+	controller.mu.RLock()
+	state := controller.gateState["gate-1"]
+	controller.mu.RUnlock()
+	if state != "closed" {
+		t.Fatalf("闸杆状态未保存,得到 %q", state)
+	}
+}
+
+func TestMQTTGateControllerRejectsAckFromOtherDevice(t *testing.T) {
+	controller := NewMQTTGateController(nil)
+	ackCh := make(chan gateAck, 1)
+	controller.pending["cmd-1"] = pendingGateCommand{deviceCode: "gate-1", ackCh: ackCh}
+
+	controller.onMessage(nil, testMQTTMessage{payload: []byte(`{"schema":"gate.ack.v1","cmd_id":"cmd-1","device_code":"gate-2","result":"success"}`)})
+	select {
+	case <-ackCh:
+		t.Fatal("其他设备的 ACK 不应完成当前命令")
+	default:
+	}
+}
+
+func setupMQTTGateStatusTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:mqtt-gate-status-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	global.GVA_LOG = zap.NewNop()
+	t.Cleanup(func() { global.GVA_LOG = nil })
+	require.NoError(t, db.AutoMigrate(&dao.UHFReader{}))
+}
+
+func TestMQTTGateControllerPersistsConfiguredDeviceStatus(t *testing.T) {
+	setupMQTTGateStatusTest(t)
+	require.NoError(t, global.GVA_DB.Create(&dao.UHFReader{
+		DeviceCode: "GATE-01", DeviceName: "东门道闸", DeviceType: "gate",
+		ConnectType: dao.ConnectTypeMQTT, ProvisionStatus: "provisioned",
+	}).Error)
+
+	controller := NewMQTTGateController(nil)
+	controller.onMessage(nil, testMQTTMessage{payload: []byte(`{"schema":"gate.state.v1","device_code":"GATE-01","state":"open"}`)})
+
+	var reader dao.UHFReader
+	require.NoError(t, global.GVA_DB.Where("device_code = ?", "GATE-01").First(&reader).Error)
+	require.Equal(t, "online", reader.Status)
+	require.Equal(t, "online", reader.ProvisionStatus)
+	require.NotNil(t, reader.LastOnlineTime)
+}
+
+func TestMQTTGateControllerMarksOnlyActiveMQTTDevicesOffline(t *testing.T) {
+	setupMQTTGateStatusTest(t)
+	readers := []dao.UHFReader{
+		{DeviceCode: "MQTT-ONLINE", DeviceName: "MQTT 在线设备", DeviceType: "gate", ConnectType: dao.ConnectTypeMQTT, IsActive: true, Status: "online"},
+		{DeviceCode: "MQTT-DISABLED", DeviceName: "MQTT 停用设备", DeviceType: "gate", ConnectType: dao.ConnectTypeMQTT, IsActive: false, Status: "online"},
+		{DeviceCode: "TCP-ONLINE", DeviceName: "TCP 在线设备", DeviceType: "reader", ConnectType: dao.ConnectTypeTCP, IsActive: true, Status: "online"},
+	}
+	require.NoError(t, global.GVA_DB.Create(&readers).Error)
+	require.NoError(t, global.GVA_DB.Model(&dao.UHFReader{}).
+		Where("device_code = ?", "MQTT-DISABLED").
+		Update("is_active", false).Error)
+
+	controller := NewMQTTGateController(nil)
+	require.NoError(t, controller.MarkConfiguredDevicesOffline())
+
+	statuses := map[string]string{}
+	var after []dao.UHFReader
+	require.NoError(t, global.GVA_DB.Find(&after).Error)
+	for _, reader := range after {
+		statuses[reader.DeviceCode] = reader.Status
+	}
+	require.Equal(t, "offline", statuses["MQTT-ONLINE"])
+	require.Equal(t, "online", statuses["MQTT-DISABLED"])
+	require.Equal(t, "online", statuses["TCP-ONLINE"])
+}

+ 4 - 2
internal/service/parking/passage.go

@@ -264,6 +264,7 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 		ExitChannelName: channel.ChannelName,
 		ExitDeviceCode:  req.DeviceCode,
 		ExitDeviceName:  device.DeviceName,
+		TriggerSource:   req.TriggerSource,
 	}
 	resp, err := vehicleSvc.ExitConfirm(confirmReq, req.OperatorID)
 	if err != nil {
@@ -294,6 +295,7 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	result := &common.PassageResult{
 		Success:           true,
 		Message:           "出场成功",
+		SessionID:         resp.SessionID,
 		Direction:         "out",
 		PlateNumber:       resp.PlateNumber,
 		RFIDTag:           req.RFIDTag,
@@ -309,7 +311,7 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	fmt.Printf("🔴 出口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
 	commandLogID, err := s.OpenGateWithContext(GateCommandContext{
 		Source:    incidentService.SourcePassage,
-		SessionID: req.SessionID,
+		SessionID: resp.SessionID,
 	}, req.DeviceCode, defaultGateValidTime)
 	if err != nil {
 		result.Success = false
@@ -318,7 +320,7 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
 			Category:        incidentService.CategoryGateFailed,
 			Source:          incidentService.SourcePassage,
-			VehicleRecordID: req.SessionID,
+			VehicleRecordID: resp.SessionID,
 			TicketNo:        req.TicketNo,
 			PlateNumber:     resp.PlateNumber,
 			RFIDTag:         req.RFIDTag,

+ 34 - 0
internal/service/parking/routing_gate_controller.go

@@ -0,0 +1,34 @@
+package parking
+
+// RoutingGateController 按设备编码在多个道闸后端之间路由。
+// fallback 为默认后端(UHF 继电器 DeviceManager 或 SimulatedGateController),
+// mqtt 为可选的独立网络道闸后端;仅当 mqtt 存在且持有该设备路由时才走 MQTT。
+type RoutingGateController struct {
+	fallback GateController
+	mqtt     *MQTTGateController
+}
+
+func NewRoutingGateController(fallback GateController, mqtt *MQTTGateController) *RoutingGateController {
+	return &RoutingGateController{fallback: fallback, mqtt: mqtt}
+}
+
+func (r *RoutingGateController) OpenGate(deviceCode string, validTime byte) error {
+	if r.mqtt != nil && r.mqtt.HasRoute(deviceCode) {
+		return r.mqtt.OpenGate(deviceCode, validTime)
+	}
+	return r.fallback.OpenGate(deviceCode, validTime)
+}
+
+func (r *RoutingGateController) CloseGate(deviceCode string, validTime byte) error {
+	if r.mqtt != nil && r.mqtt.HasRoute(deviceCode) {
+		return r.mqtt.CloseGate(deviceCode, validTime)
+	}
+	return r.fallback.CloseGate(deviceCode, validTime)
+}
+
+func (r *RoutingGateController) IsGateConnected(deviceCode string) bool {
+	if r.mqtt != nil && r.mqtt.HasRoute(deviceCode) {
+		return r.mqtt.IsGateConnected(deviceCode)
+	}
+	return r.fallback.IsGateConnected(deviceCode)
+}

+ 214 - 35
internal/service/uhf/reader.go

@@ -14,6 +14,8 @@ import (
 	incidentService "wails-app/internal/modules/incident/service"
 	"wails-app/internal/service"
 	"wails-app/internal/service/parking"
+
+	"go.uber.org/zap"
 )
 
 // Reader 通用读写接口(串口和TCP都实现它)
@@ -115,6 +117,9 @@ type DeviceHandler struct {
 	ctx       context.Context
 	cancel    context.CancelFunc
 	dataChan  chan *ReportData
+	ioMu      sync.Mutex
+	stopOnce  sync.Once
+	done      chan struct{}
 	isRunning bool
 }
 
@@ -140,13 +145,43 @@ func (m *Manager) Get(code string) (*DeviceHandler, bool) {
 	return h, ok
 }
 
+// StopDeviceHandler 停止设备读循环、关闭底层连接并等待退出。
+func StopDeviceHandler(code string) error {
+	h, ok := DeviceManager.Get(code)
+	if !ok {
+		return nil
+	}
+	h.stopOnce.Do(func() {
+		if h.cancel != nil {
+			h.cancel()
+		}
+		if h.reader != nil {
+			_ = h.reader.Disconnect()
+		}
+	})
+	if h.done == nil {
+		DeviceManager.Unregister(code)
+		return nil
+	}
+	select {
+	case <-h.done:
+	case <-time.After(5 * time.Second):
+		return fmt.Errorf("设备停止超时: %s", code)
+	}
+	return nil
+}
+
 // OpenGate 开闸(公开方法,供外部触发源调用,如摄像头识别、手动进出场)
 func (h *DeviceHandler) OpenGate(validTime byte) error {
+	h.ioMu.Lock()
+	defer h.ioMu.Unlock()
 	return h.reader.CloseRelay1(validTime)
 }
 
 // CloseGate 关闸。
 func (h *DeviceHandler) CloseGate(validTime byte) error {
+	h.ioMu.Lock()
+	defer h.ioMu.Unlock()
 	return h.reader.CloseRelay2(validTime)
 }
 
@@ -170,8 +205,8 @@ func (m *Manager) CloseGate(deviceCode string, validTime byte) error {
 
 // IsGateConnected 判断设备是否已注册到当前进程。
 func (m *Manager) IsGateConnected(deviceCode string) bool {
-	_, ok := m.Get(deviceCode)
-	return ok
+	h, ok := m.Get(deviceCode)
+	return ok && h.reader != nil && h.reader.IsConnected()
 }
 
 // OpenGateByDeviceCode 通过设备编码直接开闸(便捷方法)
@@ -204,6 +239,31 @@ func uiCrc16Cal(pucY []byte, ucX uint8) uint16 {
 
 // StartDeviceHandler 启动单个设备的读写协程
 func StartDeviceHandler(device *dao.UHFReader) error {
+	if device == nil || device.DeviceCode == "" {
+		return errors.New("设备编码不能为空")
+	}
+	if existing, ok := DeviceManager.Get(device.DeviceCode); ok {
+		if existing.reader != nil && existing.reader.IsConnected() {
+			return fmt.Errorf("设备已连接: %s", device.DeviceCode)
+		}
+		if err := StopDeviceHandler(device.DeviceCode); err != nil {
+			return err
+		}
+	}
+	// MQTT 设备由设备主动连接 broker,系统不主动建立连接。
+	if device.ConnectType == dao.ConnectTypeMQTT {
+		return nil
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("启动 UHF/RFID 设备连接",
+			zap.String("device_code", device.DeviceCode),
+			zap.String("device_name", device.DeviceName),
+			zap.String("connect_type", device.ConnectType),
+			zap.String("tcp_address", readerTCPAddress(device)),
+			zap.Uint("channel_id", device.ChannelID),
+		)
+	}
+
 	// 1. 根据连接类型创建读写器
 	var r Reader
 	switch device.ConnectType {
@@ -217,8 +277,23 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 
 	// 2. 连接设备
 	if err := r.Connect(); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("UHF/RFID 设备连接失败",
+				zap.String("device_code", device.DeviceCode),
+				zap.String("connect_type", device.ConnectType),
+				zap.String("tcp_address", readerTCPAddress(device)),
+				zap.Error(err),
+			)
+		}
 		return err
 	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("UHF/RFID 设备连接成功",
+			zap.String("device_code", device.DeviceCode),
+			zap.String("connect_type", device.ConnectType),
+			zap.String("tcp_address", readerTCPAddress(device)),
+		)
+	}
 
 	// 3. 创建上下文和handler
 	ctx, cancel := context.WithCancel(context.Background())
@@ -227,7 +302,8 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 		device:    device,
 		ctx:       ctx,
 		cancel:    cancel,
-		dataChan:  make(chan *ReportData, 200), // 适当加大
+		dataChan:  make(chan *ReportData, 200),
+		done:      make(chan struct{}),
 		isRunning: true,
 	}
 
@@ -237,56 +313,116 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 	// 5. 启动读写协程
 	go func() {
 		defer func() {
+			h.stopOnce.Do(func() { h.cancel() })
 			h.isRunning = false
 			_ = r.Disconnect()
 			close(h.dataChan)
 			DeviceManager.Unregister(device.DeviceCode)
-			global.GVA_LOG.Info("设备已停止 code:" + device.DeviceCode)
+			close(h.done)
+			if global.GVA_LOG != nil {
+				global.GVA_LOG.Info("设备已停止: " + device.DeviceCode)
+			}
 		}()
 
-			for {
-				select {
-				case <-ctx.Done():
-					return
-				default:
-					buf, err := r.ReadData()
-					if err != nil {
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			default:
+				h.ioMu.Lock()
+				buf, err := r.ReadData()
+				h.ioMu.Unlock()
+				if errors.Is(err, ErrReadTimeout) || errors.Is(err, ErrIncompleteFrame) {
+					continue
+				}
+				if err != nil {
+					if global.GVA_LOG != nil {
+						global.GVA_LOG.Warn("UHF/RFID 读取失败,准备重连",
+							zap.String("device_code", device.DeviceCode),
+							zap.String("connect_type", device.ConnectType),
+							zap.String("tcp_address", readerTCPAddress(device)),
+							zap.Error(err),
+						)
+					}
+					if global.GVA_DB != nil {
 						global.GVA_DB.Model(device).Update("status", "offline")
-						// 离线异常埋点(best-effort,同设备未关闭不重复生成)
-						incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
-							Category:    incidentService.CategoryDeviceOffline,
-							Source:      incidentService.SourceDevice,
-							ParkingLotID: device.ParkingLotID,
-							DeviceCode:  device.DeviceCode,
-							Description: "UHF 读卡器离线",
-							Detail:      err.Error(),
-						})
-						_ = r.Disconnect() // 先断开
-						time.Sleep(2 * time.Second)
-						if err := r.Connect(); err != nil {
-							continue
+					}
+					// 离线异常埋点(best-effort,同设备未关闭不重复生成)
+					incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+						Category:     incidentService.CategoryDeviceOffline,
+						Source:       incidentService.SourceDevice,
+						ParkingLotID: device.ParkingLotID,
+						DeviceCode:   device.DeviceCode,
+						Description:  "UHF 读卡器离线",
+						Detail:       err.Error(),
+					})
+					_ = r.Disconnect() // 先断开
+					if !waitDeviceRetry(ctx, 2*time.Second) {
+						return
+					}
+					if err := r.Connect(); err != nil {
+						if global.GVA_LOG != nil {
+							global.GVA_LOG.Warn("UHF/RFID 设备重连失败",
+								zap.String("device_code", device.DeviceCode),
+								zap.String("tcp_address", readerTCPAddress(device)),
+								zap.Error(err),
+							)
 						}
-						global.GVA_DB.Model(device).Update("status", "online")
-						// 设备恢复在线:自动关闭未处理的离线异常
-						incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 						continue
 					}
+					if global.GVA_LOG != nil {
+						global.GVA_LOG.Info("UHF/RFID 设备重连成功",
+							zap.String("device_code", device.DeviceCode),
+							zap.String("tcp_address", readerTCPAddress(device)),
+						)
+					}
+					if global.GVA_DB != nil {
+						global.GVA_DB.Model(device).Updates(map[string]interface{}{"status": "online", "last_online_time": time.Now()})
+					}
+					// 设备恢复在线:自动关闭未处理的离线异常
+					incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
+					continue
+				}
 
 				if len(buf) == 0 {
 					continue
 				}
+				if global.GVA_LOG != nil {
+					global.GVA_LOG.Info("收到 UHF/RFID 原始帧",
+						zap.String("device_code", device.DeviceCode),
+						zap.Int("packet_bytes", len(buf)),
+						zap.String("packet_hex", hex.EncodeToString(buf)),
+					)
+				}
 
 				// 解析上报数据
 				report, err := parseReportData(device.DeviceCode, buf)
 				if err != nil {
+					if global.GVA_LOG != nil {
+						global.GVA_LOG.Warn("UHF/RFID 报文解析失败",
+							zap.String("device_code", device.DeviceCode),
+							zap.String("packet_hex", hex.EncodeToString(buf)),
+							zap.Error(err),
+						)
+					}
 					continue
 				}
+				if global.GVA_LOG != nil {
+					global.GVA_LOG.Info("UHF/RFID 标签解析成功",
+						zap.String("device_code", report.DeviceCode),
+						zap.Strings("epcs", report.Epcs),
+						zap.Int("rssi", report.RSSI),
+						zap.Int("antenna", report.Antenna),
+					)
+				}
 
 				// 推送(不丢死,也不阻塞)
 				select {
 				case h.dataChan <- report:
 				default:
-					global.GVA_LOG.Warn("通道已满,丢弃数据 device" + device.DeviceCode)
+					if global.GVA_LOG != nil {
+						global.GVA_LOG.Warn("通道已满,丢弃数据: " + device.DeviceCode)
+					}
 				}
 			}
 		}
@@ -307,13 +443,27 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 		}
 	}()
 
-	global.GVA_DB.Model(device).Update("status", "online")
+	if global.GVA_DB != nil {
+		global.GVA_DB.Model(device).Updates(map[string]interface{}{"status": "online", "last_online_time": time.Now()})
+	}
 	// 启动时兜底:关闭历史遗留的同设备离线异常
 	incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 	return nil
 }
 
-// parseReportData 解析上报数据
+// waitDeviceRetry 等待重连间隔,同时允许停止设备时立即退出。
+func waitDeviceRetry(ctx context.Context, delay time.Duration) bool {
+	timer := time.NewTimer(delay)
+	defer timer.Stop()
+	select {
+	case <-ctx.Done():
+		return false
+	case <-timer.C:
+		return true
+	}
+}
+
+// parseReportData 解析上报数据。
 func parseReportData(deviceCode string, buf []byte) (*ReportData, error) {
 	if len(buf) < 25 || buf[0] != 0xCF {
 		return nil, errors.New("无效帧")
@@ -330,8 +480,6 @@ func parseReportData(deviceCode string, buf []byte) (*ReportData, error) {
 	antenna := int(buf[22])
 	epc := hex.EncodeToString(buf[11:23])
 
-	fmt.Printf("%s", hex.EncodeToString(buf))
-
 	return &ReportData{
 		DeviceCode: deviceCode,
 		Hex:        hex.EncodeToString(buf),
@@ -349,7 +497,14 @@ func handleReportData(report *ReportData) {
 	}
 
 	epc := report.Epcs[0]
-	fmt.Println("<UNK>", epc)
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("开始处理 RFID 通行",
+			zap.String("device_code", report.DeviceCode),
+			zap.String("epc", epc),
+			zap.Int("rssi", report.RSSI),
+			zap.Int("antenna", report.Antenna),
+		)
+	}
 
 	// 委托给统一的进出场服务(所有触发方式共用同一入口)
 	result, err := service.ServiceGroupApp.ParkingServiceGroup.PassageService.HandlePassage(common.PassageRequest{
@@ -358,7 +513,13 @@ func handleReportData(report *ReportData) {
 		TriggerSource: "rfid",
 	})
 	if err != nil {
-		fmt.Printf("⚠️ 进出场处理失败 | 设备:%s EPC:%s 错误:%v\n", report.DeviceCode, epc, err)
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("RFID 通行处理失败",
+				zap.String("device_code", report.DeviceCode),
+				zap.String("epc", epc),
+				zap.Error(err),
+			)
+		}
 		if result != nil {
 			PushChannelEvent(ChannelEvent{
 				DeviceCode: report.DeviceCode, RFIDTag: epc, PlateNumber: result.PlateNumber,
@@ -369,7 +530,18 @@ func handleReportData(report *ReportData) {
 		return
 	}
 
-	fmt.Printf("✅ 进出场成功 | 方向:%s 车牌:%s 费用:%.2f\n", result.Direction, result.PlateNumber, result.Fee)
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("RFID 通行处理完成",
+			zap.String("device_code", report.DeviceCode),
+			zap.String("epc", epc),
+			zap.String("direction", result.Direction),
+			zap.String("plate_number", result.PlateNumber),
+			zap.Uint("session_id", result.SessionID),
+			zap.Float64("fee", result.Fee),
+			zap.Bool("gate_opened", result.GateOpened),
+			zap.String("gate_status", result.GateStatus),
+		)
+	}
 
 	// 推送通道事件给前端
 	var device dao.UHFReader
@@ -389,6 +561,13 @@ func handleReportData(report *ReportData) {
 	}
 }
 
+func readerTCPAddress(device *dao.UHFReader) string {
+	if device.ConnectType != dao.ConnectTypeTCP {
+		return ""
+	}
+	return fmt.Sprintf("%s:%d", device.IPAddress, device.Port)
+}
+
 // ==============================
 // 继电器控制(完整支持 Relay1 & Relay2)
 // ==============================

+ 8 - 7
internal/service/uhf/reader_interface.go

@@ -5,6 +5,12 @@ import (
 	"wails-app/internal/dao"
 )
 
+// ErrReadTimeout 表示设备在等待下一帧期间没有收到数据,不代表连接断开。
+var ErrReadTimeout = errors.New("设备读取超时")
+
+// ErrIncompleteFrame 表示收到的数据尚未组成完整协议帧,应继续等待后续数据。
+var ErrIncompleteFrame = errors.New("设备帧不完整")
+
 // UHFReaderConn 统一的设备连接接口(适配多设备+消息收发)
 type UHFReaderConn interface {
 	Connect() error                          // 建立连接
@@ -37,17 +43,12 @@ func NewReaderConn(device *dao.UHFReader) (UHFReaderConn, error) {
 func BatchConnect(devices []*dao.UHFReader) map[string]error {
 	result := make(map[string]error)
 	for _, device := range devices {
-		conn, err := NewReaderConn(device)
-		if err != nil {
-			result[device.DeviceCode] = err
+		if device == nil {
 			continue
 		}
-		if err := conn.Connect(); err != nil {
+		if err := StartDeviceHandler(device); err != nil {
 			result[device.DeviceCode] = err
-			continue
 		}
-		// 加入连接池
-		ConnManager.AddConn(device.DeviceCode, conn)
 	}
 	return result
 }

+ 27 - 3
internal/service/uhf/serial_reader.go

@@ -5,6 +5,7 @@ import (
 	"github.com/tarm/serial"
 	"io"
 	"os"
+	"sync"
 	"time"
 )
 
@@ -14,6 +15,7 @@ type SerialReader struct {
 	port        *serial.Port
 	isConnected bool
 	buffer      []byte // 拼包缓冲区
+	ioMu        sync.Mutex
 }
 
 func NewSerialReader(comPort string, baudRate int) *SerialReader {
@@ -24,6 +26,8 @@ func NewSerialReader(comPort string, baudRate int) *SerialReader {
 }
 
 func (s *SerialReader) Connect() error {
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
 	cfg := &serial.Config{
 		Name:        s.COMPort,
 		Baud:        s.BaudRate,
@@ -43,6 +47,8 @@ func (s *SerialReader) Connect() error {
 }
 
 func (s *SerialReader) Disconnect() error {
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
 	if s.port != nil {
 		_ = s.port.Close()
 	}
@@ -52,10 +58,18 @@ func (s *SerialReader) Disconnect() error {
 }
 
 func (s *SerialReader) IsConnected() bool {
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
 	return s.isConnected
 }
 
 func (s *SerialReader) SendData(data []byte) error {
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
+	return s.sendDataLocked(data)
+}
+
+func (s *SerialReader) sendDataLocked(data []byte) error {
 	if !s.isConnected {
 		return errors.New("串口未连接")
 	}
@@ -73,6 +87,12 @@ func (s *SerialReader) SendData(data []byte) error {
 // 🔥 关键修复:串口返回完整25字节帧
 // ==============================
 func (s *SerialReader) ReadData() ([]byte, error) {
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
+	return s.readDataLocked()
+}
+
+func (s *SerialReader) readDataLocked() ([]byte, error) {
 	if !s.isConnected {
 		return nil, errors.New("串口未连接")
 	}
@@ -108,11 +128,15 @@ func (s *SerialReader) ReadData() ([]byte, error) {
 		}
 	}
 
-	return nil, errors.New("等待完整帧")
+	return nil, ErrReadTimeout
 }
 
 func (s *SerialReader) SendAndRecv(data []byte) ([]byte, error) {
-	_ = s.SendData(data)
+	s.ioMu.Lock()
+	defer s.ioMu.Unlock()
+	if err := s.sendDataLocked(data); err != nil {
+		return nil, err
+	}
 	time.Sleep(100 * time.Millisecond)
-	return s.ReadData()
+	return s.readDataLocked()
 }

+ 81 - 11
internal/service/uhf/tcp_reader.go

@@ -4,7 +4,11 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"sync"
 	"time"
+
+	"go.uber.org/zap"
+	"wails-app/internal/global"
 )
 
 type TCPReader struct {
@@ -13,31 +17,28 @@ type TCPReader struct {
 	conn        net.Conn
 	isConnected bool
 	buffer      []byte // 内部拼包缓冲区(关键修复)
+	ioMu        sync.Mutex
 }
 
 // 实现 Reader 接口的继电器方法(不再panic)
 func (t *TCPReader) CloseRelay1(validTime byte) error {
 	frame := buildRelayFrame(0x0077, 1, RELAY_OP_CLOSE, validTime)
-	_, err := t.SendAndRecv(frame)
-	return err
+	return t.sendRelayCommand("open", 1, validTime, frame)
 }
 
 func (t *TCPReader) ReleaseRelay1() error {
 	frame := buildRelayFrame(0x0077, 1, RELAY_OP_RELEASE, 0)
-	_, err := t.SendAndRecv(frame)
-	return err
+	return t.sendRelayCommand("release", 1, 0, frame)
 }
 
 func (t *TCPReader) CloseRelay2(validTime byte) error {
 	frame := buildRelayFrame(0x0078, 2, RELAY_OP_CLOSE, validTime)
-	_, err := t.SendAndRecv(frame)
-	return err
+	return t.sendRelayCommand("close", 2, validTime, frame)
 }
 
 func (t *TCPReader) ReleaseRelay2() error {
 	frame := buildRelayFrame(0x0078, 2, RELAY_OP_RELEASE, 0)
-	_, err := t.SendAndRecv(frame)
-	return err
+	return t.sendRelayCommand("release", 2, 0, frame)
 }
 
 func NewTCPReader(ip string, port int) *TCPReader {
@@ -48,37 +49,53 @@ func NewTCPReader(ip string, port int) *TCPReader {
 }
 
 func (t *TCPReader) Connect() error {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
 	addr := net.JoinHostPort(t.IP, fmt.Sprintf("%d", t.Port))
 	conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
 	if err != nil {
 		t.isConnected = false
+		t.logWarn("UHF TCP 连接失败", zap.Error(err))
 		return err
 	}
 	t.conn = conn
 	t.isConnected = true
+	t.logInfo("UHF TCP 连接成功")
 	return nil
 }
 
 func (t *TCPReader) Disconnect() error {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
 	if t.conn != nil {
 		_ = t.conn.Close()
 	}
 	t.isConnected = false
 	t.buffer = nil
+	t.logInfo("UHF TCP 连接已关闭")
 	return nil
 }
 
 func (t *TCPReader) IsConnected() bool {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
 	return t.isConnected
 }
 
 func (t *TCPReader) SendData(data []byte) error {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
+	return t.sendDataLocked(data)
+}
+
+func (t *TCPReader) sendDataLocked(data []byte) error {
 	if !t.isConnected {
 		return errors.New("TCP未连接")
 	}
 	_, err := t.conn.Write(data)
 	if err != nil {
 		t.isConnected = false
+		t.logWarn("UHF TCP 写入失败", zap.String("packet_hex", fmt.Sprintf("%x", data)), zap.Error(err))
 	}
 	return err
 }
@@ -87,6 +104,12 @@ func (t *TCPReader) SendData(data []byte) error {
 // 🔥 关键修复:TCP 自动拼包,返回完整25字节帧
 // ==============================
 func (t *TCPReader) ReadData() ([]byte, error) {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
+	return t.readDataLocked()
+}
+
+func (t *TCPReader) readDataLocked() ([]byte, error) {
 	if !t.isConnected {
 		return nil, errors.New("TCP未连接")
 	}
@@ -97,12 +120,21 @@ func (t *TCPReader) ReadData() ([]byte, error) {
 	buf := make([]byte, 1024)
 	n, err := t.conn.Read(buf)
 	if err != nil {
+		if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+			return nil, ErrReadTimeout
+		}
 		t.isConnected = false
+		t.logWarn("UHF TCP 读取失败", zap.Error(err))
 		return nil, err
 	}
 
 	// 加入缓冲区
 	t.buffer = append(t.buffer, buf[:n]...)
+	t.logInfo("UHF TCP 收到数据块",
+		zap.Int("packet_bytes", n),
+		zap.Int("buffered_bytes", len(t.buffer)),
+		zap.String("packet_hex", fmt.Sprintf("%x", buf[:n])),
+	)
 
 	// 找完整帧:0xCF 开头 + 25字节
 	for len(t.buffer) >= 25 {
@@ -115,13 +147,51 @@ func (t *TCPReader) ReadData() ([]byte, error) {
 		}
 	}
 
-	return nil, errors.New("等待完整帧")
+	return nil, ErrIncompleteFrame
 }
 
 func (t *TCPReader) SendAndRecv(data []byte) ([]byte, error) {
-	if err := t.SendData(data); err != nil {
+	t.ioMu.Lock()
+	defer t.ioMu.Unlock()
+	if err := t.sendDataLocked(data); err != nil {
 		return nil, err
 	}
 	t.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
-	return t.ReadData()
+	return t.readDataLocked()
+}
+
+func (t *TCPReader) sendRelayCommand(action string, relay int, validTime byte, frame []byte) error {
+	t.logInfo("UHF TCP 道闸指令下发",
+		zap.String("action", action),
+		zap.Int("relay", relay),
+		zap.Uint8("valid_time_seconds", validTime),
+		zap.String("packet_hex", fmt.Sprintf("%x", frame)),
+	)
+	response, err := t.SendAndRecv(frame)
+	if err != nil {
+		t.logWarn("UHF TCP 道闸指令失败", zap.String("action", action), zap.Error(err))
+		return err
+	}
+	t.logInfo("UHF TCP 道闸指令收到响应",
+		zap.String("action", action),
+		zap.Int("response_bytes", len(response)),
+		zap.String("response_hex", fmt.Sprintf("%x", response)),
+	)
+	return nil
+}
+
+func (t *TCPReader) logInfo(message string, fields ...zap.Field) {
+	if global.GVA_LOG == nil {
+		return
+	}
+	fields = append([]zap.Field{zap.String("tcp_address", net.JoinHostPort(t.IP, fmt.Sprintf("%d", t.Port)))}, fields...)
+	global.GVA_LOG.Info(message, fields...)
+}
+
+func (t *TCPReader) logWarn(message string, fields ...zap.Field) {
+	if global.GVA_LOG == nil {
+		return
+	}
+	fields = append([]zap.Field{zap.String("tcp_address", net.JoinHostPort(t.IP, fmt.Sprintf("%d", t.Port)))}, fields...)
+	global.GVA_LOG.Warn(message, fields...)
 }

+ 133 - 17
internal/service/uhf/uhf_service.go

@@ -1,7 +1,9 @@
 package uhf
 
 import (
+	"errors"
 	"fmt"
+	"strings"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/uhf/request"
@@ -14,8 +16,53 @@ type uhfService struct{}
 
 var UHFService = new(uhfService)
 
+// validateReaderRequest 校验设备连接参数,并返回通道所属停车场。
+func validateReaderRequest(connectType, deviceCode string, channelID int, ip string, port int, comPort string, baudRate int) (uint, error) {
+	if global.GVA_DB == nil {
+		return 0, errors.New("数据库未初始化")
+	}
+	if strings.TrimSpace(deviceCode) == "" {
+		return 0, errors.New("设备编码不能为空")
+	}
+	switch connectType {
+	case dao.ConnectTypeTCP:
+		if strings.TrimSpace(ip) == "" || port < 1 || port > 65535 {
+			return 0, errors.New("TCP设备必须配置有效的IP和端口")
+		}
+	case dao.ConnectTypeSerial:
+		if strings.TrimSpace(comPort) == "" || baudRate <= 0 {
+			return 0, errors.New("串口设备必须配置串口和波特率")
+		}
+	case dao.ConnectTypeMQTT:
+		if channelID <= 0 {
+			return 0, errors.New("MQTT设备必须绑定通道")
+		}
+	default:
+		return 0, fmt.Errorf("不支持的连接类型: %s", connectType)
+	}
+
+	if channelID <= 0 {
+		return 0, nil
+	}
+	var channel dao.Channel
+	if result := global.GVA_DB.Preload("Booth").Where("id = ?", channelID).First(&channel); result.Error != nil {
+		return 0, fmt.Errorf("通道不存在: %d", channelID)
+	}
+	if channel.BoothID == 0 || channel.ParkingLotID == 0 || channel.Booth == nil {
+		return 0, errors.New("通道未完整配置岗亭和停车场")
+	}
+	if channel.Booth.ParkingLotID != channel.ParkingLotID {
+		return 0, errors.New("通道、岗亭和停车场归属不一致")
+	}
+	return channel.ParkingLotID, nil
+}
+
 // CreateReader 创建UHF读写器
 func (s *uhfService) CreateReader(req request.UHFReaderCreate) error {
+	parkingLotID, err := validateReaderRequest(req.ConnectType, req.DeviceCode, req.ChannelId, req.IPAddress, req.Port, req.COMPort, req.BaudRate)
+	if err != nil {
+		return err
+	}
 	var reader dao.UHFReader
 	result := global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&reader)
 	if result.RowsAffected > 0 {
@@ -28,6 +75,11 @@ func (s *uhfService) CreateReader(req request.UHFReaderCreate) error {
 	var booth dao.Booth
 	global.GVA_DB.Model(&booth).Where("id = ?", channel.BoothID).First(&booth)
 
+	// MQTT 设备必须绑定通道(路由依赖 channel -> booth -> lot 坐标)
+	if req.ConnectType == dao.ConnectTypeMQTT && req.ChannelId == 0 {
+		return fmt.Errorf("MQTT 设备必须绑定通道")
+	}
+
 	reader = dao.UHFReader{
 		DeviceCode:   req.DeviceCode,
 		DeviceName:   req.DeviceName,
@@ -39,11 +91,11 @@ func (s *uhfService) CreateReader(req request.UHFReaderCreate) error {
 		BaudRate:     req.BaudRate,
 		IsActive:     req.IsActive,
 		Description:  req.Description,
-		ParkingLotID: booth.ParkingLotID,
+		ParkingLotID: parkingLotID,
 		ChannelID:    uint(req.ChannelId),
 	}
 
-	err := global.GVA_DB.Create(&reader).Error
+	err = global.GVA_DB.Create(&reader).Error
 
 	if req.IsActive {
 		if err := StartDeviceHandler(&reader); err != nil {
@@ -56,26 +108,73 @@ func (s *uhfService) CreateReader(req request.UHFReaderCreate) error {
 
 // UpdateReader 更新UHF读写器
 func (s *uhfService) UpdateReader(req request.UHFReaderUpdate) error {
+	if global.GVA_DB == nil {
+		return errors.New("数据库未初始化")
+	}
 	var reader dao.UHFReader
 	result := global.GVA_DB.Where("id = ?", req.ID).First(&reader)
+	deviceCode := req.DeviceCode
+	if strings.TrimSpace(deviceCode) == "" {
+		deviceCode = reader.DeviceCode
+	}
+	parkingLotID, validationErr := validateReaderRequest(req.ConnectType, deviceCode, req.ChannelId, req.IPAddress, req.Port, req.COMPort, req.BaudRate)
+	if validationErr != nil {
+		return validationErr
+	}
 	if result.RowsAffected == 0 {
 		return fmt.Errorf("设备不存在")
 	}
 
+	// MQTT 设备必须绑定通道(路由依赖 channel -> booth -> lot 坐标)
+	if req.ConnectType == dao.ConnectTypeMQTT && req.ChannelId == 0 {
+		return fmt.Errorf("MQTT 设备必须绑定通道")
+	}
+
+	if deviceCode != reader.DeviceCode {
+		var duplicate dao.UHFReader
+		if check := global.GVA_DB.Where("device_code = ? AND id <> ?", deviceCode, reader.ID).First(&duplicate); check.Error == nil {
+			return fmt.Errorf("设备编码已存在: %s", deviceCode)
+		}
+	}
+	if stopErr := StopDeviceHandler(reader.DeviceCode); stopErr != nil {
+		return stopErr
+	}
+
 	updateData := map[string]interface{}{
-		"device_name":  req.DeviceName,
-		"device_type":  req.DeviceType,
-		"connect_type": req.ConnectType,
-		"ip_address":   req.IPAddress,
-		"port":         req.Port,
-		"com_port":     req.COMPort,
-		"baud_rate":    req.BaudRate,
-		"is_active":    req.IsActive,
-		"description":  req.Description,
-		"channel_id":   req.ChannelId,
-	}
-
-	return global.GVA_DB.Model(&reader).Updates(updateData).Error
+		"device_code":    deviceCode,
+		"device_name":    req.DeviceName,
+		"device_type":    req.DeviceType,
+		"connect_type":   req.ConnectType,
+		"ip_address":     req.IPAddress,
+		"port":           req.Port,
+		"com_port":       req.COMPort,
+		"baud_rate":      req.BaudRate,
+		"is_active":      req.IsActive,
+		"description":    req.Description,
+		"channel_id":     req.ChannelId,
+		"parking_lot_id": parkingLotID,
+	}
+
+	if err := global.GVA_DB.Model(&reader).Updates(updateData).Error; err != nil {
+		return err
+	}
+	reader.DeviceCode = deviceCode
+	reader.ConnectType = req.ConnectType
+	reader.IPAddress = req.IPAddress
+	reader.Port = req.Port
+	reader.COMPort = req.COMPort
+	reader.BaudRate = req.BaudRate
+	reader.IsActive = req.IsActive
+	reader.ChannelID = uint(req.ChannelId)
+	reader.ParkingLotID = parkingLotID
+	if req.IsActive && req.ConnectType != dao.ConnectTypeMQTT {
+		if err := StartDeviceHandler(&reader); err != nil {
+			_ = global.GVA_DB.Model(&reader).Update("status", "offline").Error
+			return fmt.Errorf("设备更新成功但启动失败: %w", err)
+		}
+		return nil
+	}
+	return global.GVA_DB.Model(&reader).Update("status", "offline").Error
 }
 
 // ConnectedDevice 连接设备
@@ -86,6 +185,10 @@ func (s *uhfService) ConnectedDevice(id uint) error {
 		return fmt.Errorf("设备不存在")
 	}
 
+	if reader.ConnectType == dao.ConnectTypeMQTT {
+		return fmt.Errorf("MQTT 设备由设备主动连接,无需手动连接")
+	}
+
 	return StartDeviceHandler(&reader)
 }
 
@@ -171,6 +274,9 @@ func (s *uhfService) DeleteReader(id uint) error {
 		return fmt.Errorf("设备不存在")
 	}
 
+	if err := StopDeviceHandler(reader.DeviceCode); err != nil {
+		return err
+	}
 	return global.GVA_DB.Delete(&reader).Error
 }
 
@@ -186,8 +292,12 @@ func (s *uhfService) ConnectReader(id uint) error {
 		return fmt.Errorf("设备未启用")
 	}
 
-	err := global.GVA_DB.Model(&reader).Update("status", "online").Error
-	if err != nil {
+	if reader.ConnectType == dao.ConnectTypeMQTT {
+		return fmt.Errorf("MQTT 设备由设备主动连接,无需手动连接")
+	}
+
+	if err := StartDeviceHandler(&reader); err != nil {
+		_ = global.GVA_DB.Model(&reader).Update("status", "offline").Error
 		return err
 	}
 
@@ -203,6 +313,12 @@ func (s *uhfService) DisconnectReader(id uint) error {
 		return fmt.Errorf("设备不存在")
 	}
 
+	if reader.ConnectType == dao.ConnectTypeMQTT {
+		return fmt.Errorf("MQTT设备由设备主动连接,不能手动断开")
+	}
+	if err := StopDeviceHandler(reader.DeviceCode); err != nil {
+		return err
+	}
 	err := global.GVA_DB.Model(&reader).Update("status", "offline").Error
 	if err != nil {
 		return err

+ 23 - 0
internal/service/vehicle/parking_session_test.go

@@ -67,6 +67,29 @@ func TestOperationContextResolvesRFIDOnlyVehicleForEntry(t *testing.T) {
 	require.Equal(t, vehicle.PlateNumber, context.Vehicle.PlateNumber)
 }
 
+func TestVehicleEntryCreatesTemporaryPlateForRFIDOnlyVehicle(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	vehicleType := &dao.VehicleType{Name: "rfid-temporary", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+
+	const rfidTag = "E20047034BD060264B74010E"
+	entry, err := svc.VehicleEntry(request.VehicleEntry{RFIDTag: rfidTag, ParkingLotID: 1})
+	require.NoError(t, err)
+	require.Len(t, entry.Data.PlateNumber, 19)
+	require.Contains(t, entry.Data.PlateNumber, temporaryRFIDPlatePrefix)
+
+	var vehicle dao.Vehicle
+	require.NoError(t, global.GVA_DB.Where("rfid_tag = ?", rfidTag).First(&vehicle).Error)
+	require.Equal(t, entry.Data.PlateNumber, vehicle.PlateNumber)
+	require.NotEmpty(t, vehicle.PlateNumber)
+
+	_, err = svc.VehicleEntry(request.VehicleEntry{RFIDTag: rfidTag, ParkingLotID: 1})
+	require.Error(t, err)
+	var vehicleCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.Vehicle{}).Where("rfid_tag = ?", rfidTag).Count(&vehicleCount).Error)
+	require.EqualValues(t, 1, vehicleCount)
+}
+
 func TestOperationContextReturnsSessionAndTicketForExit(t *testing.T) {
 	svc := setupVehiclePaymentTest(t)
 	record, vehicle := createVehiclePaymentFixture(t, true)

+ 35 - 0
internal/service/vehicle/payment_edge_test.go

@@ -181,3 +181,38 @@ func TestExitConfirmConsumesPrepaidTicketWithoutSecondPayment(t *testing.T) {
 	require.NoError(t, global.GVA_DB.First(&ticket, ticket.ID).Error)
 	require.Equal(t, ticketService.StateExited, ticket.State)
 }
+
+func TestExitConfirmAllowsRFIDFreeSettlementForChargeableTemporaryVehicle(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle := createVehiclePaymentFixture(t, true)
+
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		PlateNumber: vehicle.PlateNumber, PaymentEntry: paymentService.EntryCounter,
+		PaymentMethod: paymentService.PaymentFree, TriggerSource: "rfid",
+	}, 0)
+	require.NoError(t, err)
+	require.Positive(t, result.Fee)
+	require.Equal(t, paymentService.PaymentFree, result.PaymentMethod)
+
+	var payment dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.Where("record_id = ?", record.ID).First(&payment).Error)
+	require.InDelta(t, result.Fee, payment.Amount, 0.001)
+	require.Zero(t, payment.PaidAmount)
+
+	var ticket dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", record.ID).First(&ticket).Error)
+	require.Equal(t, ticketService.StateExited, ticket.State)
+	require.InDelta(t, result.Fee, ticket.Fee, 0.001)
+	require.Zero(t, ticket.PaidAmount)
+}
+
+func TestExitConfirmRejectsManualFreeSettlementForChargeableTemporaryVehicle(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	_, vehicle := createVehiclePaymentFixture(t, true)
+
+	_, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		PlateNumber: vehicle.PlateNumber, PaymentEntry: paymentService.EntryCounter,
+		PaymentMethod: paymentService.PaymentFree,
+	}, 1)
+	require.EqualError(t, err, "临时车辆不能使用免密放行")
+}

+ 26 - 4
internal/service/vehicle/vehicle.go

@@ -1,6 +1,7 @@
 package vehicle
 
 import (
+	"crypto/sha256"
 	"errors"
 	"fmt"
 	"math"
@@ -20,6 +21,15 @@ import (
 
 type VehicleService struct{}
 
+const temporaryRFIDPlatePrefix = "TMP"
+
+// temporaryRFIDPlateNumber 为没有识别到车牌的 RFID 临时车辆生成固定编号。
+// 车牌字段限制为 20 位,使用 3 位前缀加 16 位摘要,既可追溯又不会占用真实车牌。
+func temporaryRFIDPlateNumber(rfidTag string) string {
+	digest := sha256.Sum256([]byte(strings.TrimSpace(rfidTag)))
+	return fmt.Sprintf("%s%X", temporaryRFIDPlatePrefix, digest[:8])
+}
+
 // buildEntryAccessProfileTx 计算车辆在停车场满位时使用的准入身份。
 func buildEntryAccessProfileTx(tx *gorm.DB, vehicle *dao.Vehicle, at time.Time) (sessionRequest.EntryAccessProfile, error) {
 	profile := sessionRequest.EntryAccessProfile{}
@@ -261,6 +271,11 @@ func (s *VehicleService) DeleteVehicle(id uint) error {
 
 // VehicleEntry 车辆入场
 func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.VehicleEntryResponse, error) {
+	plateNumberInput := strings.TrimSpace(req.PlateNumber)
+	rfidTagInput := strings.TrimSpace(req.RFIDTag)
+	req.PlateNumber = plateNumberInput
+	req.RFIDTag = rfidTagInput
+
 	// 黑名单检查
 	var shortlistSvc ShortlistService
 	if isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag); isBlack {
@@ -274,8 +289,12 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		if global.GVA_DB.Where("is_system = ?", true).First(&tempType).Error != nil {
 			return request.VehicleEntryResponse{}, errors.New("系统错误:临时车类型未配置")
 		}
+		temporaryPlateNumber := req.PlateNumber
+		if temporaryPlateNumber == "" && req.RFIDTag != "" {
+			temporaryPlateNumber = temporaryRFIDPlateNumber(req.RFIDTag)
+		}
 		createReq := request.VehicleCreate{
-			PlateNumber:   req.PlateNumber,
+			PlateNumber:   temporaryPlateNumber,
 			RFIDTag:       req.RFIDTag,
 			VehicleTypeID: tempType.ID,
 			OwnerId:       0,
@@ -284,7 +303,7 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		if err != nil {
 			return request.VehicleEntryResponse{}, err
 		}
-		vehicle, _ = s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
+		vehicle, _ = s.GetVehicleByPlateNumber(temporaryPlateNumber, req.RFIDTag)
 	}
 
 	// 在同一事务中创建停车会话和数字票,避免出现只有会话或只有数字票的不完整入场数据。
@@ -757,7 +776,9 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 			}
 		} else if ticket.State == ticketService.StatePendingPayment {
 			isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
-			if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
+			// RFID 读卡出场统一按免密结算,临时车也必须走同一规则。
+			// 人工入口仍禁止对有费用的临时车直接免密放行。
+			if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree && req.TriggerSource != "rfid" {
 				return errors.New("临时车辆不能使用免密放行")
 			}
 			paymentSvc := paymentService.NewPaymentService()
@@ -796,10 +817,11 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 			return err
 		}
 
-		if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, actualEntry, actualMethod, "", paidAmount, fee); err != nil {
+		if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, actualEntry, actualMethod, "", paidAmount, fee, req.TriggerSource == "rfid"); err != nil {
 			return err
 		}
 		result = vehicleResponse.ExitConfirmResponse{
+			SessionID:   record.ID,
 			PlateNumber: vehicle.PlateNumber, EntryTime: record.EntryTime.Unix(), ExitTime: exitTime.Unix(),
 			StayTime: stayTime, Fee: fee, ChangeAmount: changeAmount, PaymentEntry: actualEntry, PaymentMethod: actualMethod,
 			EntryChannelID: record.EntryChannelID, EntryChannelCode: record.EntryChannelCode,

+ 8 - 0
main.go

@@ -27,6 +27,9 @@ func main() {
 	// Reverse proxy for /api/* requests to Gin backend (strips /api prefix)
 	backendURL, _ := url.Parse("http://127.0.0.1:8888")
 	proxy := httputil.NewSingleHostReverseProxy(backendURL)
+	// Flush each MJPEG chunk immediately through the Wails asset proxy instead
+	// of buffering it until the response is considered complete.
+	proxy.FlushInterval = -1
 
 	// Embedded frontend static files
 	distFS, err := fs.Sub(assets, "frontend/dist")
@@ -51,6 +54,11 @@ func main() {
 		r.RequestURI = "/"
 		proxy.ServeHTTP(w, r)
 	})
+	// Proxy uploaded edge images to the Gin backend. The backend owns the
+	// configured local storage directory and serves /uploads/file/*.
+	mux.HandleFunc("/uploads/", func(w http.ResponseWriter, r *http.Request) {
+		proxy.ServeHTTP(w, r)
+	})
 	mux.Handle("/", fileServer)
 
 	err = wails.Run(&options.App{

+ 219 - 0
scripts/manage-project.ps1

@@ -0,0 +1,219 @@
+[CmdletBinding()]
+param(
+    [Parameter(Position = 0)]
+    [ValidateSet('menu', 'start', 'restart', 'stop', 'status')]
+    [string]$Action = 'menu',
+
+    [ValidateSet('split', 'wails')]
+    [string]$Mode = 'split'
+)
+
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$RunDir = Join-Path $Root '.run'
+$StatePath = Join-Path $RunDir 'project-processes.json'
+$BackendExe = Join-Path $RunDir 'smart-parking-backend.exe'
+$WailsGoCache = Join-Path $Root '.gocache-wails'
+$BackendBuildLog = Join-Path $RunDir 'backend-build.log'
+$BackendStdout = Join-Path $RunDir 'backend.stdout.log'
+$BackendStderr = Join-Path $RunDir 'backend.stderr.log'
+$FrontendStdout = Join-Path $RunDir 'frontend.stdout.log'
+$FrontendStderr = Join-Path $RunDir 'frontend.stderr.log'
+$WailsStdout = Join-Path $RunDir 'wails.stdout.log'
+$WailsStderr = Join-Path $RunDir 'wails.stderr.log'
+
+function Ensure-RunDirectory {
+    if (-not (Test-Path -LiteralPath $RunDir)) {
+        New-Item -ItemType Directory -Path $RunDir | Out-Null
+    }
+}
+
+function Read-State {
+    if (-not (Test-Path -LiteralPath $StatePath)) { return $null }
+    try {
+        return Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
+    } catch {
+        Write-Warning "Ignoring invalid state file: $StatePath"
+        return $null
+    }
+}
+
+function Write-State([string]$RunMode, [array]$Processes) {
+    Ensure-RunDirectory
+    [ordered]@{
+        root = $Root
+        mode = $RunMode
+        started_at = (Get-Date).ToString('o')
+        processes = $Processes
+    } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $StatePath -Encoding UTF8
+}
+
+function Remove-State {
+    if (Test-Path -LiteralPath $StatePath) {
+        Remove-Item -LiteralPath $StatePath -Force
+    }
+}
+
+function Stop-ProcessTree([int]$ProcessId) {
+    if ($ProcessId -le 0) { return }
+    $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
+    if ($null -eq $process) { return }
+    Write-Host "Stopping $($process.ProcessName) (PID $ProcessId)"
+    & taskkill.exe /PID $ProcessId /T /F 2>$null | Out-Null
+}
+
+function Stop-ManagedProcesses {
+    $state = Read-State
+    if ($null -eq $state) {
+        Write-Host 'No managed project state found.'
+        return
+    }
+    foreach ($item in @($state.processes)) {
+        Stop-ProcessTree ([int]$item.pid)
+    }
+    Remove-State
+    Write-Host 'Project processes stopped.'
+}
+
+function Assert-Command([string]$Name) {
+    if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) {
+        throw "Required command not found: $Name"
+    }
+}
+
+function Wait-Http([string]$Url, [int]$TimeoutSeconds = 30) {
+    for ($i = 0; $i -lt $TimeoutSeconds; $i++) {
+        try {
+            $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
+            if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return $true }
+        } catch { }
+        Start-Sleep -Seconds 1
+    }
+    return $false
+}
+
+function Start-SplitMode {
+    Assert-Command 'go'
+    Assert-Command 'npm'
+    Ensure-RunDirectory
+
+    Write-Host 'Building backend...'
+    Push-Location $Root
+    try {
+        & go build -o $BackendExe .\cmd_backend_only\ 2>&1 | Tee-Object -FilePath $BackendBuildLog
+        if ($LASTEXITCODE -ne 0) { throw 'Backend build failed.' }
+    } finally {
+        Pop-Location
+    }
+
+    $backend = Start-Process -FilePath $BackendExe -WorkingDirectory $Root `
+        -RedirectStandardOutput $BackendStdout -RedirectStandardError $BackendStderr -PassThru
+    $frontend = Start-Process -FilePath 'npm.cmd' -ArgumentList @('run', 'dev') `
+        -WorkingDirectory (Join-Path $Root 'frontend') `
+        -RedirectStandardOutput $FrontendStdout -RedirectStandardError $FrontendStderr -PassThru
+    Write-State 'split' @(
+        [ordered]@{ name = 'backend'; pid = $backend.Id; port = 8888 },
+        [ordered]@{ name = 'frontend'; pid = $frontend.Id; port = 5173 }
+    )
+
+    if (-not (Wait-Http 'http://127.0.0.1:8888/health')) {
+        Stop-ManagedProcesses
+        throw "Backend did not become ready. See $BackendStdout and $BackendStderr"
+    }
+    Write-Host 'Backend ready: http://127.0.0.1:8888'
+    Write-Host 'Frontend:      http://localhost:5173'
+}
+
+function Start-WailsMode {
+    Assert-Command 'wails'
+    Ensure-RunDirectory
+    New-Item -ItemType Directory -Path $WailsGoCache -Force | Out-Null
+    # Keep Wails/Go build artifacts in the project workspace. The default
+    # user cache can be inaccessible on locked-down Windows installations.
+    $env:GOCACHE = $WailsGoCache
+    $wails = Start-Process -FilePath 'wails' -ArgumentList @('dev') `
+        -WorkingDirectory $Root -RedirectStandardOutput $WailsStdout `
+        -RedirectStandardError $WailsStderr -PassThru
+    Write-State 'wails' @([ordered]@{ name = 'wails'; pid = $wails.Id; port = 5173 })
+    Write-Host "Wails dev started (PID $($wails.Id)). See $WailsStdout and $WailsStderr"
+}
+
+function Show-Status {
+    $state = Read-State
+    if ($null -eq $state) {
+        Write-Host 'Project is not managed by this script.'
+        return
+    }
+    Write-Host "Mode: $($state.mode)"
+    Write-Host "Started: $($state.started_at)"
+    foreach ($item in @($state.processes)) {
+        $process = Get-Process -Id ([int]$item.pid) -ErrorAction SilentlyContinue
+        $status = if ($null -eq $process) { 'stopped' } else { 'running' }
+        Write-Host ("{0}: PID {1}, port {2}, {3}" -f $item.name, $item.pid, $item.port, $status)
+    }
+}
+
+function Show-Menu {
+    while ($true) {
+        Clear-Host
+        Write-Host '========================================' -ForegroundColor Cyan
+        Write-Host '       Smart Parking Project Manager' -ForegroundColor Cyan
+        Write-Host '========================================'
+        Write-Host '1. Start project'
+        Write-Host '2. Stop project'
+        Write-Host '3. Restart project'
+        Write-Host '4. Show status'
+        Write-Host '0. Exit'
+        Write-Host ''
+
+        $choice = Read-Host 'Select action'
+        if ($choice -eq '1') {
+            if ($null -ne (Read-State)) {
+                Write-Host 'Project is already running; use stop or restart.' -ForegroundColor Yellow
+            } elseif ($Mode -eq 'wails') {
+                Start-WailsMode
+            } else {
+                Start-SplitMode
+            }
+        } elseif ($choice -eq '2') {
+            Stop-ManagedProcesses
+        } elseif ($choice -eq '3') {
+            Stop-ManagedProcesses
+            if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
+        } elseif ($choice -eq '4') {
+            Show-Status
+        } elseif ($choice -eq '0') {
+            return
+        } else {
+            Write-Host 'Invalid option.' -ForegroundColor Red
+        }
+
+        if ($choice -ne '0') {
+            Write-Host ''
+            Read-Host 'Press Enter to return to menu'
+        }
+    }
+}
+
+switch ($Action) {
+    'menu' {
+        Show-Menu
+    }
+    'stop' {
+        Stop-ManagedProcesses
+    }
+    'status' {
+        Show-Status
+    }
+    'restart' {
+        Stop-ManagedProcesses
+        if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
+    }
+    'start' {
+        if ($null -ne (Read-State)) {
+            Write-Host 'A managed instance already exists; use restart or stop first.'
+            exit 1
+        }
+        if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
+    }
+}

+ 65 - 0
scripts/mock-edge-device.ps1

@@ -0,0 +1,65 @@
+[CmdletBinding()]
+param(
+    [ValidateSet('start', 'stop', 'restart', 'status')]
+    [string]$Action = 'start',
+    [string]$Listen = '0.0.0.0:18443',
+    [switch]$MqttFallbackPlain
+)
+
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$RunDir = Join-Path $Root '.run\mock-edge-device'
+$GoCache = Join-Path $Root '.gocache-mock-edge'
+$Exe = Join-Path $RunDir 'mock-edge-device.exe'
+$State = Join-Path $RunDir 'process.json'
+$Stdout = Join-Path $RunDir 'stdout.log'
+$Stderr = Join-Path $RunDir 'stderr.log'
+
+function Read-State {
+    if (-not (Test-Path -LiteralPath $State)) { return $null }
+    try { return Get-Content -LiteralPath $State -Raw | ConvertFrom-Json } catch { return $null }
+}
+
+function Stop-Mock {
+    $item = Read-State
+    if ($null -ne $item) {
+        $process = Get-Process -Id ([int]$item.pid) -ErrorAction SilentlyContinue
+        if ($null -ne $process) {
+            & taskkill.exe /PID ([int]$item.pid) /T /F 2>$null | Out-Null
+            $process | Wait-Process -Timeout 5 -ErrorAction SilentlyContinue
+        }
+    }
+    if (Test-Path -LiteralPath $State) { Remove-Item -LiteralPath $State -Force }
+    Write-Host 'Mock edge device stopped.'
+}
+
+if ($Action -eq 'stop') { Stop-Mock; exit 0 }
+if ($Action -eq 'status') {
+    $item = Read-State
+    if ($null -eq $item) { Write-Host 'Mock edge device is not managed.'; exit 0 }
+    $process = Get-Process -Id ([int]$item.pid) -ErrorAction SilentlyContinue
+    Write-Host ("PID {0}, listen {1}, {2}" -f $item.pid, $item.listen, $(if ($null -eq $process) { 'stopped' } else { 'running' }))
+    exit 0
+}
+if ($Action -eq 'restart') { Stop-Mock }
+
+New-Item -ItemType Directory -Path $RunDir -Force | Out-Null
+New-Item -ItemType Directory -Path $GoCache -Force | Out-Null
+Push-Location $Root
+try {
+	$env:GOCACHE = $GoCache
+    & go build -o $Exe .\cmd\mock-edge-device
+    if ($LASTEXITCODE -ne 0) { throw 'Mock edge device build failed.' }
+} finally { Pop-Location }
+
+$arguments = "-listen `"$Listen`" -data-dir `"$RunDir`""
+if ($MqttFallbackPlain) { $arguments += ' -mqtt-fallback-plain' }
+$process = Start-Process -FilePath $Exe -ArgumentList $arguments -WorkingDirectory $Root -RedirectStandardOutput $Stdout -RedirectStandardError $Stderr -PassThru
+[ordered]@{ pid = $process.Id; listen = $Listen; started_at = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content -LiteralPath $State -Encoding UTF8
+Start-Sleep -Milliseconds 500
+if ($process.HasExited) { Get-Content -LiteralPath $Stderr -ErrorAction SilentlyContinue; throw 'Mock edge device exited during startup.' }
+Write-Host "Mock edge device started (PID $($process.Id))."
+Write-Host "Manual IP: use a non-loopback host IP with HTTPS port $($Listen.Split(':')[-1])."
+Write-Host "Automatic discovery: UDP port 31001"
+Write-Host "Pairing code: 246810"
+Write-Host "Logs: $Stdout / $Stderr"

+ 7 - 0
scripts/项目管理.bat

@@ -0,0 +1,7 @@
+@echo off
+cd /d "%~dp0"
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass ^
+  -File "%~dp0\manage-project.ps1" menu
+
+pause