Преглед на файлове

feat: 出口扫码支付全链路(订单/支付渠道/查单自动放行)

- 订单模块 internal/modules/order:金额锁定、TTL 过期、同会话复用未过期单、
  CAS 状态转移与事件日志;出口屏轮询接口 /exit-kiosk/state|order/:no
- 支付渠道抽象 internal/modules/payment/channel:mock(联调)与微信 Native
  (APIv3 商户私钥签名下单/查单),查单为一等能力(docs/adr/0002)
- 到账确认以主动查单为主:页面轮询触发即时查单 + 30s 定时任务兜底;
  确认后复用 ExitConfirm 结算并主动开闸(审计来源=payment)
- 种子:exit_kiosk 支付入口 + wechat/alipay 支付方式及关联
- 前端出口屏页面 /#/exitKiosk?device=<设备编码>,qrcode 渲染订单二维码
- 配置段 exit-kiosk / wechat-pay;gorm 注册 exit_order(同时登记中央缴费机表)
lq преди 10 часа
родител
ревизия
ed4db148f6

+ 35 - 5
config.yaml

@@ -48,7 +48,8 @@ mqtt:
     password: ""
 camera:
     conversion-mode: system
-    ffmpeg-path: D:\迅雷下载\ffmpeg-9.0.1-full_build\ffmpeg-9.0.1-full_build\bin\ffmpeg.exe
+    # Windows paths: use forward slashes, or single quotes around backslashes.
+    ffmpeg-path: D:/迅雷下载/ffmpeg-9.0.1-full_build/ffmpeg-9.0.1-full_build/bin/ffmpeg.exe
     mjpeg-fps: 8
     jpeg-quality: 5
 db-list:
@@ -188,11 +189,12 @@ sqlite:
     db-name: lc_garage
     username: ""
     password: ""
+    # Fill a directory path, not the .db filename. Use D:/data or 'D:\\data'.
     path: ""
     engine: ""
     log-mode: error
-    max-idle-conns: 10
-    max-open-conns: 100
+    max-idle-conns: 1
+    max-open-conns: 4
     singular: false
     log-zap: false
 system:
@@ -205,8 +207,10 @@ system:
     use-multipoint: false
     use-redis: false
     use-mongo: false
-    gate-simulator: false # 本地无硬件联调;生产环境必须关闭
-    debug-routes: false # 默认不注册票机和通道调试接口
+    gate-simulator: false # 联调临时开启(验证自动开闸链路);生产环境必须关闭
+    debug-routes: true # 联调期间开启(票机/通道/出口屏调试接口)
+    # 单一通道(inout)方向推断最小停留分钟数:入场后不足该时长的重复读卡按忽略处理
+    inout-min-stay-minutes: 1
 tencent-cos:
     bucket: xxxxx-10005608
     region: ap-shanghai
@@ -224,3 +228,29 @@ zap:
     max-age: 0
     show-line: true
     log-in-console: true
+
+# 出口自助终端(扫码支付出场,见 doc/扫码支付出场设计.md)
+exit-kiosk:
+    # 默认支付渠道:wechat(凭据齐全并启用后)或 mock(联调模拟)
+    channel: mock
+    # 非空时出口屏接口需携带 X-Kiosk-Token 请求头
+    token: ""
+    # 订单未支付自动过期时间(分钟),过期后屏幕重新计费
+    order-ttl-minutes: 5
+    # 页面轮询触发渠道查单的最小间隔(秒)
+    query-interval-seconds: 2
+    # 服务端兜底查单定时任务间隔(秒)
+    job-interval-seconds: 30
+
+# 微信支付 Native 扫码(APIv3)。enabled 且凭据齐全时出口自助支付走真实渠道。
+wechat-pay:
+    enabled: false
+    app-id: ""
+    mch-id: ""
+    serial-no: ""
+    # 商户 API 证书私钥文件路径(apiclient_key.pem)
+    private-key-path: ""
+    api-v3-key: ""
+    # 支付通知地址(预留挂点;当前以内网查单为主,见 docs/adr/0002)
+    notify-url: ""
+    currency: CNY

+ 1 - 0
frontend/package.json

@@ -30,6 +30,7 @@
         "nprogress": "^0.2.0",
         "path": "^0.12.7",
         "pinia": "^2.1.4",
+        "qrcode": "^1.5.4",
         "qs": "^6.11.2",
         "screenfull": "^6.0.2",
         "sortablejs": "^1.15.2",

+ 48 - 0
frontend/src/api/exitKiosk.js

@@ -0,0 +1,48 @@
+import service from '@/utils/request'
+
+// 出口屏主轮询:最近出场识别 + 费用预览 + 订单(服务端含节流查单与放行联动)
+export const getExitKioskState = (deviceCode) => service({
+  url: '/exit-kiosk/state',
+  method: 'get',
+  params: { device_code: deviceCode },
+  donNotShowLoading: true,
+})
+
+// 单订单状态查询(同样触发节流查单)
+export const getExitKioskOrder = (orderNo) => service({
+  url: '/exit-kiosk/order/' + orderNo,
+  method: 'get',
+  donNotShowLoading: true,
+})
+
+// 联调:模拟出口 LPR 识别(走真实出场管线,仅 DebugRoutes 开启时可用)
+export const debugExitKioskRecognize = (data) => service({
+  url: '/exit-kiosk/debug/recognize',
+  method: 'post',
+  data,
+  donNotShowLoading: true,
+})
+
+// 联调:模拟入口 LPR 识别(建会话+数字票;device_code 留空自动取进场设备)
+export const debugExitKioskEntry = (data) => service({
+  url: '/exit-kiosk/debug/entry',
+  method: 'post',
+  data,
+  donNotShowLoading: true,
+})
+
+// 联调:一键全流程(确保在场→出口识别→mock 支付→查单确认开闸)
+export const debugExitKioskFullFlow = (data) => service({
+  url: '/exit-kiosk/debug/full-flow',
+  method: 'post',
+  data,
+  donNotShowLoading: true,
+})
+
+// 联调:mock 渠道模拟车主已付款(仅 DebugRoutes 开启时可用)
+export const debugExitKioskMockPay = (data) => service({
+  url: '/exit-kiosk/debug/mock-pay',
+  method: 'post',
+  data,
+  donNotShowLoading: true,
+})

+ 7 - 0
frontend/src/router/index.js

