4 Комити b0548c9c3b ... 1dfa2dbaf4

Аутор SHA1 Порука Датум
  lq 1dfa2dbaf4 docs: 全量复查更新功能缺失清单与项目进度 пре 2 недеља
  lq 8cca42bc2d fix: 安全加固(封堵操作员提权、操作日志脱敏、模拟道闸告警) пре 2 недеља
  lq 6df86a0505 feat: 计费引擎重构与缴费后免费离场时限 пре 2 недеља
  lq 84bc3d32dc fix: 月卡办理/续费/退卡事务化,过期续费从今天起算 пре 2 недеља

+ 84 - 62
doc/功能缺失.md

@@ -1,23 +1,35 @@
 # 智慧停车管理系统功能缺失清单
 
-> 更新日期:2026-08-11  
-> 评估范围:`lc_garage` 当前前端、后端、设备接入和自动化测试代码  
+> 更新日期:2026-08-13
+> 评估范围:`lc_garage` 当前前端、后端、设备接入和自动化测试代码
 > 状态说明:本文用于记录尚未完成的业务闭环和生产风险,不代表相关模块完全没有代码。
+> 本次更新:基于全量代码复查(资金支付财务 / 设备通行告警 / 平台工程安全三个领域),新增安全漏洞小节与工程缺陷清单。
 
 ## 1. 总体结论
 
-当前项目已经具备单机人工收费停车场的基础业务链路,包括停车场配置、车辆管理、统一进出场、停车会话、数字票、人工收费、支付流水、月卡、交接班、报表、道闸模拟和小票打印
+当前项目已经具备单机人工收费停车场的基础业务链路,包括停车场配置、车辆管理、统一进出场、停车会话、数字票、人工收费、支付流水、月卡、交接班、报表、道闸模拟、小票打印和异常处置闭环
 
-距离正式运营仍缺少三个关键闭环:
+距离正式运营仍缺少关键闭环:
 
-1. POS、扫码、中央缴费机等真实资金交易。
-2. ~~业务异常、设备异常的记录和处置~~(已于 2026-08-12 完成核心闭环,见 2.2 节与 `doc/异常处置设计.md`)
-3. 摄像头、道闸、地感、LED 屏的状态反馈和联动
+1. POS、扫码、中央缴费机等真实资金交易(仅配置占位)
+2. 摄像头、道闸、地感、LED 屏的状态反馈和联动
+3. **权限安全**:操作员角色存在可提权通道(见 2.0),且操作日志无脱敏
 
 “存在支付方式配置”不等同于“已经接入真实支付”。当前非现金支付主要用于业务流程确认,尚未连接实际支付终端或支付网关。
 
 ## 2. P0:上线前必须解决
 
+### 2.0 安全漏洞与权限缺陷(2026-08-13 新增,最高优先级)
+
+1. **操作员(618)提权通道**:`seed.go` 授予 618 角色 `/user/admin_register` 权限,而注册接口(`internal/api/v1/system/sys_user.go:157-179`)直接使用请求体的 `AuthorityId` 建用户、不校验目标角色 → 618 可注册一个 888 管理员账号获得全部权限。数据库中 618 实际拥有 105 条 Casbin 规则(可改收费单价、删停车场、退月卡、黑名单 CRUD),角色粒度过粗。
+2. **操作日志无脱敏**:`middleware/operation.go:78-87` 请求体原文入库(密码/token/支付字段明文),`/user/changePassword` 等接口的旧密码+新密码被完整记录;响应体无限增长入库(`respPool` 死代码,响应截断分支写错字段 `record.Body` 应为 `record.Resp`)。
+3. **uploads 静态文件匿名可读**:`router.go:67` StaticFS 无鉴权,车牌抓拍图片可猜测 URL 访问。
+4. `/dashboard/monitor`(CPU/内存信息)与 `/swagger/*` 公开免鉴权(`router.go:73,86`)。
+5. **API 管理删除 API 后权限不一致**:删除清空所有角色 Casbin(`service/system/sys_api.go:39-48,124-139`),但路由仍静态注册;启动种子幂等重建 → “权限重启复活”。
+6. 种子硬编码 3 个账号与 bcrypt hash(`seed.go:41-83`),源码公开默认凭据。
+7. 摄像头密码明文存储(`dao/camera.go:12-13`,无加密 hook)。
+8. 退卡接口授权含 618 普通操作员(`seed.go:443-444`),且无审批。
+
 ### 2.1 非现金支付没有真实交易能力
 
 **当前情况**
@@ -25,11 +37,14 @@
 - 已支持支付入口、支付方式及可用关系配置。
 - POS 和外部扫码方式可以直接创建支付流水并把会话标记为已支付。
 - 尚未实现支付订单、POS 驱动、扫码下单、支付网关、异步回调、主动查询和超时处理。
+- `payment_record` 无渠道订单号、无支付状态字段;外部模式实收金额被丢弃(`payment/service/service.go:372-378`);数字票 `payment_order_no` 出场时传空串(`vehicle.go:712`)。
+- 退款、撤销、冲正完全不存在。
 
 **业务影响**
 
 - 操作员确认不能证明外部资金已经到账。
 - 网络超时、支付成功但本地未更新等情况可能造成资金与业务状态不一致。
+- **~~缴费后无限期免费离场(逃费路径)~~(2026-08-13 已修复)**:`FeeConfig.FreeExitMinutes`(默认 15,存量库经 DDL default 一次性迁移)——支付后超时限出场按实际时长全额重算差额,未补缴拒绝放行,补缴后刷新支付时间;`vehicle.go` paid 分支与预览接口已落地(`overtime`/`overtime_fee`/`overtime_diff` 字段),前后端交互与 10 个测试用例已完成。
 
 **完成标准**
 
@@ -37,6 +52,7 @@
 - 外部支付只能由可信终端结果、回调或主动查询确认成功。
 - 支付回调、重复通知和主动查询必须幂等。
 - 实现退款、撤销、冲正、失败补偿和财务对账。
+- ~~缴费后免费离场时限(如 15 分钟),超时按实际时长重新计费并补缴差额~~(2026-08-13 已完成,见上)。
 
 ### 2.2 异常报表和异常处置(核心闭环已完成,扩展点待接入)
 
@@ -46,32 +62,19 @@
 - 自动埋点已接入:统一通行五类(开闸失败、重复入场、黑名单拦截、满位拒绝、无入场记录出场)、人工抬杆/关闸(`reason` 可选参数)、设备离线(在线→离线转换,重连自动关闭)。
 - 支持待处理、处理中、已解决、已关闭四状态流转(CAS 条件更新)、处理方式与备注、`event_log` 时间线审计。
 - 权限分级:查看/上报面向全部角色;处置仅 888/9527;强制免费仅 888(前后端双校验)。
-- `device_command_log` 指令流水已实现:记录每次道闸开/关命令(来源、操作员、会话、结果、耗时),开闸失败时与 `gate_failed` 异常关联。
-- 前端 `view/report/sheet.vue` 已重写:统计行 + 筛选 + 分页 + 详情时间线 + 处置表单 + 人工上报。
-- 自动化测试已覆盖:状态机流转、CAS 并发、去重、best-effort、编号生成、统计、开闸失败埋点集成、路由鉴权。
+- `device_command_log` 指令流水已实现;前端页面已重写;自动化测试已覆盖。
 
 **仍待接入的异常类型(埋点为预留扩展点)**
 
-- 无牌车:票机无牌取票已有唯一会话身份,无独立异常分类,可后续在票机链路补充。
-- 车牌识别冲突:依赖摄像头 OCR 接入(P2),暂无可埋点。
-- 票据打印失败:待打印任务持久化(P0)落地后接入 `print_failed`。
-- 支付失败/支付状态不确定:待支付订单层(P0)落地后接入 `payment_failed`/`payment_uncertain`。
-- 余位为负、会话与数字票状态不一致:依赖对账任务,未实现自动检测。
-
-**完成标准(对照)**
-
-- ✅ 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员。
-- ✅ 支持待处理、处理中、已解决、已关闭状态和处理备注。
-- ✅ 人工放行和资金相关异常有权限控制与完整审计。
+- 无牌车独立分类、车牌识别冲突(依赖摄像头 OCR)、票据打印失败(`print_failed` 已定义零调用,待打印任务持久化后接线)、支付失败/不确定(待支付订单层)、余位为负/会话与票状态不一致(待对账任务)。
 
 ### 2.3 票机打印失败补偿未完成
 
 **当前情况**
 
-- 票机已经接入 `PassageService`,按通道定位停车场和设备,并执行统一准入、会话、数字票和开闸流程。
-- 已按新建停车会话直接返回数字票,不再通过车牌查询最新票据。
-- 无牌取票会生成唯一票机会话身份,避免多辆无牌车共用空车牌。
-- 当前打印仍是同步硬件调用;打印失败时会返回已完成的会话、票据和闸机状态,但尚无持久化任务、自动重试、重打和作废机制。
+- 票机已接入统一通行入口;无牌取票生成唯一会话身份。
+- 打印仍是同步硬件调用:无持久化任务、自动重试、重打和作废机制。
+- **串口 write 错误被丢弃**(`printer/service/service.go:75` 闭包内不检查 `srl.Write` 返回);打印失败不标设备 offline、不落 incident。
 
 **完成标准**
 
@@ -82,61 +85,67 @@
 
 ### 3.1 计费引擎能力不足
 
-- 当前只有免费时长、起步价、固定时间单位、单次日封顶和 VIP 优惠。
-- 超过 24 小时仍只应用一次日封顶,多日停车计费不正确。
+- ~~超过 24 小时仍只应用一次日封顶,多日停车计费不正确~~(2026-08-13 已修复:日封顶按跨天数累计,跨天向上取整)。
+- ~~未配置收费规则时按每分钟 0.1 隐式收费,可能造成错误收费~~(2026-08-13 已修复:无配置返回明确错误;种子为“临时车”建全零占位配置=显式免费;存量脏配置启动时自动补正)。
+- ~~收费配置缺少负值、折扣范围、时间单位和封顶金额等完整校验~~(2026-08-13 已修复:前后端双校验)。
+- ~~停留时长向下取整(不足一分钟不计费)~~(2026-08-13 已修复:向上取整)。
 - 缺少自然日或滚动 24 小时规则、跨日分段、夜间价、节假日、阶梯计费和不同停车场费率。
-- 缺少缴费后免费离场时限及超时重新计费。
-- 未配置收费规则时按每分钟 `0.1` 隐式收费,可能造成错误收费。
-- 收费配置缺少负值、折扣范围、时间单位和封顶金额等完整校验。
+- 缺少缴费后免费离场时限及超时重新计费(见 2.1)。
 
 ### 3.2 月卡办理、续费和退卡不是完整事务
 
-- 创建月卡、写白名单和写支付流水没有放在同一事务中,部分错误被忽略。
-- 续费更新月卡、白名单和支付流水时也可能产生部分成功
-- 月卡收费固定记为收费岗亭现金,不能选择支付入口和支付方式。
-- 退卡只标记 `refunded` 并删除白名单,没有退款金额、退款流水和审批。
+- ~~创建月卡、写白名单和写支付流水没有放在同一事务中,部分错误被忽略~~(2026-08-13 已修复:三操作均事务化,错误逐层返回)
+- ~~过期卡续费从过期日续期,导致续费后立即失效~~(2026-08-13 已修复:未过期卡从原到期日顺延,过期卡从今天起算)
+- 月卡收费固定记为收费岗亭现金,不能选择支付入口和支付方式(`monthly/service/service.go:75,124`)
+- 退卡只标记 `refunded` 并删除白名单,没有退款金额、退款流水和审批;且 `Unscoped().Delete` 会物理删除该车辆所有“白名单”记录,误伤管理员手动添加的永久白名单(`monthly/service/service.go:149-151`)
 - 月、季、年按 30、90、365 天计算,不是自然月、自然季度和自然年。
+- 并发办理无防护(“已有有效月卡”检查在事务外,`monthly/service/service.go:38-44`)。
 - 缺少过期清理、提前续费、冻结、转车、退款计算等业务规则。
 
 ### 3.3 交接班只覆盖现金
 
-- 只统计 `payment_method='cash'`,没有按支付入口和支付方式汇总其他交易
-- 支付流程没有要求操作员必须处于当班状态。
-- 开班、收款和交班缺少统一事务约束。
+- 只统计 `payment_method='cash'`(`shift/service/service.go:41-43` 裸 SQL),POS、扫码、月卡等流水漏计
+- 支付流程没有要求操作员必须处于当班状态(不开班也能收款,ShiftService 无外部调用点)
+- 开班、收款和交班缺少统一事务约束;Start/End 无并发防护(可并发开出两个 active 班次)
 - 缺少管理者复核、差异审批、交班单打印和交班后锁定。
 - 页面缺少完整分页、筛选、备注管理和审核能力。
 
 ### 3.4 收入报表维度和财务口径不足
 
-- 当前主要按日期和停车场汇总,只单独展示现金和免费。
+- 当前主要按日期和停车场汇总,只单独展示现金和免费(`report/service/service.go:16-33`)
 - 缺少支付入口、支付方式、操作员、班次、车型、通道等维度。
