Преглед изворни кода

feat: 票机按钮接入统一通行入口,新增停车场容量与安全测试

- 票机按钮流程重构:统一走 PassageService 通行入口(会话/数字票/闸机联动),支持匿名车辆随机标识
- 打印机模块补充 Csn_Close 导出接口
- 停车场容量计算与校验(parking_lot 服务+测试)
- 新增路由安全、票机API、仪表盘、停车容量等单元测试
- 停车场/仪表盘前端页面调整,补充功能缺失说明文档

Co-Authored-By: Claude <noreply@anthropic.com>
lq пре 3 недеља
родитељ
комит
4040523c7a
33 измењених фајлова са 1572 додато и 208 уклоњено
  1. 1 0
      config.yaml
  2. 176 0
      doc/功能缺失.md
  3. 27 4
      doc/项目进度.md
  4. 2 2
      frontend/src/view/dashboard/index.vue
  5. 62 2
      frontend/src/view/parking/parkingInfo/index.vue
  6. 44 6
      frontend/src/view/parking/parkingInfo/lotDetail.vue
  7. 1 0
      internal/config/system.go
  8. 12 8
      internal/dao/parking_lot.go
  9. 32 0
      internal/initialize/gorm.go
  10. 50 0
      internal/initialize/operation_permission_seed.go
  11. 50 0
      internal/initialize/operation_permission_seed_test.go
  12. 7 6
      internal/initialize/router.go
  13. 52 0
      internal/initialize/router_security_test.go
  14. 3 0
      internal/model/common/passage.go
  15. 15 9
      internal/model/parking/request/parking_lot.go
  16. 4 0
      internal/model/vehicle/request/vehicle.go
  17. 9 0
      internal/modules/parking-session/model/request/session.go
  18. 76 0
      internal/modules/parking-session/service/service.go
  19. 91 1
      internal/modules/parking-session/service/service_test.go
  20. 240 94
      internal/modules/printer/api.go
  21. 177 0
      internal/modules/printer/api_test.go
  22. 5 0
      internal/modules/printer/service/csn_dll.go
  23. 8 1
      internal/router/parking/parking.go
  24. 22 0
      internal/router/parking/parking_test.go
  25. 83 58
      internal/service/parking/parking_lot.go
  26. 98 0
      internal/service/parking/parking_lot_test.go
  27. 4 0
      internal/service/parking/passage.go
  28. 33 12
      internal/service/system/sys_dashboard.go
  29. 50 0
      internal/service/system/sys_dashboard_test.go
  30. 73 0
      internal/service/vehicle/parking_capacity_test.go
  31. 6 1
      internal/service/vehicle/parking_session_test.go
  32. 5 1
      internal/service/vehicle/payment_edge_test.go
  33. 54 3
      internal/service/vehicle/vehicle.go

+ 1 - 0
config.yaml

@@ -189,6 +189,7 @@ system:
     use-redis: false
     use-mongo: false
     gate-simulator: true # 本地无硬件联调;生产环境必须关闭
+    debug-routes: false # 默认不注册票机和通道调试接口
 tencent-cos:
     bucket: xxxxx-10005608
     region: ap-shanghai

+ 176 - 0
doc/功能缺失.md

@@ -0,0 +1,176 @@
+# 智慧停车管理系统功能缺失清单
+
+> 更新日期:2026-08-11  
+> 评估范围:`lc_garage` 当前前端、后端、设备接入和自动化测试代码  
+> 状态说明:本文用于记录尚未完成的业务闭环和生产风险,不代表相关模块完全没有代码。
+
+## 1. 总体结论
+
+当前项目已经具备单机人工收费停车场的基础业务链路,包括停车场配置、车辆管理、统一进出场、停车会话、数字票、人工收费、支付流水、月卡、交接班、报表、道闸模拟和小票打印。
+
+距离正式运营仍缺少三个关键闭环:
+
+1. POS、扫码、中央缴费机等真实资金交易。
+2. 业务异常、设备异常的记录和处置。
+3. 摄像头、道闸、地感、LED 屏的状态反馈和联动。
+
+“存在支付方式配置”不等同于“已经接入真实支付”。当前非现金支付主要用于业务流程确认,尚未连接实际支付终端或支付网关。
+
+## 2. P0:上线前必须解决
+
+### 2.1 非现金支付没有真实交易能力
+
+**当前情况**
+
+- 已支持支付入口、支付方式及可用关系配置。
+- POS 和外部扫码方式可以直接创建支付流水并把会话标记为已支付。
+- 尚未实现支付订单、POS 驱动、扫码下单、支付网关、异步回调、主动查询和超时处理。
+
+**业务影响**
+
+- 操作员确认不能证明外部资金已经到账。
+- 网络超时、支付成功但本地未更新等情况可能造成资金与业务状态不一致。
+
+**完成标准**
+
+- 增加独立支付订单和明确的待支付、处理中、成功、失败、不确定、已退款状态。
+- 外部支付只能由可信终端结果、回调或主动查询确认成功。
+- 支付回调、重复通知和主动查询必须幂等。
+- 实现退款、撤销、冲正、失败补偿和财务对账。
+
+### 2.2 异常报表和异常处置未实现
+
+**当前情况**
+
+- `frontend/src/view/report/sheet.vue` 只有静态表单和空表格。
+- 没有异常事件实体、后端接口、查询、处置状态和审计记录。
+
+**应覆盖的异常**
+
+- 无牌车、车牌识别冲突、重复入场、无入场记录出场。
+- 丢票、票据打印失败、支付失败、支付状态不确定。
+- 人工抬杆、强制免费、开闸失败、设备离线。
+- 余位为负、会话与数字票状态不一致。
+
+**完成标准**
+
+- 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员。
+- 支持待处理、处理中、已解决、已关闭状态和处理备注。
+- 人工放行和资金相关异常需要权限控制与完整审计。
+
+### 2.3 票机打印失败补偿未完成
+
+**当前情况**
+
+- 票机已经接入 `PassageService`,按通道定位停车场和设备,并执行统一准入、会话、数字票和开闸流程。
+- 已按新建停车会话直接返回数字票,不再通过车牌查询最新票据。
+- 无牌取票会生成唯一票机会话身份,避免多辆无牌车共用空车牌。
+- 当前打印仍是同步硬件调用;打印失败时会返回已完成的会话、票据和闸机状态,但尚无持久化任务、自动重试、重打和作废机制。
+
+**完成标准**
+
+- 打印任务持久化,支持失败重试、重打、作废和操作审计。
+- 会话创建、开闸和打印失败之间有明确的补偿规则。
+
+## 3. P1:已有基础实现但业务闭环不足
+
+### 3.1 计费引擎能力不足
+
+- 当前只有免费时长、起步价、固定时间单位、单次日封顶和 VIP 优惠。
+- 超过 24 小时仍只应用一次日封顶,多日停车计费不正确。
+- 缺少自然日或滚动 24 小时规则、跨日分段、夜间价、节假日、阶梯计费和不同停车场费率。
+- 缺少缴费后免费离场时限及超时重新计费。
+- 未配置收费规则时按每分钟 `0.1` 隐式收费,可能造成错误收费。
+- 收费配置缺少负值、折扣范围、时间单位和封顶金额等完整校验。
+
+### 3.2 月卡办理、续费和退卡不是完整事务
+
+- 创建月卡、写白名单和写支付流水没有放在同一事务中,部分错误被忽略。
+- 续费更新月卡、白名单和支付流水时也可能产生部分成功。
+- 月卡收费固定记为收费岗亭现金,不能选择支付入口和支付方式。
+- 退卡只标记 `refunded` 并删除白名单,没有退款金额、退款流水和审批。
+- 月、季、年按 30、90、365 天计算,不是自然月、自然季度和自然年。
+- 缺少过期清理、提前续费、冻结、转车、退款计算等业务规则。
+
+### 3.3 交接班只覆盖现金
+
+- 只统计 `payment_method='cash'`,没有按支付入口和支付方式汇总其他交易。
+- 支付流程没有要求操作员必须处于当班状态。
+- 开班、收款和交班缺少统一事务约束。
+- 缺少管理者复核、差异审批、交班单打印和交班后锁定。
+- 页面缺少完整分页、筛选、备注管理和审核能力。
+
+### 3.4 收入报表维度和财务口径不足
+
+- 当前主要按日期和停车场汇总,只单独展示现金和免费。
+- 缺少支付入口、支付方式、操作员、班次、车型、通道等维度。
+- 缺少退款、撤销、冲正、月卡收入和交班差异。
+- 缺少明细追溯、日结、月结、导出和打印。
+
+### 3.5 数字票过期状态没有自动执行机制
+
+- 已定义 `expired` 状态和状态流转规则。
+- 没有发现根据 `expire_at` 自动过期的定时任务。
+- 需要明确待支付票、已支付未离场票分别在何种条件下过期,以及过期后的恢复策略。
+
+### 3.6 道闸和设备模型不够独立
+
+- 当前工作台从 `UHFReader` 获取道闸设备,读卡器和道闸控制器共用设备模型。
+- 无法完整表达一个通道包含摄像头、读卡器、入口票机、道闸、地感等多设备拓扑。
+- 缺少闸杆状态反馈、地感、防砸、落杆确认、失败重试和指令流水。
+- 模拟道闸配置若在生产环境误开启,会显示在线但不发送真实指令。
+
+### 3.7 停车场基础配置约束不足
+
+- 已修复按 `LotId` 查询条件、容量低于当前在场车辆数校验,以及存在未出场会话时禁止删除。
+- 删除停车场前仍未检查岗亭、通道和设备关联。
+- 已结束的历史停车会话仍允许所属停车场被软删除,历史页面需要统一处理已删除配置的名称展示。
+
+### 3.8 操作审计和敏感数据保护不足
+
+- 操作日志保存请求体和响应体,未见密码、令牌、支付字段统一脱敏。
+- 摄像头账号密码缺少加密存储方案。
+- 设备失败主要写日志,尚未进入业务告警和异常处置中心。
+
+## 4. P2:完全缺失或只有模型
+
+| 功能 | 当前状态 | 目标能力 |
+| --- | --- | --- |
+| 车牌摄像头接入 | 只有 `camera` 数据实体 | 摄像头配置、RTSP/SDK、OCR 回调、置信度、抓拍、防重复识别、通道绑定 |
+| 中央缴费机 | 只有支付入口概念 | 终端注册、心跳、订单查询、支付、出场校验、远程配置 |
+| LED 余位屏 | 未实现 | 屏幕配置、协议适配、余位推送、离线重试 |
+| 线上支付 | 未实现 | 微信/支付宝等下单、回调、查询、退款和对账 |
+| 设备告警中心 | 未实现 | 在线状态、故障事件、告警等级、确认和恢复 |
+| 远程设备诊断 | 未实现 | 设备参数、连通性测试、指令测试、诊断审计 |
+| 数据备份恢复 | 未实现 | SQLite 备份、校验、恢复、保留策略和恢复演练 |
+| 数据归档清理 | 未实现 | 历史会话、日志、抓拍图片的归档和容量管理 |
+| 报表导出打印 | 未实现 | Excel/PDF 导出、日结单、月报和打印 |
+| 打印任务管理 | 未实现 | 打印机配置、任务队列、状态、失败重试和重打权限 |
+| 短信/邮件验证 | 模拟实现 | 服务商配置、发送限制、验证码校验和审计 |
+
+## 5. 测试覆盖缺口
+
+现有自动化测试重点覆盖停车会话、数字票、支付金额边界、重复和并发支付、事务回滚、道闸接口及 USB 票据版式。以下范围仍缺少完整测试:
+
+- 票机打印任务持久化、失败补偿、重打和作废权限。
+- 月卡办理、续费、退卡的跨表事务和退款。
+- 交接班、收入报表和多支付方式对账。
+- 多日计费、跨日规则、缴费后超时和收费配置异常。
+- 异常事件的生成、处置、权限和审计。
+- 摄像头、地感、真实道闸、LED 屏的集成和现场测试。
+- 数据备份、恢复和故障恢复演练。
+
+## 6. 推荐实施顺序
+
+1. 建立异常事件、人工处置和设备指令流水。
+2. 建立支付订单层,接入 POS、扫码和中央缴费机。
+3. 重构多日计费及缴费后离场超时规则。
+4. 完成月卡事务、退款、交接班和财务对账。
+5. 接入摄像头、地感、道闸反馈和 LED 余位屏。
+6. 补充报表导出、打印任务、备份恢复和生产运维能力。
+
+## 7. 维护规则
+
+- 功能开始开发时,在对应条目中补充负责人、计划版本和关联任务。
+- 功能完成后必须同时满足代码实现、权限控制、自动化测试和业务验收,不能仅因页面或配置存在就标记完成。
+- 已完成条目从本文移除前,应同步更新 `项目进度.md`、相关流程文档和测试报告。