@@ -9,6 +9,13 @@ const routes = [{
   name: 'Login',
   component: () => import('@/view/login/index.vue')
 },
+{
+  // 出口自助屏(kiosk):独立全屏页面,不需要登录与布局,
+  // 地址形如 /#/exitKiosk?device=<出口识别设备编码>
+  path: '/exitKiosk',
+  name: 'ExitKiosk',
+  component: () => import('@/view/parking/exitKiosk.vue')
+},
 {
   path: '/:catchAll(.*)',
   meta: {

+ 373 - 0
frontend/src/view/parking/exitKiosk.vue

@@ -0,0 +1,373 @@
+<template>
+  <div class="kiosk">
+    <header class="kiosk-head">
+      <span class="brand">智慧停车场 · 出口</span>
+      <span class="clock">{{ clock }}</span>
+    </header>
+
+    <main class="kiosk-main">
+      <!-- 配置缺失 -->
+      <section v-if="view === 'config'" class="panel">
+        <h1>缺少设备配置</h1>
+        <p class="sub">请在地址后附加出口识别设备编码,例如:#/exitKiosk?device=CAM-A02</p>
+      </section>
+
+      <!-- 待机 -->
+      <section v-else-if="view === 'idle'" class="panel">
+        <div class="pulse-dot"></div>
+        <h1>车辆出场 · 请停车扫码缴费</h1>
+        <p class="sub">识别到车牌后,本屏将自动显示停车费用与支付二维码</p>
+        <p v-if="hint" class="warn">{{ hint }}</p>
+      </section>
+
+      <!-- 费用 + 订单二维码 -->
+      <section v-else-if="view === 'fee'" class="panel fee-panel">
+        <div class="fee-left">
+          <p class="plate">{{ plate }}</p>
+          <p class="stay">已停 {{ stayText }}</p>
+          <p class="fee">¥<span>{{ feeText }}</span></p>
+          <p class="sub">请用微信 / 支付宝扫描右侧二维码支付</p>
+          <p class="countdown" :class="{ urgent: countdown <= 30 }">二维码 {{ countdown }} 秒后刷新</p>
+          <button v-if="orderChannel === 'mock'" class="mock-btn" @click="onMockPay">
+            模拟支付成功(联调)
+          </button>
+        </div>
+        <div class="fee-right">
+          <img v-if="qrDataUrl" class="qr" :src="qrDataUrl" alt="订单二维码" />
+          <div v-else class="qr loading">正在生成二维码…</div>
+        </div>
+      </section>
+
+      <!-- 支付成功 -->
+      <section v-else-if="view === 'success'" class="panel">
+        <div class="big-icon ok">✓</div>
+        <h1>支付成功 · 一路平安</h1>
+        <p class="sub">{{ plate }} · ¥{{ feeText }} · 道闸正在打开</p>
+        <p v-if="settleError" class="warn">{{ settleError }}</p>
+      </section>
+
+      <!-- 黑名单 -->
+      <section v-else-if="view === 'blacklist'" class="panel">
+        <div class="big-icon bad">!</div>
+        <h1>车辆受限</h1>
+        <p class="sub">该车辆无法自助出场,请联系管理员处理</p>
+      </section>
+
+      <!-- 其它提示(无入场记录/免密/缺车牌等) -->
+      <section v-else-if="view === 'hint'" class="panel">
+        <div class="big-icon bad">!</div>
+        <h1>{{ hint || '暂时无法办理' }}</h1>
+        <p class="sub">如有疑问请联系管理员</p>
+      </section>
+
+      <!-- 设备未注册等错误 -->
+      <section v-else class="panel">
+        <div class="big-icon bad">!</div>
+        <h1>出口屏初始化失败</h1>
+        <p class="sub">{{ fatal || '请检查出口设备编码配置' }}</p>
+      </section>
+    </main>
+
+    <footer class="kiosk-foot">
+      <span>设备:{{ device || '未配置' }}</span>
+      <span>{{ channelName }}</span>
+    </footer>
+
+    <!-- 联调面板:无真实相机时模拟识别 / 一键全流程 -->
+    <div class="debug">
+      <details>
+        <summary>联调</summary>
+        <input v-model="debugPlate" placeholder="车牌,留空随机生成" />
+        <button @click="onDebugEntry">模拟入场</button>
+        <button @click="onDebugRecognize">模拟出场识别</button>
+        <button @click="onDebugFullFlow">一键全流程</button>
+      </details>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import QRCode from 'qrcode'
+import {
+  debugExitKioskEntry,
+  debugExitKioskFullFlow,
+  debugExitKioskMockPay,
+  debugExitKioskRecognize,
+  getExitKioskState,
+} from '@/api/exitKiosk'
+
+const route = useRoute()
+const device = String(route.query.device || '')
+
+const view = ref(device ? 'idle' : 'config')
+const fatal = ref('')
+const hint = ref('')
+const channelName = ref('')
+const plate = ref('')
+const stayText = ref('')
+const feeText = ref('0.00')
+const qrDataUrl = ref('')
+const orderChannel = ref('')
+const countdown = ref(0)
+const settleError = ref('')
+const debugPlate = ref('')
+
+let pollTimer = null
+let tickTimer = null
+let successTimer = null
+let shownEventID = 0
+let currentCodeURL = ''
+let expireAt = 0
+
+const formatFee = (v) => Number(v || 0).toFixed(2)
+const clock = ref(new Date().toLocaleTimeString())
+
+function applyState(data) {
+  if (!data) return
+  channelName.value = data.channel_name || ''
+  if (data.order) currentOrderNo.value = data.order.order_no
+
+  // 支付成功:优先展示(settle_error 变体提示已收款但需人工放行)
+  if (data.order && data.order.state === 'paid') {
+    const keepPlate = plate.value
+    settleError.value = data.order.settle_error || ''
+    if (view.value !== 'success') {
+      plate.value = data.plate_number || keepPlate
+      feeText.value = formatFee(data.order.amount)
+      showSuccess()
+    }
+    return
+  }
+
+  // 黑名单
+  if (data.blacklisted) {
+    if (shownEventID !== data.event_id || view.value !== 'blacklist') {
+      shownEventID = data.event_id
+      plate.value = data.plate_number
+      switchTo('blacklist', data.plate_number)
+    }
+    return
+  }
+
+  // 可缴费订单(pending):显示费用 + 二维码
+  if (data.recognized && data.order && data.order.state === 'pending') {
+    if (!data.order.code_url) return // 渠道下单尚未成功,下一轮再刷新
+    const isNew = data.event_id !== shownEventID || view.value !== 'fee'
+    shownEventID = data.event_id
+    plate.value = data.plate_number
+    feeText.value = formatFee(data.order.amount)
+    stayText.value = formatStay(data.stay_time)
+    expireAt = data.order.expire_at * 1000
+    orderChannel.value = data.order.channel
+    if (isNew) switchTo('fee', data.plate_number)
+    if (currentCodeURL !== data.order.code_url) {
+      currentCodeURL = data.order.code_url
+      QRCode.toDataURL(currentCodeURL, { width: 460, margin: 2 })
+        .then(url => { qrDataUrl.value = url })
+        .catch(() => { qrDataUrl.value = '' })
+    }
+    return
+  }
+
+  // 识别到了但无法进入支付(无入场记录/免密/缺车牌等):仅对新事件提示一次
+  if (data.recognized && data.event_id !== shownEventID) {
+    shownEventID = data.event_id
+    if (data.preview_hint === '车辆未入场或已出场' && view.value === 'success') {
+      return // 刚支付完成的正常余波
+    }
+    hint.value = data.preview_hint || '暂时无法办理'
+    switchTo('hint', data.plate_number)
+    return
+  }
+
+  // 订单过期:服务端会在下一轮按新费用重建订单
+  if (view.value === 'fee' && (!data.order || data.order.state === 'expired')) {
+    hint.value = '二维码已过期,正在重新计费…'
+    switchTo('idle')
+  }
+}
+
+function formatStay(minutes) {
+  const total = Number(minutes || 0)
+  const h = Math.floor(total / 60)
+  const m = Math.round(total % 60)
+  return h > 0 ? `${h} 小时 ${m} 分钟` : `${m} 分钟`
+}
+
+function switchTo(target, hintPlate) {
+  view.value = target
+  if (target === 'idle' || target === 'hint') {
+    if (hintPlate) plate.value = hintPlate
+    qrDataUrl.value = ''
+    currentCodeURL = ''
+    expireAt = 0
+    countdown.value = 0
+  }
+  if (target !== 'success') settleError.value = ''
+  if (target === 'success' && !successTimer) {
+    successTimer = setTimeout(() => {
+      successTimer = null
+      switchTo('idle')
+    }, 8000)
+  }
+}
+
+function showSuccess() {
+  view.value = 'success'
+  if (successTimer) clearTimeout(successTimer)
+  successTimer = setTimeout(() => {
+    successTimer = null
+    switchTo('idle')
+  }, 8000)
+}
+
+async function poll() {
+  if (!device) return
+  try {
+    const res = await getExitKioskState(device)
+    if (res && res.code === 0) applyState(res.data)
+  } catch (error) {
+    // 网络抖动静默重试
+  }
+}
+
+async function onMockPay() {
+  if (!currentOrderNo.value) return
+  try {
+    await debugExitKioskMockPay({ order_no: currentOrderNo.value })
+    hint.value = ''
+  } catch (error) { /* 下一轮轮询继续 */ }
+}
+
+const currentOrderNo = ref('')
+
+function randomTestPlate() {
+  let plate = debugPlate.value.trim()
+  if (!plate) plate = '测试EK' + String(Math.floor(Math.random() * 90000) + 10000)
+  return plate
+}
+
+// 模拟入场:hours=26 回拨入场时间,保证出场时跨过免费窗口、必有费用
+async function onDebugEntry() {
+  if (!device) return
+  try {
+    await debugExitKioskEntry({ plate_number: randomTestPlate(), hours: 26 })
+    setTimeout(poll, 500)
+  } catch (error) { /* 静默:生产环境该端点不存在 */ }
+}
+
+async function onDebugRecognize() {
+  if (!device) return
+  try {
+    await debugExitKioskRecognize({ device_code: device, plate_number: randomTestPlate() })
+    setTimeout(poll, 500)
+  } catch (error) { /* 静默:生产环境该端点不存在 */ }
+}
+
+// 一键全流程:确保在场 → 出口识别 → mock 支付 → 查单确认开闸
+async function onDebugFullFlow() {
+  if (!device) return
+  try {
+    const res = await debugExitKioskFullFlow({ device_code: device, plate_number: randomTestPlate() })
+    if (res && res.code === 0) applyState(res.data)
+  } catch (error) { /* 静默:生产环境该端点不存在 */ }
+}
+
+function tick() {
+  clock.value = new Date().toLocaleTimeString()
+  if (expireAt > 0 && view.value === 'fee') {
+    const left = Math.max(0, Math.round((expireAt - Date.now()) / 1000))
+    countdown.value = left
+  }
+}
+
+onMounted(() => {
+  pollTimer = setInterval(poll, 2500)
+  tickTimer = setInterval(tick, 1000)
+  tick()
+  poll()
+})
+
+onUnmounted(() => {
+  clearInterval(pollTimer)
+  clearInterval(tickTimer)
+  if (successTimer) clearTimeout(successTimer)
+})
+</script>
+
+<style scoped>
+.kiosk {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  background: radial-gradient(120% 120% at 50% 0%, #17222b 0%, #0b1116 70%);
+  color: #eef3f6;
+  font-family: 'Microsoft YaHei UI', 'PingFang SC', sans-serif;
+}
+.kiosk-head, .kiosk-foot {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 18px 32px;
+  font-size: 20px;
+  color: #8fa3b0;
+}
+.kiosk-head .brand { font-size: 26px; color: #dfe9ef; letter-spacing: 2px; }
+.kiosk-main { flex: 1; display: flex; align-items: center; justify-content: center; }
+.panel { text-align: center; max-width: 1100px; padding: 0 40px; }
+.panel h1 { font-size: 64px; margin: 18px 0 10px; font-weight: 700; letter-spacing: 4px; }
+.panel .sub { font-size: 26px; color: #9fb2bd; margin: 10px 0; }
+.panel .warn { font-size: 26px; color: #ffb454; margin-top: 16px; }
+.pulse-dot {
+  width: 22px; height: 22px; margin: 0 auto; border-radius: 50%;
+  background: #35d08c; box-shadow: 0 0 0 0 rgba(53, 208, 140, 0.6);
+  animation: pulse 2s infinite;
+}
+@keyframes pulse {
+  0% { box-shadow: 0 0 0 0 rgba(53, 208, 140, 0.5); }
+  70% { box-shadow: 0 0 0 26px rgba(53, 208, 140, 0); }
+  100% { box-shadow: 0 0 0 0 rgba(53, 208, 140, 0); }
+}
+.fee-panel { display: flex; align-items: center; gap: 80px; text-align: left; }
+.fee-left { text-align: left; }
+.fee-left .plate { font-size: 54px; font-weight: 700; letter-spacing: 6px; margin: 0; }
+.fee-left .stay { font-size: 26px; color: #9fb2bd; margin: 8px 0 18px; }
+.fee-left .fee { font-size: 44px; color: #ffd166; margin: 0 0 18px; }
+.fee-left .fee span { font-size: 96px; font-weight: 700; }
+.countdown { font-size: 22px; color: #8fa3b0; }
+.countdown.urgent { color: #ff7a6b; }
+.fee-right .qr {
+  width: 420px; height: 420px; border-radius: 18px; background: #fff; padding: 16px;
+  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
+}
+.fee-right .qr.loading {
+  display: flex; align-items: center; justify-content: center;
+  font-size: 24px; color: #40525e;
+}
+.big-icon {
+  width: 130px; height: 130px; margin: 0 auto; border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 76px; font-weight: 700;
+}
+.big-icon.ok { background: rgba(53, 208, 140, 0.15); color: #35d08c; }
+.big-icon.bad { background: rgba(255, 122, 107, 0.15); color: #ff7a6b; }
+.mock-btn {
+  margin-top: 26px; padding: 14px 30px; font-size: 22px; border-radius: 12px;
+  border: 1px dashed #ffb454; background: transparent; color: #ffb454; cursor: pointer;
+}
+.kiosk-foot { font-size: 18px; }
+.debug { position: fixed; left: 12px; bottom: 12px; font-size: 14px; color: #6d8290; }
+.debug details { display: flex; align-items: center; gap: 8px; }
+.debug summary { cursor: pointer; list-style: none; opacity: 0.5; }
+.debug input {
+  padding: 6px 10px; border-radius: 8px; border: 1px solid #31424e;
+  background: #101920; color: #cfdce4;
+}
+.debug button {
+  padding: 6px 14px; border-radius: 8px; border: 1px solid #31424e;
+  background: #17222b; color: #9fb2bd; cursor: pointer;
+}
+</style>

+ 4 - 0
internal/config/config.go

@@ -32,4 +32,8 @@ type Server struct {
 	Mqtt Mqtt `mapstructure:"mqtt" json:"mqtt" yaml:"mqtt"`
 	// 摄像头系统侧转换配置
 	Camera Camera `mapstructure:"camera" json:"camera" yaml:"camera"`
+	// 出口自助终端(扫码支付出场)
+	ExitKiosk ExitKiosk `mapstructure:"exit-kiosk" json:"exit-kiosk" yaml:"exit-kiosk"`
+	// 微信支付商户配置
+	WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"`
 }

+ 27 - 0
internal/config/exit_kiosk.go

@@ -0,0 +1,27 @@
+package config
+
+// ExitKiosk 是出口自助终端(扫码支付出场)的运行配置。
+type ExitKiosk struct {
+	// Channel 默认支付渠道:mock(联调)或 wechat。
+	Channel string `mapstructure:"channel" json:"channel" yaml:"channel"`
+	// Token 非空时,出口屏接口必须携带 X-Kiosk-Token 请求头。
+	Token string `mapstructure:"token" json:"token" yaml:"token"`
+	// OrderTTLMinutes 订单未支付自动过期的时间,过期后屏幕重新计费。
+	OrderTTLMinutes int `mapstructure:"order-ttl-minutes" json:"order-ttl-minutes" yaml:"order-ttl-minutes"`
+	// QueryIntervalSeconds 页面轮询触发渠道查单的最小间隔(秒)。
+	QueryIntervalSeconds int `mapstructure:"query-interval-seconds" json:"query-interval-seconds" yaml:"query-interval-seconds"`
+	// JobIntervalSeconds 服务端兜底查单定时任务的间隔(秒)。
+	JobIntervalSeconds int `mapstructure:"job-interval-seconds" json:"job-interval-seconds" yaml:"job-interval-seconds"`
+}
+
+// WechatPay 是微信支付 Native 下单的商户配置,凭据齐全时出口自助支付自动切换到真实渠道。
+type WechatPay struct {
+	Enabled        bool   `mapstructure:"enabled" json:"enabled" yaml:"enabled"`
+	AppID          string `mapstructure:"app-id" json:"app-id" yaml:"app-id"`
+	MchID          string `mapstructure:"mch-id" json:"mch-id" yaml:"mch-id"`
+	SerialNo       string `mapstructure:"serial-no" json:"serial-no" yaml:"serial-no"`
+	PrivateKeyPath string `mapstructure:"private-key-path" json:"private-key-path" yaml:"private-key-path"`
+	APIV3Key       string `mapstructure:"api-v3-key" json:"api-v3-key" yaml:"api-v3-key"`
+	NotifyURL      string `mapstructure:"notify-url" json:"notify-url" yaml:"notify-url"`
+	Currency       string `mapstructure:"currency" json:"currency" yaml:"currency"`
+}

+ 41 - 0
internal/dao/order.go

@@ -0,0 +1,41 @@
+package dao
+
+import (
+	"time"
+
+	"wails-app/internal/global"
+)
+
+// 出口自助订单状态:pending 等待支付;paid 已确认到账;expired 超时未支付。
+const (
+	ExitOrderPending = "pending"
+	ExitOrderPaid    = "paid"
+	ExitOrderExpired = "expired"
+)
+
+// ExitOrder 出口自助扫码支付的订单:车牌识别后在出口屏创建,锁定应付金额。
+// 到账确认后由系统自动结算出场并开闸(见 doc/扫码支付出场设计.md)。
+type ExitOrder struct {
+	global.GVA_MODEL
+	OrderNo      string     `gorm:"size:64;uniqueIndex" json:"order_no"`
+	SessionID    uint       `gorm:"index" json:"session_id"`
+	TicketNo     string     `gorm:"size:64;index" json:"ticket_no"`
+	PlateNumber  string     `gorm:"size:20;index" json:"plate_number"`
+	Amount       float64    `json:"amount"`
+	Currency     string     `gorm:"size:8;default:CNY" json:"currency"`
+	Channel      string     `gorm:"size:20" json:"channel"`           // wechat / mock
+	CodeURL      string     `gorm:"size:255" json:"code_url"`         // 渠道返回的二维码内容
+	ChannelTxnID string     `gorm:"size:64" json:"channel_txn_id"`    // 渠道流水号
+	State        string     `gorm:"size:16;default:pending;index" json:"state"`
+	ExpireAt     time.Time  `json:"expire_at"`
+	PaidAt       *time.Time `json:"paid_at"`
+	EventLog     string     `gorm:"type:text" json:"event_log"`
+	// 出场通道快照:结算与开闸使用
+	ExitChannelID   uint   `gorm:"index" json:"exit_channel_id"`
+	ExitChannelCode string `gorm:"size:50" json:"exit_channel_code"`
+	ExitChannelName string `gorm:"size:100" json:"exit_channel_name"`
+	ExitDeviceCode  string `gorm:"size:50" json:"exit_device_code"`
+	ExitDeviceName  string `gorm:"size:100" json:"exit_device_name"`
+}
+
+func (ExitOrder) TableName() string { return "exit_order" }

+ 4 - 0
internal/initialize/gorm.go

@@ -70,6 +70,10 @@ func RegisterTables() {
 		dao.Printer{},
 		dao.IncidentRecord{},
 		dao.DeviceCommandLog{},
+		dao.CentralPaymentDevice{},
+		dao.CentralPaymentToken{},
+		dao.CentralPaymentRequestLog{},
+		dao.ExitOrder{},
 	)
 	if err != nil {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))

+ 7 - 3
internal/initialize/payment_seed.go

@@ -41,9 +41,11 @@ func EnsurePaymentConfiguration() error {
 	}
 
 	methods := []paymentMethodDefault{
-		{Code: "cash", Name: "人工支付", InputMode: "manual_amount", Scenes: []string{"counter"}, Enabled: true, Sort: 10, Description: "岗亭人员录入实收金额并确认收款"},
+		{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: "车主扫描停车小票发起外部支付"},
+		{Code: "wechat", Name: "微信支付", InputMode: "external", Scenes: []string{"exit_kiosk"}, Enabled: true, Sort: 40, Description: "车主扫描订单二维码通过微信支付(出口自助)"},
+		{Code: "alipay", Name: "支付宝", InputMode: "external", Scenes: []string{"exit_kiosk"}, Enabled: false, Sort: 50, Description: "车主扫描订单二维码通过支付宝支付(出口自助)"},
 	}
 	for _, item := range methods {
 		method := dao.PaymentMethodConfig{}
@@ -60,18 +62,20 @@ func EnsurePaymentConfiguration() error {
 		}
 	}
 
-	// 人工支付是基础能力,升级时也要修复旧数据中被关闭或改型的情况。
+	// 现金支付是基础能力,升级时也要修复旧数据中被关闭或改型的情况。
 	cashDefaults := dao.PaymentMethodConfig{
+		Name: "现金支付", Description: "以现金形式完成收款",
 		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 {
+		Select("name", "description", "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"}},
+		{Code: "exit_kiosk", Name: "出口自助", Enabled: true, Sort: 30, Description: "出口自助终端扫码支付到账后自动放行", Methods: []string{"wechat", "alipay"}},
 	}
 	for _, item := range entries {
 		entry := dao.PaymentEntryConfig{}

+ 7 - 0
internal/initialize/router.go

@@ -6,12 +6,15 @@ import (
 	"wails-app/internal/docs"
 	"wails-app/internal/global"
 	"wails-app/internal/middleware"
+	centralpayment "wails-app/internal/modules/central-payment"
 	"wails-app/internal/modules/deviceimage"
 	"wails-app/internal/modules/deviceprovisioning"
 	"wails-app/internal/modules/digital-ticket"
 	"wails-app/internal/modules/incident"
 	"wails-app/internal/modules/monthly"
+	"wails-app/internal/modules/order"
 	"wails-app/internal/modules/payment"
+	paymentChannel "wails-app/internal/modules/payment/channel"
 	"wails-app/internal/modules/printer"
 	"wails-app/internal/modules/report"
 	"wails-app/internal/modules/shift"
@@ -88,6 +91,10 @@ func Routers() *gin.Engine {
 		dashboardRouter.InitDashboardRouter(PublicGroup) // 监控仪表盘 不做鉴权
 		PublicGroup.POST("/device-images/upload", deviceimage.Upload)
 		parking.SetupParkingStreamPublicRouter(PublicGroup)
+		centralpayment.SetupPublicRouter(PublicGroup)
+		paymentChannel.SetupChannels() // 出口自助支付渠道注册(mock/wechat)
+		order.SetupExitKioskPublicRouter(PublicGroup)
+		order.SetupExitKioskDebugRouter(PublicGroup, global.GVA_CONFIG.System.DebugRoutes)
 	}
 	PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
 	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()).Use(middleware.OperationRecord())

+ 17 - 0
internal/initialize/timer.go

@@ -2,6 +2,9 @@ package initialize
 
 import (
 	"fmt"
+
+	"go.uber.org/zap"
+	orderservice "wails-app/internal/modules/order/service"
 	"wails-app/internal/task"
 
 	"github.com/robfig/cron/v3"
@@ -24,6 +27,20 @@ func Timer() {
 			fmt.Println("add timer error:", err)
 		}
 
+		// 出口自助订单兜底查单:确认到账并自动结算、开闸(ADR-0002 查单为主)
+		_, err = global.GVA_Timer.AddTaskByFunc("ExitKioskConfirmOrders", "@every 30s", func() {
+			orderSvc := orderservice.NewOrderService()
+			confirmed, expired, err := orderSvc.ConfirmPendingOrders()
+			if err != nil {
+				global.GVA_LOG.Warn("出口自助订单查单任务失败", zap.Int("confirmed", confirmed), zap.Error(err))
+			} else if confirmed > 0 || expired > 0 {
+				global.GVA_LOG.Info("出口自助订单查单任务", zap.Int("confirmed", confirmed), zap.Int64("expired", expired))
+			}
+		}, "出口自助扫码支付订单兜底查单", option...)
+		if err != nil {
+			fmt.Println("add timer error:", err)
+		}
+
 		// 其他定时任务定在这里 参考上方使用方法
 
 		//_, err := global.GVA_Timer.AddTaskByFunc("定时任务标识", "corn表达式", func() {

+ 173 - 0
internal/modules/order/api.go

@@ -0,0 +1,173 @@
+// Package order 出口自助扫码支付模块。
+// 公开接口供出口屏 kiosk 页面轮询(可用 X-Kiosk-Token 鉴权);
+// 调试接口仅在 System.DebugRoutes 开启时注册,用于无真实硬件时打通全链路。
+package order
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/global"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/order/service"
+	paymentChannel "wails-app/internal/modules/payment/channel"
+)
+
+var kioskService = service.NewOrderService()
+
+// checkKioskToken 配置了 exit-kiosk.token 时校验 X-Kiosk-Token 请求头。
+func checkKioskToken(c *gin.Context) bool {
+	token := global.GVA_CONFIG.ExitKiosk.Token
+	if token == "" || c.GetHeader("X-Kiosk-Token") == token {
+		return true
+	}
+	c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "出口屏令牌无效"})
+	return false
+}
+
+// KioskState 出口屏主轮询:最近出场识别 + 费用 + 订单(含节流查单与放行联动)。
+func KioskState(c *gin.Context) {
+	if !checkKioskToken(c) {
+		return
+	}
+	state, err := kioskService.KioskState(c.Query("device_code"))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(state, c)
+}
+
+// OrderStatus 单订单状态查询(含节流查单与放行联动)。
+func OrderStatus(c *gin.Context) {
+	if !checkKioskToken(c) {
+		return
+	}
+	view, err := kioskService.GetOrderStatus(c.Param("order_no"))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(view, c)
+}
+
+// DebugRecognize 模拟出口 LPR 识别:走真实 HandlePassage 管线
+// (临时车有费用会被拒绝放行并记录通道事件,与真实相机行为一致)。
+func DebugRecognize(c *gin.Context) {
+	var req struct {
+		DeviceCode  string `json:"device_code"` // 留空自动取第一个出场设备
+		PlateNumber string `json:"plate_number" binding:"required"`
+		Direction   string `json:"direction"` // 留空时 inout 通道走方向推断
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	deviceCode := strings.TrimSpace(req.DeviceCode)
+	if deviceCode == "" {
+		exitDevice, err := service.FirstExitDevice()
+		if err != nil {
+			response.FailWithMessage(err.Error(), c)
+			return
+		}
+		deviceCode = exitDevice
+	}
+	result, err := service.SimulateRecognition(deviceCode, req.PlateNumber, req.Direction)
+	if err != nil {
+		response.FailWithMessage("识别事件已按真实管线处理(预期内拒绝): "+err.Error(), c)
+		return
+	}
+	response.OkWithData(result, c)
+}
+
+// DebugEntry 模拟入口 LPR 识别:走真实管线建停车会话与数字票。
+// device_code 留空时自动取第一个绑定了进场通道的启用设备。
+func DebugEntry(c *gin.Context) {
+	var req struct {
+		DeviceCode  string `json:"device_code"` // 留空自动取第一个进场设备
+		PlateNumber string `json:"plate_number" binding:"required"`
+		Hours       int    `json:"hours"` // 回拨入场时间小时数(如 26),保证出场时有费用
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	deviceCode := strings.TrimSpace(req.DeviceCode)
+	if deviceCode == "" {
+		entryDevice, err := service.FirstEntryDevice()
+		if err != nil {
+			response.FailWithMessage(err.Error(), c)
+			return
+		}
+		deviceCode = entryDevice
+	}
+	result, err := service.SimulateRecognition(deviceCode, req.PlateNumber, "in")
+	if err != nil {
+		response.FailWithMessage("模拟入场失败: "+err.Error(), c)
+		return
+	}
+	// 测试支付流程时回拨入场时间,跨过免费窗口
+	if req.Hours > 0 {
+		if err := service.BackfillEntryTime(req.PlateNumber, req.Hours); err != nil {
+			response.FailWithMessage("入场成功但回拨入场时间失败: "+err.Error(), c)
+			return
+		}
+	}
+	response.OkWithData(result, c)
+}
+
+// DebugFullFlow 一键联调:确保在场 → 回拨入场时间 → 出口识别 → mock 支付 → 查单确认开闸。
+// hours 为入场时间回拨小时数(默认 26),保证费用必然产生。
+func DebugFullFlow(c *gin.Context) {
+	var req struct {
+		DeviceCode  string `json:"device_code"`
+		PlateNumber string `json:"plate_number" binding:"required"`
+		Hours       int    `json:"hours"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	state, err := kioskService.DebugFullFlow(req.DeviceCode, req.PlateNumber, req.Hours)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(state, c)
+}
+
+// DebugMockPay mock 渠道模拟"车主已完成支付",随后查单即确认到账。
+func DebugMockPay(c *gin.Context) {
+	var req struct {
+		OrderNo string `json:"order_no" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := paymentChannel.MarkMockPaid(req.OrderNo); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("mock 渠道已标记支付,等待查单确认", c)
+}
+
+// SetupExitKioskPublicRouter 出口屏轮询接口(公开组,可选令牌)。
+func SetupExitKioskPublicRouter(router *gin.RouterGroup) {
+	group := router.Group("/exit-kiosk")
+	group.GET("/state", KioskState)
+	group.GET("/order/:order_no", OrderStatus)
+}
+
+// SetupExitKioskDebugRouter 联调端点,仅 System.DebugRoutes 开启时注册。
+func SetupExitKioskDebugRouter(router *gin.RouterGroup, enabled bool) {
+	if !enabled {
+		return
+	}
+	group := router.Group("/exit-kiosk/debug")
+	group.POST("/entry", DebugEntry)
+	group.POST("/recognize", DebugRecognize)
+	group.POST("/mock-pay", DebugMockPay)
+	group.POST("/full-flow", DebugFullFlow)
+}

+ 53 - 0
internal/modules/order/repository/repo.go

@@ -0,0 +1,53 @@
+package repository
+
+import (
+	"errors"
+	"time"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+)
+
+type OrderRepository struct{}
+
+// GetByOrderNo 按订单号取订单。
+func (r *OrderRepository) GetByOrderNo(db *gorm.DB, orderNo string) (*dao.ExitOrder, error) {
+	var order dao.ExitOrder
+	if err := db.Where("order_no = ?", orderNo).First(&order).Error; err != nil {
+		return nil, err
+	}
+	return &order, nil
+}
+
+// FindReusablePending 找同会话未过期的待支付订单(复用,避免重复建单)。
+func (r *OrderRepository) FindReusablePending(db *gorm.DB, sessionID uint) (*dao.ExitOrder, error) {
+	var order dao.ExitOrder
+	err := db.Where("session_id = ? AND state = ? AND expire_at > ?",
+		sessionID, dao.ExitOrderPending, time.Now()).Order("id DESC").First(&order).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &order, nil
+}
+
+// ListPendingForQuery 待支付且未过期的订单(定时查单任务用)。
+func (r *OrderRepository) ListPendingForQuery(db *gorm.DB, limit int) ([]dao.ExitOrder, error) {
+	var orders []dao.ExitOrder
+	err := db.Where("state = ? AND expire_at > ?", dao.ExitOrderPending, time.Now()).
+		Order("id ASC").Limit(limit).Find(&orders).Error
+	return orders, err
+}
+
+// ExpireStale 批量把超过有效期仍未支付的订单标记过期,返回影响行数。
+func (r *OrderRepository) ExpireStale(db *gorm.DB) int64 {
+	result := db.Model(&dao.ExitOrder{}).
+		Where("state = ? AND expire_at <= ?", dao.ExitOrderPending, time.Now()).
+		Updates(map[string]interface{}{"state": dao.ExitOrderExpired})
+	if result.Error != nil {
+		return 0
+	}
+	return result.RowsAffected
+}

+ 700 - 0
internal/modules/order/service/service.go

@@ -0,0 +1,700 @@
+// Package service 出口自助扫码支付的订单服务。
+// 设计要点(doc/扫码支付出场设计.md、docs/adr/0001、docs/adr/0002):
+//   - 订单在出口屏感知到临停车后创建,锁定应付金额,TTL 内未支付自动过期;
+//   - 到账确认以主动查单为主(页面轮询触发 + 定时任务兜底);
+//   - 确认到账后复用 ExitConfirm 完成结算,再主动开闸(审计来源=支付放行)。
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/gofrs/uuid/v5"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	common "wails-app/internal/model/common"
+	vehicleRequest "wails-app/internal/model/vehicle/request"
+	vehicleResponse "wails-app/internal/model/vehicle/response"
+	incidentService "wails-app/internal/modules/incident/service"
+	repo "wails-app/internal/modules/order/repository"
+	paymentChannel "wails-app/internal/modules/payment/channel"
+	parkingSvc "wails-app/internal/service/parking"
+	uhf "wails-app/internal/service/uhf"
+	vehicleSvc "wails-app/internal/service/vehicle"
+)
+
+// PaymentEntryExitKiosk 出口自助支付入口代码(payment_entry)。
+const PaymentEntryExitKiosk = "exit_kiosk"
+
+// settleRetryInterval 结算失败后的重试间隔:钱已收,结算要兜底重试但不能刷库。
+const settleRetryInterval = 30 * time.Second
+
+type OrderService struct {
+	repo *repo.OrderRepository
+	mu   sync.Mutex
+	// lastQueryAt 渠道查单节流:同一订单两次查单的最小间隔由配置控制。
+	lastQueryAt map[string]time.Time
+	// lastSettleAt 结算重试节流。
+	lastSettleAt map[string]time.Time
+}
+
+func NewOrderService() *OrderService {
+	return &OrderService{
+		repo:         &repo.OrderRepository{},
+		lastQueryAt:  map[string]time.Time{},
+		lastSettleAt: map[string]time.Time{},
+	}
+}
+
+// OrderView 出口屏展示用的订单视图。
+type OrderView struct {
+	OrderNo     string  `json:"order_no"`
+	Channel     string  `json:"channel"`
+	Amount      float64 `json:"amount"`
+	Currency    string  `json:"currency"`
+	CodeURL     string  `json:"code_url"`
+	State       string  `json:"state"`
+	ExpireAt    int64   `json:"expire_at"`
+	PaidAt      int64   `json:"paid_at"`
+	SettleError string  `json:"settle_error,omitempty"` // 已收款但结算/开闸失败时的提示
+}
+
+// KioskState 出口屏轮询的聚合状态:最近出场识别 + 费用预览 + 订单。
+type KioskStateResponse struct {
+	DeviceCode  string     `json:"device_code"`
+	ChannelID   uint       `json:"channel_id"`
+	ChannelName string     `json:"channel_name"`
+	EventID     uint64     `json:"event_id"`
+	Recognized  bool       `json:"recognized"`
+	PlateNumber string     `json:"plate_number"`
+	StayTime    int64      `json:"stay_time"`
+	Fee         float64    `json:"fee"`
+	SessionID   uint       `json:"session_id"`
+	TicketNo    string     `json:"ticket_no"`
+	TicketState string     `json:"ticket_state"`
+	Blacklisted bool       `json:"blacklisted"`
+	PreviewHint string     `json:"preview_hint,omitempty"` // 无法进入支付流程的原因
+	Order       *OrderView `json:"order,omitempty"`
+}
+
+func orderTTL() time.Duration {
+	minutes := global.GVA_CONFIG.ExitKiosk.OrderTTLMinutes
+	if minutes <= 0 {
+		minutes = 5
+	}
+	return time.Duration(minutes) * time.Minute
+}
+
+func queryInterval() time.Duration {
+	seconds := global.GVA_CONFIG.ExitKiosk.QueryIntervalSeconds
+	if seconds <= 0 {
+		seconds = 2
+	}
+	return time.Duration(seconds) * time.Second
+}
+
+// KioskState 出口屏主轮询接口:按设备取最近的出场识别事件,
+// 有临停车费用时确保存在未过期订单,并对待支付订单做节流查单。
+func (s *OrderService) KioskState(deviceCode string) (*KioskStateResponse, error) {
+	if global.GVA_DB == nil {
+		return nil, errors.New("数据库未初始化")
+	}
+	deviceCode = strings.TrimSpace(deviceCode)
+	if deviceCode == "" {
+		return nil, errors.New("缺少出口设备编码")
+	}
+
+	resp := &KioskStateResponse{DeviceCode: deviceCode}
+
+	var reader dao.UHFReader
+	if err := global.GVA_DB.Preload("Channel").Where("device_code = ?", deviceCode).First(&reader).Error; err != nil {
+		return nil, errors.New("出口设备未注册或已停用")
+	}
+	if reader.Channel == nil {
+		return nil, errors.New("出口设备未绑定通道")
+	}
+	resp.ChannelID = reader.Channel.ID
+	resp.ChannelName = reader.Channel.ChannelName
+
+	event, ok := latestExitEvent(deviceCode)
+	if !ok {
+		return resp, nil
+	}
+	resp.EventID = event.ID
+	resp.Recognized = true
+	resp.PlateNumber = event.PlateNumber
+	if resp.PlateNumber == "" {
+		// 只有 RFID 的识别无法在出口屏发起扫码支付
+		resp.PreviewHint = "识别结果缺少车牌,无法扫码支付"
+		return resp, nil
+	}
+
+	var shortlist vehicleSvc.ShortlistService
+	isBlack, _ := shortlist.CheckVehicleShortlist(resp.PlateNumber, event.RFIDTag)
+	if isBlack {
+		resp.Blacklisted = true
+		return resp, nil
+	}
+
+	vehicleService := &vehicleSvc.VehicleService{}
+	preview, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{
+		PlateNumber: resp.PlateNumber,
+		RFIDTag:     event.RFIDTag,
+	})
+	if err != nil {
+		// 会话已结束(例如刚在本屏支付完成)或确实无入场记录
+		resp.PreviewHint = "车辆未入场或已出场"
+		return resp, nil
+	}
+	resp.SessionID = preview.SessionID
+	resp.TicketNo = preview.TicketNo
+	resp.TicketState = preview.TicketState
+	resp.StayTime = preview.StayTime
+	resp.Fee = preview.Fee
+
+	if preview.Fee <= 0 {
+		resp.PreviewHint = "免费放行,无需缴费"
+		return resp, nil
+	}
+
+	order, err := s.EnsureOrder(preview, &reader)
+	if err != nil {
+		resp.PreviewHint = "创建订单失败,请稍候重试"
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("出口订单创建失败", zap.String("plate", resp.PlateNumber), zap.Error(err))
+		}
+		return resp, nil
+	}
+	if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil {
+		global.GVA_LOG.Warn("出口订单查单失败", zap.String("order_no", order.OrderNo), zap.Error(err))
+	}
+	resp.Order = orderView(order)
+	return resp, nil
+}
+
+// EnsureOrder 复用同会话未过期的待支付订单;没有则创建并调用渠道下单。
+func (s *OrderService) EnsureOrder(preview vehicleResponse.ExitPreviewResponse, reader *dao.UHFReader) (*dao.ExitOrder, error) {
+	var existing dao.ExitOrder
+	err := global.GVA_DB.Where("session_id = ? AND state = ? AND expire_at > ?",
+		preview.SessionID, dao.ExitOrderPending, time.Now()).Order("id DESC").First(&existing).Error
+	if err == nil {
+		return &existing, nil
+	}
+	if !errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, err
+	}
+
+	orderNo, err := generateOrderNo()
+	if err != nil {
+		return nil, err
+	}
+	currency := strings.ToUpper(strings.TrimSpace(global.GVA_CONFIG.WechatPay.Currency))
+	if currency == "" {
+		currency = "CNY"
+	}
+	order := &dao.ExitOrder{
+		OrderNo:         orderNo,
+		SessionID:       preview.SessionID,
+		TicketNo:        preview.TicketNo,
+		PlateNumber:     preview.PlateNumber,
+		Amount:          preview.Fee,
+		Currency:        currency,
+		Channel:         paymentChannel.DefaultChannel(),
+		State:           dao.ExitOrderPending,
+		ExpireAt:        time.Now().Add(orderTTL()),
+		ExitChannelID:   reader.Channel.ID,
+		ExitChannelCode: reader.Channel.ChannelCode,
+		ExitChannelName: reader.Channel.ChannelName,
+		ExitDeviceCode:  reader.DeviceCode,
+		ExitDeviceName:  reader.DeviceName,
+	}
+	if order.Channel == "" {
+		order.Channel = paymentChannel.ChannelMock
+	}
+	if err := global.GVA_DB.Create(order).Error; err != nil {
+		return nil, err
+	}
+	s.appendEvent(order, "created", fmt.Sprintf("金额 %.2f %s 渠道 %s", order.Amount, order.Currency, order.Channel))
+	if err := s.createChannelOrder(order); err != nil {
+		// 渠道下单失败不阻塞:订单保留,下次轮询重试补 code_url
+		s.appendEvent(order, "channel_create_failed", err.Error())
+		return order, err
+	}
+	return order, nil
+}
+
+// createChannelOrder 调用渠道下单并回填二维码内容;确保 code_url 为空时重试。
+func (s *OrderService) createChannelOrder(order *dao.ExitOrder) error {
+	ch, err := paymentChannel.Get(order.Channel)
+	if err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	result, err := ch.CreateOrder(ctx, paymentChannel.CreateOrderRequest{
+		OrderNo:     order.OrderNo,
+		Amount:      order.Amount,
+		Currency:    order.Currency,
+		Description: fmt.Sprintf("停车费-%s", order.PlateNumber),
+	})
+	if err != nil {
+		return err
+	}
+	return global.GVA_DB.Model(order).Update("code_url", result.CodeURL).Error
+}
+
+// GetOrderStatus 取订单状态,对待支付订单按节流间隔触发渠道查单。
+func (s *OrderService) GetOrderStatus(orderNo string) (*OrderView, error) {
+	order, err := s.repo.GetByOrderNo(global.GVA_DB, orderNo)
+	if err != nil {
+		return nil, errors.New("订单不存在")
+	}
+	if order.State == dao.ExitOrderPending {
+		if err := s.refreshOrderIfDue(order); err != nil && global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("订单查单失败", zap.String("order_no", orderNo), zap.Error(err))
+		}
+	}
+	return orderView(order), nil
+}
+
+// refreshOrderIfDue 到达查单间隔才真正访问渠道,避免高频轮询打爆渠道。
+// 已支付订单在此兜底重试结算(settleOrder 内置 30 秒节流,重复结算幂等)。
+func (s *OrderService) refreshOrderIfDue(order *dao.ExitOrder) error {
+	if order.State == dao.ExitOrderPaid {
+		_, err := s.settleOrder(order)
+		return err
+	}
+	if order.State != dao.ExitOrderPending {
+		return nil
+	}
+	s.mu.Lock()
+	last, seen := s.lastQueryAt[order.OrderNo]
+	now := time.Now()
+	if seen && now.Sub(last) < queryInterval() {
+		s.mu.Unlock()
+		return nil
+	}
+	s.lastQueryAt[order.OrderNo] = now
+	s.mu.Unlock()
+
+	// 二维码还没拿到(渠道下单失败)先补下单
+	if order.CodeURL == "" {
+		if err := s.createChannelOrder(order); err != nil {
+			s.appendEvent(order, "channel_create_retry_failed", err.Error())
+			return err
+		}
+	}
+	ch, err := paymentChannel.Get(order.Channel)
+	if err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	result, err := ch.QueryOrder(ctx, order.OrderNo)
+	if err != nil {
+		return err
+	}
+	switch result.Status {
+	case paymentChannel.QueryPaid:
+		_, err = s.ConfirmOrderPaid(order, result.TransactionID)
+		return err
+	case paymentChannel.QueryClosed:
+		return s.markExpired(order)
+	}
+	return nil
+}
+
+// ConfirmOrderPaid 渠道确认到账后:订单 CAS 转 paid,随后结算出场并开闸。
+// 结算失败不回滚订单(钱已收),记录事件并按间隔兜底重试。
+func (s *OrderService) ConfirmOrderPaid(order *dao.ExitOrder, txnID string) (*OrderView, error) {
+	now := time.Now()
+	result := global.GVA_DB.Model(&dao.ExitOrder{}).
+		Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending).
+		Updates(map[string]interface{}{
+			"state":          dao.ExitOrderPaid,
+			"channel_txn_id": txnID,
+			"paid_at":        now,
+		})
+	if result.Error != nil {
+		return nil, result.Error
+	}
+	if result.RowsAffected == 0 {
+		// 并发下别处已确认;重载保证幂等
+		reloaded, err := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo)
+		if err != nil {
+			return nil, err
+		}
+		order = reloaded
+	} else {
+		order.State = dao.ExitOrderPaid
+		order.ChannelTxnID = txnID
+		order.PaidAt = &now
+		s.appendEvent(order, "paid", "渠道确认到账 "+txnID)
+	}
+
+	if _, err := s.settleOrder(order); err != nil {
+		return orderView(order), err
+	}
+	return orderView(order), nil
+}
+
+// settleOrder 结算出场并开闸。ExitConfirm 对已出场会话报错视为已结算成功,
+// 保证重复触发时的幂等。
+func (s *OrderService) settleOrder(order *dao.ExitOrder) (bool, error) {
+	s.mu.Lock()
+	if last, seen := s.lastSettleAt[order.OrderNo]; seen && time.Since(last) < settleRetryInterval {
+		s.mu.Unlock()
+		// 节流窗口内不重复尝试;若此前已成功则无副作用
+		return true, nil
+	}
+	s.lastSettleAt[order.OrderNo] = time.Now()
+	s.mu.Unlock()
+
+	vehicleService := &vehicleSvc.VehicleService{}
+	resp, err := vehicleService.ExitConfirm(vehicleRequest.ExitConfirmRequest{
+		SessionID:       order.SessionID,
+		TicketNo:        order.TicketNo,
+		PlateNumber:     order.PlateNumber,
+		PaymentEntry:    PaymentEntryExitKiosk,
+		PaymentMethod:   settleMethod(order.Channel),
+		PaidAmount:      order.Amount,
+		ExitChannelID:   order.ExitChannelID,
+		ExitChannelCode: order.ExitChannelCode,
+		ExitChannelName: order.ExitChannelName,
+		ExitDeviceCode:  order.ExitDeviceCode,
+		ExitDeviceName:  order.ExitDeviceName,
+		TriggerSource:   "exit_kiosk",
+	}, 0)
+	if err != nil {
+		if isAlreadyExitedError(err) {
+			return true, nil
+		}
+		recordSettleIncident(order, err)
+		return false, err
+	}
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("出口自助支付放行", zap.String("order_no", order.OrderNo),
+			zap.String("plate", order.PlateNumber), zap.Float64("fee", resp.Fee))
+	}
+	s.appendEvent(order, "settled", fmt.Sprintf("结算完成 fee=%.2f,开闸", resp.Fee))
+
+	// 结算成功后主动开闸(handleExit 的等价动作,来源=支付放行)
+	parking := &parkingSvc.PassageService{}
+	if _, err := parking.OpenGateWithContext(parkingSvc.GateCommandContext{
+		Source:    incidentService.SourcePayment,
+		SessionID: order.SessionID,
+	}, order.ExitDeviceCode, 2); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("出口自助支付开闸失败", zap.String("order_no", order.OrderNo), zap.Error(err))
+		}
+		recordGateIncident(order, err)
+		return true, fmt.Errorf("开闸失败,请联系管理员放行: %w", err)
+	}
+	return true, nil
+}
+
+// ConfirmPendingOrders 定时任务兜底:对待支付订单查单确认、过期单转 expired。
+func (s *OrderService) ConfirmPendingOrders() (confirmed int, expired int64, err error) {
+	if global.GVA_DB == nil {
+		return 0, 0, errors.New("数据库未初始化")
+	}
+	expired = s.repo.ExpireStale(global.GVA_DB)
+	orders, err := s.repo.ListPendingForQuery(global.GVA_DB, 50)
+	if err != nil {
+		return 0, expired, err
+	}
+	for i := range orders {
+		order := orders[i]
+		if err := s.refreshOrderIfDue(&order); err != nil {
+			if global.GVA_LOG != nil {
+				global.GVA_LOG.Warn("定时查单失败", zap.String("order_no", order.OrderNo), zap.Error(err))
+			}
+			continue
+		}
+		reloaded, rerr := s.repo.GetByOrderNo(global.GVA_DB, order.OrderNo)
+		if rerr == nil && reloaded.State == dao.ExitOrderPaid {
+			confirmed++
+		}
+	}
+	return confirmed, expired, nil
+}
+
+func (s *OrderService) markExpired(order *dao.ExitOrder) error {
+	result := global.GVA_DB.Model(&dao.ExitOrder{}).
+		Where("id = ? AND state = ?", order.ID, dao.ExitOrderPending).
+		Update("state", dao.ExitOrderExpired)
+	if result.Error != nil {
+		return result.Error
+	}
+	if result.RowsAffected > 0 {
+		order.State = dao.ExitOrderExpired
+		s.appendEvent(order, "expired", "超时未支付,订单过期")
+	}
+	return nil
+}
+
+func (s *OrderService) appendEvent(order *dao.ExitOrder, event, detail string) {
+	var log []map[string]interface{}
+	if order.EventLog != "" {
+		_ = json.Unmarshal([]byte(order.EventLog), &log)
+	}
+	log = append(log, map[string]interface{}{
+		"time": time.Now().Format(time.RFC3339), "event": event, "detail": detail,
+	})
+	if encoded, err := json.Marshal(log); err == nil {
+		order.EventLog = string(encoded)
+		if err := global.GVA_DB.Model(&dao.ExitOrder{}).Where("id = ?", order.ID).
+			Update("event_log", order.EventLog).Error; err != nil && global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("订单事件写入失败", zap.String("order_no", order.OrderNo), zap.Error(err))
+		}
+	}
+}
+
+// settleMethod mock 渠道结算时归入 wechat 方式(仅联调环境使用)。
+func settleMethod(channelCode string) string {
+	if channelCode == paymentChannel.ChannelAlipay {
+		return paymentChannel.ChannelAlipay
+	}
+	return paymentChannel.ChannelWechat
+}
+
+func isAlreadyExitedError(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := err.Error()
+	return strings.Contains(msg, "未入场、已出场") || strings.Contains(msg, "不允许出场")
+}
+
+func latestExitEvent(deviceCode string) (uhf.ChannelEvent, bool) {
+	events := uhf.GetChannelEvents(0)
+	for i := len(events) - 1; i >= 0; i-- {
+		if events[i].DeviceCode == deviceCode && events[i].Direction == "out" {
+			return events[i], true
+		}
+	}
+	return uhf.ChannelEvent{}, false
+}
+
+func generateOrderNo() (string, error) {
+	id, err := uuid.NewV4()
+	if err != nil {
+		return "", err
+	}
+	// 微信 out_trade_no 限制 6-32 字节:EK + 30 位 hex
+	return "EK" + strings.ReplaceAll(id.String(), "-", "")[:30], nil
+}
+
+func orderView(order *dao.ExitOrder) *OrderView {
+	view := &OrderView{
+		OrderNo: order.OrderNo, Channel: order.Channel, Amount: order.Amount,
+		Currency: order.Currency, CodeURL: order.CodeURL, State: order.State,
+		ExpireAt: order.ExpireAt.Unix(),
+	}
+	if order.PaidAt != nil {
+		view.PaidAt = order.PaidAt.Unix()
+	}
+	var log []map[string]interface{}
+	if order.EventLog != "" {
+		_ = json.Unmarshal([]byte(order.EventLog), &log)
+	}
+	for i := len(log) - 1; i >= 0; i-- {
+		if log[i]["event"] == "settle_failed" {
+			if detail, _ := log[i]["detail"].(string); detail != "" {
+				view.SettleError = detail
+			}
+			break
+		}
+	}
+	return view
+}
+
+func recordSettleIncident(order *dao.ExitOrder, err error) {
+	incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryPaymentFailed,
+		Source:          incidentService.SourcePayment,
+		TicketNo:        order.TicketNo,
+		PlateNumber:     order.PlateNumber,
+		VehicleRecordID: order.SessionID,
+		ChannelID:       order.ExitChannelID,
+		ChannelCode:     order.ExitChannelCode,
+		DeviceCode:      order.ExitDeviceCode,
+		Description:     "扫码支付已到账但出场结算失败",
+		Detail:          err.Error(),
+	})
+}
+
+func recordGateIncident(order *dao.ExitOrder, err error) {
+	incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryGateFailed,
+		Source:          incidentService.SourcePayment,
+		TicketNo:        order.TicketNo,
+		PlateNumber:     order.PlateNumber,
+		VehicleRecordID: order.SessionID,
+		ChannelID:       order.ExitChannelID,
+		ChannelCode:     order.ExitChannelCode,
+		DeviceCode:      order.ExitDeviceCode,
+		Description:     "扫码支付已完成结算,但开闸失败",
+		Detail:          err.Error(),
+	})
+}
+
+// SimulateRecognition 模拟一次 LPR 识别:走真实 HandlePassage 管线,
+// 并与 devicebus 一致地把成功/失败结果推入通道事件(出口屏靠它感知识别)。
+func SimulateRecognition(deviceCode, plate, direction string) (*common.PassageResult, error) {
+	passages := &parkingSvc.PassageService{}
+	result, err := passages.HandlePassage(common.PassageRequest{
+		DeviceCode:    deviceCode,
+		PlateNumber:   plate,
+		Direction:     direction,
+		TriggerSource: "camera",
+	})
+	status, message, fee, stay := "completed", "", 0.0, int64(0)
+	if err != nil {
+		status = "failed"
+		message = err.Error()
+	} else if result != nil {
+		fee = result.Fee
+		stay = result.StayTime
+		if result.Message != "" {
+			message = result.Message
+		}
+	}
+	uhf.PushChannelEvent(uhf.ChannelEvent{
+		DeviceCode: deviceCode, PlateNumber: plate,
+		Direction: direction, Status: status, Message: message, Fee: fee, StayTime: stay,
+	})
+	return result, err
+}
+
+// FirstExitDevice 取第一个绑定了出场通道的启用设备。
+func FirstExitDevice() (string, error) {
+	var readers []dao.UHFReader
+	if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil {
+		return "", err
+	}
+	for _, r := range readers {
+		if r.Channel != nil && (r.Channel.Direction == "out" || r.Channel.Direction == "inout") {
+			return r.DeviceCode, nil
+		}
+	}
+	return "", errors.New("未找到绑定了出场通道的启用设备")
+}
+
+// BackfillEntryTime 回拨在场车辆(及其数字票)的入场时间,
+// 让停留时长跨过免费窗口,用于联调时稳定产生费用。仅调试端点调用。
+func BackfillEntryTime(plate string, hours int) error {
+	if hours <= 0 {
+		hours = 26
+	}
+	var record dao.VehicleRecord
+	if err := global.GVA_DB.Where("plate_number = ? AND exit_time IS NULL", plate).
+		Order("id DESC").First(&record).Error; err != nil {
+		return fmt.Errorf("未找到在场会话: %w", err)
+	}
+	past := time.Now().Add(-time.Duration(hours) * time.Hour)
+	if err := global.GVA_DB.Model(&dao.VehicleRecord{}).Where("id = ?", record.ID).
+		Update("entry_time", past).Error; err != nil {
+		return err
+	}
+	return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("vehicle_record_id = ?", record.ID).
+		Update("entry_time", past).Error
+}
+
+// firstEntryDevice 取第一个绑定了进场通道的启用设备。
+func FirstEntryDevice() (string, error) {
+	var readers []dao.UHFReader
+	if err := global.GVA_DB.Preload("Channel").Where("is_active = ?", true).Find(&readers).Error; err != nil {
+		return "", err
+	}
+	for _, r := range readers {
+		if r.Channel != nil && (r.Channel.Direction == "in" || r.Channel.Direction == "inout") {
+			return r.DeviceCode, nil
+		}
+	}
+	return "", errors.New("未找到绑定了进场通道的启用设备")
+}
+
+// DebugFullFlow 一键联调链路:确保车辆在场(不在则模拟入场识别)→
+// 回拨入场时间使费用必然产生 → 模拟出口识别 → mock 渠道标记支付 → 立即查单确认。
+// 返回最终聚合状态;黑名单等无法建单的场景原样返回原因。
+func (s *OrderService) DebugFullFlow(deviceCode, plate string, backfillHours int) (*KioskStateResponse, error) {
+	if strings.TrimSpace(deviceCode) == "" {
+		return nil, errors.New("缺少出口设备编码")
+	}
+	plate = strings.TrimSpace(plate)
+	if plate == "" {
+		return nil, errors.New("缺少车牌")
+	}
+	if backfillHours <= 0 {
+		backfillHours = 26
+	}
+
+	vehicleService := &vehicleSvc.VehicleService{}
+	if _, err := vehicleService.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: plate}); err != nil {
+		// 不在场:先模拟入口识别(真实管线,建会话+数字票)
+		entryDevice, derr := FirstEntryDevice()
+		if derr != nil {
+			return nil, derr
+		}
+		if _, eerr := SimulateRecognition(entryDevice, plate, "in"); eerr != nil {
+			return nil, fmt.Errorf("模拟入场失败: %w", eerr)
+		}
+	}
+
+	// 回拨入场时间,保证出场时费用 > 0(仅 DebugRoutes 开启时可达的测试逻辑)
+	if err := BackfillEntryTime(plate, backfillHours); err != nil {
+		return nil, err
+	}
+
+	// 入出口同一车牌 3 秒防抖,等窗口过去再触发出场识别
+	time.Sleep(3500 * time.Millisecond)
+
+	// 出口识别:临时车有费用被拒放行属预期结果,事件已推送
+	if _, err := SimulateRecognition(deviceCode, plate, "out"); err != nil && global.GVA_LOG != nil {
+		global.GVA_LOG.Info("联调出口识别被拒(预期)", zap.String("plate", plate), zap.Error(err))
+	}
+
+	state, err := s.KioskState(deviceCode)
+	if err != nil {
+		return nil, err
+	}
+	if state.Order == nil || state.Order.OrderNo == "" {
+		return state, nil
+	}
+
+	// mock 渠道模拟车主付款并立即确认(绕过查单节流)
+	if state.Order.Channel != paymentChannel.ChannelMock {
+		return state, nil
+	}
+	if err := paymentChannel.MarkMockPaid(state.Order.OrderNo); err != nil {
+		return nil, err
+	}
+	order, err := s.repo.GetByOrderNo(global.GVA_DB, state.Order.OrderNo)
+	if err != nil {
+		return nil, err
+	}
+	ch, err := paymentChannel.Get(order.Channel)
+	if err != nil {
+		return nil, err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	result, err := ch.QueryOrder(ctx, order.OrderNo)
+	if err != nil {
+		return nil, err
+	}
+	if result.Status == paymentChannel.QueryPaid {
+		if _, err := s.ConfirmOrderPaid(order, result.TransactionID); err != nil && global.GVA_LOG != nil {
+			global.GVA_LOG.Warn("联调确认到账后结算异常", zap.String("order_no", order.OrderNo), zap.Error(err))
+		}
+	}
+	return s.KioskState(deviceCode)
+}