-- 缺少退款、撤销、冲正、月卡收入和交班差异。
-- 缺少明细追溯、日结、月结、导出和打印。
+- 缺少退款、撤销、冲正、月卡收入(当前月卡流水 record_id=0,混入现金列)和交班差异。
+- 缺少明细追溯、日结、月结、导出和打印(后端无导出接口,前端无导出按钮)。
+- 财务对账缺失:`payment_record` 与 `digital_ticket` 金额无交叉校验。
 
 ### 3.5 数字票过期状态没有自动执行机制
 
-- 已定义 `expired` 状态和状态流转规则。
-- 没有发现根据 `expire_at` 自动过期的定时任务。
+- 已定义 `expired` 状态和状态流转规则,但 `ExpireAt` 字段从未被赋值,无定时任务把票转 expired(定时任务仅 ClearDB,`timer.go:17-22`);Summary 的 expired 统计恒为 0。
 - 需要明确待支付票、已支付未离场票分别在何种条件下过期,以及过期后的恢复策略。
 
-### 3.6 道闸和设备模型不够独立
+### 3.6 道闸和设备模型不够独立(2026-08-13 补充实测缺陷)
 
-- 当前工作台从 `UHFReader` 获取道闸设备,读卡器和道闸控制器共用设备模型。
-- 无法完整表达一个通道包含摄像头、读卡器、入口票机、道闸、地感等多设备拓扑。
+- 当前工作台从 `UHFReader` 获取道闸设备,读卡器和道闸控制器共用设备模型;无法完整表达通道多设备拓扑。
+- **模拟道闸配置当前即为 true**(`config.yaml:191`):开启时停用全部 UHF/RFID,开闸“永远成功”,生产误开无任何代码级校验/告警。
+- **道闸指令只发不验**:串口写错误被 `_ =` 吞掉(`serial_reader.go:115`);响应帧内容丢弃不校验 ACK(`reader.go:401-405`);`SendAndRecv` 与主读循环共享无锁 buffer 存在竞态(`serial_reader.go:16,91-98`)。
+- **离线判定误报**:串口空闲 1s 超时、TCP 分片 <25 字节均被判离线 → 空载设备 status 抖动、反复断连重连;首次 Connect 失败无重试(`reader.go:219-221`)。
+- **设备状态四套并存矛盾**:DB status / DeviceManager(断线不注销)/ ConnManager(死代码双轨)/ GetGateRuntimeStatus;前端两个接口数据源不一致。
+- passage 只发 OpenGate 无落杆指令流水(延时落杆无确认无审计)。
 - 缺少闸杆状态反馈、地感、防砸、落杆确认、失败重试和指令流水。
-- 模拟道闸配置若在生产环境误开启,会显示在线但不发送真实指令。
 
 ### 3.7 停车场基础配置约束不足
 
 - 已修复按 `LotId` 查询条件、容量低于当前在场车辆数校验,以及存在未出场会话时禁止删除。
 - 删除停车场前仍未检查岗亭、通道和设备关联。
-- 已结束的历史停车会话仍允许所属停车场被软删除,历史页面需要统一处理已删除配置的名称展示。
+- 历史停车会话所属停车场被软删除后,历史页面名称展示需统一处理。
+- 余位为负无检测:写入端 CASE 钳制静默生效,启动校准 `reconcileParkingLotOccupancy` 发现漂移不埋点不告警。
 
 ### 3.8 操作审计和敏感数据保护不足
 
-- 操作日志保存请求体和响应体,未见密码、令牌、支付字段统一脱敏。
+- 操作日志保存请求体和响应体,未见密码、令牌、支付字段统一脱敏(详见 2.0)
 - 摄像头账号密码缺少加密存储方案。
 - 设备失败主要写日志,尚未进入业务告警和异常处置中心。
+- `fmt.Printf` 直出 20+ 处(passage/uhf/shift/timer),自定义 GinRecovery 中间件未启用。
 
 ## 4. P2:完全缺失或只有模型
 
@@ -148,34 +157,47 @@
 | 线上支付 | 未实现 | 微信/支付宝等下单、回调、查询、退款和对账 |
 | 设备告警中心 | 未实现 | 在线状态、故障事件、告警等级、确认和恢复 |
 | 远程设备诊断 | 未实现 | 设备参数、连通性测试、指令测试、诊断审计 |
-| 数据备份恢复 | 未实现 | SQLite 备份、校验、恢复、保留策略和恢复演练 |
-| 数据归档清理 | 未实现 | 历史会话、日志、抓拍图片的归档和容量管理 |
+| 数据备份恢复 | 未实现 | SQLite 备份、校验、恢复、保留策略和恢复演练(当前无 WAL、`busy_timeout=0` 并发写锁风险) |
+| 数据归档清理 | 未实现 | 历史会话、日志、抓拍图片的归档和容量管理(仅 ClearDB 清 2 张表) |
 | 报表导出打印 | 未实现 | Excel/PDF 导出、日结单、月报和打印 |
 | 打印任务管理 | 未实现 | 打印机配置、任务队列、状态、失败重试和重打权限 |
 | 短信/邮件验证 | 模拟实现 | 服务商配置、发送限制、验证码校验和审计 |
+| 通道事件持久化 | 内存 50 条队列 | 落库、重启恢复、与异常/指令流水联动 |
+
+## 5. 工程与部署缺陷(2026-08-13 新增)
+
+1. gin 恒为 debug 模式(无 release 配置,`core` 从未调用 `gin.SetMode(gin.ReleaseMode)`);`os.Exit(0)` 用成功码退出(`gorm.go:74`);`ListenAndServe` 失败仅 log 进程不退(`server.go:44`)。
+2. SQLite 无 WAL(实测 `journal_mode=delete`)、无 `busy_timeout`,多协程并发写有锁冲突风险;无自动备份。
+3. `deploy/` 全套与项目布局脱节(gva 原版遗留,引用不存在的 server/ 目录)。
+4. 双入口不一致:Wails 入口 embed 前端 dist 并剥离 `/api` 前缀;`cmd_backend_only` 不托管前端,前端 env 写死 `VITE_BASE_API=/api`,直接部署时 404。
+5. 前端 bug:`view/report/enter.vue:212` 引用未定义的 `t`,列表加载失败时 ReferenceError。
+6. LoginOut 失败不清理本地 token(`pinia/modules/user.js:91-98`);`request.js` 401 处理依赖用户点击“重新登录”,取消则不登出。
+7. i18n 不完整:statistics/vehicle.vue 14 处、login 13 处硬编码中文。
+8. 配置零校验:无端口冲突预检、路径存在性、取值范围校验。
 
-## 5. 测试覆盖缺口
+## 6. 测试覆盖缺口
 
-现有自动化测试重点覆盖停车会话、数字票、支付金额边界、重复和并发支付、事务回滚、道闸接口及 USB 票据版式。以下范围仍缺少完整测试:
+现有自动化测试覆盖停车会话、数字票、支付金额边界、重复和并发支付、事务回滚、道闸接口、USB 票据版式、异常处置(状态机/埋点/权限)、月卡事务、计费引擎与收费配置校验。以下范围仍缺少完整测试:
 
 - 票机打印任务持久化、失败补偿、重打和作废权限。
-- 月卡办理、续费、退卡的跨表事务和退款。
+- 月卡退款的跨表事务和退款金额计算
 - 交接班、收入报表和多支付方式对账。
-- 多日计费、跨日规则、缴费后超时和收费配置异常。
-- 异常事件的生成、处置、权限和审计。
+- 多日计费、跨日规则、缴费后超时和收费配置异常(多日封顶已覆盖,跨日规则未实现)
+- 异常事件的生成、处置、权限和审计(已基本覆盖)
 - 摄像头、地感、真实道闸、LED 屏的集成和现场测试。
 - 数据备份、恢复和故障恢复演练。
+- **安全面:api 层、middleware(JWT/Casbin/操作日志脱敏)、登录、越权(618 提权)零测试**。
 
-## 6. 推荐实施顺序
+## 7. 推荐实施顺序
 
-1. ~~建立异常事件、人工处置和设备指令流水~~(已于 2026-08-12 完成,见 2.2)
-2. 建立支付订单层,接入 POS、扫码和中央缴费机。
-3. 重构多日计费及缴费后离场超时规则
-4. 完成月卡事务、退款、交接班和财务对账
+1. **安全修复**:封堵 `admin_register` 提权(AuthorityId 校验 + 撤销 618 权限)、操作日志脱敏、uploads 鉴权、种子默认凭据处理
+2. 建立支付订单层,接入 POS、扫码和中央缴费机;缴费后离场时限与超时补缴
+3. 生产防护:gate-simulator 运行时告警、道闸指令只发不验修复、`os.Exit` 与 release 模式
+4. 月卡退款/自然月季年/并发防护,交接班全方式对账与审批
 5. 接入摄像头、地感、道闸反馈和 LED 余位屏。
-6. 补充报表导出、打印任务、备份恢复和生产运维能力
+6. 报表导出、打印任务、备份恢复(WAL + 定期快照)、数据归档和部署包
 
-## 7. 维护规则
+## 8. 维护规则
 
 - 功能开始开发时,在对应条目中补充负责人、计划版本和关联任务。
 - 功能完成后必须同时满足代码实现、权限控制、自动化测试和业务验收,不能仅因页面或配置存在就标记完成。

+ 26 - 1
doc/项目进度.md

@@ -72,7 +72,32 @@
 - 调试路由由 `system.debug-routes` 显式控制,默认关闭;开启后仍要求 JWT、管理员或开发角色权限,并记录操作日志。
 - 打印任务持久化、失败重试、重打和作废仍列为后续 P0 工作。
 
-### 2.6 异常处置闭环
+### 2.6 计费引擎与收费配置修复
+
+- `CalculateFee` 重构为返回 `(fee, error)`:未配置收费规则返回明确错误(不再隐式按 0.1 元/分钟兜底);`unit_time<=0` 判定配置无效。
+- 多日停车日封顶按跨天数累计(跨天向上取整),修复停 3 天只收 1 天封顶的问题。
+- 停留时长向上取整:不足一分钟按一分钟(入场/出场/预览/票面一致)。
+- 白名单车辆绕过计费(免费放行不再依赖收费配置存在)。
+- 收费配置 Create/Update 统一校验:免费时长非负、单位时间>0、金额有限非负、封顶≥起步价、VIP 折扣(0,1];前端 pricing.vue 同规则前置校验;分页参数兜底。
+- 种子 `EnsureDefaultFeeConfig`:新库为"临时车"建全零占位配置(显式免费);存量脏配置自动补正(unit_time<=0→60、vip_discount<=0→1.0、负金额→0)。
+- 测试:计费 11 用例(缺失配置/非法单位时间/免费期/单位累加/单日封顶/多日封顶/无封顶多日/显式免费/VIP 免费/VIP 折扣)+ 配置校验 13 用例 + 分页兜底。
+
+### 2.7 安全加固(2026-08-13)
+
+- 封堵提权通道:`/user/admin_register` 增加角色层级校验(`canRegisterAuthority`:618 只能建 618,888 可建 618/888,9527 全部);新库种子不再授予 618 该接口权限;`EnsureSecurityHardening` 清理存量库规则(已实测 618 规则 0 条、888 保留)。
+- 操作日志脱敏:密码/token/密钥等敏感字段在落库前递归替换为 `******`;修复响应截断写错字段的历史 bug(原 `record.Body` 应为 `record.Resp`);响应统一脱敏+超 1024 截断;删除死代码 `respPool`。
+- 模拟道闸生产防护:`gate-simulator=true` 启动时输出 Error 日志 + 控制台横幅告警(真实道闸不会动作、RFID 停用)。
+- 测试:脱敏 5 用例(字段/大小写/非 JSON/截断)+ 角色层级 9 用例,全部通过;`go test ./internal/...` 全量无 FAIL。
+
+### 2.8 缴费后免费离场时限(2026-08-13)
+
+- `FeeConfig.FreeExitMinutes` 新字段(default:15,0=不限);存量库经 SQLite `ADD COLUMN DEFAULT 15` 一次性迁移(已实测三条存量配置均为 15)。
+- 超时判定基准为 `ticket.PaidAt`:超时限后出场按当前时长**全额重算**(含多日封顶),补缴差额 = max(0, 新费用 - 已付);`PaidAt` 为空的历史票视为未超时。
+- `ExitConfirm` paid 分支:未补缴拒绝放行(提示差额)、免密方式禁止补缴、补缴走 `ValidatePayment` 校验并写独立流水(Remark="超时补缴")、票面累计(Fee/PaidAmount/PaidAt 刷新)。
+- 预览接口新增 `overtime`/`overtime_fee`/`overtime_diff` 字段;前端工作台超时告警横幅 + 补缴交互(按钮"补缴并放行");收费配置页新增"缴费后免费离场(分钟)"输入与校验。
+- 测试 10 用例:未超时/超时重算/0 不限/PaidAt 空/拒绝补缴/免密拒绝/补缴成功(流水+票面+刷新)/差额为 0 放行/未超时回归。
+
+### 2.9 异常处置闭环
 
 - 新增 `incident_record` 异常事件表与 `internal/modules/incident/` 模块(api + service + repository),详见 `doc/异常处置设计.md`。
 - 状态机:待处理 → 处理中 → 已解决 → 已关闭,支持重新打开;CAS 条件更新防并发覆盖;`event_log` 记录流转时间线。

