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

feat: 新增支付入口/支付方式配置并完善USB小票打印

- 打印机DLL改FindCsnDllPath多路径探测,随exe同目录分发(去除硬编码)
- USB小票排版重构(usb_layout)+ 配套单元测试
- 新增支付入口/支付方式配置表及种子数据,支付记录支持按入口筛选
- 数字票/支付/月卡模块补充单元测试
- 新增cmd/ticket-print调试工具

Co-Authored-By: Claude <noreply@anthropic.com>
lq 4 hete
szülő
commit
27ff34a929
37 módosított fájl, 3016 hozzáadás és 235 törlés
  1. BIN
      backend-only.exe
  2. 113 0
      cmd/ticket-print/main.go
  3. 165 0
      docs/superpowers/plans/2026-07-31-usb-ticket-photo-layout-plan.md
  4. 14 7
      docs/superpowers/specs/2026-07-31-usb-ticket-photo-layout-design.md
  5. 77 0
      frontend/src/api/payment.js
  6. 4 2
      frontend/src/lang/en.js
  7. 4 2
      frontend/src/lang/zh-CN.js
  8. 42 10
      frontend/src/view/parking/entryExit.vue
  9. 214 0
      frontend/src/view/parking/paymentEntry.vue
  10. 218 0
      frontend/src/view/parking/paymentMethod.vue
  11. 41 9
      frontend/src/view/report/paymentRecord.vue
  12. 1 0
      internal/dao/digital_ticket.go
  13. 52 0
      internal/dao/payment_method_config.go
  14. 1 0
      internal/dao/payment_record.go
  15. 2 1
      internal/dao/vehicle_record.go
  16. 3 0
      internal/initialize/gorm.go
  17. 250 0
      internal/initialize/payment_seed.go
  18. 52 0
      internal/initialize/payment_seed_test.go
  19. 11 4
      internal/initialize/seed.go
  20. 1 0
      internal/model/vehicle/request/vehicle.go
  21. 1 0
      internal/model/vehicle/response/vehicle.go
  22. 18 3
      internal/modules/digital-ticket/api.go
  23. 29 3
      internal/modules/digital-ticket/repository/repo.go
  24. 46 4
      internal/modules/digital-ticket/service/service.go
  25. 48 0
      internal/modules/digital-ticket/service/service_test.go
  26. 2 2
      internal/modules/monthly/service/service.go
  27. 224 0
      internal/modules/payment/api.go
  28. 232 15
      internal/modules/payment/repository/repo.go
  29. 375 10
      internal/modules/payment/service/service.go
  30. 97 0
      internal/modules/payment/service/service_test.go
  31. 40 17
      internal/modules/printer/api.go
  32. 87 21
      internal/modules/printer/service/csn_dll.go
  33. 24 0
      internal/modules/printer/service/csn_dll_test.go
  34. 49 65
      internal/modules/printer/service/service.go
  35. 128 0
      internal/modules/printer/service/usb_layout.go
  36. 245 0
      internal/modules/printer/service/usb_layout_test.go
  37. 106 60
      internal/service/vehicle/vehicle.go

BIN
backend-only.exe


+ 113 - 0
cmd/ticket-print/main.go

@@ -0,0 +1,113 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"syscall"
+	"unsafe"
+)
+
+var dll *syscall.LazyDLL
+
+func main() {
+	dllPath := resolveDLLPath()
+	dll = syscall.NewLazyDLL(dllPath)
+	if err := dll.Load(); err != nil {
+		fmt.Fprintf(os.Stderr, "load %s failed: %v\n", dllPath, err)
+		os.Exit(1)
+	}
+
+	usbNames := enumPortNames("Port_EnumUSB")
+	fmt.Printf("USB: %v\n", usbNames)
+
+	prnNames := enumPortNames("Port_EnumPRN")
+	fmt.Printf("PRN: %v\n", prnNames)
+
+	data := []byte{0x1B, 0x40, 0x1B, 0x7B, 0x00, 'T', 'E', 'S', 'T', '\n', '\n', '\n', 0x1B, 0x69}
+	if len(os.Args) >= 3 {
+		fileData, err := os.ReadFile(os.Args[2])
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "read %s failed: %v\n", os.Args[2], err)
+			os.Exit(1)
+		}
+		if !tryOpenPrint(os.Args[1], fileData) {
+			os.Exit(1)
+		}
+		return
+	}
+	if len(os.Args) == 2 {
+		if !tryOpenPrint(os.Args[1], data) {
+			os.Exit(1)
+		}
+		return
+	}
+
+	for _, name := range prnNames {
+		fmt.Printf("Trying PRN: %s\n", name)
+		tryPrint(name, data)
+	}
+	for _, name := range usbNames {
+		fmt.Printf("Trying USB: %s\n", name)
+		tryOpenPrint(name, data)
+	}
+}
+
+func resolveDLLPath() string {
+	if exe, err := os.Executable(); err == nil {
+		localPath := filepath.Join(filepath.Dir(exe), "CsnPrinterLibs.dll")
+		if _, err := os.Stat(localPath); err == nil {
+			return localPath
+		}
+	}
+	return "CsnPrinterLibs.dll"
+}
+
+func enumPortNames(procName string) []string {
+	var buf [256]byte
+	ret, _, _ := dll.NewProc(procName).Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
+	if ret == 0 {
+		return nil
+	}
+	var names []string
+	for start := 0; start < len(buf); {
+		end := start
+		for end < len(buf) && buf[end] != 0 {
+			end++
+		}
+		if end == start {
+			break
+		}
+		names = append(names, string(buf[start:end]))
+		start = end + 1
+	}
+	return names
+}
+
+func tryPrint(name string, data []byte) bool {
+	nameB := append([]byte(name), 0)
+	h, _, _ := dll.NewProc("Port_OpenPRNIO").Call(uintptr(unsafe.Pointer(&nameB[0])))
+	if h == 0 {
+		fmt.Printf("  OpenPRN failed\n")
+		return false
+	}
+	defer dll.NewProc("Port_ClosePort").Call(h)
+	dll.NewProc("Port_SetPort").Call(h)
+	w, _, _ := dll.NewProc("WriteData").Call(uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), 3000)
+	fmt.Printf("  WriteData: %d bytes\n", w)
+	return int(w) == len(data)
+}
+
+func tryOpenPrint(name string, data []byte) bool {
+	nameB := append([]byte(name), 0)
+	h, _, _ := dll.NewProc("Port_OpenUSBIO").Call(uintptr(unsafe.Pointer(&nameB[0])))
+	if h == 0 {
+		fmt.Printf("  OpenUSB(%s) failed\n", name)
+		return false
+	}
+	defer dll.NewProc("Port_ClosePort").Call(h)
+	dll.NewProc("Port_SetPort").Call(h)
+	w, _, _ := dll.NewProc("WriteData").Call(uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)), 3000)
+	fmt.Printf("  USB WriteData: %d bytes\n", w)
+	return int(w) == len(data)
+}

+ 165 - 0
docs/superpowers/plans/2026-07-31-usb-ticket-photo-layout-plan.md

@@ -0,0 +1,165 @@
+# USB Ticket Photo Layout Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make USB/CSN thermal-printer output match the approved reference-photo layout while leaving the serial path unchanged.
+
+**Architecture:** Add a pure USB layout builder that returns ordered write, feed, QR-code, and cut operations. Execute those operations through a small CSN device interface so layout ordering and short-write failures can be tested without printer hardware; `PrintTicket` retains database lookup and port lifecycle ownership.
+
+**Tech Stack:** Go 1.25, standard library testing, ESC/POS raw commands, existing `CsnPrinterLibs.dll` wrapper.
+
+## Global Constraints
+
+- Only the USB/CSN path changes; the serial path remains behaviorally unchanged.
+- The QR payload remains `digital_ticket.ticket_no` and its width is exactly `8`.
+- Title and vehicle use `GS ! 0x10` for double width and `GS ! 0x00` to reset.
+- Empty channel and vehicle values omit their complete lines.
+- No new dependency is added.
+
+---
+
+### Task 1: Build and test the USB photo layout
+
+**Files:**
+- Create: `internal/modules/printer/service/usb_layout.go`
+- Create: `internal/modules/printer/service/usb_layout_test.go`
+
+**Interfaces:**
+- Consumes: parking-lot name, channel, plate, formatted entry time, slip number, and ticket number.
+- Produces: `buildUSBTicketLayout(data usbTicketData) []usbPrintOp` and `executeUSBLayout(device usbPrintDevice, ops []usbPrintOp) error`.
+
+- [x] **Step 1: Write failing layout tests**
+
+Use this fixed input:
+
+```go
+data := usbTicketData{
+    lotName: "KTI AIRPORT", channelCode: "IN11", plateNo: "2BC1875",
+    entryTime: "30-07-2026 11:14:59", slipNo: "54600",
+    ticketNo: "0123456789abcdef0123456789abcdef",
+}
+```
+
+Assert this exact ordered text:
+
+```go
+[]string{
+    "Parking Ticket\n", "KTI AIRPORT\n", strings.Repeat("-", 42) + "\n", "\n",
+    "PARK AT YOUR OWN RISK\n", "KTI AIRPORT A-General Car\n",
+    "In-Time::30-07-2026 11:14:59\n", "InGate::IN11\n", "Slip No::54600\n",
+    "Veh No::2BC1875\n", "SCAN AND PAY WITH NEW\n", "THE CANADIA BANK APP\n",
+}
+```
+
+Also assert title and vehicle are bracketed by `GS ! 0x10` and `GS ! 0x00`, only one divider exists, and the QR operation uses the ticket number with width `8`. A second case with empty channel and plate must omit `InGate::` and `Veh No::`.
+
+- [x] **Step 2: Run tests and verify RED**
+
+Run:
+
+```powershell
+go test ./internal/modules/printer/service -run 'TestBuildUSBTicketLayout|TestExecuteUSBLayout' -count=1
+```
+
+Expected: compilation fails because the USB layout types and functions do not exist.
+
+- [x] **Step 3: Implement the operation model and exact layout**
+
+Create:
+
+```go
+type usbPrintOpKind uint8
+const ( usbOpWrite usbPrintOpKind = iota; usbOpFeed; usbOpQRCode; usbOpCut )
+
+type usbPrintOp struct {
+    kind usbPrintOpKind
+    data []byte
+    count int
+    qrData string
+    qrWidth int
+}
+
+type usbTicketData struct {
+    lotName, channelCode, plateNo string
+    entryTime, slipNo, ticketNo string
+}
+```
+
+Generate this sequence: initialize; centered Font A double-width bold title and reset; centered Font B lot subtitle; one 42-character divider; one blank line; centered risk text; left-aligned combined lot/type line; Font A time, optional gate, and slip; optional double-width bold vehicle and reset; centered one-line feed, QR width 8, one-line feed; Font B corrected payment lines; three-line feed; full cut.
+
+Use exact size and font commands:
+
+```go
+var (
+    usbSizeNormal  = []byte{GS, '!', 0x00}
+    usbDoubleWidth = []byte{GS, '!', 0x10}
+    usbFontA       = []byte{ESC, 'M', 0}
+    usbFontB       = []byte{ESC, 'M', 1}
+)
+```
+
+- [x] **Step 4: Implement and test the device executor**
+
+Define:
+
+```go
+type usbPrintDevice interface {
+    Write([]byte) int
+    FeedLines(int)
+    QRCode(string, int) bool
+    FullCut()
+}
+```
+
+Stop on any write count different from the requested length and return `fmt.Errorf("USB打印短写: 写入%d/%d字节", n, len(op.data))`; return `fmt.Errorf("USB二维码打印失败")` when `QRCode` returns false. The CSN adapter must call `Pos_EscQrcode`, because the vendor SDK limits `Pos_Qrcode` width to 1..6 while `Pos_EscQrcode` supports width 8. Use a fake device to prove later operations do not run after either failure.
+
+- [x] **Step 5: Run focused tests and verify GREEN**
+
+Run `go test ./internal/modules/printer/service -run 'TestBuildUSBTicketLayout|TestExecuteUSBLayout' -count=1`.
+
+Expected: PASS.
+
+### Task 2: Integrate the layout into `PrintTicket`
+
+**Files:**
+- Modify: `internal/modules/printer/service/service.go:53-109`
+- Test: `internal/modules/printer/service/usb_layout_test.go`
+
+**Interfaces:**
+- Consumes: the Task 1 builder and executor.
+- Produces: unchanged public `PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error` behavior with the new USB layout.
+
+- [x] **Step 1: Add the CSN adapter**
+
+```go
+type csnUSBDevice struct{}
+func (csnUSBDevice) Write(data []byte) int { return Csn_WriteData(data) }
+func (csnUSBDevice) FeedLines(n int) { csn_FeedLines(n) }
+func (csnUSBDevice) QRCode(data string, width int) bool { return csn_QRCode(data, width) }
+func (csnUSBDevice) FullCut() { csn_FullCut() }
+```
+
+- [x] **Step 2: Replace only the USB inline layout**
+
+Build `usbTicketData` from the existing dynamic values, execute it with `csnUSBDevice{}`, return an execution error before updating status, and retain the current `online` update after success. Do not modify the serial branch.
+
+- [x] **Step 3: Format and run package tests**
+
+Run:
+
+```powershell
+gofmt -w internal/modules/printer/service/usb_layout.go internal/modules/printer/service/usb_layout_test.go internal/modules/printer/service/service.go
+go test ./internal/modules/printer/service -count=1
+```
+
+Expected: PASS.
+
+- [x] **Step 4: Run repository-level verification**
+
+Run `go test ./internal/modules/printer/... -count=1`, then `go test ./... -count=1`.
+
+Expected: printer tests pass. Report any unrelated repository failure with its package and error output.
+
+- [x] **Step 5: Inspect the final diff**
+
+Run `git diff --check` and inspect the three printer-service files. Confirm only the USB path changed and all dynamic data sources remain intact.

+ 14 - 7
docs/superpowers/specs/2026-07-31-usb-ticket-photo-layout-design.md

@@ -23,7 +23,7 @@ InGate::{channelCode}            Font A、左对齐,可选
 Slip No::{ticketID}              Font A、左对齐
 Veh No::{plateNo}                Font A、双宽、加粗、可选
 
-             [二维码]            居中,宽度参数 8
+             [二维码]            居中,宽度参数 12
 
        SCAN AND PAY WITH NEW     Font B、居中
         THE CANADIA BANK APP     Font B、居中
