Procházet zdrojové kódy

feat: 新增异常处理(incident)模块与闸机/通行异常联动

- 新增 incident 模块:异常记录/处置(dao/种子/模块服务/前端API)
- 闸机与通行入口接入异常联动(passage/gate 异常分支处理)
- UHF 读卡器异常检测与处置
- 新增异常处理设计文档与 incident_hook 测试

Co-Authored-By: Claude <noreply@anthropic.com>
lq před 2 týdny
rodič
revize
fc96282b6c

+ 21 - 15
doc/功能缺失.md

@@ -11,7 +11,7 @@
 距离正式运营仍缺少三个关键闭环:
 
 1. POS、扫码、中央缴费机等真实资金交易。
-2. 业务异常、设备异常的记录和处置。
+2. ~~业务异常、设备异常的记录和处置~~(已于 2026-08-12 完成核心闭环,见 2.2 节与 `doc/异常处置设计.md`)
 3. 摄像头、道闸、地感、LED 屏的状态反馈和联动。
 
 “存在支付方式配置”不等同于“已经接入真实支付”。当前非现金支付主要用于业务流程确认,尚未连接实际支付终端或支付网关。
@@ -38,25 +38,31 @@
 - 支付回调、重复通知和主动查询必须幂等。
 - 实现退款、撤销、冲正、失败补偿和财务对账。
 
-### 2.2 异常报表和异常处置未实现
+### 2.2 异常报表和异常处置(核心闭环已完成,扩展点待接入)
 
-**当前情况**
+**当前情况(2026-08-12 更新)**
 
-- `frontend/src/view/report/sheet.vue` 只有静态表单和空表格。
-- 没有异常事件实体、后端接口、查询、处置状态和审计记录。
+- 已实现 `incident_record` 异常事件表 + `internal/modules/incident/` 模块,详见 `doc/异常处置设计.md`。
+- 自动埋点已接入:统一通行五类(开闸失败、重复入场、黑名单拦截、满位拒绝、无入场记录出场)、人工抬杆/关闸(`reason` 可选参数)、设备离线(在线→离线转换,重连自动关闭)。
+- 支持待处理、处理中、已解决、已关闭四状态流转(CAS 条件更新)、处理方式与备注、`event_log` 时间线审计。
+- 权限分级:查看/上报面向全部角色;处置仅 888/9527;强制免费仅 888(前后端双校验)。
+- `device_command_log` 指令流水已实现:记录每次道闸开/关命令(来源、操作员、会话、结果、耗时),开闸失败时与 `gate_failed` 异常关联。
+- 前端 `view/report/sheet.vue` 已重写:统计行 + 筛选 + 分页 + 详情时间线 + 处置表单 + 人工上报。
+- 自动化测试已覆盖:状态机流转、CAS 并发、去重、best-effort、编号生成、统计、开闸失败埋点集成、路由鉴权。
 
-**应覆盖的异常**
+**仍待接入的异常类型(埋点为预留扩展点)**
 
-- 无牌车、车牌识别冲突、重复入场、无入场记录出场。
-- 丢票、票据打印失败、支付失败、支付状态不确定。
-- 人工抬杆、强制免费、开闸失败、设备离线。
-- 余位为负、会话与数字票状态不一致。
+- 无牌车:票机无牌取票已有唯一会话身份,无独立异常分类,可后续在票机链路补充。
+- 车牌识别冲突:依赖摄像头 OCR 接入(P2),暂无可埋点。
+- 票据打印失败:待打印任务持久化(P0)落地后接入 `print_failed`。
+- 支付失败/支付状态不确定:待支付订单层(P0)落地后接入 `payment_failed`/`payment_uncertain`。
+- 余位为负、会话与数字票状态不一致:依赖对账任务,未实现自动检测。
 
-**完成标准**
+**完成标准(对照)**
 
-- 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员。
-- 支持待处理、处理中、已解决、已关闭状态和处理备注。
-- 人工放行和资金相关异常需要权限控制与完整审计。
+- 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员。
+- 支持待处理、处理中、已解决、已关闭状态和处理备注。
+- ✅ 人工放行和资金相关异常有权限控制与完整审计。
 
 ### 2.3 票机打印失败补偿未完成
 
@@ -162,7 +168,7 @@
 
 ## 6. 推荐实施顺序
 
-1. 建立异常事件、人工处置和设备指令流水。
+1. ~~建立异常事件、人工处置和设备指令流水~~(已于 2026-08-12 完成,见 2.2)
 2. 建立支付订单层,接入 POS、扫码和中央缴费机。
 3. 重构多日计费及缴费后离场超时规则。
 4. 完成月卡事务、退款、交接班和财务对账。

+ 398 - 0
doc/异常处置设计.md