+ 27 - 4
doc/项目进度.md

@@ -1,6 +1,6 @@
 # 智慧停车管理系统项目进度
 
-> 更新日期:2026-08-07
+> 更新日期:2026-08-11
 > 状态说明:`已完成` 指功能已接入前后端;`已验证` 指自动化测试或构建已通过;`待验收` 指需在真实设备或现场流程继续验证。
 
 ## 1. 当前完成度
@@ -8,7 +8,7 @@
 | 领域 | 状态 | 当前能力 |
 | --- | --- | --- |
 | 基础权限与系统管理 | 已完成 | 用户、角色、菜单、API、Casbin、字典、操作日志、登录 |
-| 停车场基础配置 | 已完成 | 停车场、岗亭、通道、UHF 设备,通道方向与临停开关 |
+| 停车场基础配置 | 已完成并验证 | 停车场、岗亭、通道、UHF 设备,容量、占用数、余位及满位准入策略 |
 | 车辆与名单 | 已完成 | 车辆、车主、车型、黑白名单、包月车 |
 | 统一进出场 | 已完成 | 手动和 UHF 统一经过 `PassageService`,支持入场、出场、计费、开闸 |
 | 停车会话 | 已完成并验证 | 防止重复在场、统一定位、支付与离场状态更新、入口/出口通道设备快照 |
@@ -23,6 +23,18 @@
 
 ## 2. 本轮已完成工作
 
+### 2.0 停车场容量与满位准入
+
+- 停车场新增实际占用数,并根据总容量和占用数维护剩余车位。
+- 入场占位、停车会话创建和数字票创建处于同一事务;出场关闭会话时在同一事务释放车位。
+- 服务启动时根据未出场停车会话自动校准各停车场占用数和余位。
+- 车位满时支持分别配置是否允许临时车、有效月租车和有效 VIP 车继续入场。
+- 超容车辆入场后余位保持为零,直到实际占用数重新低于停车场容量。
+- 停车场详情显示总车位、已占用、剩余车位和满位准入策略。
+- 仪表盘按全部未删除停车场汇总容量和实际占用数,与停车场详情使用相同统计口径。
+- 停车场存在未出场会话时禁止删除,避免继续产生无法归属的在场记录。
+- 已关闭已删除停车场遗留的 48 条测试会话;修复前数据库已备份到 `backups/lc_garage-before-orphan-fix-20260811-112552.db`。
+
 ### 2.1 停车会话统一化
 
 - 以 `vehicle_record` 作为停车会话持久化载体。
@@ -50,6 +62,15 @@
 - USB 票据按实际纸张出纸方向输出,并包含二维码、切纸前走纸和短写失败处理。
 - 已完成打印命令与版式自动测试;最终字号、物理偏移和二维码密度仍以现场纸张为准。
 
+### 2.5 票机统一通行与调试接口收口
+
+- 票机按钮通过通道编码定位停车场和绑定设备,并统一调用 `PassageService` 完成准入、会话、数字票、入口快照和开闸。
+- 删除停车场 ID、车位 ID 和测试通道的硬编码;同一通道存在多个启用设备时要求明确传入设备编码。
+- 入场响应直接返回 `session_id`、`ticket_id` 和 `ticket_no`,打印不再按车牌查询最新票据。
+- 无牌取票生成唯一 `TKT...` 会话身份,打印票面仍保持车牌为空。
+- 调试路由由 `system.debug-routes` 显式控制,默认关闭;开启后仍要求 JWT、管理员或开发角色权限,并记录操作日志。
+- 打印任务持久化、失败重试、重打和作废仍列为后续 P0 工作。
+
 ## 3. 核心业务闭环
 
 ```text
@@ -67,18 +88,20 @@
 
 | 范围 | 已覆盖的关键场景 |
 | --- | --- |
-| `parking-session` | 会话创建、重复入场拒绝、身份冲突、支付、关闭、通道设备快照 |
+| `parking-session` | 会话创建、重复入场拒绝、身份冲突、支付、关闭、通道设备快照、余位事务、满位准入、超容离场 |
 | `payment` | 负金额、零金额、金额不足、重复结算、并发结算、事务回滚、缺失会话 |
 | `vehicle` | 车辆入场创建会话和数字票、RFID 定位、预览、免费出场、重复支付回滚 |
 | `parking` | 统一通行和闸机相关服务回归 |
+| `system` | 仪表盘只汇总有效停车场,忽略已删除停车场的孤立历史会话 |
 | `printer` | USB 票据顺序、文字方向、二维码、切纸走纸、短写和二维码失败 |
+| 票机适配层 | 统一通行请求、无牌唯一身份、通道设备解析、多设备歧义拒绝、打印部分失败、调试路由隔离 |
 | 前端 | `npm run build` 生产构建通过 |
 
 最近一次相关验证命令:
 
 ```powershell
 $env:GOCACHE='D:\lq\Smart Parking\lc_garage\.gocache'
-go test ./internal/modules/parking-session/service ./internal/service/vehicle ./internal/service/parking
+go test ./internal/service/system ./internal/service/parking ./internal/modules/parking-session/service ./internal/service/vehicle ./internal/initialize
 
 cd frontend
 npm run build

+ 2 - 2
frontend/src/view/dashboard/index.vue

@@ -44,7 +44,7 @@
               <span class="heading-icon occupancy-icon">
                 <el-icon><Location /></el-icon>
               </span>
-              <span>车位使用情况</span>
+              <span>全部停车场车位</span>
             </div>
             <span class="occupancy-state" :class="{ full: isFull }">
               {{ isFull ? '车位已满' : `使用率 ${occupancyRate}%` }}
@@ -85,7 +85,7 @@
               <span>在场车辆</span>
             </div>
             <p class="metric-value">{{ data.in_park }}<small>台</small></p>
-            <p class="metric-note">当前场内车辆</p>
+            <p class="metric-note">全部停车场在场车辆</p>
           </article>
 
           <article class="metric-card">

+ 62 - 2
frontend/src/view/parking/parkingInfo/index.vue

@@ -56,7 +56,7 @@
     <el-dialog
       v-model="dialogVisible"
       :title="isEdit ? '编辑停车场' : '新增停车场'"
-      width="500"
+      width="560"
     >
       <el-form label-width="120">
         <el-form-item label="区域编码:">
@@ -66,7 +66,23 @@
           <el-input v-model="form.lot_name" />
         </el-form-item>
         <el-form-item label="区域容量:">
-          <el-input v-model="form.capacity" type="number" />
+          <el-input-number v-model="form.capacity" :min="0" :precision="0" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="满位准入:">
+          <div class="admission-policy">
+            <label class="policy-option">
+              <span>临时车</span>
+              <el-switch v-model="form.allow_temporary_when_full" />
+            </label>
+            <label class="policy-option">
+              <span>月租车</span>
+              <el-switch v-model="form.allow_monthly_when_full" />
+            </label>
+            <label class="policy-option">
+              <span>VIP 车</span>
+              <el-switch v-model="form.allow_vip_when_full" />
+            </label>
+          </div>
         </el-form-item>
         <el-form-item label="备注:">
           <el-input v-model="form.description" type="textarea" />
@@ -128,6 +144,9 @@ const form = reactive({
   lot_code: '',
   lot_name: '',
   capacity: 0,
+  allow_temporary_when_full: false,
+  allow_monthly_when_full: true,
+  allow_vip_when_full: true,
   description: '',
 })
 
@@ -137,6 +156,9 @@ const openCreateDialog = () => {
   form.lot_code = ''
   form.lot_name = ''
   form.capacity = 0
+  form.allow_temporary_when_full = false
+  form.allow_monthly_when_full = true
+  form.allow_vip_when_full = true
   form.description = ''
   dialogVisible.value = true
 }
@@ -147,6 +169,9 @@ const openEditDialog = (item) => {
   form.lot_code = item.lot_code
   form.lot_name = item.lot_name
   form.capacity = item.capacity
+  form.allow_temporary_when_full = Boolean(item.allow_temporary_when_full)
+  form.allow_monthly_when_full = Boolean(item.allow_monthly_when_full)
+  form.allow_vip_when_full = Boolean(item.allow_vip_when_full)
   form.description = item.description
   dialogVisible.value = true
 }
@@ -216,4 +241,39 @@ onMounted(() => {
     gap: 4px;
   }
 }
+
+.admission-policy {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  width: 100%;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background: #fafafa;
+}
+
+.policy-option {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 10px 12px;
+  color: #303133;
+  font-size: 14px;
+
+  & + & {
+    border-left: 1px solid #e4e7ed;
+  }
+}
+
+@media (max-width: 900px) {
+  .admission-policy {
+    grid-template-columns: 1fr;
+  }
+
+  .policy-option + .policy-option {
+    border-top: 1px solid #e4e7ed;
+    border-left: 0;
+  }
+}
 </style>

+ 44 - 6
frontend/src/view/parking/parkingInfo/lotDetail.vue

@@ -2,16 +2,35 @@
   <div v-loading="loading">
     <el-descriptions
       v-if="detail.ID"
-      :column="2"
+      :column="3"
       border
     >
-      <el-descriptions-item label="ID" :span="2">{{ detail.ID }}</el-descriptions-item>
+      <el-descriptions-item label="ID">{{ detail.ID }}</el-descriptions-item>
       <el-descriptions-item label="区域编码">{{ detail.lot_code }}</el-descriptions-item>
       <el-descriptions-item label="区域名称">{{ detail.lot_name }}</el-descriptions-item>