+ 25 - 7
frontend/src/view/parking/entryExit.vue

@@ -255,6 +255,15 @@
               <el-tag :type="ticketTagType" effect="plain">{{ ticketStateLabel }}</el-tag>
             </div>
 
+            <el-alert
+              v-if="exitData?.overtime"
+              type="warning"
+              :closable="false"
+              show-icon
+              class="overtime-alert"
+              :title="overtimeTopUp ? `缴费已超时,需补缴 ¥${formatMoney(overtimeDiff)}(已付 ¥${formatMoney(exitData.paid_amount)},总费 ¥${formatMoney(exitData.fee)})` : '缴费已超时,费用已按当前时长重新计算'"
+            />
+
             <section v-if="requiresSettlement" class="action-zone settlement-zone">
               <div class="action-copy">
                 <span class="section-kicker">{{ $t.value.digitalTicketSettlement }}</span>
@@ -283,7 +292,7 @@
                 <div v-if="selectedPaymentMethod?.input_mode === 'manual_amount'" class="cash-row">
                   <label>{{ $t.value.cashReceived }}</label>
                   <el-input-number v-model="paidAmount" :min="0" :precision="2" :step="1" controls-position="right" />
-                  <el-button plain @click="paidAmount = Number(exitData.fee || 0)">{{ $t.value.workbenchExactAmount }}</el-button>
+                  <el-button plain @click="paidAmount = dueAmount">{{ $t.value.workbenchExactAmount }}</el-button>
                   <div class="change-total">
                     <span>{{ $t.value.change }}</span>
                     <strong>¥{{ formatMoney(changeAmount) }}</strong>
