Forráskód Böngészése

feat: 新增闸机控制与停车会话模块,清理ast死代码

- 新增闸机(gate)控制:开闸放行/有效期校验,含API与前端
- 新增停车会话(parking-session)模块
- 通道事件驱动进出场联动,UHF/车辆进出场/支付链路调整
- 操作权限种子数据升级(兼容新旧库)
- 删除 internal/pkg、internal/utils、pkg/utils 下 ast 代码生成器死代码及 autocode 模板
- 补全闸机/会话/UHF事件/边界场景单元测试

Co-Authored-By: Claude <noreply@anthropic.com>
lq 1 hónapja
szülő
commit
0a21397919
93 módosított fájl, 3988 hozzáadás és 4451 törlés
  1. 1 1
      cmd_backend_only/main.go
  2. 1 15
      config.yaml
  3. 0 17
      deploy/kubernetes/server/gva-server-configmap.yaml
  4. 13 3
      frontend/src/App.vue
  5. 12 0
      frontend/src/api/gate.js
  6. 23 12
      frontend/src/api/vehicle.js
  7. 56 0
      frontend/src/lang/en.js
  8. 56 0
      frontend/src/lang/zh-CN.js
  9. 62 0
      frontend/src/lang/zh-HK.js
  10. 62 0
      frontend/src/lang/zh-TW.js
  11. 46 8
      frontend/src/view/dashboard/index.vue
  12. 929 219
      frontend/src/view/parking/entryExit.vue
  13. 1 1
      go.mod
  14. 7 2
      internal/api/v1/parking/channel_event.go
  15. 49 0
      internal/api/v1/parking/gate.go
  16. 5 21
      internal/api/v1/vehicle/vehicle.go
  17. 1 20
      internal/config.docker.yaml
  18. 0 15
      internal/config.yaml
  19. 1 0
      internal/config/system.go
  20. 3 3
      internal/dao/vehicle_record.go
  21. 5 0
      internal/initialize/gorm.go
  22. 63 0
      internal/initialize/operation_permission_seed.go
  23. 44 0
      internal/initialize/operation_permission_seed_test.go
  24. 45 0
      internal/initialize/payment_seed_test.go
  25. 6 0
      internal/initialize/seed.go
  26. 11 0
      internal/initialize/uhf.go
  27. 1 1
      internal/main.go
  28. 32 13
      internal/model/common/passage.go
  29. 11 15
      internal/model/vehicle/request/vehicle.go
  30. 23 6
      internal/model/vehicle/response/vehicle.go
  31. 15 28
      internal/modules/digital-ticket/api.go
  32. 19 1
      internal/modules/digital-ticket/repository/repo.go
  33. 123 0
      internal/modules/digital-ticket/service/edge_cases_test.go
  34. 92 1
      internal/modules/digital-ticket/service/service.go
  35. 11 1
      internal/modules/digital-ticket/service/service_test.go
  36. 7 1
      internal/modules/monthly/repository/repo.go
  37. 53 0
      internal/modules/monthly/repository/repo_test.go
  38. 35 0
      internal/modules/parking-session/model/request/session.go
  39. 66 0
      internal/modules/parking-session/repository/repo.go
  40. 305 0
      internal/modules/parking-session/service/service.go
  41. 77 0
      internal/modules/parking-session/service/service_test.go
  42. 39 1
      internal/modules/payment/api.go
  43. 137 0
      internal/modules/payment/service/edge_cases_test.go
  44. 39 12
      internal/modules/payment/service/service.go
  45. 14 2
      internal/modules/payment/service/service_test.go
  46. 0 47
      internal/pkg/ast/ast.go
  47. 0 47
      internal/pkg/ast/ast_auto_enter.go
  48. 0 181
      internal/pkg/ast/ast_enter.go
  49. 0 166
      internal/pkg/ast/ast_gorm.go
  50. 0 173
      internal/pkg/ast/ast_rollback.go
  51. 0 135
      internal/pkg/ast/ast_router.go
  52. 0 7
      internal/resource/autocode_template/readme.txt.tpl
  53. 0 202
      internal/resource/autocode_template/server/api.go.tpl
  54. 0 45
      internal/resource/autocode_template/server/model.go.tpl
  55. 0 45
      internal/resource/autocode_template/server/request.go.tpl
  56. 0 35
      internal/resource/autocode_template/server/router.go.tpl
  57. 0 151
      internal/resource/autocode_template/server/service.go.tpl
  58. 0 4
      internal/resource/autocode_template/subcontract/api_enter.go.tpl
  59. 0 14
      internal/resource/autocode_template/subcontract/data.go
  60. 0 4
      internal/resource/autocode_template/subcontract/router_enter.go.tpl
  61. 0 6
      internal/resource/autocode_template/subcontract/service_enter.go.tpl
  62. 0 113
      internal/resource/autocode_template/web/api.js.tpl
  63. 0 221
      internal/resource/autocode_template/web/form.vue.tpl
  64. 0 685
      internal/resource/autocode_template/web/table.vue.tpl
  65. 6 0
      internal/router/parking/parking.go
  66. 0 1
      internal/router/vehicle/vehicle.go
  67. 19 0
      internal/router/vehicle/vehicle_test.go
  68. 182 0
      internal/service/parking/gate.go
  69. 73 0
      internal/service/parking/gate_test.go
  70. 84 44
      internal/service/parking/passage.go
  71. 21 1
      internal/service/system/sys_dashboard.go
  72. 75 18
      internal/service/uhf/reader.go
  73. 34 0
      internal/service/uhf/reader_event_test.go
  74. 36 0
      internal/service/uhf/reader_gate_test.go
  75. 87 0
      internal/service/vehicle/parking_session_test.go
  76. 178 0
      internal/service/vehicle/payment_edge_test.go
  77. 16 5
      internal/service/vehicle/shortlist.go
  78. 76 0
      internal/service/vehicle/shortlist_test.go
  79. 335 127
      internal/service/vehicle/vehicle.go
  80. 0 47
      internal/utils/ast/ast.go
  81. 0 47
      internal/utils/ast/ast_auto_enter.go
  82. 0 181
      internal/utils/ast/ast_enter.go
  83. 0 166
      internal/utils/ast/ast_gorm.go
  84. 0 173
      internal/utils/ast/ast_rollback.go
  85. 0 135
      internal/utils/ast/ast_router.go
  86. 0 47
      pkg/utils/ast/ast.go
  87. 0 47
      pkg/utils/ast/ast_auto_enter.go
  88. 0 181
      pkg/utils/ast/ast_enter.go
  89. 0 166
      pkg/utils/ast/ast_gorm.go
  90. 0 173
      pkg/utils/ast/ast_rollback.go
  91. 0 135
      pkg/utils/ast/ast_router.go
  92. 164 56
      test/testutil/e2e_edge_cases_test.go
  93. 1 1
      test/uhf_reader_test.go

+ 1 - 1
cmd_backend_only/main.go

@@ -17,7 +17,7 @@ func main() {
 		initialize.RegisterTables()
 		initialize.SeedSystemData()
 	}
-	initialize.InitUHFDevices()
+	initialize.InitGateController()
 
 	// 只启动 Gin 后端,不启动 Wails
 	core.RunWindowsServer()

+ 1 - 15
config.yaml

@@ -5,21 +5,6 @@ aliyun-oss:
     bucket-name: yourBucketName
     bucket-url: yourBucketUrl
     base-path: yourBasePath
-autocode:
-    server-model: /model/%s
-    server-router: /router/%s
-    server: /server
-    server-api: /api/v1/%s
-    server-plug: /plugin/%s
-    server-initialize: /initialize
-    root: E:\GoWorks\src\lc_finance
-    web-table: /view
-    web: /web/src
-    server-service: /service/%s
-    server-request: /model/%s/request/
-    web-api: /api
-    web-form: /view
-    transfer-restart: true
 aws-s3:
     bucket: xxxxx-10005608
     region: ap-shanghai
@@ -203,6 +188,7 @@ system:
     use-multipoint: false
     use-redis: false
     use-mongo: false
+    gate-simulator: true # 本地无硬件联调;生产环境必须关闭
 tencent-cos:
     bucket: xxxxx-10005608
     region: ap-shanghai

+ 0 - 17
deploy/kubernetes/server/gva-server-configmap.yaml

@@ -82,23 +82,6 @@ data:
     local:
       path: 'uploads/file'
 
-    # autocode configuration
-    autocode:
-      transfer-restart: true
-      root: ""
-      server: /server
-      server-api: /api/v1/autocode
-      server-initialize: /initialize
-      server-model: /model/autocode
-      server-request: /model/autocode/request/
-      server-router: /router/autocode
-      server-service: /service/autocode
-      web: /web/src
-      web-api: /api
-      web-flow: /view
-      web-form: /view
-      web-table: /view
-
     # qiniu configuration (请自行七牛申请对应的 公钥 私钥 bucket 和 域名地址)
     qiniu:
       zone: 'ZoneHuaDong'

+ 13 - 3
frontend/src/App.vue

@@ -1,20 +1,30 @@
 <template>
   <div id="app">
-    <el-config-provider :locale="zhCn">
+    <el-config-provider :locale="elementLocale">
       <router-view />
     </el-config-provider>
   </div>
 </template>
 
 <script setup>
+import { computed } from 'vue'
 import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
-// element 2.3.8之前使用下面的语句
-// import zhCn from 'element-plus/lib/locale/lang/zh-cn'
+import zhHk from 'element-plus/dist/locale/zh-hk.mjs'
+import zhTw from 'element-plus/dist/locale/zh-tw.mjs'
+import en from 'element-plus/dist/locale/en.mjs'
+import { locale } from '@/lang'
 
 defineOptions({
   name: 'App'
 })
 
+const elementLocale = computed(() => ({
+  'zh-CN': zhCn,
+  'zh-HK': zhHk,
+  'zh-TW': zhTw,
+  en
+}[locale.lang] || zhCn))
+
 </script>
 <style lang="scss">
 @tailwind base;

+ 12 - 0
frontend/src/api/gate.js

@@ -0,0 +1,12 @@
+import service from '@/utils/request'
+
+export const getGateDevices = () => service({
+  url: '/parking/gate/devices',
+  method: 'get'
+})
+
+export const controlGate = (action, data) => service({
+  url: `/parking/gate/${action}`,
+  method: 'post',
+  data
+})

+ 23 - 12
frontend/src/api/vehicle.js

@@ -41,6 +41,24 @@ export const obtainVehicleLicense = (number) => {
   })
 }
 