@@ -0,0 +1,398 @@
+# 异常处置闭环设计
+
+> 更新日期:2026-08-12
+> 状态说明:~~本文为设计文档~~ **已于 2026-08-12 按本文实现完毕**(后端模块、自动埋点、指令流水、权限种子、前端页面、自动化测试均落地,`go test ./internal/service/... ./internal/modules/... ./internal/initialize` 与前端 `npm run build` 通过)。文中标注(预留)的项为后续扩展点,不在当前范围;标注(建议包含)的设备指令流水已实现。
+> 代码位置:设计基于 2026-08-12 当前代码(`internal/service/parking/passage.go`、`internal/service/parking/gate.go`、`internal/modules/` 等)。
+
+## 1. 现状与问题
+
+### 1.1 异常只存在于 HTTP 响应
+
+- 统一通行 `HandlePassage` 中,业务落库成功但开闸失败时返回 `result + err`(`passage.go:172-176` 入场、`passage.go:231-235` 出场),`GateStatus="failed"`、`BusinessCompleted=true`。该"业务已完成、设备动作失败"的半完成状态**不落库**,重启后无从追踪。
+- 票机打印失败(`printer` 模块)同样只写在响应里(`PrintStatus="failed"`),无持久化任务、重试、重打和作废机制(重打机制属 P0 打印任务持久化,本文不覆盖,但打印失败事件可先挂入异常表,见 7.4)。
+
+### 1.2 无任何异常实体
+
+`vehicle_record`、`payment_record`、`shift_record`、`printer`、`camera`、`uhf_reader` 均无异常标记、重试状态或补偿状态字段。`frontend/src/view/report/sheet.vue` 是纯静态占位页(无数据绑定、无 API 调用),菜单 27"异常报表"已存在并授权 618/888/9527(`internal/initialize/seed.go:135`)。
+
+### 1.3 设备状态三套并存且不一致
+
+| 状态来源 | 位置 | 问题 |
+| --- | --- | --- |
+| DB `uhf_reader.status` | 读协程 `ReadData` 出错置 offline、重连成功置 online | 只覆盖 UHF 读卡器,与内存态脱节 |
+| 内存 `DeviceManager` / `ConnManager` | `GetGateRuntimeStatus`(`gate.go:104-114`) | 仪表盘设备状态据此统计,重启即失效 |
+| `LastOnlineTime` | `internal/dao/uhf_reader.go` | 死字段,全项目无人写入 |
+
+模拟道闸(`config.yaml` 的 `gate-simulator: true`)下所有设备 `IsGateConnected` 恒真,设备真实掉线无法被现有链路感知。
+
+### 1.4 人工抬杆无业务审计
+
+`POST /parking/gate/open|close`(`internal/api/v1/parking/gate.go`)仅调用 `OpenGateByDeviceCode`,不关联会话、不记录操作原因。OperationRecord 中间件只记录原始 HTTP 请求,无法表达"为什么抬杆、抬杆对应哪辆车/哪个会话"。
+
+### 1.5 可复用的现成机制
+
+| 机制 | 位置 | 复用方式 |
+| --- | --- | --- |
+| 数字票事件日志 | `digital_ticket.event_log`(JSON 事件数组,状态机每次转换追加) | 异常表采用同样的 `event_log` 字段记录状态流转时间线 |
+| 操作审计中间件 | `internal/middleware/operation.go`,PrivateGroup 全量挂载 | API 层的处置请求自动留痕,异常表无需重复记录请求体 |
+| 状态机 CAS 更新 | `digital-ticket` 模块 `UpdateStateIfCurrentTx` 模式 | 异常状态流转按当前状态条件更新,防并发覆盖 |
+| 模块化结构 | `internal/modules/payment|shift`(api.go + service/ + repository/) | 新模块完全仿照此结构 |
+| 权限幂等种子 | `internal/initialize/operation_permission_seed.go`(sys_apis + casbin_rule) | 新增 API 权限仿照 `EnsureOperationPermissions` 实现 |
+| 前端蓝本 | `view/report/paymentRecord.vue`(统计行 + 筛选 + 表格 + 分页) | 异常处置页面直接复用该结构 |
+
+## 2. 总体设计
+
+### 2.1 核心概念:异常事件即工单
+
+一次"需要被记录并可能被人工介入"的事件,事件本身与处置过程合一,落在同一张表:
+
+- **自动产生**:业务/设备链路失败时由埋点自动落库(如开闸失败、设备离线)。
+- **人工产生**:操作员在页面主动上报(如无牌车人工处理、丢票)。
+- **可处置**:记录经历 `pending → processing → resolved → closed` 状态流转,处置信息(方式、金额、备注、处理人)随记录保存。
+- **可审计**:`event_log` 保存每次流转的事件时间线;API 请求由 OperationRecord 中间件留痕;人工放行与资金相关处置有独立权限。
+
+### 2.2 设计原则
+
+1. **best-effort 埋点**:自动埋点失败只记日志,绝不影响业务主流程的返回值与事务结果。
+2. **状态机 + CAS**:状态流转使用条件更新(仿数字票),并发时以行级条件保证只有一次生效。
+3. **增量接入**:埋点是在既有链路上的纯增量调用,不改变现有业务返回值和事务边界。
+4. **权限分级**:查看/上报面向全部角色;处置(状态流转)面向管理员;资金类处置仅最高管理员。
+5. **事件与工单不拆分**:v1 不建"事件表 + 工单表"两套,避免双写一致性问题;同一异常类型的去重由业务规则控制(见 5.4)。
+
+### 2.3 模块位置与边界
+
+新增模块 `internal/modules/incident/`,表模型 `internal/dao/incident.go`:
+
+```text
+internal/modules/incident/
+├─ api.go                  # HTTP Handler + SetupIncidentRouter
+├─ service/
+│  ├─ service.go           # 领域校验、状态机、埋点入口、统计
+│  └─ service_test.go
+└─ repository/
+   └─ repo.go              # 分页查询、条件更新、去重查询
+```
+
+模块边界:`incident` 只记录异常事实与处置过程,**不修改**会话、数字票、支付流水和道闸状态;需要联动修正业务数据时(如强制免费后补结算),由调用方在业务事务内先完成修正、再调用埋点记录,异常表不参与业务事务。
+
+## 3. 数据模型
+
+### 3.1 异常事件表 `incident_record`
+
+```go
+type IncidentRecord struct {
+    global.GVA_MODEL
+    IncidentNo    string     // 唯一编号,如 INC20260812-0001
+    Category      string     // 异常分类(见 3.2 枚举)
+    Source        string     // 来源:passage/manual/device/payment/printer/system
+    Level         string     // 等级:info/warning/critical
+    Status        string     // 状态:pending/processing/resolved/closed
+    VehicleRecordID uint     // 关联停车会话(可选)
+    TicketNo      string     // 关联数字票(可选)
+    PlateNumber   string     // 车牌(可选)
+    RFIDTag       string     // RFID(可选)
+    ParkingLotID  uint       // 停车场(可选)
+    ChannelID     uint       // 通道(可选)
+    ChannelCode   string     // 通道编码快照
+    DeviceCode    string     // 设备编码(可选)
+    OperatorID    uint       // 上报人/创建人
+    Description   string     // 异常描述(人读)
+    Detail        string     // JSON 上下文(请求参数、错误信息、设备状态等)
+    HandlerID     uint       // 处理人
+    HandledAt     *time.Time // 处理时间
+    HandleType    string     // 处置方式(见 3.3)
+    HandleRemark  string     // 处置备注(必填)
+    ForceFreeAmount float64  // 强制免费金额(资金类处置时记录)
+    EventLog      string     // JSON 事件时间线(同 digital_ticket.event_log)
+}
+```
+
+字段说明:
+
+- `incident_no` 建立唯一索引,生成规则 `INC + yyyyMMdd + 当日序号`(SQLite 下单事务内 `count + 1` 生成,冲突时重试,避免自增回绕)。
+- `category + device_code + status` 建立联合索引,供去重查询和页面筛选。
+- `Detail` 存 JSON 字符串(如失败请求、错误原文),大小受控(截断至 1000 字符),不存敏感字段(密码、支付卡号)。
+- 资金相关:`force_free_amount` 仅 `force_free` 处置时写入;其余处置方式必须为 0。
+
+### 3.2 异常分类枚举 `Category`
+
+| 值 | 中文 | 默认等级 | v1 埋点源 |
+| --- | --- | --- | --- |
+| `gate_failed` | 开闸失败 | critical | 统一通行(7.1) |
+| `duplicate_entry` | 重复入场 | warning | 统一通行(7.1) |
+| `blacklist` | 黑名单拦截 | warning | 统一通行(7.1) |
+| `lot_full` | 满位拒绝 | info | 统一通行(7.1) |
+| `no_entry_exit` | 无入场记录出场 | critical | 统一通行(7.1) |
+| `manual_raise` | 人工抬杆/关闸 | warning | 人工抬杆 API(7.2) |
+| `device_offline` | 设备离线 | warning | UHF 状态转换(7.3) |
+| `print_failed` | 打印失败 | warning | (预留,打印任务持久化落地后接入) |
+| `payment_failed` | 支付失败 | warning | (预留,支付订单层落地后接入) |
+| `payment_uncertain` | 支付状态不确定 | critical | (预留,外部支付回调接入后接入) |
+
+### 3.3 处置方式枚举 `HandleType`
+
+| 值 | 中文 | 说明 |
+| --- | --- | --- |
+| `manual_gate` | 人工抬杆放行 | 对应 manual_raise 类异常,需备注原因 |
+| `force_free` | 强制免费 | 资金类,必须填金额,仅最高管理员 |
+| `reset` | 状态重置/修正 | 修正会话、票或余位数据后关闭 |
+| `device_repaired` | 设备修复 | 设备恢复后由系统自动或人工确认 |
+| `reprint` | 重新打印 | 预留,配合打印任务持久化 |
+| `ignore` | 误报忽略 | 需备注误报原因 |
+| `other` | 其他 | 需备注 |
+
+### 3.4 指令流水表 `device_command_log`(建议包含,可裁剪)
+
+配合人工抬杆审计与开闸失败追踪,记录每次道闸开/关命令:
+
+```go
+type DeviceCommandLog struct {
+    global.GVA_MODEL
+    DeviceCode   string     // 设备编码
+    DeviceName   string     // 设备名称快照
+    Action       string     // open / close
+    Source       string     // passage(通行链路)/ manual(人工操作)/ test
+    OperatorID   uint       // 操作员(manual 时必填)
+    SessionID    uint       // 关联停车会话(可选)
+    IncidentID   uint       // 关联异常事件(可选,开闸失败时关联)
+    Result       string     // success / failed / timeout
+    ErrorMessage string     // 失败原因
+    DurationMs   int64      // 耗时
+}
+```
+
+- 统一埋点在 `OpenGateByDeviceCode` / `CloseGateByDeviceCode`(`gate.go:117-144`)封装处,所有开闸/关闸(通行、人工、测试)都经过此处,无需改动各调用点。
+- 开闸失败时:指令流水写 `failed`,同时异常埋点生成 `gate_failed`,二者通过 `incident_id` 关联。
+- 裁剪此项不影响异常事件主链路,仅损失"命令级"审计粒度。
+
+## 4. 状态机
+
+### 4.1 状态与流转
+
+```text
+pending ──> processing ──> resolved ──> closed
+   ↑                          │
+   └────────  reopen ─────────┘
+```
+
+| 流转 | 约束 |
+| --- | --- |
+| pending → processing | 记录开始处理,写入 `handler_id` |
+| pending/processing → resolved | 必须携带 `handle_type` + `handle_remark`;`force_free` 必须带 `force_free_amount ≥ 0`;写入 `handled_at` |
+| resolved → closed | 关闭工单,可带补充备注 |
+| closed → pending(reopen) | 重新打开,可带备注 |
+
+禁止其他流转(如 pending → closed 直接关闭、resolved 回退 processing)。所有流转由 `TransitionIncident` 统一入口执行,更新采用条件语句 `UPDATE ... SET status=? WHERE id=? AND status=?`,受影响行数为 0 时返回"状态已变更"错误(CAS,仿数字票 `UpdateStateIfCurrentTx`)。
+
+### 4.2 处置动作与状态的映射
+
+- 自动埋点创建的异常默认 `pending`。
+- `manual_raise` 人工抬杆:创建时即为 `resolved` + `handle_type=manual_gate`(原因即备注),因为动作已完成、仅需留痕;如需复核可人工 reopen。
+- `device_offline`:设备重连时,系统将同设备未关闭(pending/processing)的离线记录批量置为 `resolved` + `device_repaired`,仍保留人工确认入口。
+- 资金类处置(`force_free`)在服务层校验角色权限(见 6.2)。
+
+## 5. 服务层设计(`internal/modules/incident/service/`)
+
+### 5.1 方法清单
+
+| 方法 | 用途 |
+| --- | --- |
+| `RecordIncident(ctx RecordIncidentRequest)` | 自动埋点入口,best-effort:内部错误只记日志并返回 nil,不抛给业务调用方 |
+| `CreateIncident(req)` | 人工上报(API 层调用),返回完整记录 |
+| `ListIncidents(q)` | 分页 + 多条件筛选(状态/分类/等级/来源/车牌/票号/停车场/设备/日期范围/上报人) |
+| `GetIncident(id)` | 详情(含 event_log 解析后的时间线) |
+| `TransitionIncident(id, req)` | 状态流转 + 处置信息,CAS 更新 |
+| `GetIncidentStats()` | 统计:按状态计数(待处理/处理中/已解决/已关闭)、今日新增、资金类待处理数量 |
+| `ResolveDeviceOffline(deviceCode)` | 设备重连时自动关闭离线异常(7.3 调用) |
+
+### 5.2 `RecordIncident` 的入参
+
+```go
+type RecordIncidentRequest struct {
+    Category   string
+    Source     string
+    Level      string        // 缺省按分类默认等级
+    VehicleRecordID uint
+    TicketNo   string
+    PlateNumber string
+    RFIDTag    string
+    ParkingLotID uint
+    ChannelID  uint
+    ChannelCode string
+    DeviceCode string
+    OperatorID uint
+    Description string
+    Detail     interface{}   // 序列化为 JSON 存入 Detail
+}
+```
+
+### 5.3 查询
+
+`repository` 采用既有三段式:`db.Count(&total)` → 默认值兜底(page=1、page_size=10)→ `Order("created_at DESC").Offset((page-1)*pageSize).Limit(pageSize).Scan`,返回 `(list, total, err)`;API 层包 `response.PageResult`。列表关联 `sys_users` 取上报人/处理人昵称(仿 shift repository 的 `operator_name`)。
+
+### 5.4 去重规则
+
+| 分类 | 去重规则 |
+| --- | --- |
+| `device_offline` | 同设备存在 pending/processing 的离线记录时不重复生成 |
+| `gate_failed`/`duplicate_entry` 等通行类 | 同 `device_code + plate_number/rfid + category` 在 5 分钟内不重复生成(防抖复用 `passage.go` 的 3 秒防抖窗口之上再加事件级窗口) |
+| 其他 | 不做自动去重,人工判断 |
+
+### 5.5 事务边界
+
+- 异常表写入**不参与**业务事务:业务事务提交成功后调用 `RecordIncident`(独立写)。
+- 特例:人工抬杆/关闸 API 中,命令执行与异常落库可以共用一次简单事务。命令失败时仍生成记录(见 7.2),此时分类为 `gate_failed`、状态 `pending`,备注写明人工操作失败原因,供复核。
+- `TransitionIncident` 单条更新,无需跨表事务(event_log 追加与状态更新在同一 `UPDATE` 内拼 JSON 完成)。
+
+## 6. HTTP API 与权限
+
+### 6.1 路由(前缀 `/incident`)
+
+注册于 `internal/initialize/router.go` 的 PrivateGroup(自动挂 JWT + Casbin + OperationRecord):
+
+| 方法 | 路径 | 说明 | 角色 |
+| --- | --- | --- | --- |
+| GET | `/incident/list` | 分页筛选列表 | 618/888/9527 |
+| GET | `/incident/:id` | 详情 | 618/888/9527 |
+| POST | `/incident` | 人工上报 | 618/888/9527 |
+| POST | `/incident/:id/transition` | 状态流转+处置 | 888/9527;`force_free` 仅 888 |
+| GET | `/incident/stats` | 状态/今日统计 | 618/888/9527 |
+
+角色约定(沿用现有种子):618=操作员,888=管理员,9527=超级管理员。`force_free` 资金类处置在服务层通过 `utils.GetUserID` + 角色判断二次校验(Casbin 只到路径粒度,资金类需在业务层校验权限,防止低权限角色直接调用)。
+
+### 6.2 权限种子
+
+仿 `operation_permission_seed.go` 新增 `EnsureIncidentPermissions()`:
+
+- `sys_apis` 按 `path+method` 幂等插入上表 5 个 API;
+- `casbin_rule` 给 618/888/9527 插 list/detail/create/stats 规则;给 888/9527 插 transition 规则;
+- 挂到 `SeedSystemData` 新库/旧库两个分支末尾(与 `EnsureOperationPermissions` 同位置,seed.go:23-29 与 533-538)。
+
+### 6.3 菜单与页面
+
+- 复用菜单 27"异常报表"(`view/report/sheet.vue`,已授权 618/888/9527),**不新增菜单**,仅重写页面。
+- 若需将菜单标题改为"异常处置",需在种子中增加"更新已存在菜单标题"逻辑(现有种子只插不更);v1 建议保留原标题以省去迁移,文档标题在页面内体现。
+
+## 7. 自动埋点设计(v1)
+
+### 7.1 统一通行失败(`internal/service/parking/passage.go`)
+
+| 埋点位置 | 触发条件 | 分类 | 等级 |
+| --- | --- | --- | --- |
+| `handleEntry`/`handleExit` 开闸分支(passage.go:172、231) | `OpenGateByDeviceCode` 返回错误 | `gate_failed` | critical |
+| `handleEntry` 前(passage.go:130-132) | `passageStatus[identifier]` 已在场内 | `duplicate_entry` | warning |
+| `HandlePassage` 黑名单检查(passage.go:92-95) | `isBlack == true` | `blacklist` | warning |
+| `handleEntry` 内 `VehicleEntry` 返回 `ErrParkingLotFull` | 满位拒绝 | `lot_full` | info |
+| `handleExit` 内 `ExitConfirm` 返回"无入场记录"类错误 | 无会话/丢票 | `no_entry_exit` | critical |
+
+实现要点:
+
+- 错误识别:`errors.Is(err, parkingSessionService.ErrSessionNotFound)` / `ErrSessionAlreadyOpen` / `ErrParkingLotFull` 判定分类(哨兵错误已存在,见 parking-session/service.go:20-29);`ExitConfirm` 错误中"无入场记录"需按 `errors.Is` 或错误消息兜底判定。
+- 开闸失败场景:在 `passage.go` 返回 `result+err` **之前**调用 `RecordIncident`(此时会话 ID/票号已在 `result` 中),并同时写指令流水(7.5)。
+- 埋点不改变现有返回值:`RecordIncident` 内部吞错,调用前后业务代码零改动语义。
+
+### 7.2 人工抬杆/关闸(`internal/api/v1/parking/gate.go`)
+
+- `POST /parking/gate/open|close` 请求体增加**可选** `reason` 字段(向后兼容,缺省为空)。
+- 命令执行后调用 `RecordIncident`:分类 `manual_raise`、来源 `manual`、状态直接 `resolved`、`handle_type=manual_gate`、`handle_remark=reason`,`operator_id` 从 JWT 取。
+- **命令失败分支**:不生成 `manual_raise`(动作未完成),改生成分类 `gate_failed`、来源 `manual`、状态 `pending` 的待处置记录,`handle_remark` 写明人工操作失败原因与设备状态,等待管理员复核(可能需现场处理或换设备重试)。
+- 关联上下文:若请求体带 `plate_number`/`session_id`(可选字段)则一并写入;不带也可(纯设备操作留痕)。
+- 同时写指令流水(action=open/close、source=manual、result=success/failed)。
+
+### 7.3 设备离线(`internal/service/uhf/reader.go`、`conn_manager.go`)
+
+- 埋点位置:读协程 `ReadData` 出错置 `status=offline` 的分支(reader.go:251-261)与 `conn_manager.go:72` 连接断开检测处。
+- 触发条件:**状态从在线转为离线**(避免重连抖动重复生成);配合 5.4 去重规则(同设备未关闭不重复生成)。
+- 分类 `device_offline`、来源 `device`、等级 `warning`,`device_code`/`parking_lot_id` 从设备记录取。
+- 重连成功(置 `status=online` 分支):调用 `ResolveDeviceOffline(deviceCode)`,将同设备 pending/processing 的离线记录批量置 `resolved` + `device_repaired`,备注"设备恢复在线"。
+- 离线事件同时**不动**内存 `DeviceManager` 与 `GetGateRuntimeStatus`,仅补 DB 事实与异常记录;三套状态一致性问题另立任务,不在本设计内解决(记录到 `doc/功能缺失.md` 3.6 追踪)。
+
+### 7.4 扩展点(非 v1)
+
+- **打印失败**:待打印任务持久化(P0)落地后,在打印服务失败分支调用 `RecordIncident`(分类 `print_failed`),并支持 `reprint` 处置方式重打。
+- **支付失败/不确定**:待支付订单层(P0)落地后,在订单超时、回调失败、对账差异处埋点(分类 `payment_failed`/`payment_uncertain`);POS/缴费机终端超时时直接生成 `payment_uncertain` 工单引导人工核对(呼应 `doc/收费流程.md` 第 5.5 条)。
+
+### 7.5 指令流水埋点(建议包含)
+
+统一在 `OpenGateByDeviceCode`/`CloseGateByDeviceCode` 内包一层:
+
+```go
+start := time.Now()
+err := controller.OpenGate(deviceCode, validTime)
+logCommand(DeviceCommandLog{DeviceCode, Action: "open", Source: 来源, Result: success/failed, ErrorMessage, DurationMs})
+```
+
+- 通行链路调用(passage.go)时 `source=passage`,人工 API 时 `source=manual`(由调用方经参数传入,封装函数加一个 `source`/`operator` 可选参数,缺省 `passage`)。
+- 开闸失败时 `incident_id` 关联 `gate_failed` 记录(先建异常、后写流水,拿到 ID 回填)。
+- 该表只读不参与业务判断,写失败同样 best-effort 记日志。
+
+## 8. 前端页面设计(`view/report/sheet.vue` 重写)
+
+结构完全复用 `paymentRecord.vue` 蓝本:
+
+1. **统计行**(`el-statistic` × 4):待处理、处理中、今日新增、资金类待处理(金额合计,供管理层关注)。
+2. **筛选表单**(`el-form inline`):状态 `el-select`、分类 `el-select`、等级 `el-select`、车牌 `el-input`、票号 `el-input`、停车场 `el-select`(数据源 `obtainPullOverList`,同 revenue.vue)、日期范围 `el-date-picker`、搜索/重置按钮。
+3. **表格**(`el-table` + `el-table-column`):编号、分类(tag 颜色区分)、等级(tag:info 蓝 / warning 橙 / critical 红)、状态(tag:待处理 warning / 处理中 primary / 已解决 success / 已关闭 info)、车牌、停车场、通道/设备、描述(`show-overflow-tooltip`)、上报时间、处理人、操作(详情/处理)。
+4. **分页**:`el-pagination`(page/page_size/total,同 paymentRecord.vue)。
+5. **详情抽屉**(`el-drawer`):完整字段 + `event_log` 时间线(`el-timeline`)+ 处置表单(目标状态、处置方式 `el-select`、强制免费金额 `el-input-number`(仅 force_free 显示)、备注 `el-input textarea`)。处置按钮按角色控制:618 只读,888/9527 可处置,`force_free` 仅 888 可提交(前端隐藏 + 后端强制校验双保险)。
+6. **常量映射**:分类/等级/状态/处置方式 → 中文名 + tag 类型,集中定义在页面顶部常量对象(同 paymentRecord.vue 的 `methodName` 兜底模式)。
+
+新增 `frontend/src/api/incident.js`(仿 `shift.js` 极简模式):
+
+```js
+import service from '@/utils/request'
+export const getIncidentList = (params) => service({ url: '/incident/list', method: 'get', params })
+export const getIncidentDetail = (id) => service({ url: `/incident/${id}`, method: 'get' })
+export const createIncident = (data) => service({ url: '/incident', method: 'post', data })
+export const transitionIncident = (id, data) => service({ url: `/incident/${id}/transition`, method: 'post', data })
+export const getIncidentStats = () => service({ url: '/incident/stats', method: 'get' })
+```
+
+## 9. 数据库迁移与兼容
+
+- `RegisterTables`(`internal/initialize/gorm.go`)的 AutoMigrate 列表追加 `dao.IncidentRecord{}`(及 `dao.DeviceCommandLog{}`,若采纳指令流水)。AutoMigrate 只建新表,不改旧表,无历史数据兼容问题。
+- 埋点为纯增量调用:`passage.go`、`gate.go`、`reader.go` 的现有返回值与事务边界不变;人工抬杆 API 的 `reason` 为可选参数,旧客户端不受影响。
+- 异常记录软删除(GVA_MODEL 自带 DeletedAt),列表默认排除已删除;已删除记录不在统计口径内。
+
+## 10. 测试设计(实施阶段执行)
+
+沿用现有测试模式(`glebarez/sqlite` 内存库 + `global.GVA_DB`,见 `parking-session/service/service_test.go`):
+
+| 测试组 | 场景 |
+| --- | --- |
+| 编号生成 | 当日序号递增、跨日重置、并发生成不重复 |
+| 状态机 | 合法流转全路径、非法流转拒绝(pending→closed 等)、resolved 缺 handle_type/remark 拒绝、force_free 缺金额/负金额拒绝 |
+| CAS 并发 | 两条并发流转同一记录,仅一条成功 |
+| best-effort | 模拟 DB 故障(注入错误连接)时 `RecordIncident` 返回 nil 不阻塞业务 |
+| 去重 | 同设备离线不重复生成、同通行标识时间窗内不重复 |
+| 统计 | 状态计数/今日新增/资金类金额合计 |
+| 埋点集成 | 注入失败 `GateController` 使 `OpenGateByDeviceCode` 失败 → 生成 `gate_failed` + 指令流水;人工抬杆带/不带 reason 均生成记录;离线→重连自动关闭 |
+| 路由安全 | 参照 `router_security_test.go`:匿名/低权限角色访问 `transition` 被拒 |
+
+## 11. 实施顺序与验收标准
+
+### 11.1 实施顺序
+
+1. 模型:`dao.IncidentRecord`(+ `dao.DeviceCommandLog`)+ `RegisterTables` 注册。
+2. 服务层:repository 分页/去重/CAS + service 六方法 + 状态机 + 编号生成。
+3. 埋点:passage.go 五类埋点 + 人工抬杆 reason 参数 + reader.go 离线/重连埋点 + 指令流水封装。
+4. 权限种子:`EnsureIncidentPermissions()` + `SeedSystemData` 挂载。
+5. 前端:`api/incident.js` + `view/report/sheet.vue` 重写。
+6. 测试:第 10 节全部用例 + `npm run build` 前端构建验证。
+
+### 11.2 验收标准(对应 `doc/功能缺失.md` 2.2)
+
+- 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员(开闸失败、重复入场、黑名单、满位、无入场出场、人工抬杆、设备离线七类 v1 场景实测通过)。
+- 支持待处理、处理中、已解决、已关闭四状态流转,处理备注必填,流转留痕(event_log 可查)。
+- 人工抬杆与资金类(强制免费)处置有权限控制与完整审计(角色校验 + 操作日志 + 金额记录)。
+- 页面:筛选、分页、统计、详情时间线、处置表单可用;低权限角色看不到处置入口。
+- 自动化测试覆盖状态机、并发、去重、埋点集成与权限拒绝,全部通过。
+
+## 12. 与相关文档的衔接
+
+| 文档 | 衔接点 |
+| --- | --- |
+| `doc/功能缺失.md` | 本文对应 2.2 节 P0 项;实施完成后从缺失清单移除,并同步 2.3(打印补偿)与 3.6(设备模型)的追踪 |
+| `doc/项目进度.md` | 实施完成后在"待办与优先级"中更新异常处置闭环状态 |
+| `doc/代码规划.md` | 模块结构与边界遵循第 2、3 节约定;`incident` 加入模块清单 |
+| `doc/收费流程.md` | 支付失败/不确定埋点在支付订单层落地时按第 5.5 条"进入人工核对"原则实现 |

