Просмотр исходного кода

feat: 计费引擎重构与缴费后免费离场时限

计费引擎(CalculateFee 返回 error):
- 无收费配置返回明确错误,不再隐式按 0.1 元/分钟兜底
- 多日停车日封顶按跨天数累计(跨天向上取整)
- 停留时长向上取整(不足一分钟按一分钟),白名单车绕过计费
- 数字票预览保持宽容(配置缺失时费用显示 0)

收费配置:
- Create/Update 统一校验(免费时长/单位时间/金额有限非负/封顶>=起步价/VIP折扣(0,1])
- 分页兜底;种子为临时车建占位配置并补正存量脏数据
- 前端 pricing.vue 同规则前置校验

缴费后免费离场时限:
- FeeConfig.FreeExitMinutes(default:15,0=不限),存量经 DDL default 迁移
- 已支付票超时限出场按实际时长全额重算差额,未补缴/免密方式拒绝放行
- 补缴写独立流水、票面累计、PaidAt 刷新;预览返回 overtime 字段
- 前端工作台超时告警与补缴交互
lq 2 недель назад
Родитель
Сommit
6df86a0505

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

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

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

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

+ 1 - 0
internal/dao/fee_config.go

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

+ 68 - 0
internal/initialize/fee_seed.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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