@@ -32,6 +32,8 @@ Veh No::{plateNo}                Font A、双宽、加粗、可选
               [全切纸]
 ```
 
+现场打印确认 USB 模组按发送顺序排列票面内容。程序必须按上方从标题到支付提示的正常顺序发送字段,并保持字形方向正常;不能通过倒序发送模拟 180 度旋转。
+
 ## 字段映射
 
 | 票面字段 | 数据来源 | 规则 |
@@ -53,8 +55,10 @@ Veh No::{plateNo}                Font A、双宽、加粗、可选
 - 车牌使用 Font A、`GS ! 0x10` 双宽和加粗;打印后立即恢复正常尺寸和粗细。
 - 顶部分隔线使用 42 个短横线,以 Font B 打印,匹配 80mm 票纸有效宽度。
 - 移除当前 USB 票面中信息块之间的额外分隔线。
-- 二维码继续调用 CSN SDK 的 `Pos_Qrcode`,宽度参数由 4 调整为 8。
-- 二维码前后使用紧凑走纸;支付提示后保留切刀需要的尾部空间,再执行全切。
+- 二维码调用 CSN SDK 的 `Pos_EscQrcode`,宽度参数调整为 12,使二维码宽度接近参考票纸宽度的一半。
+- 现场测试确认 `ESC { 1` 只旋转字形,不反转整张票的纵向坐标;字段继续按票面从上至下发送。
+- 初始化后发送 `ESC { 0`,并在 `Pos_EscQrcode` 后再次发送 `ESC { 0`,防止 SDK 改变文字方向状态。
+- 支付提示和二维码之间使用紧凑走纸;支付提示发送后走纸 8 行,覆盖打印头到切刀的机械距离,再执行全切。
 
 ## 代码结构
 
@@ -69,18 +73,21 @@ Veh No::{plateNo}                Font A、双宽、加粗、可选
 
 - 本次不改变现有端口打开和 DLL 加载策略。
 - USB 原始字节写入返回 0 或短写时,执行器返回错误,不把打印机状态更新为 `online`。
-- 二维码和切纸 SDK 函数当前没有可靠返回值,保持现有调用方式。
+- 二维码 SDK 返回失败时立即返回打印错误,不更新打印机在线状态;走纸和切纸继续使用现有无返回值调用。
+- 串口打印路径不启用 USB 方向补偿,保持原有行为。
 
 ## 测试
 
 新增 USB 布局单元测试,至少验证:
 
-- 字段严格按照顺序出现。
+- 字段严格按照票面从上至下的正常顺序出现。
 - 标题和车牌前后包含正确的字号、粗体和复位指令。
 - 入口和车牌为空时对应行被省略。
 - 只生成一条 42 字符分隔线。
-- 二维码操作位于车牌之后、支付提示之前,宽度为 8。
-- 支付提示分为 `SCAN AND PAY WITH NEW` 和 `THE CANADIA BANK APP` 两行。
+- 二维码操作位于车牌之后、支付提示之前,宽度为 12。
+- 支付提示按 `SCAN AND PAY WITH NEW`、`THE CANADIA BANK APP` 的发送顺序生成。
+- 初始化后和二维码后均发送正常文字方向 `ESC { 0`,且不发送 `ESC { 1`。
+- 切纸前走纸 8 行,避免最后一段内容残留到下一张票。
 - 串口路径不受 USB 布局改动影响。
 
 ## 不在本次范围内

+ 77 - 0
frontend/src/api/payment.js

@@ -16,3 +16,80 @@ export const getTodaySummary = () => {
     method: 'get'
   })
 }
+
+export const getPaymentMethods = () => service({
+  url: '/payment/methods',
+  method: 'get'
+})
+
+export const getEnabledPaymentMethods = (entry) => service({
+  url: '/payment/methods/enabled',
+  method: 'get',
+  params: { entry }
+})
+
+export const getPaymentEntries = () => service({
+  url: '/payment/entries',
+  method: 'get'
+})
+
+export const getEnabledPaymentEntries = () => service({
+  url: '/payment/entries/enabled',
+  method: 'get'
+})
+
+export const getPaymentEntryOptions = () => service({
+  url: '/payment/entries/options',
+  method: 'get'
+})
+
+export const createPaymentEntry = (data) => service({
+  url: '/payment/entries',
+  method: 'post',
+  data
+})
+
+export const updatePaymentEntry = (id, data) => service({
+  url: `/payment/entries/${id}`,
+  method: 'put',
+  data
+})
+
+export const setPaymentEntryStatus = (id, enabled) => service({
+  url: `/payment/entries/${id}/status`,
+  method: 'patch',
+  data: { enabled }
+})
+
+export const deletePaymentEntry = (id) => service({
+  url: `/payment/entries/${id}`,
+  method: 'delete'
+})
+
+export const getPaymentMethodOptions = () => service({
+  url: '/payment/methods/options',
+  method: 'get'
+})
+
+export const createPaymentMethod = (data) => service({
+  url: '/payment/methods',
+  method: 'post',
+  data
+})
+
+export const updatePaymentMethod = (id, data) => service({
+  url: `/payment/methods/${id}`,
+  method: 'put',
+  data
+})
+
+export const setPaymentMethodStatus = (id, enabled) => service({
+  url: `/payment/methods/${id}/status`,
+  method: 'patch',
+  data: { enabled }
+})
+
+export const deletePaymentMethod = (id) => service({
+  url: `/payment/methods/${id}`,
+  method: 'delete'
+})

+ 4 - 2
frontend/src/lang/en.js

@@ -215,7 +215,7 @@ export default {
   rfidInput: 'Please enter RFID tag',
   exitRecordDetail: 'Entry: {entryTime} | Stay: {duration} | Fee: ¥{fee}',
   paymentMethod: 'Payment Method',
-  cashPayment: 'Cash',
+	cashPayment: 'Manual Payment',
   wechatPayment: 'QR Code',
   posPayment: 'POS Card',
   cashReceived: 'Received',
@@ -225,5 +225,7 @@ export default {
   paymentRecord: 'Payment Record',
   monthlyCard: 'Monthly Card',
   shiftRecord: 'Shift Record',
-  revenueReport: 'Revenue Report',
+	revenueReport: 'Revenue Report',
+	paymentMethodConfig: 'Payment Methods',
+	paymentEntryConfig: 'Payment Entries',
 }

+ 4 - 2
frontend/src/lang/zh-CN.js

@@ -215,7 +215,7 @@ export default {
   rfidInput: '请输入RFID标签',
   exitRecordDetail: '进场: {entryTime} | 停留: {duration} | 费用: ¥{fee}',
   paymentMethod: '支付方式',
-  cashPayment: '现金',
+	cashPayment: '人工支付',
   wechatPayment: '扫码支付',
   posPayment: 'POS刷卡',
   cashReceived: '实收金额',
@@ -225,5 +225,7 @@ export default {
   paymentRecord: '收费记录',
   monthlyCard: '包月车管理',
   shiftRecord: '交接班管理',
-  revenueReport: '收入报表',
+	revenueReport: '收入报表',
+	paymentMethodConfig: '支付方式',
+	paymentEntryConfig: '支付入口',
 }

+ 42 - 10
frontend/src/view/parking/entryExit.vue

@@ -60,13 +60,17 @@
         </el-descriptions>
 
         <div v-if="exitData.is_temp" class="payment-section">
+          <div class="payment-entry-context">
+            <span class="section-label">支付入口:</span>
+            <el-tag effect="plain">收费岗亭</el-tag>
+          </div>
           <p class="section-label">{{ $t.value.paymentMethod }}:</p>
-          <el-radio-group v-model="paymentMethod">
-            <el-radio-button value="cash">{{ $t.value.cashPayment }}</el-radio-button>
-            <el-radio-button value="wechat" disabled>{{ $t.value.wechatPayment }}</el-radio-button>
-            <el-radio-button value="pos" disabled>{{ $t.value.posPayment }}</el-radio-button>
+          <el-radio-group v-model="paymentMethod" @change="handlePaymentMethodChange">
+            <el-radio-button v-for="method in enabledPaymentMethods" :key="method.code" :label="method.code">
+              {{ method.name }}
+            </el-radio-button>
           </el-radio-group>
-          <div v-if="paymentMethod === 'cash'" class="cash-input">
+          <div v-if="selectedPaymentMethod?.input_mode === 'manual_amount'" class="cash-input">
             <span class="cash-label">{{ $t.value.cashReceived }}:</span>
             <el-input-number v-model="paidAmount" :min="0" :precision="1" style="width: 160px" />
             <span class="change-label">{{ $t.value.change }}: ¥{{ Math.max(0, paidAmount - exitData.fee).toFixed(1) }}</span>
@@ -99,10 +103,11 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { computed, ref, reactive, onMounted, onUnmounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { obtainVehicleLicense, vehicleEntry, exitPreview, exitConfirm, getChannelEvents } from '@/api/vehicle'
 import { obtainPullOverList } from '@/api/pullOver'
+import { getEnabledPaymentMethods } from '@/api/payment'
 import { t } from '@/lang/index.js'
 
 const activeTab = ref('entry')
@@ -115,13 +120,19 @@ const entryForm = reactive({ parking_lot_id: null })
 const parkingLotList = ref([])
 
 const exitData = ref(null)
+const paymentEntry = ref('counter')
 const paymentMethod = ref('cash')
 const paidAmount = ref(0)
+const enabledPaymentMethods = ref([
+  { code: 'cash', name: t.value.cashPayment, input_mode: 'manual_amount' }
+])
+const selectedPaymentMethod = computed(() => enabledPaymentMethods.value.find(item => item.code === paymentMethod.value))
 
 let eventTimer = null
 const lastEventTs = ref(0)
 
 onMounted(async () => {
+  await loadPaymentMethods()
   try {
     const res = await obtainPullOverList({ page: 1, pageSize: 100 })
     parkingLotList.value = res.data?.list || []
@@ -196,10 +207,30 @@ async function queryVehicle() {
 async function previewExitFee() {
   try {
     const res = await exitPreview({ plate_number: queryForm.plate_number.trim(), rfid_tag: queryForm.rfid_tag.trim() })
-    if (res.code === 0) { exitData.value = res.data; paidAmount.value = 0; paymentMethod.value = 'cash' }
+    if (res.code === 0) {
+      exitData.value = res.data
+      await loadPaymentMethods()
+      paidAmount.value = 0
+      paymentMethod.value = enabledPaymentMethods.value[0]?.code || 'cash'
+    }
   } catch (e) { console.error('Preview failed', e) }
 }
 
+async function loadPaymentMethods() {
+  try {
+    const res = await getEnabledPaymentMethods('counter')
+    if (res.code === 0 && Array.isArray(res.data) && res.data.length > 0) {
+      enabledPaymentMethods.value = res.data
+    }
+  } catch (e) {
+    console.error('Failed to load payment methods', e)
+  }
+}
+
+function handlePaymentMethodChange() {
+  paidAmount.value = selectedPaymentMethod.value?.input_mode === 'manual_amount' ? 0 : (exitData.value?.fee || 0)
+}
+
 async function confirmEntry() {
   if (!entryForm.parking_lot_id) { ElMessage.warning(t.value.pleaseSelectPark); return }
   if (submitting.value) return
@@ -212,14 +243,14 @@ async function confirmEntry() {
 }
 
 async function confirmExit() {
-  if (exitData.value?.is_temp && paymentMethod.value === 'cash') { if (paidAmount.value < exitData.value.fee) { ElMessage.warning(t.value.insufficientAmount); return } }
+  if (exitData.value?.is_temp && selectedPaymentMethod.value?.input_mode === 'manual_amount') { if (paidAmount.value < exitData.value.fee) { ElMessage.warning(t.value.insufficientAmount); return } }
   await ElMessageBox.confirm(t.value.confirmExitMsg, t.value.confirmExitTitle, { confirmButtonText: t.value.Confirm, cancelButtonText: t.value.Cancel, type: 'warning' })
   if (submitting.value) return
   submitting.value = true
   try {
     const pm = exitData.value?.is_temp ? paymentMethod.value : 'free'
-    const pa = exitData.value?.is_temp ? paidAmount.value : 0
-    const res = await exitConfirm({ plate_number: queryForm.plate_number.trim(), rfid_tag: queryForm.rfid_tag.trim(), payment_method: pm, paid_amount: pa })
+    const pa = exitData.value?.is_temp && selectedPaymentMethod.value?.input_mode === 'manual_amount' ? paidAmount.value : (exitData.value?.fee || 0)
+    const res = await exitConfirm({ plate_number: queryForm.plate_number.trim(), rfid_tag: queryForm.rfid_tag.trim(), payment_entry: paymentEntry.value, payment_method: pm, paid_amount: pa })
     if (res.code === 0) {
       const d = res.data
       const detail = t.value.exitRecordDetail.replace('{entryTime}', new Date(d.entry_time * 1000).toLocaleString('zh-CN')).replace('{duration}', formatDuration(d.stay_time)).replace('{fee}', d.fee.toFixed(2))
@@ -255,6 +286,7 @@ function formatDuration(minutes) {
 .exit-tip { color: #909399; margin-bottom: 12px; }
 .fee-amount { color: #e6a23c; font-size: 20px; font-weight: bold; }
 .payment-section { margin-top: 16px; }
+.payment-entry-context { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
 .section-label { font-weight: bold; margin-bottom: 8px; }
 .cash-input { margin-top: 12px; }
 .cash-label { margin-right: 8px; }

+ 214 - 0
frontend/src/view/parking/paymentEntry.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="payment-entry-page">
+    <div class="page-toolbar">
+      <div>
+        <h2>支付入口</h2>
+        <p>定义支付从哪里发起,并为每个入口配置可用的支付方式</p>
+      </div>
+      <el-button type="primary" icon="Plus" @click="openCreate">新增支付入口</el-button>
+    </div>
+
+    <el-table v-loading="loading" :data="entries" border>
+      <el-table-column label="支付入口" min-width="190">
+        <template #default="{ row }">
+          <div class="entry-name">
+            <span>{{ row.name }}</span>
+            <el-tooltip v-if="row.is_system" content="系统内置入口,只能调整配置,不能删除" placement="top">
+              <el-icon class="locked"><Lock /></el-icon>
+            </el-tooltip>
+          </div>
+          <span class="entry-code">{{ row.code }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="可用支付方式" min-width="280">
+        <template #default="{ row }">
+          <el-tag v-for="code in row.method_codes" :key="code" class="method-tag" effect="plain">
+            {{ methodLabels[code] || code }}
+          </el-tag>
+          <span v-if="!row.method_codes?.length" class="muted">未配置</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="排序" prop="sort" width="80" align="center" />
+      <el-table-column label="状态" width="110" align="center">
+        <template #default="{ row }">
+          <el-switch
+            v-model="row.enabled"
+            inline-prompt
+            active-text="启用"
+            inactive-text="停用"
+            @change="changeStatus(row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="说明" prop="description" min-width="240" show-overflow-tooltip />
+      <el-table-column label="操作" width="150" fixed="right" align="center">
+        <template #default="{ row }">
+          <el-button text type="primary" icon="Edit" @click="openEdit(row)">编辑</el-button>
+          <el-button text type="danger" icon="Delete" :disabled="row.is_system" @click="removeEntry(row)">删除</el-button>
+        </template>
+      </el-table-column>
+      <template #empty>
+        <el-empty description="暂无支付入口" />
+      </template>
+    </el-table>
+
+    <el-dialog v-model="dialogVisible" :title="editingID ? '编辑支付入口' : '新增支付入口'" width="620px" destroy-on-close>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+        <el-form-item label="名称" prop="name">
+          <el-input v-model="form.name" maxlength="50" placeholder="例如:收费岗亭、停车小票" />
+        </el-form-item>
+        <el-form-item label="入口编码" prop="code">
+          <el-input v-model="form.code" :disabled="Boolean(editingID)" maxlength="32" placeholder="例如:counter" />
+          <div class="form-hint">保存后编码不可修改,业务记录会使用该编码关联入口</div>
+        </el-form-item>
+        <el-form-item label="支付方式" prop="method_codes">
+          <el-checkbox-group v-model="form.method_codes">
+            <el-checkbox v-for="method in methods" :key="method.code" :label="method.code" :disabled="method.code === 'cash'">
+              {{ method.name }}
+            </el-checkbox>
+          </el-checkbox-group>
+          <div class="form-hint">人工支付是基础方式,始终保留在入口中;其它方式可按入口单独启停。</div>
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" />
+        </el-form-item>
+        <el-form-item label="说明">
+          <el-input v-model="form.description" type="textarea" :rows="3" maxlength="200" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveEntry">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  createPaymentEntry,
+  deletePaymentEntry,
+  getPaymentEntries,
+  getPaymentMethods,
+  setPaymentEntryStatus,
+  updatePaymentEntry
+} from '@/api/payment'
+
+const codePattern = /^[a-z][a-z0-9_]{1,31}$/
+const entries = ref([])
+const methods = ref([])
+const methodLabels = reactive({})
+const loading = ref(false)
+const saving = ref(false)
+const dialogVisible = ref(false)
+const editingID = ref(0)
+const formRef = ref()
+const form = reactive(defaultForm())
+
+const rules = {
+  name: [{ required: true, message: '请输入支付入口名称', trigger: 'blur' }],
+  code: [
+    { required: true, message: '请输入入口编码', trigger: 'blur' },
+    { pattern: codePattern, message: '以小写字母开头,只能包含小写字母、数字和下划线', trigger: 'blur' }
+  ],
+  method_codes: [{ type: 'array', required: true, min: 1, message: '请至少选择一种支付方式', trigger: 'change' }]
+}
+
+function defaultForm() {
+  return { code: '', name: '', method_codes: ['cash'], enabled: true, sort: 50, description: '' }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const [entryRes, methodRes] = await Promise.all([getPaymentEntries(), getPaymentMethods()])
+    if (entryRes.code === 0) entries.value = entryRes.data || []
+    if (methodRes.code === 0) {
+      methods.value = (methodRes.data || []).filter(item => !item.is_legacy)
+      Object.keys(methodLabels).forEach(key => delete methodLabels[key])
+      methods.value.forEach(item => { methodLabels[item.code] = item.name })
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate() {
+  editingID.value = 0
+  Object.assign(form, defaultForm())
+  dialogVisible.value = true
+}
+
+function openEdit(row) {
+  editingID.value = row.ID
+  Object.assign(form, {
+    code: row.code,
+    name: row.name,
+    method_codes: [...(row.method_codes || ['cash'])],
+    enabled: row.enabled,
+    sort: row.sort,
+    description: row.description || ''
+  })
+  if (!form.method_codes.includes('cash')) form.method_codes.unshift('cash')
+  dialogVisible.value = true
+}
+
+async function saveEntry() {
+  await formRef.value.validate()
+  saving.value = true
+  try {
+    const payload = { ...form, method_codes: [...new Set(['cash', ...form.method_codes])] }
+    const res = editingID.value
+      ? await updatePaymentEntry(editingID.value, payload)
+      : await createPaymentEntry(payload)
+    if (res.code === 0) {
+      ElMessage.success(editingID.value ? '支付入口已更新' : '支付入口已新增')
+      dialogVisible.value = false
+      await loadData()
+    }
+  } finally {
+    saving.value = false
+  }
+}
+
+async function changeStatus(row) {
+  const target = row.enabled
+  try {
+    const res = await setPaymentEntryStatus(row.ID, target)
+    if (res.code === 0) ElMessage.success(target ? '支付入口已启用' : '支付入口已停用')
+    else row.enabled = !target
+  } catch (e) {
+    row.enabled = !target
+  }
+}
+
+async function removeEntry(row) {
+  await ElMessageBox.confirm(`确定删除“${row.name}”吗?历史支付记录不会被删除。`, '删除支付入口', {
+    type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消'
+  })
+  const res = await deletePaymentEntry(row.ID)
+  if (res.code === 0) {
+    ElMessage.success('支付入口已删除')
+    await loadData()
+  }
+}
+
+onMounted(loadData)
+</script>
+
+<style scoped>
+.page-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
+.page-toolbar h2 { margin: 0; font-size: 18px; font-weight: 600; color: #303133; }
+.page-toolbar p { margin: 5px 0 0; font-size: 13px; color: #909399; }
+.entry-name { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #303133; }
+.entry-code { display: block; margin-top: 3px; color: #909399; font-family: Consolas, monospace; font-size: 12px; }
+.locked { color: #e6a23c; }
+.method-tag { margin-right: 6px; }
+.muted { color: #909399; font-size: 12px; }
+.form-hint { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.5; }
+</style>

+ 218 - 0
frontend/src/view/parking/paymentMethod.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="payment-method-page">
+    <div class="page-toolbar">
+      <div>
+        <h2>支付方式</h2>
+        <p>维护可用的收款方式;支付入口与方式的关联在“支付入口”中配置</p>
+      </div>
+      <el-button type="primary" icon="Plus" @click="openCreate">新增支付方式</el-button>
+    </div>
+
+    <el-table v-loading="loading" :data="methods" border>
+      <el-table-column label="支付方式" min-width="180">
+        <template #default="{ row }">
+          <div class="method-name">
+            <span>{{ row.name }}</span>
+            <el-tooltip v-if="row.code === 'cash'" content="基础支付能力,不能停用或删除" placement="top">
+              <el-icon class="locked"><Lock /></el-icon>
+            </el-tooltip>
+          </div>
+          <span class="method-code">{{ row.code }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="可用支付入口" min-width="220">
+        <template #default="{ row }">
+          <el-tag v-for="entry in row.entry_codes" :key="entry" class="entry-tag" effect="plain">
+            {{ entryLabels[entry] || entry }}
+          </el-tag>
+          <span v-if="!row.entry_codes?.length" class="muted">未配置入口</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="金额处理" min-width="150">
+        <template #default="{ row }">{{ modeLabels[row.input_mode] || row.input_mode }}</template>
+      </el-table-column>
+      <el-table-column label="排序" prop="sort" width="80" align="center" />
+      <el-table-column label="状态" width="110" align="center">
+        <template #default="{ row }">
+          <el-switch
+            v-model="row.enabled"
+            :disabled="row.code === 'cash'"
+            inline-prompt
+            active-text="启用"
+            inactive-text="停用"
+            @change="changeStatus(row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="说明" prop="description" min-width="240" show-overflow-tooltip />
+      <el-table-column label="操作" width="150" fixed="right" align="center">
+        <template #default="{ row }">
+          <el-button text type="primary" icon="Edit" @click="openEdit(row)">编辑</el-button>
+          <el-button text type="danger" icon="Delete" :disabled="row.is_system" @click="removeMethod(row)">删除</el-button>
+        </template>
+      </el-table-column>
+      <template #empty>
+        <el-empty description="暂无支付方式" />
+      </template>
+    </el-table>
+
+    <el-dialog v-model="dialogVisible" :title="editingID ? '编辑支付方式' : '新增支付方式'" width="560px" destroy-on-close>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="名称" prop="name">
+          <el-input v-model="form.name" maxlength="50" placeholder="例如:银行卡支付" />
+        </el-form-item>
+        <el-form-item label="支付编码" prop="code">
+          <el-input v-model="form.code" :disabled="Boolean(editingID)" maxlength="32" placeholder="例如:bank_card" />
+          <div class="form-hint">保存后编码不可修改,用于支付记录和后续接口对接</div>
+        </el-form-item>
+        <el-form-item label="金额处理" prop="input_mode">
+          <el-select v-model="form.input_mode" :disabled="form.code === 'cash'" style="width: 100%">
+            <el-option label="手工录入实收金额" value="manual_amount" />
+            <el-option label="确认支付结果" value="confirm" />
+            <el-option label="外部支付流程" value="external" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" :disabled="form.code === 'cash'" />
+        </el-form-item>
+        <el-form-item label="说明">
+          <el-input v-model="form.description" type="textarea" :rows="3" maxlength="200" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveMethod">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  createPaymentMethod,
+  deletePaymentMethod,
+  getPaymentEntries,
+  getPaymentMethods,
+  setPaymentMethodStatus,
+  updatePaymentMethod
+} from '@/api/payment'
+
+const entryLabels = reactive({})
+const modeLabels = { manual_amount: '手工录入金额', confirm: '确认支付', external: '外部支付' }
+const codePattern = /^[a-z][a-z0-9_]{1,31}$/
+
+const methods = ref([])
+const loading = ref(false)
+const saving = ref(false)
+const dialogVisible = ref(false)
+const editingID = ref(0)
+const formRef = ref()
+const form = reactive(defaultForm())
+
+const rules = {
+  name: [{ required: true, message: '请输入支付方式名称', trigger: 'blur' }],
+  code: [
+    { required: true, message: '请输入支付编码', trigger: 'blur' },
+    { pattern: codePattern, message: '以小写字母开头,只能包含小写字母、数字和下划线', trigger: 'blur' }
+  ],
+  input_mode: [{ required: true, message: '请选择金额处理方式', trigger: 'change' }]
+}
+
+function defaultForm() {
+  return { code: '', name: '', input_mode: 'confirm', enabled: false, sort: 50, description: '' }
+}
+
+async function loadMethods() {
+  loading.value = true
+  try {
+    const [methodRes, entryRes] = await Promise.all([getPaymentMethods(), getPaymentEntries()])
+    if (methodRes.code === 0) methods.value = methodRes.data || []
+    if (entryRes.code === 0) {
+      Object.keys(entryLabels).forEach(code => delete entryLabels[code])
+      for (const entry of entryRes.data || []) {
+        entryLabels[entry.code] = entry.name
+      }
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate() {
+  editingID.value = 0
+  Object.assign(form, defaultForm())
+  dialogVisible.value = true
+}
+
+function openEdit(row) {
+  editingID.value = row.ID
+  Object.assign(form, {
+    code: row.code,
+    name: row.name,
+    input_mode: row.input_mode,
+    enabled: row.enabled,
+    sort: row.sort,
+    description: row.description || ''
+  })
+  dialogVisible.value = true
+}
+
+async function saveMethod() {
+  await formRef.value.validate()
+  saving.value = true
+  try {
+    const payload = { ...form }
+    const res = editingID.value
+      ? await updatePaymentMethod(editingID.value, payload)
+      : await createPaymentMethod(payload)
+    if (res.code === 0) {
+      ElMessage.success(editingID.value ? '支付方式已更新' : '支付方式已新增')
+      dialogVisible.value = false
+      await loadMethods()
+    }
+  } finally {
+    saving.value = false
+  }
+}
+
+async function changeStatus(row) {
+  const target = row.enabled
+  try {
+    const res = await setPaymentMethodStatus(row.ID, target)
+    if (res.code === 0) ElMessage.success(target ? '支付方式已启用' : '支付方式已停用')
+    else row.enabled = !target
+  } catch (e) {
+    row.enabled = !target
+  }
+}
+
+async function removeMethod(row) {
+  await ElMessageBox.confirm(`确定删除“${row.name}”吗?历史支付记录不会被删除。`, '删除支付方式', {
+    type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消'
+  })
+  const res = await deletePaymentMethod(row.ID)
+  if (res.code === 0) {
+    ElMessage.success('支付方式已删除')
+    await loadMethods()
+  }
+}
+
+onMounted(loadMethods)
+</script>
+
+<style scoped>
+.page-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
+.page-toolbar h2 { margin: 0; font-size: 18px; font-weight: 600; color: #303133; }
+.page-toolbar p { margin: 5px 0 0; font-size: 13px; color: #909399; }
+.method-name { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #303133; }
+.method-code { display: block; margin-top: 3px; color: #909399; font-family: Consolas, monospace; font-size: 12px; }
+.locked { color: #e6a23c; }
+.entry-tag { margin-right: 6px; }
+.muted { color: #909399; font-size: 12px; }
+.form-hint { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.5; }
+</style>

+ 41 - 9
frontend/src/view/report/paymentRecord.vue

@@ -24,10 +24,14 @@
           <el-option v-for="lot in lotList" :key="lot.ID" :label="lot.lot_name" :value="lot.ID" />
         </el-select>
       </el-form-item>
-      <el-form-item :label="$t.value.paymentMethod">
-        <el-select v-model="queryForm.payment_method" clearable :placeholder="$t.value.pleaseSelect" style="width:140px">
-          <el-option label="现金" value="cash" />
-          <el-option label="免密" value="free" />
+	  <el-form-item label="支付入口">
+		<el-select v-model="queryForm.payment_entry" clearable placeholder="全部入口" style="width:140px">
+		  <el-option v-for="entry in paymentEntryOptions" :key="entry.code" :label="entry.name" :value="entry.code" />
+		</el-select>
+	  </el-form-item>
+	  <el-form-item :label="$t.value.paymentMethod">
+		<el-select v-model="queryForm.payment_method" clearable :placeholder="$t.value.pleaseSelect" style="width:140px">
+		  <el-option v-for="method in paymentMethodOptions" :key="method.code" :label="method.name" :value="method.code" />
         </el-select>
       </el-form-item>
       <el-form-item :label="$t.value.EntryTime">
@@ -43,9 +47,14 @@
       <el-table-column label="停车场" prop="parking_lot" align="center" width="120" />
       <el-table-column :label="$t.value.licensePlate" prop="plate_number" align="center" width="120" />
       <el-table-column :label="$t.value.VehicleType" prop="vehicle_type" align="center" width="100" />
+	  <el-table-column label="支付入口" align="center" width="120">
+		<template #default="{ row }">{{ row.payment_entry_name || entryName(row.payment_entry) }}</template>
+	  </el-table-column>
       <el-table-column :label="$t.value.paymentMethod" align="center" width="100">
-        <template #default="{ row }">
-          <el-tag :type="row.payment_method === 'cash' ? '' : 'success'">{{ row.payment_method === 'cash' ? '现金' : '免密' }}</el-tag>
+		<template #default="{ row }">
+		  <el-tag :type="row.payment_method === 'free' ? 'info' : (row.payment_method === 'cash' ? '' : 'success')">
+			{{ row.payment_method_name || methodName(row.payment_method) }}
+		  </el-tag>
         </template>
       </el-table-column>
       <el-table-column :label="$t.value.Fee" prop="amount" align="center" width="100">
@@ -82,17 +91,39 @@
 
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
-import { getPaymentList, getTodaySummary } from '@/api/payment'
+import { getPaymentEntryOptions, getPaymentList, getPaymentMethodOptions, getTodaySummary } from '@/api/payment'
 import { obtainPullOverList } from '@/api/pullOver'
 
-const queryForm = reactive({ plate_number: '', payment_method: '', parking_lot_id: null, page: 1, page_size: 10, start_date: '', end_date: '' })
+const queryForm = reactive({ plate_number: '', payment_entry: '', payment_method: '', parking_lot_id: null, page: 1, page_size: 10, start_date: '', end_date: '' })
 const dateRange = ref([])
 const list = ref([])
 const total = ref(0)
 const lotList = ref([])
 const summary = reactive({ total_amount: 0, cash_amount: 0, free_amount: 0, total_count: 0 })
+const paymentMethodOptions = ref([])
+const paymentEntryOptions = ref([])
 
-onMounted(() => { fetchList(); fetchSummary(); loadLotList() })
+onMounted(() => { fetchList(); fetchSummary(); loadLotList(); loadPaymentMethodOptions(); loadPaymentEntryOptions() })
+
+function loadPaymentMethodOptions() {
+	getPaymentMethodOptions().then(res => {
+		if (res.code === 0) paymentMethodOptions.value = res.data || []
+	})
+}
+
+function loadPaymentEntryOptions() {
+	getPaymentEntryOptions().then(res => {
+		if (res.code === 0) paymentEntryOptions.value = res.data || []
+	})
+}
+
+function entryName(code) {
+	return paymentEntryOptions.value.find(item => item.code === code)?.name || code || '-'
+}
+
+function methodName(code) {
+	return paymentMethodOptions.value.find(item => item.code === code)?.name || code || '-'
+}
 
 function loadLotList() {
   obtainPullOverList({ page: 1, pageSize: 100 }).then(res => {
@@ -124,6 +155,7 @@ function fetchList() {
 
 function resetQuery() {
   queryForm.plate_number = ''
+	queryForm.payment_entry = ''
   queryForm.payment_method = ''
   queryForm.parking_lot_id = null
   queryForm.page = 1

+ 1 - 0
internal/dao/digital_ticket.go

@@ -15,6 +15,7 @@ type DigitalTicket struct {
 	EntryTime       time.Time  `json:"entry_time"`
 	ExitTime        *time.Time `json:"exit_time"`
 	Fee             float64    `json:"fee"`
+	PaymentEntry    string     `gorm:"size:32" json:"payment_entry"`
 	PaymentMethod   string     `gorm:"size:20" json:"payment_method"`
 	PaymentOrderNo  string     `gorm:"size:64" json:"payment_order_no"`
 	PaidAt          *time.Time `json:"paid_at"`

+ 52 - 0
internal/dao/payment_method_config.go

@@ -0,0 +1,52 @@
+package dao
+
+import "wails-app/internal/global"
+
+// PaymentEntryConfig describes where a payment is initiated.
+type PaymentEntryConfig struct {
+	global.GVA_MODEL
+	Code        string   `gorm:"size:32;not null;uniqueIndex" json:"code"`
+	Name        string   `gorm:"size:50;not null" json:"name"`
+	Enabled     bool     `gorm:"not null;default:false" json:"enabled"`
+	Sort        int      `gorm:"not null;default:0" json:"sort"`
+	IsSystem    bool     `gorm:"not null;default:false" json:"is_system"`
+	Description string   `gorm:"size:200" json:"description"`
+	MethodCodes []string `gorm:"-" json:"method_codes"`
+}
+
+func (PaymentEntryConfig) TableName() string {
+	return "payment_entry_config"
+}
+
+// PaymentEntryMethod links a payment entry to the payment methods it accepts.
+type PaymentEntryMethod struct {
+	global.GVA_MODEL
+	EntryCode  string `gorm:"size:32;not null;uniqueIndex:idx_payment_entry_method" json:"entry_code"`
+	MethodCode string `gorm:"size:32;not null;uniqueIndex:idx_payment_entry_method" json:"method_code"`
+	Sort       int    `gorm:"not null;default:0" json:"sort"`
+}
+
+func (PaymentEntryMethod) TableName() string {
+	return "payment_entry_method"
+}
+
+// PaymentMethodConfig describes how money is settled. It is deliberately
+// independent from the entry that initiated the payment.
+type PaymentMethodConfig struct {
+	global.GVA_MODEL
+	Code        string   `gorm:"size:32;not null;uniqueIndex" json:"code"`
+	Name        string   `gorm:"size:50;not null" json:"name"`
+	InputMode   string   `gorm:"size:24;not null" json:"input_mode"`
+	Enabled     bool     `gorm:"not null;default:false" json:"enabled"`
+	Sort        int      `gorm:"not null;default:0" json:"sort"`
+	IsSystem    bool     `gorm:"not null;default:false" json:"is_system"`
+	IsLegacy    bool     `gorm:"not null;default:false" json:"is_legacy"`
+	Description string   `gorm:"size:200" json:"description"`
+	EntryCodes  []string `gorm:"-" json:"entry_codes"`
+	// Scenes is kept only to read the pre-split schema during startup upgrade.
+	Scenes []string `gorm:"serializer:json;type:text;column:scenes" json:"-"`
+}
+
+func (PaymentMethodConfig) TableName() string {
+	return "payment_method_config"
+}

+ 1 - 0
internal/dao/payment_record.go

@@ -8,6 +8,7 @@ import (
 type PaymentRecord struct {
 	global.GVA_MODEL
 	RecordID      uint      `gorm:"index" json:"record_id"`
+	PaymentEntry  string    `gorm:"size:32;index" json:"payment_entry"`
 	PaymentMethod string    `gorm:"size:20" json:"payment_method"`
 	Amount        float64   `json:"amount"`
 	PaidAmount    float64   `json:"paid_amount"`

+ 2 - 1
internal/dao/vehicle_record.go

@@ -14,7 +14,8 @@ type VehicleRecord struct {
 	StayTime       int64      `json:"stay_time"`                                      // 停留时间(分钟)
 	Fee            float64    `json:"fee"`                                            // 费用
 	PaymentStatus  string     `gorm:"size:20;default:'unpaid'" json:"payment_status"` // 支付状态
-		PaymentMethod  string     `gorm:"size:20" json:"payment_method"`
+	PaymentEntry   string     `gorm:"size:32" json:"payment_entry"`
+	PaymentMethod  string     `gorm:"size:20" json:"payment_method"`
 	EntryImage     string     `gorm:"size:255" json:"entry_image"`                    // 入场图片
 	ExitImage      string     `gorm:"size:255" json:"exit_image"`                     // 出场图片
 	ParkingLotID   uint       `json:"parking_lot_id"`                                 // 停车场ID

+ 3 - 0
internal/initialize/gorm.go

@@ -56,6 +56,9 @@ func RegisterTables() {
 		dao.ParkingLot{},
 		dao.VehicleRecord{},
 		dao.PaymentRecord{},
+		dao.PaymentMethodConfig{},
+		dao.PaymentEntryConfig{},
+		dao.PaymentEntryMethod{},
 		dao.UHFReader{},
 		dao.Camera{},
 		dao.Shortlist{},

+ 250 - 0
internal/initialize/payment_seed.go

@@ -0,0 +1,250 @@
+package initialize
+
+import (
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type paymentMethodDefault struct {
+	Code        string
+	Name        string
+	InputMode   string
+	Scenes      []string
+	Enabled     bool
+	Sort        int
+	Description string
+}
+
+type paymentEntryDefault struct {
+	Code        string
+	Name        string
+	Enabled     bool
+	Sort        int
+	Description string
+	Methods     []string
+}
+
+// EnsurePaymentConfiguration upgrades both new and existing databases. The
+// method and entry tables are deliberately seeded independently: an entry
+// controls where payment starts, while a method controls how it settles.
+func EnsurePaymentConfiguration() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+	if err := db.AutoMigrate(&dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}); err != nil {
+		return fmt.Errorf("初始化支付入口表失败: %w", err)
+	}
+
+	methods := []paymentMethodDefault{
+		{Code: "cash", Name: "人工支付", InputMode: "manual_amount", Scenes: []string{"counter"}, Enabled: true, Sort: 10, Description: "岗亭人员录入实收金额并确认收款"},
+		{Code: "pos", Name: "POS机支付", InputMode: "confirm", Scenes: []string{"counter"}, Enabled: false, Sort: 20, Description: "通过POS机完成支付后由岗亭人员确认"},
+		{Code: "ticket_qr", Name: "小票扫码支付", InputMode: "external", Scenes: []string{"ticket"}, Enabled: false, Sort: 30, Description: "车主扫描停车小票发起外部支付"},
+	}
+	for _, item := range methods {
+		method := dao.PaymentMethodConfig{}
+		err := db.Where("code = ?", item.Code).First(&method).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			method = dao.PaymentMethodConfig{
+				Code: item.Code, Name: item.Name, InputMode: item.InputMode, Scenes: item.Scenes,
+				Enabled: item.Enabled, Sort: item.Sort, IsSystem: true, Description: item.Description,
+			}
+			err = db.Create(&method).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化支付方式 %s 失败: %w", item.Code, err)
+		}
+	}
+
+	// 人工支付是基础能力,升级时也要修复旧数据中被关闭或改型的情况。
+	cashDefaults := dao.PaymentMethodConfig{
+		Enabled: true, InputMode: "manual_amount", Scenes: []string{"counter"}, IsSystem: true,
+	}
+	if err := db.Model(&dao.PaymentMethodConfig{}).Where("code = ?", "cash").
+		Select("enabled", "input_mode", "scenes", "is_system").Updates(&cashDefaults).Error; err != nil {
+		return err
+	}
+
+	entries := []paymentEntryDefault{
+		{Code: "counter", Name: "收费岗亭", Enabled: true, Sort: 10, Description: "由收费岗亭人员发起收费和放行", Methods: []string{"cash", "pos"}},
+		{Code: "ticket", Name: "停车小票", Enabled: true, Sort: 20, Description: "由停车小票或数字票发起后续支付", Methods: []string{"cash", "pos", "ticket_qr"}},
+	}
+	for _, item := range entries {
+		entry := dao.PaymentEntryConfig{}
+		err := db.Where("code = ?", item.Code).First(&entry).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			entry = dao.PaymentEntryConfig{
+				Code: item.Code, Name: item.Name, Enabled: item.Enabled, Sort: item.Sort,
+				IsSystem: true, Description: item.Description,
+			}
+			if err = db.Create(&entry).Error; err != nil {
+				return fmt.Errorf("初始化支付入口 %s 失败: %w", item.Code, err)
+			}
+		}
+		if err != nil {
+			return fmt.Errorf("读取支付入口 %s 失败: %w", item.Code, err)
+		}
+
+		var relationCount int64
+		if err := db.Model(&dao.PaymentEntryMethod{}).Where("entry_code = ?", item.Code).Count(&relationCount).Error; err != nil {
+			return err
+		}
+		if relationCount == 0 {
+			for i, methodCode := range item.Methods {
+				if err := db.Create(&dao.PaymentEntryMethod{EntryCode: item.Code, MethodCode: methodCode, Sort: i * 10}).Error; err != nil {
+					return fmt.Errorf("初始化支付入口 %s 关联失败: %w", item.Code, err)
+				}
+			}
+		}
+	}
+
+	// Migrate old method.scenes values into the relation table without
+	// overwriting any explicit entry configuration made by an administrator.
+	var legacyMethods []dao.PaymentMethodConfig
+	if err := db.Where("is_legacy = ?", false).Find(&legacyMethods).Error; err != nil {
+		return err
+	}
+	for _, method := range legacyMethods {
+		for _, entryCode := range method.Scenes {
+			var entry dao.PaymentEntryConfig
+			if err := db.Where("code = ?", entryCode).First(&entry).Error; err != nil {
+				continue
+			}
+			var count int64
+			if err := db.Model(&dao.PaymentEntryMethod{}).Where("entry_code = ? AND method_code = ?", entryCode, method.Code).Count(&count).Error; err != nil {
+				return err
+			}
+			if count == 0 {
+				if err := db.Create(&dao.PaymentEntryMethod{EntryCode: entryCode, MethodCode: method.Code, Sort: method.Sort}).Error; err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	var parkingMenu dao.SysBaseMenu
+	if err := db.Where("name = ?", "parking").First(&parkingMenu).Error; err == nil {
+		methodMenu, err := ensurePaymentMenu(db, parkingMenu.ID, "paymentMethodConfig", "paymentMethod", "view/parking/paymentMethod.vue", "支付方式", "credit-card", 8)
+		if err != nil {
+			return err
+		}
+		entryMenu, err := ensurePaymentMenu(db, parkingMenu.ID, "paymentEntryConfig", "paymentEntry", "view/parking/paymentEntry.vue", "支付入口", "connection", 9)
+		if err != nil {
+			return err
+		}
+		for _, menu := range []*dao.SysBaseMenu{methodMenu, entryMenu} {
+			for _, authorityID := range []string{"888", "9527"} {
+				var count int64
+				if err := db.Model(&dao.SysAuthorityMenu{}).
+					Where("sys_base_menu_id = ? AND sys_authority_authority_id = ?", menu.ID, authorityID).Count(&count).Error; err != nil {
+					return fmt.Errorf("初始化支付菜单权限失败: %w", err)
+				}
+				if count == 0 {
+					link := dao.SysAuthorityMenu{MenuId: fmt.Sprint(menu.ID), AuthorityId: authorityID}
+					if err := db.Create(&link).Error; err != nil {
+						return fmt.Errorf("初始化支付菜单权限失败: %w", err)
+					}
+				}
+			}
+		}
+	}
+
+	apis := []dao.SysApi{
+		{Path: "/payment/methods", Description: "查询支付方式配置", ApiGroup: "支付方式", Method: "GET"},
+		{Path: "/payment/methods/enabled", Description: "查询入口可用支付方式", ApiGroup: "支付方式", Method: "GET"},
+		{Path: "/payment/methods/options", Description: "查询支付方式选项", ApiGroup: "支付方式", Method: "GET"},
+		{Path: "/payment/methods", Description: "新增支付方式", ApiGroup: "支付方式", Method: "POST"},
+		{Path: "/payment/methods/:id", Description: "更新支付方式", ApiGroup: "支付方式", Method: "PUT"},
+		{Path: "/payment/methods/:id/status", Description: "启停支付方式", ApiGroup: "支付方式", Method: "PATCH"},
+		{Path: "/payment/methods/:id", Description: "删除支付方式", ApiGroup: "支付方式", Method: "DELETE"},
+		{Path: "/payment/entries", Description: "查询支付入口配置", ApiGroup: "支付入口", Method: "GET"},
+		{Path: "/payment/entries/enabled", Description: "查询启用的支付入口", ApiGroup: "支付入口", Method: "GET"},
+		{Path: "/payment/entries/options", Description: "查询支付入口选项", ApiGroup: "支付入口", Method: "GET"},
+		{Path: "/payment/entries", Description: "新增支付入口", ApiGroup: "支付入口", Method: "POST"},
+		{Path: "/payment/entries/:id", Description: "更新支付入口", ApiGroup: "支付入口", Method: "PUT"},
+		{Path: "/payment/entries/:id/status", Description: "启停支付入口", ApiGroup: "支付入口", Method: "PATCH"},
+		{Path: "/payment/entries/:id", Description: "删除支付入口", ApiGroup: "支付入口", Method: "DELETE"},
+	}
+	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失败: %w", 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 err
+	}
+	readRules := [][2]string{
+		{"/payment/methods/enabled", "GET"}, {"/payment/methods/options", "GET"},
+		{"/payment/entries/enabled", "GET"}, {"/payment/entries/options", "GET"},
+	}
+	for _, role := range []string{"618", "888", "9527"} {
+		for _, rule := range readRules {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return err
+			}
+		}
+	}
+	for _, role := range []string{"888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/payment/methods", "GET"}, {"/payment/methods", "POST"},
+			{"/payment/methods/:id", "PUT"}, {"/payment/methods/:id/status", "PATCH"},
+			{"/payment/methods/:id", "DELETE"},
+			{"/payment/entries", "GET"}, {"/payment/entries", "POST"},
+			{"/payment/entries/:id", "PUT"}, {"/payment/entries/:id/status", "PATCH"},
+			{"/payment/entries/:id", "DELETE"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func ensurePaymentMenu(db *gorm.DB, parentID uint, name, path, component, title, icon string, sort int) (*dao.SysBaseMenu, error) {
+	menu := &dao.SysBaseMenu{}
+	err := db.Where("name = ?", name).First(menu).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		*menu = dao.SysBaseMenu{
+			MenuLevel: 0, ParentId: parentID, Path: path, Name: name,
+			Hidden: false, Component: component, Sort: sort,
+			Meta: dao.Meta{Title: title, Icon: icon},
+		}
+		if err := db.Create(menu).Error; err != nil {
+			return nil, fmt.Errorf("初始化%s菜单失败: %w", title, err)
+		}
+		return menu, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("读取%s菜单失败: %w", title, err)
+	}
+	if err := db.Model(menu).Updates(map[string]interface{}{"parent_id": parentID, "path": path, "component": component, "sort": sort}).Error; err != nil {
+		return nil, fmt.Errorf("更新%s菜单失败: %w", title, err)
+	}
+	return menu, nil
+}
+
+func ensureCasbinRule(role, path, method string) error {
+	db := global.GVA_DB
+	var count int64
+	if err := db.Table("casbin_rule").Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", role, path, method).Count(&count).Error; err != nil {
+		return err
+	}
+	if count == 0 {
+		return db.Exec("INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES (?, ?, ?, ?, '', '', '')", "p", role, path, method).Error
+	}
+	return nil
+}

+ 52 - 0
internal/initialize/payment_seed_test.go

@@ -0,0 +1,52 @@
+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 TestEnsurePaymentConfigurationIsIdempotent(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	global.GVA_DB = db
+	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())
+	require.NoError(t, EnsurePaymentConfiguration())
+
+	var methods []dao.PaymentMethodConfig
+	require.NoError(t, db.Order("sort").Find(&methods).Error)
+	require.Len(t, methods, 3)
+	require.Equal(t, "cash", methods[0].Code)
+	require.True(t, methods[0].Enabled)
+	require.Equal(t, []string{"counter"}, methods[0].Scenes)
+
+	var entries []dao.PaymentEntryConfig
+	require.NoError(t, db.Order("sort").Find(&entries).Error)
+	require.Len(t, entries, 2)
+	var relationCount int64
+	require.NoError(t, db.Model(&dao.PaymentEntryMethod{}).Count(&relationCount).Error)
+	require.EqualValues(t, 5, relationCount)
+
+	var menuCount int64
+	require.NoError(t, db.Model(&dao.SysBaseMenu{}).Where("name = ?", "paymentMethodConfig").Count(&menuCount).Error)
+	require.EqualValues(t, 1, menuCount)
+
+	var permissionCount int64
+	require.NoError(t, db.Model(&dao.SysAuthorityMenu{}).Count(&permissionCount).Error)
+	require.EqualValues(t, 4, permissionCount)
+
+	var ruleCount int64
+	require.NoError(t, db.Table("casbin_rule").Count(&ruleCount).Error)
+	require.EqualValues(t, 32, ruleCount)
+}

+ 11 - 4
internal/initialize/seed.go

@@ -20,6 +20,9 @@ func SeedSystemData() {
 	var count int64
 	db.Model(&dao.SysUser{}).Where("username = ?", "admin").Count(&count)
 	if count > 0 {
+		if err := EnsurePaymentConfiguration(); err != nil {
+			global.GVA_LOG.Error("payment configuration upgrade failed")
+		}
 		return
 	}
 
@@ -187,10 +190,10 @@ func SeedSystemData() {
 		{MenuId: "35", AuthorityId: "618"}, {MenuId: "35", AuthorityId: "888"}, {MenuId: "35", AuthorityId: "9527"},
 	}
 	db.Create(&authMenus)
-		// --- vehicle_type (默认车辆类型:临时车,标记为系统内置) ---
-		db.FirstOrCreate(&dao.VehicleType{},
-			dao.VehicleType{Name: "临时车", IsSystem: true, Remarks: "系统内置"},
-		)
+	// --- vehicle_type (默认车辆类型:临时车,标记为系统内置) ---
+	db.FirstOrCreate(&dao.VehicleType{},
+		dao.VehicleType{Name: "临时车", IsSystem: true, Remarks: "系统内置"},
+	)
 
 	// --- sys_apis (active only, skipping soft-deleted ids 44-66,84,90-98) ---
 	apis := []dao.SysApi{
@@ -524,6 +527,10 @@ func SeedSystemData() {
 	}
 	db.Create(&dictionaries)
 
+	if err := EnsurePaymentConfiguration(); err != nil {
+		global.GVA_LOG.Error("payment configuration seed failed")
+	}
+
 	global.GVA_LOG.Info("system data seeded successfully")
 }
 

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

@@ -93,6 +93,7 @@ type VehicleRecordRequest struct {
 type ExitConfirmRequest struct {
 	PlateNumber   string  `json:"plate_number"`
 	RFIDTag       string  `json:"rfid_tag"`
+	PaymentEntry  string  `json:"payment_entry"`
 	PaymentMethod string  `json:"payment_method"`
 	PaidAmount    float64 `json:"paid_amount"`
 }

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

@@ -45,5 +45,6 @@ type ExitConfirmResponse struct {
 	StayTime      int64   `json:"stay_time"`
 	Fee           float64 `json:"fee"`
 	ChangeAmount  float64 `json:"change_amount"`
+	PaymentEntry  string  `json:"payment_entry"`
 	PaymentMethod string  `json:"payment_method"`
 }

+ 18 - 3
internal/modules/digital-ticket/api.go

@@ -2,12 +2,15 @@ package digital_ticket
 
 import (
 	"github.com/gin-gonic/gin"
+	"strings"
 	"wails-app/internal/model/common/response"
 	"wails-app/internal/modules/digital-ticket/repository"
 	"wails-app/internal/modules/digital-ticket/service"
+	paymentService "wails-app/internal/modules/payment/service"
 )
 
 var ticketSvc = service.NewTicketService()
+var ticketPaymentSvc = paymentService.NewPaymentService()
 
 func GetTicket(c *gin.Context) {
 	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
@@ -19,6 +22,7 @@ func GetTicket(c *gin.Context) {
 }
 
 type payReq struct {
+	PaymentEntry   string  `json:"payment_entry"`
 	PaymentMethod  string  `json:"payment_method"`
 	PaymentOrderNo string  `json:"payment_order_no"`
 	PaidAmount     float64 `json:"paid_amount"`
@@ -35,10 +39,21 @@ func PayTicket(c *gin.Context) {
 		response.FailWithMessage("票不存在", c)
 		return
 	}
+	entryCode := req.PaymentEntry
+	entryCode = strings.TrimSpace(entryCode)
+	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_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
-		"paid_amount": req.PaidAmount,
-		"fee": t.Fee,
+		"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 {
 		response.FailWithMessage(err.Error(), c)

+ 29 - 3
internal/modules/digital-ticket/repository/repo.go

@@ -1,6 +1,7 @@
 package repository
 
 import (
+	"gorm.io/gorm"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 )
@@ -8,7 +9,11 @@ import (
 type TicketRepository struct{}
 
 func (r *TicketRepository) Create(ticket *dao.DigitalTicket) error {
-	return global.GVA_DB.Create(ticket).Error
+	return r.CreateTx(global.GVA_DB, ticket)
+}
+
+func (r *TicketRepository) CreateTx(db *gorm.DB, ticket *dao.DigitalTicket) error {
+	return db.Create(ticket).Error
 }
 
 func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, error) {
@@ -21,8 +26,12 @@ func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, e
 }
 
 func (r *TicketRepository) GetByID(id uint) (*dao.DigitalTicket, error) {
+	return r.GetByIDTx(global.GVA_DB, id)
+}
+
+func (r *TicketRepository) GetByIDTx(db *gorm.DB, id uint) (*dao.DigitalTicket, error) {
 	var t dao.DigitalTicket
-	err := global.GVA_DB.First(&t, id).Error
+	err := db.First(&t, id).Error
 	if err != nil {
 		return nil, err
 	}
@@ -30,8 +39,25 @@ func (r *TicketRepository) GetByID(id uint) (*dao.DigitalTicket, error) {
 }
 
 func (r *TicketRepository) UpdateState(id uint, state string, updates map[string]interface{}) error {
+	return r.UpdateStateTx(global.GVA_DB, id, state, updates)
+}
+
+func (r *TicketRepository) GetByVehicleRecordID(recordID uint) (*dao.DigitalTicket, error) {
+	return r.GetByVehicleRecordIDTx(global.GVA_DB, recordID)
+}
+
+func (r *TicketRepository) GetByVehicleRecordIDTx(db *gorm.DB, recordID uint) (*dao.DigitalTicket, error) {
+	var t dao.DigitalTicket
+	err := db.Where("vehicle_record_id = ?", recordID).Order("id DESC").First(&t).Error
+	if err != nil {
+		return nil, err
+	}
+	return &t, nil
+}
+
+func (r *TicketRepository) UpdateStateTx(db *gorm.DB, id uint, state string, updates map[string]interface{}) error {
 	updates["state"] = state
-	return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
+	return db.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
 }
 
 type TicketListQuery struct {

+ 46 - 4
internal/modules/digital-ticket/service/service.go

@@ -6,8 +6,10 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"gorm.io/gorm"
 	"time"
 	"wails-app/internal/dao"
+	"wails-app/internal/global"
 	"wails-app/internal/modules/digital-ticket/repository"
 )
 
@@ -44,12 +46,16 @@ func appendEvent(log string, event map[string]interface{}) string {
 
 // Create 创建数字票(所有进场方式统一入口)
 func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
+	return s.CreateTx(global.GVA_DB, plateNumber, triggerMode, recordID)
+}
+
+func (s *TicketService) CreateTx(db *gorm.DB, plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
 	log := appendEvent("[]", map[string]interface{}{"event": "created", "trigger": triggerMode})
 	ticket := &dao.DigitalTicket{
 		TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
 		State: StatePendingPayment, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
 	}
-	if err := s.repo.Create(ticket); err != nil {
+	if err := s.repo.CreateTx(db, ticket); err != nil {
 		return nil, err
 	}
 	return ticket, nil
@@ -57,7 +63,11 @@ func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (
 
 // Transition 状态转换(校验合法性,原子更新)
 func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
-	ticket, err := s.repo.GetByID(id)
+	return s.TransitionTx(global.GVA_DB, id, toState, extra)
+}
+
+func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error {
+	ticket, err := s.repo.GetByIDTx(db, id)
 	if err != nil {
 		return errors.New("票不存在")
 	}
@@ -89,7 +99,7 @@ func (s *TicketService) Transition(id uint, toState string, extra map[string]int
 		updates["exit_time"] = now
 	}
 	// 只允许表中存在的字段写入数据库,其余仅记录到事件日志
-	dbFields := map[string]bool{"payment_method": true, "payment_order_no": true, "fee": true}
+	dbFields := map[string]bool{"payment_entry": true, "payment_method": true, "payment_order_no": true, "fee": true}
 	for k, v := range extra {
 		if dbFields[k] {
 			updates[k] = v
@@ -102,7 +112,39 @@ func (s *TicketService) Transition(id uint, toState string, extra map[string]int
 	}
 	updates["event_log"] = appendEvent(ticket.EventLog, event)
 
-	return s.repo.UpdateState(id, toState, updates)
+	return s.repo.UpdateStateTx(db, id, toState, updates)
+}
+
+// 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 {
+	ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
+	if err != nil {
+		return errors.New("车辆对应的数字票不存在")
+	}
+
+	if ticket.State == StatePendingPayment {
+		extra := map[string]interface{}{
+			"payment_entry":    paymentEntry,
+			"payment_method":   paymentMethod,
+			"payment_order_no": paymentOrderNo,
+			"paid_amount":      paidAmount,
+			"fee":              fee,
+		}
+		if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil {
+			return err
+		}
+		ticket.State = StatePaid
+	}
+
+	if ticket.State == StatePaid {
+		return s.TransitionTx(db, ticket.ID, StateExited, nil)
+	}
+	if ticket.State == StateExited {
+		return nil
+	}
+	return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
 }
 
 func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {

+ 48 - 0
internal/modules/digital-ticket/service/service_test.go

@@ -0,0 +1,48 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupTicketServiceTest(t *testing.T) *TicketService {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(&dao.VehicleRecord{}, &dao.DigitalTicket{}))
+	return NewTicketService()
+}
+
+func TestMarkPaidAndExitedUsesTheActualPaymentEntry(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+
+	record := &dao.VehicleRecord{PlateNumber: "TEST001"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+	_, err := svc.Create("TEST001", "manual", record.ID)
+	require.NoError(t, err)
+
+	require.NoError(t, svc.MarkPaidAndExitedTx(global.GVA_DB, record.ID,
+		"central_payment_machine", "pos", "ORDER-001", 10, 10))
+
+	ticket, err := svc.repo.GetByVehicleRecordID(record.ID)
+	require.NoError(t, err)
+	require.Equal(t, StateExited, ticket.State)
+	require.Equal(t, "central_payment_machine", ticket.PaymentEntry)
+	require.Equal(t, "pos", ticket.PaymentMethod)
+	require.Equal(t, "ORDER-001", ticket.PaymentOrderNo)
+}
+
+func TestMarkPaidAndExitedRequiresATicket(t *testing.T) {
+	svc := setupTicketServiceTest(t)
+	record := &dao.VehicleRecord{PlateNumber: "TEST002"}
+	require.NoError(t, global.GVA_DB.Create(record).Error)
+
+	require.EqualError(t,
+		svc.MarkPaidAndExitedTx(global.GVA_DB, record.ID, "counter", "cash", "", 0, 0),
+		"车辆对应的数字票不存在")
+}

+ 2 - 2
internal/modules/monthly/service/service.go

@@ -65,7 +65,7 @@ func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64
 
 	// 创建 payment_record
 	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentMethod: paymentSvc.PaymentCash,
+		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
 		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
 		OperatorID: operatorID, PaidAt: now,
 		Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
@@ -97,7 +97,7 @@ func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, op
 	// 创建 payment_record
 	now := time.Now()
 	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentMethod: paymentSvc.PaymentCash,
+		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
 		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
 		OperatorID: operatorID, PaidAt: now,
 		Remark: fmt.Sprintf("包月卡续费(%s)", cardType),

+ 224 - 0
internal/modules/payment/api.go

@@ -1,7 +1,10 @@
 package payment
 
 import (
+	"strconv"
+
 	"github.com/gin-gonic/gin"
+	"wails-app/internal/dao"
 	"wails-app/internal/model/common/response"
 	"wails-app/internal/model/vehicle/request"
 	"wails-app/internal/modules/payment/repository"
@@ -73,12 +76,233 @@ func TodaySummary(c *gin.Context) {
 	response.OkWithData(s, c)
 }
 
+type methodStatusRequest struct {
+	Enabled bool `json:"enabled"`
+}
+
+type methodOption struct {
+	Code string `json:"code"`
+	Name string `json:"name"`
+}
+
+type entryOption struct {
+	Code string `json:"code"`
+	Name string `json:"name"`
+}
+
+func ListMethods(c *gin.Context) {
+	list, err := paymentSvc.ListMethods()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(list, c)
+}
+
+func ListEnabledMethods(c *gin.Context) {
+	entryCode := c.Query("entry")
+	if entryCode == "" {
+		// Accept the old query name during the transition so older clients keep
+		// working while the API now exposes the concept as a payment entry.
+		entryCode = c.Query("scene")
+	}
+	list, err := paymentSvc.ListEnabledMethods(entryCode)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(list, c)
+}
+
+func ListMethodOptions(c *gin.Context) {
+	list, err := paymentSvc.ListMethods()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	options := make([]methodOption, 0, len(list)+1)
+	for _, method := range list {
+		options = append(options, methodOption{Code: method.Code, Name: method.Name})
+	}
+	options = append(options, methodOption{Code: paymentService.PaymentFree, Name: "免密放行"})
+	response.OkWithData(options, c)
+}
+
+func ListEntries(c *gin.Context) {
+	list, err := paymentSvc.ListEntries()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(list, c)
+}
+
+func ListEnabledEntries(c *gin.Context) {
+	list, err := paymentSvc.ListEnabledEntries()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(list, c)
+}
+
+func ListEntryOptions(c *gin.Context) {
+	list, err := paymentSvc.ListEnabledEntries()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	options := make([]entryOption, 0, len(list))
+	for _, entry := range list {
+		options = append(options, entryOption{Code: entry.Code, Name: entry.Name})
+	}
+	response.OkWithData(options, c)
+}
+
+func CreateMethod(c *gin.Context) {
+	var req dao.PaymentMethodConfig
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.CreateMethod(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("新增支付方式成功", c)
+}
+
+func UpdateMethod(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付方式ID", c)
+		return
+	}
+	var req dao.PaymentMethodConfig
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.UpdateMethod(uint(id), &req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("更新支付方式成功", c)
+}
+
+func SetMethodStatus(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付方式ID", c)
+		return
+	}
+	var req methodStatusRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.SetMethodEnabled(uint(id), req.Enabled); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("支付方式状态已更新", c)
+}
+
+func DeleteMethod(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付方式ID", c)
+		return
+	}
+	if err := paymentSvc.DeleteMethod(uint(id)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("删除支付方式成功", c)
+}
+
+func CreateEntry(c *gin.Context) {
+	var req dao.PaymentEntryConfig
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.CreateEntry(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("新增支付入口成功", c)
+}
+
+func UpdateEntry(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付入口ID", c)
+		return
+	}
+	var req dao.PaymentEntryConfig
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.UpdateEntry(uint(id), &req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("更新支付入口成功", c)
+}
+
+func SetEntryStatus(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付入口ID", c)
+		return
+	}
+	var req methodStatusRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentSvc.SetEntryEnabled(uint(id), req.Enabled); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("支付入口状态已更新", c)
+}
+
+func DeleteEntry(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || id == 0 {
+		response.FailWithMessage("无效的支付入口ID", c)
+		return
+	}
+	if err := paymentSvc.DeleteEntry(uint(id)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("支付入口已删除", c)
+}
+
 // SetupPaymentRouter 注册支付相关路由
 func SetupPaymentRouter(router *gin.RouterGroup) {
 	pg := router.Group("/payment")
 	{
 		pg.GET("/list", ListPayments)
 		pg.GET("/today-summary", TodaySummary)
+		pg.GET("/methods", ListMethods)
+		pg.GET("/methods/enabled", ListEnabledMethods)
+		pg.GET("/methods/options", ListMethodOptions)
+		pg.POST("/methods", CreateMethod)
+		pg.PUT("/methods/:id", UpdateMethod)
+		pg.PATCH("/methods/:id/status", SetMethodStatus)
+		pg.DELETE("/methods/:id", DeleteMethod)
+		pg.GET("/entries", ListEntries)
+		pg.GET("/entries/enabled", ListEnabledEntries)
+		pg.GET("/entries/options", ListEntryOptions)
+		pg.POST("/entries", CreateEntry)
+		pg.PUT("/entries/:id", UpdateEntry)
+		pg.PATCH("/entries/:id/status", SetEntryStatus)
+		pg.DELETE("/entries/:id", DeleteEntry)
 	}
 	eg := router.Group("/vehicle")
 	{

+ 232 - 15
internal/modules/payment/repository/repo.go

@@ -1,13 +1,219 @@
 package repository
 
 import (
+	"errors"
+	"strings"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+
+	"gorm.io/gorm"
 )
 
 type PaymentRepository struct{}
 
+func (r *PaymentRepository) EntryList() ([]dao.PaymentEntryConfig, error) {
+	var entries []dao.PaymentEntryConfig
+	if err := global.GVA_DB.Order("sort ASC, id ASC").Find(&entries).Error; err != nil {
+		return nil, err
+	}
+	for i := range entries {
+		codes, err := r.entryMethodCodes(global.GVA_DB, entries[i].Code)
+		if err != nil {
+			if strings.Contains(strings.ToLower(err.Error()), "no such table") {
+				continue
+			}
+			return nil, err
+		}
+		entries[i].MethodCodes = codes
+	}
+	return entries, nil
+}
+
+func (r *PaymentRepository) entryMethodCodes(db *gorm.DB, entryCode string) ([]string, error) {
+	var codes []string
+	err := db.Model(&dao.PaymentEntryMethod{}).Where("entry_code = ?", entryCode).
+		Order("sort ASC, id ASC").Pluck("method_code", &codes).Error
+	return codes, err
+}
+
+func (r *PaymentRepository) MethodList() ([]dao.PaymentMethodConfig, error) {
+	var methods []dao.PaymentMethodConfig
+	if err := global.GVA_DB.Where("is_legacy = ?", false).Order("sort ASC, id ASC").Find(&methods).Error; err != nil {
+		return nil, err
+	}
+	for i := range methods {
+		codes, err := r.methodEntryCodes(global.GVA_DB, methods[i].Code)
+		if err != nil {
+			if strings.Contains(strings.ToLower(err.Error()), "no such table") {
+				continue
+			}
+			return nil, err
+		}
+		methods[i].EntryCodes = codes
+	}
+	return methods, nil
+}
+
+func (r *PaymentRepository) methodEntryCodes(db *gorm.DB, methodCode string) ([]string, error) {
+	var codes []string
+	err := db.Model(&dao.PaymentEntryMethod{}).Where("method_code = ?", methodCode).
+		Order("sort ASC, id ASC").Pluck("entry_code", &codes).Error
+	return codes, err
+}
+
+func (r *PaymentRepository) EnabledMethodList(db *gorm.DB, entryCode string) ([]dao.PaymentMethodConfig, error) {
+	var methods []dao.PaymentMethodConfig
+	err := db.Table("payment_method_config AS pm").
+		Select("pm.*").
+		Joins("INNER JOIN payment_entry_method AS pem ON pem.method_code = pm.code").
+		Joins("INNER JOIN payment_entry_config AS pec ON pec.code = pem.entry_code").
+		Where("pem.entry_code = ? AND pem.deleted_at IS NULL AND pec.enabled = ? AND pm.enabled = ? AND pm.is_legacy = ?", entryCode, true, true, false).
+		Order("pem.sort ASC, pm.sort ASC, pm.id ASC").Scan(&methods).Error
+	return methods, err
+}
+
+func (r *PaymentRepository) IsMethodAllowed(db *gorm.DB, entryCode, methodCode string) (bool, error) {
+	if db == nil {
+		db = global.GVA_DB
+	}
+	var count int64
+	err := db.Table("payment_entry_method AS pem").
+		Joins("INNER JOIN payment_entry_config AS pec ON pec.code = pem.entry_code").
+		Where("pem.entry_code = ? AND pem.method_code = ? AND pem.deleted_at IS NULL AND pec.deleted_at IS NULL", entryCode, methodCode).
+		Count(&count).Error
+	return count > 0, err
+}
+
+func (r *PaymentRepository) GetMethodByID(id uint) (*dao.PaymentMethodConfig, error) {
+	var method dao.PaymentMethodConfig
+	err := global.GVA_DB.First(&method, id).Error
+	return &method, err
+}
+
+func (r *PaymentRepository) GetMethodByCode(db *gorm.DB, code string) (*dao.PaymentMethodConfig, error) {
+	if db == nil {
+		db = global.GVA_DB
+	}
+	var method dao.PaymentMethodConfig
+	err := db.Where("code = ?", code).First(&method).Error
+	return &method, err
+}
+
+func (r *PaymentRepository) GetEntryByID(id uint) (*dao.PaymentEntryConfig, error) {
+	var entry dao.PaymentEntryConfig
+	err := global.GVA_DB.First(&entry, id).Error
+	return &entry, err
+}
+
+func (r *PaymentRepository) GetEntryByCode(db *gorm.DB, code string) (*dao.PaymentEntryConfig, error) {
+	if db == nil {
+		db = global.GVA_DB
+	}
+	var entry dao.PaymentEntryConfig
+	err := db.Where("code = ?", code).First(&entry).Error
+	return &entry, err
+}
+
+func (r *PaymentRepository) CreateEntry(entry *dao.PaymentEntryConfig) error {
+	return global.GVA_DB.Create(entry).Error
+}
+
+func (r *PaymentRepository) CreateEntryTx(db *gorm.DB, entry *dao.PaymentEntryConfig) error {
+	return db.Create(entry).Error
+}
+
+func (r *PaymentRepository) UpdateEntry(entry *dao.PaymentEntryConfig) error {
+	return global.GVA_DB.Model(entry).Select("name", "enabled", "sort", "description").Updates(entry).Error
+}
+
+func (r *PaymentRepository) UpdateEntryTx(db *gorm.DB, entry *dao.PaymentEntryConfig) error {
+	return db.Model(entry).Select("name", "enabled", "sort", "description").Updates(entry).Error
+}
+
+func (r *PaymentRepository) SetEntryEnabled(id uint, enabled bool) error {
+	result := global.GVA_DB.Model(&dao.PaymentEntryConfig{}).Where("id = ?", id).Update("enabled", enabled)
+	if result.Error != nil {
+		return result.Error
+	}
+	if result.RowsAffected == 0 {
+		return gorm.ErrRecordNotFound
+	}
+	return nil
+}
+
+func (r *PaymentRepository) CreateMethod(method *dao.PaymentMethodConfig) error {
+	return global.GVA_DB.Create(method).Error
+}
+
+func (r *PaymentRepository) CreateMethodTx(db *gorm.DB, method *dao.PaymentMethodConfig) error {
+	return db.Create(method).Error
+}
+
+func (r *PaymentRepository) UpdateMethod(method *dao.PaymentMethodConfig) error {
+	return global.GVA_DB.Model(method).Select("name", "input_mode", "enabled", "sort", "description").Updates(method).Error
+}
+
+func (r *PaymentRepository) SetMethodEnabled(id uint, enabled bool) error {
+	result := global.GVA_DB.Model(&dao.PaymentMethodConfig{}).Where("id = ?", id).Update("enabled", enabled)
+	if result.Error != nil {
+		return result.Error
+	}
+	if result.RowsAffected == 0 {
+		return gorm.ErrRecordNotFound
+	}
+	return nil
+}
+
+func (r *PaymentRepository) DeleteMethod(method *dao.PaymentMethodConfig) error {
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Unscoped().Where("method_code = ?", method.Code).Delete(&dao.PaymentEntryMethod{}).Error; err != nil {
+			return err
+		}
+		result := tx.Delete(method)
+		if result.Error != nil {
+			return result.Error
+		}
+		if result.RowsAffected == 0 {
+			return errors.New("支付方式不存在")
+		}
+		return nil
+	})
+}
+
+func (r *PaymentRepository) ReplaceEntryMethods(db *gorm.DB, entryCode string, methodCodes []string) error {
+	if err := db.Unscoped().Where("entry_code = ?", entryCode).Delete(&dao.PaymentEntryMethod{}).Error; err != nil {
+		return err
+	}
+	for i, code := range methodCodes {
+		if err := db.Create(&dao.PaymentEntryMethod{EntryCode: entryCode, MethodCode: code, Sort: i * 10}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *PaymentRepository) ReplaceMethodEntries(db *gorm.DB, methodCode string, entryCodes []string) error {
+	if err := db.Unscoped().Where("method_code = ?", methodCode).Delete(&dao.PaymentEntryMethod{}).Error; err != nil {
+		return err
+	}
+	for i, code := range entryCodes {
+		if err := db.Create(&dao.PaymentEntryMethod{EntryCode: code, MethodCode: methodCode, Sort: i * 10}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *PaymentRepository) DeleteEntry(entry *dao.PaymentEntryConfig) error {
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Unscoped().Where("entry_code = ?", entry.Code).Delete(&dao.PaymentEntryMethod{}).Error; err != nil {
+			return err
+		}
+		return tx.Delete(entry).Error
+	})
+}
+
 func (r *PaymentRepository) Create(record *dao.PaymentRecord) error {
 	return global.GVA_DB.Create(record).Error
 }
@@ -15,6 +221,7 @@ func (r *PaymentRepository) Create(record *dao.PaymentRecord) error {
 // PaymentListQuery 收费记录查询参数
 type PaymentListQuery struct {
 	PlateNumber   string `form:"plate_number"`
+	PaymentEntry  string `form:"payment_entry"`
 	PaymentMethod string `form:"payment_method"`
 	ParkingLotID  uint   `form:"parking_lot_id"`
 	StartDate     string `form:"start_date"`
@@ -25,26 +232,31 @@ type PaymentListQuery struct {
 
 // PaymentListResult 收费记录列表项
 type PaymentListResult struct {
-	ID            uint    `json:"id"`
-	PlateNumber   string  `json:"plate_number"`
-	VehicleType   string  `json:"vehicle_type"`
-	PaymentMethod string  `json:"payment_method"`
-	Amount        float64 `json:"amount"`
-	PaidAmount    float64 `json:"paid_amount"`
-	ChangeAmount  float64 `json:"change_amount"`
-	OperatorID    uint    `json:"operator_id"`
-	OperatorName  string  `json:"operator_name"`
-	ParkingLot    string  `json:"parking_lot"`
-	PaidAt        string  `json:"paid_at"`
-	EntryTime     string  `json:"entry_time"`
-	ExitTime      string  `json:"exit_time"`
-	StayTime      int64   `json:"stay_time"`
+	ID                uint    `json:"id"`
+	PlateNumber       string  `json:"plate_number"`
+	VehicleType       string  `json:"vehicle_type"`
+	PaymentEntry      string  `json:"payment_entry"`
+	PaymentEntryName  string  `json:"payment_entry_name"`
+	PaymentMethod     string  `json:"payment_method"`
+	PaymentMethodName string  `json:"payment_method_name"`
+	Amount            float64 `json:"amount"`
+	PaidAmount        float64 `json:"paid_amount"`
+	ChangeAmount      float64 `json:"change_amount"`
+	OperatorID        uint    `json:"operator_id"`
+	OperatorName      string  `json:"operator_name"`
+	ParkingLot        string  `json:"parking_lot"`
+	PaidAt            string  `json:"paid_at"`
+	EntryTime         string  `json:"entry_time"`
+	ExitTime          string  `json:"exit_time"`
+	StayTime          int64   `json:"stay_time"`
 }
 
 // List 查询收费记录列表(关联 vehicle_record + vehicle + vehicle_type)
 func (r *PaymentRepository) List(q PaymentListQuery) ([]PaymentListResult, int64, error) {
 	db := global.GVA_DB.Table("payment_record").
-		Select(`payment_record.id, vehicle_record.plate_number, vehicle_type.name as vehicle_type, payment_record.payment_method,
+		Select(`payment_record.id, vehicle_record.plate_number, vehicle_type.name as vehicle_type, payment_record.payment_entry,
+			COALESCE(payment_entry_config.name, payment_record.payment_entry) as payment_entry_name, payment_record.payment_method,
+			COALESCE(payment_method_config.name, CASE WHEN payment_record.payment_method = 'free' THEN '免密放行' ELSE payment_record.payment_method END) as payment_method_name,
 			payment_record.amount, payment_record.paid_amount, payment_record.change_amount,
 			payment_record.operator_id, sys_users.nick_name as operator_name, vehicle_record.parking_lot_id, parking_lot.lot_name as parking_lot, payment_record.paid_at,
 			vehicle_record.entry_time, vehicle_record.exit_time, vehicle_record.stay_time`).
@@ -53,6 +265,8 @@ func (r *PaymentRepository) List(q PaymentListQuery) ([]PaymentListResult, int64
 		Joins("LEFT JOIN vehicle_type ON vehicle_type.id = vehicle.vehicle_type_id").
 		Joins("LEFT JOIN sys_users ON sys_users.id = payment_record.operator_id").
 		Joins("LEFT JOIN parking_lot ON parking_lot.id = vehicle_record.parking_lot_id")
+	db = db.Joins("LEFT JOIN payment_entry_config ON payment_entry_config.code = payment_record.payment_entry")
+	db = db.Joins("LEFT JOIN payment_method_config ON payment_method_config.code = payment_record.payment_method")
 
 	if q.PlateNumber != "" {
 		db = db.Where("vehicle_record.plate_number LIKE ?", "%"+q.PlateNumber+"%")
@@ -63,6 +277,9 @@ func (r *PaymentRepository) List(q PaymentListQuery) ([]PaymentListResult, int64
 	if q.PaymentMethod != "" {
 		db = db.Where("payment_record.payment_method = ?", q.PaymentMethod)
 	}
+	if q.PaymentEntry != "" {
+		db = db.Where("payment_record.payment_entry = ?", q.PaymentEntry)
+	}
 	if q.StartDate != "" {
 		db = db.Where("payment_record.paid_at >= ?", q.StartDate)
 	}

+ 375 - 10
internal/modules/payment/service/service.go

@@ -1,22 +1,41 @@
 package service
 
 import (
+	"errors"
 	"fmt"
+	"regexp"
+	"strings"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/modules/payment/repository"
+
+	"gorm.io/gorm"
 )
 
-// Payment method constants — add new methods here for POS/Wechat/Alipay
+// Internal payment constants and configuration values.
 const (
-	PaymentCash   = "cash"
-	PaymentFree   = "free"
-	PaymentPOS    = "pos"
-	PaymentWechat = "wechat"
-	PaymentAlipay = "alipay"
+	PaymentCash     = "cash"
+	PaymentFree     = "free"
+	PaymentPOS      = "pos"
+	PaymentTicketQR = "ticket_qr"
+
+	InputModeManualAmount = "manual_amount"
+	InputModeConfirm      = "confirm"
+	InputModeExternal     = "external"
+
+	EntryCounter = "counter"
+	EntryTicket  = "ticket"
+
+	// Scene* are kept as source-compatible aliases for older callers. New code
+	// should pass an Entry* value and treat it as the payment entry, not a
+	// property of the payment method.
+	SceneCounter = EntryCounter
+	SceneTicket  = EntryTicket
 )
 
+var paymentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`)
+
 type PaymentService struct {
 	repo *repository.PaymentRepository
 }
@@ -25,16 +44,359 @@ func NewPaymentService() *PaymentService {
 	return &PaymentService{repo: &repository.PaymentRepository{}}
 }
 
+func (s *PaymentService) ListMethods() ([]dao.PaymentMethodConfig, error) {
+	return s.repo.MethodList()
+}
+
+func (s *PaymentService) ListEntries() ([]dao.PaymentEntryConfig, error) {
+	return s.repo.EntryList()
+}
+
+func (s *PaymentService) ListEnabledEntries() ([]dao.PaymentEntryConfig, error) {
+	entries, err := s.repo.EntryList()
+	if err != nil {
+		return nil, err
+	}
+	result := make([]dao.PaymentEntryConfig, 0, len(entries))
+	for _, entry := range entries {
+		if entry.Enabled {
+			result = append(result, entry)
+		}
+	}
+	return result, nil
+}
+
+func (s *PaymentService) ListEnabledMethods(entryCode string) ([]dao.PaymentMethodConfig, error) {
+	if entryCode != "" && entryCode != EntryCounter && entryCode != EntryTicket {
+		// Custom entries are valid too; only reject an empty value at the API
+		// boundary where an entry is required.
+		if _, err := s.repo.GetEntryByCode(nil, entryCode); err != nil {
+			return nil, errors.New("无效的支付入口")
+		}
+	}
+	methods, err := s.repo.EnabledMethodList(global.GVA_DB, entryCode)
+	if err == nil {
+		return methods, nil
+	}
+	// Existing installations created before the split may not have the
+	// relation tables yet. Keep their old scene data usable until startup
+	// migration has completed.
+	if strings.Contains(strings.ToLower(err.Error()), "no such table") {
+		return s.listLegacyEnabledMethods(entryCode), nil
+	}
+	return nil, err
+}
+
+func (s *PaymentService) listLegacyEnabledMethods(entryCode string) []dao.PaymentMethodConfig {
+	methods, err := s.repo.MethodList()
+	if err != nil {
+		return nil
+	}
+	result := make([]dao.PaymentMethodConfig, 0, len(methods))
+	for _, method := range methods {
+		if method.Enabled && containsScene(method.Scenes, entryCode) {
+			result = append(result, method)
+		}
+	}
+	return result
+}
+
+func normalizeCodes(codes []string) []string {
+	seen := make(map[string]bool, len(codes))
+	result := make([]string, 0, len(codes))
+	for _, code := range codes {
+		code = strings.ToLower(strings.TrimSpace(code))
+		if code != "" && !seen[code] {
+			seen[code] = true
+			result = append(result, code)
+		}
+	}
+	return result
+}
+
+func normalizeMethod(method *dao.PaymentMethodConfig) error {
+	method.Code = strings.ToLower(strings.TrimSpace(method.Code))
+	method.Name = strings.TrimSpace(method.Name)
+	method.InputMode = strings.TrimSpace(method.InputMode)
+	method.Description = strings.TrimSpace(method.Description)
+	if !paymentCodePattern.MatchString(method.Code) {
+		return errors.New("支付编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
+	}
+	if method.Name == "" || len([]rune(method.Name)) > 50 {
+		return errors.New("支付方式名称不能为空且不能超过50个字符")
+	}
+	validModes := map[string]bool{InputModeManualAmount: true, InputModeConfirm: true, InputModeExternal: true}
+	if !validModes[method.InputMode] {
+		return errors.New("无效的金额处理方式")
+	}
+	method.EntryCodes = normalizeCodes(method.EntryCodes)
+	// Scenes is only read during migration. Do not use it as a runtime
+	// authorization source after payment entries have been configured.
+	method.Scenes = normalizeCodes(method.Scenes)
+	return nil
+}
+
+func (s *PaymentService) CreateMethod(method *dao.PaymentMethodConfig) error {
+	if err := normalizeMethod(method); err != nil {
+		return err
+	}
+	method.IsSystem = false
+	err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := s.repo.CreateMethodTx(tx, method); err != nil {
+			if strings.Contains(strings.ToLower(err.Error()), "unique") {
+				return errors.New("支付编码已存在")
+			}
+			return fmt.Errorf("新增支付方式失败: %w", err)
+		}
+		if len(method.EntryCodes) > 0 {
+			if err := s.validateMethodEntryCodes(tx, method.EntryCodes); err != nil {
+				return err
+			}
+			return s.repo.ReplaceMethodEntries(tx, method.Code, method.EntryCodes)
+		}
+		return nil
+	})
+	return err
+}
+
+func (s *PaymentService) UpdateMethod(id uint, updates *dao.PaymentMethodConfig) error {
+	current, err := s.repo.GetMethodByID(id)
+	if err != nil {
+		return errors.New("支付方式不存在")
+	}
+	updates.Code = current.Code
+	updates.IsSystem = current.IsSystem
+	updates.EntryCodes = nil
+	if current.Code == PaymentCash {
+		updates.InputMode = InputModeManualAmount
+		updates.Enabled = true
+	}
+	if err := normalizeMethod(updates); err != nil {
+		return err
+	}
+	updates.ID = current.ID
+	return s.repo.UpdateMethod(updates)
+}
+
+func (s *PaymentService) SetMethodEnabled(id uint, enabled bool) error {
+	method, err := s.repo.GetMethodByID(id)
+	if err != nil {
+		return errors.New("支付方式不存在")
+	}
+	if method.Code == PaymentCash && !enabled {
+		return errors.New("人工支付是基础功能,不能停用")
+	}
+	return s.repo.SetMethodEnabled(id, enabled)
+}
+
+func (s *PaymentService) DeleteMethod(id uint) error {
+	method, err := s.repo.GetMethodByID(id)
+	if err != nil {
+		return errors.New("支付方式不存在")
+	}
+	if method.IsSystem {
+		return errors.New("系统内置支付方式不能删除")
+	}
+	return s.repo.DeleteMethod(method)
+}
+
+func normalizeEntry(entry *dao.PaymentEntryConfig) error {
+	entry.Code = strings.ToLower(strings.TrimSpace(entry.Code))
+	entry.Name = strings.TrimSpace(entry.Name)
+	entry.Description = strings.TrimSpace(entry.Description)
+	if !paymentCodePattern.MatchString(entry.Code) {
+		return errors.New("支付入口编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
+	}
+	if entry.Name == "" || len([]rune(entry.Name)) > 50 {
+		return errors.New("支付入口名称不能为空且不能超过50个字符")
+	}
+	entry.MethodCodes = normalizeCodes(entry.MethodCodes)
+	if !containsScene(entry.MethodCodes, PaymentCash) {
+		entry.MethodCodes = append([]string{PaymentCash}, entry.MethodCodes...)
+	}
+	return nil
+}
+
+func (s *PaymentService) validateEntryCodes(db *gorm.DB, codes []string) error {
+	for _, code := range codes {
+		method, err := s.repo.GetMethodByCode(db, code)
+		if err != nil || method.IsLegacy {
+			return fmt.Errorf("支付方式“%s”不存在或不可用", code)
+		}
+	}
+	return nil
+}
+
+func (s *PaymentService) validateMethodEntryCodes(db *gorm.DB, codes []string) error {
+	for _, code := range codes {
+		if _, err := s.repo.GetEntryByCode(db, code); err != nil {
+			return fmt.Errorf("支付入口“%s”不存在", code)
+		}
+	}
+	return nil
+}
+
+func (s *PaymentService) CreateEntry(entry *dao.PaymentEntryConfig) error {
+	if err := normalizeEntry(entry); err != nil {
+		return err
+	}
+	if err := s.validateEntryCodes(global.GVA_DB, entry.MethodCodes); err != nil {
+		return err
+	}
+	entry.IsSystem = false
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := s.repo.CreateEntryTx(tx, entry); err != nil {
+			if strings.Contains(strings.ToLower(err.Error()), "unique") {
+				return errors.New("支付入口编码已存在")
+			}
+			return fmt.Errorf("新增支付入口失败: %w", err)
+		}
+		return s.repo.ReplaceEntryMethods(tx, entry.Code, entry.MethodCodes)
+	})
+}
+
+func (s *PaymentService) UpdateEntry(id uint, updates *dao.PaymentEntryConfig) error {
+	current, err := s.repo.GetEntryByID(id)
+	if err != nil {
+		return errors.New("支付入口不存在")
+	}
+	updates.Code = current.Code
+	updates.IsSystem = current.IsSystem
+	if err := normalizeEntry(updates); err != nil {
+		return err
+	}
+	if err := s.validateEntryCodes(global.GVA_DB, updates.MethodCodes); err != nil {
+		return err
+	}
+	updates.ID = current.ID
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := s.repo.UpdateEntryTx(tx, updates); err != nil {
+			return err
+		}
+		return s.repo.ReplaceEntryMethods(tx, current.Code, updates.MethodCodes)
+	})
+}
+
+func (s *PaymentService) SetEntryEnabled(id uint, enabled bool) error {
+	if _, err := s.repo.GetEntryByID(id); err != nil {
+		return errors.New("支付入口不存在")
+	}
+	return s.repo.SetEntryEnabled(id, enabled)
+}
+
+func (s *PaymentService) DeleteEntry(id uint) error {
+	entry, err := s.repo.GetEntryByID(id)
+	if err != nil {
+		return errors.New("支付入口不存在")
+	}
+	if entry.IsSystem {
+		return errors.New("系统内置支付入口不能删除")
+	}
+	return s.repo.DeleteEntry(entry)
+}
+
+func containsScene(scenes []string, scene string) bool {
+	for _, item := range scenes {
+		if item == scene {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *PaymentService) ValidateMethod(db *gorm.DB, code, entryCode string) (*dao.PaymentMethodConfig, error) {
+	if code == PaymentFree {
+		if entryCode != EntryCounter {
+			return nil, errors.New("免密放行不能用于当前支付场景")
+		}
+		entry, err := s.repo.GetEntryByCode(db, entryCode)
+		if err == nil && !entry.Enabled {
+			return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
+		}
+		if err != nil && !strings.Contains(strings.ToLower(err.Error()), "no such table") {
+			return nil, errors.New("支付入口不存在")
+		}
+		return &dao.PaymentMethodConfig{Code: PaymentFree, Name: "免密放行", InputMode: InputModeConfirm, Enabled: true}, nil
+	}
+	entry, entryErr := s.repo.GetEntryByCode(db, entryCode)
+	method, methodErr := s.repo.GetMethodByCode(db, code)
+	if methodErr != nil {
+		return nil, errors.New("支付方式不存在")
+	}
+	if !method.Enabled {
+		return nil, fmt.Errorf("支付方式“%s”已停用", method.Name)
+	}
+	if entryErr != nil {
+		if strings.Contains(strings.ToLower(entryErr.Error()), "no such table") && methodErr == nil {
+			if containsScene(method.Scenes, entryCode) {
+				return method, nil
+			}
+			return nil, fmt.Errorf("支付方式“%s”不适用于当前场景", method.Name)
+		}
+		return nil, errors.New("支付入口不存在")
+	}
+	if !entry.Enabled {
+		return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
+	}
+	allowed, err := s.repo.IsMethodAllowed(db, entryCode, code)
+	if err != nil {
+		if strings.Contains(strings.ToLower(err.Error()), "no such table") && containsScene(method.Scenes, entryCode) {
+			return method, nil
+		}
+		return nil, err
+	}
+	if !allowed {
+		return nil, fmt.Errorf("支付方式“%s”未配置到当前支付入口", method.Name)
+	}
+	return method, nil
+}
+
+func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, amount, paidAmount float64) (*dao.PaymentMethodConfig, float64, error) {
+	method, err := s.ValidateMethod(db, code, entryCode)
+	if err != nil {
+		return nil, 0, err
+	}
+	if amount < 0 || paidAmount < 0 {
+		return nil, 0, errors.New("支付金额不能为负数")
+	}
+	if code == PaymentFree {
+		return method, 0, nil
+	}
+	if method.InputMode == InputModeManualAmount {
+		if paidAmount < amount {
+			return nil, 0, errors.New("实收金额不足")
+		}
+		return method, paidAmount, nil
+	}
+	return method, amount, nil
+}
+
 // 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)
+}
+
+// ProcessPaymentTx keeps the pre-split argument order for callers compiled
+// against the old service. The final argument is now interpreted as the
+// payment entry.
+func (s *PaymentService) ProcessPaymentTx(db *gorm.DB, recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint, paymentEntry string) (*dao.PaymentRecord, error) {
+	return s.ProcessPaymentTxAtEntry(db, recordID, paymentEntry, paymentMethod, amount, paidAmount, operatorID)
+}
+
+func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, paymentEntry string, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
+	method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount)
+	if err != nil {
+		return nil, err
+	}
 	changeAmount := 0.0
-	if paymentMethod == PaymentCash && paidAmount > amount {
+	if method.InputMode == InputModeManualAmount && paidAmount > amount {
 		changeAmount = paidAmount - amount
 	}
 
 	now := time.Now()
 	pr := &dao.PaymentRecord{
 		RecordID:      recordID,
+		PaymentEntry:  paymentEntry,
 		PaymentMethod: paymentMethod,
 		Amount:        amount,
 		PaidAmount:    paidAmount,
@@ -43,15 +405,18 @@ func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amo
 		PaidAt:        now,
 	}
 
-	if err := s.repo.Create(pr); err != nil {
+	if err := db.Create(pr).Error; err != nil {
 		return nil, fmt.Errorf("创建支付记录失败: %w", err)
 	}
 
 	// Update vehicle_record payment status
-	global.GVA_DB.Model(&dao.VehicleRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
+	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
 }

+ 97 - 0
internal/modules/payment/service/service_test.go

@@ -0,0 +1,97 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupPaymentServiceTest(t *testing.T) *PaymentService {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}))
+	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},
+		{Code: PaymentTicketQR, Name: "小票扫码支付", InputMode: InputModeExternal, Scenes: []string{SceneTicket}, Enabled: true, Sort: 30, IsSystem: true},
+	}).Error)
+	require.NoError(t, db.Create(&[]dao.PaymentEntryConfig{
+		{Code: EntryCounter, Name: "收费岗亭", Enabled: true, IsSystem: true},
+		{Code: EntryTicket, Name: "停车小票", Enabled: true, IsSystem: true},
+	}).Error)
+	require.NoError(t, db.Create(&[]dao.PaymentEntryMethod{
+		{EntryCode: EntryCounter, MethodCode: PaymentCash, Sort: 10},
+		{EntryCode: EntryCounter, MethodCode: PaymentPOS, Sort: 20},
+		{EntryCode: EntryTicket, MethodCode: PaymentCash, Sort: 10},
+		{EntryCode: EntryTicket, MethodCode: PaymentPOS, Sort: 20},
+		{EntryCode: EntryTicket, MethodCode: PaymentTicketQR, Sort: 30},
+	}).Error)
+	return NewPaymentService()
+}
+
+func TestCashCannotBeDisabledOrDeleted(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	method, err := svc.repo.GetMethodByCode(nil, PaymentCash)
+	require.NoError(t, err)
+
+	require.EqualError(t, svc.SetMethodEnabled(method.ID, false), "人工支付是基础功能,不能停用")
+	require.EqualError(t, svc.DeleteMethod(method.ID), "系统内置支付方式不能删除")
+}
+
+func TestPaymentMethodSceneAndAmountValidation(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+
+	_, _, err := svc.ValidatePayment(nil, PaymentTicketQR, SceneCounter, 10, 10)
+	require.EqualError(t, err, "支付方式“小票扫码支付”未配置到当前支付入口")
+
+	_, _, err = svc.ValidatePayment(nil, PaymentCash, SceneCounter, 10, 9.9)
+	require.EqualError(t, err, "实收金额不足")
+
+	_, paidAmount, err := svc.ValidatePayment(nil, PaymentPOS, SceneCounter, 10, 999)
+	require.NoError(t, err)
+	require.Equal(t, 10.0, paidAmount)
+
+	_, paidAmount, err = svc.ValidatePayment(nil, PaymentFree, SceneCounter, 10, 0)
+	require.NoError(t, err)
+	require.Zero(t, paidAmount)
+
+	_, _, err = svc.ValidatePayment(nil, PaymentFree, SceneTicket, 10, 0)
+	require.EqualError(t, err, "免密放行不能用于当前支付场景")
+}
+
+func TestDisabledAndInvalidMethodsAreRejected(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	method, err := svc.repo.GetMethodByCode(nil, PaymentPOS)
+	require.NoError(t, err)
+	require.NoError(t, svc.SetMethodEnabled(method.ID, false))
+
+	_, _, err = svc.ValidatePayment(nil, PaymentPOS, SceneCounter, 10, 10)
+	require.EqualError(t, err, "支付方式“POS机支付”已停用")
+
+	err = svc.CreateMethod(&dao.PaymentMethodConfig{Code: "Bad Code", Name: "测试", InputMode: InputModeConfirm, Scenes: []string{SceneCounter}})
+	require.ErrorContains(t, err, "支付编码必须")
+}
+
+func TestCustomMethodsAreOrderedAndDuplicateCodesRejected(t *testing.T) {
+	svc := setupPaymentServiceTest(t)
+	custom := &dao.PaymentMethodConfig{
+		Code: "bank_card", Name: "银行卡", InputMode: InputModeConfirm,
+		EntryCodes: []string{EntryCounter}, Enabled: true, Sort: 5,
+	}
+	require.NoError(t, svc.CreateMethod(custom))
+
+	methods, err := svc.ListEnabledMethods(SceneCounter)
+	require.NoError(t, err)
+	require.Equal(t, "bank_card", methods[0].Code)
+
+	duplicate := &dao.PaymentMethodConfig{
+		Code: "bank_card", Name: "重复编码", InputMode: InputModeConfirm,
+		EntryCodes: []string{EntryCounter}, Enabled: true,
+	}
+	require.EqualError(t, svc.CreateMethod(duplicate), "支付编码已存在")
+}

+ 40 - 17
internal/modules/printer/api.go

@@ -58,10 +58,10 @@ func TicketMachineButton(c *gin.Context) {
 	}
 
 	response.OkWithData(gin.H{
-		"ticket_id":   tickets[0].ID,
-		"ticket_no":   tickets[0].TicketNo,
-		"slip_no":     tickets[0].ID,
-		"entry_time":  entryResp.Data.EntryTime,
+		"ticket_id":    tickets[0].ID,
+		"ticket_no":    tickets[0].TicketNo,
+		"slip_no":      tickets[0].ID,
+		"entry_time":   entryResp.Data.EntryTime,
 		"plate_number": entryResp.Data.PlateNumber,
 	}, c)
 }
@@ -73,10 +73,22 @@ func SetupPrinterRouter(router *gin.RouterGroup) {
 // SetupPrinterPublic 公开测试路由(无需JWT)
 func SetupPrinterPublic(router *gin.RouterGroup) {
 	router.GET("/ticket-machine/test", TicketMachineButtonTest)
+	router.GET("/ticket-machine/debug", func(c *gin.Context) {
+		printService.InitCSN(printService.FindCsnDllPath())
+		printService.Csn_EnumUSB()
+		printService.Csn_EnumPRN()
+		h, e := printService.Csn_OpenUSB("VID_0FE6&PID_811E#USB001")
+		printService.Csn_SetPort(h)
+		testData := []byte{0x1B, 0x40, 'T', 'E', 'S', 'T', ' ', 'P', 'R', 'I', 'N', 'T', '\n', '\n', '\n', '\n', 0x1B, 0x69}
+		n := printService.Csn_WriteData(testData)
+		response.OkWithData(gin.H{
+			"handle": h, "err": fmt.Sprint(e), "bytes_written": n,
+		}, c)
+	})
 	router.GET("/ticket-machine/usb-list", func(c *gin.Context) {
-		printService.InitCSN(`D:\lq\资料\停车场 道闸 车牌识别 收费机\票机自制\打印机\EMD807嵌入式热敏票据打印模组\5、SDK指令集\Win_SDK_Demo_2023_08_14\Win_SDK_Demo_2023_08_14\lib\x64\CsnPrinterLibs.dll`)
-		usb := printService.Csn_EnumUSB()
-		prn := printService.Csn_EnumPRN()
+		printService.InitCSN(printService.FindCsnDllPath())
+		usb := printService.Csn_EnumUSBNames()
+		prn := printService.Csn_EnumPRNNames()
 		response.OkWithData(gin.H{"usb": usb, "prn": prn}, c)
 	})
 }
@@ -99,23 +111,34 @@ func TicketMachineButtonTest(c *gin.Context) {
 		return
 	}
 
+	// 从入场记录获取真实 ParkingLotID,再查停车场名
+	var rec dao.VehicleRecord
+	if err := global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").First(&rec).Error; err != nil {
+		response.FailWithMessage("查入场记录失败: "+err.Error(), c)
+		return
+	}
 	var lot dao.ParkingLot
-	global.GVA_DB.First(&lot, entryResp.Data.ParkingLotID)
+	lot.ID = rec.ParkingLotID
+	global.GVA_DB.First(&lot)
+	lotName := lot.LotName
+	if lotName == "" {
+		lotName = fmt.Sprintf("Lot#%d", rec.ParkingLotID)
+	}
 
 	// 尝试打印
 	printErr := ""
-	if err := printSvc.PrintTicket(1, lot.LotName, "IN01", entryResp.Data.PlateNumber, tickets[0].ID, tickets[0].TicketNo); err != nil {
+	if err := printSvc.PrintTicket(1, lotName, "IN01", entryResp.Data.PlateNumber, tickets[0].ID, tickets[0].TicketNo); err != nil {
 		printErr = err.Error()
 	}
 
 	response.OkWithData(gin.H{
-		"ticket_id":     tickets[0].ID,
-		"ticket_no":     tickets[0].TicketNo,
-		"slip_no":       tickets[0].ID,
-		"plate_number":  entryResp.Data.PlateNumber,
-		"entry_time":    entryResp.Data.EntryTime,
-		"lot_name":      lot.LotName,
-		"channel_code":  "IN01",
-		"print_result":  printErr,
+		"ticket_id":    tickets[0].ID,
+		"ticket_no":    tickets[0].TicketNo,
+		"slip_no":      tickets[0].ID,
+		"plate_number": entryResp.Data.PlateNumber,
+		"entry_time":   entryResp.Data.EntryTime,
+		"lot_name":     lotName,
+		"channel_code": "IN01",
+		"print_result": printErr,
 	}, c)
 }

+ 87 - 21
internal/modules/printer/service/csn_dll.go

@@ -2,14 +2,67 @@ package service
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"syscall"
 	"unsafe"
 )
 
 // CSN Printer SDK — Go wrapper for CsnPrinterLibs.dll (x64)
 
+// FindCsnDllPath 按优先级探测 CsnPrinterLibs.dll 位置:
+// 1. exe 同目录(生产环境:build/bin/ 与 smart-parking.exe 一起分发)
+// 2. 当前工作目录
+// 3. 项目相对路径(wails dev 时 cwd 为 lc_garage/)
+// 4. 开发机原始绝对路径(兜底)
+func FindCsnDllPath() string {
+	exePath, _ := os.Executable()
+	candidates := []string{
+		filepath.Join(filepath.Dir(exePath), "CsnPrinterLibs.dll"),
+		"CsnPrinterLibs.dll",
+		"build/bin/CsnPrinterLibs.dll",
+		`D:\lq\资料\停车场 道闸 车牌识别 收费机\票机自制\打印机\EMD807嵌入式热敏票据打印模组\5、SDK指令集\Win_SDK_Demo_2023_08_14\Win_SDK_Demo_2023_08_14\lib\x64\CsnPrinterLibs.dll`,
+	}
+	for _, p := range candidates {
+		if _, err := os.Stat(p); err == nil {
+			return p
+		}
+	}
+	return candidates[0]
+}
+
 var csnDll *syscall.LazyDLL
 
+// parseCSNPortNames parses the SDK's double-NUL-terminated port list.
+// Port_EnumUSB and Port_EnumPRN return the number of entries, not the byte length.
+func parseCSNPortNames(buffer []byte) []string {
+	var names []string
+	for start := 0; start < len(buffer); {
+		end := start
+		for end < len(buffer) && buffer[end] != 0 {
+			end++
+		}
+		if end == start {
+			break
+		}
+		names = append(names, string(buffer[start:end]))
+		start = end + 1
+	}
+	return names
+}
+
+func csnEnumPortNames(procName string) []string {
+	if csnDll == nil {
+		return nil
+	}
+	buf := make([]byte, 256)
+	ret, _, _ := csnDll.NewProc(procName).Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
+	if ret == 0 {
+		return nil
+	}
+	return parseCSNPortNames(buf)
+}
+
 func InitCSN(dllPath string) error {
 	csnDll = syscall.NewLazyDLL(dllPath)
 	// Test load
@@ -22,22 +75,30 @@ func InitCSN(dllPath string) error {
 // === Port functions ===
 
 func Csn_EnumUSB() string {
-	if csnDll == nil { return "" }
-	buf := make([]byte, 256)
-	ret, _, _ := csnDll.NewProc("Port_EnumUSB").Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
-	if ret == 0 { return "" }
-	return string(buf[:ret])
+	names := csnEnumPortNames("Port_EnumUSB")
+	if len(names) == 0 {
+		return ""
+	}
+	return names[0]
 }
 
 func Csn_EnumPRN() string {
-	if csnDll == nil { return "" }
-	buf := make([]byte, 256)
-	ret, _, _ := csnDll.NewProc("Port_EnumPRN").Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
-	if ret == 0 { return "" }
-	return string(buf[:ret])
+	names := csnEnumPortNames("Port_EnumPRN")
+	if len(names) == 0 {
+		return ""
+	}
+	return names[0]
+}
+
+func Csn_EnumUSBNames() []string {
+	return csnEnumPortNames("Port_EnumUSB")
+}
+
+func Csn_EnumPRNNames() []string {
+	return csnEnumPortNames("Port_EnumPRN")
 }
 
-func csn_OpenUSB(name string) (uintptr, error) {
+func Csn_OpenUSB(name string) (uintptr, error) {
 	if csnDll == nil {
 		return 0, fmt.Errorf("DLL 未加载")
 	}
@@ -50,7 +111,7 @@ func csn_OpenUSB(name string) (uintptr, error) {
 	return ret, nil
 }
 
-func csn_OpenPRN(name string) (uintptr, error) {
+func Csn_OpenPRN(name string) (uintptr, error) {
 	if csnDll == nil {
 		return 0, fmt.Errorf("DLL 未加载")
 	}
@@ -63,7 +124,7 @@ func csn_OpenPRN(name string) (uintptr, error) {
 	return ret, nil
 }
 
-func csn_SetPort(handle uintptr) {
+func Csn_SetPort(handle uintptr) {
 	if csnDll != nil && handle != 0 {
 		csnDll.NewProc("Port_SetPort").Call(handle)
 	}
@@ -109,7 +170,9 @@ func csn_FullCut() {
 
 // WriteData 直接发送 RAW 字节到当前激活的打印机端口
 func Csn_WriteData(data []byte) int {
-	if csnDll == nil { return 0 }
+	if csnDll == nil {
+		return 0
+	}
 	ret, _, _ := csnDll.NewProc("WriteData").Call(
 		uintptr(unsafe.Pointer(&data[0])),
 		uintptr(len(data)),
@@ -117,7 +180,9 @@ func Csn_WriteData(data []byte) int {
 	return int(ret)
 }
 
-func csn_Text(text string, widthTimes, heightTimes, fontType, fontStyle int) {
+// csn_Text 打印一行文字
+// nOrgx: -1=左对齐, -2=居中, -3=右对齐
+func csn_Text(text string, widthTimes, heightTimes, fontType, fontStyle, nOrgx int) {
 	if csnDll == nil {
 		return
 	}
@@ -125,8 +190,8 @@ func csn_Text(text string, widthTimes, heightTimes, fontType, fontStyle int) {
 	wstr, _ := syscall.UTF16FromString(text)
 	csnDll.NewProc("Pos_Text").Call(
 		uintptr(unsafe.Pointer(&wstr[0])),
-		0, // nLan: 0=default
-		0, // nOrgx
+		0, // nLan: 0=GBK
+		uintptr(nOrgx),
 		uintptr(widthTimes),
 		uintptr(heightTimes),
 		uintptr(fontType),
@@ -134,14 +199,15 @@ func csn_Text(text string, widthTimes, heightTimes, fontType, fontStyle int) {
 	csn_FeedLine()
 }
 
-func csn_QRCode(data string, width int) {
+func csn_QRCode(data string, width int) bool {
 	if csnDll == nil {
-		return
+		return false
 	}
 	wstr, _ := syscall.UTF16FromString(data)
-	csnDll.NewProc("Pos_Qrcode").Call(
+	ret, _, _ := csnDll.NewProc("Pos_EscQrcode").Call(
 		uintptr(unsafe.Pointer(&wstr[0])),
-		uintptr(width), 0, 4) // nVersion=0(auto), nErrLevel=4
+		uintptr(width), 4) // nWidth, nErrLevel=4
+	return ret != 0
 }
 
 func csn_Barcode(data string, barcodeType int, unitWidth, unitHeight, fontStyle, fontPos int) {

+ 24 - 0
internal/modules/printer/service/csn_dll_test.go

@@ -0,0 +1,24 @@
+package service
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestParseCSNPortNamesUsesNulSeparatedBuffer(t *testing.T) {
+	buffer := []byte("VID_0FE6&PID_811E#USB001\x00POS-Printer\x00\x00")
+	got := parseCSNPortNames(buffer)
+	want := []string{"VID_0FE6&PID_811E#USB001", "POS-Printer"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("parsed names = %#v, want %#v", got, want)
+	}
+}
+
+func TestParseCSNPortNamesStopsAtEmptyEntry(t *testing.T) {
+	buffer := []byte("USB001\x00\x00stale-data")
+	got := parseCSNPortNames(buffer)
+	want := []string{"USB001"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("parsed names = %#v, want %#v", got, want)
+	}
+}

+ 49 - 65
internal/modules/printer/service/service.go

@@ -7,12 +7,17 @@ import (
 	"wails-app/internal/global"
 )
 
-const sdkDllPath = `D:\lq\资料\停车场 道闸 车牌识别 收费机\票机自制\打印机\EMD807嵌入式热敏票据打印模组\5、SDK指令集\Win_SDK_Demo_2023_08_14\Win_SDK_Demo_2023_08_14\lib\x64\CsnPrinterLibs.dll`
-
 type PrintService struct{}
 
 func NewPrintService() *PrintService { return &PrintService{} }
 
+type csnUSBDevice struct{}
+
+func (csnUSBDevice) Write(data []byte) int              { return Csn_WriteData(data) }
+func (csnUSBDevice) FeedLines(n int)                    { csn_FeedLines(n) }
+func (csnUSBDevice) QRCode(data string, width int) bool { return csn_QRCode(data, width) }
+func (csnUSBDevice) FullCut()                           { csn_FullCut() }
+
 func (s *PrintService) PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error {
 	var printer dao.Printer
 	if err := global.GVA_DB.First(&printer, printerID).Error; err != nil {
@@ -31,22 +36,22 @@ func (s *PrintService) PrintTicket(printerID uint, lotName, channelCode, plateNo
 		}
 	} else {
 		// USB → CSN SDK 直连
-		err = InitCSN(sdkDllPath)
+		err = InitCSN(FindCsnDllPath())
 		if err != nil {
 			return fmt.Errorf("加载 CSN DLL 失败: %w", err)
 		}
-		Csn_EnumUSB() // USB 子系统初始化
+		Csn_EnumUSB()
 		Csn_EnumPRN()
 		name := printer.PortName
-		usbHandle, err = csn_OpenUSB(name)
+		usbHandle, err = Csn_OpenUSB(name)
 		if err != nil {
-			usbHandle, err = csn_OpenPRN(name)
+			usbHandle, err = Csn_OpenPRN(name)
 		}
 		if err != nil {
 			return fmt.Errorf("打开打印机失败: %w (name=%s)", err, name)
 		}
 		useCSN = true
-		csn_SetPort(usbHandle)
+		Csn_SetPort(usbHandle)
 		defer csn_Close(usbHandle)
 	}
 
@@ -54,77 +59,56 @@ func (s *PrintService) PrintTicket(printerID uint, lotName, channelCode, plateNo
 	slipNo := fmt.Sprintf("%d", ticketID)
 
 	if useCSN {
-		var buf []byte
-		add := func(b []byte) { buf = append(buf, b...) }
-		line := "--------------------------------"
-
-		// 标题
-		add(InitCmd)
-		add(AlignCenterCmd); add(DoubleHeightCmd); add(BoldOnCmd)
-		add(ESCPOS_Text("PARKING TICKET"))
-		add(BoldOffCmd); add(NormalCmd)
-		add(ESCPOS_Text(""))
-
-		// 停车场
-		add(ESCPOS_Text(lotName))
-		add(ESCPOS_Text("A-General Car"))
-		add(ESCPOS_Text(""))
-
-		// 风险提示
-		add(BoldOnCmd)
-		add(ESCPOS_Text("PARK AT YOUR OWN RISK"))
-		add(BoldOffCmd)
-		add(ESCPOS_Text(line))
-
-		// 入场信息
-		add(AlignLeftCmd)
-		add(ESCPOS_Text("In-Time::" + now))
-		if channelCode != "" { add(ESCPOS_Text("InGate::" + channelCode)) }
-		if plateNo != "" { add(ESCPOS_Text("Veh No::" + plateNo)) }
-		add(ESCPOS_Text(line))
-		add(ESCPOS_Text("Slip No::" + slipNo))
-		add(ESCPOS_Text(line))
-
-		// 二维码
-		add(ESCPOS_Text(""))
-		add(AlignCenterCmd)
-		add(ESCPOS_QRCode(ticketNo))
-		add(ESCPOS_Text(""))
-
-		// 支付提示
-		add(ESCPOS_Text("SCAN AND PAY WITH"))
-		add(ESCPOS_Text("NEW THE CANADIA BANK APP"))
-
-		// 切纸
-		add(InitCmd)
-		add(ESCPOS_Text("")); add(ESCPOS_Text("")); add(ESCPOS_Text(""))
-		add(CutPaperCmd)
+		ops := buildUSBTicketLayout(usbTicketData{
+			lotName: lotName, channelCode: channelCode, plateNo: plateNo,
+			entryTime: now, slipNo: slipNo, ticketNo: ticketNo,
+		})
+		if err := executeUSBLayout(csnUSBDevice{}, ops); err != nil {
+			return err
+		}
 
-		Csn_WriteData(buf)
 		global.GVA_DB.Model(&printer).Update("status", "online")
 		return nil
 	}
 
-	// 串口模式
+	// === 串口模式 (保持不变) ===
 	write := func(data []byte) { srl.Write(data) }
 	line32 := "================================"
-	write(InitCmd); write(AlignCenterCmd); write(DoubleHeightCmd); write(BoldOnCmd)
+	write(InitCmd)
+	write(AlignCenterCmd)
+	write(DoubleHeightCmd)
+	write(BoldOnCmd)
 	write(ESCPOS_Text("PARKING TICKET"))
-	write(BoldOffCmd); write(NormalCmd)
-	write(ESCPOS_Text("")); write(ESCPOS_Text(lotName))
-	write(ESCPOS_Text("A-General Car")); write(ESCPOS_Text(""))
-	write(ESCPOS_Text("PARK AT YOUR OWN RISK")); write(ESCPOS_Text(line32))
+	write(BoldOffCmd)
+	write(NormalCmd)
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text(lotName))
+	write(ESCPOS_Text("A-General Car"))
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text("PARK AT YOUR OWN RISK"))
+	write(ESCPOS_Text(line32))
 	write(AlignLeftCmd)
 	write(ESCPOS_Text("In-Time::" + now))
-	if channelCode != "" { write(ESCPOS_Text("InGate::" + channelCode)) }
-	if plateNo != "" { write(ESCPOS_Text("Veh No::" + plateNo)) }
-	write(ESCPOS_Text(line32)); write(ESCPOS_Text("Slip No::" + slipNo)); write(ESCPOS_Text(line32))
-	write(AlignCenterCmd); write(ESCPOS_Text(""))
-	write(ESCPOS_QRCode(ticketNo)); write(ESCPOS_Text(""))
+	if channelCode != "" {
+		write(ESCPOS_Text("InGate::" + channelCode))
+	}
+	if plateNo != "" {
+		write(ESCPOS_Text("Veh No::" + plateNo))
+	}
+	write(ESCPOS_Text(line32))
+	write(ESCPOS_Text("Slip No::" + slipNo))
+	write(ESCPOS_Text(line32))
+	write(AlignCenterCmd)
+	write(ESCPOS_Text(""))
+	write(ESCPOS_QRCode(ticketNo))
+	write(ESCPOS_Text(""))
 	write(AlignCenterCmd)
 	write(ESCPOS_Text("SCAN AND PAY WITH"))
 	write(ESCPOS_Text("NEW THE CANADIA BANK APP"))
-	write(InitCmd); write(ESCPOS_Text("")); write(ESCPOS_Text("")); write(ESCPOS_Text(""))
+	write(InitCmd)
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text(""))
 	write(CutPaperCmd)
 
 	global.GVA_DB.Model(&printer).Update("status", "online")

+ 128 - 0
internal/modules/printer/service/usb_layout.go

@@ -0,0 +1,128 @@
+package service
+
+import (
+	"fmt"
+	"strings"
+)
+
+type usbPrintOpKind uint8
+
+const (
+	usbOpWrite usbPrintOpKind = iota
+	usbOpFeed
+	usbOpQRCode
+	usbOpCut
+)
+
+type usbPrintOp struct {
+	kind    usbPrintOpKind
+	data    []byte
+	count   int
+	qrData  string
+	qrWidth int
+}
+
+type usbTicketData struct {
+	lotName, channelCode, plateNo string
+	entryTime, slipNo, ticketNo   string
+}
+
+var (
+	usbSizeNormal   = []byte{GS, '!', 0x00}
+	usbDoubleWidth  = []byte{GS, '!', 0x10}
+	usbFontA        = []byte{ESC, 'M', 0}
+	usbFontB        = []byte{ESC, 'M', 1}
+	usbDirectionOff = []byte{ESC, '{', 0}
+)
+
+func buildUSBTicketLayout(data usbTicketData) []usbPrintOp {
+	var ops []usbPrintOp
+	write := func(data ...byte) {
+		ops = append(ops, usbPrintOp{kind: usbOpWrite, data: append([]byte(nil), data...)})
+	}
+	text := func(value string) {
+		write(append([]byte(value), '\n')...)
+	}
+	feed := func(count int) {
+		ops = append(ops, usbPrintOp{kind: usbOpFeed, count: count})
+	}
+
+	write(ESC, '@')
+	write(usbDirectionOff...)
+	write(ESC, 'a', 1)
+	write(usbFontA...)
+	write(usbDoubleWidth...)
+	write(BoldOnCmd...)
+	text("Parking Ticket")
+	write(usbSizeNormal...)
+	write(BoldOffCmd...)
+	write(usbFontB...)
+	text(data.lotName)
+	text(strings.Repeat("-", 42))
+	text("")
+	text("PARK AT YOUR OWN RISK")
+
+	write(ESC, 'a', 0)
+	lotAndType := "A-General Car"
+	if data.lotName != "" {
+		lotAndType = data.lotName + " A-General Car"
+	}
+	text(lotAndType)
+	write(usbFontA...)
+	text("In-Time::" + data.entryTime)
+	if data.channelCode != "" {
+		text("InGate::" + data.channelCode)
+	}
+	text("Slip No::" + data.slipNo)
+	if data.plateNo != "" {
+		write(usbDoubleWidth...)
+		write(BoldOnCmd...)
+		text("Veh No::" + data.plateNo)
+		write(usbSizeNormal...)
+		write(BoldOffCmd...)
+	}
+
+	write(ESC, 'a', 1)
+	feed(1)
+	ops = append(ops, usbPrintOp{kind: usbOpQRCode, qrData: data.ticketNo, qrWidth: 12})
+	feed(1)
+	// Pos_EscQrcode can change text state. Restore the settings used by the
+	// centered payment footer before writing any more text.
+	write(usbDirectionOff...)
+	write(ESC, 'a', 1)
+	write(usbFontB...)
+	text("SCAN AND PAY WITH NEW")
+	text("THE CANADIA BANK APP")
+	feed(8)
+	ops = append(ops, usbPrintOp{kind: usbOpCut})
+
+	return ops
+}
+
+type usbPrintDevice interface {
+	Write([]byte) int
+	FeedLines(int)
+	QRCode(string, int) bool
+	FullCut()
+}
+
+func executeUSBLayout(device usbPrintDevice, ops []usbPrintOp) error {
+	for _, op := range ops {
+		switch op.kind {
+		case usbOpWrite:
+			written := device.Write(op.data)
+			if written != len(op.data) {
+				return fmt.Errorf("USB打印短写: 写入%d/%d字节", written, len(op.data))
+			}
+		case usbOpFeed:
+			device.FeedLines(op.count)
+		case usbOpQRCode:
+			if !device.QRCode(op.qrData, op.qrWidth) {
+				return fmt.Errorf("USB二维码打印失败")
+			}
+		case usbOpCut:
+			device.FullCut()
+		}
+	}
+	return nil
+}

+ 245 - 0
internal/modules/printer/service/usb_layout_test.go

@@ -0,0 +1,245 @@
+package service
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestBuildUSBTicketLayoutPrintsTopToBottom(t *testing.T) {
+	data := usbTicketData{
+		lotName:     "KTI AIRPORT",
+		channelCode: "IN11",
+		plateNo:     "2BC1875",
+		entryTime:   "30-07-2026 11:14:59",
+		slipNo:      "54600",
+		ticketNo:    "0123456789abcdef0123456789abcdef",
+	}
+
+	ops := buildUSBTicketLayout(data)
+	texts := usbTextOperations(ops)
+	want := []string{
+		"Parking Ticket\n",
+		"KTI AIRPORT\n",
+		strings.Repeat("-", 42) + "\n",
+		"\n",
+		"PARK AT YOUR OWN RISK\n",
+		"KTI AIRPORT A-General Car\n",
+		"In-Time::30-07-2026 11:14:59\n",
+		"InGate::IN11\n",
+		"Slip No::54600\n",
+		"Veh No::2BC1875\n",
+		"SCAN AND PAY WITH NEW\n",
+		"THE CANADIA BANK APP\n",
+	}
+	if !reflect.DeepEqual(texts, want) {
+		t.Fatalf("USB text order = %#v, want %#v", texts, want)
+	}
+
+	if countText(ops, strings.Repeat("-", 42)+"\n") != 1 {
+		t.Fatalf("expected one 42-character divider, got %d", countText(ops, strings.Repeat("-", 42)+"\n"))
+	}
+
+	qr, ok := findUSBQRCode(ops)
+	if !ok {
+		t.Fatal("expected a QR-code operation")
+	}
+	if qr.qrData != data.ticketNo || qr.qrWidth != 12 {
+		t.Fatalf("QR operation = (%q, %d), want (%q, 12)", qr.qrData, qr.qrWidth, data.ticketNo)
+	}
+	qrIndex := findUSBQRCodeIndex(ops)
+	paymentIndex := findTextIndex(ops, "SCAN AND PAY WITH NEW\n")
+	plateIndex := findTextIndex(ops, "Veh No::2BC1875\n")
+	cutIndex := findUSBOpKindIndex(ops, usbOpCut)
+	if qrIndex < 0 || paymentIndex < 0 || plateIndex < 0 || cutIndex < 0 || !(plateIndex < qrIndex && qrIndex < paymentIndex && paymentIndex < cutIndex) {
+		t.Fatalf("expected plate before QR before payment before cut, got plate=%d QR=%d payment=%d cut=%d", plateIndex, qrIndex, paymentIndex, cutIndex)
+	}
+}
+
+func TestBuildUSBTicketLayoutOmitsOptionalLines(t *testing.T) {
+	ops := buildUSBTicketLayout(usbTicketData{
+		lotName:   "KTI AIRPORT",
+		entryTime: "30-07-2026 11:14:59",
+		slipNo:    "54600",
+		ticketNo:  "ticket-no",
+	})
+
+	texts := usbTextOperations(ops)
+	for _, text := range texts {
+		if strings.HasPrefix(text, "InGate::") || strings.HasPrefix(text, "Veh No::") {
+			t.Fatalf("optional line should be omitted, got %q", text)
+		}
+	}
+}
+
+func TestBuildUSBTicketLayoutUsesDoubleWidthAroundTitleAndPlate(t *testing.T) {
+	ops := buildUSBTicketLayout(usbTicketData{
+		lotName:   "KTI AIRPORT",
+		plateNo:   "2BC1875",
+		entryTime: "30-07-2026 11:14:59",
+		slipNo:    "54600",
+		ticketNo:  "ticket-no",
+	})
+
+	titleIndex := findTextIndex(ops, "Parking Ticket\n")
+	plateIndex := findTextIndex(ops, "Veh No::2BC1875\n")
+	if titleIndex < 2 || plateIndex < 2 {
+		t.Fatalf("title or plate operation is missing")
+	}
+	if !bytesEqual(ops[titleIndex-2].data, []byte{GS, '!', 0x10}) || !bytesEqual(ops[titleIndex+1].data, []byte{GS, '!', 0x00}) {
+		t.Fatalf("title is not bracketed by GS ! double-width/reset operations")
+	}
+	if !bytesEqual(ops[titleIndex-1].data, BoldOnCmd) || !bytesEqual(ops[titleIndex+2].data, BoldOffCmd) {
+		t.Fatalf("title is not bracketed by bold on/off operations")
+	}
+	if !bytesEqual(ops[plateIndex-2].data, []byte{GS, '!', 0x10}) || !bytesEqual(ops[plateIndex+1].data, []byte{GS, '!', 0x00}) {
+		t.Fatalf("plate is not bracketed by GS ! double-width/reset operations")
+	}
+	if !bytesEqual(ops[plateIndex-1].data, BoldOnCmd) || !bytesEqual(ops[plateIndex+2].data, BoldOffCmd) {
+		t.Fatalf("plate is not bracketed by bold on/off operations")
+	}
+}
+
+func TestBuildUSBTicketLayoutUsesNormalTextDirection(t *testing.T) {
+	ops := buildUSBTicketLayout(usbTicketData{
+		lotName:   "Lot#1",
+		plateNo:   "2BC1875",
+		entryTime: "03-08-2026 10:00:00",
+		slipNo:    "1",
+		ticketNo:  "ticket-no",
+	})
+
+	directionOff := findUSBWriteDataIndices(ops, []byte{ESC, '{', 0})
+	directionOn := findUSBWriteDataIndex(ops, []byte{ESC, '{', 1})
+	qrIndex := findUSBQRCodeIndex(ops)
+	paymentIndex := findTextIndex(ops, "SCAN AND PAY WITH NEW\n")
+	cutIndex := findUSBOpKindIndex(ops, usbOpCut)
+	if len(directionOff) < 2 || directionOn >= 0 || qrIndex < 0 || paymentIndex < 0 || cutIndex < 0 {
+		t.Fatalf("missing normal direction/print operations: off=%d on=%d qr=%d payment=%d cut=%d", len(directionOff), directionOn, qrIndex, paymentIndex, cutIndex)
+	}
+	if !(directionOff[0] < qrIndex && qrIndex < directionOff[1] && directionOff[1] < paymentIndex && paymentIndex < cutIndex) {
+		t.Fatalf("normal direction must precede text and be reapplied after QR: off=%v qr=%d payment=%d cut=%d", directionOff, qrIndex, paymentIndex, cutIndex)
+	}
+}
+
+func TestBuildUSBTicketLayoutFeedsPastCutterBeforeCut(t *testing.T) {
+	ops := buildUSBTicketLayout(usbTicketData{ticketNo: "ticket-no"})
+	if len(ops) < 2 || ops[len(ops)-2].kind != usbOpFeed || ops[len(ops)-2].count != 8 || ops[len(ops)-1].kind != usbOpCut {
+		t.Fatalf("ticket must end with an 8-line feed and cut, got %#v", ops[len(ops)-2:])
+	}
+}
+
+func TestExecuteUSBLayoutStopsOnShortWrite(t *testing.T) {
+	device := &recordingUSBDevice{shortWriteAt: 1}
+	ops := []usbPrintOp{
+		{kind: usbOpWrite, data: []byte("first\n")},
+		{kind: usbOpWrite, data: []byte("second\n")},
+		{kind: usbOpQRCode, qrData: "ticket-no", qrWidth: 8},
+	}
+
+	if err := executeUSBLayout(device, ops); err == nil {
+		t.Fatal("expected short-write error")
+	}
+	if len(device.writes) != 1 {
+		t.Fatalf("operations after short write executed: %d writes", len(device.writes))
+	}
+}
+
+func TestExecuteUSBLayoutReturnsQRCodeError(t *testing.T) {
+	device := &recordingUSBDevice{qrOK: false}
+	ops := []usbPrintOp{{kind: usbOpQRCode, qrData: "ticket-no", qrWidth: 8}}
+
+	if err := executeUSBLayout(device, ops); err == nil {
+		t.Fatal("expected QR-code error")
+	}
+}
+
+type recordingUSBDevice struct {
+	writes       [][]byte
+	shortWriteAt int
+	qrOK         bool
+}
+
+func (d *recordingUSBDevice) Write(data []byte) int {
+	d.writes = append(d.writes, append([]byte(nil), data...))
+	if len(d.writes) == d.shortWriteAt {
+		return len(data) - 1
+	}
+	return len(data)
+}
+
+func (d *recordingUSBDevice) FeedLines(int)           {}
+func (d *recordingUSBDevice) QRCode(string, int) bool { return d.qrOK }
+func (d *recordingUSBDevice) FullCut()                {}
+
+func usbTextOperations(ops []usbPrintOp) []string {
+	var texts []string
+	for _, op := range ops {
+		if op.kind == usbOpWrite && len(op.data) > 0 && op.data[len(op.data)-1] == '\n' {
+			texts = append(texts, string(op.data))
+		}
+	}
+	return texts
+}
+
+func countText(ops []usbPrintOp, want string) int {
+	count := 0
+	for _, text := range usbTextOperations(ops) {
+		if text == want {
+			count++
+		}
+	}
+	return count
+}
+
+func findTextIndex(ops []usbPrintOp, want string) int {
+	for i, op := range ops {
+		if op.kind == usbOpWrite && string(op.data) == want {
+			return i
+		}
+	}
+	return -1
+}
+
+func findUSBWriteDataIndex(ops []usbPrintOp, want []byte) int {
+	for i, op := range ops {
+		if op.kind == usbOpWrite && bytesEqual(op.data, want) {
+			return i
+		}
+	}
+	return -1
+}
+
+func findUSBWriteDataIndices(ops []usbPrintOp, want []byte) []int {
+	var indices []int
+	for i, op := range ops {
+		if op.kind == usbOpWrite && bytesEqual(op.data, want) {
+			indices = append(indices, i)
+		}
+	}
+	return indices
+}
+
+func findUSBQRCode(ops []usbPrintOp) (usbPrintOp, bool) {
+	for _, op := range ops {
+		if op.kind == usbOpQRCode {
+			return op, true
+		}
+	}
+	return usbPrintOp{}, false
+}
+
+func findUSBQRCodeIndex(ops []usbPrintOp) int {
+	return findUSBOpKindIndex(ops, usbOpQRCode)
+}
+
+func findUSBOpKindIndex(ops []usbPrintOp, kind usbPrintOpKind) int {
+	for i, op := range ops {
+		if op.kind == kind {
+			return i
+		}
+	}
+	return -1
+}
+
+func bytesEqual(a, b []byte) bool { return reflect.DeepEqual(a, b) }

+ 106 - 60
internal/service/vehicle/vehicle.go

@@ -16,7 +16,7 @@ import (
 
 type VehicleService struct{}
 
-// buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag='' 匹配所有无标签车辆)
+// buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag= 匹配所有无标签车辆)
 func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
 	if rfidTag != "" {
 		return db.Where("plate_number = ? OR rfid_tag = ?", plateNumber, rfidTag)
@@ -207,7 +207,8 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		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()
 	record := dao.VehicleRecord{
 		PlateNumber:    req.PlateNumber,
@@ -219,20 +220,30 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		PaymentStatus:  "unpaid",
 	}
 
-	err = global.GVA_DB.Create(&record).Error
+	ticketSvc := ticketService.NewTicketService()
+	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 {
+			return err
+		}
+		if _, err := ticketSvc.CreateTx(tx, req.PlateNumber, triggerMode, record.ID); err != nil {
+			return fmt.Errorf("创建数字票失败: %w", err)
+		}
+		return tx.Model(&dao.Vehicle{}).Where("id = ?", vehicle.ID).
+			Update("last_entry_time", entryTime).Error
+	})
 	if err != nil {
 		return request.VehicleEntryResponse{}, err
 	}
 
-	// 创建数字票(统一入口)
-	ticketSvc := ticketService.NewTicketService()
-	triggerMode := "manual"
-	if req.RFIDTag != "" { triggerMode = "rfid" }
-	ticketSvc.Create(req.PlateNumber, triggerMode, record.ID)
-	// 更新车辆最近入场时间
-	vehicle.LastEntryTime = &entryTime
-	global.GVA_DB.Save(&vehicle)
-
 	response := request.VehicleEntryResponse{
 		Success: true,
 		Message: "车辆入场成功",
@@ -317,6 +328,11 @@ func (s *VehicleService) ExitPreview(req request.VehicleEntry) (vehicleResponse.
 		stayTime = 0
 	}
 	fee := s.calculateFee(&vehicle, stayTime)
+	var shortlistSvc ShortlistService
+	_, isWhite := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
+	if isWhite {
+		fee = 0
+	}
 
 	typeName := ""
 	isTemp := false
@@ -339,51 +355,81 @@ func (s *VehicleService) ExitPreview(req request.VehicleEntry) (vehicleResponse.
 func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID uint) (vehicleResponse.ExitConfirmResponse, error) {
 	// 黑名单检查
 	var shortlistSvc ShortlistService
-	if isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag); isBlack {
+	isBlack, isWhite := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
+	if isBlack {
 		return vehicleResponse.ExitConfirmResponse{}, errors.New("车辆在黑名单中,禁止出场")
 	}
-	var record dao.VehicleRecord
-	err := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("exit_time IS NULL").First(&record).Error
-	if err != nil {
-		return vehicleResponse.ExitConfirmResponse{}, errors.New("车辆未入场或已出场")
-	}
-	vehicle, err := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
-	if err != nil {
-		return vehicleResponse.ExitConfirmResponse{}, err
+	paymentEntry := req.PaymentEntry
+	if paymentEntry == "" {
+		paymentEntry = paymentService.EntryCounter
 	}
-	exitTime := time.Now()
-	stayTime := int64(exitTime.Sub(record.EntryTime).Minutes())
-	if stayTime < 0 {
-		stayTime = 0
-	}
-	fee := s.calculateFee(&vehicle, stayTime)
-	record.ExitTime = &exitTime
-	record.StayTime = stayTime
-	record.Fee = fee
-	record.PaymentMethod = req.PaymentMethod
-	global.GVA_DB.Save(&record)
-	vehicle.LastExitTime = &exitTime
-	vehicle.TotalStayTime += stayTime
-	vehicle.TotalFee += fee
-	global.GVA_DB.Save(&vehicle)
-	paymentSvc := paymentService.NewPaymentService()
-	pr, err := paymentSvc.ProcessPayment(record.ID, req.PaymentMethod, fee, req.PaidAmount, operatorID)
+	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("车辆未入场或已出场")
+		}
+
+		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("车辆不存在")
+		}
+
+		exitTime := time.Now()
+		stayTime := int64(exitTime.Sub(record.EntryTime).Minutes())
+		if stayTime < 0 {
+			stayTime = 0
+		}
+		fee := s.calculateFee(&vehicle, stayTime)
+		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
+		}
+
+		record.ExitTime = &exitTime
+		record.StayTime = stayTime
+		record.Fee = fee
+		record.PaymentEntry = paymentEntry
+		record.PaymentMethod = req.PaymentMethod
+		if err := tx.Save(&record).Error; err != nil {
+			return err
+		}
+
+		vehicle.LastExitTime = &exitTime
+		vehicle.TotalStayTime += stayTime
+		vehicle.TotalFee += fee
+		if err := tx.Save(&vehicle).Error; err != nil {
+			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 {
+			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,
+		}
+		return nil
+	})
 	if err != nil {
 		return vehicleResponse.ExitConfirmResponse{}, err
 	}
-	changeAmount := 0.0
-	if pr != nil {
-		changeAmount = pr.ChangeAmount
-	}
-	return vehicleResponse.ExitConfirmResponse{
-		PlateNumber:   vehicle.PlateNumber,
-		EntryTime:     record.EntryTime.Unix(),
-		ExitTime:      exitTime.Unix(),
-		StayTime:      stayTime,
-		Fee:           fee,
-		ChangeAmount:  changeAmount,
-		PaymentMethod: req.PaymentMethod,
-	}, nil
+	return result, nil
 }
 
 // calculateFee 计算停车费用
@@ -445,15 +491,15 @@ func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) floa
 
 // InParkVehicle 在场车辆信息
 type InParkVehicle struct {
-	RecordID      uint    `json:"record_id"`
-	PlateNumber   string  `json:"plate_number"`
-	VehicleType   string  `json:"vehicle_type"`
-	OwnerName     string  `json:"owner_name"`
-	OwnerPhone    string  `json:"owner_phone"`
-	EntryTime     string  `json:"entry_time"`
-	StayMinutes   int64   `json:"stay_minutes"`
-	ParkingLot    string  `json:"parking_lot"`
-	ParkingSpace  string  `json:"parking_space"`
+	RecordID     uint   `json:"record_id"`
+	PlateNumber  string `json:"plate_number"`
+	VehicleType  string `json:"vehicle_type"`
+	OwnerName    string `json:"owner_name"`
+	OwnerPhone   string `json:"owner_phone"`
+	EntryTime    string `json:"entry_time"`
+	StayMinutes  int64  `json:"stay_minutes"`
+	ParkingLot   string `json:"parking_lot"`
+	ParkingSpace string `json:"parking_space"`
 }
 
 // GetInParkVehicles 获取所有在场车辆