@@ -381,9 +390,16 @@ const vehicleIsTemp = computed(() => currentVehicle.value?.vehicle_type?.is_syst
 const vehicleTypeName = computed(() => vehicleIsTemp.value ? t.value.tempCar : (currentVehicle.value?.vehicle_type?.name || exitData.value?.vehicle_type || ''))
 const ownerName = computed(() => currentVehicle.value?.owner?.owner_name || '')
 const ticketAlreadyPaid = computed(() => exitData.value?.ticket_state === 'paid' || exitData.value?.payment_status === 'paid')
-const requiresSettlement = computed(() => contextAction.value === 'exit' && !ticketAlreadyPaid.value && Number(exitData.value?.fee || 0) > 0)
+// 缴费后免费离场时限:已支付票超时且需补缴差额
+const overtimeTopUp = computed(() => Boolean(exitData.value?.overtime) && Number(exitData.value?.overtime_diff || 0) > 0)
+const overtimeDiff = computed(() => Number(exitData.value?.overtime_diff || 0))
+// 当前应缴金额:补缴场景为差额,常规结算场景为票面费用
+const dueAmount = computed(() => overtimeTopUp.value ? overtimeDiff.value : Number(exitData.value?.fee || 0))
+const requiresSettlement = computed(() => contextAction.value === 'exit' && (
+  (!ticketAlreadyPaid.value && Number(exitData.value?.fee || 0) > 0) || overtimeTopUp.value
+))
 const selectedPaymentMethod = computed(() => enabledPaymentMethods.value.find(item => item.code === paymentMethod.value) || null)
-const changeAmount = computed(() => Math.max(0, Number(paidAmount.value || 0) - Number(exitData.value?.fee || 0)))
+const changeAmount = computed(() => Math.max(0, Number(paidAmount.value || 0) - dueAmount.value))
 const paymentEntryAvailable = computed(() => paymentEntries.value.some(item => item.code === paymentEntry))
 const paymentEntryName = computed(() => paymentEntryLabel(paymentEntry))
 const settledEntryName = computed(() => paymentEntryLabel(exitData.value?.payment_entry))
@@ -394,6 +410,7 @@ const paymentSummary = computed(() => {
   return `${settledEntryName.value} · ${method} · ¥${formatMoney(exitData.value?.paid_amount)}`
 })
 const exitButtonLabel = computed(() => {
+  if (overtimeTopUp.value) return '补缴并放行'
   if (ticketAlreadyPaid.value) return t.value.workbenchConfirmPaidExit
   if (!requiresSettlement.value) return t.value.workbenchConfirmFreeExit
   return t.value.confirmPayAndExit
@@ -524,12 +541,12 @@ async function confirmExit() {
   if (requiresSettlement.value && (!paymentEntryAvailable.value || !selectedPaymentMethod.value)) {
     return ElMessage.warning(t.value.workbenchPaymentUnavailable)
   }
-  if (requiresSettlement.value && selectedPaymentMethod.value?.input_mode === 'manual_amount' && paidAmount.value < exitData.value.fee) {
+  if (requiresSettlement.value && selectedPaymentMethod.value?.input_mode === 'manual_amount' && paidAmount.value < dueAmount.value) {
     return ElMessage.warning(t.value.insufficientAmount)
   }
 
   const due = formatMoney(exitData.value.fee)
-  const received = ticketAlreadyPaid.value
+  const received = ticketAlreadyPaid.value && !overtimeTopUp.value
     ? formatMoney(exitData.value?.paid_amount)
     : requiresSettlement.value && selectedPaymentMethod.value?.input_mode === 'manual_amount'
       ? formatMoney(paidAmount.value)
@@ -555,7 +572,7 @@ async function confirmExit() {
     let amount = 0
     if (requiresSettlement.value) {
       method = paymentMethod.value
-      amount = selectedPaymentMethod.value?.input_mode === 'manual_amount' ? Number(paidAmount.value) : Number(exitData.value.fee)
+      amount = selectedPaymentMethod.value?.input_mode === 'manual_amount' ? Number(paidAmount.value) : dueAmount.value
     }
     const res = await handleVehiclePassage({
       session_id: exitData.value.session_id,
@@ -588,7 +605,7 @@ async function confirmExit() {
 }
 
 function handlePaymentMethodChange() {
-  paidAmount.value = selectedPaymentMethod.value?.input_mode === 'manual_amount' ? 0 : Number(exitData.value?.fee || 0)
+  paidAmount.value = selectedPaymentMethod.value?.input_mode === 'manual_amount' ? 0 : dueAmount.value
 }
 
 function assignGateDevice(action = contextAction.value) {
@@ -759,6 +776,7 @@ function formatDuration(minutes) {
 </script>
 
 <style scoped>
+.overtime-alert { margin: 12px 0; }
 .entry-exit-page {
   --ink: #20252b;
   --muted: #6b7280;

+ 38 - 0
frontend/src/view/parking/pricing.vue

@@ -177,6 +177,13 @@
             type="number"
           />
         </el-form-item>
+        <el-form-item label="缴费后免费离场(分钟):">
+          <el-input
+            v-model.number="pricingData.free_exit_minutes"
+            type="number"
+            placeholder="0表示不限,默认15"
+          />
+        </el-form-item>
         <el-form-item :label="$t.value.IsVipFree + ':'">
           <el-radio-group v-model="pricingData.is_vip_free">
             <el-radio-button :label="false">{{ $t.value.no }}</el-radio-button>
@@ -241,6 +248,7 @@ const pricingData = reactive({
   unit_time: 0,
   unit_fee: 0,
   daily_max_fee: 0,
+  free_exit_minutes: 15,
   is_vip_free: false,
   vip_discount: 1.0,
   description: ''
@@ -290,6 +298,7 @@ const openPricing = (title, data) => {
       unit_time: 0,
       unit_fee: 0,
       daily_max_fee: 0,
+      free_exit_minutes: 15,
       is_vip_free: false,
       vip_discount: 1.0,
       description: ''
@@ -316,11 +325,40 @@ const pricingOperation = async() => {
     unit_time: Number(pricingData.unit_time),
     unit_fee: Number(pricingData.unit_fee),
     daily_max_fee: Number(pricingData.daily_max_fee),
+    free_exit_minutes: Number(pricingData.free_exit_minutes),
     is_vip_free: pricingData.is_vip_free,
     vip_discount: Number(pricingData.vip_discount),
     description: pricingData.description
   }
 
+  // 与后端校验规则一致的前置校验,避免提交非法配置
+  if (!Number.isFinite(params.start_time) || params.start_time < 0) {
+    ElMessage.warning('免费时长不能为负数')
+    return
+  }
+  if (!Number.isFinite(params.unit_time) || params.unit_time <= 0) {
+    ElMessage.warning('单位时间必须大于0分钟')
+    return
+  }
+  for (const [key, label] of [['start_fee', '起步价'], ['unit_fee', '单位费用'], ['daily_max_fee', '日封顶金额']]) {
+    if (!Number.isFinite(params[key]) || params[key] < 0) {
+      ElMessage.warning(`${label}必须为非负数值`)
+      return
+    }
+  }
+  if (params.daily_max_fee > 0 && params.daily_max_fee < params.start_fee) {
+    ElMessage.warning('日封顶金额不能低于起步价')
+    return
+  }
+  if (!Number.isFinite(params.vip_discount) || params.vip_discount <= 0 || params.vip_discount > 1) {
+    ElMessage.warning('VIP折扣必须在(0,1]区间,如0.8表示八折')
+    return
+  }
+  if (!Number.isInteger(params.free_exit_minutes) || params.free_exit_minutes < 0) {
+    ElMessage.warning('缴费后免费离场分钟数必须为非负整数(0表示不限)')
+    return
+  }
+
   if (pricingTitle.value === t.value.AddChargeConfig) {
     await addPricing(params).then(res => {
       if (res.code === 0) {

+ 20 - 0
internal/api/v1/system/sys_user.go

@@ -166,6 +166,11 @@ func (b *BaseApi) Register(c *gin.Context) {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	// 权限加固:调用者只能创建不高于自身权限的角色,封堵操作员注册管理员账号的提权通道
+	if !canRegisterAuthority(utils.GetUserAuthorityId(c), r.AuthorityId) {
+		response.FailWithMessage("无权创建该角色账号", c)
+		return
+	}
 
 	user := &dao.SysUser{Username: r.Username, NickName: r.NickName, Password: r.Password, HeaderImg: r.HeaderImg, AuthorityId: r.AuthorityId, Enable: r.Enable, Phone: r.Phone, Email: r.Email}
 	userReturn, err := userService.Register(*user)
@@ -177,6 +182,21 @@ func (b *BaseApi) Register(c *gin.Context) {
 	response.OkWithDetailed(systemRes.SysUserResponse{User: userReturn}, "注册成功", c)
 }
 
+// canRegisterAuthority 判断调用者是否有权创建目标角色账号:
+// 9527(超级管理员)可创建全部;888(管理员)可创建 618/888;618(操作员)只能创建 618。
+func canRegisterAuthority(callerAuthority, targetAuthority uint) bool {
+	switch callerAuthority {
+	case 9527:
+		return true
+	case 888:
+		return targetAuthority == 618 || targetAuthority == 888
+	case 618:
+		return targetAuthority == 618
+	default:
+		return false
+	}
+}
+
 // ChangePassword
 // @Tags      SysUser
 // @Summary   用户修改密码

+ 31 - 0
internal/api/v1/system/sys_user_security_test.go

@@ -0,0 +1,31 @@
+package system
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCanRegisterAuthorityRoleHierarchy(t *testing.T) {
+	cases := []struct {
+		name     string
+		caller   uint
+		target   uint
+		expected bool
+	}{
+		{"超级管理员可创建全部", 9527, 888, true},
+		{"超级管理员可创建管理员", 9527, 9527, true},
+		{"管理员可创建操作员", 888, 618, true},
+		{"管理员可创建同级", 888, 888, true},
+		{"管理员不可创建超级管理员", 888, 9527, false},
+		{"操作员只能创建操作员", 618, 618, true},
+		{"操作员不可创建管理员", 618, 888, false}, // 提权通道必须封堵
+		{"操作员不可创建超级管理员", 618, 9527, false},
+		{"未知角色一律拒绝", 0, 618, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.expected, canRegisterAuthority(tc.caller, tc.target))
+		})
+	}
+}

+ 1 - 0
internal/dao/fee_config.go

@@ -11,6 +11,7 @@ type FeeConfig struct {
 	UnitTime      int          `json:"unit_time"`                                    // 单位时间(分钟)
 	UnitFee       float64      `json:"unit_fee"`                                     // 单位费用
 	DailyMaxFee   float64      `json:"daily_max_fee"`                                // 每日最高费用
+	FreeExitMinutes int        `gorm:"default:15" json:"free_exit_minutes"`          // 缴费后免费离场分钟数(0=不限;存量库经 DDL default 一次性迁移为 15)
 	IsVIPFree     bool         `gorm:"default:false" json:"is_vip_free"`             // VIP是否免费
 	VIPDiscount   float64      `gorm:"default:1.0" json:"vip_discount"`              // VIP折扣
 	Description   string       `gorm:"size:200" json:"description"`                  // 描述

+ 68 - 0
internal/initialize/fee_seed.go

@@ -0,0 +1,68 @@
+package initialize
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// EnsureDefaultFeeConfig 确保系统内置"临时车"类型存在收费配置占位记录,
+// 并修复存量脏配置(旧版本无校验时可能写入无业务含义的数值)。
+//
+// 补正规则(只修无业务含义的值,不动用户的有效费率):
+//   - unit_time <= 0:单位时间无意义,补正为 60 分钟(配合全零费用表示显式免费)。
+//   - vip_discount <= 0:折扣为 0 会让 VIP 费用归零,补正为 1.0(不打折)。
+//   - 负金额:补正为 0(防止出场倒贴)。
+func EnsureDefaultFeeConfig() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+
+	// 存量脏配置修复
+	var invalidUnitTime []dao.FeeConfig
+	if err := db.Where("unit_time <= 0").Find(&invalidUnitTime).Error; err == nil {
+		for i := range invalidUnitTime {
+			db.Model(&invalidUnitTime[i]).Update("unit_time", 60)
+		}
+	}
+	var invalidDiscount []dao.FeeConfig
+	// 注意:GORM 对 VIPDiscount 的列名转换是 v_ip_discount(手写 SQL 须用真实列名)
+	if err := db.Where("v_ip_discount <= 0").Find(&invalidDiscount).Error; err == nil {
+		for i := range invalidDiscount {
+			// 用结构体更新以依赖 GORM 列名转换
+			db.Model(&invalidDiscount[i]).Updates(dao.FeeConfig{VIPDiscount: 1.0})
+		}
+	}
+	var negativeFees []dao.FeeConfig
+	if err := db.Where("start_fee < 0 OR unit_fee < 0 OR daily_max_fee < 0").Find(&negativeFees).Error; err == nil {
+		for i := range negativeFees {
+			updates := map[string]interface{}{}
+			if negativeFees[i].StartFee < 0 {
+				updates["start_fee"] = 0
+			}
+			if negativeFees[i].UnitFee < 0 {
+				updates["unit_fee"] = 0
+			}
+			if negativeFees[i].DailyMaxFee < 0 {
+				updates["daily_max_fee"] = 0
+			}
+			db.Model(&negativeFees[i]).Updates(updates)
+		}
+	}
+
+	var tempType dao.VehicleType
+	if err := db.Where("name = ? AND is_system = ?", "临时车", true).First(&tempType).Error; err != nil {
+		return nil // 临时车类型不存在(新库种子尚未执行),跳过
+	}
+	var existing dao.FeeConfig
+	result := db.Where("vehicle_type_id = ?", tempType.ID).First(&existing)
+	if result.RowsAffected == 0 {
+		return db.Create(&dao.FeeConfig{
+			VehicleTypeID: tempType.ID,
+			UnitTime:      60,
+			VIPDiscount:   1.0,
+			Description:   "系统默认占位配置(免费),请按实际费率修改",
+		}).Error
+	}
+	return nil
+}

+ 26 - 0
internal/initialize/security_seed.go

@@ -0,0 +1,26 @@
+package initialize
+
+import (
+	"wails-app/internal/global"
+)
+
+// EnsureSecurityHardening upgrades both new and existing databases with the
+// permission rules required to close the privilege escalation hole where a
+// 618 operator could register an 888 admin account via /user/admin_register.
+//
+// 纵深防御:
+//  1. Casbin 层:撤销 618 角色的 admin_register 权限(本函数,覆盖存量数据库)。
+//  2. 业务层:Register 接口校验调用者只能创建不高于自身权限的角色
+//     (internal/api/v1/system/sys_user.go canRegisterAuthority)。
+func EnsureSecurityHardening() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+	// 存量库清理:删除历史上授予 618 的注册权限(新库种子已不再写入)
+	if err := db.Exec("DELETE FROM casbin_rule WHERE v0 = ? AND v1 = ? AND v2 = ?",
+		"618", "/user/admin_register", "POST").Error; err != nil {
+		return err
+	}
+	return nil
+}

+ 13 - 2
internal/initialize/seed.go

@@ -29,6 +29,12 @@ func SeedSystemData() {
 		if err := EnsureIncidentPermissions(); err != nil {
 			global.GVA_LOG.Error("incident permission upgrade failed")
 		}
+		if err := EnsureDefaultFeeConfig(); err != nil {
+			global.GVA_LOG.Error("default fee config upgrade failed")
+		}
+		if err := EnsureSecurityHardening(); err != nil {
+			global.GVA_LOG.Error("security hardening upgrade failed")
+		}
 		return
 	}
 
@@ -286,11 +292,10 @@ func SeedSystemData() {
 		V5    string `gorm:"column:v5"`
 	}
 	casbinRules := []casbinRule{
-		// 618 (普通用户)
+		// 618 (普通用户) —— 不再授予 admin_register(防止注册高权限账号提权,见 security_seed.go)
 		{"p", "618", "/base/login", "POST", "", "", ""},
 		{"p", "618", "/jwt/jsonInBlacklist", "POST", "", "", ""},
 		{"p", "618", "/menu/getMenu", "POST", "", "", ""},
-		{"p", "618", "/user/admin_register", "POST", "", "", ""},
 		{"p", "618", "/user/changePassword", "POST", "", "", ""},
 		{"p", "618", "/user/getUserInfo", "GET", "", "", ""},
 		{"p", "618", "/user/setUserAuthority", "POST", "", "", ""},
@@ -542,6 +547,12 @@ func SeedSystemData() {
 	if err := EnsureIncidentPermissions(); err != nil {
 		global.GVA_LOG.Error("incident permission seed failed")
 	}
+	if err := EnsureDefaultFeeConfig(); err != nil {
+		global.GVA_LOG.Error("default fee config seed failed")
+	}
+	if err := EnsureSecurityHardening(); err != nil {
+		global.GVA_LOG.Error("security hardening seed failed")
+	}
 
 	global.GVA_LOG.Info("system data seeded successfully")
 }

+ 11 - 1
internal/initialize/uhf.go

@@ -1,6 +1,8 @@
 package initialize
 
 import (
+	"fmt"
+
 	"go.uber.org/zap"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
@@ -12,7 +14,15 @@ import (
 func InitGateController() {
 	if global.GVA_CONFIG.System.GateSimulator {
 		parkingService.SetGateController(parkingService.NewSimulatedGateController())
-		global.GVA_LOG.Warn("道闸模拟模式已启用,不会发送真实设备指令")
+		// 生产防护:模拟模式下开闸"永远成功"且 RFID 全部停用,
+		// 误配会造成"记录显示放行成功但真实道闸从未动作",必须显著告警。
+		msg := "道闸模拟模式已启用(gate-simulator=true):真实道闸不会动作、UHF/RFID 已停用;生产环境必须关闭!"
+		global.GVA_LOG.Error(msg)
+		fmt.Println()
+		fmt.Println("======================================================================")
+		fmt.Println("!!! " + msg)
+		fmt.Println("======================================================================")
+		fmt.Println()
 		return
 	}
 	InitUHFDevices()

+ 55 - 23
internal/middleware/operation.go

@@ -9,7 +9,6 @@ import (
 	"wails-app/internal/dao"
 	"strconv"
 	"strings"
-	"sync"
 	"time"
 
 	"wails-app/internal/pkg"
@@ -22,13 +21,60 @@ import (
 
 var operationRecordService = service.ServiceGroupApp.SystemServiceGroup.OperationRecordService
 
-var respPool sync.Pool
 var bufferSize = 1024
 
-func init() {
-	respPool.New = func() interface{} {
-		return make([]byte, bufferSize)
+// sensitiveFields 操作日志脱敏字段(不区分大小写):密码、令牌、密钥、支付敏感字段。
+var sensitiveFields = map[string]bool{
+	"password": true, "old_password": true, "new_password": true,
+	"confirm_password": true, "pwd": true, "pass": true,
+	"token": true, "access_token": true, "refresh_token": true, "jwt": true,
+	"secret": true, "private_key": true,
+}
+
+// maskSensitiveJSON 对 JSON 文本中的敏感字段值脱敏(非 JSON 原样返回)。
+func maskSensitiveJSON(data []byte) []byte {
+	var obj interface{}
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
 	}
+	out, err := json.Marshal(maskValue(obj))
+	if err != nil {
+		return data
+	}
+	return out
+}
+
+// maskValue 递归脱敏:命中敏感字段名时值替换为 "******"(空值保留)。
+func maskValue(v interface{}) interface{} {
+	switch value := v.(type) {
+	case map[string]interface{}:
+		for key, child := range value {
+			if sensitiveFields[strings.ToLower(key)] {
+				if child != nil {
+					value[key] = "******"
+				}
+				continue
+			}
+			value[key] = maskValue(child)
+		}
+		return value
+	case []interface{}:
+		for i := range value {
+			value[i] = maskValue(value[i])
+		}
+		return value
+	default:
+		return v
+	}
+}
+
+// maskResponse 响应体脱敏并截断(修复旧代码只对下载类响应截断、且误写 record.Body 字段的问题)。
+func maskResponse(resp string) string {
+	masked := maskSensitiveJSON([]byte(resp))
+	if len(masked) > bufferSize {
+		return "[超出记录长度]"
+	}
+	return string(masked)
 }
 
 func OperationRecord() gin.HandlerFunc {
@@ -79,10 +125,11 @@ func OperationRecord() gin.HandlerFunc {
 		if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
 			record.Body = "[文件]"
 		} else {
-			if len(body) > bufferSize {
+			masked := maskSensitiveJSON(body)
+			if len(masked) > bufferSize {
 				record.Body = "[超出记录长度]"
 			} else {
-				record.Body = string(body)
+				record.Body = string(masked)
 			}
 		}
 
@@ -99,22 +146,7 @@ func OperationRecord() gin.HandlerFunc {
 		record.ErrorMessage = c.Errors.ByType(gin.ErrorTypePrivate).String()
 		record.Status = c.Writer.Status()
 		record.Latency = latency
-		record.Resp = writer.body.String()
-
-		if strings.Contains(c.Writer.Header().Get("Pragma"), "public") ||
-			strings.Contains(c.Writer.Header().Get("Expires"), "0") ||
-			strings.Contains(c.Writer.Header().Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/force-download") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/octet-stream") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/vnd.ms-excel") ||
-			strings.Contains(c.Writer.Header().Get("Content-Type"), "application/download") ||
-			strings.Contains(c.Writer.Header().Get("Content-Disposition"), "attachment") ||
-			strings.Contains(c.Writer.Header().Get("Content-Transfer-Encoding"), "binary") {
-			if len(record.Resp) > bufferSize {
-				// 截断
-				record.Body = "超出记录长度"
-			}
-		}
+		record.Resp = maskResponse(writer.body.String())
 
 		if err := operationRecordService.CreateSysOperationRecord(record); err != nil {
 			global.GVA_LOG.Error("create operation record error:", zap.Error(err))

+ 48 - 0
internal/middleware/operation_mask_test.go

@@ -0,0 +1,48 @@
+package middleware
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestMaskSensitiveJSONMasksPasswordAndTokenFields(t *testing.T) {
+	body := []byte(`{"username":"admin","password":"secret123","old_password":"old","new_password":"new1","token":"jwt-token","remark":"ok","nested":{"password":"x","value":1},"items":[{"pass":"p","name":"a"}]}`)
+
+	masked := string(maskSensitiveJSON(body))
+
+	// 敏感字段脱敏
+	require.NotContains(t, masked, "secret123")
+	require.NotContains(t, masked, "jwt-token")
+	require.Contains(t, masked, "******")
+	// 非敏感字段保留
+	require.Contains(t, masked, `"username":"admin"`)
+	require.Contains(t, masked, `"remark":"ok"`)
+	require.Contains(t, masked, `"value":1`)
+	require.Contains(t, masked, `"name":"a"`)
+}
+
+func TestMaskSensitiveJSONFieldNamesCaseInsensitive(t *testing.T) {
+	body := []byte(`{"Password":"UPPER","New_Password":"x"}`)
+	masked := string(maskSensitiveJSON(body))
+	require.NotContains(t, masked, "UPPER")
+	require.Contains(t, masked, "******")
+}
+
+func TestMaskSensitiveJSONPassesThroughNonJSON(t *testing.T) {
+	body := []byte(`not a json body`)
+	require.Equal(t, string(body), string(maskSensitiveJSON(body)))
+}
+
+func TestMaskResponseTruncatesLongContent(t *testing.T) {
+	long := strings.Repeat("x", 5000)
+	// JSON 数组包裹后仍超长 → 截断标记
+	resp := maskResponse(`{"data":"` + long + `"}`)
+	require.Equal(t, "[超出记录长度]", resp)
+}
+
+func TestMaskResponseKeepsShortContent(t *testing.T) {
+	resp := maskResponse(`{"code":0,"msg":"ok"}`)
+	require.Equal(t, `{"code":0,"msg":"ok"}`, resp)
+}

+ 21 - 19
internal/model/vehicle/request/fee_config.go

@@ -1,28 +1,30 @@
 package request
 
 type FeeConfigCreate struct {
-	VehicleTypeID uint    `json:"vehicle_type_id"`
-	StartTime     int     `json:"start_time"`
-	StartFee      float64 `json:"start_fee"`
-	UnitTime      int     `json:"unit_time"`
-	UnitFee       float64 `json:"unit_fee"`
-	DailyMaxFee   float64 `json:"daily_max_fee"`
-	IsVIPFree     bool    `json:"is_vip_free"`
-	VIPDiscount   float64 `json:"vip_discount"`
-	Description   string  `json:"description"`
+	VehicleTypeID   uint    `json:"vehicle_type_id"`
+	StartTime       int     `json:"start_time"`
+	StartFee        float64 `json:"start_fee"`
+	UnitTime        int     `json:"unit_time"`
+	UnitFee         float64 `json:"unit_fee"`
+	DailyMaxFee     float64 `json:"daily_max_fee"`
+	FreeExitMinutes int     `json:"free_exit_minutes"` // 缴费后免费离场分钟数(0=不限,负值非法)
+	IsVIPFree       bool    `json:"is_vip_free"`
+	VIPDiscount     float64 `json:"vip_discount"`
+	Description     string  `json:"description"`
 }
 
 type FeeConfigUpdate struct {
-	ID            uint    `json:"id"`
-	VehicleTypeID uint    `json:"vehicle_type_id"`
-	StartTime     int     `json:"start_time"`
-	StartFee      float64 `json:"start_fee"`
-	UnitTime      int     `json:"unit_time"`
-	UnitFee       float64 `json:"unit_fee"`
-	DailyMaxFee   float64 `json:"daily_max_fee"`
-	IsVIPFree     bool    `json:"is_vip_free"`
-	VIPDiscount   float64 `json:"vip_discount"`
-	Description   string  `json:"description"`
+	ID              uint    `json:"id"`
+	VehicleTypeID   uint    `json:"vehicle_type_id"`
+	StartTime       int     `json:"start_time"`
+	StartFee        float64 `json:"start_fee"`
+	UnitTime        int     `json:"unit_time"`
+	UnitFee         float64 `json:"unit_fee"`
+	DailyMaxFee     float64 `json:"daily_max_fee"`
+	FreeExitMinutes int     `json:"free_exit_minutes"`
+	IsVIPFree       bool    `json:"is_vip_free"`
+	VIPDiscount     float64 `json:"vip_discount"`
+	Description     string  `json:"description"`
 }
 
 type FeeConfigQuery struct {

+ 4 - 0
internal/model/vehicle/response/vehicle.go

@@ -51,6 +51,10 @@ type ExitPreviewResponse struct {
 	EntryDeviceName  string  `json:"entry_device_name"`
 	VehicleType      string  `json:"vehicle_type"`
 	IsTemp           bool    `json:"is_temp"`
+	// 缴费后免费离场时限(票已支付且超时时有效)
+	Overtime     bool    `json:"overtime"`      // 是否超过免费离场时限
+	OvertimeFee  float64 `json:"overtime_fee"`  // 超时后按当前时长重算的总费用
+	OvertimeDiff float64 `json:"overtime_diff"` // 需补缴差额(总费用 - 已付)
 }
 
 type OperationContextResponse struct {

+ 39 - 12
internal/modules/digital-ticket/service/fee.go

@@ -1,27 +1,45 @@
 package service
 
 import (
+	"errors"
+	"fmt"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 )
 
+var (
+	// ErrFeeConfigNotFound 车辆类型未配置收费规则(不再隐式按 0.1 元/分钟兜底收费)。
+	ErrFeeConfigNotFound = errors.New("车辆类型未配置收费规则")
+	// ErrFeeConfigInvalid 收费配置数值非法。
+	ErrFeeConfigInvalid = errors.New("收费配置无效")
+)
+
+const minutesPerDay = 1440
+
 // CalculateFee is the shared parking-fee calculation used by vehicle exit
 // flows and the digital-ticket live preview.
-func CalculateFee(vehicle *dao.Vehicle, stayTime int64) float64 {
+//
+// 规则:
+//   - 该车辆类型必须存在收费配置,否则返回错误(避免隐式兜底造成错误收费)。
+//   - 免费时长(StartTime)内费用为 0;超出后按起步价 + 单位时长累加,不足一个单位按一个单位。
+//   - 配置了日封顶(DailyMaxFee)时,费用不超过"跨天数 × 日封顶",跨天向上取整,
+//     修复多日停车只应用一次日封顶的问题。
+//   - VIP 免费优先于折扣;折扣应用于封顶后的金额。
+func CalculateFee(vehicle *dao.Vehicle, stayTime int64) (float64, error) {
 	if vehicle == nil || global.GVA_DB == nil {
-		return 0
+		return 0, errors.New("计费上下文缺失")
+	}
+	if stayTime < 0 {
+		stayTime = 0
 	}
 
 	var feeConfig dao.FeeConfig
 	if err := global.GVA_DB.Where("vehicle_type_id = ?", vehicle.VehicleTypeID).First(&feeConfig).Error; err != nil {
-		return float64(stayTime) * 0.1
-	}
-	if feeConfig.UnitFee == 0 && feeConfig.StartFee == 0 && feeConfig.DailyMaxFee == 0 {
-		return float64(stayTime) * 0.1
+		return 0, fmt.Errorf("%w: 车辆类型ID=%d", ErrFeeConfigNotFound, vehicle.VehicleTypeID)
 	}
 	if feeConfig.UnitTime <= 0 {
-		return feeConfig.StartFee
+		return 0, fmt.Errorf("%w: 单位时间必须大于0", ErrFeeConfigInvalid)
 	}
 
 	isVIP := false
@@ -33,10 +51,10 @@ func CalculateFee(vehicle *dao.Vehicle, stayTime int64) float64 {
 		}
 	}
 	if isVIP && feeConfig.IsVIPFree {
-		return 0
+		return 0, nil
 	}
 	if stayTime <= int64(feeConfig.StartTime) {
-		return 0
+		return 0, nil
 	}
 
 	extraTime := stayTime - int64(feeConfig.StartTime)
@@ -45,11 +63,20 @@ func CalculateFee(vehicle *dao.Vehicle, stayTime int64) float64 {
 		extraUnits++
 	}
 	fee := feeConfig.StartFee + float64(extraUnits)*feeConfig.UnitFee
-	if feeConfig.DailyMaxFee > 0 && fee > feeConfig.DailyMaxFee {
-		fee = feeConfig.DailyMaxFee
+
+	if feeConfig.DailyMaxFee > 0 {
+		days := stayTime / minutesPerDay
+		if stayTime%minutesPerDay > 0 {
+			days++
+		}
+		capFee := float64(days) * feeConfig.DailyMaxFee
+		if fee > capFee {
+			fee = capFee
+		}
 	}
+
 	if isVIP {
 		fee *= feeConfig.VIPDiscount
 	}
-	return fee
+	return fee, nil
 }

+ 178 - 0
internal/modules/digital-ticket/service/fee_test.go

@@ -0,0 +1,178 @@
+package service
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupFeeTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:fee-%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.Vehicle{}, &dao.VehicleType{}, &dao.Owner{}, &dao.FeeConfig{}))
+}
+
+func seedVehicle(t *testing.T, typeID uint, plate string) *dao.Vehicle {
+	t.Helper()
+	vehicle := dao.Vehicle{PlateNumber: plate, VehicleTypeID: typeID}
+	require.NoError(t, global.GVA_DB.Create(&vehicle).Error)
+	return &vehicle
+}
+
+// seedConfig 创建收费配置:免费 15 分钟,起步 5 元,每 30 分钟 3 元。
+func seedConfig(t *testing.T, typeID uint, mut func(*dao.FeeConfig)) {
+	t.Helper()
+	config := dao.FeeConfig{
+		VehicleTypeID: typeID, StartTime: 15, StartFee: 5,
+		UnitTime: 30, UnitFee: 3, DailyMaxFee: 30, VIPDiscount: 1.0,
+	}
+	if mut != nil {
+		mut(&config)
+	}
+	require.NoError(t, global.GVA_DB.Create(&config).Error)
+}
+
+func TestCalculateFeeRejectsMissingConfig(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 99, "FEE-001")
+
+	fee, err := CalculateFee(vehicle, 60)
+	require.ErrorIs(t, err, ErrFeeConfigNotFound)
+	require.Zero(t, fee)
+}
+
+func TestCalculateFeeRejectsInvalidUnitTime(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 99, "FEE-002")
+	seedConfig(t, 99, func(c *dao.FeeConfig) { c.UnitTime = 0 })
+
+	fee, err := CalculateFee(vehicle, 60)
+	require.ErrorIs(t, err, ErrFeeConfigInvalid)
+	require.Zero(t, fee)
+}
+
+func TestCalculateFeeFreePeriodAndNormalBilling(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-003")
+	seedConfig(t, 1, nil)
+
+	// 免费时长内
+	fee, err := CalculateFee(vehicle, 15)
+	require.NoError(t, err)
+	require.Zero(t, fee)
+
+	// 30 分钟:5 + ceil(15/30)*3 = 8
+	fee, err = CalculateFee(vehicle, 30)
+	require.NoError(t, err)
+	require.Equal(t, 8.0, fee)
+
+	// 45 分钟:5 + ceil(30/30)*3 = 8(免费 15 后正好 1 个单位)
+	fee, err = CalculateFee(vehicle, 45)
+	require.NoError(t, err)
+	require.Equal(t, 8.0, fee)
+
+	// 46 分钟:5 + ceil(31/30)*3 = 11(不足一个单位按一个单位)
+	fee, err = CalculateFee(vehicle, 46)
+	require.NoError(t, err)
+	require.Equal(t, 11.0, fee)
+}
+
+func TestCalculateFeeDailyCapWithinOneDay(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-004")
+	seedConfig(t, 1, nil)
+
+	// 10 小时:5 + ceil(585/30)*3 = 65 → 封顶 30
+	fee, err := CalculateFee(vehicle, 600)
+	require.NoError(t, err)
+	require.Equal(t, 30.0, fee)
+}
+
+func TestCalculateFeeMultiDayCapAccumulates(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-005")
+	seedConfig(t, 1, nil)
+
+	// 26 小时(跨 2 天):原始 5 + ceil(1545/30)*3 = 161 → 封顶 2×30 = 60
+	fee, err := CalculateFee(vehicle, 1560)
+	require.NoError(t, err)
+	require.Equal(t, 60.0, fee)
+
+	// 3 天整:封顶 3×30 = 90
+	fee, err = CalculateFee(vehicle, 3*1440)
+	require.NoError(t, err)
+	require.Equal(t, 90.0, fee)
+}
+
+func TestCalculateFeeNoCapMultiDayChargesFull(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-006")
+	seedConfig(t, 1, func(c *dao.FeeConfig) { c.DailyMaxFee = 0 })
+
+	// 2 天:5 + ceil(2865/30)*3 = 5 + 288 = 293(无封顶)
+	fee, err := CalculateFee(vehicle, 2*1440)
+	require.NoError(t, err)
+	require.Equal(t, 293.0, fee)
+}
+
+func TestCalculateFeeZeroConfigMeansExplicitFree(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-007")
+	seedConfig(t, 1, func(c *dao.FeeConfig) {
+		c.StartTime, c.StartFee, c.UnitFee, c.DailyMaxFee = 0, 0, 0, 0
+	})
+
+	fee, err := CalculateFee(vehicle, 600)
+	require.NoError(t, err)
+	require.Zero(t, fee)
+}
+
+func TestCalculateFeeVIPFree(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-008")
+	seedConfig(t, 1, func(c *dao.FeeConfig) { c.IsVIPFree = true })
+
+	future := time.Now().Add(24 * time.Hour)
+	require.NoError(t, global.GVA_DB.Create(&dao.Owner{
+		OwnerName: "VIP车", IsVip: true, VipExpireTime: future,
+	}).Error)
+	require.NoError(t, global.GVA_DB.Model(vehicle).Update("owner_id", 1).Error)
+
+	fee, err := CalculateFee(vehicle, 120)
+	require.NoError(t, err)
+	require.Zero(t, fee)
+}
+
+func TestCalculateFeeVIPDiscount(t *testing.T) {
+	setupFeeTest(t)
+	vehicle := seedVehicle(t, 1, "FEE-009")
+	seedConfig(t, 1, func(c *dao.FeeConfig) { c.VIPDiscount = 0.8 })
+
+	future := time.Now().Add(24 * time.Hour)
+	require.NoError(t, global.GVA_DB.Create(&dao.Owner{
+		OwnerName: "VIP车", IsVip: true, VipExpireTime: future,
+	}).Error)
+	require.NoError(t, global.GVA_DB.Model(vehicle).Update("owner_id", 1).Error)
+
+	fee, err := CalculateFee(vehicle, 120)
+	require.NoError(t, err)
+	// 原始:5 + ceil(105/30)*3 = 17 → 封顶 30 以内 → ×0.8 = 13.6
+	require.InDelta(t, 13.6, fee, 0.001)
+}

+ 5 - 2
internal/modules/digital-ticket/service/service.go

@@ -297,11 +297,14 @@ func (s *TicketService) enrichTicket(ticket *dao.DigitalTicket) {
 		if !vehicleFound {
 			return
 		}
-		stayTime := int64(time.Since(record.EntryTime).Minutes())
+		stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
 		if stayTime < 0 {
 			stayTime = 0
 		}
-		ticket.Fee = CalculateFee(&vehicle, stayTime)
+		// 预览保持宽容:未配置收费规则时费用显示 0,正式出场时由 CalculateFee 返回明确错误
+		if fee, calcErr := CalculateFee(&vehicle, stayTime); calcErr == nil {
+			ticket.Fee = fee
+		}
 		return
 	}
 

+ 94 - 63
internal/modules/monthly/service/service.go

@@ -8,6 +8,8 @@ import (
 	"wails-app/internal/global"
 	"wails-app/internal/modules/monthly/repository"
 	paymentSvc "wails-app/internal/modules/payment/service"
+
+	"gorm.io/gorm"
 )
 
 type MonthlyCardService struct {
@@ -31,9 +33,12 @@ func cardDays(cardType string) int {
 	}
 }
 
-// Create 办理月卡:创建 monthly_card + shortlist 白名单 + payment_record
+// Create 办理月卡:月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string) (*dao.MonthlyCard, error) {
-	existing, _ := s.repo.GetByVehicleID(vehicleID)
+	existing, err := s.repo.GetByVehicleID(vehicleID)
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, fmt.Errorf("查询有效月卡失败: %w", err)
+	}
 	if existing != nil {
 		return nil, errors.New("该车辆已有有效月卡,请先退卡或等过期后续费")
 	}
@@ -51,76 +56,102 @@ func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64
 		StartDate: now, EndDate: endDate,
 		Fee: fee, PaymentStatus: "paid", OperatorID: operatorID, Remark: remark,
 	}
-	if err := s.repo.Create(card); err != nil {
-		return nil, fmt.Errorf("创建月卡失败: %w", err)
-	}
 
-	// 写入白名单
-	shortlist := dao.Shortlist{
-		VehicleId:      int(vehicleID),
-		ListType:       "白名单",
-		ExpirationTime: &endDate,
+	err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Create(card).Error; err != nil {
+			return fmt.Errorf("创建月卡失败: %w", err)
+		}
+
+		shortlist := dao.Shortlist{
+			VehicleId:      int(vehicleID),
+			ListType:       "白名单",
+			ExpirationTime: &endDate,
+		}
+		if err := tx.Create(&shortlist).Error; err != nil {
+			return fmt.Errorf("写入白名单失败: %w", err)
+		}
+
+		payRecord := dao.PaymentRecord{
+			RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
+			Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+			OperatorID: operatorID, PaidAt: now,
+			Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
+		}
+		if err := tx.Create(&payRecord).Error; err != nil {
+			return fmt.Errorf("创建支付流水失败: %w", err)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
 	}
-	global.GVA_DB.Create(&shortlist)
-
-	// 创建 payment_record
-	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
-		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
-		OperatorID: operatorID, PaidAt: now,
-		Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
-	}
-	global.GVA_DB.Create(&payRecord)
-
 	return card, nil
 }
 
-// Renew 续费:延长到期日 + 更新 shortlist
+// Renew 续费:未过期卡从原到期日顺延,已过期卡从今天起算;
+// 月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint) error {
-	var card dao.MonthlyCard
-	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
-		return errors.New("月卡记录不存在")
-	}
-	if card.PaymentStatus != "paid" {
-		return errors.New("月卡已退卡,无法续费")
-	}
-
-	newEnd := card.EndDate.AddDate(0, 0, cardDays(cardType))
-	card.EndDate = newEnd
-	card.Fee += fee
-	global.GVA_DB.Save(&card)
-
-	// 更新 shortlist 到期日
-	global.GVA_DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
-		Update("expiration_time", newEnd)
-
-	// 创建 payment_record
-	now := time.Now()
-	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
-		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
-		OperatorID: operatorID, PaidAt: now,
-		Remark: fmt.Sprintf("包月卡续费(%s)", cardType),
-	}
-	global.GVA_DB.Create(&payRecord)
-
-	return nil
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var card dao.MonthlyCard
+		if err := tx.First(&card, cardID).Error; err != nil {
+			return errors.New("月卡记录不存在")
+		}
+		if card.PaymentStatus != "paid" {
+			return errors.New("月卡已退卡,无法续费")
+		}
+
+		now := time.Now()
+		// 过期卡从今天起算,避免"续费后仍处于过期状态"
+		base := card.EndDate
+		if base.Before(now) {
+			base = now
+		}
+		newEnd := base.AddDate(0, 0, cardDays(cardType))
+
+		card.EndDate = newEnd
+		card.Fee += fee
+		if err := tx.Save(&card).Error; err != nil {
+			return fmt.Errorf("更新月卡失败: %w", err)
+		}
+
+		if err := tx.Model(&dao.Shortlist{}).
+			Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+			Update("expiration_time", newEnd).Error; err != nil {
+			return fmt.Errorf("更新白名单失败: %w", err)
+		}
+
+		payRecord := dao.PaymentRecord{
+			RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
+			Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+			OperatorID: operatorID, PaidAt: now,
+			Remark: fmt.Sprintf("包月卡续费(%s)", cardType),
+		}
+		if err := tx.Create(&payRecord).Error; err != nil {
+			return fmt.Errorf("创建支付流水失败: %w", err)
+		}
+		return nil
+	})
 }
 
-// Refund 退卡:标记 refunded + 移除 shortlist
+// Refund 退卡:标记退款状态与移除白名单在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Refund(cardID uint) error {
-	var card dao.MonthlyCard
-	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
-		return errors.New("月卡记录不存在")
-	}
-
-	card.PaymentStatus = "refunded"
-	global.GVA_DB.Save(&card)
-
-	// 移除白名单
-	global.GVA_DB.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").Unscoped().Delete(&dao.Shortlist{})
-
-	return nil
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var card dao.MonthlyCard
+		if err := tx.First(&card, cardID).Error; err != nil {
+			return errors.New("月卡记录不存在")
+		}
+
+		card.PaymentStatus = "refunded"
+		if err := tx.Save(&card).Error; err != nil {
+			return fmt.Errorf("更新月卡状态失败: %w", err)
+		}
+
+		if err := tx.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+			Unscoped().Delete(&dao.Shortlist{}).Error; err != nil {
+			return fmt.Errorf("移除白名单失败: %w", err)
+		}
+		return nil
+	})
 }
 
 func (s *MonthlyCardService) List(q repository.MonthlyCardQuery) ([]repository.MonthlyCardResult, int64, error) {

+ 233 - 0
internal/modules/monthly/service/service_test.go

@@ -0,0 +1,233 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupMonthlyServiceTest(t *testing.T) *MonthlyCardService {
+	t.Helper()
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.Vehicle{}, &dao.MonthlyCard{}, &dao.Shortlist{}, &dao.PaymentRecord{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+	return NewMonthlyCardService()
+}
+
+func createVehicle(t *testing.T, plate string) *dao.Vehicle {
+	t.Helper()
+	vehicle := dao.Vehicle{PlateNumber: plate, VehicleTypeID: 1}
+	require.NoError(t, global.GVA_DB.Create(&vehicle).Error)
+	return &vehicle
+}
+
+func countTable(t *testing.T, model interface{}) int64 {
+	t.Helper()
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(model).Count(&count).Error)
+	return count
+}
+
+func TestCreateWritesCardWhitelistAndPayment(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-001")
+
+	card, err := svc.Create(vehicle.ID, "month", 300, 7, "测试办理")
+	require.NoError(t, err)
+	require.NotZero(t, card.ID)
+	require.Equal(t, "paid", card.PaymentStatus)
+	require.True(t, card.EndDate.After(time.Now().Add(29*24*time.Hour)))
+
+	// 三张表全部写入
+	require.Equal(t, int64(1), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(1), countTable(t, &dao.Shortlist{}))
+	require.Equal(t, int64(1), countTable(t, &dao.PaymentRecord{}))
+
+	// 白名单到期日与月卡一致
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.First(&shortlist).Error)
+	require.Equal(t, "白名单", shortlist.ListType)
+	require.NotNil(t, shortlist.ExpirationTime)
+	require.True(t, shortlist.ExpirationTime.Equal(card.EndDate))
+
+	// 支付流水
+	var pay dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.First(&pay).Error)
+	require.Equal(t, 300.0, pay.Amount)
+	require.Equal(t, uint(7), pay.OperatorID)
+}
+
+func TestCreateRollsBackWhenWhitelistWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-002")
+
+	// 模拟白名单写入失败:删除 shortlist 表
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE shortlist").Error)
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "白名单")
+
+	// 月卡与流水均回滚
+	require.Equal(t, int64(0), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(0), countTable(t, &dao.PaymentRecord{}))
+}
+
+func TestCreateRollsBackWhenPaymentWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-003")
+
+	// 模拟支付流水写入失败:删除 payment_record 表
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE payment_record").Error)
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "支付流水")
+
+	// 月卡与白名单均回滚
+	require.Equal(t, int64(0), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(0), countTable(t, &dao.Shortlist{}))
+}
+
+func TestCreateRejectsDuplicateActiveCard(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-004")
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.NoError(t, err)
+
+	_, err = svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "已有有效月卡")
+	require.Equal(t, int64(1), countTable(t, &dao.MonthlyCard{}))
+}
+
+func TestCreateRejectsUnknownVehicle(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	_, err := svc.Create(99999, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "车辆不存在")
+}
+
+func TestRenewExtendsFromEndDateWhenActive(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-005")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now.AddDate(0, -1, 0), EndDate: now.AddDate(0, 0, 10),
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	originalEnd := card.EndDate
+	require.NoError(t, global.GVA_DB.Create(&dao.Shortlist{
+		VehicleId: int(vehicle.ID), ListType: "白名单", ExpirationTime: &originalEnd,
+	}).Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.NoError(t, err)
+
+	var renewed dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&renewed, card.ID).Error)
+	// 未过期:从原到期日顺延 30 天
+	require.True(t, renewed.EndDate.After(now.AddDate(0, 0, 39)), "到期日应约为原到期日+30天")
+	require.True(t, renewed.EndDate.Before(now.AddDate(0, 0, 41)))
+	require.Equal(t, 600.0, renewed.Fee)
+
+	// 白名单到期日同步
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.Where("vehicle_id = ?", vehicle.ID).First(&shortlist).Error)
+	require.True(t, shortlist.ExpirationTime.Equal(renewed.EndDate))
+}
+
+func TestRenewStartsFromNowWhenCardExpired(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-006")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now.AddDate(0, -3, 0), EndDate: now.AddDate(0, -2, 0), // 已过期 2 个月
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	expiredEnd := card.EndDate
+	require.NoError(t, global.GVA_DB.Create(&dao.Shortlist{
+		VehicleId: int(vehicle.ID), ListType: "白名单", ExpirationTime: &expiredEnd,
+	}).Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.NoError(t, err)
+
+	var renewed dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&renewed, card.ID).Error)
+	// 已过期:从今天起算约 30 天,而不是从两个月前的到期日续期
+	require.True(t, renewed.EndDate.After(now.AddDate(0, 0, 29)), "过期卡应从今天起算")
+	require.True(t, renewed.EndDate.Before(now.AddDate(0, 0, 31)))
+
+	// 白名单到期日同步为新到期日(未过期卡同样适用)
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.Where("vehicle_id = ?", vehicle.ID).First(&shortlist).Error)
+	require.True(t, shortlist.ExpirationTime.Equal(renewed.EndDate))
+}
+
+func TestRenewRollsBackWhenPaymentWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-007")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now, EndDate: now.AddDate(0, 0, 10),
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	originalEnd := card.EndDate
+
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE payment_record").Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.Error(t, err)
+
+	// 到期日与费用均未变(回滚)
+	var after dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&after, card.ID).Error)
+	require.True(t, after.EndDate.Equal(originalEnd))
+	require.Equal(t, 300.0, after.Fee)
+}
+
+func TestRefundMarksRefundedAndRemovesWhitelist(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-008")
+
+	card, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.NoError(t, err)
+	require.Equal(t, int64(1), countTable(t, &dao.Shortlist{}))
+
+	err = svc.Refund(card.ID)
+	require.NoError(t, err)
+
+	var refunded dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&refunded, card.ID).Error)
+	require.Equal(t, "refunded", refunded.PaymentStatus)
+
+	var whitelistCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.Shortlist{}).
+		Where("vehicle_id = ? AND list_type = ?", vehicle.ID, "白名单").Count(&whitelistCount).Error)
+	require.Equal(t, int64(0), whitelistCount)
+}
+
+func TestRefundRejectsUnknownCard(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	err := svc.Refund(99999)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "月卡记录不存在")
+}

