异常处置设计.md 26 KB

异常处置闭环设计

更新日期:2026-08-12 状态说明:本文为设计文档 已于 2026-08-12 按本文实现完毕(后端模块、自动埋点、指令流水、权限种子、前端页面、自动化测试均落地,go test ./internal/service/... ./internal/modules/... ./internal/initialize 与前端 npm run build 通过)。文中标注(预留)的项为后续扩展点,不在当前范围;标注(建议包含)的设备指令流水已实现。 代码位置:设计基于 2026-08-12 当前代码(internal/service/parking/passage.gointernal/service/parking/gate.gointernal/modules/ 等)。

1. 现状与问题

1.1 异常只存在于 HTTP 响应

  • 统一通行 HandlePassage 中,业务落库成功但开闸失败时返回 result + errpassage.go:172-176 入场、passage.go:231-235 出场),GateStatus="failed"BusinessCompleted=true。该"业务已完成、设备动作失败"的半完成状态不落库,重启后无从追踪。
  • 票机打印失败(printer 模块)同样只写在响应里(PrintStatus="failed"),无持久化任务、重试、重打和作废机制(重打机制属 P0 打印任务持久化,本文不覆盖,但打印失败事件可先挂入异常表,见 7.4)。

1.2 无任何异常实体

vehicle_recordpayment_recordshift_recordprintercamerauhf_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 GetGateRuntimeStatusgate.go:104-114 仪表盘设备状态据此统计,重启即失效
LastOnlineTime internal/dao/uhf_reader.go 死字段,全项目无人写入

模拟道闸(config.yamlgate-simulator: true)下所有设备 IsGateConnected 恒真,设备真实掉线无法被现有链路感知。

1.4 人工抬杆无业务审计

POST /parking/gate/open|closeinternal/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

internal/modules/incident/
├─ api.go                  # HTTP Handler + SetupIncidentRouter
├─ service/
│  ├─ service.go           # 领域校验、状态机、埋点入口、统计
│  └─ service_test.go
└─ repository/
   └─ repo.go              # 分页查询、条件更新、去重查询

模块边界:incident 只记录异常事实与处置过程,不修改会话、数字票、支付流水和道闸状态;需要联动修正业务数据时(如强制免费后补结算),由调用方在业务事务内先完成修正、再调用埋点记录,异常表不参与业务事务。

3. 数据模型

3.1 异常事件表 incident_record

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_amountforce_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(建议包含,可裁剪)

配合人工抬杆审计与开闸失败追踪,记录每次道闸开/关命令:

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 / CloseGateByDeviceCodegate.go:117-144)封装处,所有开闸/关闸(通行、人工、测试)都经过此处,无需改动各调用点。
  • 开闸失败时:指令流水写 failed,同时异常埋点生成 gate_failed,二者通过 incident_id 关联。
  • 裁剪此项不影响异常事件主链路,仅损失"命令级"审计粒度。

4. 状态机

4.1 状态与流转

pending ──> processing ──> resolved ──> closed
   ↑                          │
   └────────  reopen ─────────┘
流转 约束
pending → processing 记录开始处理,写入 handler_id
pending/processing → resolved 必须携带 handle_type + handle_remarkforce_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 的入参

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_apispath+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
handleEntryVehicleEntry 返回 ErrParkingLotFull 满位拒绝 lot_full info
handleExitExitConfirm 返回"无入场记录"类错误 无会话/丢票 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、状态直接 resolvedhandle_type=manual_gatehandle_remark=reasonoperator_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.goconn_manager.go

  • 埋点位置:读协程 ReadData 出错置 status=offline 的分支(reader.go:251-261)与 conn_manager.go:72 连接断开检测处。
  • 触发条件:状态从在线转为离线(避免重连抖动重复生成);配合 5.4 去重规则(同设备未关闭不重复生成)。
  • 分类 device_offline、来源 device、等级 warningdevice_code/parking_lot_id 从设备记录取。
  • 重连成功(置 status=online 分支):调用 ResolveDeviceOffline(deviceCode),将同设备 pending/processing 的离线记录批量置 resolved + device_repaired,备注"设备恢复在线"。
  • 离线事件同时不动内存 DeviceManagerGetGateRuntimeStatus,仅补 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 内包一层:

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 极简模式):

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. 数据库迁移与兼容

  • RegisterTablesinternal/initialize/gorm.go)的 AutoMigrate 列表追加 dao.IncidentRecord{}(及 dao.DeviceCommandLog{},若采纳指令流水)。AutoMigrate 只建新表,不改旧表,无历史数据兼容问题。
  • 埋点为纯增量调用:passage.gogate.goreader.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 条"进入人工核对"原则实现