+ 98 - 0
internal/modules/payment/channel/channel.go

@@ -0,0 +1,98 @@
+// Package channel 抽象在线支付渠道(ADR-0001)。
+// 到账确认以主动查单为主(ADR-0002):每个渠道必须实现 QueryOrder,
+// 回调仅作为可选加速器的挂点。
+package channel
+
+import (
+	"context"
+	"errors"
+	"sync"
+
+	"go.uber.org/zap"
+	"wails-app/internal/global"
+)
+
+// 渠道代码。渠道代码同时是出口自助支付的支付方式代码(wechat/alipay),
+// mock 仅用于联调,结算时归入 wechat 方式。
+const (
+	ChannelWechat = "wechat"
+	ChannelAlipay = "alipay"
+	ChannelMock   = "mock"
+)
+
+type CreateOrderRequest struct {
+	OrderNo     string  // 商户订单号(exit_order.order_no)
+	Amount      float64 // 金额(元)
+	Currency    string  // CNY 等
+	Description string  // 订单标题,如 "停车费-粤A12345"
+}
+
+type CreateOrderResult struct {
+	CodeURL string // 二维码内容,出口屏渲染为订单二维码
+}
+
+type QueryStatus string
+
+const (
+	QueryPaid    QueryStatus = "paid"
+	QueryPending QueryStatus = "pending"
+	QueryClosed  QueryStatus = "closed" // 已关闭/过期,订单应转 expired
+)
+
+type QueryOrderResult struct {
+	Status        QueryStatus
+	TransactionID string // 渠道流水号
+}
+
+// PaymentChannel 在线支付渠道接口。查单是一等能力。
+type PaymentChannel interface {
+	Code() string
+	CreateOrder(ctx context.Context, req CreateOrderRequest) (CreateOrderResult, error)
+	QueryOrder(ctx context.Context, orderNo string) (QueryOrderResult, error)
+}
+
+var (
+	mu       sync.RWMutex
+	registry = map[string]PaymentChannel{}
+)
+
+// Register 注册渠道实现,重复注册覆盖(便于测试)。
+func Register(ch PaymentChannel) {
+	mu.Lock()
+	defer mu.Unlock()
+	registry[ch.Code()] = ch
+}
+
+// Get 按代码取渠道;未注册返回错误。
+func Get(code string) (PaymentChannel, error) {
+	mu.RLock()
+	defer mu.RUnlock()
+	ch, ok := registry[code]
+	if !ok {
+		return nil, errors.New("支付渠道未注册: " + code)
+	}
+	return ch, nil
+}
+
+// DefaultChannel 出口自助支付使用的默认渠道:
+// 微信凭据齐全时用 wechat,否则回落 mock(联调)。可用 exit-kiosk.channel 显式覆盖。
+func DefaultChannel() string {
+	cfg := global.GVA_CONFIG.ExitKiosk.Channel
+	if cfg != "" {
+		return cfg
+	}
+	if global.GVA_CONFIG.WechatPay.Enabled {
+		return ChannelWechat
+	}
+	return ChannelMock
+}
+
+// SetupChannels 注册内置渠道实现,在路由初始化前调用一次。
+func SetupChannels() {
+	Register(NewMockChannel())
+	if wc, err := NewWechatChannelFromConfig(global.GVA_CONFIG.WechatPay); err == nil {
+		Register(wc)
+	} else if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn("微信支付渠道未启用", zap.Error(err))
+	}
+}