+export const obtainVehicleIdentity = (plateNumber, rfidTag) => service({
+  url: '/vehicle/get-by-plate',
+  method: 'get',
+  params: { plate_number: plateNumber, rfid_tag: rfidTag }
+})
+
+export const getVehicleOperationContext = (data) => service({
+  url: '/vehicle/operation/context',
+  method: 'post',
+  data
+})
+
+export const handleVehiclePassage = (data) => service({
+  url: '/vehicle/passage',
+  method: 'post',
+  data
+})
+
 // 获取车辆列表
 export const obtainVehicleList = (params) => {
   return service({
@@ -68,15 +86,6 @@ export const vehicleEntry = (data) => {
   })
 }
 
-// 车辆出场
-export const vehicleAppear = (data) => {
-  return service({
-    url: '/vehicle/exit',
-    method: 'post',
-    data
-  })
-}
-
 // 创建收费配置
 export const createCharge = (data) => {
   return service({
@@ -198,9 +207,11 @@ export const getInParkVehicles = (params) => {
 }
 
 // 通道事件(RFID实时扫描)
-export const getChannelEvents = (after) => {
+export const getChannelEvents = (afterID) => {
   return service({
-    url: '/channel/events?after=' + (after || 0),
-    method: 'get'
+    url: '/channel/events',
+    method: 'get',
+    params: { after_id: afterID || 0 },
+    donNotShowLoading: true
   })
 }

+ 56 - 0
frontend/src/lang/en.js

@@ -222,6 +222,62 @@ export default {
   change: 'Change',
   confirmPayAndExit: 'Confirm & Release',
   insufficientAmount: 'Insufficient amount',
+	workbenchTitle: 'Entry and Exit Desk',
+	workbenchSubtitle: 'Current vehicle, parking session, and settlement status',
+	workbenchSyncOk: 'Channel events online',
+	workbenchSyncError: 'Channel events offline',
+	workbenchVehicleIdentity: 'Vehicle identity',
+	workbenchIdentityTitle: 'Locate a vehicle or parking session',
+	workbenchChannelEvents: 'Channel records',
+	workbenchRecentEvents: 'Recent passage results',
+	workbenchRefreshEvents: 'Refresh channel records',
+	workbenchNoEvents: 'No passage records',
+	workbenchCurrentSession: 'Current parking session',
+	workbenchNextAction: 'Next action',
+	workbenchReadyEntry: 'Ready for entry',
+	workbenchReadyExit: 'Ready for exit',
+	workbenchIdleTitle: 'Waiting for vehicle identity',
+	workbenchIdleHint: 'Enter a plate number or RFID, or use a recognition device',
+	workbenchContextFailed: 'Failed to load vehicle status',
+	workbenchRetry: 'Try again',
+	workbenchUnknownPlate: 'No plate',
+	workbenchRegisteredVehicle: 'Registered vehicle',
+	workbenchVehicleColor: 'Vehicle color',
+	workbenchVehicleBrand: 'Vehicle brand',
+	workbenchNextEntry: 'Entry registration',
+	workbenchSessionId: 'Session ID',
+	workbenchGateDevice: 'Gate control',
+	workbenchGateDeviceTitle: 'Passage gate',
+	workbenchGatePlaceholder: 'Select a gate device',
+	workbenchGateOnline: 'Online',
+	workbenchGateSimulated: 'Simulator online',
+	workbenchGateOffline: 'Offline',
+	workbenchNoGateDevices: 'No online gate is available for this direction',
+	workbenchManualOpenGate: 'Open gate manually',
+	workbenchManualCloseGate: 'Close gate manually',
+	workbenchGateRequired: 'Select an online gate device first',
+	workbenchGateConfirmTitle: 'Confirm gate operation',
+	workbenchOpenGateConfirm: 'Open the selected gate now?',
+	workbenchCloseGateConfirm: 'Close the selected gate now? Confirm that the vehicle has passed safely.',
+	workbenchGateOpenSuccess: 'Open command sent',
+	workbenchGateCloseSuccess: 'Close command sent',
+	workbenchGateOperationFailed: 'Gate operation failed',
+	workbenchBusinessDoneGateFailed: 'Business completed, but the gate did not open',
+	workbenchPaymentUnavailable: 'No payment method is available at this counter',
+	workbenchExactAmount: 'Use amount due',
+	workbenchPaidRelease: 'Payment completed, ready to release',
+	workbenchFreeRelease: 'No payment required, ready to release',
+	workbenchZeroFee: 'The amount due is zero',
+	workbenchCurrentDue: 'Amount due',
+	workbenchConfirmPaidExit: 'Confirm release',
+	workbenchConfirmFreeExit: 'Release without payment',
+	workbenchConfirmMessage: 'Vehicle {plate}: due ¥{due}, received ¥{received}. Complete settlement and release?',
+	workbenchEntryCompleted: 'Entered',
+	workbenchExitCompleted: 'Exited',
+	workbenchEntryResult: 'Parking session and digital ticket created for {plate}',
+	workbenchExitResult: '{plate} exited. Charged ¥{fee}',
+	workbenchHour: 'h',
+	workbenchMinute: 'm',
   paymentRecord: 'Payment Record',
   monthlyCard: 'Monthly Card',
   shiftRecord: 'Shift Record',

+ 56 - 0
frontend/src/lang/zh-CN.js

@@ -222,6 +222,62 @@ export default {
   change: '找零',
   confirmPayAndExit: '确认收款并放行',
   insufficientAmount: '实收金额不足',
+	workbenchTitle: '进出场工作台',
+	workbenchSubtitle: '当前车辆、停车会话与结算状态',
+	workbenchSyncOk: '通道事件正常',
+	workbenchSyncError: '通道事件中断',
+	workbenchVehicleIdentity: '车辆识别',
+	workbenchIdentityTitle: '定位车辆或停车会话',
+	workbenchChannelEvents: '通道记录',
+	workbenchRecentEvents: '近期通行结果',
+	workbenchRefreshEvents: '刷新通道记录',
+	workbenchNoEvents: '暂无通行记录',
+	workbenchCurrentSession: '当前停车会话',
+	workbenchNextAction: '下一步操作',
+	workbenchReadyEntry: '待入场',
+	workbenchReadyExit: '待出场',
+	workbenchIdleTitle: '等待车辆识别',
+	workbenchIdleHint: '输入车牌号、RFID,或使用识别设备开始处理',
+	workbenchContextFailed: '车辆状态查询失败',
+	workbenchRetry: '重新查询',
+	workbenchUnknownPlate: '无牌车辆',
+	workbenchRegisteredVehicle: '登记车辆',
+	workbenchVehicleColor: '车辆颜色',
+	workbenchVehicleBrand: '车辆品牌',
+	workbenchNextEntry: '入场登记',
+	workbenchSessionId: '会话编号',
+	workbenchGateDevice: '道闸控制',
+	workbenchGateDeviceTitle: '本次通行设备',
+	workbenchGatePlaceholder: '请选择道闸设备',
+	workbenchGateOnline: '设备在线',
+	workbenchGateSimulated: '模拟在线',
+	workbenchGateOffline: '设备离线',
+	workbenchNoGateDevices: '当前方向没有在线且可用的道闸设备',
+	workbenchManualOpenGate: '人工开闸',
+	workbenchManualCloseGate: '人工关闸',
+	workbenchGateRequired: '请先选择在线的道闸设备',
+	workbenchGateConfirmTitle: '确认道闸操作',
+	workbenchOpenGateConfirm: '确认立即打开当前道闸?',
+	workbenchCloseGateConfirm: '确认立即关闭当前道闸?请先确认车辆已安全通过。',
+	workbenchGateOpenSuccess: '开闸指令已发送',
+	workbenchGateCloseSuccess: '关闸指令已发送',
+	workbenchGateOperationFailed: '道闸操作失败',
+	workbenchBusinessDoneGateFailed: '业务已完成,但道闸未打开',
+	workbenchPaymentUnavailable: '收费岗亭没有可用的支付方式',
+	workbenchExactAmount: '按应收金额',
+	workbenchPaidRelease: '已完成支付,可以放行',
+	workbenchFreeRelease: '本次无需支付,可以放行',
+	workbenchZeroFee: '应付金额为零',
+	workbenchCurrentDue: '本次应收',
+	workbenchConfirmPaidExit: '确认放行',
+	workbenchConfirmFreeExit: '免付放行',
+	workbenchConfirmMessage: '车辆 {plate},应收 ¥{due},实收 ¥{received}。确认完成结算并放行?',
+	workbenchEntryCompleted: '已进场',
+	workbenchExitCompleted: '已出场',
+	workbenchEntryResult: '车辆 {plate} 的停车会话和数字票已创建',
+	workbenchExitResult: '车辆 {plate} 已完成出场,本次收费 ¥{fee}',
+	workbenchHour: '小时',
+	workbenchMinute: '分钟',
   paymentRecord: '收费记录',
   monthlyCard: '包月车管理',
   shiftRecord: '交接班管理',

+ 62 - 0
frontend/src/lang/zh-HK.js

@@ -214,10 +214,72 @@ export default {
   pleaseSelectPark: '請選擇停車場',
   rfidInput: '請輸入RFID標籤',
   exitRecordDetail: '進場: {entryTime} | 停留: {duration} | 費用: ¥{fee}',
+  cashReceived: '實收金額',
+  change: '找零',
+  confirmPayAndExit: '確認收款並放行',
+  insufficientAmount: '實收金額不足',
+	workbenchTitle: '進出場工作台',
+	workbenchSubtitle: '目前車輛、停車會話及結算狀態',
+	workbenchSyncOk: '通道事件正常',
+	workbenchSyncError: '通道事件中斷',
+	workbenchVehicleIdentity: '車輛識別',
+	workbenchIdentityTitle: '定位車輛或停車會話',
+	workbenchChannelEvents: '通道記錄',
+	workbenchRecentEvents: '近期通行結果',
+	workbenchRefreshEvents: '重新整理通道記錄',
+	workbenchNoEvents: '暫無通行記錄',
+	workbenchCurrentSession: '目前停車會話',
+	workbenchNextAction: '下一步操作',
+	workbenchReadyEntry: '待入場',
+	workbenchReadyExit: '待出場',
+	workbenchIdleTitle: '等待車輛識別',
+	workbenchIdleHint: '輸入車牌號碼、RFID,或使用識別裝置開始處理',
+	workbenchContextFailed: '車輛狀態查詢失敗',
+	workbenchRetry: '重新查詢',
+	workbenchUnknownPlate: '無牌車輛',
+	workbenchRegisteredVehicle: '登記車輛',
+	workbenchVehicleColor: '車輛顏色',
+	workbenchVehicleBrand: '車輛品牌',
+	workbenchNextEntry: '入場登記',
+	workbenchSessionId: '會話編號',
+	workbenchGateDevice: '道閘控制',
+	workbenchGateDeviceTitle: '今次通行設備',
+	workbenchGatePlaceholder: '請選擇道閘設備',
+	workbenchGateOnline: '設備在線',
+	workbenchGateSimulated: '模擬在線',
+	workbenchGateOffline: '設備離線',
+	workbenchNoGateDevices: '目前方向沒有在線及可用的道閘設備',
+	workbenchManualOpenGate: '人工開閘',
+	workbenchManualCloseGate: '人工關閘',
+	workbenchGateRequired: '請先選擇在線的道閘設備',
+	workbenchGateConfirmTitle: '確認道閘操作',
+	workbenchOpenGateConfirm: '確認立即開啟目前道閘?',
+	workbenchCloseGateConfirm: '確認立即關閉目前道閘?請先確認車輛已安全通過。',
+	workbenchGateOpenSuccess: '開閘指令已傳送',
+	workbenchGateCloseSuccess: '關閘指令已傳送',
+	workbenchGateOperationFailed: '道閘操作失敗',
+	workbenchBusinessDoneGateFailed: '業務已完成,但道閘未開啟',
+	workbenchPaymentUnavailable: '收費崗亭沒有可用的付款方式',
+	workbenchExactAmount: '按應收金額',
+	workbenchPaidRelease: '已完成付款,可以放行',
+	workbenchFreeRelease: '今次無需付款,可以放行',
+	workbenchZeroFee: '應付金額為零',
+	workbenchCurrentDue: '今次應收',
+	workbenchConfirmPaidExit: '確認放行',
+	workbenchConfirmFreeExit: '免付放行',
+	workbenchConfirmMessage: '車輛 {plate},應收 ¥{due},實收 ¥{received}。確認完成結算並放行?',
+	workbenchEntryCompleted: '已進場',
+	workbenchExitCompleted: '已出場',
+	workbenchEntryResult: '車輛 {plate} 的停車會話及數碼票已建立',
+	workbenchExitResult: '車輛 {plate} 已完成出場,今次收費 ¥{fee}',
+	workbenchHour: '小時',
+	workbenchMinute: '分鐘',
   paymentRecord: '收費記錄',
   monthlyCard: '包月車管理',
   shiftRecord: '交接班管理',
 	revenueReport: '收入報表',
+	paymentMethodConfig: '付款方式',
+	paymentEntryConfig: '付款入口',
 	digitalTicket: '數碼票',
 	digitalTicketEyebrow: 'DIGITAL PASS DESK',
 	digitalTicketSubtitle: '統一查看入場憑證、付款狀態及離場進度',

+ 62 - 0
frontend/src/lang/zh-TW.js

@@ -214,10 +214,72 @@ export default {
   pleaseSelectPark: '請選擇停車場',
   rfidInput: '請輸入RFID標籤',
   exitRecordDetail: '進場: {entryTime} | 停留: {duration} | 費用: ¥{fee}',
+  cashReceived: '實收金額',
+  change: '找零',
+  confirmPayAndExit: '確認收款並放行',
+  insufficientAmount: '實收金額不足',
+	workbenchTitle: '進出場工作台',
+	workbenchSubtitle: '目前車輛、停車會話與結算狀態',
+	workbenchSyncOk: '通道事件正常',
+	workbenchSyncError: '通道事件中斷',
+	workbenchVehicleIdentity: '車輛識別',
+	workbenchIdentityTitle: '定位車輛或停車會話',
+	workbenchChannelEvents: '通道記錄',
+	workbenchRecentEvents: '近期通行結果',
+	workbenchRefreshEvents: '重新整理通道記錄',
+	workbenchNoEvents: '暫無通行記錄',
+	workbenchCurrentSession: '目前停車會話',
+	workbenchNextAction: '下一步操作',
+	workbenchReadyEntry: '待入場',
+	workbenchReadyExit: '待出場',
+	workbenchIdleTitle: '等待車輛識別',
+	workbenchIdleHint: '輸入車牌號碼、RFID,或使用識別裝置開始處理',
+	workbenchContextFailed: '車輛狀態查詢失敗',
+	workbenchRetry: '重新查詢',
+	workbenchUnknownPlate: '無牌車輛',
+	workbenchRegisteredVehicle: '登記車輛',
+	workbenchVehicleColor: '車輛顏色',
+	workbenchVehicleBrand: '車輛品牌',
+	workbenchNextEntry: '入場登記',
+	workbenchSessionId: '會話編號',
+	workbenchGateDevice: '柵欄機控制',
+	workbenchGateDeviceTitle: '本次通行設備',
+	workbenchGatePlaceholder: '請選擇柵欄機設備',
+	workbenchGateOnline: '設備在線',
+	workbenchGateSimulated: '模擬在線',
+	workbenchGateOffline: '設備離線',
+	workbenchNoGateDevices: '目前方向沒有在線且可用的柵欄機設備',
+	workbenchManualOpenGate: '人工開啟',
+	workbenchManualCloseGate: '人工關閉',
+	workbenchGateRequired: '請先選擇在線的柵欄機設備',
+	workbenchGateConfirmTitle: '確認柵欄機操作',
+	workbenchOpenGateConfirm: '確認立即開啟目前柵欄機?',
+	workbenchCloseGateConfirm: '確認立即關閉目前柵欄機?請先確認車輛已安全通過。',
+	workbenchGateOpenSuccess: '開啟指令已傳送',
+	workbenchGateCloseSuccess: '關閉指令已傳送',
+	workbenchGateOperationFailed: '柵欄機操作失敗',
+	workbenchBusinessDoneGateFailed: '業務已完成,但柵欄機未開啟',
+	workbenchPaymentUnavailable: '收費崗亭沒有可用的付款方式',
+	workbenchExactAmount: '按應收金額',
+	workbenchPaidRelease: '已完成付款,可以放行',
+	workbenchFreeRelease: '本次無需付款,可以放行',
+	workbenchZeroFee: '應付金額為零',
+	workbenchCurrentDue: '本次應收',
+	workbenchConfirmPaidExit: '確認放行',
+	workbenchConfirmFreeExit: '免付放行',
+	workbenchConfirmMessage: '車輛 {plate},應收 ¥{due},實收 ¥{received}。確認完成結算並放行?',
+	workbenchEntryCompleted: '已進場',
+	workbenchExitCompleted: '已出場',
+	workbenchEntryResult: '車輛 {plate} 的停車會話與數位票已建立',
+	workbenchExitResult: '車輛 {plate} 已完成出場,本次收費 ¥{fee}',
+	workbenchHour: '小時',
+	workbenchMinute: '分鐘',
   paymentRecord: '收費記錄',
   monthlyCard: '包月車管理',
   shiftRecord: '交接班管理',
 	revenueReport: '收入報表',
+	paymentMethodConfig: '付款方式',
+	paymentEntryConfig: '付款入口',
 	digitalTicket: '數位票',
 	digitalTicketEyebrow: 'DIGITAL PASS DESK',
 	digitalTicketSubtitle: '統一查看入場憑證、付款狀態與離場進度',

+ 46 - 8
frontend/src/view/dashboard/index.vue

@@ -129,7 +129,9 @@
           </div>
           <div class="device-summary">
             <span class="summary-item online"><i />在线 {{ onlineDeviceCount }}</span>
+            <span v-if="simulatedDeviceCount" class="summary-item simulated"><i />模拟 {{ simulatedDeviceCount }}</span>
             <span class="summary-item offline"><i />离线 {{ offlineDeviceCount }}</span>
+            <span v-if="unboundDeviceCount" class="summary-item unbound"><i />未绑定 {{ unboundDeviceCount }}</span>
           </div>
         </div>
 
@@ -147,11 +149,11 @@
               <tr
                 v-for="device in deviceList"
                 :key="`${device.channel_name}-${device.device_name}`"
-                :class="{ offline: !isDeviceOnline(device) }"
+                :class="deviceStatusClass(device)"
               >
                 <td>
                   <span class="channel-name">
-                    <i :class="{ offline: !isDeviceOnline(device) }" />
+                    <i :class="deviceStatusClass(device)" />
                     {{ device.channel_name || '-' }}
                   </span>
                 </td>
@@ -164,8 +166,8 @@
                 </td>
                 <td class="device-name">{{ device.device_name || '-' }}</td>
                 <td>
-                  <span class="device-status" :class="{ offline: !isDeviceOnline(device) }">
-                    {{ isDeviceOnline(device) ? '在线' : '离线' }}
+                  <span class="device-status" :class="deviceStatusClass(device)">
+                    {{ deviceStatusLabel(device) }}
                   </span>
                 </td>
               </tr>
@@ -230,8 +232,10 @@ const progressColor = computed(() => {
   return '#2563eb'
 })
 const deviceList = computed(() => Array.isArray(data.devices) ? data.devices : [])
-const onlineDeviceCount = computed(() => deviceList.value.filter(isDeviceOnline).length)
-const offlineDeviceCount = computed(() => deviceList.value.length - onlineDeviceCount.value)
+const onlineDeviceCount = computed(() => deviceList.value.filter(device => device.device_status === 'online').length)
+const simulatedDeviceCount = computed(() => deviceList.value.filter(device => device.device_status === 'simulated').length)
+const offlineDeviceCount = computed(() => deviceList.value.filter(device => device.device_status === 'offline').length)
+const unboundDeviceCount = computed(() => deviceList.value.filter(device => device.device_status === 'unbound').length)
 const entryChannelCount = computed(() => deviceList.value.filter(device => device.direction === 'in').length)
 const exitChannelCount = computed(() => deviceList.value.filter(device => device.direction !== 'in').length)
 const statusText = computed(() => {
@@ -245,8 +249,18 @@ const updateText = computed(() => {
   return '正在获取数据'
 })
 
-function isDeviceOnline(device) {
-  return device.device_status === 'online'
+function deviceStatusClass(device) {
+  return device.device_status || 'offline'
+}
+
+function deviceStatusLabel(device) {
+  const labels = {
+    online: '在线',
+    simulated: '模拟在线',
+    offline: '离线',
+    unbound: '未绑定',
+  }
+  return labels[deviceStatusClass(device)] || '未知'
 }
 
 function formatNum(value) {
@@ -788,6 +802,16 @@ onUnmounted(() => {
   background: var(--red);
 }
 
+.summary-item.simulated i,
+.channel-name i.simulated {
+  background: var(--amber);
+}
+
+.summary-item.unbound i,
+.channel-name i.unbound {
+  background: #98a2b3;
+}
+
 .device-table-wrap {
   overflow-x: auto;
 }
@@ -832,6 +856,10 @@ onUnmounted(() => {
   background: #fffafa;
 }
 
+.device-table tbody tr.unbound td {
+  background: #fafbfc;
+}
+
 .channel-name {
   gap: 9px;
   color: #344054;
@@ -875,6 +903,16 @@ onUnmounted(() => {
   background: #fff1f2;
 }
 
+.device-status.simulated {
+  color: #b54708;
+  background: #fff7ed;
+}
+
+.device-status.unbound {
+  color: #667085;
+  background: #f2f4f7;
+}
+
 .device-empty {
   display: flex;
   flex-direction: column;

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 929 - 219
frontend/src/view/parking/entryExit.vue


+ 1 - 1
go.mod

@@ -40,7 +40,6 @@ require (
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.51.0
 	golang.org/x/sync v0.20.0
-	golang.org/x/text v0.37.0
 	gorm.io/driver/mysql v1.6.0
 	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlserver v1.6.3
@@ -151,6 +150,7 @@ require (
 	golang.org/x/image v0.40.0 // indirect
 	golang.org/x/net v0.54.0 // indirect
 	golang.org/x/sys v0.44.0 // indirect
+	golang.org/x/text v0.37.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
 	golang.org/x/tools v0.44.0 // indirect
 	google.golang.org/protobuf v1.36.6 // indirect

+ 7 - 2
internal/api/v1/parking/channel_event.go

@@ -11,8 +11,11 @@ import (
 )
 
 func GetChannelEvents(c *gin.Context) {
-	after, _ := strconv.ParseInt(c.Query("after"), 10, 64)
-	events := uhf.GetChannelEvents(after)
+	afterID, _ := strconv.ParseUint(c.Query("after_id"), 10, 64)
+	if afterID == 0 {
+		afterID, _ = strconv.ParseUint(c.Query("after"), 10, 64)
+	}
+	events := uhf.GetChannelEvents(afterID)
 	response.OkWithData(events, c)
 }
 
@@ -42,6 +45,8 @@ func SimulateEvent(c *gin.Context) {
 		Direction:   direction,
 		ChannelName: channelName,
 		Timestamp:   time.Now().Unix(),
+		Status:      "completed",
+		Message:     "模拟通道事件",
 	})
 	response.OkWithMessage("事件已推送", c)
 }

+ 49 - 0
internal/api/v1/parking/gate.go

@@ -0,0 +1,49 @@
+package parking
+
+import (
+	"github.com/gin-gonic/gin"
+
+	commonModel "wails-app/internal/model/common"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/service"
+)
+
+var passageService = &service.ServiceGroupApp.ParkingServiceGroup.PassageService
+
+// ListGateDevices 返回进出场工作台可用的道闸设备。
+func ListGateDevices(c *gin.Context) {
+	devices, err := passageService.ListGateDevices()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(devices, c)
+}
+
+// OpenGate 人工开闸。
+func OpenGate(c *gin.Context) {
+	var req commonModel.GateControlRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := passageService.OpenGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "open"}, "开闸成功", c)
+}
+
+// CloseGate 人工关闸。
+func CloseGate(c *gin.Context) {
+	var req commonModel.GateControlRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := passageService.CloseGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "close"}, "关闸成功", c)
+}

+ 5 - 21
internal/api/v1/vehicle/vehicle.go

@@ -62,15 +62,16 @@ func GetVehicleByID(c *gin.Context) {
 	response.OkWithData(vehicleInfo, c)
 }
 
-// GetVehicleByPlateNumber 根据车牌号获取车辆信息
+// GetVehicleByPlateNumber 根据车牌号或 RFID 获取车辆信息。
 func GetVehicleByPlateNumber(c *gin.Context) {
 	plateNumber := c.Query("plate_number")
-	if plateNumber == "" {
-		response.FailWithMessage("车牌号不能为空", c)
+	rfidTag := c.Query("rfid_tag")
+	if plateNumber == "" && rfidTag == "" {
+		response.FailWithMessage("车牌号和RFID不能同时为空", c)
 		return
 	}
 
-	vehicleInfo, err := vehicleService.GetVehicleByPlateNumber(plateNumber, "")
+	vehicleInfo, err := vehicleService.GetVehicleByPlateNumber(plateNumber, rfidTag)
 	if err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
@@ -144,23 +145,6 @@ func VehicleEntry(c *gin.Context) {
 	response.OkWithData(resp, c)
 }
 
-// VehicleExit 车辆出场
-func VehicleExit(c *gin.Context) {
-	var req request.VehicleExit
-	if err := c.ShouldBindJSON(&req); err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-
-	resp, err := vehicleService.VehicleExit(req)
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-
-	response.OkWithData(resp, c)
-}
-
 // GetInParkVehicles 获取所有在场车辆
 func GetInParkVehicles(c *gin.Context) {
 	lotID, _ := strconv.Atoi(c.Query("parking_lot_id"))

+ 1 - 20
internal/config.docker.yaml

@@ -120,25 +120,6 @@ local:
   path: uploads/file
   store-path: uploads/file
 
-# autocode configuration
-autocode:
-  transfer-restart: true
-  # root 自动适配项目根目录
-  # 请不要手动配置,他会在项目加载的时候识别出根路径
-  root: ""
-  server: /server
-  server-plug: /plugin/%s
-  server-api: /api/v1/%s
-  server-initialize: /initialize
-  server-model: /model/%s
-  server-request: /model/%s/request/
-  server-router: /router/%s
-  server-service: /service/%s
-  web: /web/src
-  web-api: /api
-  web-form: /view
-  web-table: /view
-
 # qiniu configuration (请自行七牛申请对应的 公钥 私钥 bucket 和 域名地址)
 qiniu:
   zone: ZoneHuaDong
@@ -217,4 +198,4 @@ cors:
       allow-headers: content-type
       allow-methods: GET, POST
       expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
-      allow-credentials: true # 布尔值
+      allow-credentials: true # 布尔值

+ 0 - 15
internal/config.yaml

@@ -5,21 +5,6 @@ aliyun-oss:
     bucket-name: yourBucketName
     bucket-url: yourBucketUrl
     base-path: yourBasePath
-autocode:
-    server-model: /model/%s
-    server-router: /router/%s
-    server: /server
-    server-api: /api/v1/%s
-    server-plug: /plugin/%s
-    server-initialize: /initialize
-    root: E:\GoWorks\src\lc_finance
-    web-table: /view
-    web: /web/src
-    server-service: /service/%s
-    server-request: /model/%s/request/
-    web-api: /api
-    web-form: /view
-    transfer-restart: true
 aws-s3:
     bucket: xxxxx-10005608
     region: ap-shanghai

+ 1 - 0
internal/config/system.go

@@ -10,4 +10,5 @@ type System struct {
 	UseMultipoint bool   `mapstructure:"use-multipoint" json:"use-multipoint" yaml:"use-multipoint"` // 多点登录拦截
 	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"` // 仅用于无硬件联调的模拟道闸
 }

+ 3 - 3
internal/dao/vehicle_record.go

@@ -7,9 +7,9 @@ import (
 
 type VehicleRecord struct {
 	global.GVA_MODEL
-	PlateNumber    string     `gorm:"size:20;" json:"plate_number"`                   // 车牌号
-	RFIDTag        string     `gorm:"size:40;column:rfid_tag" json:"rfid_tag"`        // 标签
-	EntryTime      time.Time  `json:"entry_time" gorm:"default:null"`                 // 入场时间
+	PlateNumber    string     `gorm:"size:20;index:idx_vehicle_record_plate;" json:"plate_number"`    // 车牌号
+	RFIDTag        string     `gorm:"size:40;column:rfid_tag" json:"rfid_tag"`                       // 标签
+	EntryTime      time.Time  `json:"entry_time" gorm:"default:null;index:idx_vehicle_record_entry_time;"` // 入场时间
 	ExitTime       *time.Time `json:"exit_time" gorm:"default:null"`                  // 出场时间
 	StayTime       int64      `json:"stay_time"`                                      // 停留时间(分钟)
 	Fee            float64    `json:"fee"`                                            // 费用

+ 5 - 0
internal/initialize/gorm.go

@@ -71,5 +71,10 @@ func RegisterTables() {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))
 		os.Exit(0)
 	}
+	// 操作日志复合索引: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 {
+		global.GVA_LOG.Error("create idx_op_user_time failed", zap.Error(err))
+	}
 	global.GVA_LOG.Info("register table success")
 }

+ 63 - 0
internal/initialize/operation_permission_seed.go

@@ -0,0 +1,63 @@
+package initialize
+
+import (
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// EnsureOperationPermissions upgrades both new and existing databases with the
+// API catalogue and Casbin policies required by the entry/exit operation page.
+func EnsureOperationPermissions() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+
+	apis := []dao.SysApi{
+		{Path: "/vehicle/entry", Description: "车辆入场", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/vehicle/operation/context", Description: "查询车辆操作上下文", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/vehicle/passage", Description: "统一车辆通行", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/channel/events", Description: "查询通道事件", ApiGroup: "进出场操作", Method: "GET"},
+		{Path: "/parking/gate/devices", Description: "查询道闸设备", ApiGroup: "进出场操作", Method: "GET"},
+		{Path: "/parking/gate/open", Description: "人工开闸", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/parking/gate/close", Description: "人工关闸", ApiGroup: "进出场操作", Method: "POST"},
+	}
+	for _, api := range apis {
+		var existing dao.SysApi
+		err := db.Where("path = ? AND method = ?", api.Path, api.Method).First(&existing).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			err = db.Create(&api).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化进出场API %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
+
+	if err := db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
+	)`).Error; err != nil {
+		return fmt.Errorf("初始化Casbin规则表失败: %w", err)
+	}
+
+	for _, role := range []string{"618", "888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/vehicle/entry", "POST"},
+			{"/vehicle/operation/context", "POST"},
+			{"/vehicle/passage", "POST"},
+			{"/channel/events", "GET"},
+			{"/parking/gate/devices", "GET"},
+			{"/parking/gate/open", "POST"},
+			{"/parking/gate/close", "POST"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return fmt.Errorf("初始化角色%s进出场权限失败: %w", role, err)
+			}
+		}
+	}
+	return nil
+}

+ 44 - 0
internal/initialize/operation_permission_seed_test.go

@@ -0,0 +1,44 @@
+package initialize
+
+import (
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.SysApi{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+
+	require.NoError(t, EnsureOperationPermissions())
+	require.NoError(t, EnsureOperationPermissions())
+
+	routes := [][2]string{
+		{"/vehicle/entry", "POST"},
+		{"/vehicle/operation/context", "POST"},
+		{"/vehicle/passage", "POST"},
+		{"/channel/events", "GET"},
+		{"/parking/gate/devices", "GET"},
+		{"/parking/gate/open", "POST"},
+		{"/parking/gate/close", "POST"},
+	}
+	for _, route := range routes {
+		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 ruleCount int64
+		require.NoError(t, db.Table("casbin_rule").
+			Where("v0 IN ? AND v1 = ? AND v2 = ?", []string{"618", "888", "9527"}, route[0], route[1]).
+			Count(&ruleCount).Error)
+		require.EqualValues(t, 3, ruleCount, route)
+	}
+}

+ 45 - 0
internal/initialize/payment_seed_test.go

@@ -50,3 +50,48 @@ func TestEnsurePaymentConfigurationIsIdempotent(t *testing.T) {
 	require.NoError(t, db.Table("casbin_rule").Count(&ruleCount).Error)
 	require.EqualValues(t, 47, ruleCount)
 }
+
+func TestEnsurePaymentConfigurationPermissionMatrix(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+	require.NoError(t, db.AutoMigrate(
+		&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{},
+		&dao.SysBaseMenu{}, &dao.SysAuthorityMenu{}, &dao.SysApi{},
+	))
+	parking := dao.SysBaseMenu{Path: "parking", Name: "parking", Meta: dao.Meta{Title: "停车管理"}}
+	require.NoError(t, db.Create(&parking).Error)
+	require.NoError(t, EnsurePaymentConfiguration())
+
+	assertPolicy := func(role, path, method string, expected bool) {
+		t.Helper()
+		var count int64
+		require.NoError(t, db.Table("casbin_rule").Where(
+			"ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", role, path, method,
+		).Count(&count).Error)
+		require.Equal(t, expected, count > 0, "role=%s path=%s method=%s", role, path, method)
+	}
+
+	for _, role := range []string{"618", "888", "9527"} {
+		assertPolicy(role, "/payment/methods/enabled", "GET", true)
+		assertPolicy(role, "/payment/methods/options", "GET", true)
+		assertPolicy(role, "/payment/entries/enabled", "GET", true)
+		assertPolicy(role, "/payment/entries/options", "GET", true)
+		assertPolicy(role, "/digital-ticket/list", "GET", true)
+		assertPolicy(role, "/digital-ticket/:ticketNo/pay", "POST", true)
+		assertPolicy(role, "/digital-ticket/:ticketNo/exit", "POST", true)
+	}
+
+	for _, role := range []string{"888", "9527"} {
+		assertPolicy(role, "/payment/methods", "POST", true)
+		assertPolicy(role, "/payment/methods/:id/status", "PATCH", true)
+		assertPolicy(role, "/payment/entries", "POST", true)
+		assertPolicy(role, "/payment/entries/:id/status", "PATCH", true)
+	}
+	assertPolicy("618", "/payment/methods", "POST", false)
+	assertPolicy("618", "/payment/methods/:id/status", "PATCH", false)
+	assertPolicy("618", "/payment/entries", "POST", false)
+	assertPolicy("618", "/payment/entries/:id/status", "PATCH", false)
+	assertPolicy("618", "/payment/methods", "DELETE", false)
+}

+ 6 - 0
internal/initialize/seed.go

@@ -23,6 +23,9 @@ func SeedSystemData() {
 		if err := EnsurePaymentConfiguration(); err != nil {
 			global.GVA_LOG.Error("payment configuration upgrade failed")
 		}
+		if err := EnsureOperationPermissions(); err != nil {
+			global.GVA_LOG.Error("operation permission upgrade failed")
+		}
 		return
 	}
 
@@ -530,6 +533,9 @@ func SeedSystemData() {
 	if err := EnsurePaymentConfiguration(); err != nil {
 		global.GVA_LOG.Error("payment configuration seed failed")
 	}
+	if err := EnsureOperationPermissions(); err != nil {
+		global.GVA_LOG.Error("operation permission seed failed")
+	}
 
 	global.GVA_LOG.Info("system data seeded successfully")
 }

+ 11 - 0
internal/initialize/uhf.go

@@ -4,9 +4,20 @@ import (
 	"go.uber.org/zap"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	parkingService "wails-app/internal/service/parking"
 	"wails-app/internal/service/uhf"
 )
 
+// InitGateController 根据配置初始化真实设备或模拟道闸控制器。
+func InitGateController() {
+	if global.GVA_CONFIG.System.GateSimulator {
+		parkingService.SetGateController(parkingService.NewSimulatedGateController())
+		global.GVA_LOG.Warn("道闸模拟模式已启用,不会发送真实设备指令")
+		return
+	}
+	InitUHFDevices()
+}
+
 func InitUHFDevices() {
 	var devices []dao.UHFReader
 	// 从数据库查询所有激活的设备

+ 1 - 1
internal/main.go

@@ -24,6 +24,6 @@ func InitBackend() {
 		initialize.RegisterTables()
 		initialize.SeedSystemData()
 	}
-	initialize.InitUHFDevices()
+	initialize.InitGateController()
 	go core.RunWindowsServer()
 }

+ 32 - 13
internal/model/common/passage.go

@@ -2,21 +2,40 @@ package common
 
 // PassageRequest 统一的进出场请求(所有触发方式共用:RFID、摄像头、手动录入)
 type PassageRequest struct {
-	RFIDTag     string // RFID 标签(UHF 读卡器触发时)
-	PlateNumber string // 车牌号(摄像头/手动触发时)
-	DeviceCode  string // UHF 设备编码(用于定位通道 + 控制道闸)
-	Image       string // 抓拍图片路径
+	RFIDTag       string  `json:"rfid_tag"`       // RFID 标签(UHF 读卡器触发时)
+	PlateNumber   string  `json:"plate_number"`   // 车牌号(摄像头/手动触发时)
+	DeviceCode    string  `json:"device_code"`    // 设备编码(用于定位通道和控制道闸)
+	Direction     string  `json:"direction"`      // 双向通道必须明确 in/out
+	Image         string  `json:"image"`          // 抓拍图片路径
+	ParkingLotID  uint    `json:"parking_lot_id"` // 人工入场选择的停车场
+	SessionID     uint    `json:"session_id"`
+	TicketNo      string  `json:"ticket_no"`
+	PaymentEntry  string  `json:"payment_entry"`
+	PaymentMethod string  `json:"payment_method"`
+	PaidAmount    float64 `json:"paid_amount"`
+	OperatorID    uint    `json:"-"`
+	TriggerSource string  `json:"trigger_source"`
+}
+
+// GateControlRequest 人工开关闸请求。
+type GateControlRequest struct {
+	DeviceCode string `json:"device_code" binding:"required"`
+	ValidTime  byte   `json:"valid_time"`
 }
 
 // PassageResult 进出场结果
 type PassageResult struct {
-	Success     bool    `json:"success"`
-	Message     string  `json:"message"`
-	Direction   string  `json:"direction"`    // in / out
-	PlateNumber string  `json:"plate_number"` // 车牌号
-	RFIDTag     string  `json:"rfid_tag"`     // RFID 标签
-	Fee         float64 `json:"fee"`          // 出场费用(入场时为0)
-	StayTime    int64   `json:"stay_time"`    // 停留时长(分钟)
-	EntryTime   int64   `json:"entry_time"`   // 入场时间戳
-	ExitTime    int64   `json:"exit_time"`    // 出场时间戳
+	Success           bool    `json:"success"`
+	Message           string  `json:"message"`
+	Direction         string  `json:"direction"`    // in / out
+	PlateNumber       string  `json:"plate_number"` // 车牌号
+	RFIDTag           string  `json:"rfid_tag"`     // RFID 标签
+	Fee               float64 `json:"fee"`          // 出场费用(入场时为0)
+	StayTime          int64   `json:"stay_time"`    // 停留时长(分钟)
+	EntryTime         int64   `json:"entry_time"`   // 入场时间戳
+	ExitTime          int64   `json:"exit_time"`    // 出场时间戳
+	BusinessCompleted bool    `json:"business_completed"`
+	GateOpened        bool    `json:"gate_opened"`
+	GateStatus        string  `json:"gate_status"`
+	DeviceCode        string  `json:"device_code"`
 }

+ 11 - 15
internal/model/vehicle/request/vehicle.go

@@ -53,9 +53,16 @@ type VehicleEntry struct {
 	EntryImage     string `json:"entry_image"`
 }
 
-type VehicleExit struct {
+// VehicleIdentityRequest 用车牌或 RFID 定位当前车辆及停车会话。
+type VehicleIdentityRequest struct {
+	PlateNumber string `json:"plate_number"`
+	RFIDTag     string `json:"rfid_tag"`
+}
+
+// ExitPreviewRequest 支持通过会话编号或车辆身份预览出场结算信息。
+type ExitPreviewRequest struct {
+	SessionID   uint   `json:"session_id"`
 	PlateNumber string `json:"plate_number"`
-	ExitImage   string `json:"exit_image"`
 	RFIDTag     string `json:"rfid_tag"`
 }
 
@@ -70,19 +77,6 @@ type VehicleEntryResponse struct {
 	} `json:"data"`
 }
 
-type VehicleExitResponse struct {
-	Success bool   `json:"success"`
-	Message string `json:"message"`
-	Data    struct {
-		PlateNumber   string  `json:"plate_number"`
-		EntryTime     int64   `json:"entry_time"`
-		ExitTime      int64   `json:"exit_time"`
-		StayTime      int64   `json:"stay_time"`
-		Fee           float64 `json:"fee"`
-		PaymentStatus string  `json:"payment_status"`
-	} `json:"data"`
-}
-
 type VehicleRecordRequest struct {
 	PlateNumber string `form:"plate_number" json:"plate_number"`
 	RFIDTag     string `form:"rfid_tag" json:"rfid_tag"`
@@ -91,6 +85,8 @@ type VehicleRecordRequest struct {
 }
 
 type ExitConfirmRequest struct {
+	SessionID     uint    `json:"session_id"`
+	TicketNo      string  `json:"ticket_no"`
 	PlateNumber   string  `json:"plate_number"`
 	RFIDTag       string  `json:"rfid_tag"`
 	PaymentEntry  string  `json:"payment_entry"`

+ 23 - 6
internal/model/vehicle/response/vehicle.go

@@ -30,12 +30,29 @@ type FeeConfigList struct {
 }
 
 type ExitPreviewResponse struct {
-	PlateNumber string  `json:"plate_number"`
-	EntryTime   int64   `json:"entry_time"`
-	StayTime    int64   `json:"stay_time"`
-	Fee         float64 `json:"fee"`
-	VehicleType string  `json:"vehicle_type"`
-	IsTemp      bool    `json:"is_temp"`
+	SessionID     uint    `json:"session_id"`
+	TicketNo      string  `json:"ticket_no"`
+	TicketState   string  `json:"ticket_state"`
+	PlateNumber   string  `json:"plate_number"`
+	RFIDTag       string  `json:"rfid_tag"`
+	EntryTime     int64   `json:"entry_time"`
+	StayTime      int64   `json:"stay_time"`
+	Fee           float64 `json:"fee"`
+	PaidAmount    float64 `json:"paid_amount"`
+	PaymentStatus string  `json:"payment_status"`
+	PaymentEntry  string  `json:"payment_entry"`
+	PaymentMethod string  `json:"payment_method"`
+	ParkingLotID  uint    `json:"parking_lot_id"`
+	EntryImage    string  `json:"entry_image"`
+	VehicleType   string  `json:"vehicle_type"`
+	IsTemp        bool    `json:"is_temp"`
+}
+
+type OperationContextResponse struct {
+	Action       string               `json:"action"`
+	VehicleFound bool                 `json:"vehicle_found"`
+	Vehicle      *dao.Vehicle         `json:"vehicle,omitempty"`
+	Exit         *ExitPreviewResponse `json:"exit,omitempty"`
 }
 
 type ExitConfirmResponse struct {

+ 15 - 28
internal/modules/digital-ticket/api.go

@@ -4,13 +4,15 @@ import (
 	"github.com/gin-gonic/gin"
 	"strings"
 	"wails-app/internal/model/common/response"
+	vehicleRequest "wails-app/internal/model/vehicle/request"
 	"wails-app/internal/modules/digital-ticket/repository"
-	"wails-app/internal/modules/digital-ticket/service"
-	paymentService "wails-app/internal/modules/payment/service"
+	ticketService "wails-app/internal/modules/digital-ticket/service"
+	utils "wails-app/internal/pkg"
+	appService "wails-app/internal/service"
 )
 
-var ticketSvc = service.NewTicketService()
-var ticketPaymentSvc = paymentService.NewPaymentService()
+var ticketSvc = ticketService.NewTicketService()
+var ticketVehicleSvc = appService.ServiceGroupApp.VehicleServiceGroup.VehicleService
 
 func GetTicket(c *gin.Context) {
 	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
@@ -34,28 +36,15 @@ func PayTicket(c *gin.Context) {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
-	if err != nil {
-		response.FailWithMessage("票不存在", c)
-		return
-	}
-	entryCode := req.PaymentEntry
-	entryCode = strings.TrimSpace(entryCode)
+	entryCode := strings.TrimSpace(req.PaymentEntry)
 	if entryCode == "" {
 		response.FailWithMessage("数字票支付必须指定支付入口", c)
 		return
 	}
-	_, paidAmount, err := ticketPaymentSvc.ValidatePayment(nil, req.PaymentMethod, entryCode, t.Fee, req.PaidAmount)
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-	extra := map[string]interface{}{
-		"payment_entry": entryCode, "payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
-		"paid_amount": paidAmount,
-		"fee":         t.Fee,
-	}
-	if err := ticketSvc.Transition(t.ID, service.StatePaid, extra); err != nil {
+	if err := ticketVehicleSvc.PayDigitalTicket(
+		c.Param("ticketNo"), entryCode, strings.TrimSpace(req.PaymentMethod),
+		strings.TrimSpace(req.PaymentOrderNo), req.PaidAmount, utils.GetUserID(c),
+	); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
@@ -63,16 +52,14 @@ func PayTicket(c *gin.Context) {
 }
 
 func ExitTicket(c *gin.Context) {
-	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	result, err := ticketVehicleSvc.ExitConfirm(vehicleRequest.ExitConfirmRequest{
+		TicketNo: c.Param("ticketNo"),
+	}, utils.GetUserID(c))
 	if err != nil {
-		response.FailWithMessage("票不存在", c)
-		return
-	}
-	if err := ticketSvc.Transition(t.ID, service.StateExited, nil); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	response.OkWithMessage("已离场", c)
+	response.OkWithData(result, c)
 }
 
 func ListTickets(c *gin.Context) {

+ 19 - 1
internal/modules/digital-ticket/repository/repo.go

@@ -17,8 +17,12 @@ func (r *TicketRepository) CreateTx(db *gorm.DB, ticket *dao.DigitalTicket) erro
 }
 
 func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, error) {
+	return r.GetByTicketNoTx(global.GVA_DB, ticketNo)
+}
+
+func (r *TicketRepository) GetByTicketNoTx(db *gorm.DB, ticketNo string) (*dao.DigitalTicket, error) {
 	var t dao.DigitalTicket
-	err := global.GVA_DB.Where("ticket_no = ?", ticketNo).First(&t).Error
+	err := db.Where("ticket_no = ?", ticketNo).First(&t).Error
 	if err != nil {
 		return nil, err
 	}
@@ -60,6 +64,20 @@ func (r *TicketRepository) UpdateStateTx(db *gorm.DB, id uint, state string, upd
 	return db.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
 }
 
+// UpdateStateIfCurrentTx changes a ticket only when it is still in the state
+// observed by the caller. This makes payment and exit transitions safe when
+// two requests race for the same ticket.
+func (r *TicketRepository) UpdateStateIfCurrentTx(db *gorm.DB, id uint, fromState, toState string, updates map[string]interface{}) (bool, error) {
+	if updates == nil {
+		updates = make(map[string]interface{})
+	}
+	updates["state"] = toState
+	result := db.Model(&dao.DigitalTicket{}).
+		Where("id = ? AND state = ?", id, fromState).
+		Updates(updates)
+	return result.RowsAffected == 1, result.Error
+}
+
 type TicketListQuery struct {
 	PlateNumber string `form:"plate_number"`
 	State       string `form:"state"`

+ 123 - 0
internal/modules/digital-ticket/service/edge_cases_test.go

@@ -0,0 +1,123 @@
+package service
+
+import (
+	"errors"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func createPendingTicketForEdgeTest(t *testing.T, svc *TicketService, plate string) *dao.DigitalTicket {
+	t.Helper()
+	record := &dao.VehicleRecord{PlateNumber: plate, PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	ticket, err := svc.Create(plate, "test", record.ID)
+	require.NoError(t, err)
+	return ticket
+}
+
+func TestDigitalTicketRejectsNegativePaymentAmounts(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	ticket := createPendingTicketForEdgeTest(t, svc, "NEGATIVE-TICKET")
+
+	err := svc.TransitionTx(global.GVA_DB, ticket.ID, StatePaid, map[string]interface{}{
+		"fee": -1.0, "paid_amount": 0.0,
+	})
+	require.ErrorIs(t, err, ErrInvalidTicketPayment)
+
+	var current dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&current, ticket.ID).Error)
+	require.Equal(t, StatePendingPayment, current.State)
+
+	err = svc.MarkPaidAndExitedTx(global.GVA_DB, ticket.VehicleRecordID, "counter", "cash", "", -1, 0)
+	require.ErrorIs(t, err, ErrInvalidTicketPayment)
+}
+
+func TestDigitalTicketAllowsZeroFeePayment(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	ticket := createPendingTicketForEdgeTest(t, svc, "ZERO-TICKET")
+
+	require.NoError(t, svc.TransitionTx(global.GVA_DB, ticket.ID, StatePaid, map[string]interface{}{
+		"fee": 0.0, "paid_amount": 0.0, "payment_entry": "counter", "payment_method": "cash",
+	}))
+
+	var current dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&current, ticket.ID).Error)
+	require.Equal(t, StatePaid, current.State)
+	require.Zero(t, current.Fee)
+	require.Zero(t, current.PaidAmount)
+}
+
+func TestDigitalTicketRejectsDuplicatePayment(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	ticket := createPendingTicketForEdgeTest(t, svc, "DUPLICATE-TICKET")
+	extra := map[string]interface{}{"fee": 10.0, "paid_amount": 10.0, "payment_order_no": "ORDER-1"}
+
+	require.NoError(t, svc.TransitionTx(global.GVA_DB, ticket.ID, StatePaid, extra))
+	require.Error(t, svc.TransitionTx(global.GVA_DB, ticket.ID, StatePaid, extra))
+
+	var current dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&current, ticket.ID).Error)
+	require.Equal(t, StatePaid, current.State)
+	require.Equal(t, "ORDER-1", current.PaymentOrderNo)
+}
+
+func TestDigitalTicketConcurrentPaymentHasSingleWinner(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	ticket := createPendingTicketForEdgeTest(t, svc, "CONCURRENT-TICKET")
+
+	const attempts = 8
+	start := make(chan struct{})
+	results := make(chan error, attempts)
+	var wg sync.WaitGroup
+	for i := 0; i < attempts; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			<-start
+			results <- svc.TransitionTx(global.GVA_DB, ticket.ID, StatePaid, map[string]interface{}{
+				"fee": 10.0, "paid_amount": 10.0,
+			})
+		}()
+	}
+	close(start)
+	wg.Wait()
+	close(results)
+
+	successes := 0
+	for err := range results {
+		if err == nil {
+			successes++
+		}
+	}
+	require.Equal(t, 1, successes)
+
+	var current dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&current, ticket.ID).Error)
+	require.Equal(t, StatePaid, current.State)
+}
+
+func TestDigitalTicketTransitionRollbackRestoresState(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	ticket := createPendingTicketForEdgeTest(t, svc, "ROLLBACK-TICKET")
+	rollbackErr := errors.New("force rollback")
+
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := svc.TransitionTx(tx, ticket.ID, StatePaid, map[string]interface{}{
+			"fee": 10.0, "paid_amount": 10.0,
+		}); err != nil {
+			return err
+		}
+		return rollbackErr
+	})
+	require.ErrorIs(t, err, rollbackErr)
+
+	var current dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.First(&current, ticket.ID).Error)
+	require.Equal(t, StatePendingPayment, current.State)
+	require.Empty(t, current.PaymentOrderNo)
+}

+ 92 - 1
internal/modules/digital-ticket/service/service.go

@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"gorm.io/gorm"
+	"math"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
@@ -21,6 +22,11 @@ const (
 	StateExpired        = "expired"
 )
 
+var (
+	ErrTicketStateChanged   = errors.New("digital ticket state changed")
+	ErrInvalidTicketPayment = errors.New("invalid digital ticket payment amount")
+)
+
 type TicketService struct {
 	repo *repository.TicketRepository
 }
@@ -66,7 +72,68 @@ func (s *TicketService) Transition(id uint, toState string, extra map[string]int
 	return s.TransitionTx(global.GVA_DB, id, toState, extra)
 }
 
+func extraAmount(extra map[string]interface{}, key string) (float64, bool, error) {
+	value, ok := extra[key]
+	if !ok || value == nil {
+		return 0, false, nil
+	}
+	switch number := value.(type) {
+	case float64:
+		return number, true, nil
+	case float32:
+		return float64(number), true, nil
+	case int:
+		return float64(number), true, nil
+	case int8:
+		return float64(number), true, nil
+	case int16:
+		return float64(number), true, nil
+	case int32:
+		return float64(number), true, nil
+	case int64:
+		return float64(number), true, nil
+	case uint:
+		return float64(number), true, nil
+	case uint8:
+		return float64(number), true, nil
+	case uint16:
+		return float64(number), true, nil
+	case uint32:
+		return float64(number), true, nil
+	case uint64:
+		return float64(number), true, nil
+	case json.Number:
+		parsed, err := number.Float64()
+		return parsed, true, err
+	default:
+		return 0, true, fmt.Errorf("%s must be numeric", key)
+	}
+}
+
+func validatePaymentExtra(extra map[string]interface{}) error {
+	fee, hasFee, err := extraAmount(extra, "fee")
+	if err != nil {
+		return err
+	}
+	paid, hasPaid, err := extraAmount(extra, "paid_amount")
+	if err != nil {
+		return err
+	}
+	for _, amount := range []float64{fee, paid} {
+		if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
+			return ErrInvalidTicketPayment
+		}
+	}
+	if hasFee && hasPaid && paid < fee {
+		return errors.New("paid amount insufficient")
+	}
+	return nil
+}
+
 func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error {
+	if db == nil {
+		return errors.New("database not initialized")
+	}
 	ticket, err := s.repo.GetByIDTx(db, id)
 	if err != nil {
 		return errors.New("票不存在")
@@ -89,6 +156,12 @@ func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra
 		return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState)
 	}
 
+	if toState == StatePaid {
+		if err := validatePaymentExtra(extra); err != nil {
+			return err
+		}
+	}
+
 	updates := map[string]interface{}{}
 	if toState == StatePaid {
 		now := time.Now()
@@ -112,13 +185,23 @@ func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra
 	}
 	updates["event_log"] = appendEvent(ticket.EventLog, event)
 
-	return s.repo.UpdateStateTx(db, id, toState, updates)
+	updated, err := s.repo.UpdateStateIfCurrentTx(db, id, ticket.State, toState, updates)
+	if err != nil {
+		return err
+	}
+	if !updated {
+		return ErrTicketStateChanged
+	}
+	return nil
 }
 
 // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's
 // transaction. Payment entry and method are stored as payment metadata only;
 // they do not define the ticket itself.
 func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64) error {
+	if err := validatePaymentExtra(map[string]interface{}{"paid_amount": paidAmount, "fee": fee}); err != nil {
+		return err
+	}
 	ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
 	if err != nil {
 		return errors.New("车辆对应的数字票不存在")
@@ -156,6 +239,14 @@ func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
 	return ticket, nil
 }
 
+func (s *TicketService) GetByTicketNoTx(db *gorm.DB, no string) (*dao.DigitalTicket, error) {
+	return s.repo.GetByTicketNoTx(db, no)
+}
+
+func (s *TicketService) GetByVehicleRecordIDTx(db *gorm.DB, recordID uint) (*dao.DigitalTicket, error) {
+	return s.repo.GetByVehicleRecordIDTx(db, recordID)
+}
+
 func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
 	list, total, err := s.repo.List(q)
 	if err != nil {

+ 11 - 1
internal/modules/digital-ticket/service/service_test.go

@@ -14,8 +14,18 @@ import (
 )
 
 func setupTicketServiceTest(t *testing.T) *TicketService {
-	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	dsn := fmt.Sprintf("file:digital-ticket-service-%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)
+	sqlDB.SetMaxOpenConns(16)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
 	global.GVA_DB = db
 	require.NoError(t, db.AutoMigrate(&dao.VehicleRecord{}, &dao.DigitalTicket{}))
 	return NewTicketService()

+ 7 - 1
internal/modules/monthly/repository/repo.go

@@ -1,6 +1,8 @@
 package repository
 
 import (
+	"time"
+
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 )
@@ -13,7 +15,11 @@ func (r *MonthlyCardRepository) Create(card *dao.MonthlyCard) error {
 
 func (r *MonthlyCardRepository) GetByVehicleID(vehicleID uint) (*dao.MonthlyCard, error) {
 	var card dao.MonthlyCard
-	err := global.GVA_DB.Where("vehicle_id = ? AND payment_status = ?", vehicleID, "paid").Preload("Vehicle").First(&card).Error
+	err := global.GVA_DB.
+		Where("vehicle_id = ? AND payment_status = ? AND end_date > ?", vehicleID, "paid", time.Now()).
+		Preload("Vehicle").
+		Order("end_date DESC").
+		First(&card).Error
 	if err != nil {
 		return nil, err
 	}

+ 53 - 0
internal/modules/monthly/repository/repo_test.go

@@ -0,0 +1,53 @@
+package repository
+
+import (
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func TestGetByVehicleIDReturnsOnlyUnexpiredPaidCard(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.Vehicle{}, &dao.MonthlyCard{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+
+	vehicle := dao.Vehicle{PlateNumber: "MONTHLY001", VehicleTypeID: 1}
+	require.NoError(t, db.Create(&vehicle).Error)
+	now := time.Now()
+	require.NoError(t, db.Create(&[]dao.MonthlyCard{
+		{VehicleID: vehicle.ID, StartDate: now.AddDate(0, -2, 0), EndDate: now.Add(-time.Hour), PaymentStatus: "paid"},
+		{VehicleID: vehicle.ID, StartDate: now.AddDate(0, -1, 0), EndDate: now.Add(time.Hour), PaymentStatus: "refunded"},
+		{VehicleID: vehicle.ID, StartDate: now, EndDate: now.Add(2 * time.Hour), PaymentStatus: "paid"},
+	}).Error)
+
+	card, err := (&MonthlyCardRepository{}).GetByVehicleID(vehicle.ID)
+	require.NoError(t, err)
+	require.NotNil(t, card)
+	require.True(t, card.EndDate.After(now.Add(time.Hour)))
+}
+
+func TestGetByVehicleIDRejectsExpiredCard(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.Vehicle{}, &dao.MonthlyCard{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+
+	vehicle := dao.Vehicle{PlateNumber: "MONTHLY002", VehicleTypeID: 1}
+	require.NoError(t, db.Create(&vehicle).Error)
+	now := time.Now()
+	require.NoError(t, db.Create(&dao.MonthlyCard{
+		VehicleID: vehicle.ID, StartDate: now.AddDate(0, -1, 0), EndDate: now, PaymentStatus: "paid",
+	}).Error)
+
+	card, err := (&MonthlyCardRepository{}).GetByVehicleID(vehicle.ID)
+	require.ErrorIs(t, err, gorm.ErrRecordNotFound)
+	require.Nil(t, card)
+}

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

@@ -0,0 +1,35 @@
+package request
+
+import "time"
+
+// ResolveActiveSessionRequest accepts the three supported external handles for
+// one parking session. At least one handle must be present.
+type ResolveActiveSessionRequest struct {
+	SessionID   uint
+	PlateNumber string
+	RFIDTag     string
+}
+
+// OpenSessionRequest contains the immutable entry data for one parking stay.
+type OpenSessionRequest struct {
+	PlateNumber    string
+	RFIDTag        string
+	ParkingLotID   uint
+	ParkingSpaceID uint
+	EntryImage     string
+	EntryTime      time.Time
+}
+
+// CloseSessionRequest contains the final result of a completed parking stay.
+// Payment details are copied here as the current session summary; the payment
+// ledger remains the corresponding payment_record rows.
+type CloseSessionRequest struct {
+	SessionID     uint
+	ExitTime      time.Time
+	StayTime      int64
+	Fee           float64
+	PaymentStatus string
+	PaymentEntry  string
+	PaymentMethod string
+	ExitImage     string
+}

+ 66 - 0
internal/modules/parking-session/repository/repo.go

@@ -0,0 +1,66 @@
+package repository
+
+import (
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+)
+
+// ParkingSessionRepository persists the parking-session aggregate in the
+// existing vehicle_record table. The table remains the historical parking
+// ledger while this module owns its active-session semantics.
+type ParkingSessionRepository struct{}
+
+func (r *ParkingSessionRepository) CreateTx(db *gorm.DB, session *dao.VehicleRecord) error {
+	return db.Create(session).Error
+}
+
+func (r *ParkingSessionRepository) FindByIDTx(db *gorm.DB, id uint) (*dao.VehicleRecord, error) {
+	var session dao.VehicleRecord
+	if err := db.First(&session, id).Error; err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (r *ParkingSessionRepository) FindActiveByIDTx(db *gorm.DB, id uint) (*dao.VehicleRecord, error) {
+	var session dao.VehicleRecord
+	if err := db.Where("id = ? AND exit_time IS NULL", id).First(&session).Error; err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (r *ParkingSessionRepository) FindActiveByPlateTx(db *gorm.DB, plateNumber string) ([]dao.VehicleRecord, error) {
+	var sessions []dao.VehicleRecord
+	err := db.Where("plate_number = ? AND exit_time IS NULL", plateNumber).
+		Order("entry_time DESC, id DESC").Find(&sessions).Error
+	return sessions, err
+}
+
+func (r *ParkingSessionRepository) FindActiveByRFIDTx(db *gorm.DB, rfidTag string) ([]dao.VehicleRecord, error) {
+	var sessions []dao.VehicleRecord
+	err := db.Where("rfid_tag = ? AND exit_time IS NULL", rfidTag).
+		Order("entry_time DESC, id DESC").Find(&sessions).Error
+	return sessions, err
+}
+
+func (r *ParkingSessionRepository) FindActiveByExactIdentityTx(db *gorm.DB, plateNumber, rfidTag string) (*dao.VehicleRecord, error) {
+	var session dao.VehicleRecord
+	if err := db.Where("plate_number = ? AND rfid_tag = ? AND exit_time IS NULL", plateNumber, rfidTag).
+		First(&session).Error; err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (r *ParkingSessionRepository) UpdateActiveTx(db *gorm.DB, id uint, updates map[string]interface{}) (int64, error) {
+	result := db.Model(&dao.VehicleRecord{}).Where("id = ? AND exit_time IS NULL", id).Updates(updates)
+	return result.RowsAffected, result.Error
+}
+
+func (r *ParkingSessionRepository) UpdateUnpaidActiveTx(db *gorm.DB, id uint, updates map[string]interface{}) (int64, error) {
+	result := db.Model(&dao.VehicleRecord{}).
+		Where("id = ? AND exit_time IS NULL AND COALESCE(payment_status, '') <> ?", id, "paid").
+		Updates(updates)
+	return result.RowsAffected, result.Error
+}

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

@@ -0,0 +1,305 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"math"
+	"strings"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/parking-session/model/request"
+	"wails-app/internal/modules/parking-session/repository"
+
+	"gorm.io/gorm"
+)
+
+const (
+	PaymentStatusUnpaid = "unpaid"
+	PaymentStatusPaid   = "paid"
+)
+
+var (
+	ErrSessionNotFound         = errors.New("未找到在场停车会话")
+	ErrSessionAlreadyOpen      = errors.New("车辆已有在场停车会话")
+	ErrSessionAlreadyPaid      = errors.New("停车会话已完成支付")
+	ErrSessionAlreadyClosed    = errors.New("停车会话已关闭")
+	ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
+	ErrSessionAmbiguous        = errors.New("存在多条匹配的在场停车会话")
+	ErrSessionIdentifierEmpty  = errors.New("停车会话必须提供车牌、RFID或会话编号")
+)
+
+// ParkingSessionService owns the parking-stay lifecycle. It deliberately
+// persists to vehicle_record so existing reports and historical data remain
+// compatible while callers stop treating the record as an unstructured table.
+type ParkingSessionService struct {
+	repo *repository.ParkingSessionRepository
+}
+
+func NewParkingSessionService() *ParkingSessionService {
+	return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}}
+}
+
+// FindActiveSession is the domain-facing name used by adapters that do not
+// need to know whether the lookup is backed by a transaction.
+func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
+	return s.ResolveActiveSession(req)
+}
+
+func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
+	if global.GVA_DB == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var session *dao.VehicleRecord
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var err error
+		session, err = s.OpenSessionTx(tx, req)
+		return err
+	})
+	return session, err
+}
+
+func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
+	if global.GVA_DB == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var session *dao.VehicleRecord
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var err error
+		session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee)
+		return err
+	})
+	return session, err
+}
+
+func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
+	if global.GVA_DB == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var session *dao.VehicleRecord
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var err error
+		session, err = s.CloseSessionTx(tx, req)
+		return err
+	})
+	return session, err
+}
+
+func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
+	return s.ResolveActiveSessionTx(global.GVA_DB, req)
+}
+
+// ResolveActiveSessionTx resolves one active parking session deterministically.
+// When both identities are supplied, it refuses to pick an arbitrary session
+// if the plate and RFID point to different active records.
+func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
+	if db == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	if req.SessionID != 0 {
+		session, err := s.repo.FindActiveByIDTx(db, req.SessionID)
+		if err == nil {
+			return session, nil
+		}
+		return nil, sessionLookupError(db, req.SessionID, err)
+	}
+
+	plateNumber := strings.TrimSpace(req.PlateNumber)
+	rfidTag := strings.TrimSpace(req.RFIDTag)
+	if plateNumber == "" && rfidTag == "" {
+		return nil, ErrSessionIdentifierEmpty
+	}
+	if plateNumber != "" && rfidTag != "" {
+		if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil {
+			return session, nil
+		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, err
+		}
+	}
+
+	var plateSessions, rfidSessions []dao.VehicleRecord
+	var err error
+	if plateNumber != "" {
+		plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber)
+		if err != nil {
+			return nil, err
+		}
+	}
+	if rfidTag != "" {
+		rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if len(plateSessions) > 1 || len(rfidSessions) > 1 {
+		return nil, ErrSessionAmbiguous
+	}
+	if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID {
+		return nil, ErrSessionIdentityConflict
+	}
+	if len(plateSessions) == 1 {
+		return &plateSessions[0], nil
+	}
+	if len(rfidSessions) == 1 {
+		return &rfidSessions[0], nil
+	}
+	return nil, ErrSessionNotFound
+}
+
+func sessionLookupError(db *gorm.DB, id uint, err error) error {
+	if !errors.Is(err, gorm.ErrRecordNotFound) {
+		return err
+	}
+	var session dao.VehicleRecord
+	if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil {
+		if session.ExitTime != nil {
+			return ErrSessionAlreadyClosed
+		}
+		if session.PaymentStatus == PaymentStatusPaid {
+			return ErrSessionAlreadyPaid
+		}
+		return ErrSessionNotFound
+	} else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
+		return lookupErr
+	}
+	return ErrSessionNotFound
+}
+
+// OpenSessionTx creates a durable parking session. The active-session lookup
+// is repeated in the caller transaction to make the validation part of the
+// same atomic operation as the insert.
+func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
+	if db == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	req.PlateNumber = strings.TrimSpace(req.PlateNumber)
+	req.RFIDTag = strings.TrimSpace(req.RFIDTag)
+	if req.PlateNumber == "" && req.RFIDTag == "" {
+		return nil, ErrSessionIdentifierEmpty
+	}
+
+	_, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{
+		PlateNumber: req.PlateNumber,
+		RFIDTag:     req.RFIDTag,
+	})
+	if err == nil {
+		return nil, ErrSessionAlreadyOpen
+	}
+	if !errors.Is(err, ErrSessionNotFound) {
+		return nil, err
+	}
+
+	entryTime := req.EntryTime
+	if entryTime.IsZero() {
+		entryTime = time.Now()
+	}
+	session := &dao.VehicleRecord{
+		PlateNumber:    req.PlateNumber,
+		RFIDTag:        req.RFIDTag,
+		EntryTime:      entryTime,
+		ParkingLotID:   req.ParkingLotID,
+		ParkingSpaceID: req.ParkingSpaceID,
+		EntryImage:     req.EntryImage,
+		PaymentStatus:  PaymentStatusUnpaid,
+	}
+	if err := s.repo.CreateTx(db, session); err != nil {
+		return nil, err
+	}
+	return session, nil
+}
+
+// MarkPaidTx updates the current settlement summary on a still-active session.
+// The payment_record created by the payment module remains the source of truth
+// for the monetary transaction itself.
+func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
+	if db == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	if sessionID == 0 {
+		return nil, ErrSessionNotFound
+	}
+	if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 {
+		return nil, errors.New("停车费用必须是有限的非负数")
+	}
+
+	session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID})
+	if err != nil {
+		return nil, err
+	}
+	if session.PaymentStatus == PaymentStatusPaid {
+		return nil, ErrSessionAlreadyPaid
+	}
+
+	rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{
+		"payment_status": PaymentStatusPaid,
+		"payment_entry":  paymentEntry,
+		"payment_method": paymentMethod,
+		"fee":            fee,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err)
+	}
+	if rows != 1 {
+		return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound)
+	}
+	session.PaymentStatus = PaymentStatusPaid
+	session.PaymentEntry = paymentEntry
+	session.PaymentMethod = paymentMethod
+	session.Fee = fee
+	return session, nil
+}
+
+// CloseSessionTx finalizes one paid parking session exactly once.
+func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
+	if db == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	if req.SessionID == 0 {
+		return nil, ErrSessionNotFound
+	}
+	if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 {
+		return nil, errors.New("停车会话结算数据无效")
+	}
+
+	session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID})
+	if err != nil {
+		return nil, err
+	}
+	exitTime := req.ExitTime
+	if exitTime.IsZero() {
+		exitTime = time.Now()
+	}
+	paymentStatus := req.PaymentStatus
+	if paymentStatus == "" {
+		paymentStatus = PaymentStatusPaid
+	}
+	updates := map[string]interface{}{
+		"exit_time":      exitTime,
+		"stay_time":      req.StayTime,
+		"fee":            req.Fee,
+		"payment_status": paymentStatus,
+		"payment_entry":  req.PaymentEntry,
+		"payment_method": req.PaymentMethod,
+	}
+	if req.ExitImage != "" {
+		updates["exit_image"] = req.ExitImage
+	}
+	rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates)
+	if err != nil {
+		return nil, fmt.Errorf("关闭停车会话失败: %w", err)
+	}
+	if rows != 1 {
+		return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
+	}
+	session.ExitTime = &exitTime
+	session.StayTime = req.StayTime
+	session.Fee = req.Fee
+	session.PaymentStatus = paymentStatus
+	session.PaymentEntry = req.PaymentEntry
+	session.PaymentMethod = req.PaymentMethod
+	if req.ExitImage != "" {
+		session.ExitImage = req.ExitImage
+	}
+	return session, nil
+}

+ 77 - 0
internal/modules/parking-session/service/service_test.go

@@ -0,0 +1,77 @@
+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"
+	"wails-app/internal/modules/parking-session/model/request"
+)
+
+func setupParkingSessionTest(t *testing.T) *ParkingSessionService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:parking-session-%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.VehicleRecord{}))
+	return NewParkingSessionService()
+}
+
+func TestParkingSessionOpenResolveAndClose(t *testing.T) {
+	svc := setupParkingSessionTest(t)
+	var session *dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var err error
+		session, err = svc.OpenSessionTx(tx, request.OpenSessionRequest{
+			PlateNumber: "SESSION-001", RFIDTag: "RFID-001", ParkingLotID: 1,
+		})
+		return err
+	}))
+
+	resolved, err := svc.ResolveActiveSession(request.ResolveActiveSessionRequest{
+		PlateNumber: "SESSION-001", RFIDTag: "RFID-001",
+	})
+	require.NoError(t, err)
+	require.Equal(t, session.ID, resolved.ID)
+
+	require.NoError(t, global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if _, err := svc.MarkPaidTx(tx, session.ID, "counter", "cash", 12); err != nil {
+			return err
+		}
+		_, err := svc.CloseSessionTx(tx, request.CloseSessionRequest{
+			SessionID: session.ID, StayTime: 60, Fee: 12, PaymentEntry: "counter", PaymentMethod: "cash",
+		})
+		return err
+	}))
+
+	_, err = svc.ResolveActiveSession(request.ResolveActiveSessionRequest{SessionID: session.ID})
+	require.ErrorIs(t, err, ErrSessionAlreadyClosed)
+}
+
+func TestParkingSessionRejectsDuplicateAndConflictingIdentity(t *testing.T) {
+	svc := setupParkingSessionTest(t)
+	first, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{PlateNumber: "SESSION-A", RFIDTag: "RFID-A"})
+	require.NoError(t, err)
+	_, err = svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{PlateNumber: "SESSION-A"})
+	require.ErrorIs(t, err, ErrSessionAlreadyOpen)
+
+	second, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{PlateNumber: "SESSION-B", RFIDTag: "RFID-B"})
+	require.NoError(t, err)
+	require.NotEqual(t, first.ID, second.ID)
+	_, err = svc.ResolveActiveSession(request.ResolveActiveSessionRequest{PlateNumber: "SESSION-A", RFIDTag: "RFID-B"})
+	require.ErrorIs(t, err, ErrSessionIdentityConflict)
+}

+ 39 - 1
internal/modules/payment/api.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/gin-gonic/gin"
 	"wails-app/internal/dao"
+	commonModel "wails-app/internal/model/common"
 	"wails-app/internal/model/common/response"
 	"wails-app/internal/model/vehicle/request"
 	"wails-app/internal/modules/payment/repository"
@@ -18,7 +19,7 @@ var paymentSvc = paymentService.NewPaymentService()
 
 // ExitPreview 费用预览(只读,不修改数据库)
 func ExitPreview(c *gin.Context) {
-	var req request.VehicleEntry
+	var req request.ExitPreviewRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
@@ -31,6 +32,41 @@ func ExitPreview(c *gin.Context) {
 	response.OkWithData(resp, c)
 }
 
+// OperationContext 返回车辆当前应执行的进场或出场操作上下文。
+func OperationContext(c *gin.Context) {
+	var req request.VehicleIdentityRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	resp, err := vehicleSvc.GetOperationContext(req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(resp, c)
+}
+
+// HandlePassage 统一处理人工、UHF 和摄像头触发的进出场与开闸。
+func HandlePassage(c *gin.Context) {
+	var req commonModel.PassageRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	req.OperatorID = utils.GetUserID(c)
+	result, err := service.ServiceGroupApp.ParkingServiceGroup.PassageService.HandlePassage(req)
+	if err != nil {
+		if result != nil {
+			response.FailWithDetailed(result, err.Error(), c)
+			return
+		}
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(result, c)
+}
+
 // ExitConfirm 确认出场 + 支付
 func ExitConfirm(c *gin.Context) {
 	var req request.ExitConfirmRequest
@@ -306,6 +342,8 @@ func SetupPaymentRouter(router *gin.RouterGroup) {
 	}
 	eg := router.Group("/vehicle")
 	{
+		eg.POST("/operation/context", OperationContext)
+		eg.POST("/passage", HandlePassage)
 		eg.POST("/exit/preview", ExitPreview)
 		eg.POST("/exit/confirm", ExitConfirm)
 	}

+ 137 - 0
internal/modules/payment/service/edge_cases_test.go

@@ -0,0 +1,137 @@
+package service
+
+import (
+	"errors"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func TestPaymentRejectsNegativeAndInsufficientAmounts(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+
+	for _, amounts := range [][2]float64{{-1, 0}, {1, -1}, {-1, -1}} {
+		_, _, err := svc.ValidatePayment(nil, PaymentCash, EntryCounter, amounts[0], amounts[1])
+		require.Error(t, err)
+	}
+
+	_, _, err := svc.ValidatePayment(nil, PaymentCash, EntryCounter, 10, 0)
+	require.Error(t, err)
+
+	record := &dao.VehicleRecord{PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	_, err = svc.ProcessPayment(record.ID, PaymentCash, -1, 0, 1)
+	require.Error(t, err)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.Zero(t, paymentCount)
+}
+
+func TestZeroAmountPaymentIsAllowedForZeroFeeSettlement(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+
+	_, paidAmount, err := svc.ValidatePayment(nil, PaymentPOS, EntryCounter, 0, 999)
+	require.NoError(t, err)
+	require.Zero(t, paidAmount)
+
+	record := &dao.VehicleRecord{PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	payment, err := svc.ProcessPayment(record.ID, PaymentCash, 0, 0, 1)
+	require.NoError(t, err)
+	require.NotNil(t, payment)
+	require.Zero(t, payment.Amount)
+	require.Zero(t, payment.PaidAmount)
+	require.Zero(t, payment.ChangeAmount)
+
+	var persisted dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persisted, record.ID).Error)
+	require.Equal(t, "paid", persisted.PaymentStatus)
+}
+
+func TestPaymentRejectsDuplicateSettlement(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	record := &dao.VehicleRecord{PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+
+	_, err := svc.ProcessPayment(record.ID, PaymentCash, 10, 10, 1)
+	require.NoError(t, err)
+	_, err = svc.ProcessPayment(record.ID, PaymentCash, 10, 10, 1)
+	require.ErrorIs(t, err, ErrPaymentAlreadyProcessed)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.EqualValues(t, 1, paymentCount)
+}
+
+func TestPaymentConcurrentSettlementHasSingleWinner(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	record := &dao.VehicleRecord{PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+
+	const attempts = 8
+	start := make(chan struct{})
+	results := make(chan error, attempts)
+	var wg sync.WaitGroup
+	for i := 0; i < attempts; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			<-start
+			_, err := svc.ProcessPayment(record.ID, PaymentCash, 0, 0, 1)
+			results <- err
+		}()
+	}
+	close(start)
+	wg.Wait()
+	close(results)
+
+	successes := 0
+	for err := range results {
+		if err == nil {
+			successes++
+		}
+	}
+	require.Equal(t, 1, successes)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.EqualValues(t, 1, paymentCount)
+}
+
+func TestPaymentTransactionRollbackLeavesNoPartialState(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	record := &dao.VehicleRecord{PaymentStatus: "unpaid"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	rollbackErr := errors.New("force rollback")
+
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if _, err := svc.ProcessPaymentTxAtEntry(tx, record.ID, EntryCounter, PaymentCash, 10, 10, 1); err != nil {
+			return err
+		}
+		return rollbackErr
+	})
+	require.ErrorIs(t, err, rollbackErr)
+
+	var persisted dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persisted, record.ID).Error)
+	require.Equal(t, "unpaid", persisted.PaymentStatus)
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.Zero(t, paymentCount)
+}
+
+func TestPaymentMissingVehicleRollsBackCreatedRecord(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+
+	_, err := svc.ProcessPayment(999999, PaymentCash, 0, 0, 1)
+	require.ErrorIs(t, err, ErrVehicleRecordNotFound)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Count(&paymentCount).Error)
+	require.Zero(t, paymentCount)
+}

+ 39 - 12
internal/modules/payment/service/service.go

@@ -3,11 +3,13 @@ package service
 import (
 	"errors"
 	"fmt"
+	"math"
 	"regexp"
 	"strings"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	parkingSessionService "wails-app/internal/modules/parking-session/service"
 	"wails-app/internal/modules/payment/repository"
 
 	"gorm.io/gorm"
@@ -36,6 +38,11 @@ const (
 
 var paymentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`)
 
+var (
+	ErrPaymentAlreadyProcessed = errors.New("payment already processed")
+	ErrVehicleRecordNotFound   = errors.New("vehicle record not found")
+)
+
 type PaymentService struct {
 	repo *repository.PaymentRepository
 }
@@ -356,8 +363,8 @@ func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, am
 	if err != nil {
 		return nil, 0, err
 	}
-	if amount < 0 || paidAmount < 0 {
-		return nil, 0, errors.New("支付金额不能为负数")
+	if math.IsNaN(amount) || math.IsInf(amount, 0) || math.IsNaN(paidAmount) || math.IsInf(paidAmount, 0) || amount < 0 || paidAmount < 0 {
+		return nil, 0, errors.New("支付金额必须是有限的非负数")
 	}
 	if code == PaymentFree {
 		return method, 0, nil
@@ -373,7 +380,19 @@ func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, am
 
 // ProcessPayment creates a payment_record and updates vehicle_record payment status
 func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
-	return s.ProcessPaymentTx(global.GVA_DB, recordID, paymentMethod, amount, paidAmount, operatorID, EntryCounter)
+	if global.GVA_DB == nil {
+		return nil, errors.New("database not initialized")
+	}
+	var result *dao.PaymentRecord
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var err error
+		result, err = s.ProcessPaymentTxAtEntry(tx, recordID, EntryCounter, paymentMethod, amount, paidAmount, operatorID)
+		return err
+	})
+	if err != nil {
+		return nil, err
+	}
+	return result, nil
 }
 
 // ProcessPaymentTx keeps the pre-split argument order for callers compiled
@@ -384,6 +403,9 @@ func (s *PaymentService) ProcessPaymentTx(db *gorm.DB, recordID uint, paymentMet
 }
 
 func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, paymentEntry string, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
+	if db == nil {
+		return nil, errors.New("database not initialized")
+	}
 	method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount)
 	if err != nil {
 		return nil, err
@@ -392,6 +414,20 @@ func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, pay
 	if method.InputMode == InputModeManualAmount && paidAmount > amount {
 		changeAmount = paidAmount - amount
 	}
+	if recordID != 0 {
+		sessionSvc := parkingSessionService.NewParkingSessionService()
+		if _, err := sessionSvc.MarkPaidTx(db, recordID, paymentEntry, paymentMethod, amount); err != nil {
+			switch {
+			case errors.Is(err, parkingSessionService.ErrSessionNotFound):
+				return nil, ErrVehicleRecordNotFound
+			case errors.Is(err, parkingSessionService.ErrSessionAlreadyPaid),
+				errors.Is(err, parkingSessionService.ErrSessionAlreadyClosed):
+				return nil, fmt.Errorf("payment already processed: %w", ErrPaymentAlreadyProcessed)
+			default:
+				return nil, fmt.Errorf("failed to update payment status: %w", err)
+			}
+		}
+	}
 
 	now := time.Now()
 	pr := &dao.PaymentRecord{
@@ -409,15 +445,6 @@ func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, pay
 		return nil, fmt.Errorf("创建支付记录失败: %w", err)
 	}
 
-	// Update vehicle_record payment status
-	if err := db.Model(&dao.VehicleRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
-		"payment_status": "paid",
-		"payment_entry":  paymentEntry,
-		"payment_method": paymentMethod,
-	}).Error; err != nil {
-		return nil, fmt.Errorf("更新支付状态失败: %w", err)
-	}
-
 	return pr, nil
 }
 

+ 14 - 2
internal/modules/payment/service/service_test.go

@@ -1,7 +1,9 @@
 package service
 
 import (
+	"fmt"
 	"testing"
+	"time"
 
 	"github.com/glebarez/sqlite"
 	"github.com/stretchr/testify/require"
@@ -11,10 +13,20 @@ import (
 )
 
 func setupPaymentServiceTest(t *testing.T) *PaymentService {
-	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	dsn := fmt.Sprintf("file:payment-service-%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)
+	sqlDB.SetMaxOpenConns(16)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
 	global.GVA_DB = db
-	require.NoError(t, db.AutoMigrate(&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}))
+	require.NoError(t, db.AutoMigrate(&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}, &dao.VehicleRecord{}, &dao.PaymentRecord{}))
 	require.NoError(t, db.Create(&[]dao.PaymentMethodConfig{
 		{Code: PaymentCash, Name: "人工支付", InputMode: InputModeManualAmount, Scenes: []string{SceneCounter}, Enabled: true, Sort: 10, IsSystem: true},
 		{Code: PaymentPOS, Name: "POS机支付", InputMode: InputModeConfirm, Scenes: []string{SceneCounter}, Enabled: true, Sort: 20, IsSystem: true},

+ 0 - 47
internal/pkg/ast/ast.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"fmt"
-	"go/ast"
-	"go/token"
-)
-
-// 增加 import 方法
-func AddImport(astNode ast.Node, imp string) {
-	impStr := fmt.Sprintf("\"%s\"", imp)
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if genDecl, ok := node.(*ast.GenDecl); ok {
-			if genDecl.Tok == token.IMPORT {
-				for i := range genDecl.Specs {
-					if impNode, ok := genDecl.Specs[i].(*ast.ImportSpec); ok {
-						if impNode.Path.Value == impStr {
-							return false
-						}
-					}
-				}
-				genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-					Path: &ast.BasicLit{
-						Kind:  token.STRING,
-						Value: impStr,
-					},
-				})
-			}
-		}
-		return true
-	})
-}
-
-// 查询特定function方法
-func FindFunction(astNode ast.Node, FunctionName string) *ast.FuncDecl {
-	var funcDeclP *ast.FuncDecl
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if funcDecl, ok := node.(*ast.FuncDecl); ok {
-			if funcDecl.Name.String() == FunctionName {
-				funcDeclP = funcDecl
-				return false
-			}
-		}
-		return true
-	})
-	return funcDeclP
-}

+ 0 - 47
internal/pkg/ast/ast_auto_enter.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-func ImportForAutoEnter(path string, funcName string, code string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if typeSpec, ok := node.(*ast.TypeSpec); ok {
-			if typeSpec.Name.Name == funcName {
-				if st, ok := typeSpec.Type.(*ast.StructType); ok {
-					for i := range st.Fields.List {
-						if t, ok := st.Fields.List[i].Type.(*ast.Ident); ok {
-							if t.Name == code {
-								return false
-							}
-						}
-					}
-					sn := &ast.Field{
-						Type: &ast.Ident{Name: code},
-					}
-					st.Fields.List = append(st.Fields.List, sn)
-				}
-			}
-		}
-		return true
-	})
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	err = printer.Fprint(bf, fileSet, astFile)
-	if err != nil {
-		return
-	}
-	_ = os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 181
internal/pkg/ast/ast_enter.go

@@ -1,181 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"go/ast"
-	"go/format"
-	"go/parser"
-	"go/token"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-	"log"
-	"os"
-	"strconv"
-	"strings"
-)
-
-type Visitor struct {
-	ImportCode  string
-	StructName  string
-	PackageName string
-	GroupName   string
-}
-
-func (vi *Visitor) Visit(node ast.Node) ast.Visitor {
-	switch n := node.(type) {
-	case *ast.GenDecl:
-		// 查找有没有import context包
-		// Notice:没有考虑没有import任何包的情况
-		if n.Tok == token.IMPORT && vi.ImportCode != "" {
-			vi.addImport(n)
-			// 不需要再遍历子树
-			return nil
-		}
-		if n.Tok == token.TYPE && vi.StructName != "" && vi.PackageName != "" && vi.GroupName != "" {
-			vi.addStruct(n)
-			return nil
-		}
-	case *ast.FuncDecl:
-		if n.Name.Name == "Routers" {
-			vi.addFuncBodyVar(n)
-			return nil
-		}
-
-	}
-	return vi
-}
-
-func (vi *Visitor) addStruct(genDecl *ast.GenDecl) ast.Visitor {
-	for i := range genDecl.Specs {
-		switch n := genDecl.Specs[i].(type) {
-		case *ast.TypeSpec:
-			if strings.Index(n.Name.Name, "Group") > -1 {
-				switch t := n.Type.(type) {
-				case *ast.StructType:
-					f := &ast.Field{
-						Names: []*ast.Ident{
-							{
-								Name: vi.StructName,
-								Obj: &ast.Object{
-									Kind: ast.Var,
-									Name: vi.StructName,
-								},
-							},
-						},
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: vi.PackageName,
-							},
-							Sel: &ast.Ident{
-								Name: vi.GroupName,
-							},
-						},
-					}
-					t.Fields.List = append(t.Fields.List, f)
-				}
-			}
-		}
-	}
-	return vi
-}
-
-func (vi *Visitor) addImport(genDecl *ast.GenDecl) ast.Visitor {
-	// 是否已经import
-	hasImported := false
-	for _, v := range genDecl.Specs {
-		importSpec := v.(*ast.ImportSpec)
-		// 如果已经包含
-		if importSpec.Path.Value == strconv.Quote(vi.ImportCode) {
-			hasImported = true
-		}
-	}
-	if !hasImported {
-		genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-			Path: &ast.BasicLit{
-				Kind:  token.STRING,
-				Value: strconv.Quote(vi.ImportCode),
-			},
-		})
-	}
-	return vi
-}
-
-func (vi *Visitor) addFuncBodyVar(funDecl *ast.FuncDecl) ast.Visitor {
-	hasVar := false
-	for _, v := range funDecl.Body.List {
-		switch varSpec := v.(type) {
-		case *ast.AssignStmt:
-			for i := range varSpec.Lhs {
-				switch nn := varSpec.Lhs[i].(type) {
-				case *ast.Ident:
-					if nn.Name == vi.PackageName+"Router" {
-						hasVar = true
-					}
-				}
-			}
-		}
-	}
-	if !hasVar {
-		assignStmt := &ast.AssignStmt{
-			Lhs: []ast.Expr{
-				&ast.Ident{
-					Name: vi.PackageName + "Router",
-					Obj: &ast.Object{
-						Kind: ast.Var,
-						Name: vi.PackageName + "Router",
-					},
-				},
-			},
-			Tok: token.DEFINE,
-			Rhs: []ast.Expr{
-				&ast.SelectorExpr{
-					X: &ast.SelectorExpr{
-						X: &ast.Ident{
-							Name: "router",
-						},
-						Sel: &ast.Ident{
-							Name: "RouterGroupApp",
-						},
-					},
-					Sel: &ast.Ident{
-						Name: cases.Title(language.English).String(vi.PackageName),
-					},
-				},
-			},
-		}
-		funDecl.Body.List = append(funDecl.Body.List, funDecl.Body.List[1])
-		index := 1
-		copy(funDecl.Body.List[index+1:], funDecl.Body.List[index:])
-		funDecl.Body.List[index] = assignStmt
-	}
-	return vi
-}
-
-func ImportReference(filepath, importCode, structName, packageName, groupName string) error {
-	fSet := token.NewFileSet()
-	fParser, err := parser.ParseFile(fSet, filepath, nil, parser.ParseComments)
-	if err != nil {
-		return err
-	}
-	importCode = strings.TrimSpace(importCode)
-	v := &Visitor{
-		ImportCode:  importCode,
-		StructName:  structName,
-		PackageName: packageName,
-		GroupName:   groupName,
-	}
-	if importCode == "" {
-		ast.Print(fSet, fParser)
-	}
-
-	ast.Walk(v, fParser)
-
-	var output []byte
-	buffer := bytes.NewBuffer(output)
-	err = format.Node(buffer, fSet, fParser)
-	if err != nil {
-		log.Fatal(err)
-	}
-	// 写回数据
-	return os.WriteFile(filepath, buffer.Bytes(), 0o600)
-}

+ 0 - 166
internal/pkg/ast/ast_gorm.go

@@ -1,166 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-// 自动为 gorm.go 注册一个自动迁移
-func AddRegisterTablesAst(path, funcName, pk, varName, dbName, model string) {
-	modelPk := fmt.Sprintf("wails-app/internal/model/%s", pk)
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	AddImport(astFile, modelPk)
-	FuncNode := FindFunction(astFile, funcName)
-	if FuncNode != nil {
-		ast.Print(fileSet, FuncNode)
-	}
-	addDBVar(FuncNode.Body, varName, dbName)
-	addAutoMigrate(FuncNode.Body, varName, pk, model)
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-// 增加一个 db库变量
-func addDBVar(astBody *ast.BlockStmt, varName, dbName string) {
-	if dbName == "" {
-		return
-	}
-	dbStr := fmt.Sprintf("\"%s\"", dbName)
-
-	for i := range astBody.List {
-		if assignStmt, ok := astBody.List[i].(*ast.AssignStmt); ok {
-			if ident, ok := assignStmt.Lhs[0].(*ast.Ident); ok {
-				if ident.Name == varName {
-					return
-				}
-			}
-		}
-	}
-	assignNode := &ast.AssignStmt{
-		Lhs: []ast.Expr{
-			&ast.Ident{
-				Name: varName,
-			},
-		},
-		Tok: token.DEFINE,
-		Rhs: []ast.Expr{
-			&ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: "global",
-					},
-					Sel: &ast.Ident{
-						Name: "GetGlobalDBByDBName",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.BasicLit{
-						Kind:  token.STRING,
-						Value: dbStr,
-					},
-				},
-			},
-		},
-	}
-	astBody.List = append([]ast.Stmt{assignNode}, astBody.List...)
-}
-
-// 为db库变量增加 AutoMigrate 方法
-func addAutoMigrate(astBody *ast.BlockStmt, dbname string, pk string, model string) {
-	if dbname == "" {
-		dbname = "db"
-	}
-	flag := true
-	ast.Inspect(astBody, func(node ast.Node) bool {
-		// 首先判断需要加入的方法调用语句是否存在 不存在则直接走到下方逻辑
-		switch n := node.(type) {
-		case *ast.CallExpr:
-			// 判断是否找到了AutoMigrate语句
-			if s, ok := n.Fun.(*ast.SelectorExpr); ok {
-				if x, ok := s.X.(*ast.Ident); ok {
-					if s.Sel.Name == "AutoMigrate" && x.Name == dbname {
-						flag = false
-						if !NeedAppendModel(n, pk, model) {
-							return false
-						}
-						// 判断已经找到了AutoMigrate语句
-						n.Args = append(n.Args, &ast.CompositeLit{
-							Type: &ast.SelectorExpr{
-								X: &ast.Ident{
-									Name: pk,
-								},
-								Sel: &ast.Ident{
-									Name: model,
-								},
-							},
-						})
-						return false
-					}
-				}
-			}
-		}
-		return true
-		//然后判断 pk.model是否存在 如果存在直接跳出 如果不存在 则向已经找到的方法调用语句的node里面push一条
-	})
-
-	if flag {
-		exprStmt := &ast.ExprStmt{
-			X: &ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: dbname,
-					},
-					Sel: &ast.Ident{
-						Name: "AutoMigrate",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.CompositeLit{
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: pk,
-							},
-							Sel: &ast.Ident{
-								Name: model,
-							},
-						},
-					},
-				},
-			}}
-		astBody.List = append(astBody.List, exprStmt)
-	}
-}
-
-// 为automigrate增加实参
-func NeedAppendModel(callNode ast.Node, pk string, model string) bool {
-	flag := true
-	ast.Inspect(callNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.SelectorExpr:
-			if x, ok := n.X.(*ast.Ident); ok {
-				if n.Sel.Name == model && x.Name == pk {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 0 - 173
internal/pkg/ast/ast_rollback.go

@@ -1,173 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"path/filepath"
-	"wails-app/internal/global"
-)
-
-func RollBackAst(pk, model string) {
-	RollGormBack(pk, model)
-	RollRouterBack(pk, model)
-}
-
-func RollGormBack(pk, model string) {
-
-	// 首先分析存在多少个ttt作为调用方的node块
-	// 如果多个 仅仅删除对应块即可
-	// 如果单个 那么还需要剔除import
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "gorm.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	var n *ast.CallExpr
-	var k int = -1
-	var pkNum = 0
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if node, ok := node.(*ast.CallExpr); ok {
-			for i := range node.Args {
-				pkOK := false
-				modelOK := false
-				ast.Inspect(node.Args[i], func(item ast.Node) bool {
-					if ii, ok := item.(*ast.Ident); ok {
-						if ii.Name == pk {
-							pkOK = true
-							pkNum++
-						}
-						if ii.Name == model {
-							modelOK = true
-						}
-					}
-					if pkOK && modelOK {
-						n = node
-						k = i
-					}
-					return true
-				})
-			}
-		}
-		return true
-	})
-	if k > 0 {
-		n.Args = append(append([]ast.Expr{}, n.Args[:k]...), n.Args[k+1:]...)
-	}
-	if pkNum == 1 {
-		var imI int = -1
-		var gp *ast.GenDecl
-		ast.Inspect(astFile, func(node ast.Node) bool {
-			if gen, ok := node.(*ast.GenDecl); ok {
-				for i := range gen.Specs {
-					if imspec, ok := gen.Specs[i].(*ast.ImportSpec); ok {
-						if imspec.Path.Value == "\"wails-app/internal/model/"+pk+"\"" {
-							gp = gen
-							imI = i
-							return false
-						}
-					}
-				}
-			}
-			return true
-		})
-
-		if imI > -1 {
-			gp.Specs = append(append([]ast.Spec{}, gp.Specs[:imI]...), gp.Specs[imI+1:]...)
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-
-}
-
-func RollRouterBack(pk, model string) {
-
-	// 首先抓到所有的代码块结构 {}
-	// 分析结构中是否存在一个变量叫做 pk+Router
-	// 然后获取到代码块指针 对内部需要回滚的代码进行剔除
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "router.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	var block *ast.BlockStmt
-	var routerStmt *ast.FuncDecl
-
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if n, ok := node.(*ast.FuncDecl); ok {
-			if n.Name.Name == "Routers" {
-				routerStmt = n
-			}
-		}
-
-		if n, ok := node.(*ast.BlockStmt); ok {
-			ast.Inspect(n, func(bNode ast.Node) bool {
-				if in, ok := bNode.(*ast.Ident); ok {
-					if in.Name == pk+"Router" {
-						block = n
-						return false
-					}
-				}
-				return true
-			})
-			return true
-		}
-		return true
-	})
-	var k int
-	for i := range block.List {
-		if stmtNode, ok := block.List[i].(*ast.ExprStmt); ok {
-			ast.Inspect(stmtNode, func(node ast.Node) bool {
-				if n, ok := node.(*ast.Ident); ok {
-					if n.Name == "Init"+model+"Router" {
-						k = i
-						return false
-					}
-				}
-				return true
-			})
-		}
-	}
-
-	block.List = append(append([]ast.Stmt{}, block.List[:k]...), block.List[k+1:]...)
-
-	if len(block.List) == 1 {
-		// 说明这个块就没任何意义了
-		block.List = nil
-	}
-
-	for i, n := range routerStmt.Body.List {
-		if n, ok := n.(*ast.BlockStmt); ok {
-			if n.List == nil {
-				routerStmt.Body.List = append(append([]ast.Stmt{}, routerStmt.Body.List[:i]...), routerStmt.Body.List[i+1:]...)
-				i--
-			}
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 135
internal/pkg/ast/ast_router.go

@@ -1,135 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"strings"
-)
-
-func AppendNodeToList(stmts []ast.Stmt, stmt ast.Stmt, index int) []ast.Stmt {
-	return append(stmts[:index], append([]ast.Stmt{stmt}, stmts[index:]...)...)
-}
-
-func AddRouterCode(path, funcName, pk, model string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, parser.ParseComments)
-
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	FuncNode := FindFunction(astFile, funcName)
-
-	pkName := strings.ToUpper(pk[:1]) + pk[1:]
-	routerName := fmt.Sprintf("%sRouter", pk)
-	modelName := fmt.Sprintf("Init%sRouter", model)
-	var bloctPre *ast.BlockStmt
-	for i := len(FuncNode.Body.List) - 1; i >= 0; i-- {
-		if block, ok := FuncNode.Body.List[i].(*ast.BlockStmt); ok {
-			bloctPre = block
-		}
-	}
-	ast.Print(fileSet, FuncNode)
-	if ok, b := needAppendRouter(FuncNode, pk); ok {
-		routerNode :=
-			&ast.BlockStmt{
-				List: []ast.Stmt{
-					&ast.AssignStmt{
-						Lhs: []ast.Expr{
-							&ast.Ident{Name: routerName},
-						},
-						Tok: token.DEFINE,
-						Rhs: []ast.Expr{
-							&ast.SelectorExpr{
-								X: &ast.SelectorExpr{
-									X:   &ast.Ident{Name: "router"},
-									Sel: &ast.Ident{Name: "RouterGroupApp"},
-								},
-								Sel: &ast.Ident{Name: pkName},
-							},
-						},
-					},
-				},
-			}
-
-		FuncNode.Body.List = AppendNodeToList(FuncNode.Body.List, routerNode, len(FuncNode.Body.List)-2)
-		bloctPre = routerNode
-	} else {
-		bloctPre = b
-	}
-
-	if needAppendInit(FuncNode, routerName, modelName) {
-		bloctPre.List = append(bloctPre.List,
-			&ast.ExprStmt{
-				X: &ast.CallExpr{
-					Fun: &ast.SelectorExpr{
-						X:   &ast.Ident{Name: routerName},
-						Sel: &ast.Ident{Name: modelName},
-					},
-					Args: []ast.Expr{
-						&ast.Ident{
-							Name: "PrivateGroup",
-						},
-						&ast.Ident{
-							Name: "PublicGroup",
-						},
-					},
-				},
-			})
-	}
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-func needAppendRouter(funcNode ast.Node, pk string) (bool, *ast.BlockStmt) {
-	flag := true
-	var block *ast.BlockStmt
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.BlockStmt:
-			for i := range n.List {
-				if assignNode, ok := n.List[i].(*ast.AssignStmt); ok {
-					if identNode, ok := assignNode.Lhs[0].(*ast.Ident); ok {
-						if identNode.Name == fmt.Sprintf("%sRouter", pk) {
-							flag = false
-							block = n
-							return false
-						}
-					}
-				}
-			}
-
-		}
-		return true
-	})
-	return flag, block
-}
-
-func needAppendInit(funcNode ast.Node, routerName string, modelName string) bool {
-	flag := true
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := funcNode.(type) {
-		case *ast.CallExpr:
-			if selectNode, ok := n.Fun.(*ast.SelectorExpr); ok {
-				x, xok := selectNode.X.(*ast.Ident)
-				if xok && x.Name == routerName && selectNode.Sel.Name == modelName {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 0 - 7
internal/resource/autocode_template/readme.txt.tpl

@@ -1,7 +0,0 @@
-代码解压后把fe的api文件内容粘贴进前端api文件夹下并修改为自己想要的名字即可
-
-后端代码解压后同理,放到自己想要的 mvc对应路径 并且到 initRouter中注册自动生成的路由 到registerTable中注册自动生成的model
-
-项目github:"https://github.com/piexlmax/github.com/flipped-aurora/gin-vue-admin/server"
-
-希望大家给个star多多鼓励

+ 0 - 202
internal/resource/autocode_template/server/api.go.tpl

@@ -1,202 +0,0 @@
-package {{.Package}}
-
-import (
-	"github.com/flipped-aurora/gin-vue-admin/server/global"
-    "github.com/flipped-aurora/gin-vue-admin/server/model/{{.Package}}"
-    {{.Package}}Req "github.com/flipped-aurora/gin-vue-admin/server/model/{{.Package}}/request"
-    "github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
-    "github.com/flipped-aurora/gin-vue-admin/server/service"
-    "github.com/gin-gonic/gin"
-    "go.uber.org/zap"
-    {{- if .AutoCreateResource}}
-    "github.com/flipped-aurora/gin-vue-admin/server/utils"
-    {{- end }}
-)
-
-type {{.StructName}}Api struct {
-}
-
-var {{.Abbreviation}}Service = service.ServiceGroupApp.{{.PackageT}}ServiceGroup.{{.StructName}}Service
-
-
-// Create{{.StructName}} 创建{{.Description}}
-// @Tags {{.StructName}}
-// @Summary 创建{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body {{.Package}}.{{.StructName}} true "创建{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}"
-// @Router /{{.Abbreviation}}/create{{.StructName}} [post]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Create{{.StructName}}(c *gin.Context) {
-	var {{.Abbreviation}} {{.Package}}.{{.StructName}}
-	err := c.ShouldBindJSON(&{{.Abbreviation}})
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-	{{- if .AutoCreateResource }}
-    {{.Abbreviation}}.CreatedBy = utils.GetUserID(c)
-	{{- end }}
-
-	if err := {{.Abbreviation}}Service.Create{{.StructName}}(&{{.Abbreviation}}); err != nil {
-        global.GVA_LOG.Error("创建失败!", zap.Error(err))
-		response.FailWithMessage("创建失败", c)
-	} else {
-		response.OkWithMessage("创建成功", c)
-	}
-}
-
-// Delete{{.StructName}} 删除{{.Description}}
-// @Tags {{.StructName}}
-// @Summary 删除{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body {{.Package}}.{{.StructName}} true "删除{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
-// @Router /{{.Abbreviation}}/delete{{.StructName}} [delete]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Delete{{.StructName}}(c *gin.Context) {
-	{{.PrimaryField.FieldJson}} := c.Query("{{.PrimaryField.FieldJson}}")
-		{{- if .AutoCreateResource }}
-    	userID := utils.GetUserID(c)
-        {{- end }}
-	if err := {{.Abbreviation}}Service.Delete{{.StructName}}({{.PrimaryField.FieldJson}} {{- if .AutoCreateResource -}},userID{{- end -}}); err != nil {
-        global.GVA_LOG.Error("删除失败!", zap.Error(err))
-		response.FailWithMessage("删除失败", c)
-	} else {
-		response.OkWithMessage("删除成功", c)
-	}
-}
-
-// Delete{{.StructName}}ByIds 批量删除{{.Description}}
-// @Tags {{.StructName}}
-// @Summary 批量删除{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"批量删除成功"}"
-// @Router /{{.Abbreviation}}/delete{{.StructName}}ByIds [delete]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Delete{{.StructName}}ByIds(c *gin.Context) {
-	{{.PrimaryField.FieldJson}}s := c.QueryArray("{{.PrimaryField.FieldJson}}s[]")
-    	{{- if .AutoCreateResource }}
-    userID := utils.GetUserID(c)
-        {{- end }}
-	if err := {{.Abbreviation}}Service.Delete{{.StructName}}ByIds({{.PrimaryField.FieldJson}}s{{- if .AutoCreateResource }},userID{{- end }}); err != nil {
-        global.GVA_LOG.Error("批量删除失败!", zap.Error(err))
-		response.FailWithMessage("批量删除失败", c)
-	} else {
-		response.OkWithMessage("批量删除成功", c)
-	}
-}
-
-// Update{{.StructName}} 更新{{.Description}}
-// @Tags {{.StructName}}
-// @Summary 更新{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body {{.Package}}.{{.StructName}} true "更新{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"更新成功"}"
-// @Router /{{.Abbreviation}}/update{{.StructName}} [put]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Update{{.StructName}}(c *gin.Context) {
-	var {{.Abbreviation}} {{.Package}}.{{.StructName}}
-	err := c.ShouldBindJSON(&{{.Abbreviation}})
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-	    {{- if .AutoCreateResource }}
-    {{.Abbreviation}}.UpdatedBy = utils.GetUserID(c)
-        {{- end }}
-
-	if err := {{.Abbreviation}}Service.Update{{.StructName}}({{.Abbreviation}}); err != nil {
-        global.GVA_LOG.Error("更新失败!", zap.Error(err))
-		response.FailWithMessage("更新失败", c)
-	} else {
-		response.OkWithMessage("更新成功", c)
-	}
-}
-
-// Find{{.StructName}} 用id查询{{.Description}}
-// @Tags {{.StructName}}
-// @Summary 用id查询{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data query {{.Package}}.{{.StructName}} true "用id查询{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}"
-// @Router /{{.Abbreviation}}/find{{.StructName}} [get]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Find{{.StructName}}(c *gin.Context) {
-	{{.PrimaryField.FieldJson}} := c.Query("{{.PrimaryField.FieldJson}}")
-	if re{{.Abbreviation}}, err := {{.Abbreviation}}Service.Get{{.StructName}}({{.PrimaryField.FieldJson}}); err != nil {
-        global.GVA_LOG.Error("查询失败!", zap.Error(err))
-		response.FailWithMessage("查询失败", c)
-	} else {
-		response.OkWithData(gin.H{"re{{.Abbreviation}}": re{{.Abbreviation}}}, c)
-	}
-}
-
-// Get{{.StructName}}List 分页获取{{.Description}}列表
-// @Tags {{.StructName}}
-// @Summary 分页获取{{.Description}}列表
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data query {{.Package}}Req.{{.StructName}}Search true "分页获取{{.Description}}列表"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
-// @Router /{{.Abbreviation}}/get{{.StructName}}List [get]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Get{{.StructName}}List(c *gin.Context) {
-	var pageInfo {{.Package}}Req.{{.StructName}}Search
-	err := c.ShouldBindQuery(&pageInfo)
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
-	}
-	if list, total, err := {{.Abbreviation}}Service.Get{{.StructName}}InfoList(pageInfo); err != nil {
-	    global.GVA_LOG.Error("获取失败!", zap.Error(err))
-        response.FailWithMessage("获取失败", c)
-    } else {
-        response.OkWithDetailed(response.PageResult{
-            List:     list,
-            Total:    total,
-            Page:     pageInfo.Page,
-            PageSize: pageInfo.PageSize,
-        }, "获取成功", c)
-    }
-}
-
-{{- if .HasDataSource }}
-// Get{{.StructName}}DataSource 获取{{.StructName}}的数据源
-// @Tags {{.StructName}}
-// @Summary 获取{{.StructName}}的数据源
-// @accept application/json
-// @Produce application/json
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
-// @Router /{{.Abbreviation}}/get{{.StructName}}DataSource [get]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Get{{.StructName}}DataSource(c *gin.Context) {
-    // 此接口为获取数据源定义的数据
-   if dataSource, err := {{.Abbreviation}}Service.Get{{.StructName}}DataSource(); err != nil {
-        global.GVA_LOG.Error("查询失败!", zap.Error(err))
-   		response.FailWithMessage("查询失败", c)
-   	} else {
-   		response.OkWithData(dataSource, c)
-   	}
-}
-{{- end }}
-
-// Get{{.StructName}}Public 不需要鉴权的{{.Description}}接口
-// @Tags {{.StructName}}
-// @Summary 不需要鉴权的{{.Description}}接口
-// @accept application/json
-// @Produce application/json
-// @Param data query {{.Package}}Req.{{.StructName}}Search true "分页获取{{.Description}}列表"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
-// @Router /{{.Abbreviation}}/get{{.StructName}}List [get]
-func ({{.Abbreviation}}Api *{{.StructName}}Api) Get{{.StructName}}Public(c *gin.Context) {
-    // 此接口不需要鉴权
-    // 示例为返回了一个固定的消息接口,一般本接口用于C端服务,需要自己实现业务逻辑
-    response.OkWithDetailed(gin.H{
-       "info": "不需要鉴权的{{.Description}}接口信息",
-    }, "获取成功", c)
-}