+ 66 - 10
internal/service/vehicle/fee_config.go

@@ -2,6 +2,7 @@ package vehicle
 
 import (
 	"errors"
+	"math"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/vehicle/request"
@@ -9,8 +10,41 @@ import (
 
 type FeeConfigService struct{}
 
+// validateFeeRules 校验收费规则的数值合法性,防止脏配置静默改变计费。
+// 全零配置允许(显式免费),但单位时间必须大于 0,金额必须非负且有限。
+func validateFeeRules(req request.FeeConfigCreate) error {
+	if req.StartTime < 0 {
+		return errors.New("免费时长不能为负数")
+	}
+	if req.UnitTime <= 0 {
+		return errors.New("单位时间必须大于0分钟")
+	}
+	if req.StartFee < 0 || math.IsNaN(req.StartFee) || math.IsInf(req.StartFee, 0) {
+		return errors.New("起步价必须为非负有限数值")
+	}
+	if req.UnitFee < 0 || math.IsNaN(req.UnitFee) || math.IsInf(req.UnitFee, 0) {
+		return errors.New("单位费用必须为非负有限数值")
+	}
+	if req.DailyMaxFee < 0 || math.IsNaN(req.DailyMaxFee) || math.IsInf(req.DailyMaxFee, 0) {
+		return errors.New("日封顶金额必须为非负有限数值")
+	}
+	if req.DailyMaxFee > 0 && req.DailyMaxFee < req.StartFee {
+		return errors.New("日封顶金额不能低于起步价")
+	}
+	if req.VIPDiscount <= 0 || req.VIPDiscount > 1 || math.IsNaN(req.VIPDiscount) || math.IsInf(req.VIPDiscount, 0) {
+		return errors.New("VIP折扣必须在(0,1]区间,如0.8表示八折")
+	}
+	if req.FreeExitMinutes < 0 {
+		return errors.New("缴费后免费离场分钟数不能为负数")
+	}
+	return nil
+}
+
 // CreateFeeConfig 创建收费配置
 func (s *FeeConfigService) CreateFeeConfig(req request.FeeConfigCreate) error {
+	if err := validateFeeRules(req); err != nil {
+		return err
+	}
 	// 检查车辆类型是否存在
 	var vehicleType dao.VehicleType
 	result := global.GVA_DB.First(&vehicleType, req.VehicleTypeID)
@@ -26,15 +60,16 @@ func (s *FeeConfigService) CreateFeeConfig(req request.FeeConfigCreate) error {
 	}
 
 	config := dao.FeeConfig{
-		VehicleTypeID: req.VehicleTypeID,
-		StartTime:     req.StartTime,
-		StartFee:      req.StartFee,
-		UnitTime:      req.UnitTime,
-		UnitFee:       req.UnitFee,
-		DailyMaxFee:   req.DailyMaxFee,
-		IsVIPFree:     req.IsVIPFree,
-		VIPDiscount:   req.VIPDiscount,
-		Description:   req.Description,
+		VehicleTypeID:   req.VehicleTypeID,
+		StartTime:       req.StartTime,
+		StartFee:        req.StartFee,
+		UnitTime:        req.UnitTime,
+		UnitFee:         req.UnitFee,
+		DailyMaxFee:     req.DailyMaxFee,
+		FreeExitMinutes: req.FreeExitMinutes,
+		IsVIPFree:       req.IsVIPFree,
+		VIPDiscount:     req.VIPDiscount,
+		Description:     req.Description,
 	}
 
 	return global.GVA_DB.Create(&config).Error
@@ -42,6 +77,20 @@ func (s *FeeConfigService) CreateFeeConfig(req request.FeeConfigCreate) error {
 
 // UpdateFeeConfig 更新收费配置
 func (s *FeeConfigService) UpdateFeeConfig(req request.FeeConfigUpdate) error {
+	if err := validateFeeRules(request.FeeConfigCreate{
+		VehicleTypeID:   req.VehicleTypeID,
+		StartTime:       req.StartTime,
+		StartFee:        req.StartFee,
+		UnitTime:        req.UnitTime,
+		UnitFee:         req.UnitFee,
+		DailyMaxFee:     req.DailyMaxFee,
+		FreeExitMinutes: req.FreeExitMinutes,
+		IsVIPFree:       req.IsVIPFree,
+		VIPDiscount:     req.VIPDiscount,
+		Description:     req.Description,
+	}); err != nil {
+		return err
+	}
 	var config dao.FeeConfig
 	result := global.GVA_DB.First(&config, req.ID)
 	if result.RowsAffected == 0 {
@@ -68,6 +117,7 @@ func (s *FeeConfigService) UpdateFeeConfig(req request.FeeConfigUpdate) error {
 	config.UnitTime = req.UnitTime
 	config.UnitFee = req.UnitFee
 	config.DailyMaxFee = req.DailyMaxFee
+	config.FreeExitMinutes = req.FreeExitMinutes
 	config.IsVIPFree = req.IsVIPFree
 	config.VIPDiscount = req.VIPDiscount
 	config.Description = req.Description
@@ -109,7 +159,13 @@ func (s *FeeConfigService) ListFeeConfigs(req request.FeeConfigQuery) (int64, []
 	// 计算总数
 	query.Count(&total)
 
-	// 分页查询
+	// 分页兜底(gin 不解析 default 标签,Page/PageSize 可能为 0 或负数)
+	if req.Page <= 0 {
+		req.Page = 1
+	}
+	if req.PageSize <= 0 {
+		req.PageSize = 10
+	}
 	offset := (req.Page - 1) * req.PageSize
 	result := query.Offset(offset).Limit(req.PageSize).Preload("VehicleType").Find(&configs)
 	if result.Error != nil {

+ 115 - 0
internal/service/vehicle/fee_config_test.go

@@ -0,0 +1,115 @@
+package vehicle
+
+import (
+	"fmt"
+	"math"
+	"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/model/vehicle/request"
+)
+
+func setupFeeConfigTest(t *testing.T) *FeeConfigService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:fee-config-%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.VehicleType{}, &dao.FeeConfig{}))
+	require.NoError(t, db.Create(&dao.VehicleType{Name: "临时车", IsSystem: true}).Error)
+	return &FeeConfigService{}
+}
+
+func validReq() request.FeeConfigCreate {
+	return request.FeeConfigCreate{
+		VehicleTypeID: 1, StartTime: 15, StartFee: 5,
+		UnitTime: 30, UnitFee: 3, DailyMaxFee: 30, VIPDiscount: 1.0,
+	}
+}
+
+func TestCreateFeeConfigAcceptsValidRules(t *testing.T) {
+	svc := setupFeeConfigTest(t)
+	require.NoError(t, svc.CreateFeeConfig(validReq()))
+
+	var config dao.FeeConfig
+	require.NoError(t, global.GVA_DB.First(&config, "vehicle_type_id = ?", uint(1)).Error)
+	require.Equal(t, 30.0, config.DailyMaxFee)
+}
+
+func TestCreateFeeConfigRejectsInvalidRules(t *testing.T) {
+	svc := setupFeeConfigTest(t)
+
+	cases := []struct {
+		name string
+		mut  func(*request.FeeConfigCreate)
+		want string
+	}{
+		{"负免费时长", func(r *request.FeeConfigCreate) { r.StartTime = -1 }, "免费时长"},
+		{"单位时间为零", func(r *request.FeeConfigCreate) { r.UnitTime = 0 }, "单位时间"},
+		{"负起步价", func(r *request.FeeConfigCreate) { r.StartFee = -1 }, "起步价"},
+		{"负单位费用", func(r *request.FeeConfigCreate) { r.UnitFee = -1 }, "单位费用"},
+		{"负日封顶", func(r *request.FeeConfigCreate) { r.DailyMaxFee = -1 }, "日封顶"},
+		{"封顶低于起步价", func(r *request.FeeConfigCreate) { r.DailyMaxFee = 3 }, "日封顶"},
+		{"折扣大于1", func(r *request.FeeConfigCreate) { r.VIPDiscount = 1.5 }, "VIP折扣"},
+		{"折扣为负", func(r *request.FeeConfigCreate) { r.VIPDiscount = -0.5 }, "VIP折扣"},
+		{"折扣为NaN", func(r *request.FeeConfigCreate) { r.VIPDiscount = math.NaN() }, "VIP折扣"},
+		{"起步价为NaN", func(r *request.FeeConfigCreate) { r.StartFee = math.NaN() }, "起步价"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			req := validReq()
+			tc.mut(&req)
+			err := svc.CreateFeeConfig(req)
+			require.Error(t, err)
+			require.Contains(t, err.Error(), tc.want)
+		})
+	}
+
+	// 全部拒绝,未入库
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.FeeConfig{}).Count(&count).Error)
+	require.Zero(t, count)
+}
+
+func TestUpdateFeeConfigRejectsInvalidRules(t *testing.T) {
+	svc := setupFeeConfigTest(t)
+	require.NoError(t, svc.CreateFeeConfig(validReq()))
+
+	var existing dao.FeeConfig
+	require.NoError(t, global.GVA_DB.First(&existing).Error)
+
+	update := request.FeeConfigUpdate{
+		ID: existing.ID, VehicleTypeID: 1, StartTime: 15, StartFee: 5,
+		UnitTime: 0, UnitFee: 3, DailyMaxFee: 30, VIPDiscount: 1.0,
+	}
+	require.Error(t, svc.UpdateFeeConfig(update))
+
+	// 原配置未被修改
+	var after dao.FeeConfig
+	require.NoError(t, global.GVA_DB.First(&after, existing.ID).Error)
+	require.Equal(t, 30, after.UnitTime)
+}
+
+func TestListFeeConfigsHandlesZeroPage(t *testing.T) {
+	svc := setupFeeConfigTest(t)
+	require.NoError(t, svc.CreateFeeConfig(validReq()))
+
+	// Page=0 兜底为第 1 页,不产生负 offset
+	total, list, err := svc.ListFeeConfigs(request.FeeConfigQuery{Page: 0, PageSize: 0})
+	require.NoError(t, err)
+	require.Equal(t, int64(1), total)
+	require.Len(t, list, 1)
+}

+ 198 - 0
internal/service/vehicle/overtime_test.go

@@ -0,0 +1,198 @@
+package vehicle
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/vehicle/request"
+	"wails-app/internal/modules/digital-ticket/service"
+	paymentService "wails-app/internal/modules/payment/service"
+)
+
+// createPaidTicketFixture 构造"已支付票 + 已支付流水 + 会话已支付"的出场场景。
+// paidAgoMinutes 控制支付时间距今多久(用于超时判定)。
+func createPaidTicketFixture(t *testing.T, paidAgoMinutes int) (*dao.VehicleRecord, *dao.Vehicle, *dao.DigitalTicket) {
+	t.Helper()
+	record, vehicle := createVehiclePaymentFixture(t, true)
+
+	ticketSvc := service.NewTicketService()
+	ticket, err := ticketSvc.GetByVehicleRecordIDTx(global.GVA_DB, record.ID)
+	require.NoError(t, err)
+
+	paidAt := time.Now().Add(-time.Duration(paidAgoMinutes) * time.Minute)
+	require.NoError(t, global.GVA_DB.Model(ticket).Updates(map[string]interface{}{
+		"state":          service.StatePaid,
+		"fee":            10.0,
+		"paid_amount":    10.0,
+		"paid_at":        paidAt,
+		"payment_entry":  paymentService.EntryCounter,
+		"payment_method": paymentService.PaymentCash,
+	}).Error)
+
+	require.NoError(t, global.GVA_DB.Create(&dao.PaymentRecord{
+		RecordID: record.ID, PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentCash,
+		Amount: 10, PaidAmount: 10, ChangeAmount: 0, PaidAt: paidAt, OperatorID: 1,
+	}).Error)
+	require.NoError(t, global.GVA_DB.Model(record).Update("payment_status", "paid").Error)
+
+	ticket, err = ticketSvc.GetByVehicleRecordIDTx(global.GVA_DB, record.ID)
+	require.NoError(t, err)
+	return record, vehicle, ticket
+}
+
+// setFreeExitMinutes 设置该车辆类型的缴费后免费离场时限。
+func setFreeExitMinutes(t *testing.T, vehicleTypeID uint, minutes int) {
+	t.Helper()
+	require.NoError(t, global.GVA_DB.Model(&dao.FeeConfig{}).
+		Where("vehicle_type_id = ?", vehicleTypeID).Update("free_exit_minutes", minutes).Error)
+}
+
+func TestPaidTicketOvertimeNotExceeded(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	_, vehicle, ticket := createPaidTicketFixture(t, 3) // 3 分钟前支付
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+
+	overtime, newFee, diff, err := svc.paidTicketOvertime(vehicle, ticket, 120)
+	require.NoError(t, err)
+	require.False(t, overtime)
+	require.Zero(t, newFee)
+	require.Zero(t, diff)
+}
+
+func TestPaidTicketOvertimeExceeded(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	_, vehicle, ticket := createPaidTicketFixture(t, 20) // 20 分钟前支付
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+
+	// 停留 1200 分钟:fixture 配置 UnitTime=60/UnitFee=1 → 新费用 20 元,已付 10 → 补缴 10
+	overtime, newFee, diff, err := svc.paidTicketOvertime(vehicle, ticket, 1200)
+	require.NoError(t, err)
+	require.True(t, overtime)
+	require.Equal(t, 20.0, newFee)
+	require.Equal(t, 10.0, diff)
+}
+
+func TestPaidTicketOvertimeDisabledAndNilPaidAt(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	_, vehicle, ticket := createPaidTicketFixture(t, 20)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 0) // 0=不限
+
+	overtime, _, _, err := svc.paidTicketOvertime(vehicle, ticket, 1200)
+	require.NoError(t, err)
+	require.False(t, overtime)
+
+	// PaidAt 为空(历史票)视为未超时
+	require.NoError(t, global.GVA_DB.Model(ticket).Update("paid_at", nil).Error)
+	ticket.PaidAt = nil
+	overtime, _, _, err = svc.paidTicketOvertime(vehicle, ticket, 1200)
+	require.NoError(t, err)
+	require.False(t, overtime)
+}
+
+func TestExitConfirmOvertimeRequiresTopUp(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle, _ := createPaidTicketFixture(t, 20)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+	// 停留 20 小时 → 费用 20 元,补缴 10 元
+	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now().Add(-20*time.Hour)).Error)
+
+	// 未携带补缴实收 → 拒绝
+	_, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentCash,
+	}, 1)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "需补缴差额")
+
+	// 会话仍未关闭
+	var after dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&after, record.ID).Error)
+	require.Nil(t, after.ExitTime)
+}
+
+func TestExitConfirmOvertimeFreeMethodRejected(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle, _ := createPaidTicketFixture(t, 20)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now().Add(-20*time.Hour)).Error)
+
+	_, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentFree,
+		PaidAmount: 10,
+	}, 1)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "免密放行")
+}
+
+func TestExitConfirmOvertimeTopUpSucceeds(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle, ticket := createPaidTicketFixture(t, 20)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now().Add(-20*time.Hour)).Error)
+
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PlateNumber: vehicle.PlateNumber,
+		PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentCash,
+		PaidAmount: 10,
+	}, 2)
+	require.NoError(t, err)
+	require.Equal(t, 20.0, result.Fee) // 总费用
+
+	// 会话已关闭且费用为总费用
+	var after dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&after, record.ID).Error)
+	require.NotNil(t, after.ExitTime)
+	require.Equal(t, 20.0, after.Fee)
+
+	// 两条流水:原 10 元 + 补缴 10 元
+	var payments []dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.Where("record_id = ?", record.ID).Find(&payments).Error)
+	require.Len(t, payments, 2)
+	require.Equal(t, 20.0, payments[0].Amount+payments[1].Amount)
+
+	// 票面累计 + PaidAt 刷新
+	var updatedTicket dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&updatedTicket, ticket.ID).Error)
+	require.Equal(t, 20.0, updatedTicket.Fee)
+	require.Equal(t, 20.0, updatedTicket.PaidAmount)
+	require.NotNil(t, updatedTicket.PaidAt)
+	require.True(t, updatedTicket.PaidAt.After(time.Now().Add(-time.Minute)), "PaidAt 应刷新为补缴时间")
+}
+
+func TestExitConfirmOvertimeNoDiffPasses(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle, _ := createPaidTicketFixture(t, 20)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+	// 停留仅 2 小时:新费用 2 元 < 已付 10 元 → 差额 0,无需补缴直接放行
+	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now().Add(-2*time.Hour)).Error)
+
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentCash,
+	}, 1)
+	require.NoError(t, err)
+	require.Equal(t, 2.0, result.Fee)
+
+	var after dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&after, record.ID).Error)
+	require.NotNil(t, after.ExitTime)
+}
+
+func TestExitConfirmPaidNotOvertimeRegresses(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle, _ := createPaidTicketFixture(t, 3)
+	setFreeExitMinutes(t, vehicle.VehicleTypeID, 15)
+	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now().Add(-2*time.Hour)).Error)
+
+	// 未超时:消费旧流水(10 元)放行,不重算不补缴
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentCash,
+	}, 1)
+	require.NoError(t, err)
+	require.Equal(t, 10.0, result.Fee)
+
+	var payments []dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.Where("record_id = ?", record.ID).Find(&payments).Error)
+	require.Len(t, payments, 1)
+}