+ 56 - 0
internal/modules/payment/channel/mock.go

@@ -0,0 +1,56 @@
+package channel
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+)
+
+// MockChannel 联调用模拟渠道:订单创建即返回二维码内容,
+// 通过 MarkMockPaid 模拟"车主已付款",QueryOrder 才会返回已支付。
+type MockChannel struct {
+	mu   sync.Mutex
+	paid map[string]string // orderNo -> mock transaction id
+}
+
+func NewMockChannel() *MockChannel {
+	return &MockChannel{paid: map[string]string{}}
+}
+
+func (m *MockChannel) Code() string { return ChannelMock }
+
+func (m *MockChannel) CreateOrder(ctx context.Context, req CreateOrderRequest) (CreateOrderResult, error) {
+	// 二维码内容对 mock 无真实意义,保证同一订单稳定即可。
+	return CreateOrderResult{CodeURL: fmt.Sprintf("mockpay:order:%s:%.2f", req.OrderNo, req.Amount)}, nil
+}
+
+func (m *MockChannel) QueryOrder(ctx context.Context, orderNo string) (QueryOrderResult, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if txn, ok := m.paid[orderNo]; ok {
+		return QueryOrderResult{Status: QueryPaid, TransactionID: txn}, nil
+	}
+	return QueryOrderResult{Status: QueryPending}, nil
+}
+
+// MarkMockPaid 模拟渠道侧支付成功(调试端点调用)。
+func (m *MockChannel) MarkMockPaid(orderNo string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.paid[orderNo] = "MOCK" + orderNo
+}
+
+// MarkMockPaid 从注册表取 mock 渠道并模拟支付成功。
+func MarkMockPaid(orderNo string) error {
+	ch, err := Get(ChannelMock)
+	if err != nil {
+		return err
+	}
+	mock, ok := ch.(*MockChannel)
+	if !ok {
+		return errors.New("mock 渠道不可用")
+	}
+	mock.MarkMockPaid(orderNo)
+	return nil
+}

