Pārlūkot izejas kodu

feat: 单一通道RFID读卡方向推断(inout通道管进也管出)

- HandlePassage inout 分支:事件未显式携带方向时按在场会话推断——
  无在场会话=进场,已有在场会话=出场(RFID 触发沿用免密结算规则)
- 防误判:推断为出场时校验最小停留时长 system.inout-min-stay-minutes
  (默认1分钟),不足则忽略本次读卡,避免车未驶离读卡区就被结算出场
- 显式携带方向的请求(相机/工作台)不受影响;联调识别端点支持省略方向
- 单测覆盖:无会话进场/满停留出场/重复读卡忽略/同卡多会话保错退出
lq 12 stundas atpakaļ
vecāks
revīzija
05756d70e3

+ 3 - 0
internal/config/system.go

@@ -12,4 +12,7 @@ type System struct {
 	UseMongo      bool   `mapstructure:"use-mongo" json:"use-mongo" yaml:"use-mongo"`                // 使用mongo
 	GateSimulator bool   `mapstructure:"gate-simulator" json:"gate-simulator" yaml:"gate-simulator"` // 仅用于无硬件联调的模拟道闸
 	DebugRoutes   bool   `mapstructure:"debug-routes" json:"debug-routes" yaml:"debug-routes"`       // 显式开启受管理员鉴权保护的调试路由
+	// InoutMinStayMinutes 单一通道(inout)方向推断的最小停留时长(分钟):
+	// 入场后不足该时长的再次读卡按重复读卡忽略,防止车未离场就被推断为出场。<=0 时默认 1 分钟。
+	InoutMinStayMinutes int `mapstructure:"inout-min-stay-minutes" json:"inout-min-stay-minutes" yaml:"inout-min-stay-minutes"`
 }

+ 66 - 32
internal/service/parking/passage.go

@@ -11,6 +11,7 @@ import (
 	common "wails-app/internal/model/common"
 	"wails-app/internal/model/vehicle/request"
 	incidentService "wails-app/internal/modules/incident/service"
+	sessionRequest "wails-app/internal/modules/parking-session/model/request"
 	sessionService "wails-app/internal/modules/parking-session/service"
 	"wails-app/internal/service/vehicle"
 )
@@ -67,10 +68,14 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	channel := device.Channel
 	direction := channel.Direction
 	if direction == "inout" {
-		if req.Direction != "in" && req.Direction != "out" {
-			return nil, errors.New("双向通道必须明确本次通行方向")
+		if req.Direction == "in" || req.Direction == "out" {
+			direction = req.Direction
+		} else if inferred, inferErr := s.inferInoutDirection(req); inferErr != nil {
+			// 单一通道方向推断失败(如重复读卡):按失败事件处理,出口屏/工作台可见原因
+			return nil, inferErr
+		} else {
+			direction = inferred
 		}
-		direction = req.Direction
 	} else if req.Direction != "" && req.Direction != direction {
 		return nil, fmt.Errorf("所选设备属于%s通道,不能执行%s操作", direction, req.Direction)
 	}
@@ -95,16 +100,16 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	if isBlack {
 		fmt.Printf("🚫 禁止通行 | 车辆在黑名单中 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
 		incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
-			Category:    incidentService.CategoryBlacklist,
-			Source:      incidentService.SourcePassage,
-			PlateNumber: req.PlateNumber,
-			RFIDTag:     req.RFIDTag,
+			Category:     incidentService.CategoryBlacklist,
+			Source:       incidentService.SourcePassage,
+			PlateNumber:  req.PlateNumber,
+			RFIDTag:      req.RFIDTag,
 			ParkingLotID: channel.ParkingLotID,
-			ChannelID:   channel.ID,
-			ChannelCode: channel.ChannelCode,
-			DeviceCode:  req.DeviceCode,
-			OperatorID:  req.OperatorID,
-			Description: "黑名单车辆通行拦截",
+			ChannelID:    channel.ID,
+			ChannelCode:  channel.ChannelCode,
+			DeviceCode:   req.DeviceCode,
+			OperatorID:   req.OperatorID,
+			Description:  "黑名单车辆通行拦截",
 		})
 		return nil, fmt.Errorf("车辆在黑名单中")
 	}