+ 0 - 45
internal/resource/autocode_template/server/model.go.tpl

@@ -1,45 +0,0 @@
-// 自动生成模板{{.StructName}}
-package {{.Package}}
-
-import (
-	{{ if .GvaModel }}"github.com/flipped-aurora/gin-vue-admin/server/global"{{ end }}
-	{{ if or .HasTimer }}"time"{{ end }}
-	{{ if .NeedJSON }}"gorm.io/datatypes"{{ end }}
-)
-
-// {{.Description}} 结构体  {{.StructName}}
-type {{.StructName}} struct {
-{{ if .GvaModel }} global.GVA_MODEL {{ end }}
-      {{- range .Fields}}
-            {{- if eq .FieldType "enum" }}
-      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};type:enum({{.DataTypeLong}});comment:{{.Comment}};" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if eq .FieldType "picture" }}
-      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if eq .FieldType "video" }}
-      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-             {{- else if eq .FieldType "file" }}
-      {{.FieldName}}  datatypes.JSON `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if eq .FieldType "pictures" }}
-      {{.FieldName}}  datatypes.JSON `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if eq .FieldType "richtext" }}
-      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}type:text;" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if eq .FieldType "json" }}
-      {{.FieldName}}  datatypes.JSON `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}type:text;" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else if ne .FieldType "string" }}
-      {{.FieldName}}  *{{.FieldType}} `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- else }}
-      {{.FieldName}}  {{.FieldType}} `json:"{{.FieldJson}}" form:"{{.FieldJson}}" gorm:"{{- if .PrimaryKey -}}primarykey;{{- end -}}{{- if .DefaultValue -}}default:{{ .DefaultValue }};{{- end -}}column:{{.ColumnName}};comment:{{.Comment}};{{- if .DataTypeLong -}}size:{{.DataTypeLong}};{{- end -}}" {{- if .Require }} binding:"required"{{- end -}}`
-            {{- end }}  {{ if .FieldDesc }}//{{.FieldDesc}} {{ end }} {{- end }}
-      {{- if .AutoCreateResource }}
-      CreatedBy  uint   `gorm:"column:created_by;comment:创建者"`
-      UpdatedBy  uint   `gorm:"column:updated_by;comment:更新者"`
-      DeletedBy  uint   `gorm:"column:deleted_by;comment:删除者"`
-      {{- end}}
-}
-
-{{ if .TableName }}
-// TableName {{.Description}} {{.StructName}}自定义表名 {{.TableName}}
-func ({{.StructName}}) TableName() string {
-  return "{{.TableName}}"
-}
-{{ end }}