+ 229 - 0
internal/modules/payment/channel/wechat.go

@@ -0,0 +1,229 @@
+package channel
+
+import (
+	"bytes"
+	"context"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	"wails-app/internal/config"
+)
+
+const (
+	wechatAPIBase   = "https://api.mch.weixin.qq.com"
+	wechatNativePay = "/v3/pay/transactions/native"
+	wechatQueryByID = "/v3/pay/transactions/out-trade-no/"
+)
+
+// WechatChannel 微信支付 Native 扫码(APIv3)。
+// 到账确认走查单接口(ADR-0002);notify_url 仅透传给微信做预留挂点,
+// 系统内没有回调端点。v1 未校验应答的微信平台签名(查询应答为明文 JSON),
+// 商户私钥签名请求已启用。
+type WechatChannel struct {
+	appID      string
+	mchID      string
+	serialNo   string
+	privateKey *rsa.PrivateKey
+	notifyURL  string
+	currency   string
+	client     *http.Client
+}
+
+func NewWechatChannelFromConfig(cfg config.WechatPay) (*WechatChannel, error) {
+	if !cfg.Enabled {
+		return nil, errors.New("微信支付未启用")
+	}
+	if cfg.AppID == "" || cfg.MchID == "" || cfg.SerialNo == "" || cfg.PrivateKeyPath == "" {
+		return nil, errors.New("微信支付商户配置不完整(app-id/mch-id/serial-no/private-key-path)")
+	}
+	pemBytes, err := os.ReadFile(cfg.PrivateKeyPath)
+	if err != nil {
+		return nil, fmt.Errorf("读取商户私钥失败: %w", err)
+	}
+	key, err := parsePrivateKey(pemBytes)
+	if err != nil {
+		return nil, err
+	}
+	currency := strings.ToUpper(strings.TrimSpace(cfg.Currency))
+	if currency == "" {
+		currency = "CNY"
+	}
+	return &WechatChannel{
+		appID: cfg.AppID, mchID: cfg.MchID, serialNo: cfg.SerialNo,
+		privateKey: key, notifyURL: cfg.NotifyURL, currency: currency,
+		client: &http.Client{Timeout: 10 * time.Second},
+	}, nil
+}
+
+func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
+	for {
+		block, rest := pem.Decode(pemBytes)
+		if block == nil {
+			return nil, errors.New("商户私钥 PEM 解析失败")
+		}
+		if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+			return key, nil
+		}
+		if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
+			if rsaKey, ok := key.(*rsa.PrivateKey); ok {
+				return rsaKey, nil
+			}
+			return nil, errors.New("私钥不是 RSA 类型")
+		}
+		pemBytes = rest
+	}
+}
+
+func (w *WechatChannel) Code() string { return ChannelWechat }
+
+type wechatNativeResp struct {
+	CodeURL string `json:"code_url"`
+	Message string `json:"message"`
+}
+
+func (w *WechatChannel) CreateOrder(ctx context.Context, req CreateOrderRequest) (CreateOrderResult, error) {
+	total, err := yuanToFen(req.Amount)
+	if err != nil {
+		return CreateOrderResult{}, err
+	}
+	body := map[string]interface{}{
+		"appid":        w.appID,
+		"mchid":        w.mchID,
+		"description":  req.Description,
+		"out_trade_no": req.OrderNo,
+		"amount":       map[string]interface{}{"total": total, "currency": w.currency},
+	}
+	if w.notifyURL != "" {
+		body["notify_url"] = w.notifyURL
+	}
+	var resp wechatNativeResp
+	if err := w.call(ctx, http.MethodPost, wechatNativePay, body, &resp); err != nil {
+		return CreateOrderResult{}, err
+	}
+	if resp.CodeURL == "" {
+		return CreateOrderResult{}, fmt.Errorf("微信下单未返回 code_url: %s", resp.Message)
+	}
+	return CreateOrderResult{CodeURL: resp.CodeURL}, nil
+}
+
+type wechatQueryResp struct {
+	TradeState    string `json:"trade_state"`
+	TransactionID string `json:"transaction_id"`
+	OutTradeNo    string `json:"out_trade_no"`
+}
+
+func (w *WechatChannel) QueryOrder(ctx context.Context, orderNo string) (QueryOrderResult, error) {
+	path := wechatQueryByID + orderNo + "?mchid=" + w.mchID
+	var resp wechatQueryResp
+	if err := w.call(ctx, http.MethodGet, path, nil, &resp); err != nil {
+		return QueryOrderResult{}, err
+	}
+	switch resp.TradeState {
+	case "SUCCESS":
+		return QueryOrderResult{Status: QueryPaid, TransactionID: resp.TransactionID}, nil
+	case "CLOSED", "REVOKED", "PAYERROR":
+		return QueryOrderResult{Status: QueryClosed}, nil
+	default: // NOTPAY, USERPAYING 等
+		return QueryOrderResult{Status: QueryPending}, nil
+	}
+}
+
+// call 发起微信 APIv3 请求并用商户私钥签名(WECHATPAY2-SHA256withRSA)。
+func (w *WechatChannel) call(ctx context.Context, method, path string, body interface{}, out interface{}) error {
+	var bodyBytes []byte
+	if body != nil {
+		var err error
+		bodyBytes, err = json.Marshal(body)
+		if err != nil {
+			return err
+		}
+	}
+	req, err := http.NewRequestWithContext(ctx, method, wechatAPIBase+path, bytes.NewReader(bodyBytes))
+	if err != nil {
+		return err
+	}
+	authorization, err := w.authorizationHeader(method, path, bodyBytes)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", authorization)
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("User-Agent", "smart-parking-exit-kiosk")
+
+	resp, err := w.client.Do(req)
+	if err != nil {
+		return fmt.Errorf("微信支付请求失败: %w", err)
+	}
+	defer resp.Body.Close()
+	respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("微信支付接口错误 HTTP %d: %s", resp.StatusCode, truncate(respBytes, 300))
+	}
+	if out != nil && len(respBytes) > 0 {
+		if err := json.Unmarshal(respBytes, out); err != nil {
+			return fmt.Errorf("微信支付应答解析失败: %w", err)
+		}
+	}
+	return nil
+}
+
+func (w *WechatChannel) authorizationHeader(method, path string, body []byte) (string, error) {
+	timestamp := fmt.Sprintf("%d", time.Now().Unix())
+	nonce, err := randomNonce()
+	if err != nil {
+		return "", err
+	}
+	message := strings.Join([]string{method, path, timestamp, nonce, string(body)}, "\n") + "\n"
+	digest := sha256.Sum256([]byte(message))
+	signature, err := rsa.SignPKCS1v15(rand.Reader, w.privateKey, crypto.SHA256, digest[:])
+	if err != nil {
+		return "", fmt.Errorf("微信支付签名失败: %w", err)
+	}
+	return fmt.Sprintf(`WECHATPAY2-SHA256withRSA mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
+		w.mchID, nonce, base64.StdEncoding.EncodeToString(signature), timestamp, w.serialNo), nil
+}
+
+func randomNonce() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// yuanToFen 金额(元)转分,超出两位小数视为非法。
+func yuanToFen(amount float64) (int64, error) {
+	if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
+		return 0, errors.New("支付金额必须是有限的非负数")
+	}
+	fen := math.Round(amount * 100)
+	if math.Abs(fen-amount*100) > 0.51 {
+		return 0, fmt.Errorf("支付金额精度超限: %.4f", amount)
+	}
+	return int64(fen), nil
+}
+
+func truncate(b []byte, n int) string {
+	if len(b) <= n {
+		return string(b)
+	}
+	return string(b[:n]) + "..."
+}