@@ -136,6 +141,35 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	}
 }
 
+// inferInoutDirection 单一通道(inout)方向推断:单一通道只装一个读卡器,管进也管出,
+// 事件本身不带方向,由业务状态决定——无在场会话即进场,已有在场会话即出场。
+// 防误判:入场后不足 system.inout-min-stay-minutes 的再次读卡按重复读卡忽略,
+// 避免车还没驶离读卡区就被推断成出场。显式携带方向的请求不会走到这里。
+func (s *PassageService) inferInoutDirection(req common.PassageRequest) (string, error) {
+	sessionSvc := sessionService.NewParkingSessionService()
+	record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
+		PlateNumber: req.PlateNumber,
+		RFIDTag:     req.RFIDTag,
+	})
+	if errors.Is(err, sessionService.ErrSessionNotFound) {
+		return "in", nil
+	}
+	if err != nil {
+		return "", fmt.Errorf("单一通道方向推断失败: %w", err)
+	}
+
+	minStay := time.Duration(global.GVA_CONFIG.System.InoutMinStayMinutes) * time.Minute
+	if minStay <= 0 {
+		minStay = time.Minute
+	}
+	stay := time.Since(record.EntryTime)
+	if stay < minStay {
+		return "", fmt.Errorf("单一通道重复读卡忽略: 入场后需满 %v 才触发出场(当前 %v)",
+			minStay, stay.Round(time.Second))
+	}
+	return "out", nil
+}
+
 // handleEntry 处理入场
 func (s *PassageService) handleEntry(req common.PassageRequest, identifier string, parkingLotID uint, channel *dao.Channel, device *dao.UHFReader) (*common.PassageResult, error) {
 	passageMutex.Lock()
@@ -144,16 +178,16 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 
 	if !canIn {
 		incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
-			Category:    incidentService.CategoryDuplicateEntry,
-			Source:      incidentService.SourcePassage,
-			PlateNumber: req.PlateNumber,
-			RFIDTag:     req.RFIDTag,
+			Category:     incidentService.CategoryDuplicateEntry,
+			Source:       incidentService.SourcePassage,
+			PlateNumber:  req.PlateNumber,
+			RFIDTag:      req.RFIDTag,
 			ParkingLotID: parkingLotID,
-			ChannelID:   channel.ID,
-			ChannelCode: channel.ChannelCode,
-			DeviceCode:  req.DeviceCode,
-			OperatorID:  req.OperatorID,
-			Description: "车辆已在场内,重复入场拦截",
+			ChannelID:    channel.ID,
+			ChannelCode:  channel.ChannelCode,
+			DeviceCode:   req.DeviceCode,
+			OperatorID:   req.OperatorID,
+			Description:  "车辆已在场内,重复入场拦截",
 		})
 		return nil, fmt.Errorf("车辆已在场内: %s", identifier)
 	}
@@ -271,18 +305,18 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 		// 无入场记录/丢票出场(resolveExitRecordTx 将所有定位失败统一包装为"车辆未入场")
 		if strings.Contains(err.Error(), "车辆未入场") {
 			incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
-				Category:    incidentService.CategoryNoEntryExit,
-				Source:      incidentService.SourcePassage,
-				TicketNo:    req.TicketNo,
-				PlateNumber: req.PlateNumber,
-				RFIDTag:     req.RFIDTag,
+				Category:     incidentService.CategoryNoEntryExit,
+				Source:       incidentService.SourcePassage,
+				TicketNo:     req.TicketNo,
+				PlateNumber:  req.PlateNumber,
+				RFIDTag:      req.RFIDTag,
 				ParkingLotID: channel.ParkingLotID,
-				ChannelID:   channel.ID,
-				ChannelCode: channel.ChannelCode,
-				DeviceCode:  req.DeviceCode,
-				OperatorID:  req.OperatorID,
-				Description: "无入场记录出场或票据不存在",
-				Detail:      err.Error(),
+				ChannelID:    channel.ID,
+				ChannelCode:  channel.ChannelCode,
+				DeviceCode:   req.DeviceCode,
+				OperatorID:   req.OperatorID,
+				Description:  "无入场记录出场或票据不存在",
+				Detail:       err.Error(),
 			})
 		}
 		return nil, err