+ 0 - 45
internal/resource/autocode_template/server/request.go.tpl

@@ -1,45 +0,0 @@
-package request
-
-import (
-	"github.com/flipped-aurora/gin-vue-admin/server/model/common/request"
-	{{ if or .HasSearchTimer .GvaModel}}"time"{{ end }}
-	{{ if .NeedJSON }}"gorm.io/datatypes"{{ end }}
-)
-
-type {{.StructName}}Search struct{
-    {{ if .GvaModel }}
-        StartCreatedAt *time.Time `json:"startCreatedAt" form:"startCreatedAt"`
-        EndCreatedAt   *time.Time `json:"endCreatedAt" form:"endCreatedAt"`
-    {{ end }}
-    {{- range .Fields}}
-        {{- if ne .FieldSearchType ""}}
-            {{- if eq .FieldSearchType "BETWEEN" "NOT BETWEEN"}}
-                Start{{.FieldName}}  *{{.FieldType}}  `json:"start{{.FieldName}}" form:"start{{.FieldName}}"`
-                End{{.FieldName}}  *{{.FieldType}}  `json:"end{{.FieldName}}" form:"end{{.FieldName}}"`
-             {{- else }}
-                {{- if eq .FieldType "enum" }}
-                      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}"`
-                            {{- else if eq .FieldType "picture" }}
-                      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- else if eq .FieldType "video" }}
-                      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                             {{- else if eq .FieldType "file" }}
-                      {{.FieldName}}  datatypes.JSON `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- else if eq .FieldType "pictures" }}
-                      {{.FieldName}}  datatypes.JSON `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- else if eq .FieldType "richtext" }}
-                      {{.FieldName}}  string `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- else if ne .FieldType "string" }}
-                      {{.FieldName}}  *{{.FieldType}} `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- else }}
-                      {{.FieldName}}  {{.FieldType}} `json:"{{.FieldJson}}" form:"{{.FieldJson}}" `
-                            {{- end }}
-            {{- end }}
-        {{- end}}
-       {{- end }}
-    request.PageInfo
-    {{- if .NeedSort}}
-    Sort  string `json:"sort" form:"sort"`
-    Order string `json:"order" form:"order"`
-    {{- end}}
-}

+ 0 - 35
internal/resource/autocode_template/server/router.go.tpl

@@ -1,35 +0,0 @@
-package {{.Package}}
-
-import (
-	"github.com/flipped-aurora/gin-vue-admin/server/api/v1"
-	"github.com/flipped-aurora/gin-vue-admin/server/middleware"
-	"github.com/gin-gonic/gin"
-)
-
-type {{.StructName}}Router struct {
-}
-
-// Init{{.StructName}}Router 初始化 {{.Description}} 路由信息
-func (s *{{.StructName}}Router) Init{{.StructName}}Router(Router *gin.RouterGroup,PublicRouter *gin.RouterGroup) {
-	{{.Abbreviation}}Router := Router.Group("{{.Abbreviation}}").Use(middleware.OperationRecord())
-	{{.Abbreviation}}RouterWithoutRecord := Router.Group("{{.Abbreviation}}")
-	{{.Abbreviation}}RouterWithoutAuth := PublicRouter.Group("{{.Abbreviation}}")
-
-	var {{.Abbreviation}}Api = v1.ApiGroupApp.{{.PackageT}}ApiGroup.{{.StructName}}Api
-	{
-		{{.Abbreviation}}Router.POST("create{{.StructName}}", {{.Abbreviation}}Api.Create{{.StructName}})   // 新建{{.Description}}
-		{{.Abbreviation}}Router.DELETE("delete{{.StructName}}", {{.Abbreviation}}Api.Delete{{.StructName}}) // 删除{{.Description}}
-		{{.Abbreviation}}Router.DELETE("delete{{.StructName}}ByIds", {{.Abbreviation}}Api.Delete{{.StructName}}ByIds) // 批量删除{{.Description}}
-		{{.Abbreviation}}Router.PUT("update{{.StructName}}", {{.Abbreviation}}Api.Update{{.StructName}})    // 更新{{.Description}}
-	}
-	{
-		{{.Abbreviation}}RouterWithoutRecord.GET("find{{.StructName}}", {{.Abbreviation}}Api.Find{{.StructName}})        // 根据ID获取{{.Description}}
-		{{.Abbreviation}}RouterWithoutRecord.GET("get{{.StructName}}List", {{.Abbreviation}}Api.Get{{.StructName}}List)  // 获取{{.Description}}列表
-	}
-	{
-	{{- if .HasDataSource}}
-	    {{.Abbreviation}}RouterWithoutAuth.GET("get{{.StructName}}DataSource", {{.Abbreviation}}Api.Get{{.StructName}}DataSource)  // 获取{{.Description}}数据源
-	{{- end}}
-	    {{.Abbreviation}}RouterWithoutAuth.GET("get{{.StructName}}Public", {{.Abbreviation}}Api.Get{{.StructName}}Public)  // 获取{{.Description}}列表
-	}
-}

+ 0 - 151
internal/resource/autocode_template/server/service.go.tpl

@@ -1,151 +0,0 @@
-package {{.Package}}
-
-import (
-	"github.com/flipped-aurora/gin-vue-admin/server/global"
-	"github.com/flipped-aurora/gin-vue-admin/server/model/{{.Package}}"
-    {{.Package}}Req "github.com/flipped-aurora/gin-vue-admin/server/model/{{.Package}}/request"
-    {{- if .AutoCreateResource }}
-    "gorm.io/gorm"
-    {{- end}}
-)
-
-type {{.StructName}}Service struct {
-}
-
-{{- $db := "" }}
-{{- if eq .BusinessDB "" }}
- {{- $db = "global.GVA_DB" }}
-{{- else}}
- {{- $db =  printf "global.MustGetGlobalDBByDBName(\"%s\")" .BusinessDB   }}
-{{- end}}
-
-// Create{{.StructName}} 创建{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service) Create{{.StructName}}({{.Abbreviation}} *{{.Package}}.{{.StructName}}) (err error) {
-	err = {{$db}}.Create({{.Abbreviation}}).Error
-	return err
-}
-
-// Delete{{.StructName}} 删除{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Delete{{.StructName}}({{.PrimaryField.FieldJson}} string{{- if .AutoCreateResource -}},userID uint{{- end -}}) (err error) {
-	{{- if .AutoCreateResource }}
-	err = {{$db}}.Transaction(func(tx *gorm.DB) error {
-	    if err := tx.Model(&{{.Package}}.{{.StructName}}{}).Where("{{.PrimaryField.ColumnName}} = ?", {{.PrimaryField.FieldJson}}).Update("deleted_by", userID).Error; err != nil {
-              return err
-        }
-        if err = tx.Delete(&{{.Package}}.{{.StructName}}{},"{{.PrimaryField.ColumnName}} = ?",{{.PrimaryField.FieldJson}}).Error; err != nil {
-              return err
-        }
-        return nil
-	})
-    {{- else }}
-	err = {{$db}}.Delete(&{{.Package}}.{{.StructName}}{},"{{.PrimaryField.ColumnName}} = ?",{{.PrimaryField.FieldJson}}).Error
-	{{- end }}
-	return err
-}
-
-// Delete{{.StructName}}ByIds 批量删除{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Delete{{.StructName}}ByIds({{.PrimaryField.FieldJson}}s []string {{- if .AutoCreateResource }},deleted_by uint{{- end}}) (err error) {
-	{{- if .AutoCreateResource }}
-	err = {{$db}}.Transaction(func(tx *gorm.DB) error {
-	    if err := tx.Model(&{{.Package}}.{{.StructName}}{}).Where("{{.PrimaryField.ColumnName}} in ?", {{.PrimaryField.FieldJson}}s).Update("deleted_by", deleted_by).Error; err != nil {
-            return err
-        }
-        if err := tx.Where("{{.PrimaryField.ColumnName}} in ?", {{.PrimaryField.FieldJson}}s).Delete(&{{.Package}}.{{.StructName}}{}).Error; err != nil {
-            return err
-        }
-        return nil
-    })
-    {{- else}}
-	err = {{$db}}.Delete(&[]{{.Package}}.{{.StructName}}{},"{{.PrimaryField.ColumnName}} in ?",{{.PrimaryField.FieldJson}}s).Error
-    {{- end}}
-	return err
-}
-
-// Update{{.StructName}} 更新{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Update{{.StructName}}({{.Abbreviation}} {{.Package}}.{{.StructName}}) (err error) {
-	err = {{$db}}.Model(&{{.Package}}.{{.StructName}}{}).Where("{{.PrimaryField.ColumnName}} = ?",{{.Abbreviation}}.{{.PrimaryField.FieldName}}).Updates(&{{.Abbreviation}}).Error
-	return err
-}
-
-// Get{{.StructName}} 根据{{.PrimaryField.FieldJson}}获取{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Get{{.StructName}}({{.PrimaryField.FieldJson}} string) ({{.Abbreviation}} {{.Package}}.{{.StructName}}, err error) {
-	err = {{$db}}.Where("{{.PrimaryField.ColumnName}} = ?", {{.PrimaryField.FieldJson}}).First(&{{.Abbreviation}}).Error
-	return
-}
-
-// Get{{.StructName}}InfoList 分页获取{{.Description}}记录
-// Author [piexlmax](https://github.com/piexlmax)
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Get{{.StructName}}InfoList(info {{.Package}}Req.{{.StructName}}Search) (list []{{.Package}}.{{.StructName}}, total int64, err error) {
-	limit := info.PageSize
-	offset := info.PageSize * (info.Page - 1)
-    // 创建db
-	db := {{$db}}.Model(&{{.Package}}.{{.StructName}}{})
-    var {{.Abbreviation}}s []{{.Package}}.{{.StructName}}
-    // 如果有条件搜索 下方会自动创建搜索语句
-{{- if .GvaModel }}
-    if info.StartCreatedAt !=nil && info.EndCreatedAt !=nil {
-     db = db.Where("created_at BETWEEN ? AND ?", info.StartCreatedAt, info.EndCreatedAt)
-    }
-{{- end }}
-        {{- range .Fields}}
-            {{- if .FieldSearchType}}
-                {{- if or (eq .FieldType "string") (eq .FieldType "enum") (eq .FieldType "picture") (eq .FieldType "video") (eq .FieldType "richtext") }}
-    if info.{{.FieldName}} != "" {
-        db = db.Where("{{.ColumnName}} {{.FieldSearchType}} ?",{{if eq .FieldSearchType "LIKE"}}"%"+ {{ end }}info.{{.FieldName}}{{if eq .FieldSearchType "LIKE"}}+"%"{{ end }})
-    }
-    {{- else if eq .FieldSearchType "BETWEEN" "NOT BETWEEN"}}
-        if info.Start{{.FieldName}} != nil && info.End{{.FieldName}} != nil {
-            db = db.Where("{{.ColumnName}} {{.FieldSearchType}} ? AND ? ",info.Start{{.FieldName}},info.End{{.FieldName}})
-        }
-    {{- else}}
-    if info.{{.FieldName}} != nil {
-        db = db.Where("{{.ColumnName}} {{.FieldSearchType}} ?",{{if eq .FieldSearchType "LIKE"}}"%"+{{ end }}info.{{.FieldName}}{{if eq .FieldSearchType "LIKE"}}+"%"{{ end }})
-    }
-            {{- end }}
-        {{- end }}
-    {{- end }}
-	err = db.Count(&total).Error
-	if err!=nil {
-    	return
-    }
-    {{- if .NeedSort}}
-        var OrderStr string
-        orderMap := make(map[string]bool)
-       {{- range .Fields}}
-            {{- if .Sort}}
-         	orderMap["{{.ColumnName}}"] = true
-         	{{- end}}
-       {{- end}}
-       if orderMap[info.Sort] {
-          OrderStr = info.Sort
-          if info.Order == "descending" {
-             OrderStr = OrderStr + " desc"
-          }
-          db = db.Order(OrderStr)
-       }
-    {{- end}}
-
-	if limit != 0 {
-       db = db.Limit(limit).Offset(offset)
-    }
-	
-	err = db.Find(&{{.Abbreviation}}s).Error
-	return  {{.Abbreviation}}s, total, err
-}
-
-{{- if .HasDataSource }}
-func ({{.Abbreviation}}Service *{{.StructName}}Service)Get{{.StructName}}DataSource() (res map[string][]map[string]any, err error) {
-	res = make(map[string][]map[string]any)
-	{{range $key, $value := .DataSourceMap}}
-	   {{$key}} := make([]map[string]any, 0)
-       {{$db}}.Table("{{$value.Table}}").Select("{{$value.Label}} as label,{{$value.Value}} as value").Scan(&{{$key}})
-	   res["{{$key}}"] = {{$key}}
-	{{- end }}
-	return
-}
-{{- end }}

+ 0 - 4
internal/resource/autocode_template/subcontract/api_enter.go.tpl

@@ -1,4 +0,0 @@
-package {{ .PackageName }}
-
-type ApiGroup struct {
-}

+ 0 - 14
internal/resource/autocode_template/subcontract/data.go

@@ -1,14 +0,0 @@
-package subcontract
-
-import (
-	_ "embed"
-)
-
-//go:embed api_enter.go.tpl
-var API []byte
-
-//go:embed  router_enter.go.tpl
-var Router []byte
-
-//go:embed service_enter.go.tpl
-var Server []byte

+ 0 - 4
internal/resource/autocode_template/subcontract/router_enter.go.tpl

@@ -1,4 +0,0 @@
-package {{ .PackageName }}
-
-type RouterGroup struct {
-}

+ 0 - 6
internal/resource/autocode_template/subcontract/service_enter.go.tpl

@@ -1,6 +0,0 @@
-package {{ .PackageName }}
-
-
-type ServiceGroup struct {
-}
-

+ 0 - 113
internal/resource/autocode_template/web/api.js.tpl

@@ -1,113 +0,0 @@
-import service from '@/utils/request'
-
-// @Tags {{.StructName}}
-// @Summary 创建{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body model.{{.StructName}} true "创建{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}"
-// @Router /{{.Abbreviation}}/create{{.StructName}} [post]
-export const create{{.StructName}} = (data) => {
-  return service({
-    url: '/{{.Abbreviation}}/create{{.StructName}}',
-    method: 'post',
-    data
-  })
-}
-
-// @Tags {{.StructName}}
-// @Summary 删除{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body model.{{.StructName}} true "删除{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
-// @Router /{{.Abbreviation}}/delete{{.StructName}} [delete]
-export const delete{{.StructName}} = (params) => {
-  return service({
-    url: '/{{.Abbreviation}}/delete{{.StructName}}',
-    method: 'delete',
-    params
-  })
-}
-
-// @Tags {{.StructName}}
-// @Summary 批量删除{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body request.IdsReq true "批量删除{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
-// @Router /{{.Abbreviation}}/delete{{.StructName}} [delete]
-export const delete{{.StructName}}ByIds = (params) => {
-  return service({
-    url: '/{{.Abbreviation}}/delete{{.StructName}}ByIds',
-    method: 'delete',
-    params
-  })
-}
-
-// @Tags {{.StructName}}
-// @Summary 更新{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data body model.{{.StructName}} true "更新{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"更新成功"}"
-// @Router /{{.Abbreviation}}/update{{.StructName}} [put]
-export const update{{.StructName}} = (data) => {
-  return service({
-    url: '/{{.Abbreviation}}/update{{.StructName}}',
-    method: 'put',
-    data
-  })
-}
-
-// @Tags {{.StructName}}
-// @Summary 用id查询{{.Description}}
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data query model.{{.StructName}} true "用id查询{{.Description}}"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}"
-// @Router /{{.Abbreviation}}/find{{.StructName}} [get]
-export const find{{.StructName}} = (params) => {
-  return service({
-    url: '/{{.Abbreviation}}/find{{.StructName}}',
-    method: 'get',
-    params
-  })
-}
-
-// @Tags {{.StructName}}
-// @Summary 分页获取{{.Description}}列表
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Param data query request.PageInfo true "分页获取{{.Description}}列表"
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
-// @Router /{{.Abbreviation}}/get{{.StructName}}List [get]
-export const get{{.StructName}}List = (params) => {
-  return service({
-    url: '/{{.Abbreviation}}/get{{.StructName}}List',
-    method: 'get',
-    params
-  })
-}
-
-{{- if .HasDataSource}}
-// @Tags {{.StructName}}
-// @Summary 获取数据源
-// @Security ApiKeyAuth
-// @accept application/json
-// @Produce application/json
-// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}"
-// @Router /{{.Abbreviation}}/find{{.StructName}}DataSource [get]
-export const get{{.StructName}}DataSource = () => {
-  return service({
-    url: '/{{.Abbreviation}}/get{{.StructName}}DataSource',
-    method: 'get',
-  })
-}
-{{- end}}

+ 0 - 221
internal/resource/autocode_template/web/form.vue.tpl

@@ -1,221 +0,0 @@
-<template>
-  <div>
-    <div class="gva-form-box">
-      <el-form :model="formData" ref="elFormRef" label-position="right" :rules="rule" label-width="80px">
-      {{- range .Fields}}
-        <el-form-item label="{{.FieldDesc}}:" prop="{{.FieldJson}}">
-       {{- if .CheckDataSource}}
-        <el-select v-model="formData.{{.FieldJson}}" placeholder="请选择{{.FieldDesc}}" style="width:100%" :clearable="{{.Clearable}}" >
-          <el-option v-for="(item,key) in dataSource.{{.FieldJson}}" :key="key" :label="item.label" :value="item.value" />
-        </el-select>
-       {{- else }}
-      {{- if eq .FieldType "bool" }}
-          <el-switch v-model="formData.{{.FieldJson}}" active-color="#13ce66" inactive-color="#ff4949" active-text="是" inactive-text="否" clearable ></el-switch>
-      {{- end }}
-      {{- if eq .FieldType "string" }}
-      {{- if .DictType}}
-           <el-select v-model="formData.{{ .FieldJson }}" placeholder="请选择{{.FieldDesc}}" style="width:100%" :clearable="{{.Clearable}}" >
-              <el-option v-for="(item,key) in {{ .DictType }}Options" :key="key" :label="item.label" :value="item.value" />
-           </el-select>
-      {{- else }}
-          <el-input v-model="formData.{{.FieldJson}}" :clearable="{{.Clearable}}"  placeholder="请输入{{.FieldDesc}}" />
-      {{- end }}
-      {{- end }}
-      {{- if eq .FieldType "richtext" }}
-          <RichEdit v-model="formData.{{.FieldJson}}"/>
-      {{- end }}
-      {{- if eq .FieldType "int" }}
-          <el-input v-model.number="formData.{{ .FieldJson }}" :clearable="{{.Clearable}}" placeholder="请输入" />
-      {{- end }}
-      {{- if eq .FieldType "time.Time" }}
-          <el-date-picker v-model="formData.{{ .FieldJson }}" type="date" placeholder="选择日期" :clearable="{{.Clearable}}"></el-date-picker>
-      {{- end }}
-      {{- if eq .FieldType "float64" }}
-          <el-input-number v-model="formData.{{ .FieldJson }}" :precision="2" :clearable="{{.Clearable}}"></el-input-number>
-      {{- end }}
-      {{- if eq .FieldType "enum" }}
-        <el-select v-model="formData.{{ .FieldJson }}" placeholder="请选择" style="width:100%" :clearable="{{.Clearable}}">
-          <el-option v-for="item in [{{ .DataTypeLong }}]" :key="item" :label="item" :value="item" />
-        </el-select>
-      {{- end }}
-       {{- if eq .FieldType "picture" }}
-          <SelectImage v-model="formData.{{ .FieldJson }}" file-type="image"/>
-       {{- end }}
-       {{- if eq .FieldType "video" }}
-          <SelectImage v-model="formData.{{ .FieldJson }}" file-type="video"/>
-       {{- end }}
-       {{- if eq .FieldType "pictures" }}
-           <SelectImage v-model="formData.{{ .FieldJson }}" multiple file-type="image"/>
-       {{- end }}
-       {{- if eq .FieldType "file" }}
-          <SelectFile v-model="formData.{{ .FieldJson }}" />
-       {{- end }}
-       {{- if eq .FieldType "json" }}
-          // 此字段为json结构,可以前端自行控制展示和数据绑定模式 需绑定json的key为 formData.{{.FieldJson}} 后端会按照json的类型进行存取
-          {{"{{"}} formData.{{.FieldJson}} {{"}}"}}
-       {{- end }}
-       {{- end }}
-       </el-form-item>
-      {{- end }}
-        <el-form-item>
-          <el-button type="primary" @click="save">保存</el-button>
-          <el-button type="primary" @click="back">返回</el-button>
-        </el-form-item>
-      </el-form>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import {
-  {{- if .HasDataSource }}
-    get{{.StructName}}DataSource,
-  {{- end }}
-  create{{.StructName}},
-  update{{.StructName}},
-  find{{.StructName}}
-} from '@/api/{{.Package}}/{{.PackageName}}'
-
-defineOptions({
-    name: '{{.StructName}}Form'
-})
-
-// 自动获取字典
-import { getDictFunc } from '@/utils/format'
-import { useRoute, useRouter } from "vue-router"
-import { ElMessage } from 'element-plus'
-import { ref, reactive } from 'vue'
-{{- if .HasPic }}
-import SelectImage from '@/components/selectImage/selectImage.vue'
-{{- end }}
-{{- if .HasFile }}
-import SelectFile from '@/components/selectFile/selectFile.vue'
-{{- end }}
-
-{{- if .HasRichText }}
-// 富文本组件
-import RichEdit from '@/components/richtext/rich-edit.vue'
-{{- end }}
-
-const route = useRoute()
-const router = useRouter()
-
-const type = ref('')
-    {{- range $index, $element := .DictTypes}}
-const {{ $element }}Options = ref([])
-    {{- end }}
-const formData = ref({
-        {{- range .Fields}}
-            {{- if eq .FieldType "bool" }}
-            {{.FieldJson}}: false,
-            {{- end }}
-            {{- if eq .FieldType "string" }}
-            {{.FieldJson}}: '',
-            {{- end }}
-            {{- if eq .FieldType "richtext" }}
-            {{.FieldJson}}: '',
-            {{- end }}
-            {{- if eq .FieldType "int" }}
-            {{.FieldJson}}: {{- if .DictType }} undefined{{ else }} 0{{- end }},
-            {{- end }}
-            {{- if eq .FieldType "time.Time" }}
-            {{.FieldJson}}: new Date(),
-            {{- end }}
-            {{- if eq .FieldType "float64" }}
-            {{.FieldJson}}: 0,
-            {{- end }}
-            {{- if eq .FieldType "picture" }}
-            {{.FieldJson}}: "",
-            {{- end }}
-            {{- if eq .FieldType "video" }}
-            {{.FieldJson}}: "",
-            {{- end }}
-            {{- if eq .FieldType "pictures" }}
-            {{.FieldJson}}: [],
-            {{- end }}
-            {{- if eq .FieldType "file" }}
-            {{.FieldJson}}: [],
-            {{- end }}
-            {{- if eq .FieldType "json" }}
-            {{.FieldJson}}: {},
-            {{- end }}
-        {{- end }}
-        })
-// 验证规则
-const rule = reactive({
-    {{- range .Fields }}
-            {{- if eq .Require true }}
-               {{.FieldJson }} : [{
-                   required: true,
-                   message: '{{ .ErrorText }}',
-                   trigger: ['input','blur'],
-               }],
-            {{- end }}
-    {{- end }}
-})
-
-const elFormRef = ref()
-
-{{- if .HasDataSource }}
-  const dataSource = ref([])
-  const getDataSourceFunc = async()=>{
-    const res = await get{{.StructName}}DataSource()
-    if (res.code === 0) {
-      dataSource.value = res.data
-    }
-  }
-  getDataSourceFunc()
-{{- end }}
-
-// 初始化方法
-const init = async () => {
- // 建议通过url传参获取目标数据ID 调用 find方法进行查询数据操作 从而决定本页面是create还是update 以下为id作为url参数示例
-    if (route.query.id) {
-      const res = await find{{.StructName}}({ ID: route.query.id })
-      if (res.code === 0) {
-        formData.value = res.data.re{{.Abbreviation}}
-        type.value = 'update'
-      }
-    } else {
-      type.value = 'create'
-    }
-    {{- range $index, $element := .DictTypes }}
-    {{ $element }}Options.value = await getDictFunc('{{$element}}')
-    {{- end }}
-}
-
-init()
-// 保存按钮
-const save = async() => {
-      elFormRef.value?.validate( async (valid) => {
-         if (!valid) return
-            let res
-           switch (type.value) {
-             case 'create':
-               res = await create{{.StructName}}(formData.value)
-               break
-             case 'update':
-               res = await update{{.StructName}}(formData.value)
-               break
-             default:
-               res = await create{{.StructName}}(formData.value)
-               break
-           }
-           if (res.code === 0) {
-             ElMessage({
-               type: 'success',
-               message: '创建/更改成功'
-             })
-           }
-       })
-}
-
-// 返回按钮
-const back = () => {
-    router.go(-1)
-}
-
-</script>
-
-<style>
-</style>

+ 0 - 685
internal/resource/autocode_template/web/table.vue.tpl

