更新日期: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/等)。
HandlePassage 中,业务落库成功但开闸失败时返回 result + err(passage.go:172-176 入场、passage.go:231-235 出场),GateStatus="failed"、BusinessCompleted=true。该"业务已完成、设备动作失败"的半完成状态不落库,重启后无从追踪。printer 模块)同样只写在响应里(PrintStatus="failed"),无持久化任务、重试、重打和作废机制(重打机制属 P0 打印任务持久化,本文不覆盖,但打印失败事件可先挂入异常表,见 7.4)。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)。
| 状态来源 | 位置 | 问题 |
|---|---|---|
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 恒真,设备真实掉线无法被现有链路感知。
POST /parking/gate/open|close(internal/api/v1/parking/gate.go)仅调用 OpenGateByDeviceCode,不关联会话、不记录操作原因。OperationRecord 中间件只记录原始 HTTP 请求,无法表达"为什么抬杆、抬杆对应哪辆车/哪个会话"。
| 机制 | 位置 | 复用方式 |
|---|---|---|
| 数字票事件日志 | 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(统计行 + 筛选 + 表格 + 分页) |
异常处置页面直接复用该结构 |
一次"需要被记录并可能被人工介入"的事件,事件本身与处置过程合一,落在同一张表:
pending → processing → resolved → closed 状态流转,处置信息(方式、金额、备注、处理人)随记录保存。event_log 保存每次流转的事件时间线;API 请求由 OperationRecord 中间件留痕;人工放行与资金相关处置有独立权限。新增模块 internal/modules/incident/,表模型 internal/dao/incident.go:
internal/modules/incident/
├─ api.go # HTTP Handler + SetupIncidentRouter
├─ service/
│ ├─ service.go # 领域校验、状态机、埋点入口、统计
│ └─ service_test.go
└─ repository/
└─ repo.go # 分页查询、条件更新、去重查询
模块边界:incident 只记录异常事实与处置过程,不修改会话、数字票、支付流水和道闸状态;需要联动修正业务数据时(如强制免费后补结算),由调用方在业务事务内先完成修正、再调用埋点记录,异常表不参与业务事务。
incident_recordtype 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。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 | (预留,外部支付回调接入后接入) |
HandleType| 值 | 中文 | 说明 |
|---|---|---|
manual_gate |
人工抬杆放行 | 对应 manual_raise 类异常,需备注原因 |
force_free |
强制免费 | 资金类,必须填金额,仅最高管理员 |
reset |
状态重置/修正 | 修正会话、票或余位数据后关闭 |
device_repaired |
设备修复 | 设备恢复后由系统自动或人工确认 |
reprint |
重新打印 | 预留,配合打印任务持久化 |
ignore |
误报忽略 | 需备注误报原因 |
other |
其他 | 需备注 |
device_command_log(建议包含,可裁剪)配合人工抬杆审计与开闸失败追踪,记录每次道闸开/关命令:
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 关联。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)。
pending。manual_raise 人工抬杆:创建时即为 resolved + handle_type=manual_gate(原因即备注),因为动作已完成、仅需留痕;如需复核可人工 reopen。device_offline:设备重连时,系统将同设备未关闭(pending/processing)的离线记录批量置为 resolved + device_repaired,仍保留人工确认入口。force_free)在服务层校验角色权限(见 6.2)。internal/modules/incident/service/)| 方法 | 用途 |
|---|---|
RecordIncident(ctx RecordIncidentRequest) |
自动埋点入口,best-effort:内部错误只记日志并返回 nil,不抛给业务调用方 |
CreateIncident(req) |
人工上报(API 层调用),返回完整记录 |
ListIncidents(q) |
分页 + 多条件筛选(状态/分类/等级/来源/车牌/票号/停车场/设备/日期范围/上报人) |
GetIncident(id) |
详情(含 event_log 解析后的时间线) |
TransitionIncident(id, req) |
状态流转 + 处置信息,CAS 更新 |
GetIncidentStats() |
统计:按状态计数(待处理/处理中/已解决/已关闭)、今日新增、资金类待处理数量 |
ResolveDeviceOffline(deviceCode) |
设备重连时自动关闭离线异常(7.3 调用) |
RecordIncident 的入参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
}
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)。
| 分类 | 去重规则 |
|---|---|
device_offline |
同设备存在 pending/processing 的离线记录时不重复生成 |
gate_failed/duplicate_entry 等通行类 |
同 device_code + plate_number/rfid + category 在 5 分钟内不重复生成(防抖复用 passage.go 的 3 秒防抖窗口之上再加事件级窗口) |
| 其他 | 不做自动去重,人工判断 |
RecordIncident(独立写)。gate_failed、状态 pending,备注写明人工操作失败原因,供复核。TransitionIncident 单条更新,无需跨表事务(event_log 追加与状态更新在同一 UPDATE 内拼 JSON 完成)。/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 只到路径粒度,资金类需在业务层校验权限,防止低权限角色直接调用)。
仿 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)。view/report/sheet.vue,已授权 618/888/9527),不新增菜单,仅重写页面。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 内部吞错,调用前后业务代码零改动语义。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(可选字段)则一并写入;不带也可(纯设备操作留痕)。internal/service/uhf/reader.go、conn_manager.go)ReadData 出错置 status=offline 的分支(reader.go:251-261)与 conn_manager.go:72 连接断开检测处。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 追踪)。RecordIncident(分类 print_failed),并支持 reprint 处置方式重打。payment_failed/payment_uncertain);POS/缴费机终端超时时直接生成 payment_uncertain 工单引导人工核对(呼应 doc/收费流程.md 第 5.5 条)。统一在 OpenGateByDeviceCode/CloseGateByDeviceCode 内包一层:
start := time.Now()
err := controller.OpenGate(deviceCode, validTime)
logCommand(DeviceCommandLog{DeviceCode, Action: "open", Source: 来源, Result: success/failed, ErrorMessage, DurationMs})
source=passage,人工 API 时 source=manual(由调用方经参数传入,封装函数加一个 source/operator 可选参数,缺省 passage)。incident_id 关联 gate_failed 记录(先建异常、后写流水,拿到 ID 回填)。view/report/sheet.vue 重写)结构完全复用 paymentRecord.vue 蓝本:
el-statistic × 4):待处理、处理中、今日新增、资金类待处理(金额合计,供管理层关注)。el-form inline):状态 el-select、分类 el-select、等级 el-select、车牌 el-input、票号 el-input、停车场 el-select(数据源 obtainPullOverList,同 revenue.vue)、日期范围 el-date-picker、搜索/重置按钮。el-table + el-table-column):编号、分类(tag 颜色区分)、等级(tag:info 蓝 / warning 橙 / critical 红)、状态(tag:待处理 warning / 处理中 primary / 已解决 success / 已关闭 info)、车牌、停车场、通道/设备、描述(show-overflow-tooltip)、上报时间、处理人、操作(详情/处理)。el-pagination(page/page_size/total,同 paymentRecord.vue)。el-drawer):完整字段 + event_log 时间线(el-timeline)+ 处置表单(目标状态、处置方式 el-select、强制免费金额 el-input-number(仅 force_free 显示)、备注 el-input textarea)。处置按钮按角色控制:618 只读,888/9527 可处置,force_free 仅 888 可提交(前端隐藏 + 后端强制校验双保险)。methodName 兜底模式)。新增 frontend/src/api/incident.js(仿 shift.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' })
RegisterTables(internal/initialize/gorm.go)的 AutoMigrate 列表追加 dao.IncidentRecord{}(及 dao.DeviceCommandLog{},若采纳指令流水)。AutoMigrate 只建新表,不改旧表,无历史数据兼容问题。passage.go、gate.go、reader.go 的现有返回值与事务边界不变;人工抬杆 API 的 reason 为可选参数,旧客户端不受影响。沿用现有测试模式(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 被拒 |
dao.IncidentRecord(+ dao.DeviceCommandLog)+ RegisterTables 注册。EnsureIncidentPermissions() + SeedSystemData 挂载。api/incident.js + view/report/sheet.vue 重写。npm run build 前端构建验证。doc/功能缺失.md 2.2)| 文档 | 衔接点 |
|---|---|
doc/功能缺失.md |
本文对应 2.2 节 P0 项;实施完成后从缺失清单移除,并同步 2.3(打印补偿)与 3.6(设备模型)的追踪 |
doc/项目进度.md |
实施完成后在"待办与优先级"中更新异常处置闭环状态 |
doc/代码规划.md |
模块结构与边界遵循第 2、3 节约定;incident 加入模块清单 |
doc/收费流程.md |
支付失败/不确定埋点在支付订单层落地时按第 5.5 条"进入人工核对"原则实现 |