service_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. package service
  2. import (
  3. "fmt"
  4. "strings"
  5. "testing"
  6. "time"
  7. "github.com/glebarez/sqlite"
  8. "github.com/stretchr/testify/require"
  9. "gorm.io/gorm"
  10. "wails-app/internal/dao"
  11. "wails-app/internal/global"
  12. "wails-app/internal/modules/incident/repository"
  13. )
  14. func setupIncidentTest(t *testing.T) *IncidentService {
  15. t.Helper()
  16. dsn := fmt.Sprintf("file:incident-%d?mode=memory&cache=shared", time.Now().UnixNano())
  17. db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
  18. require.NoError(t, err)
  19. sqlDB, err := db.DB()
  20. require.NoError(t, err)
  21. t.Cleanup(func() {
  22. _ = sqlDB.Close()
  23. if global.GVA_DB == db {
  24. global.GVA_DB = nil
  25. }
  26. })
  27. global.GVA_DB = db
  28. require.NoError(t, db.AutoMigrate(
  29. &dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.SysUser{}, &dao.ParkingLot{},
  30. &dao.VehicleRecord{}, &dao.DigitalTicket{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{},
  31. ))
  32. return NewIncidentService()
  33. }
  34. func countIncidents(t *testing.T) int64 {
  35. var count int64
  36. require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error)
  37. return count
  38. }
  39. func TestIncidentFullLifecycle(t *testing.T) {
  40. svc := setupIncidentTest(t)
  41. // 人工上报 → pending
  42. created, err := svc.CreateIncident(CreateIncidentRequest{
  43. Category: CategoryNoEntryExit, Description: "无牌车无入场记录出场", PlateNumber: "SU-001",
  44. }, 1)
  45. require.NoError(t, err)
  46. require.Equal(t, StatusPending, created.Status)
  47. require.Equal(t, SourceManual, created.Source)
  48. require.Equal(t, LevelCritical, created.Level) // 分类默认等级
  49. require.True(t, strings.HasPrefix(created.IncidentNo, "INC"))
  50. // pending → processing
  51. processing, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
  52. require.NoError(t, err)
  53. require.Equal(t, StatusProcessing, processing.Status)
  54. require.Equal(t, uint(2), processing.HandlerID)
  55. // processing → resolved(缺处置方式拒绝)
  56. _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleRemark: "已核实"}, 2, true)
  57. require.Error(t, err)
  58. // 缺备注拒绝
  59. _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleType: HandleReset}, 2, true)
  60. require.Error(t, err)
  61. // processing → resolved(合法)
  62. resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
  63. ToStatus: StatusResolved, HandleType: HandleReset, HandleRemark: "已人工核实放行",
  64. }, 2, true)
  65. require.NoError(t, err)
  66. require.Equal(t, StatusResolved, resolved.Status)
  67. require.NotNil(t, resolved.HandledAt)
  68. require.Equal(t, HandleReset, resolved.HandleType)
  69. require.Contains(t, resolved.EventLog, "resolved")
  70. // resolved → closed
  71. closed, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed, HandleRemark: "关闭"}, 2, true)
  72. require.NoError(t, err)
  73. require.Equal(t, StatusClosed, closed.Status)
  74. // closed → pending(重新打开)
  75. reopened, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusPending, HandleRemark: "复核中"}, 2, true)
  76. require.NoError(t, err)
  77. require.Equal(t, StatusPending, reopened.Status)
  78. }
  79. func TestIncidentInvalidTransitions(t *testing.T) {
  80. svc := setupIncidentTest(t)
  81. created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
  82. require.NoError(t, err)
  83. // pending 不允许直接 closed
  84. _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed}, 2, true)
  85. require.Error(t, err)
  86. // 未知目标状态
  87. _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: "unknown"}, 2, true)
  88. require.Error(t, err)
  89. // 不存在的记录
  90. _, err = svc.TransitionIncident(99999, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
  91. require.ErrorIs(t, err, ErrIncidentNotFound)
  92. }
  93. func TestForceFreeRequiresAdminAndValidAmount(t *testing.T) {
  94. svc := setupIncidentTest(t)
  95. created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
  96. require.NoError(t, err)
  97. // 非管理员禁止强制免费
  98. _, err = svc.TransitionIncident(created.ID, TransitionRequest{
  99. ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
  100. }, 2, false)
  101. require.Error(t, err)
  102. require.Contains(t, err.Error(), "管理员权限")
  103. // 负金额拒绝
  104. _, err = svc.TransitionIncident(created.ID, TransitionRequest{
  105. ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: -1,
  106. }, 2, true)
  107. require.Error(t, err)
  108. // 管理员 + 合法金额
  109. resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
  110. ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
  111. }, 2, true)
  112. require.NoError(t, err)
  113. require.Equal(t, 20.0, resolved.ForceFreeAmount)
  114. }
  115. func TestRecordIncidentBestEffortAndDedup(t *testing.T) {
  116. svc := setupIncidentTest(t)
  117. // 正常记录
  118. id := svc.RecordIncident(RecordIncidentRequest{
  119. Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
  120. PlateNumber: "SU-001", Description: "开闸失败", Detail: "timeout",
  121. })
  122. require.NotZero(t, id)
  123. require.Equal(t, int64(1), countIncidents(t))
  124. // 同设备同车 5 分钟内重复 → 去重
  125. id2 := svc.RecordIncident(RecordIncidentRequest{
  126. Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
  127. PlateNumber: "SU-001", Description: "开闸失败",
  128. })
  129. require.Zero(t, id2)
  130. require.Equal(t, int64(1), countIncidents(t))
  131. // 不同设备 → 生成
  132. id3 := svc.RecordIncident(RecordIncidentRequest{
  133. Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-02",
  134. PlateNumber: "SU-001", Description: "开闸失败",
  135. })
  136. require.NotZero(t, id3)
  137. // 无车辆标识 → 不去重
  138. id4 := svc.RecordIncident(RecordIncidentRequest{
  139. Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
  140. Description: "开闸失败",
  141. })
  142. require.NotZero(t, id4)
  143. // 数据库未初始化(模拟 DB 故障)→ best-effort 返回 0 不 panic
  144. global.GVA_DB = nil
  145. id5 := svc.RecordIncident(RecordIncidentRequest{
  146. Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "GATE-X", Description: "离线",
  147. })
  148. require.Zero(t, id5)
  149. }
  150. func TestDeviceOfflineDedupAndResolve(t *testing.T) {
  151. svc := setupIncidentTest(t)
  152. // 首次离线生成
  153. id := svc.RecordIncident(RecordIncidentRequest{
  154. Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
  155. })
  156. require.NotZero(t, id)
  157. // 重复离线(未关闭)→ 去重
  158. require.Zero(t, svc.RecordIncident(RecordIncidentRequest{
  159. Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
  160. }))
  161. // 设备恢复在线 → 自动关闭
  162. svc.ResolveDeviceOffline("READER-01")
  163. var record dao.IncidentRecord
  164. require.NoError(t, global.GVA_DB.First(&record, id).Error)
  165. require.Equal(t, StatusResolved, record.Status)
  166. require.Equal(t, HandleDeviceRepaired, record.HandleType)
  167. require.Contains(t, record.EventLog, "device_repaired")
  168. // 再次离线(已解决,可再生成)
  169. require.NotZero(t, svc.RecordIncident(RecordIncidentRequest{
  170. Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
  171. }))
  172. }
  173. func TestIncidentNoSequential(t *testing.T) {
  174. svc := setupIncidentTest(t)
  175. a, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
  176. require.NoError(t, err)
  177. b, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
  178. require.NoError(t, err)
  179. require.NotEqual(t, a.IncidentNo, b.IncidentNo)
  180. require.True(t, strings.HasPrefix(a.IncidentNo, "INC"+time.Now().Format("20060102")))
  181. }
  182. func TestIncidentStats(t *testing.T) {
  183. svc := setupIncidentTest(t)
  184. svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
  185. svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
  186. // 强制免费处置
  187. created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
  188. require.NoError(t, err)
  189. _, err = svc.TransitionIncident(created.ID, TransitionRequest{
  190. ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 15,
  191. }, 2, true)
  192. require.NoError(t, err)
  193. stats, err := svc.GetIncidentStats()
  194. require.NoError(t, err)
  195. require.Equal(t, int64(2), stats.Pending) // gate_failed + device_offline
  196. require.Equal(t, int64(1), stats.Resolved) // no_entry_exit 已处置
  197. require.Equal(t, int64(3), stats.TodayCreated)
  198. require.Equal(t, int64(1), stats.OpenFundRelated) // 未关闭资金类:gate_failed(no_entry_exit 已 resolved)
  199. require.Equal(t, 15.0, stats.TodayForceFreeAmount)
  200. }
  201. func TestRecordGateCommandAndLink(t *testing.T) {
  202. svc := setupIncidentTest(t)
  203. // 指令流水写入
  204. logID := svc.RecordGateCommand(GateCommandRecord{
  205. DeviceCode: "GATE-01", DeviceName: "东门道闸", Action: "open",
  206. Source: SourcePassage, SessionID: 7, Result: CommandFailed, ErrorMessage: "timeout", DurationMs: 30,
  207. })
  208. require.NotZero(t, logID)
  209. // 异常关联回填
  210. incidentID := svc.RecordIncident(RecordIncidentRequest{
  211. Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败",
  212. })
  213. require.NotZero(t, incidentID)
  214. svc.LinkIncidentToCommand(logID, incidentID)
  215. var log dao.DeviceCommandLog
  216. require.NoError(t, global.GVA_DB.First(&log, logID).Error)
  217. require.Equal(t, incidentID, log.IncidentID)
  218. var incident dao.IncidentRecord
  219. require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error)
  220. require.Equal(t, logID, incident.GateCommandID)
  221. // 空设备编码不写流水
  222. require.Zero(t, svc.RecordGateCommand(GateCommandRecord{Action: "open", Result: CommandSuccess}))
  223. }
  224. func TestIncidentAssociationEnrichment(t *testing.T) {
  225. svc := setupIncidentTest(t)
  226. lot := dao.ParkingLot{LotName: "一号停车场"}
  227. require.NoError(t, global.GVA_DB.Create(&lot).Error)
  228. channel := dao.Channel{ChannelCode: "ENTRY-01", ChannelName: "东入口", Direction: "in", ParkingLotID: lot.ID}
  229. require.NoError(t, global.GVA_DB.Create(&channel).Error)
  230. device := dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东入口闸机", DeviceType: "gate", ConnectType: "tcp", ChannelID: channel.ID, ParkingLotID: lot.ID}
  231. require.NoError(t, global.GVA_DB.Create(&device).Error)
  232. 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}
  233. require.NoError(t, global.GVA_DB.Create(&session).Error)
  234. ticket := dao.DigitalTicket{TicketNo: "TICKET-001", PlateNumber: session.PlateNumber, VehicleRecordID: session.ID, EntryTime: time.Now()}
  235. require.NoError(t, global.GVA_DB.Create(&ticket).Error)
  236. payment := dao.PaymentRecord{RecordID: session.ID, PaymentEntry: "counter", PaymentMethod: "cash", PaidAt: time.Now()}
  237. require.NoError(t, global.GVA_DB.Create(&payment).Error)
  238. created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryPaymentFailed, TicketNo: ticket.TicketNo, Description: "支付异常"}, 1)
  239. require.NoError(t, err)
  240. require.Equal(t, session.ID, created.VehicleRecordID)
  241. require.Equal(t, ticket.ID, created.DigitalTicketID)
  242. require.Equal(t, payment.ID, created.PaymentRecordID)
  243. require.Equal(t, lot.ID, created.ParkingLotID)
  244. require.Equal(t, lot.LotName, created.ParkingLotName)
  245. require.Equal(t, channel.ID, created.ChannelID)
  246. require.Equal(t, channel.ChannelName, created.ChannelName)
  247. require.Equal(t, device.DeviceName, created.DeviceName)
  248. _, err = svc.CreateIncident(CreateIncidentRequest{Category: CategoryManualRaise, VehicleRecordID: 999999, Description: "错误关联"}, 1)
  249. require.ErrorContains(t, err, "停车会话不存在")
  250. }
  251. func TestTransitionCASConcurrency(t *testing.T) {
  252. svc := setupIncidentTest(t)
  253. created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
  254. require.NoError(t, err)
  255. // 第一条流转成功
  256. _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
  257. require.NoError(t, err)
  258. // 基于陈旧状态直接 CAS 更新(模拟并发竞争中落后的一方)→ 不生效
  259. updated, err := svc.repo.UpdateStateIfCurrent(created.ID, StatusPending, StatusProcessing,
  260. map[string]interface{}{"handler_id": 3})
  261. require.NoError(t, err)
  262. require.False(t, updated)
  263. // 基于当前状态 CAS 更新 → 生效
  264. updated, err = svc.repo.UpdateStateIfCurrent(created.ID, StatusProcessing, StatusResolved,
  265. map[string]interface{}{"handle_type": HandleReset, "handle_remark": "并发竞争后生效"})
  266. require.NoError(t, err)
  267. require.True(t, updated)
  268. }
  269. func TestIncidentListFilter(t *testing.T) {
  270. svc := setupIncidentTest(t)
  271. svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
  272. svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
  273. // 按分类筛选
  274. list, total, err := svc.ListIncidents(repository.IncidentListQuery{Category: CategoryDeviceOffline})
  275. _ = list
  276. require.NoError(t, err)
  277. require.Equal(t, int64(1), total)
  278. // 分页默认值
  279. list, total, err = svc.ListIncidents(repository.IncidentListQuery{})
  280. require.NoError(t, err)
  281. require.Equal(t, int64(2), total)
  282. require.Len(t, list, 2)
  283. }