@@ -1,685 +0,0 @@
-<template>
-  <div>
-    <div class="gva-search-box">
-      <el-form ref="elSearchFormRef" :inline="true" :model="searchInfo" class="demo-form-inline" :rules="searchRule" @keyup.enter="onSubmit">
-      {{- if .GvaModel }}
-      <el-form-item label="创建日期" prop="createdAt">
-      <template #label>
-        <span>
-          创建日期
-          <el-tooltip content="搜索范围是开始日期(包含)至结束日期(不包含)">
-            <el-icon><QuestionFilled /></el-icon>
-          </el-tooltip>
-        </span>
-      </template>
-      <el-date-picker v-model="searchInfo.startCreatedAt" type="datetime" placeholder="开始日期" :disabled-date="time=> searchInfo.endCreatedAt ? time.getTime() > searchInfo.endCreatedAt.getTime() : false"></el-date-picker>
-       —
-      <el-date-picker v-model="searchInfo.endCreatedAt" type="datetime" placeholder="结束日期" :disabled-date="time=> searchInfo.startCreatedAt ? time.getTime() < searchInfo.startCreatedAt.getTime() : false"></el-date-picker>
-      </el-form-item>
-      {{ end -}}
-           {{- range .Fields}}  {{- if .FieldSearchType}} {{- if eq .FieldType "bool" }}
-            <el-form-item label="{{.FieldDesc}}" prop="{{.FieldJson}}">
-            <el-select v-model="searchInfo.{{.FieldJson}}" clearable placeholder="请选择">
-                <el-option
-                    key="true"
-                    label="是"
-                    value="true">
-                </el-option>
-                <el-option
-                    key="false"
-                    label="否"
-                    value="false">
-                </el-option>
-            </el-select>
-            </el-form-item>
-           {{- else if .DictType}}
-           <el-form-item label="{{.FieldDesc}}" prop="{{.FieldJson}}">
-            <el-select v-model="searchInfo.{{.FieldJson}}" clearable placeholder="请选择" @clear="()=>{searchInfo.{{.FieldJson}}=undefined}">
-              <el-option v-for="(item,key) in {{ .DictType }}Options" :key="key" :label="item.label" :value="item.value" />
-            </el-select>
-            </el-form-item>
-            {{- else}}
-        <el-form-item label="{{.FieldDesc}}" prop="{{.FieldJson}}">
-        {{- if eq .FieldType "float64" "int"}}
-            {{if eq .FieldSearchType "BETWEEN" "NOT BETWEEN"}}
-            <el-input v-model.number="searchInfo.start{{.FieldName}}" placeholder="最小值" />
-            —
-            <el-input v-model.number="searchInfo.end{{.FieldName}}" placeholder="最大值" />
-           {{- else}}
-             {{- if .DictType}}
-              <el-select v-model="searchInfo.{{.FieldJson}}" placeholder="请选择" style="width:100%" :clearable="true" >
-               <el-option v-for="(item,key) in {{ .DictType }}Options" :key="key" :label="item.label" :value="item.value" />
-             </el-select>
-                    {{- else}}
-             <el-input v-model.number="searchInfo.{{.FieldJson}}" placeholder="搜索条件" />
-                    {{- end }}
-          {{- end}}
-        {{- else if eq .FieldType "time.Time"}}
-            {{if eq .FieldSearchType "BETWEEN" "NOT BETWEEN"}}
-            <template #label>
-            <span>
-              {{.FieldDesc}}
-              <el-tooltip content="搜索范围是开始日期(包含)至结束日期(不包含)">
-                <el-icon><QuestionFilled /></el-icon>
-              </el-tooltip>
-            </span>
-          </template>
-            <el-date-picker v-model="searchInfo.start{{.FieldName}}" type="datetime" placeholder="开始日期" :disabled-date="time=> searchInfo.end{{.FieldName}} ? time.getTime() > searchInfo.end{{.FieldName}}.getTime() : false"></el-date-picker>
-            —
-            <el-date-picker v-model="searchInfo.end{{.FieldName}}" type="datetime" placeholder="结束日期" :disabled-date="time=> searchInfo.start{{.FieldName}} ? time.getTime() < searchInfo.start{{.FieldName}}.getTime() : false"></el-date-picker>
-           {{- else}}
-           <el-date-picker v-model="searchInfo.{{.FieldJson}}" type="datetime" placeholder="搜索条件"></el-date-picker>
-          {{- end}}
-        {{- else}}
-         <el-input v-model="searchInfo.{{.FieldJson}}" placeholder="搜索条件" />
-        {{- end}}
-
-        </el-form-item>{{ end }}{{ end }}{{ end }}
-        <el-form-item>
-          <el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
-          <el-button icon="refresh" @click="onReset">重置</el-button>
-        </el-form-item>
-      </el-form>
-    </div>
-    <div class="gva-table-box">
-        <div class="gva-btn-list">
-            <el-button type="primary" icon="plus" @click="openDialog">新增</el-button>
-            <el-button icon="delete" style="margin-left: 10px;" :disabled="!multipleSelection.length" @click="onDelete">删除</el-button>
-        </div>
-        <el-table
-        ref="multipleTable"
-        style="width: 100%"
-        tooltip-effect="dark"
-        :data="tableData"
-        row-key="{{.PrimaryField.FieldJson}}"
-        @selection-change="handleSelectionChange"
-        {{- if .NeedSort}}
-        @sort-change="sortChange"
-        {{- end}}
-        >
-        <el-table-column type="selection" width="55" />
-        {{ if .GvaModel }}
-        <el-table-column align="left" label="日期" width="180">
-            <template #default="scope">{{ "{{ formatDate(scope.row.CreatedAt) }}" }}</template>
-        </el-table-column>
-        {{ end }}
-        {{- range .FrontFields}}
-        {{- if .CheckDataSource }}
-        <el-table-column {{- if .Sort}} sortable{{- end}} align="left" label="{{.FieldDesc}}" prop="{{.FieldJson}}" width="120">
-          <template #default="scope">
-          {{"{{"}} filterDataSource(dataSource.{{.FieldJson}},scope.row.{{.FieldJson}}) {{"}}"}}
-         </template>
-         </el-table-column>
-        {{- else if .DictType}}
-        <el-table-column {{- if .Sort}} sortable{{- end}} align="left" label="{{.FieldDesc}}" prop="{{.FieldJson}}" width="120">
-            <template #default="scope">
-            {{"{{"}} filterDict(scope.row.{{.FieldJson}},{{.DictType}}Options) {{"}}"}}
-            </template>
-        </el-table-column>
-        {{- else if eq .FieldType "bool" }}
-        <el-table-column {{- if .Sort}} sortable{{- end}} align="left" label="{{.FieldDesc}}" prop="{{.FieldJson}}" width="120">
-            <template #default="scope">{{"{{"}} formatBoolean(scope.row.{{.FieldJson}}) {{"}}"}}</template>
-        </el-table-column>
-         {{- else if eq .FieldType "time.Time" }}
-         <el-table-column {{- if .Sort}} sortable{{- end}} align="left" label="{{.FieldDesc}}" width="180">
-            <template #default="scope">{{"{{"}} formatDate(scope.row.{{.FieldJson}}) {{"}}"}}</template>
-         </el-table-column>
-          {{- else if eq .FieldType "picture" }}
-          <el-table-column label="{{.FieldDesc}}" width="200">
-              <template #default="scope">
-                <el-image style="width: 100px; height: 100px" :src="getUrl(scope.row.{{.FieldJson}})" fit="cover"/>
-              </template>
-          </el-table-column>
-           {{- else if eq .FieldType "pictures" }}
-           <el-table-column label="{{.FieldDesc}}" width="200">
-              <template #default="scope">
-                 <div class="multiple-img-box">
-                    <el-image v-for="(item,index) in scope.row.{{.FieldJson}}" :key="index" style="width: 80px; height: 80px" :src="getUrl(item)" fit="cover"/>
-                </div>
-              </template>
-           </el-table-column>
-           {{- else if eq .FieldType "video" }}
-           <el-table-column label="{{.FieldDesc}}" width="200">
-              <template #default="scope">
-               <video
-                  style="width: 100px; height: 100px"
-                  muted
-                  preload="metadata"
-                  >
-                    <source :src="getUrl(scope.row.{{.FieldJson}}) + '#t=1'">
-                  </video>
-              </template>
-           </el-table-column>
-           {{- else if eq .FieldType "richtext" }}
-                      <el-table-column label="{{.FieldDesc}}" width="200">
-                         <template #default="scope">
-                            [富文本内容]
-                         </template>
-                      </el-table-column>
-           {{- else if eq .FieldType "file" }}
-                    <el-table-column label="{{.FieldDesc}}" width="200">
-                        <template #default="scope">
-                             <div class="file-list">
-                               <el-tag v-for="file in scope.row.{{.FieldJson}}" :key="file.uid">{{"{{"}}file.name{{"}}"}}</el-tag>
-                             </div>
-                        </template>
-                    </el-table-column>
-         {{- else if eq .FieldType "json" }}
-          <el-table-column label="{{.FieldDesc}}" width="200">
-              <template #default="scope">
-                  [JSON]
-              </template>
-          </el-table-column>
-        {{- else }}
-        <el-table-column {{- if .Sort}} sortable{{- end}} align="left" label="{{.FieldDesc}}" prop="{{.FieldJson}}" width="120" />
-        {{- end }}
-        {{- end }}
-        <el-table-column align="left" label="操作" fixed="right" min-width="240">
-            <template #default="scope">
-            <el-button type="primary" link class="table-button" @click="getDetails(scope.row)">
-                <el-icon style="margin-right: 5px"><InfoFilled /></el-icon>
-                查看详情
-            </el-button>
-            <el-button type="primary" link icon="edit" class="table-button" @click="update{{.StructName}}Func(scope.row)">变更</el-button>
-            <el-button type="primary" link icon="delete" @click="deleteRow(scope.row)">删除</el-button>
-            </template>
-        </el-table-column>
-        </el-table>
-        <div class="gva-pagination">
-            <el-pagination
-            layout="total, sizes, prev, pager, next, jumper"
-            :current-page="page"
-            :page-size="pageSize"
-            :page-sizes="[10, 30, 50, 100]"
-            :total="total"
-            @current-change="handleCurrentChange"
-            @size-change="handleSizeChange"
-            />
-        </div>
-    </div>
-    <el-drawer size="800" v-model="dialogFormVisible" :show-close="false" :before-close="closeDialog">
-       <template #header>
-              <div class="flex justify-between items-center">
-                <span class="text-lg">{{"{{"}}type==='create'?'添加':'修改'{{"}}"}}</span>
-                <div>
-                  <el-button type="primary" @click="enterDialog">确 定</el-button>
-                  <el-button @click="closeDialog">取 消</el-button>
-                </div>
-              </div>
-            </template>
-
-          <el-form :model="formData" label-position="top" ref="elFormRef" :rules="rule" label-width="80px">
-        {{- range .FrontFields}}
-            <el-form-item label="{{.FieldDesc}}:"  prop="{{.FieldJson}}" >
-          {{- if .CheckDataSource}}
-            <el-select v-model="formData.{{.FieldJson}}" placeholder="请选择{{.FieldDesc}}" style="width:100%" :clearable="{{.Clearable}}" >
-              <el-option v-for="(item,key) in dataSource.{{.FieldJson}}" :key="key" :label="item.label" :value="item.value" />
-            </el-select>
-          {{- else }}
-          {{- if eq .FieldType "bool" }}
-              <el-switch v-model="formData.{{.FieldJson}}" active-color="#13ce66" inactive-color="#ff4949" active-text="是" inactive-text="否" clearable ></el-switch>
-          {{- end }}
-          {{- if eq .FieldType "string" }}
-          {{- if .DictType}}
-              <el-select v-model="formData.{{ .FieldJson }}" placeholder="请选择{{.FieldDesc}}" style="width:100%" :clearable="{{.Clearable}}" >
-                <el-option v-for="(item,key) in {{ .DictType }}Options" :key="key" :label="item.label" :value="item.value" />
-              </el-select>
-          {{- else }}
-              <el-input v-model="formData.{{.FieldJson}}" :clearable="{{.Clearable}}"  placeholder="请输入{{.FieldDesc}}" />
-          {{- end }}
-          {{- end }}
-          {{- if eq .FieldType "richtext" }}
-              <RichEdit v-model="formData.{{.FieldJson}}"/>
-          {{- end }}
-          {{- if eq .FieldType "json" }}
-              // 此字段为json结构,可以前端自行控制展示和数据绑定模式 需绑定json的key为 formData.{{.FieldJson}} 后端会按照json的类型进行存取
-              {{"{{"}} formData.{{.FieldJson}} {{"}}"}}
-          {{- end }}
-          {{- if eq .FieldType "int" }}
-              <el-input v-model.number="formData.{{ .FieldJson }}" :clearable="{{.Clearable}}" placeholder="请输入{{.FieldDesc}}" />
-          {{- end }}
-          {{- if eq .FieldType "time.Time" }}
-              <el-date-picker v-model="formData.{{ .FieldJson }}" type="date" style="width:100%" placeholder="选择日期" :clearable="{{.Clearable}}"  />
-          {{- end }}
-          {{- if eq .FieldType "float64" }}
-              <el-input-number v-model="formData.{{ .FieldJson }}"  style="width:100%" :precision="2" :clearable="{{.Clearable}}"  />
-          {{- end }}
-          {{- if eq .FieldType "enum" }}
-                <el-select v-model="formData.{{ .FieldJson }}" placeholder="请选择{{.FieldDesc}}" style="width:100%" :clearable="{{.Clearable}}" >
-                   <el-option v-for="item in [{{.DataTypeLong}}]" :key="item" :label="item" :value="item" />
-                </el-select>
-          {{- end }}
-          {{- if eq .FieldType "picture" }}
-                <SelectImage
-                 v-model="formData.{{ .FieldJson }}"
-                 file-type="image"
-                />
-          {{- end }}
-          {{- if eq .FieldType "pictures" }}
-                <SelectImage
-                 multiple
-                 v-model="formData.{{ .FieldJson }}"
-                 file-type="image"
-                 />
-          {{- end }}
-          {{- if eq .FieldType "video" }}
-                <SelectImage
-                v-model="formData.{{ .FieldJson }}"
-                file-type="video"
-                />
-           {{- end }}
-          {{- if eq .FieldType "file" }}
-                <SelectFile v-model="formData.{{ .FieldJson }}" />
-          {{- end }}
-          {{- end }}
-            </el-form-item>
-          {{- end }}
-          </el-form>
-    </el-drawer>
-  </div>
-</template>
-
-<script setup>
-import {
-  {{- if .HasDataSource }}
-    get{{.StructName}}DataSource,
-  {{- end }}
-  create{{.StructName}},
-  delete{{.StructName}},
-  delete{{.StructName}}ByIds,
-  update{{.StructName}},
-  find{{.StructName}},
-  get{{.StructName}}List
-} from '@/api/{{.Package}}/{{.PackageName}}'
-
-{{- if or .HasPic .HasFile}}
-import { getUrl } from '@/utils/image'
-{{- end }}
-{{- if .HasPic }}
-// 图片选择组件
-import SelectImage from '@/components/selectImage/selectImage.vue'
-{{- end }}
-
-{{- if .HasRichText }}
-// 富文本组件
-import RichEdit from '@/components/richtext/rich-edit.vue'
-{{- end }}
-
-
-{{- if .HasFile }}
-// 文件选择组件
-import SelectFile from '@/components/selectFile/selectFile.vue'
-{{- end }}
-
-// 全量引入格式化工具 请按需保留
-import { getDictFunc, formatDate, formatBoolean, filterDict,filterDataSource, ReturnArrImg, onDownloadFile } from '@/utils/format'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import { ref, reactive } from 'vue'
-
-defineOptions({
-    name: '{{.StructName}}'
-})
-
-// 自动化生成的字典(可能为空)以及字段
-    {{- range $index, $element := .DictTypes}}
-const {{ $element }}Options = ref([])
-    {{- end }}
-const formData = ref({
-        {{- range .FrontFields}}
-        {{- if eq .FieldType "bool" }}
-        {{.FieldJson}}: false,
-        {{- end }}
-        {{- if eq .FieldType "string" }}
-        {{.FieldJson}}: '',
-        {{- end }}
-        {{- if eq .FieldType "richtext" }}
-        {{.FieldJson}}: '',
-        {{- end }}
-        {{- if eq .FieldType "int" }}
-        {{.FieldJson}}: {{- if .DictType }} undefined{{ else }} 0{{- end }},
-        {{- end }}
-        {{- if eq .FieldType "time.Time" }}
-        {{.FieldJson}}: new Date(),
-        {{- end }}
-        {{- if eq .FieldType "float64" }}
-        {{.FieldJson}}: 0,
-        {{- end }}
-        {{- if eq .FieldType "picture" }}
-        {{.FieldJson}}: "",
-        {{- end }}
-        {{- if eq .FieldType "video" }}
-        {{.FieldJson}}: "",
-        {{- end }}
-        {{- if eq .FieldType "pictures" }}
-        {{.FieldJson}}: [],
-        {{- end }}
-        {{- if eq .FieldType "file" }}
-        {{.FieldJson}}: [],
-        {{- end }}
-        {{- if eq .FieldType "json" }}
-        {{.FieldJson}}: {},
-        {{- end }}
-        {{- end }}
-        })
-
-{{- if .HasDataSource }}
-  const dataSource = ref([])
-  const getDataSourceFunc = async()=>{
-    const res = await get{{.StructName}}DataSource()
-    if (res.code === 0) {
-      dataSource.value = res.data
-    }
-  }
-  getDataSourceFunc()
-{{- end }}
-
-
-
-// 验证规则
-const rule = reactive({
-    {{- range .FrontFields }}
-            {{- if eq .Require true }}
-               {{.FieldJson }} : [{
-                   required: true,
-                   message: '{{ .ErrorText }}',
-                   trigger: ['input','blur'],
-               },
-               {{- if eq .FieldType "string" }}
-               {
-                   whitespace: true,
-                   message: '不能只输入空格',
-                   trigger: ['input', 'blur'],
-              }
-              {{- end }}
-              ],
-            {{- end }}
-    {{- end }}
-})
-
-const searchRule = reactive({
-  createdAt: [
-    { validator: (rule, value, callback) => {
-      if (searchInfo.value.startCreatedAt && !searchInfo.value.endCreatedAt) {
-        callback(new Error('请填写结束日期'))
-      } else if (!searchInfo.value.startCreatedAt && searchInfo.value.endCreatedAt) {
-        callback(new Error('请填写开始日期'))
-      } else if (searchInfo.value.startCreatedAt && searchInfo.value.endCreatedAt && (searchInfo.value.startCreatedAt.getTime() === searchInfo.value.endCreatedAt.getTime() || searchInfo.value.startCreatedAt.getTime() > searchInfo.value.endCreatedAt.getTime())) {
-        callback(new Error('开始日期应当早于结束日期'))
-      } else {
-        callback()
-      }
-    }, trigger: 'change' }
-  ],
-  {{- range .FrontFields }}
-    {{- if .FieldSearchType}}
-      {{- if eq .FieldType "time.Time" }}
-        {{.FieldJson }} : [{ validator: (rule, value, callback) => {
-        if (searchInfo.value.start{{.FieldName}} && !searchInfo.value.end{{.FieldName}}) {
-          callback(new Error('请填写结束日期'))
-        } else if (!searchInfo.value.start{{.FieldName}} && searchInfo.value.end{{.FieldName}}) {
-          callback(new Error('请填写开始日期'))
-        } else if (searchInfo.value.start{{.FieldName}} && searchInfo.value.end{{.FieldName}} && (searchInfo.value.start{{.FieldName}}.getTime() === searchInfo.value.end{{.FieldName}}.getTime() || searchInfo.value.start{{.FieldName}}.getTime() > searchInfo.value.end{{.FieldName}}.getTime())) {
-          callback(new Error('开始日期应当早于结束日期'))
-        } else {
-          callback()
-        }
-      }, trigger: 'change' }],
-      {{- end }}
-    {{- end }}
-  {{- end }}
-})
-
-const elFormRef = ref()
-const elSearchFormRef = ref()
-
-// =========== 表格控制部分 ===========
-const page = ref(1)
-const total = ref(0)
-const pageSize = ref(10)
-const tableData = ref([])
-const searchInfo = ref({})
-
-{{- if .NeedSort}}
-// 排序
-const sortChange = ({ prop, order }) => {
-  const sortMap = {
-    {{- range .FrontFields}}
-      {{- if and .Sort}}
-        {{- if not (eq .ColumnName "")}}
-            {{.FieldJson}}: '{{.ColumnName}}',
-        {{- end}}
-      {{- end}}
-    {{- end}}
-  }
-
-  let sort = sortMap[prop]
-  if(!sort){
-   sort = prop.replace(/[A-Z]/g, match => `_${match.toLowerCase()}`)
-  }
-
-  searchInfo.value.sort = sort
-  searchInfo.value.order = order
-  getTableData()
-}
-{{- end}}
-
-// 重置
-const onReset = () => {
-  searchInfo.value = {}
-  getTableData()
-}
-
-// 搜索
-const onSubmit = () => {
-  elSearchFormRef.value?.validate(async(valid) => {
-    if (!valid) return
-    page.value = 1
-    pageSize.value = 10
-    {{- range .FrontFields}}{{- if eq .FieldType "bool" }}
-    if (searchInfo.value.{{.FieldJson}} === ""){
-        searchInfo.value.{{.FieldJson}}=null
-    }{{ end }}{{ end }}
-    getTableData()
-  })
-}
-
-// 分页
-const handleSizeChange = (val) => {
-  pageSize.value = val
-  getTableData()
-}
-
-// 修改页面容量
-const handleCurrentChange = (val) => {
-  page.value = val
-  getTableData()
-}
-
-// 查询
-const getTableData = async() => {
-  const table = await get{{.StructName}}List({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
-  if (table.code === 0) {
-    tableData.value = table.data.list
-    total.value = table.data.total
-    page.value = table.data.page
-    pageSize.value = table.data.pageSize
-  }
-}
-
-getTableData()
-
-// ============== 表格控制部分结束 ===============
-
-// 获取需要的字典 可能为空 按需保留
-const setOptions = async () =>{
-{{- range $index, $element := .DictTypes }}
-    {{ $element }}Options.value = await getDictFunc('{{$element}}')
-{{- end }}
-}
-
-// 获取需要的字典 可能为空 按需保留
-setOptions()
-
-
-// 多选数据
-const multipleSelection = ref([])
-// 多选
-const handleSelectionChange = (val) => {
-    multipleSelection.value = val
-}
-
-// 删除行
-const deleteRow = (row) => {
-    ElMessageBox.confirm('确定要删除吗?', '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-    }).then(() => {
-            delete{{.StructName}}Func(row)
-        })
-    }
-
-// 多选删除
-const onDelete = async() => {
-  ElMessageBox.confirm('确定要删除吗?', '提示', {
-    confirmButtonText: '确定',
-    cancelButtonText: '取消',
-    type: 'warning'
-  }).then(async() => {
-      const {{.PrimaryField.FieldJson}}s = []
-      if (multipleSelection.value.length === 0) {
-        ElMessage({
-          type: 'warning',
-          message: '请选择要删除的数据'
-        })
-        return
-      }
-      multipleSelection.value &&
-        multipleSelection.value.map(item => {
-          {{.PrimaryField.FieldJson}}s.push(item.{{.PrimaryField.FieldJson}})
-        })
-      const res = await delete{{.StructName}}ByIds({ {{.PrimaryField.FieldJson}}s })
-      if (res.code === 0) {
-        ElMessage({
-          type: 'success',
-          message: '删除成功'
-        })
-        if (tableData.value.length === {{.PrimaryField.FieldJson}}s.length && page.value > 1) {
-          page.value--
-        }
-        getTableData()
-      }
-      })
-    }
-
-// 行为控制标记(弹窗内部需要增还是改)
-const type = ref('')
-
-// 更新行
-const update{{.StructName}}Func = async(row) => {
-    const res = await find{{.StructName}}({ {{.PrimaryField.FieldJson}}: row.{{.PrimaryField.FieldJson}} })
-    type.value = 'update'
-    if (res.code === 0) {
-        formData.value = res.data.re{{.Abbreviation}}
-        dialogFormVisible.value = true
-    }
-}
-
-
-// 删除行
-const delete{{.StructName}}Func = async (row) => {
-    const res = await delete{{.StructName}}({ {{.PrimaryField.FieldJson}}: row.{{.PrimaryField.FieldJson}} })
-    if (res.code === 0) {
-        ElMessage({
-                type: 'success',
-                message: '删除成功'
-            })
-            if (tableData.value.length === 1 && page.value > 1) {
-            page.value--
-        }
-        getTableData()
-    }
-}
-
-// 弹窗控制标记
-const dialogFormVisible = ref(false)
-
-// 打开弹窗
-const openDialog = () => {
-    type.value = 'create'
-    dialogFormVisible.value = true
-}
-
-// 关闭弹窗
-const closeDialog = () => {
-    dialogFormVisible.value = false
-    formData.value = {
-    {{- range .FrontFields}}
-        {{- if eq .FieldType "bool" }}
-        {{.FieldJson}}: false,
-        {{- end }}
-        {{- if eq .FieldType "string" }}
-        {{.FieldJson}}: '',
-        {{- end }}
-        {{- if eq .FieldType "int" }}
-        {{.FieldJson}}: {{- if .DictType }} undefined{{ else }} 0{{- end }},
-        {{- end }}
-        {{- if eq .FieldType "time.Time" }}
-        {{.FieldJson}}: new Date(),
-        {{- end }}
-        {{- if eq .FieldType "float64" }}
-        {{.FieldJson}}: 0,
-        {{- end }}
-        {{- end }}
-        }
-}
-// 弹窗确定
-const enterDialog = async () => {
-     elFormRef.value?.validate( async (valid) => {
-             if (!valid) return
-              let res
-              switch (type.value) {
-                case 'create':
-                  res = await create{{.StructName}}(formData.value)
-                  break
-                case 'update':
-                  res = await update{{.StructName}}(formData.value)
-                  break
-                default:
-                  res = await create{{.StructName}}(formData.value)
-                  break
-              }
-              if (res.code === 0) {
-                ElMessage({
-                  type: 'success',
-                  message: '创建/更改成功'
-                })
-                closeDialog()
-                getTableData()
-              }
-      })
-}
-{{if .HasFile }}
-const downloadFile = (url) => {
-    window.open(getUrl(url), '_blank')
-}
-{{end}}
-</script>
-
-<style>
-{{if .HasFile }}
-.file-list{
-  display: flex;
-  flex-wrap: wrap;
-  gap: 4px;
-}
-
-.fileBtn{
-  margin-bottom: 10px;
-}
-
-.fileBtn:last-child{
-  margin-bottom: 0;
-}
-{{end}}
-</style>

+ 6 - 0
internal/router/parking/parking.go

@@ -12,6 +12,12 @@ func SetupParkingRouter(router *gin.RouterGroup) {
 	router.GET("/channel/test-event", parking.SimulateEvent)
 
 	parkingGroup := router.Group("/parking")
+	gateGroup := parkingGroup.Group("/gate")
+	{
+		gateGroup.GET("/devices", parking.ListGateDevices)
+		gateGroup.POST("/open", parking.OpenGate)
+		gateGroup.POST("/close", parking.CloseGate)
+	}
 	parkLotGroup := parkingGroup.Group("/lot")
 	{
 		parkLotGroup.POST("/create", parking.CreateParkingLot)

+ 0 - 1
internal/router/vehicle/vehicle.go

@@ -30,7 +30,6 @@ func SetupVehicleRouter(router *gin.RouterGroup) {
 
 		// 车辆出入
 		vehicleGroup.POST("/entry", vehicle.VehicleEntry)
-		vehicleGroup.POST("/exit", vehicle.VehicleExit)
 
 		// 收费配置
 		vehicleGroup.POST("/fee-config/create", vehicle.CreateFeeConfig)

+ 19 - 0
internal/router/vehicle/vehicle_test.go

@@ -0,0 +1,19 @@
+package vehicle
+
+import (
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSetupVehicleRouterDoesNotRegisterLegacyExit(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	SetupVehicleRouter(router.Group(""))
+
+	for _, route := range router.Routes() {
+		require.False(t, route.Method == "POST" && route.Path == "/vehicle/exit",
+			"legacy exit route must remain disabled")
+	}
+}

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

@@ -0,0 +1,182 @@
+package parking
+
+import (
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	"go.uber.org/zap"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+const defaultGateValidTime byte = 2
+
+// GateController 统一道闸设备控制接口,具体协议由设备模块实现。
+type GateController interface {
+	OpenGate(deviceCode string, validTime byte) error
+	CloseGate(deviceCode string, validTime byte) error
+	IsGateConnected(deviceCode string) bool
+}
+
+// GateDeviceOption 是工作台可见的安全设备信息,不包含串口、IP 等连接参数。
+type GateDeviceOption struct {
+	DeviceCode   string `json:"device_code"`
+	DeviceName   string `json:"device_name"`
+	ChannelID    uint   `json:"channel_id"`
+	ChannelCode  string `json:"channel_code"`
+	ChannelName  string `json:"channel_name"`
+	Direction    string `json:"direction"`
+	ParkingLotID uint   `json:"parking_lot_id"`
+	Connected    bool   `json:"connected"`
+	Simulated    bool   `json:"simulated"`
+}
+
+// GateRuntimeStatus 表示统一控制器中的设备实时状态。
+type GateRuntimeStatus struct {
+	Connected bool
+	Simulated bool
+}
+
+// SimulatedGateController 用于无硬件环境联调,只记录指令,不访问串口或网络设备。
+type SimulatedGateController struct {
+	mu         sync.RWMutex
+	lastAction map[string]string
+}
+
+// NewSimulatedGateController 创建模拟道闸控制器。
+func NewSimulatedGateController() *SimulatedGateController {
+	return &SimulatedGateController{lastAction: make(map[string]string)}
+}
+
+func (c *SimulatedGateController) record(deviceCode, action string) error {
+	if deviceCode == "" {
+		return errors.New("道闸设备编码不能为空")
+	}
+	c.mu.Lock()
+	c.lastAction[deviceCode] = action
+	c.mu.Unlock()
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn("模拟道闸指令", zap.String("device_code", deviceCode), zap.String("action", action), zap.Time("time", time.Now()))
+	}
+	return nil
+}
+
+// OpenGate 模拟开闸。
+func (c *SimulatedGateController) OpenGate(deviceCode string, _ byte) error {
+	return c.record(deviceCode, "open")
+}
+
+// CloseGate 模拟关闸。
+func (c *SimulatedGateController) CloseGate(deviceCode string, _ byte) error {
+	return c.record(deviceCode, "close")
+}
+
+// IsGateConnected 在模拟模式下将所有有效设备编码视为在线。
+func (c *SimulatedGateController) IsGateConnected(deviceCode string) bool {
+	return deviceCode != ""
+}
+
+func (c *SimulatedGateController) isSimulator() bool {
+	return true
+}
+
+var (
+	gateControllerMu sync.RWMutex
+	gateController   GateController
+)
+
+// SetGateController 注入设备控制实现,避免停车业务反向依赖具体设备协议。
+func SetGateController(controller GateController) {
+	gateControllerMu.Lock()
+	defer gateControllerMu.Unlock()
+	gateController = controller
+}
+
+func currentGateController() GateController {
+	gateControllerMu.RLock()
+	defer gateControllerMu.RUnlock()
+	return gateController
+}
+
+// GetGateRuntimeStatus 返回设备在当前控制器中的实时连接状态。
+func GetGateRuntimeStatus(deviceCode string) GateRuntimeStatus {
+	controller := currentGateController()
+	if controller == nil || deviceCode == "" {
+		return GateRuntimeStatus{}
+	}
+	status := GateRuntimeStatus{Connected: controller.IsGateConnected(deviceCode)}
+	if simulator, ok := controller.(interface{ isSimulator() bool }); ok {
+		status.Simulated = simulator.isSimulator()
+	}
+	return status
+}
+
+// OpenGateByDeviceCode 通过统一控制器开闸。
+func (s *PassageService) OpenGateByDeviceCode(deviceCode string, validTime byte) error {
+	if deviceCode == "" {
+		return errors.New("道闸设备编码不能为空")
+	}
+	controller := currentGateController()
+	if controller == nil {
+		return errors.New("道闸控制器未初始化")
+	}
+	if validTime == 0 {
+		validTime = defaultGateValidTime
+	}
+	return controller.OpenGate(deviceCode, validTime)
+}
+
+// CloseGateByDeviceCode 通过统一控制器关闸。
+func (s *PassageService) CloseGateByDeviceCode(deviceCode string, validTime byte) error {
+	if deviceCode == "" {
+		return errors.New("道闸设备编码不能为空")
+	}
+	controller := currentGateController()
+	if controller == nil {
+		return errors.New("道闸控制器未初始化")
+	}
+	if validTime == 0 {
+		validTime = defaultGateValidTime
+	}
+	return controller.CloseGate(deviceCode, validTime)
+}
+
+// ListGateDevices 返回已启用且已绑定通道的道闸设备。
+func (s *PassageService) ListGateDevices() ([]GateDeviceOption, error) {
+	if global.GVA_DB == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	var readers []dao.UHFReader
+	if err := global.GVA_DB.Preload("Channel").
+		Where("is_active = ? AND channel_id > 0", true).
+		Order("device_name ASC").Find(&readers).Error; err != nil {
+		return nil, err
+	}
+
+	options := make([]GateDeviceOption, 0, len(readers))
+	for _, reader := range readers {
+		if reader.Channel == nil {
+			continue
+		}
+		parkingLotID := reader.Channel.ParkingLotID
+		if parkingLotID == 0 {
+			parkingLotID = reader.ParkingLotID
+		}
+		runtimeStatus := GetGateRuntimeStatus(reader.DeviceCode)
+		options = append(options, GateDeviceOption{
+			DeviceCode: reader.DeviceCode, DeviceName: reader.DeviceName,
+			ChannelID: reader.ChannelID, ChannelCode: reader.Channel.ChannelCode,
+			ChannelName: reader.Channel.ChannelName, Direction: reader.Channel.Direction,
+			ParkingLotID: parkingLotID,
+			Connected:    runtimeStatus.Connected,
+			Simulated:    runtimeStatus.Simulated,
+		})
+	}
+	return options, nil
+}
+
+func gateOperationError(action, deviceCode string, err error) error {
+	return fmt.Errorf("道闸%s失败(设备 %s): %w", action, deviceCode, err)
+}

+ 73 - 0
internal/service/parking/gate_test.go

@@ -0,0 +1,73 @@
+package parking
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+type gateControllerStub struct {
+	openDevice  string
+	closeDevice string
+	openTime    byte
+	closeTime   byte
+}
+
+func (s *gateControllerStub) OpenGate(deviceCode string, validTime byte) error {
+	s.openDevice = deviceCode
+	s.openTime = validTime
+	return nil
+}
+
+func (s *gateControllerStub) CloseGate(deviceCode string, validTime byte) error {
+	s.closeDevice = deviceCode
+	s.closeTime = validTime
+	return nil
+}
+
+func (s *gateControllerStub) IsGateConnected(string) bool { return true }
+
+func TestPassageServiceUsesUnifiedGateController(t *testing.T) {
+	previous := currentGateController()
+	stub := &gateControllerStub{}
+	SetGateController(stub)
+	t.Cleanup(func() { SetGateController(previous) })
+
+	svc := &PassageService{}
+	require.NoError(t, svc.OpenGateByDeviceCode("GATE-01", 0))
+	require.NoError(t, svc.CloseGateByDeviceCode("GATE-01", 5))
+
+	require.Equal(t, "GATE-01", stub.openDevice)
+	require.Equal(t, defaultGateValidTime, stub.openTime)
+	require.Equal(t, "GATE-01", stub.closeDevice)
+	require.Equal(t, byte(5), stub.closeTime)
+}
+
+func TestPassageServiceRejectsEmptyGateDevice(t *testing.T) {
+	svc := &PassageService{}
+	require.Error(t, svc.OpenGateByDeviceCode("", 2))
+	require.Error(t, svc.CloseGateByDeviceCode("", 2))
+}
+
+func TestSimulatedGateController(t *testing.T) {
+	controller := NewSimulatedGateController()
+	require.True(t, controller.IsGateConnected("GATE-SIM-01"))
+	require.False(t, controller.IsGateConnected(""))
+	require.NoError(t, controller.OpenGate("GATE-SIM-01", 2))
+	require.Equal(t, "open", controller.lastAction["GATE-SIM-01"])
+	require.NoError(t, controller.CloseGate("GATE-SIM-01", 2))
+	require.Equal(t, "close", controller.lastAction["GATE-SIM-01"])
+	require.Error(t, controller.OpenGate("", 2))
+}
+
+func TestGetGateRuntimeStatus(t *testing.T) {
+	previous := currentGateController()
+	controller := NewSimulatedGateController()
+	SetGateController(controller)
+	t.Cleanup(func() { SetGateController(previous) })
+
+	status := GetGateRuntimeStatus("GATE-SIM-01")
+	require.True(t, status.Connected)
+	require.True(t, status.Simulated)
+	require.Equal(t, GateRuntimeStatus{}, GetGateRuntimeStatus(""))
+}

+ 84 - 44
internal/service/parking/passage.go

@@ -1,6 +1,7 @@
 package parking
 
 import (
+	"errors"
 	"fmt"
 	"sync"
 	"time"
@@ -11,9 +12,6 @@ import (
 	"wails-app/internal/service/vehicle"
 )
 
-// GateOpener 道闸控制函数(由 UHF 模块在初始化时注入,避免循环依赖)
-var GateOpener func(deviceCode string, validTime byte) error
-
 // PassageService 统一的进出场服务(所有触发方式共用:RFID、摄像头、手动录入)
 type PassageService struct{}
 
@@ -57,8 +55,22 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	if err := global.GVA_DB.Preload("Channel").First(&device, "device_code = ?", req.DeviceCode).Error; err != nil {
 		return nil, fmt.Errorf("设备不存在: %s", req.DeviceCode)
 	}
+	if !device.IsActive {
+		return nil, fmt.Errorf("设备已停用: %s", req.DeviceCode)
+	}
+	if device.Channel == nil {
+		return nil, fmt.Errorf("设备未绑定通道: %s", req.DeviceCode)
+	}
 	channel := device.Channel
 	direction := channel.Direction
+	if direction == "inout" {
+		if req.Direction != "in" && req.Direction != "out" {
+			return nil, errors.New("双向通道必须明确本次通行方向")
+		}
+		direction = req.Direction
+	} else if req.Direction != "" && req.Direction != direction {
+		return nil, fmt.Errorf("所选设备属于%s通道,不能执行%s操作", direction, req.Direction)
+	}
 	allowTemporary := channel.AllowTemporary
 
 	// ===================== 3. 查询车辆 =====================
@@ -91,7 +103,17 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	// ===================== 6. 根据方向处理 =====================
 	switch direction {
 	case "in":
-		return s.handleEntry(req, identifier, channel.ParkingLotID)
+		parkingLotID := channel.ParkingLotID
+		if parkingLotID == 0 {
+			parkingLotID = req.ParkingLotID
+		}
+		if parkingLotID == 0 {
+			return nil, errors.New("通道未配置停车场")
+		}
+		if req.ParkingLotID != 0 && req.ParkingLotID != parkingLotID {
+			return nil, errors.New("所选停车场与通道配置不一致")
+		}
+		return s.handleEntry(req, identifier, parkingLotID)
 	case "out":
 		return s.handleExit(req, identifier)
 	default:
@@ -109,16 +131,7 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 		return nil, fmt.Errorf("车辆已在场内: %s", identifier)
 	}
 
-	fmt.Printf("🟢 入口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
-
-	// 开闸
-	if GateOpener != nil {
-		if err := GateOpener(req.DeviceCode, 2); err != nil {
-			fmt.Printf("⚠️ 开闸失败: %v\n", err)
-		}
-	}
-
-	// 执行入场
+	// 先创建停车会话和数字票,避免业务失败时车辆已经放行。
 	vehicleSvc := &vehicle.VehicleService{}
 	resp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
 		PlateNumber:  req.PlateNumber,
@@ -134,36 +147,49 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	passageStatus[identifier] = true
 	passageMutex.Unlock()
 
-	return &common.PassageResult{
-		Success:     true,
-		Message:     "入场成功",
-		Direction:   "in",
-		PlateNumber: resp.Data.PlateNumber,
-		RFIDTag:     req.RFIDTag,
-		EntryTime:   resp.Data.EntryTime,
-	}, nil
+	result := &common.PassageResult{
+		Success:           true,
+		Message:           "入场成功",
+		Direction:         "in",
+		PlateNumber:       resp.Data.PlateNumber,
+		RFIDTag:           req.RFIDTag,
+		EntryTime:         resp.Data.EntryTime,
+		BusinessCompleted: true,
+		GateStatus:        "opening",
+		DeviceCode:        req.DeviceCode,
+	}
+
+	fmt.Printf("🟢 入口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
+	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+		result.Success = false
+		result.Message = "入场记录已创建,但开闸失败"
+		result.GateStatus = "failed"
+		return result, gateOperationError("开闸", req.DeviceCode, err)
+	}
+	result.GateOpened = true
+	result.GateStatus = "opened"
+	return result, nil
 }
 
 // handleExit 处理出场(使用新的收费流程 ExitConfirm)
 func (s *PassageService) handleExit(req common.PassageRequest, identifier string) (*common.PassageResult, error) {
-	fmt.Printf("🔴 出口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
 
-	// 开闸
-	if GateOpener != nil {
-		if err := GateOpener(req.DeviceCode, 2); err != nil {
-			fmt.Printf("⚠️ 开闸失败: %v\n", err)
-		}
+	// UHF 自动出场默认走免密;人工入口会显式传入支付参数。
+	paymentMethod := req.PaymentMethod
+	if paymentMethod == "" {
+		paymentMethod = "free"
 	}
-
-	// 执行出场(RFID自动出场统一走免密,无人工收费)
 	vehicleSvc := &vehicle.VehicleService{}
 	confirmReq := request.ExitConfirmRequest{
+		SessionID:     req.SessionID,
+		TicketNo:      req.TicketNo,
 		PlateNumber:   req.PlateNumber,
 		RFIDTag:       req.RFIDTag,
-		PaymentMethod: "free",
-		PaidAmount:    0,
+		PaymentEntry:  req.PaymentEntry,
+		PaymentMethod: paymentMethod,
+		PaidAmount:    req.PaidAmount,
 	}
-	resp, err := vehicleSvc.ExitConfirm(confirmReq, 0)
+	resp, err := vehicleSvc.ExitConfirm(confirmReq, req.OperatorID)
 	if err != nil {
 		return nil, err
 	}
@@ -172,17 +198,31 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	passageStatus[identifier] = false
 	passageMutex.Unlock()
 
-	return &common.PassageResult{
-		Success:     true,
-		Message:     "出场成功",
-		Direction:   "out",
-		PlateNumber: req.PlateNumber,
-		RFIDTag:     req.RFIDTag,
-		Fee:         resp.Fee,
-		StayTime:    resp.StayTime,
-		EntryTime:   resp.EntryTime,
-		ExitTime:    resp.ExitTime,
-	}, nil
+	result := &common.PassageResult{
+		Success:           true,
+		Message:           "出场成功",
+		Direction:         "out",
+		PlateNumber:       resp.PlateNumber,
+		RFIDTag:           req.RFIDTag,
+		Fee:               resp.Fee,
+		StayTime:          resp.StayTime,
+		EntryTime:         resp.EntryTime,
+		ExitTime:          resp.ExitTime,
+		BusinessCompleted: true,
+		GateStatus:        "opening",
+		DeviceCode:        req.DeviceCode,
+	}
+
+	fmt.Printf("🔴 出口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
+	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+		result.Success = false
+		result.Message = "出场结算已完成,但开闸失败"
+		result.GateStatus = "failed"
+		return result, gateOperationError("开闸", req.DeviceCode, err)
+	}
+	result.GateOpened = true
+	result.GateStatus = "opened"
+	return result, nil
 }
 
 // SetEpcExit 手动重置离场状态

+ 21 - 1
internal/service/system/sys_dashboard.go

@@ -3,11 +3,13 @@ package system
 import (
 	"time"
 	"wails-app/internal/global"
+	parkingService "wails-app/internal/service/parking"
 )
 
 type DeviceStatus struct {
 	ChannelName  string `json:"channel_name"`
 	Direction    string `json:"direction"`
+	DeviceCode   string `json:"device_code"`
 	DeviceName   string `json:"device_name"`
 	DeviceStatus string `json:"device_status"`
 }
@@ -41,10 +43,28 @@ func (s *DashboardService) GetMonitorData() (MonitorData, error) {
 		today, today+" 23:59:59").Scan(&d.TodayExit)
 
 	global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction,
+		COALESCE(uhf.device_code, '') as device_code,
 		COALESCE(uhf.device_name, '-') as device_name,
-		COALESCE(uhf.status, 'offline') as device_status
+		'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)
 
+	for i := range d.Devices {
+		device := &d.Devices[i]
+		if device.DeviceCode == "" {
+			device.DeviceStatus = "unbound"
+			continue
+		}
+		status := parkingService.GetGateRuntimeStatus(device.DeviceCode)
+		switch {
+		case status.Simulated && status.Connected:
+			device.DeviceStatus = "simulated"
+		case status.Connected:
+			device.DeviceStatus = "online"
+		default:
+			device.DeviceStatus = "offline"
+		}
+	}
+
 	return d, nil
 }

+ 75 - 18
internal/service/uhf/reader.go

@@ -6,13 +6,13 @@ import (
 	"encoding/hex"
 	"errors"
 	"fmt"
+	"sync"
+	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	common "wails-app/internal/model/common"
 	"wails-app/internal/service"
 	"wails-app/internal/service/parking"
-	"sync"
-	"time"
 )
 
 // Reader 通用读写接口(串口和TCP都实现它)
@@ -32,36 +32,50 @@ type Reader interface {
 
 // ChannelEvent 通道事件(RFID/车牌识别后推送给前端)
 type ChannelEvent struct {
-	DeviceCode  string `json:"device_code"`
-	RFIDTag     string `json:"rfid_tag"`
-	PlateNumber string `json:"plate_number"`
-	Direction   string `json:"direction"`
-	ChannelName string `json:"channel_name"`
-	Timestamp   int64  `json:"timestamp"`
+	ID          uint64  `json:"id"`
+	DeviceCode  string  `json:"device_code"`
+	RFIDTag     string  `json:"rfid_tag"`
+	PlateNumber string  `json:"plate_number"`
+	Direction   string  `json:"direction"`
+	ChannelName string  `json:"channel_name"`
+	Timestamp   int64   `json:"timestamp"`
+	Status      string  `json:"status"`
+	Message     string  `json:"message"`
+	Fee         float64 `json:"fee"`
+	StayTime    int64   `json:"stay_time"`
 }
 
 var (
 	channelEventMu     sync.Mutex
 	ChannelEventQueue  = make([]ChannelEvent, 0, 50)
+	channelEventNextID uint64
 )
 
 // PushChannelEvent 追加通道事件(保留最近 50 条)
 func PushChannelEvent(e ChannelEvent) {
 	channelEventMu.Lock()
 	defer channelEventMu.Unlock()
+	channelEventNextID++
+	e.ID = channelEventNextID
+	if e.Timestamp == 0 {
+		e.Timestamp = time.Now().Unix()
+	}
+	if e.Status == "" {
+		e.Status = "completed"
+	}
 	if len(ChannelEventQueue) >= 50 {
 		ChannelEventQueue = ChannelEventQueue[1:]
 	}
 	ChannelEventQueue = append(ChannelEventQueue, e)
 }
 
-// GetChannelEvents 获取 timestamp > after 的事件
-func GetChannelEvents(after int64) []ChannelEvent {
+// GetChannelEvents 获取事件编号大于 afterID 的通道结果。
+func GetChannelEvents(afterID uint64) []ChannelEvent {
 	channelEventMu.Lock()
 	defer channelEventMu.Unlock()
 	result := make([]ChannelEvent, 0)
 	for _, e := range ChannelEventQueue {
-		if e.Timestamp > after {
+		if e.ID > afterID {
 			result = append(result, e)
 		}
 	}
@@ -83,9 +97,9 @@ var DeviceManager = &Manager{
 	devices: make(map[string]*DeviceHandler),
 }
 
-// init 注入道闸控制回调(避免循环依赖)
+// init 注入统一道闸控制器(避免停车业务依赖具体设备协议)。
 func init() {
-	parking.GateOpener = DeviceManager.OpenGateByDeviceCode
+	parking.SetGateController(DeviceManager)
 }
 
 type Manager struct {
@@ -130,8 +144,13 @@ func (h *DeviceHandler) OpenGate(validTime byte) error {
 	return h.reader.CloseRelay1(validTime)
 }
 
-// OpenGateByDeviceCode 通过设备编码直接开闸(便捷方法)
-func (m *Manager) OpenGateByDeviceCode(deviceCode string, validTime byte) error {
+// CloseGate 关闸。
+func (h *DeviceHandler) CloseGate(validTime byte) error {
+	return h.reader.CloseRelay2(validTime)
+}
+
+// OpenGate 通过设备编码开闸,实现 parking.GateController。
+func (m *Manager) OpenGate(deviceCode string, validTime byte) error {
 	h, ok := m.Get(deviceCode)
 	if !ok {
 		return fmt.Errorf("设备未连接: %s", deviceCode)
@@ -139,6 +158,31 @@ func (m *Manager) OpenGateByDeviceCode(deviceCode string, validTime byte) error
 	return h.OpenGate(validTime)
 }
 
+// CloseGate 通过设备编码关闸,实现 parking.GateController。
+func (m *Manager) CloseGate(deviceCode string, validTime byte) error {
+	h, ok := m.Get(deviceCode)
+	if !ok {
+		return fmt.Errorf("设备未连接: %s", deviceCode)
+	}
+	return h.CloseGate(validTime)
+}
+
+// IsGateConnected 判断设备是否已注册到当前进程。
+func (m *Manager) IsGateConnected(deviceCode string) bool {
+	_, ok := m.Get(deviceCode)
+	return ok
+}
+
+// OpenGateByDeviceCode 通过设备编码直接开闸(便捷方法)
+func (m *Manager) OpenGateByDeviceCode(deviceCode string, validTime byte) error {
+	return m.OpenGate(deviceCode, validTime)
+}
+
+// CloseGateByDeviceCode 通过设备编码直接关闸。
+func (m *Manager) CloseGateByDeviceCode(deviceCode string, validTime byte) error {
+	return m.CloseGate(deviceCode, validTime)
+}
+
 // ===================== 复用原有CRC和解析函数(无需修改)=====================
 func uiCrc16Cal(pucY []byte, ucX uint8) uint16 {
 	const PRESET_VALUE = 0xFFFF
@@ -272,7 +316,7 @@ func parseReportData(deviceCode string, buf []byte) (*ReportData, error) {
 	antenna := int(buf[22])
 	epc := hex.EncodeToString(buf[11:23])
 
-	fmt.Printf(hex.EncodeToString(buf))
+	fmt.Printf("%s", hex.EncodeToString(buf))
 
 	return &ReportData{
 		DeviceCode: deviceCode,
@@ -295,11 +339,19 @@ func handleReportData(report *ReportData) {
 
 	// 委托给统一的进出场服务(所有触发方式共用同一入口)
 	result, err := service.ServiceGroupApp.ParkingServiceGroup.PassageService.HandlePassage(common.PassageRequest{
-		RFIDTag:    epc,
-		DeviceCode: report.DeviceCode,
+		RFIDTag:       epc,
+		DeviceCode:    report.DeviceCode,
+		TriggerSource: "rfid",
 	})
 	if err != nil {
 		fmt.Printf("⚠️ 进出场处理失败 | 设备:%s EPC:%s 错误:%v\n", report.DeviceCode, epc, err)
+		if result != nil {
+			PushChannelEvent(ChannelEvent{
+				DeviceCode: report.DeviceCode, RFIDTag: epc, PlateNumber: result.PlateNumber,
+				Direction: result.Direction, Timestamp: time.Now().Unix(), Status: result.GateStatus,
+				Message: result.Message, Fee: result.Fee, StayTime: result.StayTime,
+			})
+		}
 		return
 	}
 
@@ -311,9 +363,14 @@ func handleReportData(report *ReportData) {
 		PushChannelEvent(ChannelEvent{
 			DeviceCode:  report.DeviceCode,
 			RFIDTag:     epc,
+			PlateNumber: result.PlateNumber,
 			Direction:   result.Direction,
 			ChannelName: device.Channel.ChannelName,
 			Timestamp:   time.Now().Unix(),
+			Status:      "completed",
+			Message:     result.Message,
+			Fee:         result.Fee,
+			StayTime:    result.StayTime,
 		})
 	}
 }

+ 34 - 0
internal/service/uhf/reader_event_test.go

@@ -0,0 +1,34 @@
+package uhf
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestChannelEventsUseMonotonicIDs(t *testing.T) {
+	channelEventMu.Lock()
+	previousQueue := ChannelEventQueue
+	previousNextID := channelEventNextID
+	ChannelEventQueue = make([]ChannelEvent, 0, 50)
+	channelEventNextID = 0
+	channelEventMu.Unlock()
+	t.Cleanup(func() {
+		channelEventMu.Lock()
+		ChannelEventQueue = previousQueue
+		channelEventNextID = previousNextID
+		channelEventMu.Unlock()
+	})
+
+	PushChannelEvent(ChannelEvent{RFIDTag: "A", Timestamp: 100})
+	PushChannelEvent(ChannelEvent{RFIDTag: "B", Timestamp: 100})
+
+	all := GetChannelEvents(0)
+	require.Len(t, all, 2)
+	require.Equal(t, uint64(1), all[0].ID)
+	require.Equal(t, uint64(2), all[1].ID)
+
+	afterFirst := GetChannelEvents(all[0].ID)
+	require.Len(t, afterFirst, 1)
+	require.Equal(t, "B", afterFirst[0].RFIDTag)
+}

+ 36 - 0
internal/service/uhf/reader_gate_test.go

@@ -0,0 +1,36 @@
+package uhf
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+type gateReaderStub struct {
+	opened byte
+	closed byte
+}
+
+func (s *gateReaderStub) Connect() error                   { return nil }
+func (s *gateReaderStub) Disconnect() error                { return nil }
+func (s *gateReaderStub) SendData([]byte) error            { return nil }
+func (s *gateReaderStub) ReadData() ([]byte, error)        { return nil, nil }
+func (s *gateReaderStub) IsConnected() bool                { return true }
+func (s *gateReaderStub) ReleaseRelay1() error             { return nil }
+func (s *gateReaderStub) ReleaseRelay2() error             { return nil }
+func (s *gateReaderStub) CloseRelay1(validTime byte) error { s.opened = validTime; return nil }
+func (s *gateReaderStub) CloseRelay2(validTime byte) error { s.closed = validTime; return nil }
+
+func TestManagerControlsBothGateDirections(t *testing.T) {
+	reader := &gateReaderStub{}
+	manager := &Manager{devices: map[string]*DeviceHandler{
+		"GATE-01": {reader: reader},
+	}}
+
+	require.True(t, manager.IsGateConnected("GATE-01"))
+	require.NoError(t, manager.OpenGate("GATE-01", 2))
+	require.NoError(t, manager.CloseGate("GATE-01", 3))
+	require.Equal(t, byte(2), reader.opened)
+	require.Equal(t, byte(3), reader.closed)
+	require.Error(t, manager.OpenGate("MISSING", 2))
+}

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

@@ -0,0 +1,87 @@
+package vehicle
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/vehicle/request"
+	ticketService "wails-app/internal/modules/digital-ticket/service"
+	sessionRequest "wails-app/internal/modules/parking-session/model/request"
+	sessionService "wails-app/internal/modules/parking-session/service"
+	paymentService "wails-app/internal/modules/payment/service"
+)
+
+func TestVehicleEntryOpensSessionUsingCanonicalVehicleIdentity(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	vehicleType := &dao.VehicleType{Name: "session-entry", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{
+		PlateNumber: "SESSION-ENTRY", RFIDTag: "RFID-SESSION-ENTRY", VehicleTypeID: vehicleType.ID,
+	}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{
+		PlateNumber: vehicle.PlateNumber, ParkingLotID: 1,
+	})
+	require.NoError(t, err)
+
+	session, err := sessionService.NewParkingSessionService().FindActiveSession(sessionRequest.ResolveActiveSessionRequest{
+		RFIDTag: vehicle.RFIDTag,
+	})
+	require.NoError(t, err)
+	require.Equal(t, vehicle.PlateNumber, session.PlateNumber)
+	require.Equal(t, vehicle.RFIDTag, session.RFIDTag)
+
+	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)
+}
+
+func TestOperationContextResolvesRFIDOnlyVehicleForEntry(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	vehicleType := &dao.VehicleType{Name: "rfid-entry", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "RFID-CAR", RFIDTag: "RFID-ONLY-001", VehicleTypeID: vehicleType.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+
+	context, err := svc.GetOperationContext(request.VehicleIdentityRequest{RFIDTag: vehicle.RFIDTag})
+	require.NoError(t, err)
+	require.Equal(t, "entry", context.Action)
+	require.True(t, context.VehicleFound)
+	require.NotNil(t, context.Vehicle)
+	require.Equal(t, vehicle.PlateNumber, context.Vehicle.PlateNumber)
+}
+
+func TestOperationContextReturnsSessionAndTicketForExit(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle := createVehiclePaymentFixture(t, true)
+
+	context, err := svc.GetOperationContext(request.VehicleIdentityRequest{PlateNumber: vehicle.PlateNumber})
+	require.NoError(t, err)
+	require.Equal(t, "exit", context.Action)
+	require.NotNil(t, context.Exit)
+	require.Equal(t, record.ID, context.Exit.SessionID)
+	require.Equal(t, ticketService.StatePendingPayment, context.Exit.TicketState)
+	require.NotEmpty(t, context.Exit.TicketNo)
+	require.Equal(t, sessionService.PaymentStatusUnpaid, context.Exit.PaymentStatus)
+}
+
+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)
+
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		SessionID: record.ID, PlateNumber: vehicle.PlateNumber,
+		PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentFree,
+	}, 1)
+	require.NoError(t, err)
+	require.Zero(t, result.Fee)
+
+	var persisted dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persisted, record.ID).Error)
+	require.NotNil(t, persisted.ExitTime)
+}

+ 178 - 0
internal/service/vehicle/payment_edge_test.go

@@ -0,0 +1,178 @@
+package vehicle
+
+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/vehicle/request"
+	ticketService "wails-app/internal/modules/digital-ticket/service"
+	paymentService "wails-app/internal/modules/payment/service"
+)
+
+func setupVehiclePaymentTest(t *testing.T) *VehicleService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:vehicle-payment-%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)
+	sqlDB.SetMaxOpenConns(8)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	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{},
+	))
+	require.NoError(t, db.Create(&dao.PaymentMethodConfig{
+		Code: paymentService.PaymentCash, Name: "cash", InputMode: paymentService.InputModeManualAmount,
+		Scenes: []string{paymentService.EntryCounter}, Enabled: true, IsSystem: true,
+	}).Error)
+	require.NoError(t, db.Create(&dao.PaymentEntryConfig{
+		Code: paymentService.EntryCounter, Name: "counter", Enabled: true, IsSystem: true,
+	}).Error)
+	require.NoError(t, db.Create(&dao.PaymentEntryMethod{
+		EntryCode: paymentService.EntryCounter, MethodCode: paymentService.PaymentCash,
+	}).Error)
+	return &VehicleService{}
+}
+
+func createVehiclePaymentFixture(t *testing.T, withTicket bool) (*dao.VehicleRecord, *dao.Vehicle) {
+	t.Helper()
+	vehicleType := &dao.VehicleType{Name: "temporary", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.FeeConfig{
+		VehicleTypeID: vehicleType.ID, StartTime: 0, StartFee: 0, UnitTime: 60, UnitFee: 1, DailyMaxFee: 100,
+	}).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "PAY-EDGE", VehicleTypeID: vehicleType.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+	record := &dao.VehicleRecord{
+		PlateNumber: vehicle.PlateNumber, EntryTime: time.Now().Add(-2 * time.Hour), PaymentStatus: "unpaid",
+	}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	if withTicket {
+		_, err := ticketService.NewTicketService().CreateTx(global.GVA_DB, vehicle.PlateNumber, "test", record.ID)
+		require.NoError(t, err)
+	}
+	return record, vehicle
+}
+
+func TestExitConfirmRollsBackPaymentWhenTicketUpdateFails(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle := createVehiclePaymentFixture(t, false)
+
+	_, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		PlateNumber: vehicle.PlateNumber, PaymentEntry: paymentService.EntryCounter,
+		PaymentMethod: paymentService.PaymentCash, PaidAmount: 10,
+	}, 1)
+	require.Error(t, err)
+
+	var persistedRecord dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persistedRecord, record.ID).Error)
+	require.Nil(t, persistedRecord.ExitTime)
+	require.Equal(t, "unpaid", persistedRecord.PaymentStatus)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.Zero(t, paymentCount)
+
+	var persistedVehicle dao.Vehicle
+	require.NoError(t, global.GVA_DB.First(&persistedVehicle, vehicle.ID).Error)
+	require.Zero(t, persistedVehicle.TotalFee)
+	require.Zero(t, persistedVehicle.TotalStayTime)
+}
+
+func TestExitConfirmRejectsDuplicatePayment(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle := createVehiclePaymentFixture(t, true)
+	req := request.ExitConfirmRequest{
+		PlateNumber: vehicle.PlateNumber, PaymentEntry: paymentService.EntryCounter,
+		PaymentMethod: paymentService.PaymentCash, PaidAmount: 10,
+	}
+
+	_, err := svc.ExitConfirm(req, 1)
+	require.NoError(t, err)
+	_, err = svc.ExitConfirm(req, 1)
+	require.Error(t, err)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.EqualValues(t, 1, paymentCount)
+
+	var ticket dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", record.ID).First(&ticket).Error)
+	require.Equal(t, ticketService.StateExited, ticket.State)
+}
+
+func TestPayDigitalTicketUpdatesPaymentAndVehicleRecordTogether(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, _ := createVehiclePaymentFixture(t, true)
+
+	var ticket dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", record.ID).First(&ticket).Error)
+	require.NoError(t, svc.PayDigitalTicket(
+		ticket.TicketNo, paymentService.EntryCounter, paymentService.PaymentCash, "ORDER-UNIFIED-1", 10, 7,
+	))
+
+	var persistedRecord dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persistedRecord, record.ID).Error)
+	require.Equal(t, "paid", persistedRecord.PaymentStatus)
+	require.Equal(t, paymentService.EntryCounter, persistedRecord.PaymentEntry)
+	require.Equal(t, paymentService.PaymentCash, persistedRecord.PaymentMethod)
+	require.Positive(t, persistedRecord.Fee)
+	require.Nil(t, persistedRecord.ExitTime)
+
+	var payment dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.Where("record_id = ?", record.ID).First(&payment).Error)
+	require.EqualValues(t, 7, payment.OperatorID)
+	require.InDelta(t, persistedRecord.Fee, payment.Amount, 0.001)
+
+	require.NoError(t, global.GVA_DB.First(&ticket, ticket.ID).Error)
+	require.Equal(t, ticketService.StatePaid, ticket.State)
+	require.Equal(t, "ORDER-UNIFIED-1", ticket.PaymentOrderNo)
+	require.InDelta(t, payment.Amount, ticket.Fee, 0.001)
+}
+
+func TestExitConfirmConsumesPrepaidTicketWithoutSecondPayment(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	record, vehicle := createVehiclePaymentFixture(t, true)
+
+	var ticket dao.DigitalTicket
+	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", record.ID).First(&ticket).Error)
+	require.NoError(t, svc.PayDigitalTicket(
+		ticket.TicketNo, paymentService.EntryCounter, paymentService.PaymentCash, "ORDER-UNIFIED-2", 10, 7,
+	))
+
+	result, err := svc.ExitConfirm(request.ExitConfirmRequest{
+		PlateNumber: vehicle.PlateNumber,
+		// A device may still send its normal free-release request. A prepaid
+		// ticket must consume the recorded payment instead of charging again.
+		PaymentEntry: paymentService.EntryCounter, PaymentMethod: paymentService.PaymentFree,
+	}, 9)
+	require.NoError(t, err)
+	require.Equal(t, paymentService.PaymentCash, result.PaymentMethod)
+	require.Equal(t, paymentService.EntryCounter, result.PaymentEntry)
+
+	var paymentCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.EqualValues(t, 1, paymentCount)
+
+	var persistedRecord dao.VehicleRecord
+	require.NoError(t, global.GVA_DB.First(&persistedRecord, record.ID).Error)
+	require.NotNil(t, persistedRecord.ExitTime)
+	require.Equal(t, "paid", persistedRecord.PaymentStatus)
+	require.Equal(t, paymentService.PaymentCash, persistedRecord.PaymentMethod)
+
+	require.NoError(t, global.GVA_DB.First(&ticket, ticket.ID).Error)
+	require.Equal(t, ticketService.StateExited, ticket.State)
+}