+ 19 - 2
doc/项目进度.md

@@ -18,6 +18,7 @@
 | 仪表盘与车辆监控 | 已完成 | 在场车辆、进出统计、收入及设备状态展示 |
 | 闸机控制 | 已完成,待现场验收 | 统一开闸/关闸封装,支持真实设备和模拟在线设备 |
 | 小票打印 | 已完成,待真实打印验收 | 串口 ESC/POS 与 USB CSN SDK,二维码与票据排版测试 |
+| 异常处置闭环 | 已完成并验证 | 异常事件表、四状态处置流转、自动埋点(通行失败/人工抬杆/设备离线)、指令流水、前端页面与权限分级 |
 | 摄像头识别 | 未完成 | 已有摄像头实体,尚未接入 RTSP、OCR 和进出场事件 |
 | LED 车位屏 | 未完成 | 尚未接入硬件协议和余位推送 |
 
@@ -71,6 +72,19 @@
 - 调试路由由 `system.debug-routes` 显式控制,默认关闭;开启后仍要求 JWT、管理员或开发角色权限,并记录操作日志。
 - 打印任务持久化、失败重试、重打和作废仍列为后续 P0 工作。
 
+### 2.6 异常处置闭环
+
+- 新增 `incident_record` 异常事件表与 `internal/modules/incident/` 模块(api + service + repository),详见 `doc/异常处置设计.md`。
+- 状态机:待处理 → 处理中 → 已解决 → 已关闭,支持重新打开;CAS 条件更新防并发覆盖;`event_log` 记录流转时间线。
+- 自动埋点(best-effort,不影响业务主流程):
+  - 统一通行(`PassageService`):开闸失败(critical)、重复入场、黑名单拦截、满位拒绝、无入场记录出场。
+  - 人工抬杆/关闸:`/parking/gate/open|close` 新增可选 `reason` 参数,成功生成已解决的 `manual_raise` 记录,失败生成待处置的 `gate_failed` 记录。
+  - 设备离线:UHF 读卡器在线→离线转换时生成(同设备未关闭不重复),恢复在线自动关闭。
+- 新增 `device_command_log` 指令流水:每次道闸开/关命令(来源/操作员/会话/结果/耗时),开闸失败时关联异常事件。
+- 权限分级:查看/上报 618/888/9527;处置 888/9527;强制免费仅 888(前端隐藏 + 后端强制校验)。
+- 前端:`view/report/sheet.vue` 重写为异常处置页面(统计行 + 筛选 + 分页 + 详情时间线 + 处置表单 + 人工上报),新增 `api/incident.js`;复用菜单 27"异常报表"。
+- 自动化测试:状态机合法/非法流转、强制免费权限与金额校验、CAS 并发、best-effort(DB 故障不阻塞)、设备离线去重与自动关闭、编号生成、统计、开闸失败埋点集成(指令流水 + 异常关联)、路由鉴权(未登录 401)。
+
 ## 3. 核心业务闭环
 
 ```text
@@ -95,13 +109,16 @@
 | `system` | 仪表盘只汇总有效停车场,忽略已删除停车场的孤立历史会话 |
 | `printer` | USB 票据顺序、文字方向、二维码、切纸走纸、短写和二维码失败 |
 | 票机适配层 | 统一通行请求、无牌唯一身份、通道设备解析、多设备歧义拒绝、打印部分失败、调试路由隔离 |
+| `incident` | 异常状态机合法/非法流转、强制免费权限与金额、CAS 并发、best-effort(DB 故障不阻塞)、设备离线去重与自动关闭、编号生成、统计、指令流水关联 |
+| 埋点集成 | 开闸失败 → `gate_failed` 异常 + `failed` 指令流水并回填关联;人工抬杆(带原因)生成已解决记录;成功开闸不产生异常 |
+| 路由安全 | `/incident/*` 全部要求 JWT(未登录 401) |
 | 前端 | `npm run build` 生产构建通过 |
 
 最近一次相关验证命令:
 
 ```powershell
 $env:GOCACHE='D:\lq\Smart Parking\lc_garage\.gocache'
-go test ./internal/service/system ./internal/service/parking ./internal/modules/parking-session/service ./internal/service/vehicle ./internal/initialize
+go test ./internal/service/... ./internal/modules/... ./internal/initialize
 
 cd frontend
 npm run build
@@ -114,7 +131,7 @@ npm run build
 | P0 | 真实闸机联调 | 待验收 | 开闸、关闸、设备离线、超时和人工兜底 |
 | P0 | 小票打印现场验收 | 待验收 | 真实 USB/串口打印机的方向、物理偏移、二维码大小和切纸 |
 | P0 | 摄像头车牌识别 | 未开始 | RTSP/SDK 抓拍、OCR、车牌置信度和统一通行入口接入 |
-| P1 | 异常处置闭环 | 待完善 | 无牌车、票据丢失、人工抬杆、设备离线、支付不确定状态 |
+| P1 | 异常处置扩展点 | 预留 | 打印失败/支付不确定埋点、无牌车独立分类、余位为负与状态不一致自动检测(核心闭环已完成,见 2.6) |
 | P1 | LED 车位屏 | 未开始 | 余位计算、设备协议、定时/事件推送 |
 | P1 | 现场数据与权限验收 | 待验收 | 角色菜单、操作日志、数据归属、交接班对账 |
 | P2 | POS 真机支付 | 待接入 | 以已配置的 POS 支付方式为基础接入交易请求和异步回调 |

+ 17 - 0
frontend/src/api/incident.js

@@ -0,0 +1,17 @@
+import service from '@/utils/request'
+
+export const getIncidentList = (params) => {
+  return service({ url: '/incident/list', method: 'get', params })
+}
+export const getIncidentDetail = (id) => {
+  return service({ url: `/incident/${id}`, method: 'get' })
+}
+export const createIncident = (data) => {
+  return service({ url: '/incident', method: 'post', data })
+}
+export const transitionIncident = (id, data) => {
+  return service({ url: `/incident/${id}/transition`, method: 'post', data })
+}
+export const getIncidentStats = () => {
+  return service({ url: '/incident/stats', method: 'get' })
+}

+ 365 - 14
frontend/src/view/report/sheet.vue

@@ -1,29 +1,380 @@
 <template>
   <div>
-    <el-form inline>
-      <el-form-item :label="$t.value.licensePlate">
-        <el-input />
+    <el-row :gutter="16" class="summary-row">
+      <el-col :span="6">
+        <el-statistic title="待处理" :value="stats.pending" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="处理中" :value="stats.processing" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="今日新增" :value="stats.today_created" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="今日强制免费" :value="stats.today_force_free_amount" prefix="¥" :precision="2" />
+      </el-col>
+    </el-row>
+
+    <el-form inline style="margin-top:16px">
+      <el-form-item label="状态">
+        <el-select v-model="queryForm.status" clearable placeholder="全部" style="width:120px">
+          <el-option v-for="(label, code) in statusMap" :key="code" :label="label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="类型">
+        <el-select v-model="queryForm.category" clearable placeholder="全部" style="width:150px">
+          <el-option v-for="(item, code) in categoryMap" :key="code" :label="item.label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="等级">
+        <el-select v-model="queryForm.level" clearable placeholder="全部" style="width:110px">
+          <el-option v-for="(item, code) in levelMap" :key="code" :label="item.label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item :label="$t.value.LicensePlateNo">
+        <el-input v-model="queryForm.plate_number" :placeholder="$t.value.carPlateInput" clearable style="width:130px" />
+      </el-form-item>
+      <el-form-item label="票号">
+        <el-input v-model="queryForm.ticket_no" placeholder="数字票号" clearable style="width:130px" />
+      </el-form-item>
+      <el-form-item label="停车场">
+        <el-select v-model="queryForm.parking_lot_id" clearable placeholder="全部" style="width:150px">
+          <el-option v-for="lot in lotList" :key="lot.ID" :label="lot.lot_name" :value="lot.ID" />
+        </el-select>
+      </el-form-item>
+      <el-form-item :label="$t.value.EntryTime">
+        <el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" />
       </el-form-item>
       <el-form-item>
-        <el-button type="primary">{{ $t.value.Search }}</el-button>
+        <el-button type="primary" @click="fetchList">{{ $t.value.Search }}</el-button>
+        <el-button @click="resetQuery">{{ $t.value.Reset }}</el-button>
+        <el-button type="success" plain @click="createDialogVisible = true">上报异常</el-button>
       </el-form-item>
     </el-form>
-    <el-table>
-      <el-table-column :label="$t.value.licensePlate" />
-      <el-table-column :label="$t.value.ExitTime" />
-      <el-table-column :label="$t.value.ExitTime" />
-      <el-table-column :label="$t.value.ExitBooth" />
-      <el-table-column :label="$t.value.ExitPassage" />
-      <el-table-column :label="$t.value.vehicleNumber" />
-      <el-table-column :label="$t.value.ownerName" />
+
+    <el-table :data="list" height="560" border>
+      <el-table-column label="编号" prop="incident_no" align="center" width="180" />
+      <el-table-column label="类型" align="center" width="120">
+        <template #default="{ row }">
+          <el-tag :type="categoryMap[row.category]?.tag || 'info'">{{ categoryName(row.category) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="等级" align="center" width="90">
+        <template #default="{ row }">
+          <el-tag :type="levelMap[row.level]?.tag || 'info'" size="small">{{ levelName(row.level) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="90">
+        <template #default="{ row }">
+          <el-tag :type="statusTag(row.status)">{{ statusName(row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t.value.licensePlate" align="center" width="110">
+        <template #default="{ row }">{{ row.plate_number || row.rfid_tag || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="停车场" prop="lot_name" align="center" width="110">
+        <template #default="{ row }">{{ row.lot_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="通道/设备" align="center" width="130">
+        <template #default="{ row }">{{ row.channel_code || row.device_code || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="描述" prop="description" min-width="180" show-overflow-tooltip />
+      <el-table-column label="上报时间" align="center" width="170">
+        <template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
+      </el-table-column>
+      <el-table-column label="上报人" prop="operator_name" align="center" width="100">
+        <template #default="{ row }">{{ row.operator_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="处理人" prop="handler_name" align="center" width="100">
+        <template #default="{ row }">{{ row.handler_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="130" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link @click="openDetail(row)">详情</el-button>
+          <el-button v-if="canHandle" type="warning" link @click="openHandle(row)">处理</el-button>
+        </template>
+      </el-table-column>
     </el-table>
+
+    <el-pagination
+      v-model:current-page="queryForm.page"
+      :page-size="queryForm.page_size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top:12px; justify-content:flex-end"
+      @current-change="fetchList"
+    />
+
+    <!-- 人工上报对话框 -->
+    <el-dialog v-model="createDialogVisible" title="上报异常" width="560px">
+      <el-form :model="createForm" label-width="90px">
+        <el-form-item label="类型" required>
+          <el-select v-model="createForm.category" placeholder="选择异常类型" style="width:100%">
+            <el-option v-for="(item, code) in categoryMap" :key="code" :label="item.label" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="车牌">
+          <el-input v-model="createForm.plate_number" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="票号">
+          <el-input v-model="createForm.ticket_no" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="停车会话ID">
+          <el-input-number v-model="createForm.vehicle_record_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="数字票ID">
+          <el-input-number v-model="createForm.digital_ticket_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="支付流水ID">
+          <el-input-number v-model="createForm.payment_record_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="通道ID">
+          <el-input-number v-model="createForm.channel_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="设备编码">
+          <el-input v-model="createForm.device_code" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="描述" required>
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="异常情况说明" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="doCreate">提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 详情抽屉 -->
+    <el-drawer v-model="detailVisible" :title="detail.incident_no ? `异常详情 ${detail.incident_no}` : '异常详情'" size="520px">
+      <el-descriptions :column="2" border v-if="detail.id">
+        <el-descriptions-item label="类型">{{ categoryName(detail.category) }}</el-descriptions-item>
+        <el-descriptions-item label="等级">{{ levelName(detail.level) }}</el-descriptions-item>
+        <el-descriptions-item label="状态">{{ statusName(detail.status) }}</el-descriptions-item>
+        <el-descriptions-item label="来源">{{ sourceName(detail.source) }}</el-descriptions-item>
+        <el-descriptions-item label="车牌">{{ detail.plate_number || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="票号">{{ detail.ticket_no || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="停车会话">{{ detail.vehicle_record_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="数字票">{{ detail.digital_ticket_id || detail.ticket_no || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="支付流水">{{ detail.payment_record_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="闸机指令">{{ detail.gate_command_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="停车场">{{ detail.parking_lot_name || detail.parking_lot_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="通道">{{ detail.channel_name || detail.channel_code || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="设备">{{ detail.device_name || detail.device_code || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上报人">{{ detail.operator_name || detail.operator_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="处理人">{{ detail.handler_name || detail.handler_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="处置方式">{{ handleName(detail.handle_type) }}</el-descriptions-item>
+        <el-descriptions-item label="处置金额">
+          {{ detail.handle_type === 'force_free' ? '¥' + (detail.force_free_amount || 0).toFixed(2) : '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="上报时间" :span="2">{{ fmtTime(detail.created_at) }}</el-descriptions-item>
+        <el-descriptions-item label="处置备注" :span="2">{{ detail.handle_remark || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="描述" :span="2">{{ detail.description }}</el-descriptions-item>
+        <el-descriptions-item label="上下文" :span="2">
+          <el-input :model-value="detail.detail" type="textarea" :rows="3" readonly />
+        </el-descriptions-item>
+      </el-descriptions>
+      <el-divider content-position="left">流转记录</el-divider>
+      <el-timeline v-if="timeline.length">
+        <el-timeline-item v-for="(evt, idx) in timeline" :key="idx" :timestamp="evt.at" placement="top">
+          {{ eventText(evt) }}
+        </el-timeline-item>
+      </el-timeline>
+      <el-empty v-else description="暂无流转记录" :image-size="60" />
+    </el-drawer>
+
+    <!-- 处置对话框 -->
+    <el-dialog v-model="handleDialogVisible" :title="`处理异常 ${detail.incident_no || ''}`" width="520px">
+      <el-form :model="handleForm" label-width="90px">
+        <el-form-item label="目标状态">
+          <el-select v-model="handleForm.to_status" style="width:100%">
+            <el-option v-for="(label, code) in handleTargets" :key="code" :label="label" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="handleForm.to_status === 'resolved'" label="处置方式" required>
+          <el-select v-model="handleForm.handle_type" style="width:100%">
+            <el-option v-for="(item, code) in handleTypeMap" :key="code" :label="item" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="handleForm.handle_type === 'force_free'" label="强制免费金额" required>
+          <el-input-number v-model="handleForm.force_free_amount" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="备注" required>
+          <el-input v-model="handleForm.handle_remark" type="textarea" :rows="3" placeholder="处置说明(必填)" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="handleDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="doHandle">提交处置</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { createIncident, getIncidentDetail, getIncidentList, getIncidentStats, transitionIncident } from '@/api/incident'
+import { obtainPullOverList } from '@/api/pullOver'
+import { useUserStore } from '@/pinia/modules/user'
 
-</script>
+const userStore = useUserStore()
+
+const statusMap = { pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭' }
+const categoryMap = {
+  gate_failed: { label: '开闸失败', tag: 'danger' },
+  duplicate_entry: { label: '重复入场', tag: 'warning' },
+  blacklist: { label: '黑名单拦截', tag: 'warning' },
+  lot_full: { label: '满位拒绝', tag: 'info' },
+  no_entry_exit: { label: '无入场记录出场', tag: 'danger' },
+  manual_raise: { label: '人工抬杆', tag: 'warning' },
+  device_offline: { label: '设备离线', tag: 'warning' },
+  print_failed: { label: '打印失败', tag: 'warning' },
+  payment_failed: { label: '支付失败', tag: 'warning' },
+  payment_uncertain: { label: '支付状态不确定', tag: 'danger' }
+}
+const levelMap = { info: { label: '提示', tag: 'info' }, warning: { label: '警告', tag: 'warning' }, critical: { label: '严重', tag: 'danger' } }
+const sourceMap = { passage: '通行链路', manual: '人工上报', device: '设备', payment: '支付', printer: '打印', system: '系统' }
+const handleTypeMap = {
+  manual_gate: '人工抬杆放行',
+  force_free: '强制免费',
+  reset: '状态重置/修正',
+  device_repaired: '设备修复',
+  reprint: '重新打印',
+  ignore: '误报忽略',
+  other: '其他'
+}
 
-<style scoped lang="scss">
+const queryForm = reactive({ status: '', category: '', level: '', plate_number: '', ticket_no: '', parking_lot_id: null, page: 1, page_size: 10, start_date: '', end_date: '' })
+const dateRange = ref([])
+const list = ref([])
+const total = ref(0)
+const lotList = ref([])
+const stats = reactive({ pending: 0, processing: 0, today_created: 0, today_force_free_amount: 0 })
+
+const detail = ref({})
+const timeline = ref([])
+const detailVisible = ref(false)
+const createDialogVisible = ref(false)
+const createForm = reactive({ category: '', plate_number: '', ticket_no: '', vehicle_record_id: 0, digital_ticket_id: 0, payment_record_id: 0, gate_command_id: 0, parking_lot_id: 0, channel_id: 0, device_code: '', description: '' })
+
+const handleDialogVisible = ref(false)
+const handleForm = reactive({ to_status: 'processing', handle_type: '', force_free_amount: 0, handle_remark: '' })
+
+// 当前角色:618 操作员只读;888/9527 可处置;强制免费仅 888(后端强制校验)
+const authorityId = computed(() => String(userStore.userInfo.authorityId || ''))
+const canHandle = computed(() => ['888', '9527'].includes(authorityId.value))
+const handleTargets = computed(() => {
+  const targets = { ...statusMap }
+  delete targets[detail.value.status]
+  return targets
+})
+
+onMounted(() => { fetchList(); fetchStats(); loadLotList() })
+
+function categoryName(code) { return categoryMap[code]?.label || code || '-' }
+function levelName(code) { return levelMap[code]?.label || code || '-' }
+function statusName(code) { return statusMap[code] || code || '-' }
+function sourceName(code) { return sourceMap[code] || code || '-' }
+function handleName(code) { return handleTypeMap[code] || code || '-' }
+function statusTag(status) {
+  return { pending: 'warning', processing: 'primary', resolved: 'success', closed: 'info' }[status] || 'info'
+}
+function fmtTime(t) {
+  if (!t) return '-'
+  return String(t).replace('T', ' ').slice(0, 19)
+}
+function eventText(evt) {
+  const parts = [statusMap[evt.event] || evt.event]
+  if (evt.handle_type) parts.push(handleName(evt.handle_type))
+  if (evt.force_free_amount !== undefined) parts.push('¥' + Number(evt.force_free_amount).toFixed(2))
+  if (evt.remark) parts.push(evt.remark)
+  return parts.join(' · ')
+}
+
+function loadLotList() {
+  obtainPullOverList({ page: 1, pageSize: 100 }).then(res => {
+    if (res.code === 0) lotList.value = res.data?.list || []
+  })
+}
+
+function fetchStats() {
+  getIncidentStats().then(res => {
+    if (res.code === 0) Object.assign(stats, res.data || {})
+  })
+}
+
+function fetchList() {
+  if (dateRange.value && dateRange.value.length === 2) {
+    queryForm.start_date = dateRange.value[0]
+    queryForm.end_date = dateRange.value[1]
+  } else {
+    queryForm.start_date = ''
+    queryForm.end_date = ''
+  }
+  getIncidentList(queryForm).then(res => {
+    if (res.code === 0) {
+      list.value = res.data.list || []
+      total.value = res.data.total || 0
+    }
+  })
+}
+
+function resetQuery() {
+  Object.assign(queryForm, { status: '', category: '', level: '', plate_number: '', ticket_no: '', parking_lot_id: null, page: 1 })
+  dateRange.value = []
+  fetchList()
+}
+
+function openDetail(row) {
+  getIncidentDetail(row.ID || row.id).then(res => {
+    if (res.code === 0) {
+      detail.value = res.data
+      try {
+        timeline.value = JSON.parse(res.data.event_log || '[]')
+      } catch (e) {
+        timeline.value = []
+      }
+      detailVisible.value = true
+    }
+  })
+}
+
+function openHandle(row) {
+  openDetail(row)
+  Object.assign(handleForm, { to_status: 'processing', handle_type: '', force_free_amount: 0, handle_remark: '' })
+  handleDialogVisible.value = true
+}
+
+function doCreate() {
+  if (!createForm.category || !createForm.description) {
+    ElMessage.warning('请填写类型和描述')
+    return
+  }
+  createIncident({ ...createForm }).then(res => {
+    if (res.code === 0) {
+      ElMessage.success('上报成功')
+      createDialogVisible.value = false
+      Object.assign(createForm, { category: '', plate_number: '', ticket_no: '', vehicle_record_id: 0, digital_ticket_id: 0, payment_record_id: 0, gate_command_id: 0, parking_lot_id: 0, channel_id: 0, device_code: '', description: '' })
+      fetchList(); fetchStats()
+    }
+  })
+}
+
+function doHandle() {
+  const id = detail.value.ID || detail.value.id
+  if (!id) return
+  transitionIncident(id, { ...handleForm }).then(res => {
+    if (res.code === 0) {
+      ElMessage.success('处置成功')
+      handleDialogVisible.value = false
+      detailVisible.value = false
+      fetchList(); fetchStats()
+    }
+  })
+}
+</script>
 
+<style scoped>
+.summary-row { margin-bottom: 16px; }
+.summary-row .el-statistic { background: #f5f7fa; padding: 16px; border-radius: 8px; }
 </style>

+ 72 - 4
internal/api/v1/parking/gate.go

@@ -5,11 +5,16 @@ import (
 
 	commonModel "wails-app/internal/model/common"
 	"wails-app/internal/model/common/response"
+	incidentService "wails-app/internal/modules/incident/service"
+	utils "wails-app/internal/pkg"
 	"wails-app/internal/service"
+	parkingService "wails-app/internal/service/parking"
 )
 
 var passageService = &service.ServiceGroupApp.ParkingServiceGroup.PassageService
 
+var incidentSvc = incidentService.NewIncidentService()
+
 // ListGateDevices 返回进出场工作台可用的道闸设备。
 func ListGateDevices(c *gin.Context) {
 	devices, err := passageService.ListGateDevices()
@@ -20,30 +25,93 @@ func ListGateDevices(c *gin.Context) {
 	response.OkWithData(devices, c)
 }
 
-// OpenGate 人工开闸。
+// OpenGate 人工开闸。可携带 reason(原因)、plate_number / session_id(关联上下文),
+// 每次操作生成可审计的异常记录(manual_raise 或 gate_failed)。
 func OpenGate(c *gin.Context) {
 	var req commonModel.GateControlRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	if err := passageService.OpenGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+	operatorID := utils.GetUserID(c)
+	commandLogID, err := passageService.OpenGateWithContext(parkingService.GateCommandContext{
+		Source:     incidentService.SourceManual,
+		OperatorID: operatorID,
+		SessionID:  req.SessionID,
+	}, req.DeviceCode, req.ValidTime)
+	if err != nil {
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourceManual,
+			VehicleRecordID: req.SessionID,
+			PlateNumber:     req.PlateNumber,
+			DeviceCode:      req.DeviceCode,
+			GateCommandID:   commandLogID,
+			OperatorID:      operatorID,
+			Description:     "人工开闸失败",
+			Detail:          err.Error(),
+			HandleRemark:    req.Reason,
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: req.SessionID,
+		PlateNumber:     req.PlateNumber,
+		DeviceCode:      req.DeviceCode,
+		GateCommandID:   commandLogID,
+		OperatorID:      operatorID,
+		Description:     "人工开闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    req.Reason,
+	})
 	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "open"}, "开闸成功", c)
 }
 
-// CloseGate 人工关闸。
+// CloseGate 人工关闸。同样生成可审计记录。
 func CloseGate(c *gin.Context) {
 	var req commonModel.GateControlRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	if err := passageService.CloseGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+	operatorID := utils.GetUserID(c)
+	commandLogID, err := passageService.CloseGateWithContext(parkingService.GateCommandContext{
+		Source:     incidentService.SourceManual,
+		OperatorID: operatorID,
+		SessionID:  req.SessionID,
+	}, req.DeviceCode, req.ValidTime)
+	if err != nil {
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourceManual,
+			VehicleRecordID: req.SessionID,
+			PlateNumber:     req.PlateNumber,
+			DeviceCode:      req.DeviceCode,
+			GateCommandID:   commandLogID,
+			OperatorID:      operatorID,
+			Description:     "人工关闸失败",
+			Detail:          err.Error(),
+			HandleRemark:    req.Reason,
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: req.SessionID,
+		PlateNumber:     req.PlateNumber,
+		DeviceCode:      req.DeviceCode,
+		GateCommandID:   commandLogID,
+		OperatorID:      operatorID,
+		Description:     "人工关闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    req.Reason,
+	})
 	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "close"}, "关闸成功", c)
 }

+ 62 - 0
internal/dao/incident.go

@@ -0,0 +1,62 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+// IncidentRecord 异常事件记录(事件与工单合一)。
+// 自动埋点(通行失败、设备离线、人工抬杆)与人工上报共用一张表,
+// 处置过程(状态流转、处置方式、金额、处理人)随记录保存。
+type IncidentRecord struct {
+	global.GVA_MODEL
+	IncidentNo string `gorm:"size:32;uniqueIndex" json:"incident_no"`
+	Category   string `gorm:"size:32;index" json:"category"`
+	Source     string `gorm:"size:20" json:"source"`
+	Level      string `gorm:"size:20" json:"level"`
+	Status     string `gorm:"size:20;default:pending;index;index:idx_incident_device_status,priority:2" json:"status"`
+	// VehicleRecordID 是停车会话(历史表名 vehicle_record)的关联 ID。
+	VehicleRecordID uint       `gorm:"index" json:"vehicle_record_id"`
+	DigitalTicketID uint       `gorm:"index" json:"digital_ticket_id"`
+	PaymentRecordID uint       `gorm:"index" json:"payment_record_id"`
+	GateCommandID   uint       `gorm:"index" json:"gate_command_id"`
+	TicketNo        string     `gorm:"size:64" json:"ticket_no"`
+	PlateNumber     string     `gorm:"size:20" json:"plate_number"`
+	RFIDTag         string     `gorm:"size:40" json:"rfid_tag"`
+	ParkingLotID    uint       `json:"parking_lot_id"`
+	ParkingLotName  string     `gorm:"size:100" json:"parking_lot_name"`
+	ChannelID       uint       `json:"channel_id"`
+	ChannelCode     string     `gorm:"size:32" json:"channel_code"`
+	ChannelName     string     `gorm:"size:100" json:"channel_name"`
+	DeviceCode      string     `gorm:"size:32;index:idx_incident_device_status,priority:1" json:"device_code"`
+	DeviceName      string     `gorm:"size:100" json:"device_name"`
+	OperatorID      uint       `json:"operator_id"`
+	Description     string     `gorm:"size:500" json:"description"`
+	Detail          string     `gorm:"type:text" json:"detail"`
+	HandlerID       uint       `json:"handler_id"`
+	HandledAt       *time.Time `json:"handled_at"`
+	HandleType      string     `gorm:"size:32" json:"handle_type"`
+	HandleRemark    string     `gorm:"size:500" json:"handle_remark"`
+	ForceFreeAmount float64    `json:"force_free_amount"`
+	EventLog        string     `gorm:"type:text" json:"event_log"`
+}
+
+func (IncidentRecord) TableName() string { return "incident_record" }
+
+// DeviceCommandLog 道闸指令流水:每次开闸/关闸命令的审计记录。
+// 开闸失败时通过 incident_id 关联对应的异常事件。
+type DeviceCommandLog struct {
+	global.GVA_MODEL
+	DeviceCode   string `gorm:"size:32;index" json:"device_code"`
+	DeviceName   string `gorm:"size:64" json:"device_name"`
+	Action       string `gorm:"size:10" json:"action"` // open / close
+	Source       string `gorm:"size:20" json:"source"` // passage / manual / test
+	OperatorID   uint   `json:"operator_id"`
+	SessionID    uint   `json:"session_id"`
+	IncidentID   uint   `json:"incident_id"`
+	Result       string `gorm:"size:20" json:"result"` // success / failed
+	ErrorMessage string `gorm:"size:500" json:"error_message"`
+	DurationMs   int64  `json:"duration_ms"`
+}
+
+func (DeviceCommandLog) TableName() string { return "device_command_log" }

+ 2 - 0
internal/initialize/gorm.go

@@ -66,6 +66,8 @@ func RegisterTables() {
 		dao.DigitalTicket{},
 		dao.ShiftRecord{},
 		dao.Printer{},
+		dao.IncidentRecord{},
+		dao.DeviceCommandLog{},
 	)
 	if err != nil {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))

+ 60 - 0
internal/initialize/incident_seed.go

@@ -0,0 +1,60 @@
+package initialize
+
+import (
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// EnsureIncidentPermissions upgrades both new and existing databases with the
+// API catalogue and Casbin policies required by the incident handling module.
+// 查看/上报对 618/888/9527 开放;处置(状态流转)仅 888/9527。
+func EnsureIncidentPermissions() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+
+	apis := []dao.SysApi{
+		{Path: "/incident/list", Description: "异常记录列表", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident/stats", Description: "异常统计", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident/:id", Description: "异常记录详情", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident", Description: "上报异常", ApiGroup: "异常处置", Method: "POST"},
+		{Path: "/incident/:id/transition", Description: "异常状态流转与处置", ApiGroup: "异常处置", Method: "POST"},
+	}
+	for _, api := range apis {
+		var existing dao.SysApi
+		err := db.Where("path = ? AND method = ?", api.Path, api.Method).First(&existing).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			err = db.Create(&api).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化异常处置API %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
+
+	// 查看与上报:全部角色
+	for _, role := range []string{"618", "888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/incident/list", "GET"},
+			{"/incident/stats", "GET"},
+			{"/incident/:id", "GET"},
+			{"/incident", "POST"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return fmt.Errorf("初始化角色%s异常查看权限失败: %w", role, err)
+			}
+		}
+	}
+
+	// 处置:仅管理员与超级管理员(资金类处置另有业务层校验)
+	for _, role := range []string{"888", "9527"} {
+		if err := ensureCasbinRule(role, "/incident/:id/transition", "POST"); err != nil {
+			return fmt.Errorf("初始化角色%s异常处置权限失败: %w", role, err)
+		}
+	}
+	return nil
+}

+ 4 - 0
internal/initialize/router.go

@@ -7,6 +7,7 @@ import (
 	"wails-app/internal/global"
 	"wails-app/internal/middleware"
 	"wails-app/internal/modules/digital-ticket"
+	"wails-app/internal/modules/incident"
 	"wails-app/internal/modules/monthly"
 	"wails-app/internal/modules/payment"
 	"wails-app/internal/modules/printer"
@@ -130,6 +131,9 @@ func Routers() *gin.Engine {
 		// 收入报表路由
 		report.SetupReportRouter(PrivateGroup)
 
+		// 异常处置路由
+		incident.SetupIncidentRouter(PrivateGroup)
+
 		// UHF读写器路由
 		//uhf.SetupUHFRouter(PrivateGroup)
 	}

+ 18 - 0
internal/initialize/router_security_test.go

@@ -41,6 +41,24 @@ func TestDebugRoutesAreNotRegisteredByDefault(t *testing.T) {
 	require.NotContains(t, routes, "/channel/test-event")
 }
 
+func TestIncidentRoutesRegisteredAndRequireAuth(t *testing.T) {
+	router := setupRouterSecurityTest(t, false)
+	cases := []struct{ method, path string }{
+		{http.MethodGet, "/incident/list"},
+		{http.MethodGet, "/incident/stats"},
+		{http.MethodGet, "/incident/1"},
+		{http.MethodPost, "/incident"},
+		{http.MethodPost, "/incident/1/transition"},
+	}
+	for _, tc := range cases {
+		recorder := httptest.NewRecorder()
+		request := httptest.NewRequest(tc.method, tc.path, nil)
+		router.ServeHTTP(recorder, request)
+		// 未登录一律 401(说明路由存在且受 JWT 保护)
+		require.Equal(t, http.StatusUnauthorized, recorder.Code, "%s %s", tc.method, tc.path)
+	}
+}
+
 func TestEnabledDebugRoutesStillRequireAuthentication(t *testing.T) {
 	router := setupRouterSecurityTest(t, true)
 	recorder := httptest.NewRecorder()

+ 6 - 0
internal/initialize/seed.go

@@ -26,6 +26,9 @@ func SeedSystemData() {
 		if err := EnsureOperationPermissions(); err != nil {
 			global.GVA_LOG.Error("operation permission upgrade failed")
 		}
+		if err := EnsureIncidentPermissions(); err != nil {
+			global.GVA_LOG.Error("incident permission upgrade failed")
+		}
 		return
 	}
 
@@ -536,6 +539,9 @@ func SeedSystemData() {
 	if err := EnsureOperationPermissions(); err != nil {
 		global.GVA_LOG.Error("operation permission seed failed")
 	}
+	if err := EnsureIncidentPermissions(); err != nil {
+		global.GVA_LOG.Error("incident permission seed failed")
+	}
 
 	global.GVA_LOG.Info("system data seeded successfully")
 }

+ 5 - 2
internal/model/common/passage.go

@@ -19,8 +19,11 @@ type PassageRequest struct {
 
 // GateControlRequest 人工开关闸请求。
 type GateControlRequest struct {
-	DeviceCode string `json:"device_code" binding:"required"`
-	ValidTime  byte   `json:"valid_time"`
+	DeviceCode  string `json:"device_code" binding:"required"`
+	ValidTime   byte   `json:"valid_time"`
+	Reason      string `json:"reason"`       // 人工抬杆/关闸原因(可选,用于异常留痕审计)
+	PlateNumber string `json:"plate_number"` // 关联车牌(可选)
+	SessionID   uint   `json:"session_id"`   // 关联停车会话(可选)
 }
 
 // PassageResult 进出场结果

+ 102 - 0
internal/modules/incident/api.go

@@ -0,0 +1,102 @@
+package incident
+
+import (
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/incident/repository"
+	"wails-app/internal/modules/incident/service"
+	utils "wails-app/internal/pkg"
+)
+
+var incidentSvc = service.NewIncidentService()
+
+// adminAuthorityId 管理员角色(888);处置权限见初始化种子中的 Casbin 规则。
+const adminAuthorityId = "888"
+
+// ListIncidents 分页筛选异常记录。
+func ListIncidents(c *gin.Context) {
+	var q repository.IncidentListQuery
+	if err := c.ShouldBindQuery(&q); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	list, total, err := incidentSvc.ListIncidents(q)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+// GetIncident 异常详情。
+func GetIncident(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		response.FailWithMessage("异常记录 ID 无效", c)
+		return
+	}
+	record, err := incidentSvc.GetIncident(uint(id))
+	if err != nil {
+		response.FailWithMessage(service.ErrIncidentNotFound.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// CreateIncident 人工上报异常。
+func CreateIncident(c *gin.Context) {
+	var req service.CreateIncidentRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	record, err := incidentSvc.CreateIncident(req, utils.GetUserID(c))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// TransitionIncident 状态流转+处置。强制免费等资金类处置仅管理员(888)可执行。
+func TransitionIncident(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		response.FailWithMessage("异常记录 ID 无效", c)
+		return
+	}
+	var req service.TransitionRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	isAdmin := strconv.FormatUint(uint64(utils.GetUserAuthorityId(c)), 10) == adminAuthorityId
+	record, err := incidentSvc.TransitionIncident(uint(id), req, utils.GetUserID(c), isAdmin)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// GetIncidentStats 异常统计(页面头部汇总)。
+func GetIncidentStats(c *gin.Context) {
+	stats, err := incidentSvc.GetIncidentStats()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(stats, c)
+}
+
+// SetupIncidentRouter 注册异常处置路由(挂在 PrivateGroup,自动带 JWT+Casbin+操作日志)。
+func SetupIncidentRouter(router *gin.RouterGroup) {
+	sr := router.Group("/incident")
+	sr.GET("/list", ListIncidents)
+	sr.GET("/stats", GetIncidentStats)
+	sr.GET("/:id", GetIncident)
+	sr.POST("", CreateIncident)
+	sr.POST("/:id/transition", TransitionIncident)
+}

+ 256 - 0
internal/modules/incident/repository/repo.go

@@ -0,0 +1,256 @@
+package repository
+
+import (
+	"encoding/json"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+
+	"gorm.io/gorm"
+)
+
+// appendEventLog 向事件日志 JSON 数组追加一条事件(沿用数字票 event_log 约定)。
+func appendEventLog(log, eventJSON string) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	var event map[string]interface{}
+	json.Unmarshal([]byte(eventJSON), &event)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}
+
+type IncidentRepository struct{}
+
+// Create 创建异常记录。
+func (r *IncidentRepository) Create(record *dao.IncidentRecord) error {
+	return global.GVA_DB.Create(record).Error
+}
+
+// GetByID 查询异常详情(含软删除过滤)。
+func (r *IncidentRepository) GetByID(id uint) (*dao.IncidentRecord, error) {
+	var record dao.IncidentRecord
+	err := global.GVA_DB.First(&record, id).Error
+	if err != nil {
+		return nil, err
+	}
+	return &record, nil
+}
+
+// UpdateStateIfCurrent CAS 状态更新:仅当记录仍处于调用方观察到的状态时生效。
+func (r *IncidentRepository) UpdateStateIfCurrent(id uint, fromStatus, toStatus string, updates map[string]interface{}) (bool, error) {
+	if updates == nil {
+		updates = make(map[string]interface{})
+	}
+	updates["status"] = toStatus
+	result := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("id = ? AND status = ?", id, fromStatus).
+		Updates(updates)
+	return result.RowsAffected == 1, result.Error
+}
+
+// UpdateIncidentID 建立异常与指令流水的双向关联(开闸失败场景)。
+func (r *IncidentRepository) UpdateIncidentID(logID, incidentID uint) error {
+	if logID == 0 || incidentID == 0 {
+		return nil
+	}
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(&dao.DeviceCommandLog{}).
+			Where("id = ?", logID).Update("incident_id", incidentID).Error; err != nil {
+			return err
+		}
+		return tx.Model(&dao.IncidentRecord{}).
+			Where("id = ?", incidentID).Update("gate_command_id", logID).Error
+	})
+}
+
+// HasOpenDeviceOffline 同设备是否已有未关闭(待处理/处理中)的离线异常。
+func (r *IncidentRepository) HasOpenDeviceOffline(deviceCode string) (bool, error) {
+	if deviceCode == "" {
+		return false, nil
+	}
+	var count int64
+	err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("device_code = ? AND category = ? AND status IN (?, ?)",
+			deviceCode, "device_offline", "pending", "processing").
+		Count(&count).Error
+	return count > 0, err
+}
+
+// ExistsRecent 通行类异常去重:同设备 + 同分类 + 同车辆标识,在时间窗口内不重复生成。
+// 无车辆标识(车牌和 RFID 均为空)时不去重。
+func (r *IncidentRepository) ExistsRecent(deviceCode, category, plateNumber, rfidTag string, minutes int) (bool, error) {
+	if plateNumber == "" && rfidTag == "" {
+		return false, nil
+	}
+	db := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("device_code = ? AND category = ? AND status IN (?, ?) AND created_at >= ?",
+			deviceCode, category, "pending", "processing", time.Now().Add(-time.Duration(minutes)*time.Minute))
+	if plateNumber != "" {
+		db = db.Where("plate_number = ?", plateNumber)
+	} else {
+		db = db.Where("rfid_tag = ?", rfidTag)
+	}
+	var count int64
+	err := db.Count(&count).Error
+	return count > 0, err
+}
+
+// CreateCommandLog 写入指令流水。
+func (r *IncidentRepository) CreateCommandLog(log *dao.DeviceCommandLog) error {
+	return global.GVA_DB.Create(log).Error
+}
+
+// ResolveDeviceOfflineByDevice 设备恢复在线时批量关闭该设备未处理的离线异常。
+func (r *IncidentRepository) ResolveDeviceOfflineByDevice(deviceCode, remark string) error {
+	if deviceCode == "" {
+		return nil
+	}
+	var open []dao.IncidentRecord
+	if err := global.GVA_DB.Where("device_code = ? AND category = ? AND status IN (?, ?)",
+		deviceCode, "device_offline", "pending", "processing").Find(&open).Error; err != nil {
+		return err
+	}
+	now := time.Now()
+	for i := range open {
+		event, _ := json.Marshal(map[string]interface{}{
+			"event": "resolved", "handle_type": "device_repaired", "remark": remark, "at": now.Format(time.RFC3339),
+		})
+		if err := global.GVA_DB.Model(&open[i]).Updates(map[string]interface{}{
+			"status": "resolved", "handle_type": "device_repaired",
+			"handled_at": now, "handle_remark": remark, "handler_id": 0,
+			"event_log": appendEventLog(open[i].EventLog, string(event)),
+		}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type IncidentListQuery struct {
+	Status       string `form:"status"`
+	Category     string `form:"category"`
+	Level        string `form:"level"`
+	Source       string `form:"source"`
+	PlateNumber  string `form:"plate_number"`
+	TicketNo     string `form:"ticket_no"`
+	ParkingLotID uint   `form:"parking_lot_id"`
+	DeviceCode   string `form:"device_code"`
+	StartDate    string `form:"start_date"`
+	EndDate      string `form:"end_date"`
+	Page         int    `form:"page"`
+	PageSize     int    `form:"page_size"`
+}
+
+type IncidentListItem struct {
+	dao.IncidentRecord
+	OperatorName string `json:"operator_name"`
+	HandlerName  string `json:"handler_name"`
+	LotName      string `json:"lot_name"`
+}
+
+// List 分页筛选异常记录(上报人/处理人昵称、停车场名称联查)。
+func (r *IncidentRepository) List(q IncidentListQuery) ([]IncidentListItem, int64, error) {
+	db := global.GVA_DB.Table("incident_record ir").
+		Select("ir.*, op.nick_name as operator_name, hd.nick_name as handler_name, COALESCE(ir.parking_lot_name, pl.lot_name) as lot_name").
+		Joins("LEFT JOIN sys_users op ON op.id = ir.operator_id").
+		Joins("LEFT JOIN sys_users hd ON hd.id = ir.handler_id").
+		Joins("LEFT JOIN parking_lot pl ON pl.id = ir.parking_lot_id")
+
+	if q.Status != "" {
+		db = db.Where("ir.status = ?", q.Status)
+	}
+	if q.Category != "" {
+		db = db.Where("ir.category = ?", q.Category)
+	}
+	if q.Level != "" {
+		db = db.Where("ir.level = ?", q.Level)
+	}
+	if q.Source != "" {
+		db = db.Where("ir.source = ?", q.Source)
+	}
+	if q.PlateNumber != "" {
+		db = db.Where("ir.plate_number LIKE ?", "%"+q.PlateNumber+"%")
+	}
+	if q.TicketNo != "" {
+		db = db.Where("ir.ticket_no LIKE ?", "%"+q.TicketNo+"%")
+	}
+	if q.ParkingLotID > 0 {
+		db = db.Where("ir.parking_lot_id = ?", q.ParkingLotID)
+	}
+	if q.DeviceCode != "" {
+		db = db.Where("ir.device_code LIKE ?", "%"+q.DeviceCode+"%")
+	}
+	if q.StartDate != "" {
+		db = db.Where("ir.created_at >= ?", q.StartDate+" 00:00:00")
+	}
+	if q.EndDate != "" {
+		db = db.Where("ir.created_at < ?", q.EndDate+" 23:59:59")
+	}
+
+	var total int64
+	db.Count(&total)
+
+	if q.Page <= 0 {
+		q.Page = 1
+	}
+	if q.PageSize <= 0 {
+		q.PageSize = 10
+	}
+	var list []IncidentListItem
+	err := db.Order("ir.id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Scan(&list).Error
+	return list, total, err
+}
+
+type IncidentStats struct {
+	Pending              int64   `json:"pending"`
+	Processing           int64   `json:"processing"`
+	Resolved             int64   `json:"resolved"`
+	Closed               int64   `json:"closed"`
+	TodayCreated         int64   `json:"today_created"`
+	OpenFundRelated      int64   `json:"open_fund_related"`
+	TodayForceFreeAmount float64 `json:"today_force_free_amount"`
+}
+
+// Stats 异常统计:状态分布、今日新增、未关闭资金类数量、今日强制免费金额。
+func (r *IncidentRepository) Stats() (IncidentStats, error) {
+	var stats IncidentStats
+	// 每次 Count 使用全新的查询构建器,避免 GORM Where 条件在复用实例上累积
+	base := func() *gorm.DB {
+		return global.GVA_DB.Model(&dao.IncidentRecord{})
+	}
+	if err := base().Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "processing").Count(&stats.Processing).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "resolved").Count(&stats.Resolved).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "closed").Count(&stats.Closed).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("created_at >= ?", startOfDay()).Count(&stats.TodayCreated).Error; err != nil {
+		return stats, err
+	}
+	// 资金相关分类(支付失败/不确定、开闸失败、无入场记录出场)中未关闭的数量
+	if err := base().Where("status IN (?, ?) AND category IN (?, ?, ?, ?)",
+		"pending", "processing",
+		"payment_failed", "payment_uncertain", "gate_failed", "no_entry_exit").
+		Count(&stats.OpenFundRelated).Error; err != nil {
+		return stats, err
+	}
+	// 今日强制免费金额合计
+	if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("handle_type = ? AND handled_at >= ?", "force_free", startOfDay()).
+		Select("COALESCE(SUM(force_free_amount), 0)").Scan(&stats.TodayForceFreeAmount).Error; err != nil {
+		return stats, err
+	}
+	return stats, nil
+}
+
+func startOfDay() time.Time {
+	now := time.Now()
+	return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+}

+ 686 - 0
internal/modules/incident/service/service.go

@@ -0,0 +1,686 @@
+package service
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/incident/repository"
+
+	"go.uber.org/zap"
+)
+
+// ===================== 常量 =====================
+
+// 异常状态
+const (
+	StatusPending    = "pending"
+	StatusProcessing = "processing"
+	StatusResolved   = "resolved"
+	StatusClosed     = "closed"
+)
+
+// 异常来源
+const (
+	SourcePassage = "passage"
+	SourceManual  = "manual"
+	SourceDevice  = "device"
+	SourcePayment = "payment"
+	SourcePrinter = "printer"
+	SourceSystem  = "system"
+)
+
+// 异常分类
+const (
+	CategoryGateFailed       = "gate_failed"
+	CategoryDuplicateEntry   = "duplicate_entry"
+	CategoryBlacklist        = "blacklist"
+	CategoryLotFull          = "lot_full"
+	CategoryNoEntryExit      = "no_entry_exit"
+	CategoryManualRaise      = "manual_raise"
+	CategoryDeviceOffline    = "device_offline"
+	CategoryPrintFailed      = "print_failed"
+	CategoryPaymentFailed    = "payment_failed"
+	CategoryPaymentUncertain = "payment_uncertain"
+)
+
+// 异常等级
+const (
+	LevelInfo     = "info"
+	LevelWarning  = "warning"
+	LevelCritical = "critical"
+)
+
+// 处置方式
+const (
+	HandleManualGate     = "manual_gate"
+	HandleForceFree      = "force_free"
+	HandleReset          = "reset"
+	HandleDeviceRepaired = "device_repaired"
+	HandleReprint        = "reprint"
+	HandleIgnore         = "ignore"
+	HandleOther          = "other"
+)
+
+// 指令流水结果
+const (
+	CommandSuccess = "success"
+	CommandFailed  = "failed"
+)
+
+// 资金相关分类(统计与权限关注)
+var fundRelatedCategories = map[string]bool{
+	CategoryPaymentFailed:    true,
+	CategoryPaymentUncertain: true,
+	CategoryGateFailed:       true,
+	CategoryNoEntryExit:      true,
+}
+
+// 分类默认等级
+var categoryDefaultLevel = map[string]string{
+	CategoryGateFailed:       LevelCritical,
+	CategoryDuplicateEntry:   LevelWarning,
+	CategoryBlacklist:        LevelWarning,
+	CategoryLotFull:          LevelInfo,
+	CategoryNoEntryExit:      LevelCritical,
+	CategoryManualRaise:      LevelWarning,
+	CategoryDeviceOffline:    LevelWarning,
+	CategoryPrintFailed:      LevelWarning,
+	CategoryPaymentFailed:    LevelWarning,
+	CategoryPaymentUncertain: LevelCritical,
+}
+
+// 状态机:仅允许以下流转
+var allowedTransitions = map[string]map[string]bool{
+	StatusPending:    {StatusProcessing: true, StatusResolved: true},
+	StatusProcessing: {StatusResolved: true},
+	StatusResolved:   {StatusClosed: true, StatusPending: true},
+	StatusClosed:     {StatusPending: true},
+}
+
+var (
+	ErrIncidentNotFound = errors.New("异常记录不存在")
+	ErrIncidentChanged  = errors.New("异常状态已被他人变更,请刷新后重试")
+)
+
+// ===================== 服务 =====================
+
+// logError/logWarn/logInfo 带 nil 防护的日志(测试环境与单测中 GVA_LOG 可为空)。
+func logError(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Error(msg, fields...)
+	}
+}
+
+func logWarn(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn(msg, fields...)
+	}
+}
+
+func logInfo(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info(msg, fields...)
+	}
+}
+
+type IncidentService struct {
+	repo *repository.IncidentRepository
+}
+
+func NewIncidentService() *IncidentService {
+	return &IncidentService{repo: &repository.IncidentRepository{}}
+}
+
+// RecordIncidentRequest 自动埋点入参。HandleType/HandleRemark 非空时创建即置为已解决
+// (用于人工抬杆等"动作已完成、仅需留痕"的场景)。
+type RecordIncidentRequest struct {
+	Category        string
+	Source          string
+	Level           string
+	VehicleRecordID uint
+	DigitalTicketID uint
+	PaymentRecordID uint
+	GateCommandID   uint
+	TicketNo        string
+	PlateNumber     string
+	RFIDTag         string
+	ParkingLotID    uint
+	ParkingLotName  string
+	ChannelID       uint
+	ChannelCode     string
+	ChannelName     string
+	DeviceCode      string
+	DeviceName      string
+	OperatorID      uint
+	Description     string
+	Detail          interface{}
+	HandleType      string
+	HandleRemark    string
+}
+
+func enrichSessionAssociation(req *RecordIncidentRequest) {
+	var record dao.VehicleRecord
+	if req.VehicleRecordID != 0 && global.GVA_DB.First(&record, req.VehicleRecordID).Error == nil {
+		if req.PlateNumber == "" {
+			req.PlateNumber = record.PlateNumber
+		}
+		if req.RFIDTag == "" {
+			req.RFIDTag = record.RFIDTag
+		}
+		if req.ParkingLotID == 0 {
+			req.ParkingLotID = record.ParkingLotID
+		}
+		if req.ChannelID == 0 {
+			if record.ExitTime != nil && record.ExitChannelID != 0 {
+				req.ChannelID = record.ExitChannelID
+			} else {
+				req.ChannelID = record.EntryChannelID
+			}
+		}
+		if req.ChannelCode == "" {
+			if record.ExitTime != nil && record.ExitChannelCode != "" {
+				req.ChannelCode = record.ExitChannelCode
+			} else {
+				req.ChannelCode = record.EntryChannelCode
+			}
+		}
+		if req.ChannelName == "" {
+			if record.ExitTime != nil && record.ExitChannelName != "" {
+				req.ChannelName = record.ExitChannelName
+			} else {
+				req.ChannelName = record.EntryChannelName
+			}
+		}
+		if req.DeviceCode == "" {
+			if record.ExitTime != nil && record.ExitDeviceCode != "" {
+				req.DeviceCode = record.ExitDeviceCode
+			} else {
+				req.DeviceCode = record.EntryDeviceCode
+			}
+		}
+		if req.DeviceName == "" {
+			if record.ExitTime != nil && record.ExitDeviceName != "" {
+				req.DeviceName = record.ExitDeviceName
+			} else {
+				req.DeviceName = record.EntryDeviceName
+			}
+		}
+	}
+}
+
+// enrichAssociations 根据稳定 ID、票号和设备编码补齐异常上下文快照。
+// 业务埋点通常只知道会话或设备,统一在异常服务中解析,避免各调用方重复查询。
+func enrichAssociations(req *RecordIncidentRequest) {
+	if req == nil || global.GVA_DB == nil {
+		return
+	}
+	enrichSessionAssociation(req)
+
+	var ticket dao.DigitalTicket
+	if req.DigitalTicketID != 0 {
+		_ = global.GVA_DB.First(&ticket, req.DigitalTicketID).Error
+	} else if req.TicketNo != "" {
+		_ = global.GVA_DB.Where("ticket_no = ?", req.TicketNo).First(&ticket).Error
+	} else if req.VehicleRecordID != 0 {
+		_ = global.GVA_DB.Where("vehicle_record_id = ?", req.VehicleRecordID).First(&ticket).Error
+	}
+	if ticket.ID != 0 {
+		req.DigitalTicketID = ticket.ID
+		if req.VehicleRecordID == 0 {
+			req.VehicleRecordID = ticket.VehicleRecordID
+		}
+		if req.TicketNo == "" {
+			req.TicketNo = ticket.TicketNo
+		}
+		if req.PlateNumber == "" {
+			req.PlateNumber = ticket.PlateNumber
+		}
+	}
+	// 仅传票号时会在上一步得到停车会话 ID,此处继续补齐车辆与进出场位置快照。
+	enrichSessionAssociation(req)
+
+	if req.PaymentRecordID == 0 && req.VehicleRecordID != 0 {
+		var payment dao.PaymentRecord
+		if global.GVA_DB.Where("record_id = ?", req.VehicleRecordID).Order("id DESC").First(&payment).Error == nil {
+			req.PaymentRecordID = payment.ID
+		}
+	}
+	if req.GateCommandID != 0 {
+		var command dao.DeviceCommandLog
+		if global.GVA_DB.First(&command, req.GateCommandID).Error == nil {
+			if req.DeviceCode == "" {
+				req.DeviceCode = command.DeviceCode
+			}
+			if req.DeviceName == "" {
+				req.DeviceName = command.DeviceName
+			}
+		}
+	}
+	if req.ChannelID != 0 {
+		var channel dao.Channel
+		if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
+			if req.ChannelCode == "" {
+				req.ChannelCode = channel.ChannelCode
+			}
+			if req.ChannelName == "" {
+				req.ChannelName = channel.ChannelName
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = channel.ParkingLotID
+			}
+		}
+	}
+	if req.DeviceCode != "" {
+		var device dao.UHFReader
+		if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&device).Error == nil {
+			if req.DeviceName == "" {
+				req.DeviceName = device.DeviceName
+			}
+			if req.ChannelID == 0 {
+				req.ChannelID = device.ChannelID
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = device.ParkingLotID
+			}
+		}
+	}
+	// 设备可能刚刚补齐通道 ID,再查询一次通道名称和所属停车场。
+	if req.ChannelID != 0 && (req.ChannelCode == "" || req.ChannelName == "" || req.ParkingLotID == 0) {
+		var channel dao.Channel
+		if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
+			if req.ChannelCode == "" {
+				req.ChannelCode = channel.ChannelCode
+			}
+			if req.ChannelName == "" {
+				req.ChannelName = channel.ChannelName
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = channel.ParkingLotID
+			}
+		}
+	}
+	if req.ParkingLotID != 0 && req.ParkingLotName == "" {
+		var lot dao.ParkingLot
+		if global.GVA_DB.First(&lot, req.ParkingLotID).Error == nil {
+			req.ParkingLotName = lot.LotName
+		}
+	}
+}
+
+func validateAssociationIDs(req CreateIncidentRequest) error {
+	checks := []struct {
+		id    uint
+		model interface{}
+		name  string
+	}{
+		{req.VehicleRecordID, &dao.VehicleRecord{}, "停车会话"},
+		{req.DigitalTicketID, &dao.DigitalTicket{}, "数字票"},
+		{req.PaymentRecordID, &dao.PaymentRecord{}, "支付流水"},
+		{req.GateCommandID, &dao.DeviceCommandLog{}, "闸机指令"},
+		{req.ParkingLotID, &dao.ParkingLot{}, "停车场"},
+		{req.ChannelID, &dao.Channel{}, "通道"},
+	}
+	for _, check := range checks {
+		if check.id == 0 {
+			continue
+		}
+		var count int64
+		if err := global.GVA_DB.Model(check.model).Where("id = ?", check.id).Count(&count).Error; err != nil {
+			return err
+		}
+		if count == 0 {
+			return fmt.Errorf("关联的%s不存在: %d", check.name, check.id)
+		}
+	}
+	return nil
+}
+
+// RecordIncident 自动埋点入口(best-effort):
+// 内部任何错误只记日志并返回 0,绝不影响业务主流程;成功返回异常记录 ID(供指令流水关联)。
+func (s *IncidentService) RecordIncident(req RecordIncidentRequest) uint {
+	if req.Category == "" {
+		return 0
+	}
+	if global.GVA_DB == nil {
+		logWarn("异常埋点跳过:数据库未初始化", zap.String("category", req.Category))
+		return 0
+	}
+	enrichAssociations(&req)
+	if req.Source == "" {
+		req.Source = SourceSystem
+	}
+	if req.Level == "" {
+		req.Level = categoryDefaultLevel[req.Category]
+		if req.Level == "" {
+			req.Level = LevelWarning
+		}
+	}
+
+	// 去重:离线异常同设备未关闭不重复;通行类同标识时间窗内不重复
+	var exists bool
+	var err error
+	if req.Category == CategoryDeviceOffline {
+		exists, err = s.repo.HasOpenDeviceOffline(req.DeviceCode)
+	} else {
+		exists, err = s.repo.ExistsRecent(req.DeviceCode, req.Category, req.PlateNumber, req.RFIDTag, 5)
+	}
+	if err != nil {
+		logWarn("异常去重查询失败,继续记录", zap.Error(err))
+	} else if exists {
+		return 0
+	}
+
+	status := StatusPending
+	if req.HandleType != "" {
+		status = StatusResolved
+	}
+	detail := ""
+	if req.Detail != nil {
+		if data, marshalErr := json.Marshal(req.Detail); marshalErr == nil {
+			detail = string(data)
+			if len(detail) > 1000 {
+				detail = detail[:1000]
+			}
+		}
+	}
+
+	no, err := s.generateIncidentNo()
+	if err != nil {
+		logError("生成异常编号失败", zap.Error(err))
+		return 0
+	}
+
+	now := time.Now()
+	event := map[string]interface{}{"event": "created", "source": req.Source}
+	record := &dao.IncidentRecord{
+		IncidentNo:      no,
+		Category:        req.Category,
+		Source:          req.Source,
+		Level:           req.Level,
+		Status:          status,
+		VehicleRecordID: req.VehicleRecordID,
+		DigitalTicketID: req.DigitalTicketID,
+		PaymentRecordID: req.PaymentRecordID,
+		GateCommandID:   req.GateCommandID,
+		TicketNo:        req.TicketNo,
+		PlateNumber:     req.PlateNumber,
+		RFIDTag:         req.RFIDTag,
+		ParkingLotID:    req.ParkingLotID,
+		ParkingLotName:  req.ParkingLotName,
+		ChannelID:       req.ChannelID,
+		ChannelCode:     req.ChannelCode,
+		ChannelName:     req.ChannelName,
+		DeviceCode:      req.DeviceCode,
+		DeviceName:      req.DeviceName,
+		OperatorID:      req.OperatorID,
+		Description:     req.Description,
+		Detail:          detail,
+		HandlerID:       req.OperatorID,
+		HandleType:      req.HandleType,
+		HandleRemark:    req.HandleRemark,
+		EventLog:        appendEvent("[]", event),
+	}
+	if status == StatusResolved {
+		record.HandledAt = &now
+	}
+
+	if err := s.repo.Create(record); err != nil {
+		logError("记录异常事件失败",
+			zap.Error(err), zap.String("category", req.Category), zap.String("device_code", req.DeviceCode))
+		return 0
+	}
+	logInfo("异常事件已记录",
+		zap.String("incident_no", no), zap.String("category", req.Category), zap.String("status", status))
+	return record.ID
+}
+
+// CreateIncidentRequest 人工上报入参。
+type CreateIncidentRequest struct {
+	Category        string `json:"category" binding:"required"`
+	Level           string `json:"level"`
+	VehicleRecordID uint   `json:"vehicle_record_id"`
+	DigitalTicketID uint   `json:"digital_ticket_id"`
+	PaymentRecordID uint   `json:"payment_record_id"`
+	GateCommandID   uint   `json:"gate_command_id"`
+	TicketNo        string `json:"ticket_no"`
+	PlateNumber     string `json:"plate_number"`
+	RFIDTag         string `json:"rfid_tag"`
+	ParkingLotID    uint   `json:"parking_lot_id"`
+	ParkingLotName  string `json:"parking_lot_name"`
+	ChannelID       uint   `json:"channel_id"`
+	ChannelCode     string `json:"channel_code"`
+	ChannelName     string `json:"channel_name"`
+	DeviceCode      string `json:"device_code"`
+	DeviceName      string `json:"device_name"`
+	Description     string `json:"description" binding:"required"`
+	Detail          string `json:"detail"`
+}
+
+// CreateIncident 人工上报异常(来源 manual,待处理状态)。
+func (s *IncidentService) CreateIncident(req CreateIncidentRequest, operatorID uint) (*dao.IncidentRecord, error) {
+	if _, known := categoryDefaultLevel[req.Category]; !known {
+		return nil, errors.New("未知异常分类: " + req.Category)
+	}
+	if err := validateAssociationIDs(req); err != nil {
+		return nil, err
+	}
+	level := req.Level
+	if level == "" {
+		level = categoryDefaultLevel[req.Category]
+		if level == "" {
+			level = LevelWarning
+		}
+	}
+	autoReq := RecordIncidentRequest{
+		VehicleRecordID: req.VehicleRecordID, DigitalTicketID: req.DigitalTicketID, PaymentRecordID: req.PaymentRecordID, GateCommandID: req.GateCommandID,
+		TicketNo: req.TicketNo, PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, ParkingLotID: req.ParkingLotID, ParkingLotName: req.ParkingLotName,
+		ChannelID: req.ChannelID, ChannelCode: req.ChannelCode, ChannelName: req.ChannelName, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName,
+	}
+	enrichAssociations(&autoReq)
+	no, err := s.generateIncidentNo()
+	if err != nil {
+		return nil, err
+	}
+	record := &dao.IncidentRecord{
+		IncidentNo:      no,
+		Category:        req.Category,
+		Source:          SourceManual,
+		Level:           level,
+		Status:          StatusPending,
+		VehicleRecordID: autoReq.VehicleRecordID,
+		DigitalTicketID: autoReq.DigitalTicketID,
+		PaymentRecordID: autoReq.PaymentRecordID,
+		GateCommandID:   autoReq.GateCommandID,
+		TicketNo:        autoReq.TicketNo,
+		PlateNumber:     autoReq.PlateNumber,
+		RFIDTag:         autoReq.RFIDTag,
+		ParkingLotID:    autoReq.ParkingLotID,
+		ParkingLotName:  autoReq.ParkingLotName,
+		ChannelID:       autoReq.ChannelID,
+		ChannelCode:     autoReq.ChannelCode,
+		ChannelName:     autoReq.ChannelName,
+		DeviceCode:      autoReq.DeviceCode,
+		DeviceName:      autoReq.DeviceName,
+		OperatorID:      operatorID,
+		Description:     req.Description,
+		Detail:          req.Detail,
+		EventLog:        appendEvent("[]", map[string]interface{}{"event": "created", "source": SourceManual}),
+	}
+	if err := s.repo.Create(record); err != nil {
+		return nil, err
+	}
+	return record, nil
+}
+
+// TransitionRequest 状态流转+处置入参。
+type TransitionRequest struct {
+	ToStatus        string  `json:"to_status" binding:"required"`
+	HandleType      string  `json:"handle_type"`
+	HandleRemark    string  `json:"handle_remark"`
+	ForceFreeAmount float64 `json:"force_free_amount"`
+}
+
+// TransitionIncident 状态流转(CAS 条件更新)。
+// isAdmin 表示是否允许资金类处置(force_free)。
+func (s *IncidentService) TransitionIncident(id uint, req TransitionRequest, operatorID uint, isAdmin bool) (*dao.IncidentRecord, error) {
+	incident, err := s.repo.GetByID(id)
+	if err != nil {
+		return nil, ErrIncidentNotFound
+	}
+
+	toStatus := req.ToStatus
+	if toStatus == incident.Status {
+		return incident, nil
+	}
+	if !allowedTransitions[incident.Status][toStatus] {
+		return nil, fmt.Errorf("状态转换不合法: %s → %s", incident.Status, toStatus)
+	}
+
+	// 处置校验
+	if toStatus == StatusResolved {
+		if req.HandleType == "" {
+			return nil, errors.New("解决异常必须填写处置方式")
+		}
+		if req.HandleRemark == "" {
+			return nil, errors.New("解决异常必须填写处置备注")
+		}
+	}
+	if req.HandleType == HandleForceFree {
+		if !isAdmin {
+			return nil, errors.New("强制免费处置需要管理员权限")
+		}
+		if req.ForceFreeAmount < 0 {
+			return nil, errors.New("强制免费金额不能为负数")
+		}
+	}
+
+	updates := map[string]interface{}{
+		"handler_id":    operatorID,
+		"handle_type":   req.HandleType,
+		"handle_remark": req.HandleRemark,
+	}
+	event := map[string]interface{}{"event": toStatus, "handler_id": operatorID}
+	if toStatus == StatusResolved {
+		now := time.Now()
+		updates["handled_at"] = now
+		updates["force_free_amount"] = req.ForceFreeAmount
+		event["handle_type"] = req.HandleType
+		if req.HandleType == HandleForceFree {
+			event["force_free_amount"] = req.ForceFreeAmount
+		}
+	}
+	updates["event_log"] = appendEvent(incident.EventLog, event)
+
+	updated, err := s.repo.UpdateStateIfCurrent(id, incident.Status, toStatus, updates)
+	if err != nil {
+		return nil, err
+	}
+	if !updated {
+		return nil, ErrIncidentChanged
+	}
+	return s.repo.GetByID(id)
+}
+
+// GetIncident 查询详情。
+func (s *IncidentService) GetIncident(id uint) (*dao.IncidentRecord, error) {
+	return s.repo.GetByID(id)
+}
+
+// ListIncidents 分页筛选。
+func (s *IncidentService) ListIncidents(q repository.IncidentListQuery) ([]repository.IncidentListItem, int64, error) {
+	return s.repo.List(q)
+}
+
+// GetIncidentStats 异常统计。
+func (s *IncidentService) GetIncidentStats() (repository.IncidentStats, error) {
+	return s.repo.Stats()
+}
+
+// ResolveDeviceOffline 设备恢复在线:关闭该设备未处理的离线异常(best-effort)。
+func (s *IncidentService) ResolveDeviceOffline(deviceCode string) {
+	if err := s.repo.ResolveDeviceOfflineByDevice(deviceCode, "设备恢复在线"); err != nil {
+		logWarn("关闭设备离线异常失败",
+			zap.Error(err), zap.String("device_code", deviceCode))
+	}
+}
+
+// GateCommandRecord 道闸指令流水入参。
+type GateCommandRecord struct {
+	DeviceCode   string
+	DeviceName   string
+	Action       string // open / close
+	Source       string // passage / manual / test
+	OperatorID   uint
+	SessionID    uint
+	Result       string // success / failed
+	ErrorMessage string
+	DurationMs   int64
+}
+
+// RecordGateCommand 记录道闸指令流水(best-effort),返回流水 ID 供异常关联。
+func (s *IncidentService) RecordGateCommand(rec GateCommandRecord) uint {
+	if rec.DeviceCode == "" {
+		return 0
+	}
+	if rec.Source == "" {
+		rec.Source = SourcePassage
+	}
+	log := &dao.DeviceCommandLog{
+		DeviceCode:   rec.DeviceCode,
+		DeviceName:   rec.DeviceName,
+		Action:       rec.Action,
+		Source:       rec.Source,
+		OperatorID:   rec.OperatorID,
+		SessionID:    rec.SessionID,
+		Result:       rec.Result,
+		ErrorMessage: rec.ErrorMessage,
+		DurationMs:   rec.DurationMs,
+	}
+	if err := s.repo.CreateCommandLog(log); err != nil {
+		logWarn("记录道闸指令流水失败", zap.Error(err))
+		return 0
+	}
+	return log.ID
+}
+
+// LinkIncidentToCommand 回填指令流水关联的异常事件 ID(best-effort)。
+func (s *IncidentService) LinkIncidentToCommand(logID, incidentID uint) {
+	if err := s.repo.UpdateIncidentID(logID, incidentID); err != nil {
+		logWarn("回填指令流水异常关联失败", zap.Error(err))
+	}
+}
+
+// generateIncidentNo 生成唯一编号:INC + yyyyMMdd + 当日序号(冲突时重试)。
+func (s *IncidentService) generateIncidentNo() (string, error) {
+	prefix := "INC" + time.Now().Format("20060102")
+	for attempt := 0; attempt < 5; attempt++ {
+		var count int64
+		if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+			Where("incident_no LIKE ?", prefix+"%").Count(&count).Error; err != nil {
+			return "", err
+		}
+		no := fmt.Sprintf("%s-%04d", prefix, count+1)
+		var dup int64
+		if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+			Where("incident_no = ?", no).Count(&dup).Error; err != nil {
+			return "", err
+		}
+		if dup == 0 {
+			return no, nil
+		}
+	}
+	return "", errors.New("生成异常编号失败,请重试")
+}
+
+// appendEvent 向事件日志 JSON 数组追加事件(沿用数字票 event_log 约定)。
+func appendEvent(log string, event map[string]interface{}) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	event["at"] = time.Now().Format(time.RFC3339)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}

+ 330 - 0
internal/modules/incident/service/service_test.go

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

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

@@ -9,10 +9,20 @@ import (
 	"go.uber.org/zap"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	incidentService "wails-app/internal/modules/incident/service"
 )
 
 const defaultGateValidTime byte = 2
 
+var incidentSvc = incidentService.NewIncidentService()
+
+// GateCommandContext 道闸指令上下文(用于指令流水审计)。
+type GateCommandContext struct {
+	Source     string // passage / manual / test
+	OperatorID uint
+	SessionID  uint
+}
+
 // GateController 统一道闸设备控制接口,具体协议由设备模块实现。
 type GateController interface {
 	OpenGate(deviceCode string, validTime byte) error
@@ -143,6 +153,53 @@ func (s *PassageService) CloseGateByDeviceCode(deviceCode string, validTime byte
 	return controller.CloseGate(deviceCode, validTime)
 }
 
+// OpenGateWithContext 开闸并记录指令流水(所有开闸路径的审计入口),返回流水 ID。
+func (s *PassageService) OpenGateWithContext(ctx GateCommandContext, deviceCode string, validTime byte) (uint, error) {
+	return s.runGateCommand(ctx, "open", deviceCode, validTime)
+}
+
+// CloseGateWithContext 关闸并记录指令流水,返回流水 ID。
+func (s *PassageService) CloseGateWithContext(ctx GateCommandContext, deviceCode string, validTime byte) (uint, error) {
+	return s.runGateCommand(ctx, "close", deviceCode, validTime)
+}
+
+// runGateCommand 执行道闸指令并写指令流水(best-effort,流水失败不影响指令结果)。
+func (s *PassageService) runGateCommand(ctx GateCommandContext, action, deviceCode string, validTime byte) (uint, error) {
+	if ctx.Source == "" {
+		ctx.Source = "passage"
+	}
+	start := time.Now()
+	var err error
+	if action == "open" {
+		err = s.OpenGateByDeviceCode(deviceCode, validTime)
+	} else {
+		err = s.CloseGateByDeviceCode(deviceCode, validTime)
+	}
+
+	rec := incidentService.GateCommandRecord{
+		DeviceCode: deviceCode,
+		Action:     action,
+		Source:     ctx.Source,
+		OperatorID: ctx.OperatorID,
+		SessionID:  ctx.SessionID,
+		DurationMs: time.Since(start).Milliseconds(),
+	}
+	if err != nil {
+		rec.Result = incidentService.CommandFailed
+		rec.ErrorMessage = err.Error()
+	} else {
+		rec.Result = incidentService.CommandSuccess
+	}
+	// 设备名称快照(查询失败不影响指令结果)
+	var device dao.UHFReader
+	if global.GVA_DB != nil {
+		if qerr := global.GVA_DB.Where("device_code = ?", deviceCode).First(&device).Error; qerr == nil {
+			rec.DeviceName = device.DeviceName
+		}
+	}
+	return incidentSvc.RecordGateCommand(rec), err
+}
+
 // ListGateDevices 返回已启用且已绑定通道的道闸设备。
 func (s *PassageService) ListGateDevices() ([]GateDeviceOption, error) {
 	if global.GVA_DB == nil {

+ 151 - 0
internal/service/parking/incident_hook_test.go

@@ -0,0 +1,151 @@
+package parking
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+	"time"
+
+	"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"
+)
+
+// failingGateController 注入失败的闸机控制器,模拟开闸失败。
+type failingGateController struct{}
+
+func (c *failingGateController) OpenGate(deviceCode string, _ byte) error {
+	return errors.New("relay timeout")
+}
+
+func (c *failingGateController) CloseGate(deviceCode string, _ byte) error {
+	return errors.New("relay timeout")
+}
+
+func (c *failingGateController) IsGateConnected(string) bool { return true }
+
+func setupIncidentHookTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:incident-hook-%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() // 避免 nil 日志
+	t.Cleanup(func() { global.GVA_LOG = nil })
+	require.NoError(t, db.AutoMigrate(&dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.UHFReader{}))
+}
+
+// TestOpenGateFailureCreatesIncidentAndCommandLog 开闸失败时:
+// 指令流水(failed) + 异常事件(gate_failed) 同时产生,且流水回填异常关联。
+func TestOpenGateFailureCreatesIncidentAndCommandLog(t *testing.T) {
+	setupIncidentHookTest(t)
+	previous := currentGateController()
+	SetGateController(&failingGateController{})
+	t.Cleanup(func() { SetGateController(previous) })
+
+	require.NoError(t, global.GVA_DB.Create(&dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东门道闸"}).Error)
+
+	svc := &PassageService{}
+	commandLogID, err := svc.OpenGateWithContext(GateCommandContext{
+		Source: incidentService.SourcePassage, SessionID: 7,
+	}, "GATE-01", 0)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "relay timeout")
+
+	// 调用方(passage.go 同款逻辑):记录异常并回填指令流水关联
+	incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryGateFailed,
+		Source:          incidentService.SourcePassage,
+		VehicleRecordID: 7,
+		DeviceCode:      "GATE-01",
+		Description:     "入场记录已创建,但开闸失败",
+		Detail:          err.Error(),
+	})
+	require.NotZero(t, incidentID)
+	incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
+
+	// 指令流水已写入且为 failed
+	require.NotZero(t, commandLogID)
+	var cmd dao.DeviceCommandLog
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, "open", cmd.Action)
+	require.Equal(t, incidentService.CommandFailed, cmd.Result)
+	require.Equal(t, "东门道闸", cmd.DeviceName)
+	require.Equal(t, uint(7), cmd.SessionID)
+
+	// 异常事件已生成(gate_failed)
+	var incident dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.Where("category = ?", incidentService.CategoryGateFailed).First(&incident).Error)
+	require.Equal(t, incidentService.StatusPending, incident.Status)
+	require.Equal(t, incidentService.SourcePassage, incident.Source)
+	require.Equal(t, incidentService.LevelCritical, incident.Level)
+	require.Equal(t, "GATE-01", incident.DeviceCode)
+
+	// 指令流水回填了异常关联
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, incident.ID, cmd.IncidentID)
+}
+
+// TestOpenGateSuccessRecordsCommandLog 开闸成功只写指令流水,不产生异常。
+func TestOpenGateSuccessRecordsCommandLog(t *testing.T) {
+	setupIncidentHookTest(t)
+	previous := currentGateController()
+	SetGateController(&gateControllerStub{})
+	t.Cleanup(func() { SetGateController(previous) })
+
+	svc := &PassageService{}
+	commandLogID, err := svc.OpenGateWithContext(GateCommandContext{
+		Source: incidentService.SourceManual, OperatorID: 5,
+	}, "GATE-01", 0)
+	require.NoError(t, err)
+	require.NotZero(t, commandLogID)
+
+	var cmd dao.DeviceCommandLog
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, incidentService.CommandSuccess, cmd.Result)
+	require.Equal(t, incidentService.SourceManual, cmd.Source)
+	require.Equal(t, uint(5), cmd.OperatorID)
+
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error)
+	require.Equal(t, int64(0), count)
+}
+
+// TestManualRaiseRecordedAsResolved 人工抬杆(带原因)应生成已解决的 manual_raise 记录。
+func TestManualRaiseRecordedAsResolved(t *testing.T) {
+	setupIncidentHookTest(t)
+
+	// 模拟 API 层人工开闸成功后的埋点调用
+	incidentID := incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: 3,
+		PlateNumber:     "SU-001",
+		DeviceCode:      "GATE-01",
+		OperatorID:      5,
+		Description:     "人工开闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    "车主称设备未识别",
+	})
+	require.NotZero(t, incidentID)
+
+	var incident dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error)
+	require.Equal(t, incidentService.StatusResolved, incident.Status)
+	require.Equal(t, incidentService.HandleManualGate, incident.HandleType)
+	require.Equal(t, "车主称设备未识别", incident.HandleRemark)
+	require.NotNil(t, incident.HandledAt)
+	require.Equal(t, uint(5), incident.HandlerID)
+}

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

@@ -3,12 +3,15 @@ package parking
 import (
 	"errors"
 	"fmt"
+	"strings"
 	"sync"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	common "wails-app/internal/model/common"
 	"wails-app/internal/model/vehicle/request"
+	incidentService "wails-app/internal/modules/incident/service"
+	sessionService "wails-app/internal/modules/parking-session/service"
 	"wails-app/internal/service/vehicle"
 )
 
@@ -91,6 +94,18 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
 	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,
+			ParkingLotID: channel.ParkingLotID,
+			ChannelID:   channel.ID,
+			ChannelCode: channel.ChannelCode,
+			DeviceCode:  req.DeviceCode,
+			OperatorID:  req.OperatorID,
+			Description: "黑名单车辆通行拦截",
+		})
 		return nil, fmt.Errorf("车辆在黑名单中")
 	}
 
@@ -128,6 +143,18 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	passageMutex.Unlock()
 
 	if !canIn {
+		incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			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: "车辆已在场内,重复入场拦截",
+		})
 		return nil, fmt.Errorf("车辆已在场内: %s", identifier)
 	}
 
@@ -146,6 +173,21 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 		TriggerMode:      req.TriggerSource,
 	})
 	if err != nil {
+		if errors.Is(err, sessionService.ErrParkingLotFull) {
+			incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+				Category:     incidentService.CategoryLotFull,
+				Source:       incidentService.SourcePassage,
+				PlateNumber:  req.PlateNumber,
+				RFIDTag:      req.RFIDTag,
+				ParkingLotID: parkingLotID,
+				ChannelID:    channel.ID,
+				ChannelCode:  channel.ChannelCode,
+				DeviceCode:   req.DeviceCode,
+				OperatorID:   req.OperatorID,
+				Description:  "停车场满位,入场被拒绝",
+				Detail:       err.Error(),
+			})
+		}
 		return nil, err
 	}
 
@@ -169,10 +211,30 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	}
 
 	fmt.Printf("🟢 入口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
-	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+	commandLogID, err := s.OpenGateWithContext(GateCommandContext{
+		Source:    incidentService.SourcePassage,
+		SessionID: resp.Data.SessionID,
+	}, req.DeviceCode, defaultGateValidTime)
+	if err != nil {
 		result.Success = false
 		result.Message = "入场记录已创建,但开闸失败"
 		result.GateStatus = "failed"
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourcePassage,
+			VehicleRecordID: resp.Data.SessionID,
+			TicketNo:        resp.Data.TicketNo,
+			PlateNumber:     resp.Data.PlateNumber,
+			RFIDTag:         req.RFIDTag,
+			ParkingLotID:    parkingLotID,
+			ChannelID:       channel.ID,
+			ChannelCode:     channel.ChannelCode,
+			DeviceCode:      req.DeviceCode,
+			OperatorID:      req.OperatorID,
+			Description:     "入场记录已创建,但开闸失败",
+			Detail:          err.Error(),
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		return result, gateOperationError("开闸", req.DeviceCode, err)
 	}
 	result.GateOpened = true
@@ -205,6 +267,23 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	}
 	resp, err := vehicleSvc.ExitConfirm(confirmReq, req.OperatorID)
 	if err != nil {
+		// 无入场记录/丢票出场(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,
+				ParkingLotID: channel.ParkingLotID,
+				ChannelID:   channel.ID,
+				ChannelCode: channel.ChannelCode,
+				DeviceCode:  req.DeviceCode,
+				OperatorID:  req.OperatorID,
+				Description: "无入场记录出场或票据不存在",
+				Detail:      err.Error(),
+			})
+		}
 		return nil, err
 	}
 
@@ -228,10 +307,30 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	}
 
 	fmt.Printf("🔴 出口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
-	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+	commandLogID, err := s.OpenGateWithContext(GateCommandContext{
+		Source:    incidentService.SourcePassage,
+		SessionID: req.SessionID,
+	}, req.DeviceCode, defaultGateValidTime)
+	if err != nil {
 		result.Success = false
 		result.Message = "出场结算已完成,但开闸失败"
 		result.GateStatus = "failed"
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourcePassage,
+			VehicleRecordID: req.SessionID,
+			TicketNo:        req.TicketNo,
+			PlateNumber:     resp.PlateNumber,
+			RFIDTag:         req.RFIDTag,
+			ParkingLotID:    channel.ParkingLotID,
+			ChannelID:       channel.ID,
+			ChannelCode:     channel.ChannelCode,
+			DeviceCode:      req.DeviceCode,
+			OperatorID:      req.OperatorID,
+			Description:     "出场结算已完成,但开闸失败",
+			Detail:          err.Error(),
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		return result, gateOperationError("开闸", req.DeviceCode, err)
 	}
 	result.GateOpened = true

+ 28 - 14
internal/service/uhf/reader.go

@@ -11,6 +11,7 @@ import (
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	common "wails-app/internal/model/common"
+	incidentService "wails-app/internal/modules/incident/service"
 	"wails-app/internal/service"
 	"wails-app/internal/service/parking"
 )
@@ -243,22 +244,33 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 			global.GVA_LOG.Info("设备已停止 code:" + device.DeviceCode)
 		}()
 
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			default:
-				buf, err := r.ReadData()
-				if err != nil {
-					global.GVA_DB.Model(device).Update("status", "offline")
-					_ = r.Disconnect() // 先断开
-					time.Sleep(2 * time.Second)
-					if err := r.Connect(); err != nil {
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				default:
+					buf, err := r.ReadData()
+					if err != 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
+						}
+						global.GVA_DB.Model(device).Update("status", "online")
+						// 设备恢复在线:自动关闭未处理的离线异常
+						incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 						continue
 					}
-					global.GVA_DB.Model(device).Update("status", "online")
-					continue
-				}
 
 				if len(buf) == 0 {
 					continue
@@ -296,6 +308,8 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 	}()
 
 	global.GVA_DB.Model(device).Update("status", "online")
+	// 启动时兜底:关闭历史遗留的同设备离线异常
+	incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 	return nil
 }