+ 98 - 0
internal/service/parking/passage_infer_test.go

@@ -0,0 +1,98 @@
+package parking
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	common "wails-app/internal/model/common"
+)
+
+// newInferTestDB 每个用例独立的内存库,避免相互污染。
+func newInferTestDB(t *testing.T) {
+	t.Helper()
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("打开内存数据库失败: %v", err)
+	}
+	if err := db.AutoMigrate(&dao.VehicleRecord{}); err != nil {
+		t.Fatalf("迁移表失败: %v", err)
+	}
+	oldDB := global.GVA_DB
+	global.GVA_DB = db
+	oldMinutes := global.GVA_CONFIG.System.InoutMinStayMinutes
+	global.GVA_CONFIG.System.InoutMinStayMinutes = 1
+	t.Cleanup(func() {
+		global.GVA_DB = oldDB
+		global.GVA_CONFIG.System.InoutMinStayMinutes = oldMinutes
+	})
+}
+
+func TestInferInoutDirection_NoSessionMeansEntry(t *testing.T) {
+	newInferTestDB(t)
+	svc := &PassageService{}
+	dir, err := svc.inferInoutDirection(common.PassageRequest{RFIDTag: "E280001"})
+	if err != nil {
+		t.Fatalf("无会话应推断为进场,却返回错误: %v", err)
+	}
+	if dir != "in" {
+		t.Fatalf("期望方向 in,实际 %s", dir)
+	}
+}
+
+func TestInferInoutDirection_ActiveSessionPastMinStayMeansExit(t *testing.T) {
+	newInferTestDB(t)
+	rec := dao.VehicleRecord{
+		PlateNumber: "湘A11111", RFIDTag: "E280001",
+		EntryTime: time.Now().Add(-5 * time.Minute),
+	}
+	if err := global.GVA_DB.Create(&rec).Error; err != nil {
+		t.Fatalf("造会话数据失败: %v", err)
+	}
+	svc := &PassageService{}
+	dir, err := svc.inferInoutDirection(common.PassageRequest{RFIDTag: "E280001"})
+	if err != nil {
+		t.Fatalf("停留已满最小值应推断为出场,却返回错误: %v", err)
+	}
+	if dir != "out" {
+		t.Fatalf("期望方向 out,实际 %s", dir)
+	}
+}
+
+func TestInferInoutDirection_DuplicateReadWithinMinStayIgnored(t *testing.T) {
+	newInferTestDB(t)
+	rec := dao.VehicleRecord{
+		PlateNumber: "湘A22222", RFIDTag: "E280002",
+		EntryTime: time.Now().Add(-30 * time.Second),
+	}
+	if err := global.GVA_DB.Create(&rec).Error; err != nil {
+		t.Fatalf("造会话数据失败: %v", err)
+	}
+	svc := &PassageService{}
+	_, err := svc.inferInoutDirection(common.PassageRequest{RFIDTag: "E280002"})
+	if err == nil || !strings.Contains(err.Error(), "重复读卡忽略") {
+		t.Fatalf("最小停留内重复读卡应被忽略,实际: %v", err)
+	}
+}
+
+func TestInferInoutDirection_AmbiguousSessionsFailSafe(t *testing.T) {
+	newInferTestDB(t)
+	now := time.Now().Add(-10 * time.Minute)
+	for _, rec := range []dao.VehicleRecord{
+		{PlateNumber: "湘A33333", RFIDTag: "E280003", EntryTime: now},
+		{PlateNumber: "湘A33334", RFIDTag: "E280003", EntryTime: now},
+	} {
+		if err := global.GVA_DB.Create(&rec).Error; err != nil {
+			t.Fatalf("造会话数据失败: %v", err)
+		}
+	}
+	svc := &PassageService{}
+	_, err := svc.inferInoutDirection(common.PassageRequest{RFIDTag: "E280003"})
+	if err == nil || !strings.Contains(err.Error(), "方向推断失败") {
+		t.Fatalf("同卡多会话应保错退出,实际: %v", err)
+	}
+}