+ 16 - 5
internal/service/vehicle/shortlist.go

@@ -1,6 +1,8 @@
 package vehicle
 
 import (
+	"time"
+
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/vehicle/request"
@@ -39,14 +41,23 @@ func (s *ShortlistService) CheckVehicleShortlist(plateNumber, rfidTag string) (i
 		return false, false
 	}
 
-	var shortlist dao.Shortlist
-	if err := global.GVA_DB.Where("vehicle_id = ?", vehicle.ID).First(&shortlist).Error; err != nil {
+	var shortlists []dao.Shortlist
+	now := time.Now()
+	if err := global.GVA_DB.
+		Where("vehicle_id = ? AND (expiration_time IS NULL OR expiration_time > ?)", vehicle.ID, now).
+		Find(&shortlists).Error; err != nil {
 		return false, false
 	}
 
-	isBlack = shortlist.ListType == "黑名单"
-	isWhite = shortlist.ListType == "白名单"
-	return
+	for _, shortlist := range shortlists {
+		if shortlist.ListType == "黑名单" {
+			return true, false
+		}
+		if shortlist.ListType == "白名单" {
+			isWhite = true
+		}
+	}
+	return false, isWhite
 }
 
 func (shortlistService *ShortlistService) CreateShortlist(shortlist dao.Shortlist) error {

+ 76 - 0
internal/service/vehicle/shortlist_test.go

@@ -0,0 +1,76 @@
+package vehicle
+
+import (
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupShortlistTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.Vehicle{}, &dao.Shortlist{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+	return db
+}
+
+func TestCheckVehicleShortlistExpiration(t *testing.T) {
+	db := setupShortlistTestDB(t)
+	vehicle := dao.Vehicle{PlateNumber: "TEST001", RFIDTag: "RFID001", VehicleTypeID: 1}
+	require.NoError(t, db.Create(&vehicle).Error)
+	service := &ShortlistService{}
+
+	tests := []struct {
+		name      string
+		listType  string
+		expiresAt *time.Time
+		wantBlack bool
+		wantWhite bool
+	}{
+		{name: "永久白名单", listType: "白名单", wantWhite: true},
+		{name: "有效白名单", listType: "白名单", expiresAt: timePtr(time.Now().Add(time.Hour)), wantWhite: true},
+		{name: "到期白名单", listType: "白名单", expiresAt: timePtr(time.Now()), wantWhite: false},
+		{name: "过期白名单", listType: "白名单", expiresAt: timePtr(time.Now().Add(-time.Hour)), wantWhite: false},
+		{name: "有效黑名单", listType: "黑名单", expiresAt: timePtr(time.Now().Add(time.Hour)), wantBlack: true},
+		{name: "过期黑名单", listType: "黑名单", expiresAt: timePtr(time.Now().Add(-time.Hour)), wantBlack: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require.NoError(t, db.Unscoped().Where("1 = 1").Delete(&dao.Shortlist{}).Error)
+			require.NoError(t, db.Create(&dao.Shortlist{
+				VehicleId: int(vehicle.ID), ListType: tt.listType, ExpirationTime: tt.expiresAt,
+			}).Error)
+
+			isBlack, isWhite := service.CheckVehicleShortlist(vehicle.PlateNumber, "")
+			require.Equal(t, tt.wantBlack, isBlack)
+			require.Equal(t, tt.wantWhite, isWhite)
+		})
+	}
+}
+
+func TestCheckVehicleShortlistBlackTakesPrecedence(t *testing.T) {
+	db := setupShortlistTestDB(t)
+	vehicle := dao.Vehicle{PlateNumber: "TEST002", RFIDTag: "RFID002", VehicleTypeID: 1}
+	require.NoError(t, db.Create(&vehicle).Error)
+	expiresAt := time.Now().Add(time.Hour)
+	require.NoError(t, db.Create(&[]dao.Shortlist{
+		{VehicleId: int(vehicle.ID), ListType: "白名单", ExpirationTime: &expiresAt},
+		{VehicleId: int(vehicle.ID), ListType: "黑名单", ExpirationTime: &expiresAt},
+	}).Error)
+
+	isBlack, isWhite := (&ShortlistService{}).CheckVehicleShortlist("", vehicle.RFIDTag)
+	require.True(t, isBlack)
+	require.False(t, isWhite)
+}
+
+func timePtr(value time.Time) *time.Time {
+	return &value
+}

+ 335 - 127
internal/service/vehicle/vehicle.go

@@ -9,6 +9,8 @@ import (
 	"wails-app/internal/model/vehicle/request"
 	vehicleResponse "wails-app/internal/model/vehicle/response"
 	ticketService "wails-app/internal/modules/digital-ticket/service"
+	sessionRequest "wails-app/internal/modules/parking-session/model/request"
+	sessionService "wails-app/internal/modules/parking-session/service"
 	paymentService "wails-app/internal/modules/payment/service"
 
 	"gorm.io/gorm"
@@ -24,6 +26,45 @@ func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
 	return db.Where("plate_number = ?", plateNumber)
 }
 
+// getVehicleForSessionTx 根据停车会话中保存的车辆身份定位车辆档案。
+// 优先同时匹配车牌和 RFID;若历史会话未完整保存 RFID,则依次按 RFID、车牌降级查询。
+func getVehicleForSessionTx(db *gorm.DB, session *dao.VehicleRecord) (dao.Vehicle, error) {
+	if db == nil || session == nil {
+		return dao.Vehicle{}, errors.New("车辆会话不存在")
+	}
+	var vehicle dao.Vehicle
+	if session.PlateNumber != "" && session.RFIDTag != "" {
+		result := db.Preload("VehicleType").Preload("Owner").
+			Where("plate_number = ? AND rfid_tag = ?", session.PlateNumber, session.RFIDTag).
+			First(&vehicle)
+		if result.Error == nil {
+			return vehicle, nil
+		}
+		if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
+			return dao.Vehicle{}, result.Error
+		}
+	}
+	if session.RFIDTag != "" {
+		result := db.Preload("VehicleType").Preload("Owner").Where("rfid_tag = ?", session.RFIDTag).First(&vehicle)
+		if result.Error == nil {
+			return vehicle, nil
+		}
+		if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
+			return dao.Vehicle{}, result.Error
+		}
+	}
+	if session.PlateNumber != "" {
+		result := db.Preload("VehicleType").Preload("Owner").Where("plate_number = ?", session.PlateNumber).First(&vehicle)
+		if result.Error == nil {
+			return vehicle, nil
+		}
+		if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
+			return dao.Vehicle{}, result.Error
+		}
+	}
+	return dao.Vehicle{}, errors.New("车辆不存在")
+}
+
 // CreateVehicle 创建车辆信息
 func (s *VehicleService) CreateVehicle(req request.VehicleCreate) error {
 	// 检查车牌号是否已存在
@@ -110,7 +151,13 @@ func (s *VehicleService) GetVehicleByID(id uint) (dao.Vehicle, error) {
 // GetVehicleByPlateNumber 根据车牌号或RFID获取车辆信息
 func (s *VehicleService) GetVehicleByPlateNumber(plateNumber, RFIDTag string) (dao.Vehicle, error) {
 	var vehicle dao.Vehicle
-	query := global.GVA_DB.Preload("VehicleType")
+	if plateNumber != "" && RFIDTag != "" {
+		exactQuery := global.GVA_DB.Preload("VehicleType").Preload("Owner").Where("plate_number = ? AND rfid_tag = ?", plateNumber, RFIDTag)
+		if result := exactQuery.First(&vehicle); result.Error == nil {
+			return vehicle, nil
+		}
+	}
+	query := global.GVA_DB.Preload("VehicleType").Preload("Owner")
 	if RFIDTag != "" {
 		query = query.Where("plate_number = ? OR rfid_tag = ?", plateNumber, RFIDTag)
 	} else {
@@ -190,6 +237,7 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		}
 		createReq := request.VehicleCreate{
 			PlateNumber:   req.PlateNumber,
+			RFIDTag:       req.RFIDTag,
 			VehicleTypeID: tempType.ID,
 			OwnerId:       0,
 		}
@@ -200,42 +248,34 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		vehicle, _ = s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
 	}
 
-	// 检查是否有未出场的记录
-	var existingRecord dao.VehicleRecord
-	result := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("exit_time IS NULL").First(&existingRecord)
-	if result.RowsAffected > 0 {
-		return request.VehicleEntryResponse{}, errors.New("车辆已入场,未出场")
-	}
-
-	// Create the vehicle record and its digital ticket atomically. A vehicle
-	// entry without a ticket would make every later payment flow ambiguous.
+	// 在同一事务中创建停车会话和数字票,避免出现只有会话或只有数字票的不完整入场数据。
 	entryTime := time.Now()
 	plateNumber := vehicle.PlateNumber
 	if plateNumber == "" {
 		plateNumber = req.PlateNumber
 	}
-	record := dao.VehicleRecord{
-		PlateNumber:    plateNumber,
-		RFIDTag:        req.RFIDTag,
-		EntryTime:      entryTime,
-		ParkingLotID:   req.ParkingLotID,
-		ParkingSpaceID: req.ParkingSpaceID,
-		EntryImage:     req.EntryImage,
-		PaymentStatus:  "unpaid",
+	rfidTag := req.RFIDTag
+	if rfidTag == "" {
+		rfidTag = vehicle.RFIDTag
 	}
-
+	var record *dao.VehicleRecord
 	ticketSvc := ticketService.NewTicketService()
+	sessionSvc := sessionService.NewParkingSessionService()
 	triggerMode := "manual"
 	if req.RFIDTag != "" {
 		triggerMode = "rfid"
 	}
 	err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
-		var activeRecord dao.VehicleRecord
-		if result := buildPlateOrRfidQuery(tx, req.PlateNumber, req.RFIDTag).
-			Where("exit_time IS NULL").First(&activeRecord); result.RowsAffected > 0 {
-			return errors.New("车辆已入场,未出场")
-		}
-		if err := tx.Create(&record).Error; err != nil {
+		var err error
+		record, err = sessionSvc.OpenSessionTx(tx, sessionRequest.OpenSessionRequest{
+			PlateNumber:    plateNumber,
+			RFIDTag:        rfidTag,
+			ParkingLotID:   req.ParkingLotID,
+			ParkingSpaceID: req.ParkingSpaceID,
+			EntryImage:     req.EntryImage,
+			EntryTime:      entryTime,
+		})
+		if err != nil {
 			return err
 		}
 		if _, err := ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID); err != nil {
@@ -253,90 +293,42 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		Message: "车辆入场成功",
 	}
 	response.Data.PlateNumber = plateNumber
-	response.Data.EntryTime = entryTime.Unix()
+	response.Data.EntryTime = record.EntryTime.Unix()
 	response.Data.ParkingLotID = req.ParkingLotID
 	response.Data.ParkingSpaceID = req.ParkingSpaceID
 
 	return response, nil
 }
 
-// VehicleExit 车辆出场
-func (s *VehicleService) VehicleExit(req request.VehicleExit) (request.VehicleExitResponse, error) {
-	// 1. 查询未出场记录
-	var record dao.VehicleRecord
-	if err := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("exit_time IS NULL").First(&record).Error; err != nil {
-		return request.VehicleExitResponse{}, errors.New("车辆未入场或已出场")
+func (s *VehicleService) buildExitPreview(record *dao.VehicleRecord) (vehicleResponse.ExitPreviewResponse, error) {
+	if record == nil {
+		return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话不存在")
 	}
 
-	// 2. 获取车辆信息
-	vehicle, err := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
-	if err != nil {
-		return request.VehicleExitResponse{}, err
-	}
-
-	// 3. 计算停留时长 & 费用
-	exitTime := time.Now()
-	stayTime := int64(exitTime.Sub(record.EntryTime).Minutes())
-	if stayTime < 0 {
-		stayTime = 0
-	}
-	fee := s.calculateFee(&vehicle, stayTime)
-
-	// 4. 更新出场记录
-	record.ExitTime = &exitTime
-	record.StayTime = stayTime
-	record.Fee = fee
-	record.ExitImage = req.ExitImage
-	record.PaymentStatus = "paid"
-
-	if err := global.GVA_DB.Save(&record).Error; err != nil {
-		return request.VehicleExitResponse{}, err
-	}
-
-	// 5. 更新车辆统计信息
-	vehicle.LastExitTime = &exitTime
-	vehicle.TotalStayTime += stayTime
-	vehicle.TotalFee += fee
-	global.GVA_DB.Save(&vehicle)
-
-	// 6. 完全匹配你的匿名结构体返回(正确写法)
-	var response request.VehicleExitResponse
-	response.Success = true
-	response.Message = "车辆出场成功"
-	response.Data.PlateNumber = record.PlateNumber
-	response.Data.EntryTime = record.EntryTime.Unix()
-	response.Data.ExitTime = exitTime.Unix()
-	response.Data.StayTime = stayTime
-	response.Data.Fee = fee
-	response.Data.PaymentStatus = record.PaymentStatus
-
-	return response, nil
-}
-
-// ExitPreview 费用预览(不修改数据库,仅查询+计算费用)
-func (s *VehicleService) ExitPreview(req request.VehicleEntry) (vehicleResponse.ExitPreviewResponse, error) {
-	var record dao.VehicleRecord
-	err := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("exit_time IS NULL").First(&record).Error
+	ticketSvc := ticketService.NewTicketService()
+	ticket, err := ticketSvc.GetByVehicleRecordIDTx(global.GVA_DB, record.ID)
 	if err != nil {
-		return vehicleResponse.ExitPreviewResponse{}, errors.New("车辆未入场或已出场")
+		return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话对应的数字票不存在")
 	}
 
-	vehicle, err := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
+	vehicle, err := getVehicleForSessionTx(global.GVA_DB, record)
 	if err != nil {
 		return vehicleResponse.ExitPreviewResponse{}, err
 	}
 
-	now := time.Now()
-	stayTime := int64(now.Sub(record.EntryTime).Minutes())
+	stayTime := int64(time.Since(record.EntryTime).Minutes())
 	if stayTime < 0 {
 		stayTime = 0
 	}
 	fee := s.calculateFee(&vehicle, stayTime)
 	var shortlistSvc ShortlistService
-	_, isWhite := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
+	_, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
 	if isWhite {
 		fee = 0
 	}
+	if ticket.State == ticketService.StatePaid {
+		fee = ticket.Fee
+	}
 
 	typeName := ""
 	isTemp := false
@@ -344,41 +336,237 @@ func (s *VehicleService) ExitPreview(req request.VehicleEntry) (vehicleResponse.
 		typeName = vehicle.VehicleType.Name
 		isTemp = vehicle.VehicleType.IsSystem
 	}
+	plateNumber := record.PlateNumber
+	if plateNumber == "" {
+		plateNumber = vehicle.PlateNumber
+	}
 
 	return vehicleResponse.ExitPreviewResponse{
-		PlateNumber: vehicle.PlateNumber,
-		EntryTime:   record.EntryTime.Unix(),
-		StayTime:    stayTime,
-		Fee:         fee,
-		VehicleType: typeName,
-		IsTemp:      isTemp,
+		SessionID:     record.ID,
+		TicketNo:      ticket.TicketNo,
+		TicketState:   ticket.State,
+		PlateNumber:   plateNumber,
+		RFIDTag:       record.RFIDTag,
+		EntryTime:     record.EntryTime.Unix(),
+		StayTime:      stayTime,
+		Fee:           fee,
+		PaidAmount:    ticket.PaidAmount,
+		PaymentStatus: record.PaymentStatus,
+		PaymentEntry:  ticket.PaymentEntry,
+		PaymentMethod: ticket.PaymentMethod,
+		ParkingLotID:  record.ParkingLotID,
+		EntryImage:    record.EntryImage,
+		VehicleType:   typeName,
+		IsTemp:        isTemp,
 	}, nil
 }
 
-// ExitConfirm 确认出场 + 支付(更新车辆记录 + 创建支付记录)
-func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID uint) (vehicleResponse.ExitConfirmResponse, error) {
-	// 黑名单检查
-	var shortlistSvc ShortlistService
-	isBlack, isWhite := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
-	if isBlack {
-		return vehicleResponse.ExitConfirmResponse{}, errors.New("车辆在黑名单中,禁止出场")
+// GetOperationContext 根据车辆身份判断下一步应执行进场还是出场。
+func (s *VehicleService) GetOperationContext(req request.VehicleIdentityRequest) (vehicleResponse.OperationContextResponse, error) {
+	sessionSvc := sessionService.NewParkingSessionService()
+	record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
+		PlateNumber: req.PlateNumber,
+		RFIDTag:     req.RFIDTag,
+	})
+	if err == nil {
+		preview, previewErr := s.buildExitPreview(record)
+		if previewErr != nil {
+			return vehicleResponse.OperationContextResponse{}, previewErr
+		}
+		vehicle, vehicleErr := getVehicleForSessionTx(global.GVA_DB, record)
+		if vehicleErr != nil {
+			return vehicleResponse.OperationContextResponse{}, vehicleErr
+		}
+		return vehicleResponse.OperationContextResponse{
+			Action:       "exit",
+			VehicleFound: true,
+			Vehicle:      &vehicle,
+			Exit:         &preview,
+		}, nil
+	}
+	if !errors.Is(err, sessionService.ErrSessionNotFound) {
+		return vehicleResponse.OperationContextResponse{}, err
+	}
+
+	vehicle, vehicleErr := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
+	if vehicleErr != nil {
+		return vehicleResponse.OperationContextResponse{Action: "entry", VehicleFound: false}, nil
+	}
+	return vehicleResponse.OperationContextResponse{
+		Action:       "entry",
+		VehicleFound: true,
+		Vehicle:      &vehicle,
+	}, nil
+}
+
+// ExitPreview 费用预览(不修改数据库,仅查询和计算费用)。
+func (s *VehicleService) ExitPreview(req request.ExitPreviewRequest) (vehicleResponse.ExitPreviewResponse, error) {
+	sessionSvc := sessionService.NewParkingSessionService()
+	record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
+		SessionID:   req.SessionID,
+		PlateNumber: req.PlateNumber,
+		RFIDTag:     req.RFIDTag,
+	})
+	if err != nil {
+		return vehicleResponse.ExitPreviewResponse{}, errors.New("车辆未入场或已出场")
+	}
+	return s.buildExitPreview(record)
+}
+
+// PayDigitalTicket settles a digital ticket and its vehicle record atomically.
+// A successful ticket payment is therefore visible to exit processing,
+// payment reports, and shift reconciliation immediately.
+func (s *VehicleService) PayDigitalTicket(ticketNo, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount float64, operatorID uint) error {
+	if ticketNo == "" {
+		return errors.New("数字票号不能为空")
+	}
+	if paymentEntry == "" {
+		return errors.New("数字票支付必须指定支付入口")
+	}
+
+	ticketSvc := ticketService.NewTicketService()
+	paymentSvc := paymentService.NewPaymentService()
+	sessionSvc := sessionService.NewParkingSessionService()
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		ticket, err := ticketSvc.GetByTicketNoTx(tx, ticketNo)
+		if err != nil {
+			return errors.New("票不存在")
+		}
+		if ticket.State != ticketService.StatePendingPayment {
+			return fmt.Errorf("数字票当前状态不允许支付: %s", ticket.State)
+		}
+
+		record, err := sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
+			SessionID: ticket.VehicleRecordID,
+		})
+		if err != nil {
+			return errors.New("车辆未入场或已出场")
+		}
+
+		vehicle, err := getVehicleForSessionTx(tx, record)
+		if err != nil {
+			return err
+		}
+
+		stayTime := int64(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
+		}
+
+		payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, paymentMethod, fee, paidAmount, operatorID)
+		if err != nil {
+			return err
+		}
+		extra := map[string]interface{}{
+			"payment_entry": paymentEntry, "payment_method": paymentMethod, "payment_order_no": paymentOrderNo,
+			"paid_amount": payment.PaidAmount, "fee": fee,
+		}
+		return ticketSvc.TransitionTx(tx, ticket.ID, ticketService.StatePaid, extra)
+	})
+}
+
+func resolveExitRecordTx(tx *gorm.DB, ticketSvc *ticketService.TicketService, req request.ExitConfirmRequest) (*dao.VehicleRecord, *dao.DigitalTicket, error) {
+	sessionSvc := sessionService.NewParkingSessionService()
+	var record *dao.VehicleRecord
+	var ticket *dao.DigitalTicket
+	var err error
+
+	if req.SessionID != 0 {
+		record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{SessionID: req.SessionID})
+		if err == nil {
+			ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
+		}
+		if err == nil && req.TicketNo != "" && ticket.TicketNo != req.TicketNo {
+			return nil, nil, errors.New("停车会话与数字票不匹配")
+		}
+	} else if req.TicketNo != "" {
+		ticket, err = ticketSvc.GetByTicketNoTx(tx, req.TicketNo)
+		if err != nil {
+			return nil, nil, errors.New("票不存在")
+		}
+		record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
+			SessionID: ticket.VehicleRecordID,
+		})
+	} else {
+		record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
+			PlateNumber: req.PlateNumber,
+			RFIDTag:     req.RFIDTag,
+		})
+		if err == nil {
+			ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
+		}
+	}
+	if err != nil {
+		return nil, nil, errors.New("车辆未入场、已出场或数字票不存在")
+	}
+	return record, ticket, nil
+}
+
+func ensurePrepaidPaymentTx(tx *gorm.DB, ticket *dao.DigitalTicket, recordID uint) (*dao.PaymentRecord, error) {
+	var payment dao.PaymentRecord
+	err := tx.Where("record_id = ?", recordID).Order("id DESC").First(&payment).Error
+	if err == nil {
+		return &payment, nil
+	}
+	if !errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, err
+	}
+	if ticket.PaymentEntry == "" || ticket.PaymentMethod == "" {
+		return nil, errors.New("已支付数字票缺少支付明细")
 	}