-      <el-descriptions-item label="容量">{{ detail.capacity }}</el-descriptions-item>
-      <el-descriptions-item label="备注">{{ detail.description || '-' }}</el-descriptions-item>
-      <el-descriptions-item label="创建时间" :span="2">{{ detail.CreatedAt || '-' }}</el-descriptions-item>
-      <el-descriptions-item label="更新时间" :span="2">{{ detail.UpdatedAt || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="总车位">{{ detail.capacity }}</el-descriptions-item>
+      <el-descriptions-item label="已占用">
+        <span class="space-value occupied">{{ detail.occupied || 0 }}</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="剩余车位">
+        <span class="space-value" :class="{ full: detail.available === 0 }">{{ detail.available || 0 }}</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="满位准入" :span="3">
+        <div class="policy-tags">
+          <el-tag :type="detail.allow_temporary_when_full ? 'success' : 'info'" effect="plain">
+            临时车{{ detail.allow_temporary_when_full ? '允许' : '禁止' }}
+          </el-tag>
+          <el-tag :type="detail.allow_monthly_when_full ? 'success' : 'info'" effect="plain">
+            月租车{{ detail.allow_monthly_when_full ? '允许' : '禁止' }}
+          </el-tag>
+          <el-tag :type="detail.allow_vip_when_full ? 'success' : 'info'" effect="plain">
+            VIP 车{{ detail.allow_vip_when_full ? '允许' : '禁止' }}
+          </el-tag>
+        </div>
+      </el-descriptions-item>
+      <el-descriptions-item label="备注" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="创建时间" :span="3">{{ detail.CreatedAt || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="更新时间" :span="3">{{ detail.UpdatedAt || '-' }}</el-descriptions-item>
     </el-descriptions>
 
     <div v-if="!detail.ID && !loading" style="text-align: center; color: #999; margin-top: 40px;">
@@ -80,4 +99,23 @@ watch(
   display: flex;
   gap: 10px;
 }
+
+.space-value {
+  color: #16805b;
+  font-weight: 600;
+}
+
+.space-value.occupied {
+  color: #b26a00;
+}
+
+.space-value.full {
+  color: #c03639;
+}
+
+.policy-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
 </style>

+ 1 - 0
internal/config/system.go

@@ -11,4 +11,5 @@ type System struct {
 	UseRedis      bool   `mapstructure:"use-redis" json:"use-redis" yaml:"use-redis"`                // 使用redis
 	UseMongo      bool   `mapstructure:"use-mongo" json:"use-mongo" yaml:"use-mongo"`                // 使用mongo
 	GateSimulator bool   `mapstructure:"gate-simulator" json:"gate-simulator" yaml:"gate-simulator"` // 仅用于无硬件联调的模拟道闸
+	DebugRoutes   bool   `mapstructure:"debug-routes" json:"debug-routes" yaml:"debug-routes"`       // 显式开启受管理员鉴权保护的调试路由
 }

+ 12 - 8
internal/dao/parking_lot.go

@@ -5,14 +5,18 @@ import "wails-app/internal/global"
 // ParkingLot 停车区域
 type ParkingLot struct {
 	global.GVA_MODEL
-	LotCode     string      `gorm:"size:50;not null;uniqueIndex" json:"lot_code"` // 区域编码
-	LotName     string      `gorm:"size:100;not null" json:"lot_name"`            // 区域名称
-	Capacity    int         `gorm:"not null" json:"capacity"`                     // 车位数量
-	Available   int         `gorm:"not null" json:"available"`                    // 可用车位
-	Description string      `gorm:"size:200" json:"description"`                  // 备注
-	Booths      []Booth     `gorm:"foreignKey:ParkingLotID" json:"booths"`        // 岗亭列表(一对多)
-	Channels    []Channel   `gorm:"foreignKey:ParkingLotID" json:"channels"`      // 通道列表(一对多)
-	UHFReaders  []UHFReader `gorm:"foreignKey:ParkingLotID" json:"uhf_readers"`   // UHF读写器列表(一对多)
+	LotCode                string      `gorm:"size:50;not null;uniqueIndex" json:"lot_code"`                                             // 区域编码
+	LotName                string      `gorm:"size:100;not null" json:"lot_name"`                                                        // 区域名称
+	Capacity               int         `gorm:"not null" json:"capacity"`                                                                 // 车位数量
+	Occupied               int         `gorm:"not null;default:0" json:"occupied"`                                                       // 当前占用车位
+	Available              int         `gorm:"not null" json:"available"`                                                                // 可用车位
+	AllowTemporaryWhenFull bool        `gorm:"column:allow_temporary_when_full;not null;default:false" json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool        `gorm:"column:allow_monthly_when_full;not null;default:true" json:"allow_monthly_when_full"`      // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool        `gorm:"column:allow_vip_when_full;not null;default:true" json:"allow_vip_when_full"`              // 满位时允许有效 VIP 车入场
+	Description            string      `gorm:"size:200" json:"description"`                                                              // 备注
+	Booths                 []Booth     `gorm:"foreignKey:ParkingLotID" json:"booths"`                                                    // 岗亭列表(一对多)
+	Channels               []Channel   `gorm:"foreignKey:ParkingLotID" json:"channels"`                                                  // 通道列表(一对多)
+	UHFReaders             []UHFReader `gorm:"foreignKey:ParkingLotID" json:"uhf_readers"`                                               // UHF读写器列表(一对多)
 }
 
 func (ParkingLot) TableName() string {

+ 32 - 0
internal/initialize/gorm.go

@@ -71,6 +71,9 @@ func RegisterTables() {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))
 		os.Exit(0)
 	}
+	if err := reconcileParkingLotOccupancy(db); err != nil {
+		global.GVA_LOG.Error("reconcile parking lot occupancy failed", zap.Error(err))
+	}
 	// 操作日志复合索引:created_at 位于 GVA_MODEL 内嵌字段(多表共用,不能加 tag),故显式创建
 	// 覆盖高频查询:按用户 + 时间筛选操作日志
 	if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_op_user_time ON sys_operation_records (user_id, created_at)").Error; err != nil {
@@ -78,3 +81,32 @@ func RegisterTables() {
 	}
 	global.GVA_LOG.Info("register table success")
 }
+
+// reconcileParkingLotOccupancy 在服务启动时以未出场停车会话为准校准占用数和余位。
+func reconcileParkingLotOccupancy(db *gorm.DB) error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		var lots []dao.ParkingLot
+		if err := tx.Find(&lots).Error; err != nil {
+			return err
+		}
+		for _, lot := range lots {
+			var occupied int64
+			if err := tx.Model(&dao.VehicleRecord{}).
+				Where("parking_lot_id = ? AND exit_time IS NULL", lot.ID).
+				Count(&occupied).Error; err != nil {
+				return err
+			}
+			available := int64(lot.Capacity) - occupied
+			if available < 0 {
+				available = 0
+			}
+			if err := tx.Model(&lot).UpdateColumns(map[string]interface{}{
+				"occupied":  occupied,
+				"available": available,
+			}).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}

+ 50 - 0
internal/initialize/operation_permission_seed.go

@@ -25,6 +25,7 @@ func EnsureOperationPermissions() error {
 		{Path: "/parking/gate/devices", Description: "查询道闸设备", ApiGroup: "进出场操作", Method: "GET"},
 		{Path: "/parking/gate/open", Description: "人工开闸", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/parking/gate/close", Description: "人工关闸", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/ticket-machine/button", Description: "票机按钮入场", ApiGroup: "进出场操作", Method: "POST"},
 	}
 	for _, api := range apis {
 		var existing dao.SysApi
@@ -53,11 +54,60 @@ func EnsureOperationPermissions() error {
 			{"/parking/gate/devices", "GET"},
 			{"/parking/gate/open", "POST"},
 			{"/parking/gate/close", "POST"},
+			{"/ticket-machine/button", "POST"},
 		} {
 			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
 				return fmt.Errorf("初始化角色%s进出场权限失败: %w", role, err)
 			}
 		}
 	}
+
+	debugAPIs := []dao.SysApi{
+		{Path: "/ticket-machine/test", Description: "票机业务链路测试", ApiGroup: "开发调试", Method: "POST"},
+		{Path: "/ticket-machine/debug", Description: "票机原始打印测试", ApiGroup: "开发调试", Method: "POST"},
+		{Path: "/ticket-machine/usb-list", Description: "枚举票机设备", ApiGroup: "开发调试", Method: "GET"},
+		{Path: "/channel/test-event", Description: "模拟通道事件", ApiGroup: "开发调试", Method: "POST"},
+	}
+	for _, api := range debugAPIs {
+		var existing dao.SysApi
+		err := db.Where("path = ? AND method = ?", api.Path, api.Method).First(&existing).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			err = db.Create(&api).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化调试API %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
+	for _, role := range []string{"888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/ticket-machine/test", "POST"},
+			{"/ticket-machine/debug", "POST"},
+			{"/ticket-machine/usb-list", "GET"},
+			{"/channel/test-event", "POST"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return fmt.Errorf("初始化角色%s调试权限失败: %w", role, err)
+			}
+		}
+	}
+
+	legacyDebugRoutes := [][2]string{
+		{"/ticket-machine/test", "GET"},
+		{"/ticket-machine/debug", "GET"},
+		{"/channel/test-event", "GET"},
+	}
+	for _, route := range legacyDebugRoutes {
+		if err := db.Unscoped().Where("path = ? AND method = ?", route[0], route[1]).Delete(&dao.SysApi{}).Error; err != nil {
+			return fmt.Errorf("清理旧调试API %s %s 失败: %w", route[1], route[0], err)
+		}
+		if err := db.Exec("DELETE FROM casbin_rule WHERE v1 = ? AND v2 = ?", route[0], route[1]).Error; err != nil {
+			return fmt.Errorf("清理旧调试权限 %s %s 失败: %w", route[1], route[0], err)
+		}
+	}
+	for _, api := range debugAPIs {
+		if err := db.Exec("DELETE FROM casbin_rule WHERE v0 = ? AND v1 = ? AND v2 = ?", "618", api.Path, api.Method).Error; err != nil {
+			return fmt.Errorf("撤销操作员调试权限 %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
 	return nil
 }

+ 50 - 0
internal/initialize/operation_permission_seed_test.go

@@ -16,6 +16,21 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 	require.NoError(t, db.AutoMigrate(&dao.SysApi{}))
 	global.GVA_DB = db
 	t.Cleanup(func() { global.GVA_DB = nil })
+	require.NoError(t, db.Create(&dao.SysApi{
+		Path: "/ticket-machine/test", Description: "旧票机测试", ApiGroup: "开发调试", Method: "GET",
+	}).Error)
+	require.NoError(t, db.Exec(`CREATE TABLE casbin_rule (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
+	)`).Error)
+	require.NoError(t, db.Exec(
+		"INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (?, ?, ?, ?)",
+		"p", "618", "/ticket-machine/test", "GET",
+	).Error)
+	require.NoError(t, db.Exec(
+		"INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (?, ?, ?, ?)",
+		"p", "618", "/channel/test-event", "POST",
+	).Error)
 
 	require.NoError(t, EnsureOperationPermissions())
 	require.NoError(t, EnsureOperationPermissions())
@@ -28,6 +43,7 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 		{"/parking/gate/devices", "GET"},
 		{"/parking/gate/open", "POST"},
 		{"/parking/gate/close", "POST"},
+		{"/ticket-machine/button", "POST"},
 	}
 	for _, route := range routes {
 		var apiCount int64
@@ -41,4 +57,38 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 			Count(&ruleCount).Error)
 		require.EqualValues(t, 3, ruleCount, route)
 	}
+
+	debugRoutes := [][2]string{
+		{"/ticket-machine/test", "POST"},
+		{"/ticket-machine/debug", "POST"},
+		{"/ticket-machine/usb-list", "GET"},
+		{"/channel/test-event", "POST"},
+	}
+	for _, route := range debugRoutes {
+		var apiCount int64
+		require.NoError(t, db.Model(&dao.SysApi{}).
+			Where("path = ? AND method = ?", route[0], route[1]).Count(&apiCount).Error)
+		require.EqualValues(t, 1, apiCount, route)
+
+		var adminRuleCount int64
+		require.NoError(t, db.Table("casbin_rule").
+			Where("v0 IN ? AND v1 = ? AND v2 = ?", []string{"888", "9527"}, route[0], route[1]).
+			Count(&adminRuleCount).Error)
+		require.EqualValues(t, 2, adminRuleCount, route)
+
+		var operatorRuleCount int64
+		require.NoError(t, db.Table("casbin_rule").
+			Where("v0 = ? AND v1 = ? AND v2 = ?", "618", route[0], route[1]).
+			Count(&operatorRuleCount).Error)
+		require.Zero(t, operatorRuleCount, route)
+	}
+
+	var legacyAPICount int64
+	require.NoError(t, db.Unscoped().Model(&dao.SysApi{}).
+		Where("path = ? AND method = ?", "/ticket-machine/test", "GET").Count(&legacyAPICount).Error)
+	require.Zero(t, legacyAPICount)
+	var legacyRuleCount int64
+	require.NoError(t, db.Table("casbin_rule").
+		Where("v1 = ? AND v2 = ?", "/ticket-machine/test", "GET").Count(&legacyRuleCount).Error)
+	require.Zero(t, legacyRuleCount)
 }

+ 7 - 6
internal/initialize/router.go

@@ -6,14 +6,14 @@ import (
 	"wails-app/internal/docs"
 	"wails-app/internal/global"
 	"wails-app/internal/middleware"
-	"wails-app/internal/router"
-	"wails-app/internal/router/owner"
 	"wails-app/internal/modules/digital-ticket"
 	"wails-app/internal/modules/monthly"
-	"wails-app/internal/modules/report"
-	"wails-app/internal/modules/shift"
 	"wails-app/internal/modules/payment"
 	"wails-app/internal/modules/printer"
+	"wails-app/internal/modules/report"
+	"wails-app/internal/modules/shift"
+	"wails-app/internal/router"
+	"wails-app/internal/router/owner"
 	"wails-app/internal/router/parking"
 	"wails-app/internal/router/vehicle"
 
@@ -81,9 +81,8 @@ func Routers() *gin.Engine {
 		})
 	}
 	{
-		systemRouter.InitBaseRouter(PublicGroup)    // 注册基础功能路由 不做鉴权
+		systemRouter.InitBaseRouter(PublicGroup)         // 注册基础功能路由 不做鉴权
 		dashboardRouter.InitDashboardRouter(PublicGroup) // 监控仪表盘 不做鉴权
-		printer.SetupPrinterPublic(PublicGroup)    // 票机测试路由 不做鉴权
 	}
 	PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
 	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()).Use(middleware.OperationRecord())
@@ -111,6 +110,7 @@ func Routers() *gin.Engine {
 
 		// 停车相关路由
 		parking.SetupParkingRouter(PrivateGroup)
+		parking.SetupParkingDebugRouter(PrivateGroup, global.GVA_CONFIG.System.DebugRoutes)
 
 		// 支付相关路由
 		payment.SetupPaymentRouter(PrivateGroup)
@@ -125,6 +125,7 @@ func Routers() *gin.Engine {
 		shift.SetupShiftRouter(PrivateGroup)
 		// 打印机路由
 		printer.SetupPrinterRouter(PrivateGroup)
+		printer.SetupPrinterDebugRoutes(PrivateGroup, global.GVA_CONFIG.System.DebugRoutes)
 
 		// 收入报表路由
 		report.SetupReportRouter(PrivateGroup)

+ 52 - 0
internal/initialize/router_security_test.go

@@ -0,0 +1,52 @@
+package initialize
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"wails-app/internal/config"
+	"wails-app/internal/global"
+)
+
+func setupRouterSecurityTest(t *testing.T, debugRoutes bool) *gin.Engine {
+	t.Helper()
+	previousConfig := global.GVA_CONFIG
+	previousLog := global.GVA_LOG
+	t.Cleanup(func() {
+		global.GVA_CONFIG = previousConfig
+		global.GVA_LOG = previousLog
+	})
+	global.GVA_CONFIG = config.Server{}
+	global.GVA_CONFIG.System.DebugRoutes = debugRoutes
+	global.GVA_CONFIG.Local.StorePath = "/uploads"
+	global.GVA_LOG = zap.NewNop()
+	gin.SetMode(gin.TestMode)
+	return Routers()
+}
+
+func TestDebugRoutesAreNotRegisteredByDefault(t *testing.T) {
+	router := setupRouterSecurityTest(t, false)
+	routes := map[string]string{}
+	for _, route := range router.Routes() {
+		routes[route.Path] = route.Method
+	}
+	require.Equal(t, "POST", routes["/ticket-machine/button"])
+	require.NotContains(t, routes, "/ticket-machine/test")
+	require.NotContains(t, routes, "/ticket-machine/debug")
+	require.NotContains(t, routes, "/ticket-machine/usb-list")
+	require.NotContains(t, routes, "/channel/test-event")
+}
+
+func TestEnabledDebugRoutesStillRequireAuthentication(t *testing.T) {
+	router := setupRouterSecurityTest(t, true)
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequest(http.MethodPost, "/channel/test-event?plate=TEST", nil)
+	router.ServeHTTP(recorder, request)
+
+	require.Equal(t, http.StatusUnauthorized, recorder.Code)
+	require.Contains(t, recorder.Body.String(), "未登录或非法访问")
+}

+ 3 - 0
internal/model/common/passage.go

@@ -27,6 +27,9 @@ type GateControlRequest struct {
 type PassageResult struct {
 	Success           bool    `json:"success"`
 	Message           string  `json:"message"`
+	SessionID         uint    `json:"session_id"`
+	TicketID          uint    `json:"ticket_id"`
+	TicketNo          string  `json:"ticket_no"`
 	Direction         string  `json:"direction"`    // in / out
 	PlateNumber       string  `json:"plate_number"` // 车牌号
 	RFIDTag           string  `json:"rfid_tag"`     // RFID 标签

+ 15 - 9
internal/model/parking/request/parking_lot.go

@@ -2,19 +2,25 @@ package request
 
 // ParkingLotCreate 停车区域创建请求
 type ParkingLotCreate struct {
-	LotCode     string `json:"lot_code"`                 // 区域编码
-	LotName     string `json:"lot_name"`                 // 区域名称
-	Capacity    int    `json:"capacity" binding:"min=0"` // 车位数量
-	Description string `json:"description"`              // 备注
+	LotCode                string `json:"lot_code"`                  // 区域编码
+	LotName                string `json:"lot_name"`                  // 区域名称
+	Capacity               int    `json:"capacity" binding:"min=0"`  // 车位数量
+	AllowTemporaryWhenFull bool   `json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool   `json:"allow_monthly_when_full"`   // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool   `json:"allow_vip_when_full"`       // 满位时允许有效 VIP 车入场
+	Description            string `json:"description"`               // 备注
 }
 
 // ParkingLotUpdate 停车区域更新请求
 type ParkingLotUpdate struct {
-	ID          uint   `json:"id"`                       // 区域ID
-	LotCode     string `json:"lot_code"`                 // 区域编码
-	LotName     string `json:"lot_name"`                 // 区域名称
-	Capacity    int    `json:"capacity" binding:"min=0"` // 车位数量
-	Description string `json:"description"`              // 备注
+	ID                     uint   `json:"id"`                        // 区域ID
+	LotCode                string `json:"lot_code"`                  // 区域编码
+	LotName                string `json:"lot_name"`                  // 区域名称
+	Capacity               int    `json:"capacity" binding:"min=0"`  // 车位数量
+	AllowTemporaryWhenFull bool   `json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool   `json:"allow_monthly_when_full"`   // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool   `json:"allow_vip_when_full"`       // 满位时允许有效 VIP 车入场
+	Description            string `json:"description"`               // 备注
 }
 
 // ParkingLotQuery 停车区域查询请求

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

@@ -56,6 +56,7 @@ type VehicleEntry struct {
 	EntryChannelName string `json:"entry_channel_name"`
 	EntryDeviceCode  string `json:"entry_device_code"`
 	EntryDeviceName  string `json:"entry_device_name"`
+	TriggerMode      string `json:"-"`
 }
 
 // VehicleIdentityRequest 用车牌或 RFID 定位当前车辆及停车会话。
@@ -75,6 +76,9 @@ type VehicleEntryResponse struct {
 	Success bool   `json:"success"`
 	Message string `json:"message"`
 	Data    struct {
+		SessionID        uint   `json:"session_id"`
+		TicketID         uint   `json:"ticket_id"`
+		TicketNo         string `json:"ticket_no"`
 		PlateNumber      string `json:"plate_number"`
 		EntryTime        int64  `json:"entry_time"`
 		ParkingLotID     uint   `json:"parking_lot_id"`

+ 9 - 0
internal/modules/parking-session/model/request/session.go

@@ -9,6 +9,14 @@ type ResolveActiveSessionRequest struct {
 	RFIDTag     string
 }
 
+// EntryAccessProfile 描述车辆在停车场满位时参与准入判断的身份。
+// 同一车辆可以同时具有多个身份,只要任一身份被停车场允许即可入场。
+type EntryAccessProfile struct {
+	IsTemporary         bool
+	HasValidMonthlyCard bool
+	IsVIP               bool
+}
+
 // OpenSessionRequest 保存一次停车会话不可变的入场信息。
 type OpenSessionRequest struct {
 	PlateNumber      string
@@ -22,6 +30,7 @@ type OpenSessionRequest struct {
 	EntryDeviceName  string
 	EntryImage       string
 	EntryTime        time.Time
+	AccessProfile    EntryAccessProfile
 }
 
 // CloseSessionRequest 保存一次停车会话的最终结算与出场信息。

+ 76 - 0
internal/modules/parking-session/service/service.go

@@ -27,6 +27,8 @@ var (
 	ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
 	ErrSessionAmbiguous        = errors.New("存在多条匹配的在场停车会话")
 	ErrSessionIdentifierEmpty  = errors.New("停车会话必须提供车牌、RFID或会话编号")
+	ErrParkingLotNotFound      = errors.New("停车场不存在")
+	ErrParkingLotFull          = errors.New("停车场车位已满")
 )
 
 // ParkingSessionService owns the parking-stay lifecycle. It deliberately
@@ -188,6 +190,9 @@ func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessi
 	if !errors.Is(err, ErrSessionNotFound) {
 		return nil, err
 	}
+	if err := reserveParkingSpaceTx(db, req.ParkingLotID, req.AccessProfile); err != nil {
+		return nil, err
+	}
 
 	entryTime := req.EntryTime
 	if entryTime.IsZero() {
@@ -213,6 +218,74 @@ func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessi
 	return session, nil
 }
 
+// reserveParkingSpaceTx 在创建会话前原子增加停车场占用数。
+// 满位时只有停车场明确允许的车辆身份可以继续入场。
+func reserveParkingSpaceTx(db *gorm.DB, parkingLotID uint, profile request.EntryAccessProfile) error {
+	if parkingLotID == 0 {
+		return nil
+	}
+
+	result := db.Model(&dao.ParkingLot{}).
+		Where(`id = ? AND (
+			occupied < capacity OR
+			(? AND allow_temporary_when_full) OR
+			(? AND allow_monthly_when_full) OR
+			(? AND allow_vip_when_full)
+		)`, parkingLotID, profile.IsTemporary, profile.HasValidMonthlyCard, profile.IsVIP).
+		UpdateColumns(map[string]interface{}{
+			"occupied":  gorm.Expr("occupied + 1"),
+			"available": gorm.Expr("CASE WHEN capacity > occupied + 1 THEN capacity - occupied - 1 ELSE 0 END"),
+		})
+	if result.Error != nil {
+		return fmt.Errorf("更新停车场占用数失败: %w", result.Error)
+	}
+	if result.RowsAffected == 1 {
+		return nil
+	}
+
+	var lot dao.ParkingLot
+	if err := db.First(&lot, parkingLotID).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return ErrParkingLotNotFound
+		}
+		return err
+	}
+	if lot.Occupied < lot.Capacity {
+		return errors.New("停车场车位占用状态更新失败")
+	}
+
+	switch {
+	case profile.IsVIP && !lot.AllowVIPWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许 VIP 车满位入场", ErrParkingLotFull)
+	case profile.HasValidMonthlyCard && !lot.AllowMonthlyWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许月租车满位入场", ErrParkingLotFull)
+	case profile.IsTemporary && !lot.AllowTemporaryWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许临时车满位入场", ErrParkingLotFull)
+	default:
+		return ErrParkingLotFull
+	}
+}
+
+// releaseParkingSpaceTx 在会话关闭后释放占用,并根据实际占用数重新计算余位。
+func releaseParkingSpaceTx(db *gorm.DB, parkingLotID uint) error {
+	if parkingLotID == 0 {
+		return nil
+	}
+	result := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
+		UpdateColumn("occupied", gorm.Expr("CASE WHEN occupied > 0 THEN occupied - 1 ELSE 0 END"))
+	if result.Error != nil {
+		return fmt.Errorf("释放停车场占用数失败: %w", result.Error)
+	}
+	if result.RowsAffected != 1 {
+		return ErrParkingLotNotFound
+	}
+	if err := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
+		UpdateColumn("available", gorm.Expr("CASE WHEN capacity > occupied THEN capacity - occupied ELSE 0 END")).Error; err != nil {
+		return fmt.Errorf("更新停车场余位失败: %w", err)
+	}
+	return nil
+}
+
 // MarkPaidTx 更新在场会话的当前结算摘要。
 // 实际资金流水仍以支付模块创建的 payment_record 为准。
 func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
@@ -310,6 +383,9 @@ func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSes
 	if rows != 1 {
 		return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
 	}
+	if err := releaseParkingSpaceTx(db, session.ParkingLotID); err != nil {
+		return nil, err
+	}
 	session.ExitTime = &exitTime
 	session.StayTime = req.StayTime
 	session.Fee = req.Fee

+ 91 - 1
internal/modules/parking-session/service/service_test.go

@@ -27,7 +27,10 @@ func setupParkingSessionTest(t *testing.T) *ParkingSessionService {
 		}
 	})
 	global.GVA_DB = db
-	require.NoError(t, db.AutoMigrate(&dao.VehicleRecord{}))
+	require.NoError(t, db.AutoMigrate(&dao.ParkingLot{}, &dao.VehicleRecord{}))
+	require.NoError(t, db.Create(&dao.ParkingLot{
+		LotCode: "SESSION-LOT", LotName: "会话测试停车场", Capacity: 10, Available: 10,
+	}).Error)
 	return NewParkingSessionService()
 }
 
@@ -92,3 +95,90 @@ func TestParkingSessionRejectsDuplicateAndConflictingIdentity(t *testing.T) {
 	_, err = svc.ResolveActiveSession(request.ResolveActiveSessionRequest{PlateNumber: "SESSION-A", RFIDTag: "RFID-B"})
 	require.ErrorIs(t, err, ErrSessionIdentityConflict)
 }
+
+func TestParkingSessionFullAdmissionPolicies(t *testing.T) {
+	tests := []struct {
+		name    string
+		profile request.EntryAccessProfile
+		policy  string
+		allow   bool
+		wantErr bool
+	}{
+		{name: "普通车辆满位拒绝", wantErr: true},
+		{name: "临时车未配置时拒绝", profile: request.EntryAccessProfile{IsTemporary: true}, policy: "allow_temporary_when_full", wantErr: true},
+		{name: "临时车配置后允许", profile: request.EntryAccessProfile{IsTemporary: true}, policy: "allow_temporary_when_full", allow: true},
+		{name: "月租车配置后允许", profile: request.EntryAccessProfile{HasValidMonthlyCard: true}, policy: "allow_monthly_when_full", allow: true},
+		{name: "VIP车配置后允许", profile: request.EntryAccessProfile{IsVIP: true}, policy: "allow_vip_when_full", allow: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			svc := setupParkingSessionTest(t)
+			updates := map[string]interface{}{
+				"capacity": 0, "occupied": 0, "available": 0,
+				"allow_temporary_when_full": false,
+				"allow_monthly_when_full":   false,
+				"allow_vip_when_full":       false,
+			}
+			if tt.policy != "" {
+				updates[tt.policy] = tt.allow
+			}
+			require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(updates).Error)
+
+			session, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{
+				PlateNumber: "FULL-" + tt.name, ParkingLotID: 1, AccessProfile: tt.profile,
+			})
+			if tt.wantErr {
+				require.ErrorIs(t, err, ErrParkingLotFull)
+				require.Nil(t, session)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, session)
+			}
+
+			var lot dao.ParkingLot
+			require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+			if tt.wantErr {
+				require.Zero(t, lot.Occupied)
+			} else {
+				require.Equal(t, 1, lot.Occupied)
+			}
+			require.Zero(t, lot.Available)
+		})
+	}
+}
+
+func TestParkingSessionOverflowExitKeepsAvailabilityAccurate(t *testing.T) {
+	svc := setupParkingSessionTest(t)
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(map[string]interface{}{
+		"capacity": 1, "occupied": 0, "available": 1, "allow_vip_when_full": true,
+	}).Error)
+
+	regular, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{PlateNumber: "REGULAR", ParkingLotID: 1})
+	require.NoError(t, err)
+	overflow, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{
+		PlateNumber: "VIP-OVERFLOW", ParkingLotID: 1, AccessProfile: request.EntryAccessProfile{IsVIP: true},
+	})
+	require.NoError(t, err)
+
+	closeSession := func(session *dao.VehicleRecord) {
+		require.NoError(t, global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+			if _, err := svc.MarkPaidTx(tx, session.ID, "counter", "free", 0); err != nil {
+				return err
+			}
+			_, err := svc.CloseSessionTx(tx, request.CloseSessionRequest{SessionID: session.ID})
+			return err
+		}))
+	}
+	closeSession(overflow)
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Zero(t, lot.Available)
+
+	closeSession(regular)
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Zero(t, lot.Occupied)
+	require.Equal(t, 1, lot.Available)
+}

+ 240 - 94
internal/modules/printer/api.go

@@ -1,144 +1,290 @@
 package printer
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"fmt"
-	"time"
+	"strings"
 
 	"github.com/gin-gonic/gin"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	commonModel "wails-app/internal/model/common"
 	"wails-app/internal/model/common/response"
-	"wails-app/internal/model/vehicle/request"
 	printService "wails-app/internal/modules/printer/service"
-	svc "wails-app/internal/service"
+	"wails-app/internal/service"
 )
 
 var printSvc = printService.NewPrintService()
-var vehicleSvc = svc.ServiceGroupApp.VehicleServiceGroup.VehicleService
+
+type ticketMachinePassage interface {
+	HandlePassage(commonModel.PassageRequest) (*commonModel.PassageResult, error)
+}
+
+type ticketMachinePrinter interface {
+	PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error
+}
 
 type buttonReq struct {
 	PrinterID   uint   `json:"printer_id"`
 	ChannelCode string `json:"channel_code"`
+	DeviceCode  string `json:"device_code"`
 	PlateNo     string `json:"plate_number"`
 }
 
-func TicketMachineButton(c *gin.Context) {
-	var req buttonReq
-	if err := c.ShouldBindJSON(&req); err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
+type rawPrintDebugReq struct {
+	DeviceName string `json:"device_name"`
+}
+
+type ticketMachineResult struct {
+	SessionID        uint   `json:"session_id"`
+	TicketID         uint   `json:"ticket_id"`
+	TicketNo         string `json:"ticket_no"`
+	SlipNo           uint   `json:"slip_no"`
+	EntryTime        int64  `json:"entry_time"`
+	PlateNumber      string `json:"plate_number"`
+	AnonymousVehicle bool   `json:"anonymous_vehicle"`
+	ParkingLotID     uint   `json:"parking_lot_id"`
+	ParkingLotName   string `json:"parking_lot_name"`
+	ChannelCode      string `json:"channel_code"`
+	ChannelName      string `json:"channel_name"`
+	DeviceCode       string `json:"device_code"`
+	DeviceName       string `json:"device_name"`
+	BusinessStatus   string `json:"business_status"`
+	GateStatus       string `json:"gate_status"`
+	PrintStatus      string `json:"print_status"`
+}
+
+func generateTicketMachineIdentity(prefix string) (string, error) {
+	data := make([]byte, 8)
+	if _, err := rand.Read(data); err != nil {
+		return "", fmt.Errorf("生成票机车辆标识失败: %w", err)
 	}
+	return prefix + strings.ToUpper(hex.EncodeToString(data)), nil
+}
 
-	// 创建入场记录(临时车,可能无车牌)
-	entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
-		PlateNumber: req.PlateNo, RFIDTag: "",
-		ParkingLotID: 1, ParkingSpaceID: 1,
-	})
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
+func resolveTicketMachineLocation(req buttonReq) (*dao.Channel, *dao.UHFReader, *dao.ParkingLot, error) {
+	if global.GVA_DB == nil {
+		return nil, nil, nil, fmt.Errorf("数据库未初始化")
+	}
+	channelCode := strings.TrimSpace(req.ChannelCode)
+	if channelCode == "" {
+		return nil, nil, nil, fmt.Errorf("通道编码不能为空")
 	}
 
-	// 获取刚创建的数字票
-	var tickets []dao.DigitalTicket
-	global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
-	if len(tickets) == 0 {
-		response.FailWithMessage("数字票创建失败", c)
-		return
+	var channel dao.Channel
+	if err := global.GVA_DB.Where("channel_code = ?", channelCode).First(&channel).Error; err != nil {
+		return nil, nil, nil, fmt.Errorf("票机通道不存在: %s", channelCode)
+	}
+	if channel.Direction != "in" && channel.Direction != "inout" {
+		return nil, nil, nil, fmt.Errorf("票机只能绑定入口或双向通道: %s", channelCode)
+	}
+	if channel.ParkingLotID == 0 {
+		return nil, nil, nil, fmt.Errorf("票机通道未配置停车场: %s", channelCode)
+	}
+
+	var devices []dao.UHFReader
+	deviceCode := strings.TrimSpace(req.DeviceCode)
+	query := global.GVA_DB.Where("channel_id = ? AND is_active = ?", channel.ID, true)
+	if deviceCode != "" {
+		query = query.Where("device_code = ?", deviceCode)
+	}
+	if err := query.Order("id").Find(&devices).Error; err != nil {
+		return nil, nil, nil, err
+	}
+	if len(devices) == 0 {
+		if deviceCode == "" {
+			return nil, nil, nil, fmt.Errorf("票机通道没有绑定启用的闸机设备: %s", channelCode)
+		}
+		return nil, nil, nil, fmt.Errorf("设备未启用或不属于票机通道: %s", deviceCode)
+	}
+	if len(devices) > 1 {
+		return nil, nil, nil, fmt.Errorf("票机通道绑定了多个启用设备,必须指定 device_code")
 	}
 
-	// 获取停车场名
 	var lot dao.ParkingLot
-	global.GVA_DB.First(&lot, entryResp.Data.ParkingLotID)
+	if err := global.GVA_DB.First(&lot, channel.ParkingLotID).Error; err != nil {
+		return nil, nil, nil, fmt.Errorf("票机通道所属停车场不存在")
+	}
+	return &channel, &devices[0], &lot, nil
+}
 
-	// 打印小票
-	if err := printSvc.PrintTicket(req.PrinterID, lot.LotName, req.ChannelCode, req.PlateNo, tickets[0].ID, tickets[0].TicketNo); err != nil {
-		response.FailWithMessage("打印失败: "+err.Error(), c)
-		return
+func processTicketMachineButton(req buttonReq, passage ticketMachinePassage, printer ticketMachinePrinter) (*ticketMachineResult, error) {
+	if req.PrinterID == 0 {
+		return nil, fmt.Errorf("打印机ID不能为空")
+	}
+	channel, device, lot, err := resolveTicketMachineLocation(req)
+	if err != nil {
+		return nil, err
 	}
 
-	response.OkWithData(gin.H{
-		"ticket_id":    tickets[0].ID,
-		"ticket_no":    tickets[0].TicketNo,
-		"slip_no":      tickets[0].ID,
-		"entry_time":   entryResp.Data.EntryTime,
-		"plate_number": entryResp.Data.PlateNumber,
-	}, c)
+	displayPlate := strings.TrimSpace(req.PlateNo)
+	plateNumber := displayPlate
+	anonymous := plateNumber == ""
+	if anonymous {
+		plateNumber, err = generateTicketMachineIdentity("TKT")
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	passageResult, passageErr := passage.HandlePassage(commonModel.PassageRequest{
+		PlateNumber:   plateNumber,
+		DeviceCode:    device.DeviceCode,
+		Direction:     "in",
+		TriggerSource: "ticket_machine",
+	})
+	if passageResult == nil {
+		if passageErr == nil {
+			passageErr = fmt.Errorf("统一通行入口未返回处理结果")
+		}
+		return nil, passageErr
+	}
+
+	result := &ticketMachineResult{
+		SessionID:        passageResult.SessionID,
+		TicketID:         passageResult.TicketID,
+		TicketNo:         passageResult.TicketNo,
+		SlipNo:           passageResult.TicketID,
+		EntryTime:        passageResult.EntryTime,
+		PlateNumber:      passageResult.PlateNumber,
+		AnonymousVehicle: anonymous,
+		ParkingLotID:     lot.ID,
+		ParkingLotName:   lot.LotName,
+		ChannelCode:      channel.ChannelCode,
+		ChannelName:      channel.ChannelName,
+		DeviceCode:       device.DeviceCode,
+		DeviceName:       device.DeviceName,
+		BusinessStatus:   "completed",
+		GateStatus:       passageResult.GateStatus,
+		PrintStatus:      "pending",
+	}
+	if !passageResult.BusinessCompleted {
+		result.BusinessStatus = "failed"
+		if passageErr == nil {
+			passageErr = fmt.Errorf("统一通行入口未完成入场业务")
+		}
+		return result, passageErr
+	}
+	if result.SessionID == 0 || result.TicketID == 0 || result.TicketNo == "" {
+		result.PrintStatus = "skipped"
+		return result, fmt.Errorf("统一通行入口未返回新建会话和数字票")
+	}
+
+	if err := printer.PrintTicket(req.PrinterID, lot.LotName, channel.ChannelCode, displayPlate, result.TicketID, result.TicketNo); err != nil {
+		result.PrintStatus = "failed"
+		if passageErr != nil {
+			return result, fmt.Errorf("%v;打印失败: %w", passageErr, err)
+		}
+		return result, fmt.Errorf("打印失败: %w", err)
+	}
+	result.PrintStatus = "printed"
+	if passageErr != nil {
+		return result, passageErr
+	}
+	return result, nil
 }
 
-func SetupPrinterRouter(router *gin.RouterGroup) {
-	router.POST("/ticket-machine/button", TicketMachineButton)
+func handleTicketMachineButton(c *gin.Context, req buttonReq) {
+	result, err := processTicketMachineButton(
+		req,
+		&service.ServiceGroupApp.ParkingServiceGroup.PassageService,
+		printSvc,
+	)
+	if err != nil {
+		if result != nil {
+			response.FailWithDetailed(result, err.Error(), c)
+			return
+		}
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(result, c)
 }
 
-// SetupPrinterPublic 公开测试路由(无需JWT)
-func SetupPrinterPublic(router *gin.RouterGroup) {
-	router.GET("/ticket-machine/test", TicketMachineButtonTest)
-	router.GET("/ticket-machine/debug", func(c *gin.Context) {
-		printService.InitCSN(printService.FindCsnDllPath())
-		printService.Csn_EnumUSB()
-		printService.Csn_EnumPRN()
-		h, e := printService.Csn_OpenUSB("VID_0FE6&PID_811E#USB001")
-		printService.Csn_SetPort(h)
-		testData := []byte{0x1B, 0x40, 'T', 'E', 'S', 'T', ' ', 'P', 'R', 'I', 'N', 'T', '\n', '\n', '\n', '\n', 0x1B, 0x69}
-		n := printService.Csn_WriteData(testData)
-		response.OkWithData(gin.H{
-			"handle": h, "err": fmt.Sprint(e), "bytes_written": n,
-		}, c)
-	})
-	router.GET("/ticket-machine/usb-list", func(c *gin.Context) {
-		printService.InitCSN(printService.FindCsnDllPath())
-		usb := printService.Csn_EnumUSBNames()
-		prn := printService.Csn_EnumPRNNames()
-		response.OkWithData(gin.H{"usb": usb, "prn": prn}, c)
-	})
+// TicketMachineButton 处理真实票机按钮事件,业务统一进入 PassageService。
+func TicketMachineButton(c *gin.Context) {
+	var req buttonReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	handleTicketMachineButton(c, req)
 }
 
 func TicketMachineButtonTest(c *gin.Context) {
-	// 每次生成唯一测试车牌
-	testPlate := fmt.Sprintf("TEST%04d", time.Now().Unix()%10000)
-	entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
-		PlateNumber: testPlate, RFIDTag: "",
-		ParkingLotID: 1, ParkingSpaceID: 1,
-	})
-	if err != nil {
+	var req buttonReq
+	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	var tickets []dao.DigitalTicket
-	global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
-	if len(tickets) == 0 {
-		response.FailWithMessage("票创建失败", c)
+	plateNumber, err := generateTicketMachineIdentity("TEST")
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	req.PlateNo = plateNumber
+	handleTicketMachineButton(c, req)
+}
 
-	// 从入场记录获取真实 ParkingLotID,再查停车场名
-	var rec dao.VehicleRecord
-	if err := global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").First(&rec).Error; err != nil {
-		response.FailWithMessage("查入场记录失败: "+err.Error(), c)
+func ticketMachineRawPrintDebug(c *gin.Context) {
+	var req rawPrintDebugReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	var lot dao.ParkingLot
-	lot.ID = rec.ParkingLotID
-	global.GVA_DB.First(&lot)
-	lotName := lot.LotName
-	if lotName == "" {
-		lotName = fmt.Sprintf("Lot#%d", rec.ParkingLotID)
+	req.DeviceName = strings.TrimSpace(req.DeviceName)
+	if req.DeviceName == "" {
+		response.FailWithMessage("打印设备名称不能为空", c)
+		return
 	}
-
-	// 尝试打印
-	printErr := ""
-	if err := printSvc.PrintTicket(1, lotName, "IN01", entryResp.Data.PlateNumber, tickets[0].ID, tickets[0].TicketNo); err != nil {
-		printErr = err.Error()
+	if err := printService.InitCSN(printService.FindCsnDllPath()); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	printService.Csn_EnumUSB()
+	printService.Csn_EnumPRN()
+	h, err := printService.Csn_OpenUSB(req.DeviceName)
+	if err != nil {
+		h, err = printService.Csn_OpenPRN(req.DeviceName)
+	}
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	defer printService.Csn_Close(h)
+	printService.Csn_SetPort(h)
+	testData := []byte{0x1B, 0x40, 'T', 'E', 'S', 'T', ' ', 'P', 'R', 'I', 'N', 'T', '\n', '\n', '\n', '\n', 0x1B, 0x69}
+	n := printService.Csn_WriteData(testData)
+	if n != len(testData) {
+		response.FailWithDetailed(gin.H{"bytes_written": n, "bytes_expected": len(testData)}, "打印数据未完整写入", c)
+		return
 	}
-
 	response.OkWithData(gin.H{
-		"ticket_id":    tickets[0].ID,
-		"ticket_no":    tickets[0].TicketNo,
-		"slip_no":      tickets[0].ID,
-		"plate_number": entryResp.Data.PlateNumber,
-		"entry_time":   entryResp.Data.EntryTime,
-		"lot_name":     lotName,
-		"channel_code": "IN01",
-		"print_result": printErr,
+		"device_name": req.DeviceName, "bytes_written": n,
 	}, c)
 }
+
+func ticketMachineUSBList(c *gin.Context) {
+	if err := printService.InitCSN(printService.FindCsnDllPath()); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	usb := printService.Csn_EnumUSBNames()
+	prn := printService.Csn_EnumPRNNames()
+	response.OkWithData(gin.H{"usb": usb, "prn": prn}, c)
+}
+
+func SetupPrinterRouter(router *gin.RouterGroup) {
+	router.POST("/ticket-machine/button", TicketMachineButton)
+}
+
+// SetupPrinterDebugRoutes 仅在显式开发配置开启时注册,调用方必须使用私有鉴权路由组。
+func SetupPrinterDebugRoutes(router *gin.RouterGroup, enabled bool) {
+	if !enabled {
+		return
+	}
+	router.POST("/ticket-machine/test", TicketMachineButtonTest)
+	router.POST("/ticket-machine/debug", ticketMachineRawPrintDebug)
+	router.GET("/ticket-machine/usb-list", ticketMachineUSBList)
+}

+ 177 - 0
internal/modules/printer/api_test.go

@@ -0,0 +1,177 @@
+package printer
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	commonModel "wails-app/internal/model/common"
+)
+
+type fakeTicketMachinePassage struct {
+	req    commonModel.PassageRequest
+	result *commonModel.PassageResult
+	err    error
+}
+
+func (f *fakeTicketMachinePassage) HandlePassage(req commonModel.PassageRequest) (*commonModel.PassageResult, error) {
+	f.req = req
+	return f.result, f.err
+}
+
+type fakeTicketMachinePrinter struct {
+	printerID   uint
+	lotName     string
+	channelCode string
+	plateNo     string
+	ticketID    uint
+	ticketNo    string
+	err         error
+}
+
+func (f *fakeTicketMachinePrinter) PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error {
+	f.printerID = printerID
+	f.lotName = lotName
+	f.channelCode = channelCode
+	f.plateNo = plateNo
+	f.ticketID = ticketID
+	f.ticketNo = ticketNo
+	return f.err
+}
+
+func setupTicketMachineTest(t *testing.T) (*dao.ParkingLot, *dao.Channel) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:ticket-machine-%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.ParkingLot{}, &dao.Channel{}, &dao.UHFReader{}))
+
+	lot := &dao.ParkingLot{LotCode: "LOT-TICKET", LotName: "票机停车场", Capacity: 50, Available: 50}
+	require.NoError(t, db.Create(lot).Error)
+	channel := &dao.Channel{
+		ChannelCode: "TICKET-IN", ChannelName: "票机入口", Direction: "in",
+		AllowTemporary: true, ParkingLotID: lot.ID,
+	}
+	require.NoError(t, db.Create(channel).Error)
+	return lot, channel
+}
+
+func createTicketMachineDevice(t *testing.T, channelID uint, code string) *dao.UHFReader {
+	t.Helper()
+	device := &dao.UHFReader{
+		DeviceCode: code, DeviceName: code + "名称", DeviceType: "gate",
+		ConnectType: "tcp", IsActive: true, ChannelID: channelID,
+	}
+	require.NoError(t, global.GVA_DB.Create(device).Error)
+	return device
+}
+
+func TestProcessTicketMachineButtonUsesUnifiedPassageAndReturnedTicket(t *testing.T) {
+	lot, channel := setupTicketMachineTest(t)
+	device := createTicketMachineDevice(t, channel.ID, "GATE-TICKET-01")
+	passage := &fakeTicketMachinePassage{result: &commonModel.PassageResult{
+		Success: true, Direction: "in", PlateNumber: "TKT-RESULT",
+		SessionID: 41, TicketID: 73, TicketNo: "ticket-no-73", EntryTime: 123456,
+		BusinessCompleted: true, GateOpened: true, GateStatus: "opened",
+	}}
+	printer := &fakeTicketMachinePrinter{}
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 9, ChannelCode: channel.ChannelCode,
+	}, passage, printer)
+	require.NoError(t, err)
+	require.True(t, result.AnonymousVehicle)
+	require.Equal(t, lot.ID, result.ParkingLotID)
+	require.Equal(t, channel.ChannelCode, result.ChannelCode)
+	require.Equal(t, device.DeviceCode, result.DeviceCode)
+	require.EqualValues(t, 41, result.SessionID)
+	require.EqualValues(t, 73, result.TicketID)
+	require.Equal(t, "ticket-no-73", result.TicketNo)
+	require.Equal(t, "printed", result.PrintStatus)
+
+	require.Equal(t, device.DeviceCode, passage.req.DeviceCode)
+	require.Equal(t, "in", passage.req.Direction)
+	require.Equal(t, "ticket_machine", passage.req.TriggerSource)
+	require.True(t, strings.HasPrefix(passage.req.PlateNumber, "TKT"))
+	require.Len(t, passage.req.PlateNumber, 19)
+	require.EqualValues(t, 9, printer.printerID)
+	require.Equal(t, lot.LotName, printer.lotName)
+	require.Equal(t, channel.ChannelCode, printer.channelCode)
+	require.Empty(t, printer.plateNo)
+	require.EqualValues(t, 73, printer.ticketID)
+	require.Equal(t, "ticket-no-73", printer.ticketNo)
+}
+
+func TestProcessTicketMachineButtonRequiresDeviceWhenChannelHasMultiple(t *testing.T) {
+	_, channel := setupTicketMachineTest(t)
+	createTicketMachineDevice(t, channel.ID, "GATE-A")
+	createTicketMachineDevice(t, channel.ID, "GATE-B")
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 1, ChannelCode: channel.ChannelCode,
+	}, &fakeTicketMachinePassage{}, &fakeTicketMachinePrinter{})
+	require.Nil(t, result)
+	require.EqualError(t, err, "票机通道绑定了多个启用设备,必须指定 device_code")
+}
+
+func TestProcessTicketMachineButtonKeepsBusinessResultWhenPrintFails(t *testing.T) {
+	_, channel := setupTicketMachineTest(t)
+	createTicketMachineDevice(t, channel.ID, "GATE-PRINT")
+	passage := &fakeTicketMachinePassage{result: &commonModel.PassageResult{
+		SessionID: 8, TicketID: 9, TicketNo: "ticket-9", PlateNumber: "TEST-PLATE",
+		EntryTime: 100, BusinessCompleted: true, GateStatus: "opened",
+	}}
+	printer := &fakeTicketMachinePrinter{err: fmt.Errorf("paper out")}
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 1, ChannelCode: channel.ChannelCode, PlateNo: "TEST-PLATE",
+	}, passage, printer)
+	require.ErrorContains(t, err, "打印失败")
+	require.EqualValues(t, 8, result.SessionID)
+	require.EqualValues(t, 9, result.TicketID)
+	require.Equal(t, "completed", result.BusinessStatus)
+	require.Equal(t, "failed", result.PrintStatus)
+}
+
+func TestPrinterDebugRoutesAreDisabledByDefault(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	group := router.Group("")
+	SetupPrinterRouter(group)
+	SetupPrinterDebugRoutes(group, false)
+
+	routes := router.Routes()
+	require.Len(t, routes, 1)
+	require.Equal(t, "POST", routes[0].Method)
+	require.Equal(t, "/ticket-machine/button", routes[0].Path)
+}
+
+func TestPrinterDebugRoutesUseSafeMethodsWhenEnabled(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	SetupPrinterDebugRoutes(router.Group(""), true)
+
+	methods := map[string]string{}
+	for _, route := range router.Routes() {
+		methods[route.Path] = route.Method
+	}
+	require.Equal(t, "POST", methods["/ticket-machine/test"])
+	require.Equal(t, "POST", methods["/ticket-machine/debug"])
+	require.Equal(t, "GET", methods["/ticket-machine/usb-list"])
+}

+ 5 - 0
internal/modules/printer/service/csn_dll.go

@@ -136,6 +136,11 @@ func csn_Close(handle uintptr) {
 	}
 }
 
+// Csn_Close 关闭调试接口或外部适配层打开的 CSN 端口。
+func Csn_Close(handle uintptr) {
+	csn_Close(handle)
+}
+
 // === Print functions ===
 
 func csn_Reset() {

+ 8 - 1
internal/router/parking/parking.go

@@ -9,7 +9,6 @@ import (
 
 func SetupParkingRouter(router *gin.RouterGroup) {
 	router.GET("/channel/events", parking.GetChannelEvents)
-	router.GET("/channel/test-event", parking.SimulateEvent)
 
 	parkingGroup := router.Group("/parking")
 	gateGroup := parkingGroup.Group("/gate")
@@ -59,3 +58,11 @@ func SetupParkingRouter(router *gin.RouterGroup) {
 		deviceGroup.DELETE("/delete", uhf.DeleteReader)
 	}
 }
+
+// SetupParkingDebugRouter 仅在显式开发配置开启时注册模拟事件接口。
+func SetupParkingDebugRouter(router *gin.RouterGroup, enabled bool) {
+	if !enabled {
+		return
+	}
+	router.POST("/channel/test-event", parking.SimulateEvent)
+}

+ 22 - 0
internal/router/parking/parking_test.go

@@ -0,0 +1,22 @@
+package parking
+
+import (
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParkingDebugRouteRegistration(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	disabled := gin.New()
+	SetupParkingDebugRouter(disabled.Group(""), false)
+	require.Empty(t, disabled.Routes())
+
+	enabled := gin.New()
+	SetupParkingDebugRouter(enabled.Group(""), true)
+	routes := enabled.Routes()
+	require.Len(t, routes, 1)
+	require.Equal(t, "POST", routes[0].Method)
+	require.Equal(t, "/channel/test-event", routes[0].Path)
+}

+ 83 - 58
internal/service/parking/parking_lot.go

@@ -5,6 +5,8 @@ import (
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/parking/request"
+
+	"gorm.io/gorm"
 )
 
 type parkingLotService struct{}
@@ -22,44 +24,70 @@ func (s *parkingLotService) CreateParkingLot(req request.ParkingLotCreate) error
 		return errors.New("区域编码已存在")
 	}
 
-	lot := dao.ParkingLot{
-		LotCode:     req.LotCode,
-		LotName:     req.LotName,
-		Capacity:    req.Capacity,
-		Available:   req.Capacity, // 初始可用车位等于总车位
-		Description: req.Description,
-	}
-
-	return global.GVA_DB.Create(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		lot := dao.ParkingLot{
+			LotCode:     req.LotCode,
+			LotName:     req.LotName,
+			Capacity:    req.Capacity,
+			Occupied:    0,
+			Available:   req.Capacity,
+			Description: req.Description,
+		}
+		if err := tx.Create(&lot).Error; err != nil {
+			return err
+		}
+		// 显式更新布尔值,确保 false 不会被 GORM 的数据库默认值覆盖。
+		return tx.Model(&lot).Updates(map[string]interface{}{
+			"allow_temporary_when_full": req.AllowTemporaryWhenFull,
+			"allow_monthly_when_full":   req.AllowMonthlyWhenFull,
+			"allow_vip_when_full":       req.AllowVIPWhenFull,
+		}).Error
+	})
 }
 
 // UpdateParkingLot 更新停车区域
 func (s *parkingLotService) UpdateParkingLot(req request.ParkingLotUpdate) error {
-	var lot dao.ParkingLot
-	result := global.GVA_DB.First(&lot, req.ID)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-
-	// 检查区域编码是否被其他区域使用
-	var existingLot dao.ParkingLot
-	result = global.GVA_DB.Where("lot_code = ? AND id != ?", req.LotCode, req.ID).First(&existingLot)
-	if result.RowsAffected > 0 {
-		return errors.New("区域编码已被其他区域使用")
-	}
-
-	// 计算可用车位的变化
-	capacityDiff := req.Capacity - lot.Capacity
-	lot.LotCode = req.LotCode
-	lot.LotName = req.LotName
-	lot.Capacity = req.Capacity
-	lot.Available += capacityDiff
-	if lot.Available < 0 {
-		lot.Available = 0
-	}
-	lot.Description = req.Description
-
-	return global.GVA_DB.Save(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var lot dao.ParkingLot
+		if err := tx.First(&lot, req.ID).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return errors.New("停车区域不存在")
+			}
+			return err
+		}
+
+		var duplicateCount int64
+		if err := tx.Model(&dao.ParkingLot{}).
+			Where("lot_code = ? AND id != ?", req.LotCode, req.ID).
+			Count(&duplicateCount).Error; err != nil {
+			return err
+		}
+		if duplicateCount > 0 {
+			return errors.New("区域编码已被其他区域使用")
+		}
+
+		var occupied int64
+		if err := tx.Model(&dao.VehicleRecord{}).
+			Where("parking_lot_id = ? AND exit_time IS NULL", req.ID).
+			Count(&occupied).Error; err != nil {
+			return err
+		}
+		if int64(req.Capacity) < occupied {
+			return errors.New("停车场容量不能小于当前在场车辆数")
+		}
+
+		return tx.Model(&lot).Updates(map[string]interface{}{
+			"lot_code":                  req.LotCode,
+			"lot_name":                  req.LotName,
+			"capacity":                  req.Capacity,
+			"occupied":                  occupied,
+			"available":                 int64(req.Capacity) - occupied,
+			"allow_temporary_when_full": req.AllowTemporaryWhenFull,
+			"allow_monthly_when_full":   req.AllowMonthlyWhenFull,
+			"allow_vip_when_full":       req.AllowVIPWhenFull,
+			"description":               req.Description,
+		}).Error
+	})
 }
 
 // GetParkingLotByID 根据ID获取停车区域
@@ -104,7 +132,7 @@ func (s *parkingLotService) ListParkingLots(req request.ParkingLotQuery) (int64,
 		query = query.Where("lot_code LIKE ?", "%"+req.LotCode+"%")
 	}
 
-	if req.LotCode != "" {
+	if req.LotId > 0 {
 		query = query.Where("id = ?", req.LotId)
 	}
 
@@ -127,27 +155,24 @@ func (s *parkingLotService) ListParkingLots(req request.ParkingLotQuery) (int64,
 
 // DeleteParkingLot 删除停车区域
 func (s *parkingLotService) DeleteParkingLot(id uint) error {
-	result := global.GVA_DB.Delete(&dao.ParkingLot{}, id)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-	return result.Error
-}
-
-// UpdateParkingLotAvailability 更新停车区域可用车位
-func (s *parkingLotService) UpdateParkingLotAvailability(lotID uint, delta int) error {
-	var lot dao.ParkingLot
-	result := global.GVA_DB.First(&lot, lotID)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-
-	lot.Available += delta
-	if lot.Available < 0 {
-		lot.Available = 0
-	} else if lot.Available > lot.Capacity {
-		lot.Available = lot.Capacity
-	}
-
-	return global.GVA_DB.Save(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var lot dao.ParkingLot
+		if err := tx.First(&lot, id).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return errors.New("停车区域不存在")
+			}
+			return err
+		}
+
+		var activeSessions int64
+		if err := tx.Model(&dao.VehicleRecord{}).
+			Where("parking_lot_id = ? AND exit_time IS NULL", id).
+			Count(&activeSessions).Error; err != nil {
+			return err
+		}
+		if activeSessions > 0 {
+			return errors.New("停车场仍有在场车辆,不能删除")
+		}
+		return tx.Delete(&lot).Error
+	})
 }

+ 98 - 0
internal/service/parking/parking_lot_test.go

@@ -0,0 +1,98 @@
+package parking
+
+import (
+	"fmt"
+	"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/parking/request"
+)
+
+func setupParkingLotTest(t *testing.T) *parkingLotService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:parking-lot-%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.ParkingLot{}, &dao.VehicleRecord{}))
+	return NewParkingLotService()
+}
+
+func TestCreateParkingLotPersistsFullAdmissionPolicies(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	require.NoError(t, svc.CreateParkingLot(request.ParkingLotCreate{
+		LotCode: "LOT-A", LotName: "A区", Capacity: 20,
+		AllowTemporaryWhenFull: false,
+		AllowMonthlyWhenFull:   true,
+		AllowVIPWhenFull:       false,
+	}))
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.Where("lot_code = ?", "LOT-A").First(&lot).Error)
+	require.Zero(t, lot.Occupied)
+	require.Equal(t, 20, lot.Available)
+	require.False(t, lot.AllowTemporaryWhenFull)
+	require.True(t, lot.AllowMonthlyWhenFull)
+	require.False(t, lot.AllowVIPWhenFull)
+}
+
+func TestUpdateParkingLotUsesActiveSessionsForAvailability(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-B", LotName: "B区", Capacity: 2, Available: 2}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "IN-PARK", ParkingLotID: lot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	err := svc.UpdateParkingLot(request.ParkingLotUpdate{
+		ID: lot.ID, LotCode: lot.LotCode, LotName: lot.LotName, Capacity: 0,
+	})
+	require.EqualError(t, err, "停车场容量不能小于当前在场车辆数")
+
+	require.NoError(t, svc.UpdateParkingLot(request.ParkingLotUpdate{
+		ID: lot.ID, LotCode: lot.LotCode, LotName: lot.LotName, Capacity: 3,
+		AllowTemporaryWhenFull: true, AllowMonthlyWhenFull: false, AllowVIPWhenFull: true,
+	}))
+	require.NoError(t, global.GVA_DB.First(&lot, lot.ID).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Equal(t, 2, lot.Available)
+	require.True(t, lot.AllowTemporaryWhenFull)
+	require.False(t, lot.AllowMonthlyWhenFull)
+	require.True(t, lot.AllowVIPWhenFull)
+}
+
+func TestDeleteParkingLotRejectsActiveSessions(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-C", LotName: "C区", Capacity: 10, Available: 9, Occupied: 1}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "ACTIVE-CAR", ParkingLotID: lot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	require.EqualError(t, svc.DeleteParkingLot(lot.ID), "停车场仍有在场车辆,不能删除")
+	require.NoError(t, global.GVA_DB.First(&dao.ParkingLot{}, lot.ID).Error)
+}
+
+func TestDeleteParkingLotWithoutActiveSessions(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-D", LotName: "D区", Capacity: 10, Available: 10}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+
+	require.NoError(t, svc.DeleteParkingLot(lot.ID))
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", lot.ID).Count(&count).Error)
+	require.Zero(t, count)
+}

+ 4 - 0
internal/service/parking/passage.go

@@ -143,6 +143,7 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 		EntryChannelName: channel.ChannelName,
 		EntryDeviceCode:  req.DeviceCode,
 		EntryDeviceName:  device.DeviceName,
+		TriggerMode:      req.TriggerSource,
 	})
 	if err != nil {
 		return nil, err
@@ -155,6 +156,9 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	result := &common.PassageResult{
 		Success:           true,
 		Message:           "入场成功",
+		SessionID:         resp.Data.SessionID,
+		TicketID:          resp.Data.TicketID,
+		TicketNo:          resp.Data.TicketNo,
 		Direction:         "in",
 		PlateNumber:       resp.Data.PlateNumber,
 		RFIDTag:           req.RFIDTag,

+ 33 - 12
internal/service/system/sys_dashboard.go

@@ -30,24 +30,45 @@ func (s *DashboardService) GetMonitorData() (MonitorData, error) {
 	var d MonitorData
 	today := time.Now().Format("2006-01-02")
 
-	global.GVA_DB.Raw("SELECT COALESCE(SUM(capacity), 0) FROM parking_lot").Scan(&d.TotalSpaces)
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time IS NULL").Scan(&d.InPark)
-	d.UsedSpaces = d.InPark
+	var spaces struct {
+		TotalSpaces int64
+		UsedSpaces  int64
+	}
+	if err := global.GVA_DB.Raw(`SELECT
+		COALESCE(SUM(capacity), 0) AS total_spaces,
+		COALESCE(SUM(occupied), 0) AS used_spaces
+		FROM parking_lot WHERE deleted_at IS NULL`).Scan(&spaces).Error; err != nil {
+		return d, err
+	}
+	d.TotalSpaces = spaces.TotalSpaces
+	d.UsedSpaces = spaces.UsedSpaces
+	d.InPark = d.UsedSpaces
 
-	global.GVA_DB.Raw("SELECT COALESCE(SUM(amount), 0) FROM payment_record WHERE paid_at >= ? AND paid_at < ?",
-		today, today+" 23:59:59").Scan(&d.TodayIncome)
+	if err := global.GVA_DB.Raw(`SELECT COALESCE(SUM(amount), 0) FROM payment_record
+		WHERE deleted_at IS NULL AND paid_at >= ? AND paid_at < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayIncome).Error; err != nil {
+		return d, err
+	}
 
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE entry_time >= ? AND entry_time < ?",
-		today, today+" 23:59:59").Scan(&d.TodayEntry)
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time >= ? AND exit_time < ?",
-		today, today+" 23:59:59").Scan(&d.TodayExit)
+	if err := global.GVA_DB.Raw(`SELECT COUNT(*) FROM vehicle_record
+		WHERE deleted_at IS NULL AND entry_time >= ? AND entry_time < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayEntry).Error; err != nil {
+		return d, err
+	}
+	if err := global.GVA_DB.Raw(`SELECT COUNT(*) FROM vehicle_record
+		WHERE deleted_at IS NULL AND exit_time >= ? AND exit_time < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayExit).Error; err != nil {
+		return d, err
+	}
 
-	global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction,
+	if err := global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction,
 		COALESCE(uhf.device_code, '') as device_code,
 		COALESCE(uhf.device_name, '-') as device_name,
 		'unbound' as device_status
-		FROM channel ch LEFT JOIN uhf_reader uhf ON uhf.channel_id = ch.id
-		WHERE ch.deleted_at IS NULL ORDER BY ch.id`).Scan(&d.Devices)
+		FROM channel ch LEFT JOIN uhf_reader uhf ON uhf.channel_id = ch.id AND uhf.deleted_at IS NULL
+		WHERE ch.deleted_at IS NULL ORDER BY ch.id`).Scan(&d.Devices).Error; err != nil {
+		return d, err
+	}
 
 	for i := range d.Devices {
 		device := &d.Devices[i]

+ 50 - 0
internal/service/system/sys_dashboard_test.go

@@ -0,0 +1,50 @@
+package system
+
+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 setupDashboardTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:dashboard-%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.ParkingLot{}, &dao.VehicleRecord{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{},
+	))
+}
+
+func TestDashboardUsesActiveParkingLotOccupancy(t *testing.T) {
+	setupDashboardTest(t)
+	activeLot := &dao.ParkingLot{LotCode: "ACTIVE", LotName: "有效停车场", Capacity: 80, Occupied: 1, Available: 79}
+	require.NoError(t, global.GVA_DB.Create(activeLot).Error)
+	deletedLot := &dao.ParkingLot{LotCode: "DELETED", LotName: "已删除停车场", Capacity: 20, Occupied: 5, Available: 15}
+	require.NoError(t, global.GVA_DB.Create(deletedLot).Error)
+	require.NoError(t, global.GVA_DB.Delete(deletedLot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "ORPHAN", ParkingLotID: deletedLot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	data, err := (&DashboardService{}).GetMonitorData()
+	require.NoError(t, err)
+	require.EqualValues(t, 80, data.TotalSpaces)
+	require.EqualValues(t, 1, data.UsedSpaces)
+	require.EqualValues(t, 1, data.InPark)
+}

+ 73 - 0
internal/service/vehicle/parking_capacity_test.go

@@ -0,0 +1,73 @@
+package vehicle
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/vehicle/request"
+	parkingSessionService "wails-app/internal/modules/parking-session/service"
+)
+
+func setFullLotPolicies(t *testing.T, temporary, monthly, vip bool) {
+	t.Helper()
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(map[string]interface{}{
+		"capacity": 0, "occupied": 0, "available": 0,
+		"allow_temporary_when_full": temporary,
+		"allow_monthly_when_full":   monthly,
+		"allow_vip_when_full":       vip,
+	}).Error)
+}
+
+func TestVehicleEntryRejectsTemporaryVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, true, true)
+	vehicleType := &dao.VehicleType{Name: "临时车", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.Vehicle{PlateNumber: "TEMP-FULL", VehicleTypeID: vehicleType.ID}).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: "TEMP-FULL", ParkingLotID: 1})
+	require.ErrorIs(t, err, parkingSessionService.ErrParkingLotFull)
+
+	var sessionCount, ticketCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.VehicleRecord{}).Count(&sessionCount).Error)
+	require.NoError(t, global.GVA_DB.Model(&dao.DigitalTicket{}).Count(&ticketCount).Error)
+	require.Zero(t, sessionCount)
+	require.Zero(t, ticketCount)
+}
+
+func TestVehicleEntryAllowsValidMonthlyVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, true, false)
+	vehicleType := &dao.VehicleType{Name: "月租车型"}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "MONTHLY-FULL", VehicleTypeID: vehicleType.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.MonthlyCard{
+		VehicleID: vehicle.ID, StartDate: time.Now().Add(-time.Hour), EndDate: time.Now().Add(time.Hour), PaymentStatus: "paid",
+	}).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: vehicle.PlateNumber, ParkingLotID: 1})
+	require.NoError(t, err)
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Zero(t, lot.Available)
+}
+
+func TestVehicleEntryAllowsValidVIPVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, false, true)
+	owner := &dao.Owner{OwnerName: "VIP车主", IsVip: true, VipExpireTime: time.Now().Add(time.Hour)}
+	require.NoError(t, global.GVA_DB.Create(owner).Error)
+	vehicleType := &dao.VehicleType{Name: "VIP车型"}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "VIP-FULL", VehicleTypeID: vehicleType.ID, OwnerId: &owner.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: vehicle.PlateNumber, ParkingLotID: 1})
+	require.NoError(t, err)
+}