+ 3 - 0
internal/service/vehicle/parking_session_test.go

@@ -85,6 +85,9 @@ func TestExitConfirmAcceptsZeroFeeFreeReleaseBySession(t *testing.T) {
 	svc := setupVehiclePaymentTest(t)
 	record, vehicle := createVehiclePaymentFixture(t, true)
 	require.NoError(t, global.GVA_DB.Model(record).Update("entry_time", time.Now()).Error)
+	// 零费用场景:该车辆类型显式免费配置(单位费用为 0)
+	require.NoError(t, global.GVA_DB.Model(&dao.FeeConfig{}).
+		Where("vehicle_type_id = ?", vehicle.VehicleTypeID).Update("unit_fee", 0).Error)
 
 	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
 		SessionID: record.ID, PlateNumber: vehicle.PlateNumber,

+ 110 - 14
internal/service/vehicle/vehicle.go

@@ -3,6 +3,7 @@ package vehicle
 import (
 	"errors"
 	"fmt"
+	"math"
 	"strings"
 	"time"
 	"wails-app/internal/dao"
@@ -377,18 +378,30 @@ func (s *VehicleService) buildExitPreview(record *dao.VehicleRecord) (vehicleRes
 		return vehicleResponse.ExitPreviewResponse{}, err
 	}
 
-	stayTime := int64(time.Since(record.EntryTime).Minutes())
+	stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
 	if stayTime < 0 {
 		stayTime = 0
 	}
-	fee := s.calculateFee(&vehicle, stayTime)
 	var shortlistSvc ShortlistService
 	_, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
-	if isWhite {
-		fee = 0
-	}
+	fee := 0.0
+	var overtime bool
+	var overtimeFee, overtimeDiff float64
 	if ticket.State == ticketService.StatePaid {
 		fee = ticket.Fee
+		// 缴费后免费离场时限:超时按当前时长重算,供前端提示补缴
+		overtime, overtimeFee, overtimeDiff, err = s.paidTicketOvertime(&vehicle, ticket, stayTime)
+		if err != nil {
+			return vehicleResponse.ExitPreviewResponse{}, err
+		}
+		if overtime {
+			fee = overtimeFee
+		}
+	} else if !isWhite {
+		fee, err = s.calculateFee(&vehicle, stayTime)
+		if err != nil {
+			return vehicleResponse.ExitPreviewResponse{}, err
+		}
 	}
 
 	typeName := ""
@@ -424,9 +437,37 @@ func (s *VehicleService) buildExitPreview(record *dao.VehicleRecord) (vehicleRes
 		EntryDeviceName:  record.EntryDeviceName,
 		VehicleType:      typeName,
 		IsTemp:           isTemp,
+		Overtime:         overtime,
+		OvertimeFee:      overtimeFee,
+		OvertimeDiff:     overtimeDiff,
 	}, nil
 }
 
+// paidTicketOvertime 判定已支付票是否超过"缴费后免费离场时限":
+// 未超时返回 (false, 0, 0, nil);超时返回 (true, 按当前时长重算的总费用, 需补缴差额, nil)。
+// FreeExitMinutes<=0(不限)或 PaidAt 为空(历史票)视为未超时。
+func (s *VehicleService) paidTicketOvertime(vehicle *dao.Vehicle, ticket *dao.DigitalTicket, stayTime int64) (bool, float64, float64, error) {
+	var config dao.FeeConfig
+	if err := global.GVA_DB.Where("vehicle_type_id = ?", vehicle.VehicleTypeID).First(&config).Error; err != nil {
+		return false, 0, 0, err
+	}
+	if config.FreeExitMinutes <= 0 || ticket.PaidAt == nil {
+		return false, 0, 0, nil
+	}
+	if time.Since(*ticket.PaidAt) <= time.Duration(config.FreeExitMinutes)*time.Minute {
+		return false, 0, 0, nil
+	}
+	newFee, err := s.calculateFee(vehicle, stayTime)
+	if err != nil {
+		return false, 0, 0, err
+	}
+	diff := newFee - ticket.PaidAmount
+	if diff < 0 {
+		diff = 0
+	}
+	return true, newFee, diff, nil
+}
+
 // GetOperationContext 根据车辆身份判断下一步应执行进场还是出场。
 func (s *VehicleService) GetOperationContext(req request.VehicleIdentityRequest) (vehicleResponse.OperationContextResponse, error) {
 	sessionSvc := sessionService.NewParkingSessionService()
@@ -514,15 +555,18 @@ func (s *VehicleService) PayDigitalTicket(ticketNo, paymentEntry, paymentMethod,
 			return err
 		}
 
-		stayTime := int64(time.Since(record.EntryTime).Minutes())
+		stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
 		if stayTime < 0 {
 			stayTime = 0
 		}
-		fee := s.calculateFee(&vehicle, stayTime)
 		var shortlistSvc ShortlistService
 		_, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
-		if isWhite {
-			fee = 0
+		fee := 0.0
+		if !isWhite {
+			fee, err = s.calculateFee(&vehicle, stayTime)
+			if err != nil {
+				return err
+			}
 		}
 
 		payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, paymentMethod, fee, paidAmount, operatorID)
@@ -636,13 +680,16 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 		}
 
 		exitTime := time.Now()
-		stayTime := int64(exitTime.Sub(record.EntryTime).Minutes())
+		stayTime := int64(math.Ceil(exitTime.Sub(record.EntryTime).Minutes()))
 		if stayTime < 0 {
 			stayTime = 0
 		}
-		fee := s.calculateFee(&vehicle, stayTime)
-		if isWhite {
-			fee = 0
+		fee := 0.0
+		if !isWhite {
+			fee, err = s.calculateFee(&vehicle, stayTime)
+			if err != nil {
+				return err
+			}
 		}
 
 		actualEntry := paymentEntry
@@ -659,6 +706,55 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 			changeAmount = payment.ChangeAmount
 			actualEntry = payment.PaymentEntry
 			actualMethod = payment.PaymentMethod
+
+			// 缴费后免费离场时限:超时按实际时长重算费用,差额需现场补缴
+			overtime, overtimeFee, overtimeDiff, err := s.paidTicketOvertime(&vehicle, ticket, stayTime)
+			if err != nil {
+				return err
+			}
+			if overtime {
+				fee = overtimeFee
+				if overtimeDiff > 0 {
+					if req.PaymentMethod == paymentService.PaymentFree {
+						return errors.New("缴费已超时,补缴差额不能使用免密放行")
+					}
+					if req.PaidAmount <= 0 {
+						return fmt.Errorf("缴费已超时,需补缴差额 %.2f 元", overtimeDiff)
+					}
+					paymentSvc := paymentService.NewPaymentService()
+					// 补缴校验走统一金额/方式校验(会话已 paid,不走 ProcessPayment 的会话状态守卫)
+					if _, actualPaid, verr := paymentSvc.ValidatePayment(tx, req.PaymentMethod, paymentEntry, overtimeDiff, req.PaidAmount); verr != nil {
+						return verr
+					} else {
+						now := time.Now()
+						topUpChange := actualPaid - overtimeDiff
+						if topUpChange < 0 {
+							topUpChange = 0
+						}
+						topUp := dao.PaymentRecord{
+							RecordID: record.ID, PaymentEntry: paymentEntry, PaymentMethod: req.PaymentMethod,
+							Amount: overtimeDiff, PaidAmount: actualPaid, ChangeAmount: topUpChange,
+							OperatorID: operatorID, PaidAt: now,
+							Remark: fmt.Sprintf("超时补缴(已付%.2f,总费%.2f)", payment.PaidAmount, overtimeFee),
+						}
+						if err := tx.Create(&topUp).Error; err != nil {
+							return fmt.Errorf("写入补缴流水失败: %w", err)
+						}
+						paidAmount += actualPaid
+						changeAmount += topUpChange
+						actualMethod = req.PaymentMethod
+						// 票面累计并刷新支付时间(重新获得免费离场窗口)
+						if err := tx.Model(&ticket).Updates(map[string]interface{}{
+							"fee":         overtimeFee,
+							"paid_amount": ticket.PaidAmount + actualPaid,
+							"paid_at":     now,
+						}).Error; err != nil {
+							return fmt.Errorf("更新数字票补缴信息失败: %w", err)
+						}
+					}
+				}
+				// overtimeDiff == 0:费用未涨,无需补缴,直接放行
+			}
 		} else if ticket.State == ticketService.StatePendingPayment {
 			isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
 			if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
@@ -721,7 +817,7 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 }
 
 // calculateFee 计算停车费用
-func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) float64 {
+func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) (float64, error) {
 	return ticketService.CalculateFee(vehicle, stayTime)
 }