+
+	paidAt := time.Now()
+	if ticket.PaidAt != nil {
+		paidAt = *ticket.PaidAt
+	}
+	changeAmount := ticket.PaidAmount - ticket.Fee
+	if changeAmount < 0 {
+		changeAmount = 0
+	}
+	payment = dao.PaymentRecord{
+		RecordID: recordID, PaymentEntry: ticket.PaymentEntry, PaymentMethod: ticket.PaymentMethod,
+		Amount: ticket.Fee, PaidAmount: ticket.PaidAmount, ChangeAmount: changeAmount,
+		PaidAt: paidAt, Remark: "由历史数字票支付记录补建",
+	}
+	if err := tx.Create(&payment).Error; err != nil {
+		return nil, err
+	}
+	return &payment, nil
+}
+
+// ExitConfirm is the only exit settlement path. It either creates a new
+// payment or consumes an existing paid digital ticket, then updates the vehicle
+// record, vehicle statistics, and ticket state in one transaction.
+func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID uint) (vehicleResponse.ExitConfirmResponse, error) {
 	paymentEntry := req.PaymentEntry
 	if paymentEntry == "" {
 		paymentEntry = paymentService.EntryCounter
 	}
+
 	var result vehicleResponse.ExitConfirmResponse
 	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
-		var record dao.VehicleRecord
-		if err := buildPlateOrRfidQuery(tx, req.PlateNumber, req.RFIDTag).Where("exit_time IS NULL").First(&record).Error; err != nil {
-			return errors.New("车辆未入场或已出场")
+		ticketSvc := ticketService.NewTicketService()
+		sessionSvc := sessionService.NewParkingSessionService()
+		record, ticket, err := resolveExitRecordTx(tx, ticketSvc, req)
+		if err != nil {
+			return err
 		}
 
-		var vehicle dao.Vehicle
-		query := tx.Preload("VehicleType")
-		query = buildPlateOrRfidQuery(query, req.PlateNumber, req.RFIDTag)
-		if err := query.First(&vehicle).Error; err != nil {
-			return errors.New("车辆不存在")
+		var shortlistSvc ShortlistService
+		isBlack, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
+		if isBlack {
+			return errors.New("车辆在黑名单中,禁止出场")
+		}
+
+		vehicle, err := getVehicleForSessionTx(tx, record)
+		if err != nil {
+			return err
 		}
 
 		exitTime := time.Now()
@@ -390,22 +578,47 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 		if isWhite {
 			fee = 0
 		}
-		isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
-		if isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
-			return errors.New("临时车辆不能使用免密放行")
-		}
 
-		paymentSvc := paymentService.NewPaymentService()
-		if _, _, err := paymentSvc.ValidatePayment(tx, req.PaymentMethod, paymentEntry, fee, req.PaidAmount); err != nil {
-			return err
+		actualEntry := paymentEntry
+		actualMethod := req.PaymentMethod
+		paidAmount := req.PaidAmount
+		changeAmount := 0.0
+		if ticket.State == ticketService.StatePaid {
+			payment, err := ensurePrepaidPaymentTx(tx, ticket, record.ID)
+			if err != nil {
+				return err
+			}
+			fee = payment.Amount
+			paidAmount = payment.PaidAmount
+			changeAmount = payment.ChangeAmount
+			actualEntry = payment.PaymentEntry
+			actualMethod = payment.PaymentMethod
+		} else if ticket.State == ticketService.StatePendingPayment {
+			isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
+			if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
+				return errors.New("临时车辆不能使用免密放行")
+			}
+			paymentSvc := paymentService.NewPaymentService()
+			payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, req.PaymentMethod, fee, req.PaidAmount, operatorID)
+			if err != nil {
+				return err
+			}
+			paidAmount = payment.PaidAmount
+			changeAmount = payment.ChangeAmount
+		} else {
+			return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
 		}
 
-		record.ExitTime = &exitTime
-		record.StayTime = stayTime
-		record.Fee = fee
-		record.PaymentEntry = paymentEntry
-		record.PaymentMethod = req.PaymentMethod
-		if err := tx.Save(&record).Error; err != nil {
+		record, err = sessionSvc.CloseSessionTx(tx, sessionRequest.CloseSessionRequest{
+			SessionID:     record.ID,
+			ExitTime:      exitTime,
+			StayTime:      stayTime,
+			Fee:           fee,
+			PaymentStatus: sessionService.PaymentStatusPaid,
+			PaymentEntry:  actualEntry,
+			PaymentMethod: actualMethod,
+		})
+		if err != nil {
 			return err
 		}
 
@@ -416,17 +629,12 @@ func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID
 			return err
 		}
 
-		pr, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, req.PaymentMethod, fee, req.PaidAmount, operatorID)
-		if err != nil {
-			return err
-		}
-		ticketSvc := ticketService.NewTicketService()
-		if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, paymentEntry, req.PaymentMethod, "", pr.PaidAmount, fee); err != nil {
+		if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, actualEntry, actualMethod, "", paidAmount, fee); err != nil {
 			return err
 		}
 		result = vehicleResponse.ExitConfirmResponse{
 			PlateNumber: vehicle.PlateNumber, EntryTime: record.EntryTime.Unix(), ExitTime: exitTime.Unix(),
-			StayTime: stayTime, Fee: fee, ChangeAmount: pr.ChangeAmount, PaymentEntry: paymentEntry, PaymentMethod: req.PaymentMethod,
+			StayTime: stayTime, Fee: fee, ChangeAmount: changeAmount, PaymentEntry: actualEntry, PaymentMethod: actualMethod,
 		}
 		return nil
 	})

+ 0 - 47
internal/utils/ast/ast.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"fmt"
-	"go/ast"
-	"go/token"
-)
-
-// 增加 import 方法
-func AddImport(astNode ast.Node, imp string) {
-	impStr := fmt.Sprintf("\"%s\"", imp)
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if genDecl, ok := node.(*ast.GenDecl); ok {
-			if genDecl.Tok == token.IMPORT {
-				for i := range genDecl.Specs {
-					if impNode, ok := genDecl.Specs[i].(*ast.ImportSpec); ok {
-						if impNode.Path.Value == impStr {
-							return false
-						}
-					}
-				}
-				genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-					Path: &ast.BasicLit{
-						Kind:  token.STRING,
-						Value: impStr,
-					},
-				})
-			}
-		}
-		return true
-	})
-}
-
-// 查询特定function方法
-func FindFunction(astNode ast.Node, FunctionName string) *ast.FuncDecl {
-	var funcDeclP *ast.FuncDecl
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if funcDecl, ok := node.(*ast.FuncDecl); ok {
-			if funcDecl.Name.String() == FunctionName {
-				funcDeclP = funcDecl
-				return false
-			}
-		}
-		return true
-	})
-	return funcDeclP
-}

+ 0 - 47
internal/utils/ast/ast_auto_enter.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-func ImportForAutoEnter(path string, funcName string, code string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if typeSpec, ok := node.(*ast.TypeSpec); ok {
-			if typeSpec.Name.Name == funcName {
-				if st, ok := typeSpec.Type.(*ast.StructType); ok {
-					for i := range st.Fields.List {
-						if t, ok := st.Fields.List[i].Type.(*ast.Ident); ok {
-							if t.Name == code {
-								return false
-							}
-						}
-					}
-					sn := &ast.Field{
-						Type: &ast.Ident{Name: code},
-					}
-					st.Fields.List = append(st.Fields.List, sn)
-				}
-			}
-		}
-		return true
-	})
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	err = printer.Fprint(bf, fileSet, astFile)
-	if err != nil {
-		return
-	}
-	_ = os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 181
internal/utils/ast/ast_enter.go

@@ -1,181 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"go/ast"
-	"go/format"
-	"go/parser"
-	"go/token"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-	"log"
-	"os"
-	"strconv"
-	"strings"
-)
-
-type Visitor struct {
-	ImportCode  string
-	StructName  string
-	PackageName string
-	GroupName   string
-}
-
-func (vi *Visitor) Visit(node ast.Node) ast.Visitor {
-	switch n := node.(type) {
-	case *ast.GenDecl:
-		// 查找有没有import context包
-		// Notice:没有考虑没有import任何包的情况
-		if n.Tok == token.IMPORT && vi.ImportCode != "" {
-			vi.addImport(n)
-			// 不需要再遍历子树
-			return nil
-		}
-		if n.Tok == token.TYPE && vi.StructName != "" && vi.PackageName != "" && vi.GroupName != "" {
-			vi.addStruct(n)
-			return nil
-		}
-	case *ast.FuncDecl:
-		if n.Name.Name == "Routers" {
-			vi.addFuncBodyVar(n)
-			return nil
-		}
-
-	}
-	return vi
-}
-
-func (vi *Visitor) addStruct(genDecl *ast.GenDecl) ast.Visitor {
-	for i := range genDecl.Specs {
-		switch n := genDecl.Specs[i].(type) {
-		case *ast.TypeSpec:
-			if strings.Index(n.Name.Name, "Group") > -1 {
-				switch t := n.Type.(type) {
-				case *ast.StructType:
-					f := &ast.Field{
-						Names: []*ast.Ident{
-							{
-								Name: vi.StructName,
-								Obj: &ast.Object{
-									Kind: ast.Var,
-									Name: vi.StructName,
-								},
-							},
-						},
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: vi.PackageName,
-							},
-							Sel: &ast.Ident{
-								Name: vi.GroupName,
-							},
-						},
-					}
-					t.Fields.List = append(t.Fields.List, f)
-				}
-			}
-		}
-	}
-	return vi
-}
-
-func (vi *Visitor) addImport(genDecl *ast.GenDecl) ast.Visitor {
-	// 是否已经import
-	hasImported := false
-	for _, v := range genDecl.Specs {
-		importSpec := v.(*ast.ImportSpec)
-		// 如果已经包含
-		if importSpec.Path.Value == strconv.Quote(vi.ImportCode) {
-			hasImported = true
-		}
-	}
-	if !hasImported {
-		genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-			Path: &ast.BasicLit{
-				Kind:  token.STRING,
-				Value: strconv.Quote(vi.ImportCode),
-			},
-		})
-	}
-	return vi
-}
-
-func (vi *Visitor) addFuncBodyVar(funDecl *ast.FuncDecl) ast.Visitor {
-	hasVar := false
-	for _, v := range funDecl.Body.List {
-		switch varSpec := v.(type) {
-		case *ast.AssignStmt:
-			for i := range varSpec.Lhs {
-				switch nn := varSpec.Lhs[i].(type) {
-				case *ast.Ident:
-					if nn.Name == vi.PackageName+"Router" {
-						hasVar = true
-					}
-				}
-			}
-		}
-	}
-	if !hasVar {
-		assignStmt := &ast.AssignStmt{
-			Lhs: []ast.Expr{
-				&ast.Ident{
-					Name: vi.PackageName + "Router",
-					Obj: &ast.Object{
-						Kind: ast.Var,
-						Name: vi.PackageName + "Router",
-					},
-				},
-			},
-			Tok: token.DEFINE,
-			Rhs: []ast.Expr{
-				&ast.SelectorExpr{
-					X: &ast.SelectorExpr{
-						X: &ast.Ident{
-							Name: "router",
-						},
-						Sel: &ast.Ident{
-							Name: "RouterGroupApp",
-						},
-					},
-					Sel: &ast.Ident{
-						Name: cases.Title(language.English).String(vi.PackageName),
-					},
-				},
-			},
-		}
-		funDecl.Body.List = append(funDecl.Body.List, funDecl.Body.List[1])
-		index := 1
-		copy(funDecl.Body.List[index+1:], funDecl.Body.List[index:])
-		funDecl.Body.List[index] = assignStmt
-	}
-	return vi
-}
-
-func ImportReference(filepath, importCode, structName, packageName, groupName string) error {
-	fSet := token.NewFileSet()
-	fParser, err := parser.ParseFile(fSet, filepath, nil, parser.ParseComments)
-	if err != nil {
-		return err
-	}
-	importCode = strings.TrimSpace(importCode)
-	v := &Visitor{
-		ImportCode:  importCode,
-		StructName:  structName,
-		PackageName: packageName,
-		GroupName:   groupName,
-	}
-	if importCode == "" {
-		ast.Print(fSet, fParser)
-	}
-
-	ast.Walk(v, fParser)
-
-	var output []byte
-	buffer := bytes.NewBuffer(output)
-	err = format.Node(buffer, fSet, fParser)
-	if err != nil {
-		log.Fatal(err)
-	}
-	// 写回数据
-	return os.WriteFile(filepath, buffer.Bytes(), 0o600)
-}

+ 0 - 166
internal/utils/ast/ast_gorm.go

@@ -1,166 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-// 自动为 gorm.go 注册一个自动迁移
-func AddRegisterTablesAst(path, funcName, pk, varName, dbName, model string) {
-	modelPk := fmt.Sprintf("wails-app/internal/model/%s", pk)
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	AddImport(astFile, modelPk)
-	FuncNode := FindFunction(astFile, funcName)
-	if FuncNode != nil {
-		ast.Print(fileSet, FuncNode)
-	}
-	addDBVar(FuncNode.Body, varName, dbName)
-	addAutoMigrate(FuncNode.Body, varName, pk, model)
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-// 增加一个 db库变量
-func addDBVar(astBody *ast.BlockStmt, varName, dbName string) {
-	if dbName == "" {
-		return
-	}
-	dbStr := fmt.Sprintf("\"%s\"", dbName)
-
-	for i := range astBody.List {
-		if assignStmt, ok := astBody.List[i].(*ast.AssignStmt); ok {
-			if ident, ok := assignStmt.Lhs[0].(*ast.Ident); ok {
-				if ident.Name == varName {
-					return
-				}
-			}
-		}
-	}
-	assignNode := &ast.AssignStmt{
-		Lhs: []ast.Expr{
-			&ast.Ident{
-				Name: varName,
-			},
-		},
-		Tok: token.DEFINE,
-		Rhs: []ast.Expr{
-			&ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: "global",
-					},
-					Sel: &ast.Ident{
-						Name: "GetGlobalDBByDBName",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.BasicLit{
-						Kind:  token.STRING,
-						Value: dbStr,
-					},
-				},
-			},
-		},
-	}
-	astBody.List = append([]ast.Stmt{assignNode}, astBody.List...)
-}
-
-// 为db库变量增加 AutoMigrate 方法
-func addAutoMigrate(astBody *ast.BlockStmt, dbname string, pk string, model string) {
-	if dbname == "" {
-		dbname = "db"
-	}
-	flag := true
-	ast.Inspect(astBody, func(node ast.Node) bool {
-		// 首先判断需要加入的方法调用语句是否存在 不存在则直接走到下方逻辑
-		switch n := node.(type) {
-		case *ast.CallExpr:
-			// 判断是否找到了AutoMigrate语句
-			if s, ok := n.Fun.(*ast.SelectorExpr); ok {
-				if x, ok := s.X.(*ast.Ident); ok {
-					if s.Sel.Name == "AutoMigrate" && x.Name == dbname {
-						flag = false
-						if !NeedAppendModel(n, pk, model) {
-							return false
-						}
-						// 判断已经找到了AutoMigrate语句
-						n.Args = append(n.Args, &ast.CompositeLit{
-							Type: &ast.SelectorExpr{
-								X: &ast.Ident{
-									Name: pk,
-								},
-								Sel: &ast.Ident{
-									Name: model,
-								},
-							},
-						})
-						return false
-					}
-				}
-			}
-		}
-		return true
-		//然后判断 pk.model是否存在 如果存在直接跳出 如果不存在 则向已经找到的方法调用语句的node里面push一条
-	})
-
-	if flag {
-		exprStmt := &ast.ExprStmt{
-			X: &ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: dbname,
-					},
-					Sel: &ast.Ident{
-						Name: "AutoMigrate",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.CompositeLit{
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: pk,
-							},
-							Sel: &ast.Ident{
-								Name: model,
-							},
-						},
-					},
-				},
-			}}
-		astBody.List = append(astBody.List, exprStmt)
-	}
-}
-
-// 为automigrate增加实参
-func NeedAppendModel(callNode ast.Node, pk string, model string) bool {
-	flag := true
-	ast.Inspect(callNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.SelectorExpr:
-			if x, ok := n.X.(*ast.Ident); ok {
-				if n.Sel.Name == model && x.Name == pk {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 0 - 173
internal/utils/ast/ast_rollback.go

@@ -1,173 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"path/filepath"
-	"wails-app/internal/global"
-)
-
-func RollBackAst(pk, model string) {
-	RollGormBack(pk, model)
-	RollRouterBack(pk, model)
-}
-
-func RollGormBack(pk, model string) {
-
-	// 首先分析存在多少个ttt作为调用方的node块
-	// 如果多个 仅仅删除对应块即可
-	// 如果单个 那么还需要剔除import
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "gorm.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	var n *ast.CallExpr
-	var k int = -1
-	var pkNum = 0
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if node, ok := node.(*ast.CallExpr); ok {
-			for i := range node.Args {
-				pkOK := false
-				modelOK := false
-				ast.Inspect(node.Args[i], func(item ast.Node) bool {
-					if ii, ok := item.(*ast.Ident); ok {
-						if ii.Name == pk {
-							pkOK = true
-							pkNum++
-						}
-						if ii.Name == model {
-							modelOK = true
-						}
-					}
-					if pkOK && modelOK {
-						n = node
-						k = i
-					}
-					return true
-				})
-			}
-		}
-		return true
-	})
-	if k > 0 {
-		n.Args = append(append([]ast.Expr{}, n.Args[:k]...), n.Args[k+1:]...)
-	}
-	if pkNum == 1 {
-		var imI int = -1
-		var gp *ast.GenDecl
-		ast.Inspect(astFile, func(node ast.Node) bool {
-			if gen, ok := node.(*ast.GenDecl); ok {
-				for i := range gen.Specs {
-					if imspec, ok := gen.Specs[i].(*ast.ImportSpec); ok {
-						if imspec.Path.Value == "\"wails-app/internal/model/"+pk+"\"" {
-							gp = gen
-							imI = i
-							return false
-						}
-					}
-				}
-			}
-			return true
-		})
-
-		if imI > -1 {
-			gp.Specs = append(append([]ast.Spec{}, gp.Specs[:imI]...), gp.Specs[imI+1:]...)
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-
-}
-
-func RollRouterBack(pk, model string) {
-
-	// 首先抓到所有的代码块结构 {}
-	// 分析结构中是否存在一个变量叫做 pk+Router
-	// 然后获取到代码块指针 对内部需要回滚的代码进行剔除
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "router.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	var block *ast.BlockStmt
-	var routerStmt *ast.FuncDecl
-
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if n, ok := node.(*ast.FuncDecl); ok {
-			if n.Name.Name == "Routers" {
-				routerStmt = n
-			}
-		}
-
-		if n, ok := node.(*ast.BlockStmt); ok {
-			ast.Inspect(n, func(bNode ast.Node) bool {
-				if in, ok := bNode.(*ast.Ident); ok {
-					if in.Name == pk+"Router" {
-						block = n
-						return false
-					}
-				}
-				return true
-			})
-			return true
-		}
-		return true
-	})
-	var k int
-	for i := range block.List {
-		if stmtNode, ok := block.List[i].(*ast.ExprStmt); ok {
-			ast.Inspect(stmtNode, func(node ast.Node) bool {
-				if n, ok := node.(*ast.Ident); ok {
-					if n.Name == "Init"+model+"Router" {
-						k = i
-						return false
-					}
-				}
-				return true
-			})
-		}
-	}
-
-	block.List = append(append([]ast.Stmt{}, block.List[:k]...), block.List[k+1:]...)
-
-	if len(block.List) == 1 {
-		// 说明这个块就没任何意义了
-		block.List = nil
-	}
-
-	for i, n := range routerStmt.Body.List {
-		if n, ok := n.(*ast.BlockStmt); ok {
-			if n.List == nil {
-				routerStmt.Body.List = append(append([]ast.Stmt{}, routerStmt.Body.List[:i]...), routerStmt.Body.List[i+1:]...)
-				i--
-			}
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 135
internal/utils/ast/ast_router.go

@@ -1,135 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"strings"
-)
-
-func AppendNodeToList(stmts []ast.Stmt, stmt ast.Stmt, index int) []ast.Stmt {
-	return append(stmts[:index], append([]ast.Stmt{stmt}, stmts[index:]...)...)
-}
-
-func AddRouterCode(path, funcName, pk, model string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, parser.ParseComments)
-
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	FuncNode := FindFunction(astFile, funcName)
-
-	pkName := strings.ToUpper(pk[:1]) + pk[1:]
-	routerName := fmt.Sprintf("%sRouter", pk)
-	modelName := fmt.Sprintf("Init%sRouter", model)
-	var bloctPre *ast.BlockStmt
-	for i := len(FuncNode.Body.List) - 1; i >= 0; i-- {
-		if block, ok := FuncNode.Body.List[i].(*ast.BlockStmt); ok {
-			bloctPre = block
-		}
-	}
-	ast.Print(fileSet, FuncNode)
-	if ok, b := needAppendRouter(FuncNode, pk); ok {
-		routerNode :=
-			&ast.BlockStmt{
-				List: []ast.Stmt{
-					&ast.AssignStmt{
-						Lhs: []ast.Expr{
-							&ast.Ident{Name: routerName},
-						},
-						Tok: token.DEFINE,
-						Rhs: []ast.Expr{
-							&ast.SelectorExpr{
-								X: &ast.SelectorExpr{
-									X:   &ast.Ident{Name: "router"},
-									Sel: &ast.Ident{Name: "RouterGroupApp"},
-								},
-								Sel: &ast.Ident{Name: pkName},
-							},
-						},
-					},
-				},
-			}
-
-		FuncNode.Body.List = AppendNodeToList(FuncNode.Body.List, routerNode, len(FuncNode.Body.List)-2)
-		bloctPre = routerNode
-	} else {
-		bloctPre = b
-	}
-
-	if needAppendInit(FuncNode, routerName, modelName) {
-		bloctPre.List = append(bloctPre.List,
-			&ast.ExprStmt{
-				X: &ast.CallExpr{
-					Fun: &ast.SelectorExpr{
-						X:   &ast.Ident{Name: routerName},
-						Sel: &ast.Ident{Name: modelName},
-					},
-					Args: []ast.Expr{
-						&ast.Ident{
-							Name: "PrivateGroup",
-						},
-						&ast.Ident{
-							Name: "PublicGroup",
-						},
-					},
-				},
-			})
-	}
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-func needAppendRouter(funcNode ast.Node, pk string) (bool, *ast.BlockStmt) {
-	flag := true
-	var block *ast.BlockStmt
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.BlockStmt:
-			for i := range n.List {
-				if assignNode, ok := n.List[i].(*ast.AssignStmt); ok {
-					if identNode, ok := assignNode.Lhs[0].(*ast.Ident); ok {
-						if identNode.Name == fmt.Sprintf("%sRouter", pk) {
-							flag = false
-							block = n
-							return false
-						}
-					}
-				}
-			}
-
-		}
-		return true
-	})
-	return flag, block
-}
-
-func needAppendInit(funcNode ast.Node, routerName string, modelName string) bool {
-	flag := true
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := funcNode.(type) {
-		case *ast.CallExpr:
-			if selectNode, ok := n.Fun.(*ast.SelectorExpr); ok {
-				x, xok := selectNode.X.(*ast.Ident)
-				if xok && x.Name == routerName && selectNode.Sel.Name == modelName {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 0 - 47
pkg/utils/ast/ast.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"fmt"
-	"go/ast"
-	"go/token"
-)
-
-// 增加 import 方法
-func AddImport(astNode ast.Node, imp string) {
-	impStr := fmt.Sprintf("\"%s\"", imp)
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if genDecl, ok := node.(*ast.GenDecl); ok {
-			if genDecl.Tok == token.IMPORT {
-				for i := range genDecl.Specs {
-					if impNode, ok := genDecl.Specs[i].(*ast.ImportSpec); ok {
-						if impNode.Path.Value == impStr {
-							return false
-						}
-					}
-				}
-				genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-					Path: &ast.BasicLit{
-						Kind:  token.STRING,
-						Value: impStr,
-					},
-				})
-			}
-		}
-		return true
-	})
-}
-
-// 查询特定function方法
-func FindFunction(astNode ast.Node, FunctionName string) *ast.FuncDecl {
-	var funcDeclP *ast.FuncDecl
-	ast.Inspect(astNode, func(node ast.Node) bool {
-		if funcDecl, ok := node.(*ast.FuncDecl); ok {
-			if funcDecl.Name.String() == FunctionName {
-				funcDeclP = funcDecl
-				return false
-			}
-		}
-		return true
-	})
-	return funcDeclP
-}

+ 0 - 47
pkg/utils/ast/ast_auto_enter.go

@@ -1,47 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-func ImportForAutoEnter(path string, funcName string, code string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if typeSpec, ok := node.(*ast.TypeSpec); ok {
-			if typeSpec.Name.Name == funcName {
-				if st, ok := typeSpec.Type.(*ast.StructType); ok {
-					for i := range st.Fields.List {
-						if t, ok := st.Fields.List[i].Type.(*ast.Ident); ok {
-							if t.Name == code {
-								return false
-							}
-						}
-					}
-					sn := &ast.Field{
-						Type: &ast.Ident{Name: code},
-					}
-					st.Fields.List = append(st.Fields.List, sn)
-				}
-			}
-		}
-		return true
-	})
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	err = printer.Fprint(bf, fileSet, astFile)
-	if err != nil {
-		return
-	}
-	_ = os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 181
pkg/utils/ast/ast_enter.go