+ 6 - 1
internal/service/vehicle/parking_session_test.go

@@ -23,10 +23,11 @@ func TestVehicleEntryOpensSessionUsingCanonicalVehicleIdentity(t *testing.T) {
 	}
 	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
 
-	_, err := svc.VehicleEntry(request.VehicleEntry{
+	entryResult, err := svc.VehicleEntry(request.VehicleEntry{
 		PlateNumber: vehicle.PlateNumber, ParkingLotID: 1,
 		EntryChannelID: 31, EntryChannelCode: "ENTRY-31", EntryChannelName: "入口三号通道",
 		EntryDeviceCode: "GATE-31", EntryDeviceName: "入口三号设备",
+		TriggerMode: "ticket_machine",
 	})
 	require.NoError(t, err)
 
@@ -45,6 +46,10 @@ func TestVehicleEntryOpensSessionUsingCanonicalVehicleIdentity(t *testing.T) {
 	var ticket dao.DigitalTicket
 	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", session.ID).First(&ticket).Error)
 	require.Equal(t, vehicle.PlateNumber, ticket.PlateNumber)
+	require.Equal(t, "ticket_machine", ticket.TriggerMode)
+	require.Equal(t, session.ID, entryResult.Data.SessionID)
+	require.Equal(t, ticket.ID, entryResult.Data.TicketID)
+	require.Equal(t, ticket.TicketNo, entryResult.Data.TicketNo)
 }
 
 func TestOperationContextResolvesRFIDOnlyVehicleForEntry(t *testing.T) {

+ 5 - 1
internal/service/vehicle/payment_edge_test.go

@@ -33,8 +33,12 @@ func setupVehiclePaymentTest(t *testing.T) *VehicleService {
 	require.NoError(t, db.AutoMigrate(
 		&dao.Owner{}, &dao.Vehicle{}, &dao.VehicleType{}, &dao.FeeConfig{}, &dao.VehicleRecord{}, &dao.DigitalTicket{},
 		&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}, &dao.PaymentRecord{}, &dao.Shortlist{},
-		&dao.Channel{}, &dao.UHFReader{},
+		&dao.Channel{}, &dao.UHFReader{}, &dao.ParkingLot{}, &dao.MonthlyCard{},
 	))
+	require.NoError(t, db.Create(&dao.ParkingLot{
+		LotCode: "TEST-LOT", LotName: "测试停车场", Capacity: 100, Available: 100,
+		AllowMonthlyWhenFull: true, AllowVIPWhenFull: true,
+	}).Error)
 	require.NoError(t, db.Create(&dao.PaymentMethodConfig{
 		Code: paymentService.PaymentCash, Name: "cash", InputMode: paymentService.InputModeManualAmount,
 		Scenes: []string{paymentService.EntryCounter}, Enabled: true, IsSystem: true,

+ 54 - 3
internal/service/vehicle/vehicle.go

@@ -3,6 +3,7 @@ package vehicle
 import (
 	"errors"
 	"fmt"
+	"strings"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
@@ -18,6 +19,43 @@ import (
 
 type VehicleService struct{}
 
+// buildEntryAccessProfileTx 计算车辆在停车场满位时使用的准入身份。
+func buildEntryAccessProfileTx(tx *gorm.DB, vehicle *dao.Vehicle, at time.Time) (sessionRequest.EntryAccessProfile, error) {
+	profile := sessionRequest.EntryAccessProfile{}
+	if vehicle == nil || vehicle.ID == 0 {
+		profile.IsTemporary = true
+		return profile, nil
+	}
+
+	if vehicle.VehicleType != nil {
+		profile.IsTemporary = vehicle.VehicleType.IsSystem
+	} else {
+		var vehicleType dao.VehicleType
+		if err := tx.First(&vehicleType, vehicle.VehicleTypeID).Error; err != nil {
+			return profile, err
+		}
+		profile.IsTemporary = vehicleType.IsSystem
+	}
+
+	if vehicle.OwnerId != nil {
+		var owner dao.Owner
+		if err := tx.First(&owner, *vehicle.OwnerId).Error; err == nil {
+			profile.IsVIP = owner.IsVip && !owner.VipExpireTime.IsZero() && owner.VipExpireTime.After(at)
+		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return profile, err
+		}
+	}
+
+	var monthlyCount int64
+	if err := tx.Model(&dao.MonthlyCard{}).
+		Where("vehicle_id = ? AND payment_status = ? AND start_date <= ? AND end_date > ?", vehicle.ID, "paid", at, at).
+		Count(&monthlyCount).Error; err != nil {
+		return profile, err
+	}
+	profile.HasValidMonthlyCard = monthlyCount > 0
+	return profile, nil
+}
+
 // buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag=” 匹配所有无标签车辆)
 func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
 	if rfidTag != "" {
@@ -259,13 +297,21 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		rfidTag = vehicle.RFIDTag
 	}
 	var record *dao.VehicleRecord
+	var ticket *dao.DigitalTicket
 	ticketSvc := ticketService.NewTicketService()
 	sessionSvc := sessionService.NewParkingSessionService()
-	triggerMode := "manual"
-	if req.RFIDTag != "" {
+	triggerMode := strings.TrimSpace(req.TriggerMode)
+	if triggerMode == "" {
+		triggerMode = "manual"
+	}
+	if req.RFIDTag != "" && req.TriggerMode == "" {
 		triggerMode = "rfid"
 	}
 	err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		accessProfile, profileErr := buildEntryAccessProfileTx(tx, &vehicle, entryTime)
+		if profileErr != nil {
+			return fmt.Errorf("识别车辆满位准入身份失败: %w", profileErr)
+		}
 		var err error
 		record, err = sessionSvc.OpenSessionTx(tx, sessionRequest.OpenSessionRequest{
 			PlateNumber:      plateNumber,
@@ -279,11 +325,13 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 			EntryDeviceName:  req.EntryDeviceName,
 			EntryImage:       req.EntryImage,
 			EntryTime:        entryTime,
+			AccessProfile:    accessProfile,
 		})
 		if err != nil {
 			return err
 		}
-		if _, err := ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID); err != nil {
+		ticket, err = ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID)
+		if err != nil {
 			return fmt.Errorf("创建数字票失败: %w", err)
 		}
 		return tx.Model(&dao.Vehicle{}).Where("id = ?", vehicle.ID).
@@ -297,6 +345,9 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		Success: true,
 		Message: "车辆入场成功",
 	}
+	response.Data.SessionID = record.ID
+	response.Data.TicketID = ticket.ID
+	response.Data.TicketNo = ticket.TicketNo
 	response.Data.PlateNumber = plateNumber
 	response.Data.EntryTime = record.EntryTime.Unix()
 	response.Data.ParkingLotID = req.ParkingLotID