| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- package service
- import (
- "fmt"
- "strings"
- "testing"
- "time"
- "github.com/glebarez/sqlite"
- "github.com/stretchr/testify/require"
- "gorm.io/gorm"
- "wails-app/internal/dao"
- "wails-app/internal/global"
- "wails-app/internal/modules/incident/repository"
- )
- func setupIncidentTest(t *testing.T) *IncidentService {
- t.Helper()
- dsn := fmt.Sprintf("file:incident-%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
- require.NoError(t, db.AutoMigrate(
- &dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.SysUser{}, &dao.ParkingLot{},
- &dao.VehicleRecord{}, &dao.DigitalTicket{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{},
- ))
- return NewIncidentService()
- }
- func countIncidents(t *testing.T) int64 {
- var count int64
- require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error)
- return count
- }
- func TestIncidentFullLifecycle(t *testing.T) {
- svc := setupIncidentTest(t)
- // 人工上报 → pending
- created, err := svc.CreateIncident(CreateIncidentRequest{
- Category: CategoryNoEntryExit, Description: "无牌车无入场记录出场", PlateNumber: "SU-001",
- }, 1)
- require.NoError(t, err)
- require.Equal(t, StatusPending, created.Status)
- require.Equal(t, SourceManual, created.Source)
- require.Equal(t, LevelCritical, created.Level) // 分类默认等级
- require.True(t, strings.HasPrefix(created.IncidentNo, "INC"))
- // pending → processing
- processing, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
- require.NoError(t, err)
- require.Equal(t, StatusProcessing, processing.Status)
- require.Equal(t, uint(2), processing.HandlerID)
- // processing → resolved(缺处置方式拒绝)
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleRemark: "已核实"}, 2, true)
- require.Error(t, err)
- // 缺备注拒绝
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleType: HandleReset}, 2, true)
- require.Error(t, err)
- // processing → resolved(合法)
- resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
- ToStatus: StatusResolved, HandleType: HandleReset, HandleRemark: "已人工核实放行",
- }, 2, true)
- require.NoError(t, err)
- require.Equal(t, StatusResolved, resolved.Status)
- require.NotNil(t, resolved.HandledAt)
- require.Equal(t, HandleReset, resolved.HandleType)
- require.Contains(t, resolved.EventLog, "resolved")
- // resolved → closed
- closed, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed, HandleRemark: "关闭"}, 2, true)
- require.NoError(t, err)
- require.Equal(t, StatusClosed, closed.Status)
- // closed → pending(重新打开)
- reopened, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusPending, HandleRemark: "复核中"}, 2, true)
- require.NoError(t, err)
- require.Equal(t, StatusPending, reopened.Status)
- }
- func TestIncidentInvalidTransitions(t *testing.T) {
- svc := setupIncidentTest(t)
- created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
- require.NoError(t, err)
- // pending 不允许直接 closed
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed}, 2, true)
- require.Error(t, err)
- // 未知目标状态
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: "unknown"}, 2, true)
- require.Error(t, err)
- // 不存在的记录
- _, err = svc.TransitionIncident(99999, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
- require.ErrorIs(t, err, ErrIncidentNotFound)
- }
- func TestForceFreeRequiresAdminAndValidAmount(t *testing.T) {
- svc := setupIncidentTest(t)
- created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
- require.NoError(t, err)
- // 非管理员禁止强制免费
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{
- ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
- }, 2, false)
- require.Error(t, err)
- require.Contains(t, err.Error(), "管理员权限")
- // 负金额拒绝
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{
- ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: -1,
- }, 2, true)
- require.Error(t, err)
- // 管理员 + 合法金额
- resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
- ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
- }, 2, true)
- require.NoError(t, err)
- require.Equal(t, 20.0, resolved.ForceFreeAmount)
- }
- func TestRecordIncidentBestEffortAndDedup(t *testing.T) {
- svc := setupIncidentTest(t)
- // 正常记录
- id := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
- PlateNumber: "SU-001", Description: "开闸失败", Detail: "timeout",
- })
- require.NotZero(t, id)
- require.Equal(t, int64(1), countIncidents(t))
- // 同设备同车 5 分钟内重复 → 去重
- id2 := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
- PlateNumber: "SU-001", Description: "开闸失败",
- })
- require.Zero(t, id2)
- require.Equal(t, int64(1), countIncidents(t))
- // 不同设备 → 生成
- id3 := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-02",
- PlateNumber: "SU-001", Description: "开闸失败",
- })
- require.NotZero(t, id3)
- // 无车辆标识 → 不去重
- id4 := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
- Description: "开闸失败",
- })
- require.NotZero(t, id4)
- // 数据库未初始化(模拟 DB 故障)→ best-effort 返回 0 不 panic
- global.GVA_DB = nil
- id5 := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "GATE-X", Description: "离线",
- })
- require.Zero(t, id5)
- }
- func TestDeviceOfflineDedupAndResolve(t *testing.T) {
- svc := setupIncidentTest(t)
- // 首次离线生成
- id := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
- })
- require.NotZero(t, id)
- // 重复离线(未关闭)→ 去重
- require.Zero(t, svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
- }))
- // 设备恢复在线 → 自动关闭
- svc.ResolveDeviceOffline("READER-01")
- var record dao.IncidentRecord
- require.NoError(t, global.GVA_DB.First(&record, id).Error)
- require.Equal(t, StatusResolved, record.Status)
- require.Equal(t, HandleDeviceRepaired, record.HandleType)
- require.Contains(t, record.EventLog, "device_repaired")
- // 再次离线(已解决,可再生成)
- require.NotZero(t, svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
- }))
- }
- func TestIncidentNoSequential(t *testing.T) {
- svc := setupIncidentTest(t)
- a, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
- require.NoError(t, err)
- b, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
- require.NoError(t, err)
- require.NotEqual(t, a.IncidentNo, b.IncidentNo)
- require.True(t, strings.HasPrefix(a.IncidentNo, "INC"+time.Now().Format("20060102")))
- }
- func TestIncidentStats(t *testing.T) {
- svc := setupIncidentTest(t)
- svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
- svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
- // 强制免费处置
- created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
- require.NoError(t, err)
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{
- ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 15,
- }, 2, true)
- require.NoError(t, err)
- stats, err := svc.GetIncidentStats()
- require.NoError(t, err)
- require.Equal(t, int64(2), stats.Pending) // gate_failed + device_offline
- require.Equal(t, int64(1), stats.Resolved) // no_entry_exit 已处置
- require.Equal(t, int64(3), stats.TodayCreated)
- require.Equal(t, int64(1), stats.OpenFundRelated) // 未关闭资金类:gate_failed(no_entry_exit 已 resolved)
- require.Equal(t, 15.0, stats.TodayForceFreeAmount)
- }
- func TestRecordGateCommandAndLink(t *testing.T) {
- svc := setupIncidentTest(t)
- // 指令流水写入
- logID := svc.RecordGateCommand(GateCommandRecord{
- DeviceCode: "GATE-01", DeviceName: "东门道闸", Action: "open",
- Source: SourcePassage, SessionID: 7, Result: CommandFailed, ErrorMessage: "timeout", DurationMs: 30,
- })
- require.NotZero(t, logID)
- // 异常关联回填
- incidentID := svc.RecordIncident(RecordIncidentRequest{
- Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败",
- })
- require.NotZero(t, incidentID)
- svc.LinkIncidentToCommand(logID, incidentID)
- var log dao.DeviceCommandLog
- require.NoError(t, global.GVA_DB.First(&log, logID).Error)
- require.Equal(t, incidentID, log.IncidentID)
- var incident dao.IncidentRecord
- require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error)
- require.Equal(t, logID, incident.GateCommandID)
- // 空设备编码不写流水
- require.Zero(t, svc.RecordGateCommand(GateCommandRecord{Action: "open", Result: CommandSuccess}))
- }
- func TestIncidentAssociationEnrichment(t *testing.T) {
- svc := setupIncidentTest(t)
- lot := dao.ParkingLot{LotName: "一号停车场"}
- require.NoError(t, global.GVA_DB.Create(&lot).Error)
- channel := dao.Channel{ChannelCode: "ENTRY-01", ChannelName: "东入口", Direction: "in", ParkingLotID: lot.ID}
- require.NoError(t, global.GVA_DB.Create(&channel).Error)
- device := dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东入口闸机", DeviceType: "gate", ConnectType: "tcp", ChannelID: channel.ID, ParkingLotID: lot.ID}
- require.NoError(t, global.GVA_DB.Create(&device).Error)
- session := dao.VehicleRecord{PlateNumber: "粤A12345", EntryTime: time.Now(), ParkingLotID: lot.ID, EntryChannelID: channel.ID, EntryChannelCode: channel.ChannelCode, EntryChannelName: channel.ChannelName, EntryDeviceCode: device.DeviceCode, EntryDeviceName: device.DeviceName}
- require.NoError(t, global.GVA_DB.Create(&session).Error)
- ticket := dao.DigitalTicket{TicketNo: "TICKET-001", PlateNumber: session.PlateNumber, VehicleRecordID: session.ID, EntryTime: time.Now()}
- require.NoError(t, global.GVA_DB.Create(&ticket).Error)
- payment := dao.PaymentRecord{RecordID: session.ID, PaymentEntry: "counter", PaymentMethod: "cash", PaidAt: time.Now()}
- require.NoError(t, global.GVA_DB.Create(&payment).Error)
- created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryPaymentFailed, TicketNo: ticket.TicketNo, Description: "支付异常"}, 1)
- require.NoError(t, err)
- require.Equal(t, session.ID, created.VehicleRecordID)
- require.Equal(t, ticket.ID, created.DigitalTicketID)
- require.Equal(t, payment.ID, created.PaymentRecordID)
- require.Equal(t, lot.ID, created.ParkingLotID)
- require.Equal(t, lot.LotName, created.ParkingLotName)
- require.Equal(t, channel.ID, created.ChannelID)
- require.Equal(t, channel.ChannelName, created.ChannelName)
- require.Equal(t, device.DeviceName, created.DeviceName)
- _, err = svc.CreateIncident(CreateIncidentRequest{Category: CategoryManualRaise, VehicleRecordID: 999999, Description: "错误关联"}, 1)
- require.ErrorContains(t, err, "停车会话不存在")
- }
- func TestTransitionCASConcurrency(t *testing.T) {
- svc := setupIncidentTest(t)
- created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
- require.NoError(t, err)
- // 第一条流转成功
- _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
- require.NoError(t, err)
- // 基于陈旧状态直接 CAS 更新(模拟并发竞争中落后的一方)→ 不生效
- updated, err := svc.repo.UpdateStateIfCurrent(created.ID, StatusPending, StatusProcessing,
- map[string]interface{}{"handler_id": 3})
- require.NoError(t, err)
- require.False(t, updated)
- // 基于当前状态 CAS 更新 → 生效
- updated, err = svc.repo.UpdateStateIfCurrent(created.ID, StatusProcessing, StatusResolved,
- map[string]interface{}{"handle_type": HandleReset, "handle_remark": "并发竞争后生效"})
- require.NoError(t, err)
- require.True(t, updated)
- }
- func TestIncidentListFilter(t *testing.T) {
- svc := setupIncidentTest(t)
- svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
- svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
- // 按分类筛选
- list, total, err := svc.ListIncidents(repository.IncidentListQuery{Category: CategoryDeviceOffline})
- _ = list
- require.NoError(t, err)
- require.Equal(t, int64(1), total)
- // 分页默认值
- list, total, err = svc.ListIncidents(repository.IncidentListQuery{})
- require.NoError(t, err)
- require.Equal(t, int64(2), total)
- require.Len(t, list, 2)
- }
|