@@ -1,181 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"go/ast"
-	"go/format"
-	"go/parser"
-	"go/token"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-	"log"
-	"os"
-	"strconv"
-	"strings"
-)
-
-type Visitor struct {
-	ImportCode  string
-	StructName  string
-	PackageName string
-	GroupName   string
-}
-
-func (vi *Visitor) Visit(node ast.Node) ast.Visitor {
-	switch n := node.(type) {
-	case *ast.GenDecl:
-		// 查找有没有import context包
-		// Notice:没有考虑没有import任何包的情况
-		if n.Tok == token.IMPORT && vi.ImportCode != "" {
-			vi.addImport(n)
-			// 不需要再遍历子树
-			return nil
-		}
-		if n.Tok == token.TYPE && vi.StructName != "" && vi.PackageName != "" && vi.GroupName != "" {
-			vi.addStruct(n)
-			return nil
-		}
-	case *ast.FuncDecl:
-		if n.Name.Name == "Routers" {
-			vi.addFuncBodyVar(n)
-			return nil
-		}
-
-	}
-	return vi
-}
-
-func (vi *Visitor) addStruct(genDecl *ast.GenDecl) ast.Visitor {
-	for i := range genDecl.Specs {
-		switch n := genDecl.Specs[i].(type) {
-		case *ast.TypeSpec:
-			if strings.Index(n.Name.Name, "Group") > -1 {
-				switch t := n.Type.(type) {
-				case *ast.StructType:
-					f := &ast.Field{
-						Names: []*ast.Ident{
-							{
-								Name: vi.StructName,
-								Obj: &ast.Object{
-									Kind: ast.Var,
-									Name: vi.StructName,
-								},
-							},
-						},
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: vi.PackageName,
-							},
-							Sel: &ast.Ident{
-								Name: vi.GroupName,
-							},
-						},
-					}
-					t.Fields.List = append(t.Fields.List, f)
-				}
-			}
-		}
-	}
-	return vi
-}
-
-func (vi *Visitor) addImport(genDecl *ast.GenDecl) ast.Visitor {
-	// 是否已经import
-	hasImported := false
-	for _, v := range genDecl.Specs {
-		importSpec := v.(*ast.ImportSpec)
-		// 如果已经包含
-		if importSpec.Path.Value == strconv.Quote(vi.ImportCode) {
-			hasImported = true
-		}
-	}
-	if !hasImported {
-		genDecl.Specs = append(genDecl.Specs, &ast.ImportSpec{
-			Path: &ast.BasicLit{
-				Kind:  token.STRING,
-				Value: strconv.Quote(vi.ImportCode),
-			},
-		})
-	}
-	return vi
-}
-
-func (vi *Visitor) addFuncBodyVar(funDecl *ast.FuncDecl) ast.Visitor {
-	hasVar := false
-	for _, v := range funDecl.Body.List {
-		switch varSpec := v.(type) {
-		case *ast.AssignStmt:
-			for i := range varSpec.Lhs {
-				switch nn := varSpec.Lhs[i].(type) {
-				case *ast.Ident:
-					if nn.Name == vi.PackageName+"Router" {
-						hasVar = true
-					}
-				}
-			}
-		}
-	}
-	if !hasVar {
-		assignStmt := &ast.AssignStmt{
-			Lhs: []ast.Expr{
-				&ast.Ident{
-					Name: vi.PackageName + "Router",
-					Obj: &ast.Object{
-						Kind: ast.Var,
-						Name: vi.PackageName + "Router",
-					},
-				},
-			},
-			Tok: token.DEFINE,
-			Rhs: []ast.Expr{
-				&ast.SelectorExpr{
-					X: &ast.SelectorExpr{
-						X: &ast.Ident{
-							Name: "router",
-						},
-						Sel: &ast.Ident{
-							Name: "RouterGroupApp",
-						},
-					},
-					Sel: &ast.Ident{
-						Name: cases.Title(language.English).String(vi.PackageName),
-					},
-				},
-			},
-		}
-		funDecl.Body.List = append(funDecl.Body.List, funDecl.Body.List[1])
-		index := 1
-		copy(funDecl.Body.List[index+1:], funDecl.Body.List[index:])
-		funDecl.Body.List[index] = assignStmt
-	}
-	return vi
-}
-
-func ImportReference(filepath, importCode, structName, packageName, groupName string) error {
-	fSet := token.NewFileSet()
-	fParser, err := parser.ParseFile(fSet, filepath, nil, parser.ParseComments)
-	if err != nil {
-		return err
-	}
-	importCode = strings.TrimSpace(importCode)
-	v := &Visitor{
-		ImportCode:  importCode,
-		StructName:  structName,
-		PackageName: packageName,
-		GroupName:   groupName,
-	}
-	if importCode == "" {
-		ast.Print(fSet, fParser)
-	}
-
-	ast.Walk(v, fParser)
-
-	var output []byte
-	buffer := bytes.NewBuffer(output)
-	err = format.Node(buffer, fSet, fParser)
-	if err != nil {
-		log.Fatal(err)
-	}
-	// 写回数据
-	return os.WriteFile(filepath, buffer.Bytes(), 0o600)
-}

+ 0 - 166
pkg/utils/ast/ast_gorm.go

@@ -1,166 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-)
-
-// 自动为 gorm.go 注册一个自动迁移
-func AddRegisterTablesAst(path, funcName, pk, varName, dbName, model string) {
-	modelPk := fmt.Sprintf("wails-app/internal/model/%s", pk)
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	AddImport(astFile, modelPk)
-	FuncNode := FindFunction(astFile, funcName)
-	if FuncNode != nil {
-		ast.Print(fileSet, FuncNode)
-	}
-	addDBVar(FuncNode.Body, varName, dbName)
-	addAutoMigrate(FuncNode.Body, varName, pk, model)
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-// 增加一个 db库变量
-func addDBVar(astBody *ast.BlockStmt, varName, dbName string) {
-	if dbName == "" {
-		return
-	}
-	dbStr := fmt.Sprintf("\"%s\"", dbName)
-
-	for i := range astBody.List {
-		if assignStmt, ok := astBody.List[i].(*ast.AssignStmt); ok {
-			if ident, ok := assignStmt.Lhs[0].(*ast.Ident); ok {
-				if ident.Name == varName {
-					return
-				}
-			}
-		}
-	}
-	assignNode := &ast.AssignStmt{
-		Lhs: []ast.Expr{
-			&ast.Ident{
-				Name: varName,
-			},
-		},
-		Tok: token.DEFINE,
-		Rhs: []ast.Expr{
-			&ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: "global",
-					},
-					Sel: &ast.Ident{
-						Name: "GetGlobalDBByDBName",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.BasicLit{
-						Kind:  token.STRING,
-						Value: dbStr,
-					},
-				},
-			},
-		},
-	}
-	astBody.List = append([]ast.Stmt{assignNode}, astBody.List...)
-}
-
-// 为db库变量增加 AutoMigrate 方法
-func addAutoMigrate(astBody *ast.BlockStmt, dbname string, pk string, model string) {
-	if dbname == "" {
-		dbname = "db"
-	}
-	flag := true
-	ast.Inspect(astBody, func(node ast.Node) bool {
-		// 首先判断需要加入的方法调用语句是否存在 不存在则直接走到下方逻辑
-		switch n := node.(type) {
-		case *ast.CallExpr:
-			// 判断是否找到了AutoMigrate语句
-			if s, ok := n.Fun.(*ast.SelectorExpr); ok {
-				if x, ok := s.X.(*ast.Ident); ok {
-					if s.Sel.Name == "AutoMigrate" && x.Name == dbname {
-						flag = false
-						if !NeedAppendModel(n, pk, model) {
-							return false
-						}
-						// 判断已经找到了AutoMigrate语句
-						n.Args = append(n.Args, &ast.CompositeLit{
-							Type: &ast.SelectorExpr{
-								X: &ast.Ident{
-									Name: pk,
-								},
-								Sel: &ast.Ident{
-									Name: model,
-								},
-							},
-						})
-						return false
-					}
-				}
-			}
-		}
-		return true
-		//然后判断 pk.model是否存在 如果存在直接跳出 如果不存在 则向已经找到的方法调用语句的node里面push一条
-	})
-
-	if flag {
-		exprStmt := &ast.ExprStmt{
-			X: &ast.CallExpr{
-				Fun: &ast.SelectorExpr{
-					X: &ast.Ident{
-						Name: dbname,
-					},
-					Sel: &ast.Ident{
-						Name: "AutoMigrate",
-					},
-				},
-				Args: []ast.Expr{
-					&ast.CompositeLit{
-						Type: &ast.SelectorExpr{
-							X: &ast.Ident{
-								Name: pk,
-							},
-							Sel: &ast.Ident{
-								Name: model,
-							},
-						},
-					},
-				},
-			}}
-		astBody.List = append(astBody.List, exprStmt)
-	}
-}
-
-// 为automigrate增加实参
-func NeedAppendModel(callNode ast.Node, pk string, model string) bool {
-	flag := true
-	ast.Inspect(callNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.SelectorExpr:
-			if x, ok := n.X.(*ast.Ident); ok {
-				if n.Sel.Name == model && x.Name == pk {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 0 - 173
pkg/utils/ast/ast_rollback.go

@@ -1,173 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"path/filepath"
-	"wails-app/internal/global"
-)
-
-func RollBackAst(pk, model string) {
-	RollGormBack(pk, model)
-	RollRouterBack(pk, model)
-}
-
-func RollGormBack(pk, model string) {
-
-	// 首先分析存在多少个ttt作为调用方的node块
-	// 如果多个 仅仅删除对应块即可
-	// 如果单个 那么还需要剔除import
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "gorm.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-	var n *ast.CallExpr
-	var k int = -1
-	var pkNum = 0
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if node, ok := node.(*ast.CallExpr); ok {
-			for i := range node.Args {
-				pkOK := false
-				modelOK := false
-				ast.Inspect(node.Args[i], func(item ast.Node) bool {
-					if ii, ok := item.(*ast.Ident); ok {
-						if ii.Name == pk {
-							pkOK = true
-							pkNum++
-						}
-						if ii.Name == model {
-							modelOK = true
-						}
-					}
-					if pkOK && modelOK {
-						n = node
-						k = i
-					}
-					return true
-				})
-			}
-		}
-		return true
-	})
-	if k > 0 {
-		n.Args = append(append([]ast.Expr{}, n.Args[:k]...), n.Args[k+1:]...)
-	}
-	if pkNum == 1 {
-		var imI int = -1
-		var gp *ast.GenDecl
-		ast.Inspect(astFile, func(node ast.Node) bool {
-			if gen, ok := node.(*ast.GenDecl); ok {
-				for i := range gen.Specs {
-					if imspec, ok := gen.Specs[i].(*ast.ImportSpec); ok {
-						if imspec.Path.Value == "\"wails-app/internal/model/"+pk+"\"" {
-							gp = gen
-							imI = i
-							return false
-						}
-					}
-				}
-			}
-			return true
-		})
-
-		if imI > -1 {
-			gp.Specs = append(append([]ast.Spec{}, gp.Specs[:imI]...), gp.Specs[imI+1:]...)
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-
-}
-
-func RollRouterBack(pk, model string) {
-
-	// 首先抓到所有的代码块结构 {}
-	// 分析结构中是否存在一个变量叫做 pk+Router
-	// 然后获取到代码块指针 对内部需要回滚的代码进行剔除
-	path := filepath.Join(global.GVA_CONFIG.AutoCode.Root, global.GVA_CONFIG.AutoCode.Server, "initialize", "router.go")
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, 0)
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	var block *ast.BlockStmt
-	var routerStmt *ast.FuncDecl
-
-	ast.Inspect(astFile, func(node ast.Node) bool {
-		if n, ok := node.(*ast.FuncDecl); ok {
-			if n.Name.Name == "Routers" {
-				routerStmt = n
-			}
-		}
-
-		if n, ok := node.(*ast.BlockStmt); ok {
-			ast.Inspect(n, func(bNode ast.Node) bool {
-				if in, ok := bNode.(*ast.Ident); ok {
-					if in.Name == pk+"Router" {
-						block = n
-						return false
-					}
-				}
-				return true
-			})
-			return true
-		}
-		return true
-	})
-	var k int
-	for i := range block.List {
-		if stmtNode, ok := block.List[i].(*ast.ExprStmt); ok {
-			ast.Inspect(stmtNode, func(node ast.Node) bool {
-				if n, ok := node.(*ast.Ident); ok {
-					if n.Name == "Init"+model+"Router" {
-						k = i
-						return false
-					}
-				}
-				return true
-			})
-		}
-	}
-
-	block.List = append(append([]ast.Stmt{}, block.List[:k]...), block.List[k+1:]...)
-
-	if len(block.List) == 1 {
-		// 说明这个块就没任何意义了
-		block.List = nil
-	}
-
-	for i, n := range routerStmt.Body.List {
-		if n, ok := n.(*ast.BlockStmt); ok {
-			if n.List == nil {
-				routerStmt.Body.List = append(append([]ast.Stmt{}, routerStmt.Body.List[:i]...), routerStmt.Body.List[i+1:]...)
-				i--
-			}
-		}
-	}
-
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.Remove(path)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}

+ 0 - 135
pkg/utils/ast/ast_router.go

@@ -1,135 +0,0 @@
-package ast
-
-import (
-	"bytes"
-	"fmt"
-	"go/ast"
-	"go/parser"
-	"go/printer"
-	"go/token"
-	"os"
-	"strings"
-)
-
-func AppendNodeToList(stmts []ast.Stmt, stmt ast.Stmt, index int) []ast.Stmt {
-	return append(stmts[:index], append([]ast.Stmt{stmt}, stmts[index:]...)...)
-}
-
-func AddRouterCode(path, funcName, pk, model string) {
-	src, err := os.ReadFile(path)
-	if err != nil {
-		fmt.Println(err)
-	}
-	fileSet := token.NewFileSet()
-	astFile, err := parser.ParseFile(fileSet, "", src, parser.ParseComments)
-
-	if err != nil {
-		fmt.Println(err)
-	}
-
-	FuncNode := FindFunction(astFile, funcName)
-
-	pkName := strings.ToUpper(pk[:1]) + pk[1:]
-	routerName := fmt.Sprintf("%sRouter", pk)
-	modelName := fmt.Sprintf("Init%sRouter", model)
-	var bloctPre *ast.BlockStmt
-	for i := len(FuncNode.Body.List) - 1; i >= 0; i-- {
-		if block, ok := FuncNode.Body.List[i].(*ast.BlockStmt); ok {
-			bloctPre = block
-		}
-	}
-	ast.Print(fileSet, FuncNode)
-	if ok, b := needAppendRouter(FuncNode, pk); ok {
-		routerNode :=
-			&ast.BlockStmt{
-				List: []ast.Stmt{
-					&ast.AssignStmt{
-						Lhs: []ast.Expr{
-							&ast.Ident{Name: routerName},
-						},
-						Tok: token.DEFINE,
-						Rhs: []ast.Expr{
-							&ast.SelectorExpr{
-								X: &ast.SelectorExpr{
-									X:   &ast.Ident{Name: "router"},
-									Sel: &ast.Ident{Name: "RouterGroupApp"},
-								},
-								Sel: &ast.Ident{Name: pkName},
-							},
-						},
-					},
-				},
-			}
-
-		FuncNode.Body.List = AppendNodeToList(FuncNode.Body.List, routerNode, len(FuncNode.Body.List)-2)
-		bloctPre = routerNode
-	} else {
-		bloctPre = b
-	}
-
-	if needAppendInit(FuncNode, routerName, modelName) {
-		bloctPre.List = append(bloctPre.List,
-			&ast.ExprStmt{
-				X: &ast.CallExpr{
-					Fun: &ast.SelectorExpr{
-						X:   &ast.Ident{Name: routerName},
-						Sel: &ast.Ident{Name: modelName},
-					},
-					Args: []ast.Expr{
-						&ast.Ident{
-							Name: "PrivateGroup",
-						},
-						&ast.Ident{
-							Name: "PublicGroup",
-						},
-					},
-				},
-			})
-	}
-	var out []byte
-	bf := bytes.NewBuffer(out)
-	printer.Fprint(bf, fileSet, astFile)
-	os.WriteFile(path, bf.Bytes(), 0666)
-}
-
-func needAppendRouter(funcNode ast.Node, pk string) (bool, *ast.BlockStmt) {
-	flag := true
-	var block *ast.BlockStmt
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := node.(type) {
-		case *ast.BlockStmt:
-			for i := range n.List {
-				if assignNode, ok := n.List[i].(*ast.AssignStmt); ok {
-					if identNode, ok := assignNode.Lhs[0].(*ast.Ident); ok {
-						if identNode.Name == fmt.Sprintf("%sRouter", pk) {
-							flag = false
-							block = n
-							return false
-						}
-					}
-				}
-			}
-
-		}
-		return true
-	})
-	return flag, block
-}
-
-func needAppendInit(funcNode ast.Node, routerName string, modelName string) bool {
-	flag := true
-	ast.Inspect(funcNode, func(node ast.Node) bool {
-		switch n := funcNode.(type) {
-		case *ast.CallExpr:
-			if selectNode, ok := n.Fun.(*ast.SelectorExpr); ok {
-				x, xok := selectNode.X.(*ast.Ident)
-				if xok && x.Name == routerName && selectNode.Sel.Name == modelName {
-					flag = false
-					return false
-				}
-			}
-		}
-		return true
-	})
-	return flag
-}

+ 164 - 56
test/testutil/e2e_edge_cases_test.go

@@ -27,7 +27,10 @@ func TestVehicleReEntry(t *testing.T) {
 		"plate_number": "JingR00001", "rfid_tag": "RFID_REENTRY",
 		"parking_lot_id": 1, "parking_space_id": 2, "entry_image": "test2.jpg",
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(entryBody2, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Vehicle re-entry was NOT blocked! code=%d msg=%s", result.Code, result.Msg)
@@ -41,7 +44,10 @@ func TestExitWithoutEntry(t *testing.T) {
 	_, exitPreviewBody := env.DoPost("/vehicle/exit/preview", map[string]interface{}{
 		"plate_number": "JingZ99999", "rfid_tag": "RFID_NOENTRY",
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(exitPreviewBody, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Exit preview succeeded for non-entered vehicle!")
@@ -63,7 +69,10 @@ func TestPaymentInsufficientAmount(t *testing.T) {
 		"plate_number": "JingP00001", "rfid_tag": "RFID_PAY",
 		"payment_method": "cash", "paid_amount": 0.0,
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(exitConfirmBody, &result)
 	fmt.Printf("INFO: Insufficient payment response: code=%d msg=%s\n", result.Code, result.Msg)
 }
@@ -74,7 +83,7 @@ func TestVIPExpired(t *testing.T) {
 	ownerResp, ownerBody := env.DoPost("/owner/create", map[string]interface{}{
 		"owner_name": "VIP Expired Test", "owner_surname": "VIP",
 		"owner_phone": "13900000001", "is_vip": true,
-		"vip_expire_time": time.Now().Add(-24*time.Hour).Format(time.RFC3339),
+		"vip_expire_time": time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
 	})
 	AssertResponse(t, ownerResp, ownerBody, http.StatusOK, response.SUCCESS)
 
@@ -119,7 +128,9 @@ func TestFreeDuration(t *testing.T) {
 		"plate_number": "JingF00001", "rfid_tag": "RFID_FREE",
 	})
 	AssertResponse(t, exitResp, exitBody, http.StatusOK, response.SUCCESS)
-	var preview struct { Fee float64 `json:"fee"` }
+	var preview struct {
+		Fee float64 `json:"fee"`
+	}
 	json.Unmarshal(exitBody, &preview)
 	if preview.Fee != 0 {
 		t.Fatalf("BUG: Vehicle within free duration should have zero fee, got %.2f", preview.Fee)
@@ -141,7 +152,9 @@ func TestDailyCap(t *testing.T) {
 		"plate_number": "JingD00001", "rfid_tag": "RFID_CAP",
 	})
 	AssertResponse(t, exitResp, exitBody, http.StatusOK, response.SUCCESS)
-	var preview struct { Fee float64 `json:"fee"` }
+	var preview struct {
+		Fee float64 `json:"fee"`
+	}
 	json.Unmarshal(exitBody, &preview)
 	if preview.Fee > 50.0 {
 		t.Fatalf("BUG: Fee exceeds daily cap! Expected <= 50, got %.2f", preview.Fee)
@@ -197,7 +210,9 @@ func TestShortlistByPlateOrRFID(t *testing.T) {
 	})
 	AssertResponse(t, resp1, body1, http.StatusOK, response.SUCCESS)
 	list1 := ParsePageList(body1)
-	if len(list1) < 1 { t.Fatalf("Expected at least 1 shortlist record by plate number") }
+	if len(list1) < 1 {
+		t.Fatalf("Expected at least 1 shortlist record by plate number")
+	}
 	fmt.Printf("PASS: Shortlist query by plate number returned %d records\n", len(list1))
 
 	resp2, body2 := env.DoPost("/shortlist/queryShortlistList", map[string]interface{}{
@@ -205,7 +220,9 @@ func TestShortlistByPlateOrRFID(t *testing.T) {
 	})
 	AssertResponse(t, resp2, body2, http.StatusOK, response.SUCCESS)
 	list2 := ParsePageList(body2)
-	if len(list2) < 1 { t.Fatalf("Expected at least 1 shortlist record by RFID") }
+	if len(list2) < 1 {
+		t.Fatalf("Expected at least 1 shortlist record by RFID")
+	}
 	fmt.Printf("PASS: Shortlist query by RFID returned %d records\n", len(list2))
 }
 
@@ -220,14 +237,21 @@ func TestShortlistUpdate(t *testing.T) {
 	})
 	queryResp, queryBody := env.DoGet("/shortlist/queryAllShortlists", nil)
 	AssertResponse(t, queryResp, queryBody, http.StatusOK, response.SUCCESS)
-	var result struct { Data []map[string]interface{} `json:"data"` }
+	var result struct {
+		Data []map[string]interface{} `json:"data"`
+	}
 	json.Unmarshal(queryBody, &result)
-	if len(result.Data) == 0 { t.Fatalf("No shortlist found") }
-	idVal, ok := result.Data[0]["ID"]; if !ok { idVal = result.Data[0]["id"] }
+	if len(result.Data) == 0 {
+		t.Fatalf("No shortlist found")
+	}
+	idVal, ok := result.Data[0]["ID"]
+	if !ok {
+		idVal = result.Data[0]["id"]
+	}
 	id := int(idVal.(float64))
 	updateResp, updateBody := env.DoPut("/shortlist/updateShortlist", map[string]interface{}{
 		"ID": id, "list_type": "黑名单", "vehicle_id": vehicle.ID,
-		"expiration_time": time.Now().Add(30*24*time.Hour).Format(time.RFC3339),
+		"expiration_time": time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339),
 	})
 	AssertResponse(t, updateResp, updateBody, http.StatusOK, response.SUCCESS)
 	updatedResp, updatedBody := env.DoGet("/shortlist/queryAllShortlists", nil)
@@ -246,10 +270,17 @@ func TestShortlistDelete(t *testing.T) {
 	})
 	queryResp, queryBody := env.DoGet("/shortlist/queryAllShortlists", nil)
 	AssertResponse(t, queryResp, queryBody, http.StatusOK, response.SUCCESS)
-	var result struct { Data []map[string]interface{} `json:"data"` }
+	var result struct {
+		Data []map[string]interface{} `json:"data"`
+	}
 	json.Unmarshal(queryBody, &result)
-	if len(result.Data) == 0 { t.Fatalf("No shortlist found") }
-	idVal, ok := result.Data[0]["ID"]; if !ok { idVal = result.Data[0]["id"] }
+	if len(result.Data) == 0 {
+		t.Fatalf("No shortlist found")
+	}
+	idVal, ok := result.Data[0]["ID"]
+	if !ok {
+		idVal = result.Data[0]["id"]
+	}
 	id := int(idVal.(float64))
 	deleteResp, deleteBody := env.DoDelete(fmt.Sprintf("/shortlist/deleteShortlist?id=%d", id))
 	AssertResponse(t, deleteResp, deleteBody, http.StatusOK, response.SUCCESS)
@@ -273,10 +304,17 @@ func TestOwnerVehicleRelationship(t *testing.T) {
 	found := false
 	for _, item := range list {
 		m, ok := item.(map[string]interface{})
-		if !ok { continue }
-		if m["owner_phone"] == "13911111111" { found = true; break }
+		if !ok {
+			continue
+		}
+		if m["owner_phone"] == "13911111111" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("Owner not found in list")
 	}
-	if !found { t.Fatalf("Owner not found in list") }
 	fmt.Println("PASS: Owner-vehicle relationship verified")
 }
 
@@ -293,7 +331,10 @@ func TestParkingLotCapacity(t *testing.T) {
 		"plate_number": "JingC00001", "rfid_tag": "RFID_FULL",
 		"parking_lot_id": 1, "parking_space_id": 1, "entry_image": "test.jpg",
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(entryBody, &result)
 	fmt.Printf("INFO: Entry into zero-capacity lot: code=%d msg=%s\n", result.Code, result.Msg)
 }
@@ -309,7 +350,9 @@ func TestBoothCRUD(t *testing.T) {
 	listResp, listBody := env.DoGet("/parking/booth/list", map[string]string{"page": "1", "page_size": "10"})
 	AssertResponse(t, listResp, listBody, http.StatusOK, response.SUCCESS)
 	list := ParsePageList(listBody)
-	if len(list) < 1 { t.Fatalf("Booth list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Booth list is empty")
+	}
 	fmt.Printf("PASS: Booth CRUD successful, total %d booths\n", len(list))
 }
 
@@ -325,7 +368,9 @@ func TestChannelCRUD(t *testing.T) {
 	listResp, listBody := env.DoGet("/parking/channel/list", map[string]string{"page": "1", "page_size": "10"})
 	AssertResponse(t, listResp, listBody, http.StatusOK, response.SUCCESS)
 	list := ParsePageList(listBody)
-	if len(list) < 1 { t.Fatalf("Channel list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Channel list is empty")
+	}
 	fmt.Printf("PASS: Channel CRUD successful, total %d channels\n", len(list))
 }
 
@@ -342,7 +387,9 @@ func TestDeviceCRUD(t *testing.T) {
 	listResp, listBody := env.DoGet("/parking/device/list", map[string]string{"page": "1", "page_size": "10"})
 	AssertResponse(t, listResp, listBody, http.StatusOK, response.SUCCESS)
 	list := ParsePageList(listBody)
-	if len(list) < 1 { t.Fatalf("Device list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Device list is empty")
+	}
 	fmt.Printf("PASS: Device CRUD successful, total %d devices\n", len(list))
 }
 
@@ -356,13 +403,14 @@ func TestVehicleTypeCRUD(t *testing.T) {
 	listResp, listBody := env.DoGet("/vehicle/type/all", nil)
 	AssertResponse(t, listResp, listBody, http.StatusOK, response.SUCCESS)
 	list := ParseDataList(listBody)
-	if len(list) < 1 { t.Fatalf("Vehicle type list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Vehicle type list is empty")
+	}
 	fmt.Printf("PASS: Vehicle type CRUD successful, total %d types\n", len(list))
 }
 
 // ===================== Helper functions =====================
 
-
 // TestVehicleUpdate 车辆信息更新
 func TestVehicleUpdate(t *testing.T) {
 	env := InitTestEnv(t)
@@ -396,7 +444,9 @@ func TestVehicleList(t *testing.T) {
 	resp, body := env.DoGet("/vehicle/list", map[string]string{"page": "1", "page_size": "10"})
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 	list := ParsePageList(body)
-	if len(list) < 1 { t.Fatalf("Vehicle list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Vehicle list is empty")
+	}
 	fmt.Printf("PASS: Vehicle list returned %d records\n", len(list))
 }
 
@@ -461,7 +511,6 @@ func TestParkingLotUpdate(t *testing.T) {
 	fmt.Println("PASS: Parking lot update successful")
 }
 
-
 // ===================== 月租车管理模块测试 =====================
 
 // TestMonthlyCardCreate 办理月卡
@@ -478,13 +527,19 @@ func TestMonthlyCardCreate(t *testing.T) {
 
 	var card dao.MonthlyCard
 	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
-	if card.ID == 0 { t.Fatalf("Monthly card not created in DB") }
-	if card.PaymentStatus != "paid" { t.Fatalf("Expected paid, got %s", card.PaymentStatus) }
+	if card.ID == 0 {
+		t.Fatalf("Monthly card not created in DB")
+	}
+	if card.PaymentStatus != "paid" {
+		t.Fatalf("Expected paid, got %s", card.PaymentStatus)
+	}
 
 	// Verify shortlist (whitelist) was created
 	var sl dao.Shortlist
 	env.DB.Where("vehicle_id = ? AND list_type = ?", v.ID, "白名单").First(&sl)
-	if sl.ID == 0 { t.Fatalf("Shortlist not created") }
+	if sl.ID == 0 {
+		t.Fatalf("Shortlist not created")
+	}
 
 	fmt.Println("PASS: Monthly card created successfully")
 }
@@ -531,11 +586,15 @@ func TestMonthlyCardRefund(t *testing.T) {
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 
 	env.DB.First(&card, card.ID)
-	if card.PaymentStatus != "refunded" { t.Fatalf("Expected refunded, got %s", card.PaymentStatus) }
+	if card.PaymentStatus != "refunded" {
+		t.Fatalf("Expected refunded, got %s", card.PaymentStatus)
+	}
 
 	var count int64
 	env.DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", v.ID, "白名单").Count(&count)
-	if count != 0 { t.Fatalf("Shortlist should be removed after refund, found %d", count) }
+	if count != 0 {
+		t.Fatalf("Shortlist should be removed after refund, found %d", count)
+	}
 
 	fmt.Println("PASS: Monthly card refunded successfully")
 }
@@ -553,14 +612,18 @@ func TestMonthlyCardList(t *testing.T) {
 	resp, body := env.DoGet("/monthly-card/list", map[string]string{"page": "1", "page_size": "10"})
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 	list := ParsePageList(body)
-	if len(list) < 1 { t.Fatalf("Monthly card list is empty") }
+	if len(list) < 1 {
+		t.Fatalf("Monthly card list is empty")
+	}
 
 	resp, body2 := env.DoGet("/monthly-card/list", map[string]string{
 		"page": "1", "page_size": "10", "card_type": "quarter",
 	})
 	AssertResponse(t, resp, body2, http.StatusOK, response.SUCCESS)
 	list2 := ParsePageList(body2)
-	if len(list2) < 1 { t.Fatalf("Filtered list by card_type is empty") }
+	if len(list2) < 1 {
+		t.Fatalf("Filtered list by card_type is empty")
+	}
 
 	fmt.Printf("PASS: Monthly card list returned %d records\\n", len(list))
 }
@@ -579,7 +642,10 @@ func TestMonthlyCardDuplicate(t *testing.T) {
 	_, body2 := env.DoPost("/monthly-card/create", map[string]interface{}{
 		"vehicle_id": v.ID, "card_type": "year", "fee": 3000.0,
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(body2, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Duplicate monthly card creation was NOT blocked!")
@@ -592,7 +658,10 @@ func TestMonthlyCardNonexistentRefund(t *testing.T) {
 	env := InitTestEnv(t)
 	_, body := env.DoDelete("/monthly-card/refund?id=99999")
 	// Should fail with error code
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(body, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Refunding non-existent card was NOT blocked!")
@@ -622,7 +691,6 @@ func TestMonthlyCardDifferentTypes(t *testing.T) {
 	fmt.Printf("PASS: Year card duration = %.0f days\\n", duration)
 }
 
-
 // ===================== 数字票模块测试 =====================
 
 func createTicket(t *testing.T, env *TestEnv, plate, trigger string) (string, uint) {
@@ -663,18 +731,34 @@ func TestDigitalTicketDetail(t *testing.T) {
 // payment entry is explicit because entry and method are independent settings.
 func TestDigitalTicketPay(t *testing.T) {
 	env := InitTestEnv(t)
-	ticketNo, _ := createTicket(t, env, "DT002", "plate_recognition")
+	ticketNo, ticketID := createTicket(t, env, "DT002", "plate_recognition")
 	resp, body := env.DoPost("/digital-ticket/"+ticketNo+"/pay", map[string]interface{}{
 		"payment_entry": "ticket", "payment_method": "cash", "payment_order_no": "PO002", "paid_amount": 20.0,
 	})
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	var ticket dao.DigitalTicket
+	if err := env.DB.First(&ticket, ticketID).Error; err != nil {
+		t.Fatalf("Failed to reload paid ticket: %v", err)
+	}
+	var record dao.VehicleRecord
+	if err := env.DB.First(&record, ticket.VehicleRecordID).Error; err != nil {
+		t.Fatalf("Failed to reload paid vehicle record: %v", err)
+	}
+	if record.PaymentStatus != "paid" {
+		t.Fatalf("Expected vehicle record payment status=paid, got %s", record.PaymentStatus)
+	}
+	var paymentCount int64
+	env.DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount)
+	if paymentCount != 1 {
+		t.Fatalf("Expected one unified payment record, got %d", paymentCount)
+	}
 	fmt.Println("PASS: Pay API works correctly")
 }
 
 // TestDigitalTicketExit DT03: 离场验证
 func TestDigitalTicketExit(t *testing.T) {
 	env := InitTestEnv(t)
-	ticketNo, _ := createTicket(t, env, "DT003", "rfid")
+	ticketNo, ticketID := createTicket(t, env, "DT003", "rfid")
 	// 支付
 	_, _ = env.DoPost("/digital-ticket/"+ticketNo+"/pay", map[string]interface{}{
 		"payment_entry": "ticket", "payment_method": "cash", "payment_order_no": "PO003", "paid_amount": 15.0,
@@ -682,6 +766,22 @@ func TestDigitalTicketExit(t *testing.T) {
 	// 支付成功后离场
 	resp, body := env.DoPost("/digital-ticket/"+ticketNo+"/exit", nil)
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	var ticket dao.DigitalTicket
+	if err := env.DB.First(&ticket, ticketID).Error; err != nil {
+		t.Fatalf("Failed to reload exited ticket: %v", err)
+	}
+	var record dao.VehicleRecord
+	if err := env.DB.First(&record, ticket.VehicleRecordID).Error; err != nil {
+		t.Fatalf("Failed to reload exited vehicle record: %v", err)
+	}
+	if record.ExitTime == nil {
+		t.Fatal("Digital ticket exit must update the vehicle record exit time")
+	}
+	var paymentCount int64
+	env.DB.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount)
+	if paymentCount != 1 {
+		t.Fatalf("Digital ticket exit must not duplicate payment records, got %d", paymentCount)
+	}
 	fmt.Println("PASS: Pay → Exit flow works correctly")
 }
 
@@ -725,7 +825,10 @@ func TestDigitalTicketInvalidExit(t *testing.T) {
 	env := InitTestEnv(t)
 	ticketNo, _ := createTicket(t, env, "DT006", "manual")
 	_, body := env.DoPost("/digital-ticket/"+ticketNo+"/exit", nil)
-	var result struct{ Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(body, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Exit without payment was NOT blocked!")
@@ -737,7 +840,10 @@ func TestDigitalTicketInvalidExit(t *testing.T) {
 func TestDigitalTicketNonexistent(t *testing.T) {
 	env := InitTestEnv(t)
 	_, body := env.DoGet("/digital-ticket/FAKE", nil)
-	var result struct{ Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(body, &result)
 	if result.Code == response.SUCCESS {
 		t.Fatalf("BUG: Non-existent ticket returned success!")
@@ -780,7 +886,10 @@ func createVehicleWithOwner(t *testing.T, env *TestEnv, plateNumber, rfidTag str
 		"vehicle_brand": "TestBrand", "vehicle_color": "TestColor",
 		"rfid_tag": rfidTag, "owner_id": ownerID,
 	})
-	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
 	json.Unmarshal(body, &result)
 	if result.Code != response.SUCCESS {
 		t.Fatalf("Failed to create vehicle with owner: %s", result.Msg)
@@ -929,9 +1038,9 @@ func TestShiftCurrent(t *testing.T) {
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 
 	var result struct {
-		Code int                        `json:"code"`
-		Data map[string]interface{}     `json:"data"`
-		Msg  string                     `json:"msg"`
+		Code int                    `json:"code"`
+		Data map[string]interface{} `json:"data"`
+		Msg  string                 `json:"msg"`
 	}
 	json.Unmarshal(body, &result)
 
@@ -1014,9 +1123,9 @@ func TestRevenueReportBasic(t *testing.T) {
 	}
 
 	var result struct {
-		Code int              `json:"code"`
-		Data json.RawMessage  `json:"data"`
-		Msg  string           `json:"msg"`
+		Code int             `json:"code"`
+		Data json.RawMessage `json:"data"`
+		Msg  string          `json:"msg"`
 	}
 	json.Unmarshal(body, &result)
 
@@ -1025,8 +1134,8 @@ func TestRevenueReportBasic(t *testing.T) {
 		t.Fatalf("Revenue report failed: code=%d msg=%s", result.Code, result.Msg)
 	}
 	if result.Data == nil || string(result.Data) == "null" {
-		t.Fatalf("BUG: Revenue report with no date params returns null data! "+
-			"Cause: SQL condition 'paid_at >= \"\" AND paid_at <= \" 23:59:59\"' filters out all records. "+
+		t.Fatalf("BUG: Revenue report with no date params returns null data! " +
+			"Cause: SQL condition 'paid_at >= \"\" AND paid_at <= \" 23:59:59\"' filters out all records. " +
 			"Fix: when start/end_date are empty, omit the WHERE clause or default to today.")
 	}
 	fmt.Printf("PASS: Revenue report basic query successful, data=%s\n", string(result.Data))
@@ -1046,9 +1155,9 @@ func TestRevenueReportDateRange(t *testing.T) {
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 
 	var result struct {
-		Code int              `json:"code"`
-		Data json.RawMessage  `json:"data"`
-		Msg  string           `json:"msg"`
+		Code int             `json:"code"`
+		Data json.RawMessage `json:"data"`
+		Msg  string          `json:"msg"`
 	}
 	json.Unmarshal(body, &result)
 	if len(result.Data) == 0 || string(result.Data) == "null" {
@@ -1072,9 +1181,9 @@ func TestRevenueReportWithLotFilter(t *testing.T) {
 	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
 
 	var result struct {
-		Code int              `json:"code"`
-		Data json.RawMessage  `json:"data"`
-		Msg  string           `json:"msg"`
+		Code int             `json:"code"`
+		Data json.RawMessage `json:"data"`
+		Msg  string          `json:"msg"`
 	}
 	json.Unmarshal(body, &result)
 	if len(result.Data) == 0 || string(result.Data) == "null" {
@@ -1082,4 +1191,3 @@ func TestRevenueReportWithLotFilter(t *testing.T) {
 	}
 	fmt.Printf("PASS: Revenue report with lot filter successful, data=%s\n", string(result.Data))
 }
-

+ 1 - 1
test/uhf_reader_test.go

@@ -5,7 +5,7 @@ import (
 	"encoding/binary"
 	"encoding/hex"
 	"fmt"
-	"server/service/uhf"
+	"internal/service/uhf"
 	"sync"
 	"testing"
 	"time"