Bläddra i källkod

feat: 中央缴费机对接(POF协议/支付回调/模拟器与权限种子)

- internal/modules/central-payment:POF 设备 token 鉴权、
  queryprice/paysuccess/paystatus/heartbeat 端点与协议日志脱敏落库
- cmd/pof-simulator 缴费机模拟器;central_payment_seed 与权限种子
- 支付文案修正:人工支付→现金支付;paymentEntry 页面同步
lq 14 timmar sedan
förälder
incheckning
92d2b222b2

+ 94 - 0
cmd/pof-simulator/main.go

@@ -0,0 +1,94 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type envelope struct {
+	Result int             `json:"Result"`
+	Token  string          `json:"Token"`
+	Data   json.RawMessage `json:"Data"`
+	ReMark string          `json:"ReMark"`
+}
+
+func post(client *http.Client, baseURL, path string, payload interface{}) (envelope, error) {
+	var result envelope
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return result, err
+	}
+	response, err := client.Post(strings.TrimRight(baseURL, "/")+path, "application/json", bytes.NewReader(body))
+	if err != nil {
+		return result, err
+	}
+	defer response.Body.Close()
+	data, err := io.ReadAll(response.Body)
+	if err != nil {
+		return result, err
+	}
+	fmt.Printf("%s\n%s\n", path, string(data))
+	if response.StatusCode != http.StatusOK {
+		return result, fmt.Errorf("HTTP %d", response.StatusCode)
+	}
+	if err := json.Unmarshal(data, &result); err != nil {
+		return result, fmt.Errorf("响应不是合法 POF JSON: %w", err)
+	}
+	if result.Result != 1 {
+		return result, fmt.Errorf("%s", result.ReMark)
+	}
+	return result, nil
+}
+
+func main() {
+	baseURL := flag.String("base-url", "http://127.0.0.1:8888", "Smart Parking server URL")
+	username := flag.String("username", "pof-sim", "POF device username")
+	password := flag.String("password", "pof-sim-123", "POF device password")
+	plate := flag.String("plate", "粤A12345", "plate number of an active parking session")
+	payType := flag.Int("pay-type", 0, "0=cash, 1=POS")
+	noPay := flag.Bool("no-pay", false, "only login, query price and heartbeat")
+	flag.Parse()
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	login, err := post(client, *baseURL, "/api/login", map[string]string{"UserName": *username, "Password": *password})
+	if err != nil {
+		panic(err)
+	}
+	query, err := post(client, *baseURL, "/api/queryprice", map[string]interface{}{
+		"Token": login.Token,
+		"Data":  map[string]string{"CarNo": *plate, "QueryTime": time.Now().Format("2006-01-02 15:04:05")},
+	})
+	if err != nil {
+		panic(err)
+	}
+	var price struct {
+		OrderID string `json:"OrderId"`
+		CarNo   string `json:"CarNo"`
+		Price   string `json:"Price"`
+	}
+	if err := json.Unmarshal(query.Data, &price); err != nil {
+		panic(err)
+	}
+	if !*noPay {
+		_, err = post(client, *baseURL, "/api/paysuccess", map[string]interface{}{
+			"Token": login.Token,
+			"Data": map[string]interface{}{
+				"OrderId": price.OrderID, "CarNo": price.CarNo, "Money": price.Price,
+				"PayTime": time.Now().Format("2006-01-02 15:04:05"), "PayType": *payType,
+				"PayDev": 2, "DevId": "POF-SIM-01", "ReMark": "POF simulator",
+			},
+		})
+		if err != nil {
+			panic(err)
+		}
+	}
+	if _, err := post(client, *baseURL, "/api/heartbeat", map[string]string{"Token": login.Token}); err != nil {
+		panic(err)
+	}
+}

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

@@ -219,7 +219,7 @@ export default {
   rfidInput: '请输入RFID标签',
   exitRecordDetail: '进场: {entryTime} | 停留: {duration} | 费用: ¥{fee}',
   paymentMethod: '支付方式',
-	cashPayment: '人工支付',
+	cashPayment: '现金支付',
   wechatPayment: '扫码支付',
   posPayment: 'POS刷卡',
   cashReceived: '实收金额',
@@ -359,7 +359,7 @@ export default {
 	digitalTicketEntryCounter: '收费岗亭',
 	digitalTicketEntryTicket: '停车小票',
 	digitalTicketEntryCentral: '中央缴费机',
-	digitalTicketMethodCash: '人工支付',
+	digitalTicketMethodCash: '现金支付',
 	digitalTicketMethodPos: 'POS机支付',
 	digitalTicketMethodQr: '小票扫码支付',
 	digitalTicketCopied: '票号已复制',

+ 1 - 1
frontend/src/view/parking/paymentEntry.vue

@@ -67,7 +67,7 @@
               {{ method.name }}
             </el-checkbox>
           </el-checkbox-group>
-          <div class="form-hint">人工支付是基础方式,始终保留在入口中;其它方式可按入口单独启停。</div>
+          <div class="form-hint">现金支付是基础方式,始终保留在入口中;其它方式可按入口单独启停。</div>
         </el-form-item>
         <el-form-item label="排序">
           <el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" />

+ 46 - 0
internal/dao/central_payment_device.go

@@ -0,0 +1,46 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+// CentralPaymentDevice is an independently authenticated POF payment device.
+// It deliberately does not share credentials with a Smart Parking web user.
+type CentralPaymentDevice struct {
+	global.GVA_MODEL
+	DeviceID     string `gorm:"size:64;not null;uniqueIndex" json:"device_id"`
+	Username     string `gorm:"size:64;not null;uniqueIndex" json:"username"`
+	PasswordHash string `gorm:"size:100;not null" json:"-"`
+	Enabled      bool   `gorm:"not null;default:true" json:"enabled"`
+	ParkingLotID uint   `json:"parking_lot_id"`
+	PaymentEntry string `gorm:"size:32;not null" json:"payment_entry"`
+	AllowedIPs   string `gorm:"size:500" json:"allowed_ips"`
+	Description  string `gorm:"size:200" json:"description"`
+}
+
+func (CentralPaymentDevice) TableName() string { return "central_payment_device" }
+
+// CentralPaymentToken keeps only a SHA-256 digest of an issued device token.
+type CentralPaymentToken struct {
+	global.GVA_MODEL
+	DeviceID  string    `gorm:"size:64;not null;index" json:"device_id"`
+	TokenHash string    `gorm:"size:64;not null;uniqueIndex" json:"-"`
+	ExpireAt  time.Time `gorm:"not null;index" json:"expire_at"`
+}
+
+func (CentralPaymentToken) TableName() string { return "central_payment_token" }
+
+// CentralPaymentRequestLog records POF requests for field verification and
+// reconciliation. Token values are intentionally excluded from RequestBody.
+type CentralPaymentRequestLog struct {
+	global.GVA_MODEL
+	Endpoint    string `gorm:"size:64;not null;index" json:"endpoint"`
+	DeviceID    string `gorm:"size:64;index" json:"device_id"`
+	SourceIP    string `gorm:"size:64;index" json:"source_ip"`
+	RequestBody string `gorm:"type:text" json:"request_body"`
+	Result      int    `json:"result"`
+	Remark      string `gorm:"size:500" json:"remark"`
+}
+
+func (CentralPaymentRequestLog) TableName() string { return "central_payment_request_log" }

+ 78 - 0
internal/initialize/central_payment_seed.go

@@ -0,0 +1,78 @@
+package initialize
+
+import (
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	utils "wails-app/internal/pkg"
+)
+
+const (
+	pofSimulatorDeviceID       = "POF-SIM-01"
+	pofSimulatorUsername       = "admin"
+	pofSimulatorPassword       = "admin"
+	legacyPOFSimulatorUsername = "pof-sim"
+)
+
+// EnsureCentralPaymentConfiguration creates the POF payment entry and a local
+// simulator credential. Real equipment must use a separately created device.
+func EnsureCentralPaymentConfiguration() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+	if err := db.AutoMigrate(&dao.CentralPaymentDevice{}, &dao.CentralPaymentToken{}, &dao.CentralPaymentRequestLog{}); err != nil {
+		return fmt.Errorf("初始化中央缴费机表失败: %w", err)
+	}
+
+	var entry dao.PaymentEntryConfig
+	err := db.Where("code = ?", "central_payment_machine").First(&entry).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		entry = dao.PaymentEntryConfig{
+			Code: "central_payment_machine", Name: "中央缴费机", Enabled: true, Sort: 30,
+			IsSystem: true, Description: "POF 标准协议中央缴费机发起的支付",
+		}
+		if err := db.Create(&entry).Error; err != nil {
+			return fmt.Errorf("初始化中央缴费机支付入口失败: %w", err)
+		}
+	} else if err != nil {
+		return err
+	}
+	for index, methodCode := range []string{"cash", "pos"} {
+		var relationCount int64
+		if err := db.Model(&dao.PaymentEntryMethod{}).Where("entry_code = ? AND method_code = ?", entry.Code, methodCode).Count(&relationCount).Error; err != nil {
+			return err
+		}
+		if relationCount == 0 {
+			if err := db.Create(&dao.PaymentEntryMethod{EntryCode: entry.Code, MethodCode: methodCode, Sort: index * 10}).Error; err != nil {
+				return fmt.Errorf("初始化中央缴费机支付方式失败: %w", err)
+			}
+		}
+	}
+
+	var device dao.CentralPaymentDevice
+	err = db.Where("device_id = ?", pofSimulatorDeviceID).First(&device).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		device = dao.CentralPaymentDevice{
+			DeviceID: pofSimulatorDeviceID, Username: pofSimulatorUsername,
+			PasswordHash: utils.BcryptHash(pofSimulatorPassword), Enabled: true,
+			PaymentEntry: "central_payment_machine", Description: "仅用于本地 POF 模拟桩联调,真机启用前应禁用或删除",
+		}
+		if err := db.Create(&device).Error; err != nil {
+			return fmt.Errorf("初始化 POF 模拟设备失败: %w", err)
+		}
+	} else if err != nil {
+		return err
+	} else if device.Username == legacyPOFSimulatorUsername {
+		if err := db.Model(&device).Updates(map[string]interface{}{
+			"username":      pofSimulatorUsername,
+			"password_hash": utils.BcryptHash(pofSimulatorPassword),
+		}).Error; err != nil {
+			return fmt.Errorf("升级 POF 模拟设备登录凭据失败: %w", err)
+		}
+	}
+	return nil
+}

+ 14 - 0
internal/initialize/operation_permission_seed.go

@@ -34,6 +34,10 @@ func EnsureOperationPermissions() error {
 		{Path: "/parking/gate/open", Description: "人工开闸", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/parking/gate/close", Description: "人工关闸", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/parking/camera/streams", Description: "查询摄像头实时流", ApiGroup: "进出场操作", Method: "GET"},
+		{Path: "/parking/camera/create", Description: "创建摄像头配置", ApiGroup: "摄像头管理", Method: "POST"},
+		{Path: "/parking/camera/update", Description: "更新摄像头配置", ApiGroup: "摄像头管理", Method: "PUT"},
+		{Path: "/parking/camera/list", Description: "查询摄像头配置", ApiGroup: "摄像头管理", Method: "GET"},
+		{Path: "/parking/camera/delete", Description: "删除摄像头配置", ApiGroup: "摄像头管理", Method: "DELETE"},
 		{Path: "/ticket-machine/button", Description: "票机按钮入场", ApiGroup: "进出场操作", Method: "POST"},
 	}
 	for _, api := range apis {
@@ -84,6 +88,16 @@ func EnsureOperationPermissions() error {
 			return fmt.Errorf("初始化管理员设备接入权限失败: %w", err)
 		}
 	}
+	for _, rule := range [][2]string{
+		{"/parking/camera/create", "POST"},
+		{"/parking/camera/update", "PUT"},
+		{"/parking/camera/list", "GET"},
+		{"/parking/camera/delete", "DELETE"},
+	} {
+		if err := ensureCasbinRule("888", rule[0], rule[1]); err != nil {
+			return fmt.Errorf("初始化管理员摄像头管理权限失败: %w", err)
+		}
+	}
 
 	debugAPIs := []dao.SysApi{
 		{Path: "/ticket-machine/test", Description: "票机业务链路测试", ApiGroup: "开发调试", Method: "POST"},

+ 6 - 0
internal/initialize/seed.go

@@ -23,6 +23,9 @@ func SeedSystemData() {
 		if err := EnsurePaymentConfiguration(); err != nil {
 			global.GVA_LOG.Error("payment configuration upgrade failed")
 		}
+		if err := EnsureCentralPaymentConfiguration(); err != nil {
+			global.GVA_LOG.Error("central payment configuration upgrade failed")
+		}
 		if err := EnsureOperationPermissions(); err != nil {
 			global.GVA_LOG.Error("operation permission upgrade failed")
 		}
@@ -539,6 +542,9 @@ func SeedSystemData() {
 	if err := EnsurePaymentConfiguration(); err != nil {
 		global.GVA_LOG.Error("payment configuration seed failed")
 	}
+	if err := EnsureCentralPaymentConfiguration(); err != nil {
+		global.GVA_LOG.Error("central payment configuration seed failed")
+	}
 	if err := EnsureOperationPermissions(); err != nil {
 		global.GVA_LOG.Error("operation permission seed failed")
 	}

+ 6 - 0
internal/middleware/operation.go

@@ -80,6 +80,12 @@ func maskResponse(resp string) string {
 
 func OperationRecord() gin.HandlerFunc {
 	return func(c *gin.Context) {
+		// GET 请求是只读查询,很多页面会周期性轮询。避免为每次查询
+		// 同步写入操作日志,减少 SQLite 写锁竞争;写操作仍完整审计。
+		if c.Request.Method == http.MethodGet {
+			c.Next()
+			return
+		}
 		var body []byte
 		var userId int
 		if c.Request.Method != http.MethodGet {

+ 239 - 0
internal/modules/central-payment/api.go

@@ -0,0 +1,239 @@
+package centralpayment
+
+import (
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/central-payment/model/request"
+	responseModel "wails-app/internal/modules/central-payment/model/response"
+	centralService "wails-app/internal/modules/central-payment/service"
+)
+
+var svc = centralService.New()
+
+func requestFields(c *gin.Context, endpoint string, fields ...zap.Field) []zap.Field {
+	base := []zap.Field{
+		zap.String("endpoint", endpoint),
+		zap.String("source_ip", c.ClientIP()),
+		zap.String("content_type", c.ContentType()),
+		zap.Int64("content_length", c.Request.ContentLength),
+	}
+	return append(base, fields...)
+}
+
+func logInfo(c *gin.Context, message, endpoint string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info(message, requestFields(c, endpoint, fields...)...)
+	}
+}
+
+func logWarn(c *gin.Context, message, endpoint string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn(message, requestFields(c, endpoint, fields...)...)
+	}
+}
+
+func passwordFormat(password string) string {
+	trimmed := strings.TrimSpace(password)
+	if trimmed == "" {
+		return "empty"
+	}
+	if len(trimmed) == 32 {
+		isHex := true
+		for _, ch := range trimmed {
+			if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
+				isHex = false
+				break
+			}
+		}
+		if isHex {
+			return "md5-32-hex_unsupported"
+		}
+	}
+	if len(trimmed) == 60 && (strings.HasPrefix(trimmed, "$2a$") || strings.HasPrefix(trimmed, "$2b$") || strings.HasPrefix(trimmed, "$2y$")) {
+		return "bcrypt-hash_unsupported"
+	}
+	return "plain-text_expected"
+}
+
+func fail(c *gin.Context, endpoint, deviceID, remark string, body interface{}) {
+	svc.Log(endpoint, deviceID, c.ClientIP(), body, 0, remark)
+	logWarn(c, "中央缴费机请求处理失败", endpoint, zap.String("device_id", deviceID), zap.String("reason", remark))
+	c.JSON(http.StatusOK, responseModel.Envelope{Result: 0, Data: struct{}{}, ReMark: remark})
+}
+
+func Login(c *gin.Context) {
+	var req request.LoginRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		logWarn(c, "中央缴费机收到无法解析的登录请求", "/api/login", zap.Error(err))
+		fail(c, "/api/login", "", "请求参数错误", struct{}{})
+		return
+	}
+	format := passwordFormat(req.Password)
+	logInfo(c, "中央缴费机收到登录请求", "/api/login",
+		zap.String("username", strings.TrimSpace(req.UserName)),
+		zap.String("password_format", format), zap.Int("password_length", len(req.Password)))
+	token, err := svc.Login(req)
+	if err != nil {
+		fail(c, "/api/login", "", err.Error(), map[string]interface{}{
+			"username": strings.TrimSpace(req.UserName), "password_format": format, "password_length": len(req.Password),
+		})
+		return
+	}
+	svc.Log("/api/login", "", c.ClientIP(), map[string]interface{}{
+		"username": strings.TrimSpace(req.UserName), "password_format": format, "password_length": len(req.Password),
+	}, 1, "success")
+	logInfo(c, "中央缴费机登录成功", "/api/login", zap.String("username", strings.TrimSpace(req.UserName)))
+	c.JSON(http.StatusOK, responseModel.LoginResponse{Result: 1, Token: token, ReMark: "success"})
+}
+
+func QueryPrice(c *gin.Context) {
+	var req request.QueryPriceRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		logWarn(c, "中央缴费机收到无法解析的费用查询请求", "/api/queryprice", zap.Error(err))
+		fail(c, "/api/queryprice", "", "请求参数错误", struct{}{})
+		return
+	}
+	logInfo(c, "中央缴费机收到费用查询请求", "/api/queryprice", zap.String("car_no", req.Data.CarNo), zap.Bool("token_present", strings.TrimSpace(req.Token) != ""))
+	device, err := svc.Authenticate(req.Token)
+	if err != nil {
+		fail(c, "/api/queryprice", "", err.Error(), req.Data)
+		return
+	}
+	price, err := svc.QueryPrice(device, req.Data.CarNo)
+	if err != nil {
+		fail(c, "/api/queryprice", device.DeviceID, err.Error(), req.Data)
+		return
+	}
+	data := responseModel.QueryPriceData{
+		OrderID: price.OrderID, CarNo: price.CarNo,
+		InTime:   price.InTime.Format("2006-01-02 15:04:05"),
+		OutTime:  price.OutTime.Format("2006-01-02 15:04:05"),
+		StopTime: price.StopTime, Price: price.Price,
+		CardNo: "", Remark: "系统实时计费", InPic: "",
+	}
+	svc.Log("/api/queryprice", device.DeviceID, c.ClientIP(), req.Data, 1, "success")
+	logInfo(c, "中央缴费机费用查询成功", "/api/queryprice", zap.String("device_id", device.DeviceID), zap.String("car_no", req.Data.CarNo), zap.String("order_id", price.OrderID))
+	c.JSON(http.StatusOK, responseModel.Envelope{Result: 1, Data: data, ReMark: "success"})
+}
+
+func PaySuccess(c *gin.Context) {
+	var req request.PaySuccessRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		logWarn(c, "中央缴费机收到无法解析的支付通知", "/api/paysuccess", zap.Error(err))
+		fail(c, "/api/paysuccess", "", "请求参数错误", struct{}{})
+		return
+	}
+	logInfo(c, "中央缴费机收到支付通知", "/api/paysuccess",
+		zap.String("order_id", strings.TrimSpace(req.Data.OrderID)),
+		zap.String("car_no", strings.TrimSpace(req.Data.CarNo)),
+		zap.String("device_id", strings.TrimSpace(req.Data.DevID)),
+		zap.Int("pay_type", req.Data.PayType),
+		zap.Int("pay_dev", req.Data.PayDev),
+		zap.String("money_raw", strings.TrimSpace(string(req.Data.Money))),
+		zap.String("pay_time", req.Data.PayTime),
+		zap.Bool("token_present", strings.TrimSpace(req.Token) != ""))
+	device, err := svc.Authenticate(req.Token)
+	if err != nil {
+		fail(c, "/api/paysuccess", "", err.Error(), req.Data)
+		return
+	}
+	callbackDeviceID := strings.TrimSpace(req.Data.DevID)
+	logInfo(c, "中央缴费机支付身份校验", "/api/paysuccess",
+		zap.String("token_device_id", device.DeviceID),
+		zap.String("callback_device_id", callbackDeviceID),
+		zap.Bool("device_id_match", callbackDeviceID == device.DeviceID))
+	if err := svc.PaySuccess(device, req); err != nil {
+		fail(c, "/api/paysuccess", device.DeviceID, err.Error(), req.Data)
+		return
+	}
+	svc.Log("/api/paysuccess", device.DeviceID, c.ClientIP(), req.Data, 1, "success")
+	logInfo(c, "中央缴费机支付通知处理成功", "/api/paysuccess", zap.String("device_id", device.DeviceID), zap.String("order_id", strings.TrimSpace(req.Data.OrderID)), zap.String("money_raw", strings.TrimSpace(string(req.Data.Money))))
+	c.JSON(http.StatusOK, responseModel.Envelope{Result: 1, Data: struct{}{}, ReMark: "success"})
+}
+
+func PayStatus(c *gin.Context) {
+	var req request.PayStatusRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		logWarn(c, "中央缴费机收到无法解析的支付状态查询", "/api/paystatus", zap.Error(err))
+		fail(c, "/api/paystatus", "", "请求参数错误", struct{}{})
+		return
+	}
+	logInfo(c, "中央缴费机收到支付状态查询", "/api/paystatus",
+		zap.String("order_id", strings.TrimSpace(req.Data.OrderID)),
+		zap.String("car_no", strings.TrimSpace(req.Data.CarNo)),
+		zap.String("device_id", strings.TrimSpace(req.Data.DevID)),
+		zap.Bool("token_present", strings.TrimSpace(req.Token) != ""))
+	device, err := svc.Authenticate(req.Token)
+	if err != nil {
+		fail(c, "/api/paystatus", "", err.Error(), req.Data)
+		return
+	}
+	callbackDeviceID := strings.TrimSpace(req.Data.DevID)
+	logInfo(c, "中央缴费机支付状态身份校验", "/api/paystatus",
+		zap.String("token_device_id", device.DeviceID),
+		zap.String("callback_device_id", callbackDeviceID),
+		zap.Bool("device_id_match", callbackDeviceID == "" || callbackDeviceID == device.DeviceID))
+	ticket, record, err := svc.PayStatus(device, req)
+	if err != nil {
+		fail(c, "/api/paystatus", device.DeviceID, err.Error(), req.Data)
+		return
+	}
+	payStatus := "unpaid"
+	if ticket.State == "paid" || ticket.State == "exited" || record.PaymentStatus == "paid" {
+		payStatus = "paid"
+	}
+	payTime := ""
+	if ticket.PaidAt != nil {
+		payTime = ticket.PaidAt.Format("2006-01-02 15:04:05")
+	}
+	data := responseModel.PayStatusData{
+		OrderID: ticket.TicketNo, CarNo: ticket.PlateNumber, State: ticket.State,
+		PayStatus: payStatus, PaymentStatus: record.PaymentStatus,
+		Fee: strconv.FormatFloat(ticket.Fee, 'f', 2, 64), Money: strconv.FormatFloat(ticket.PaidAmount, 'f', 2, 64),
+		PaidAmount: strconv.FormatFloat(ticket.PaidAmount, 'f', 2, 64), PayTime: payTime,
+	}
+	svc.Log("/api/paystatus", device.DeviceID, c.ClientIP(), req.Data, 1, "success")
+	logInfo(c, "中央缴费机支付状态查询成功", "/api/paystatus",
+		zap.String("device_id", device.DeviceID), zap.String("order_id", ticket.TicketNo),
+		zap.String("state", ticket.State), zap.String("record_payment_status", record.PaymentStatus),
+		zap.String("pay_status", payStatus), zap.Float64("fee", ticket.Fee),
+		zap.Float64("paid_amount", ticket.PaidAmount), zap.String("payment_entry", ticket.PaymentEntry),
+		zap.String("payment_method", ticket.PaymentMethod), zap.String("pay_time", payTime))
+	c.JSON(http.StatusOK, responseModel.Envelope{Result: 1, Data: data, ReMark: "success"})
+}
+
+func Heartbeat(c *gin.Context) {
+	var req request.HeartbeatRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		logWarn(c, "中央缴费机收到无法解析的心跳请求", "/api/heartbeat", zap.Error(err))
+		fail(c, "/api/heartbeat", "", "请求参数错误", struct{}{})
+		return
+	}
+	logInfo(c, "中央缴费机收到心跳请求", "/api/heartbeat", zap.Bool("token_present", strings.TrimSpace(req.Token) != ""))
+	device, err := svc.Authenticate(req.Token)
+	if err != nil {
+		fail(c, "/api/heartbeat", "", err.Error(), struct{}{})
+		return
+	}
+	data := map[string]string{"SystemTime": time.Now().Format("2006-01-02 15:04:05")}
+	svc.Log("/api/heartbeat", device.DeviceID, c.ClientIP(), struct{}{}, 1, "success")
+	logInfo(c, "中央缴费机心跳处理成功", "/api/heartbeat", zap.String("device_id", device.DeviceID))
+	c.JSON(http.StatusOK, responseModel.Envelope{Result: 1, Data: data, ReMark: "success"})
+}
+
+// SetupPublicRouter is intentionally unauthenticated at the Gin level. Each
+// POF endpoint applies its own isolated device-token authentication.
+func SetupPublicRouter(router *gin.RouterGroup) {
+	group := router.Group("/api")
+	group.POST("/login", Login)
+	group.POST("/queryprice", QueryPrice)
+	group.POST("/paysuccess", PaySuccess)
+	group.POST("/paystatus", PayStatus)
+	group.POST("/heartbeat", Heartbeat)
+}

+ 135 - 0
internal/modules/central-payment/api_test.go

@@ -0,0 +1,135 @@
+package centralpayment
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	ticketService "wails-app/internal/modules/digital-ticket/service"
+	paymentService "wails-app/internal/modules/payment/service"
+	utils "wails-app/internal/pkg"
+)
+
+func TestPOFSimulatorHTTPFlow(t *testing.T) {
+	dsn := fmt.Sprintf("file:pof-http-%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() })
+
+	previousDB := global.GVA_DB
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = previousDB })
+	require.NoError(t, db.AutoMigrate(
+		&dao.CentralPaymentDevice{}, &dao.CentralPaymentToken{}, &dao.CentralPaymentRequestLog{},
+		&dao.Owner{}, &dao.Vehicle{}, &dao.VehicleType{}, &dao.FeeConfig{}, &dao.VehicleRecord{}, &dao.DigitalTicket{},
+		&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}, &dao.PaymentRecord{},
+		&dao.Shortlist{}, &dao.Channel{}, &dao.UHFReader{}, &dao.ParkingLot{}, &dao.MonthlyCard{},
+	))
+
+	device := dao.CentralPaymentDevice{
+		DeviceID: "POF-SIM-01", Username: "admin", PasswordHash: utils.BcryptHash("admin"),
+		Enabled: true, PaymentEntry: "central_payment_machine",
+	}
+	require.NoError(t, db.Create(&device).Error)
+	require.NoError(t, db.Create(&dao.PaymentMethodConfig{
+		Code: paymentService.PaymentCash, Name: "现金", InputMode: paymentService.InputModeManualAmount,
+		Enabled: true, IsSystem: true,
+	}).Error)
+	require.NoError(t, db.Create(&dao.PaymentEntryConfig{
+		Code: "central_payment_machine", Name: "中央缴费机", Enabled: true, IsSystem: true,
+	}).Error)
+	require.NoError(t, db.Create(&dao.PaymentEntryMethod{
+		EntryCode: "central_payment_machine", MethodCode: paymentService.PaymentCash,
+	}).Error)
+
+	vehicleType := dao.VehicleType{Name: "临时车", IsSystem: true}
+	require.NoError(t, db.Create(&vehicleType).Error)
+	require.NoError(t, db.Create(&dao.FeeConfig{
+		VehicleTypeID: vehicleType.ID, StartTime: 0, StartFee: 0, UnitTime: 60, UnitFee: 1, DailyMaxFee: 100,
+	}).Error)
+	require.NoError(t, db.Create(&dao.Vehicle{PlateNumber: "POF-TEST", VehicleTypeID: vehicleType.ID}).Error)
+	record := dao.VehicleRecord{
+		PlateNumber: "POF-TEST", EntryTime: time.Now().Add(-2 * time.Hour), PaymentStatus: "unpaid",
+	}
+	require.NoError(t, db.Create(&record).Error)
+	ticket, err := ticketService.NewTicketService().CreateTx(db, record.PlateNumber, "pof-test", record.ID)
+	require.NoError(t, err)
+
+	router := gin.New()
+	SetupPublicRouter(router.Group(""))
+	server := httptest.NewServer(router)
+	t.Cleanup(server.Close)
+
+	login := pofPost(t, server.URL, "/api/login", map[string]string{"UserName": "admin", "Password": "admin"})
+	token, ok := login["Token"].(string)
+	require.True(t, ok)
+	require.NotEmpty(t, token)
+
+	var loginLog dao.CentralPaymentRequestLog
+	require.NoError(t, db.Where("endpoint = ?", "/api/login").First(&loginLog).Error)
+	require.NotEmpty(t, loginLog.SourceIP)
+	require.Contains(t, loginLog.RequestBody, `"password_format":"plain-text_expected"`)
+	require.NotContains(t, loginLog.RequestBody, `"password":"admin"`)
+
+	price := pofPost(t, server.URL, "/api/queryprice", map[string]interface{}{
+		"Token": token,
+		"Data":  map[string]string{"CarNo": record.PlateNumber, "InTime": record.EntryTime.Format("2006-01-02 15:04:05")},
+	})
+	priceData := price["Data"].(map[string]interface{})
+	require.Equal(t, ticket.TicketNo, priceData["OrderId"])
+	priceValue, ok := priceData["Price"].(string)
+	require.True(t, ok)
+
+	payment := map[string]interface{}{
+		"Token": token,
+		"Data": map[string]interface{}{
+			"OrderId": ticket.TicketNo, "CarNo": record.PlateNumber, "Money": priceValue,
+			"PayTime": time.Now().Format("2006-01-02 15:04:05"), "PayType": 0, "PayDev": 2,
+			// DevId is an audit field; authentication relies on the Token.
+			"DevId": "FC068C0D365F", "ReMark": "HTTP simulator",
+		},
+	}
+	require.EqualValues(t, 1, pofPost(t, server.URL, "/api/paysuccess", payment)["Result"])
+	status := pofPost(t, server.URL, "/api/paystatus", map[string]interface{}{
+		"Token": token, "Data": map[string]string{"OrderId": ticket.TicketNo, "CarNo": record.PlateNumber, "DevId": "FC068C0D365F"},
+	})
+	statusData := status["Data"].(map[string]interface{})
+	require.Equal(t, "paid", statusData["PayStatus"])
+	require.Equal(t, ticketService.StatePaid, statusData["State"])
+	// A common device retry must be accepted without generating another payment record.
+	require.EqualValues(t, 1, pofPost(t, server.URL, "/api/paysuccess", payment)["Result"])
+	require.EqualValues(t, 1, pofPost(t, server.URL, "/api/heartbeat", map[string]string{"Token": token})["Result"])
+
+	var paymentCount int64
+	require.NoError(t, db.Model(&dao.PaymentRecord{}).Where("record_id = ?", record.ID).Count(&paymentCount).Error)
+	require.EqualValues(t, 1, paymentCount)
+	var persistedTicket dao.DigitalTicket
+	require.NoError(t, db.First(&persistedTicket, ticket.ID).Error)
+	require.Equal(t, ticketService.StatePaid, persistedTicket.State)
+}
+
+func pofPost(t *testing.T, baseURL, path string, payload interface{}) map[string]interface{} {
+	t.Helper()
+	body, err := json.Marshal(payload)
+	require.NoError(t, err)
+	response, err := http.Post(baseURL+path, "application/json", bytes.NewReader(body))
+	require.NoError(t, err)
+	defer response.Body.Close()
+	require.Equal(t, http.StatusOK, response.StatusCode)
+	var envelope map[string]interface{}
+	require.NoError(t, json.NewDecoder(response.Body).Decode(&envelope))
+	require.EqualValues(t, 1, envelope["Result"], "POF failure: %v", envelope["ReMark"])
+	return envelope
+}

+ 46 - 0
internal/modules/central-payment/model/request/pof.go

@@ -0,0 +1,46 @@
+package request
+
+import "encoding/json"
+
+type LoginRequest struct {
+	UserName string `json:"UserName"`
+	Password string `json:"Password"`
+}
+
+type QueryPriceRequest struct {
+	Token string `json:"Token"`
+	Data  struct {
+		CarNo     string `json:"CarNo"`
+		QueryTime string `json:"QueryTime"`
+		InTime    string `json:"InTime"`
+	} `json:"Data"`
+}
+
+type PaySuccessRequest struct {
+	Token string `json:"Token"`
+	Data  struct {
+		OrderID string          `json:"OrderId"`
+		CarNo   string          `json:"CarNo"`
+		Money   json.RawMessage `json:"Money"`
+		PayTime string          `json:"PayTime"`
+		PayType int             `json:"PayType"`
+		PayDev  int             `json:"PayDev"`
+		DevID   string          `json:"DevId"`
+		Remark  string          `json:"ReMark"`
+	} `json:"Data"`
+}
+
+// PayStatusRequest is used by a payment machine to reconcile a payment
+// notification that may have timed out or been retried.
+type PayStatusRequest struct {
+	Token string `json:"Token"`
+	Data  struct {
+		OrderID string `json:"OrderId"`
+		CarNo   string `json:"CarNo"`
+		DevID   string `json:"DevId"`
+	} `json:"Data"`
+}
+
+type HeartbeatRequest struct {
+	Token string `json:"Token"`
+}

+ 39 - 0
internal/modules/central-payment/model/response/pof.go

@@ -0,0 +1,39 @@
+package response
+
+// Envelope follows the POF protocol's fixed Result/Data/ReMark response form.
+type Envelope struct {
+	Result int         `json:"Result"`
+	Data   interface{} `json:"Data"`
+	ReMark string      `json:"ReMark"`
+}
+
+type LoginResponse struct {
+	Result int      `json:"Result"`
+	Token  string   `json:"Token,omitempty"`
+	Data   struct{} `json:"Data"`
+	ReMark string   `json:"ReMark"`
+}
+
+type QueryPriceData struct {
+	OrderID  string `json:"OrderId"`
+	CarNo    string `json:"CarNo"`
+	InTime   string `json:"InTime"`
+	OutTime  string `json:"OutTime"`
+	StopTime string `json:"StopTime"`
+	Price    string `json:"Price"`
+	CardNo   string `json:"CardNo"`
+	Remark   string `json:"Remark"`
+	InPic    string `json:"InPic"`
+}
+
+type PayStatusData struct {
+	OrderID       string `json:"OrderId"`
+	CarNo         string `json:"CarNo"`
+	State         string `json:"State"`
+	PayStatus     string `json:"PayStatus"`
+	PaymentStatus string `json:"PaymentStatus"`
+	Fee           string `json:"Fee"`
+	Money         string `json:"Money"`
+	PaidAmount    string `json:"PaidAmount"`
+	PayTime       string `json:"PayTime"`
+}

+ 72 - 0
internal/modules/central-payment/repository/repo.go

@@ -0,0 +1,72 @@
+package repository
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type Repository struct{}
+
+func New() *Repository { return &Repository{} }
+
+func (r *Repository) FindDeviceByUsername(username string) (*dao.CentralPaymentDevice, error) {
+	var device dao.CentralPaymentDevice
+	if err := global.GVA_DB.Where("username = ?", username).First(&device).Error; err != nil {
+		return nil, err
+	}
+	return &device, nil
+}
+
+func (r *Repository) FindDeviceByTokenHash(hash string, now time.Time) (*dao.CentralPaymentDevice, error) {
+	var token dao.CentralPaymentToken
+	if err := global.GVA_DB.Where("token_hash = ? AND expire_at > ?", hash, now).First(&token).Error; err != nil {
+		return nil, err
+	}
+	var device dao.CentralPaymentDevice
+	if err := global.GVA_DB.Where("device_id = ? AND enabled = ?", token.DeviceID, true).First(&device).Error; err != nil {
+		return nil, err
+	}
+	return &device, nil
+}
+
+func (r *Repository) ReplaceTokens(deviceID string, token *dao.CentralPaymentToken) error {
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("device_id = ?", deviceID).Delete(&dao.CentralPaymentToken{}).Error; err != nil {
+			return err
+		}
+		return tx.Create(token).Error
+	})
+}
+
+func (r *Repository) FindTicket(ticketNo string) (*dao.DigitalTicket, error) {
+	var ticket dao.DigitalTicket
+	if err := global.GVA_DB.Where("ticket_no = ?", ticketNo).First(&ticket).Error; err != nil {
+		return nil, err
+	}
+	return &ticket, nil
+}
+
+func (r *Repository) FindVehicleRecord(id uint) (*dao.VehicleRecord, error) {
+	var record dao.VehicleRecord
+	if err := global.GVA_DB.First(&record, id).Error; err != nil {
+		return nil, err
+	}
+	return &record, nil
+}
+
+func (r *Repository) FindLatestPayment(recordID uint, entry string) (*dao.PaymentRecord, error) {
+	var payment dao.PaymentRecord
+	if err := global.GVA_DB.Where("record_id = ? AND payment_entry = ?", recordID, entry).Order("id DESC").First(&payment).Error; err != nil {
+		return nil, err
+	}
+	return &payment, nil
+}
+
+func (r *Repository) Log(log *dao.CentralPaymentRequestLog) {
+	if global.GVA_DB != nil {
+		_ = global.GVA_DB.Create(log).Error
+	}
+}

+ 272 - 0
internal/modules/central-payment/service/service.go

@@ -0,0 +1,272 @@
+package service
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	vehicleRequest "wails-app/internal/model/vehicle/request"
+	"wails-app/internal/modules/central-payment/model/request"
+	centralRepo "wails-app/internal/modules/central-payment/repository"
+	paymentService "wails-app/internal/modules/payment/service"
+	utils "wails-app/internal/pkg"
+	vehicleService "wails-app/internal/service/vehicle"
+)
+
+const (
+	PaymentEntryCentral = "central_payment_machine"
+	tokenLifetime       = 30 * time.Minute
+)
+
+type Service struct {
+	repo       *centralRepo.Repository
+	vehicleSvc *vehicleService.VehicleService
+}
+
+func New() *Service {
+	return &Service{repo: centralRepo.New(), vehicleSvc: &vehicleService.VehicleService{}}
+}
+
+func tokenHash(token string) string {
+	digest := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(digest[:])
+}
+
+func randomToken() (string, error) {
+	bytes := make([]byte, 32)
+	if _, err := rand.Read(bytes); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(bytes), nil
+}
+
+func (s *Service) Login(req request.LoginRequest) (string, error) {
+	username := strings.TrimSpace(req.UserName)
+	if username == "" || req.Password == "" {
+		return "", errors.New("用户名或密码不能为空")
+	}
+	device, err := s.repo.FindDeviceByUsername(username)
+	if err != nil || !device.Enabled || !utils.BcryptCheck(req.Password, device.PasswordHash) {
+		return "", errors.New("用户名或密码错误")
+	}
+	token, err := randomToken()
+	if err != nil {
+		return "", fmt.Errorf("生成设备令牌失败: %w", err)
+	}
+	if err := s.repo.ReplaceTokens(device.DeviceID, &dao.CentralPaymentToken{
+		DeviceID: device.DeviceID, TokenHash: tokenHash(token), ExpireAt: time.Now().Add(tokenLifetime),
+	}); err != nil {
+		return "", fmt.Errorf("保存设备令牌失败: %w", err)
+	}
+	return token, nil
+}
+
+func (s *Service) Authenticate(token string) (*dao.CentralPaymentDevice, error) {
+	if strings.TrimSpace(token) == "" {
+		return nil, errors.New("Token不能为空")
+	}
+	device, err := s.repo.FindDeviceByTokenHash(tokenHash(token), time.Now())
+	if err != nil {
+		return nil, errors.New("Token无效或已过期")
+	}
+	return device, nil
+}
+
+func stopTimeText(minutes int64) string {
+	if minutes < 60 {
+		return fmt.Sprintf("%d分钟", minutes)
+	}
+	return fmt.Sprintf("%d小时%d分钟", minutes/60, minutes%60)
+}
+
+func moneyString(amount float64) string {
+	return strconv.FormatFloat(amount, 'f', 2, 64)
+}
+
+func parseMoney(raw json.RawMessage) (float64, error) {
+	value := strings.TrimSpace(string(raw))
+	if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
+		if err := json.Unmarshal(raw, &value); err != nil {
+			return 0, errors.New("Money格式错误")
+		}
+	}
+	amount, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+	if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
+		return 0, errors.New("Money必须是非负金额")
+	}
+	return amount, nil
+}
+
+func moneyEqual(left, right float64) bool {
+	return math.Abs(left-right) < 0.005
+}
+
+func paymentMethod(payType int) (string, error) {
+	switch payType {
+	case 0:
+		return paymentService.PaymentCash, nil
+	case 1:
+		return paymentService.PaymentPOS, nil
+	default:
+		return "", errors.New("PayType仅支持0(现金)或1(刷卡)")
+	}
+}
+
+type Price struct {
+	OrderID  string
+	CarNo    string
+	InTime   time.Time
+	OutTime  time.Time
+	StopTime string
+	Price    string
+}
+
+func (s *Service) QueryPrice(device *dao.CentralPaymentDevice, carNo string) (*Price, error) {
+	carNo = strings.TrimSpace(carNo)
+	if carNo == "" {
+		return nil, errors.New("CarNo不能为空")
+	}
+	preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: carNo})
+	if err != nil {
+		return nil, err
+	}
+	if preview.TicketNo == "" || preview.TicketState != "pending_payment" || preview.PaymentStatus != "unpaid" {
+		return nil, errors.New("当前订单不允许缴费")
+	}
+	if device != nil && device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID {
+		return nil, errors.New("设备无权查询该停车场订单")
+	}
+	now := time.Now()
+	return &Price{
+		OrderID: preview.TicketNo, CarNo: preview.PlateNumber,
+		InTime: time.Unix(preview.EntryTime, 0), OutTime: now,
+		StopTime: stopTimeText(preview.StayTime), Price: moneyString(preview.Fee),
+	}, nil
+}
+
+func (s *Service) PaySuccess(device *dao.CentralPaymentDevice, req request.PaySuccessRequest) error {
+	if req.Data.PayDev != 2 {
+		return errors.New("PayDev必须为2(POF)")
+	}
+	// Token authentication already binds the request to a central payment
+	// device. DevId is retained as a protocol/audit field and is deliberately
+	// non-blocking because vendor hardware identifiers may differ from the
+	// management-system device_id.
+	method, err := paymentMethod(req.Data.PayType)
+	if err != nil {
+		return err
+	}
+	money, err := parseMoney(req.Data.Money)
+	if err != nil {
+		return err
+	}
+	orderID := strings.TrimSpace(req.Data.OrderID)
+	carNo := strings.TrimSpace(req.Data.CarNo)
+	if orderID == "" || carNo == "" {
+		return errors.New("OrderId和CarNo不能为空")
+	}
+
+	ticket, err := s.repo.FindTicket(orderID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return errors.New("订单不存在")
+		}
+		return err
+	}
+	if ticket.PlateNumber != "" && ticket.PlateNumber != carNo {
+		return errors.New("OrderId与CarNo不匹配")
+	}
+
+	entry := device.PaymentEntry
+	if entry == "" {
+		entry = PaymentEntryCentral
+	}
+	if ticket.State == "paid" {
+		if !s.matchesExistingPayment(ticket, entry, method, money) {
+			return errors.New("订单已支付,支付数据不一致")
+		}
+		return nil
+	}
+	if ticket.State != "pending_payment" {
+		return fmt.Errorf("订单当前状态不允许支付: %s", ticket.State)
+	}
+
+	preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{SessionID: ticket.VehicleRecordID})
+	if err != nil {
+		return fmt.Errorf("重新计算订单费用失败: %w", err)
+	}
+	if preview.TicketNo != orderID || preview.PlateNumber != carNo {
+		return errors.New("订单对应的在场车辆不匹配")
+	}
+	if device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID {
+		return errors.New("设备无权支付该停车场订单")
+	}
+	if !moneyEqual(preview.Fee, money) {
+		return errors.New("金额已变化,请重新查询")
+	}
+	if err := s.vehicleSvc.PayDigitalTicket(orderID, entry, method, "", money, 0); err != nil {
+		// A retry can race with the first notification. Re-read the order so a
+		// successful first payment is reported as an idempotent success.
+		if current, findErr := s.repo.FindTicket(orderID); findErr == nil && current.State == "paid" && s.matchesExistingPayment(current, entry, method, money) {
+			return nil
+		}
+		return fmt.Errorf("写入数字票支付状态失败: %w", err)
+	}
+	return nil
+}
+
+// PayStatus returns the persisted ticket state without recalculating or
+// mutating payment data. It is intended for payment-machine retry/reconcile
+// flows after a paysuccess response was lost.
+func (s *Service) PayStatus(device *dao.CentralPaymentDevice, req request.PayStatusRequest) (*dao.DigitalTicket, *dao.VehicleRecord, error) {
+	orderID := strings.TrimSpace(req.Data.OrderID)
+	carNo := strings.TrimSpace(req.Data.CarNo)
+	if orderID == "" {
+		return nil, nil, errors.New("OrderId不能为空")
+	}
+	ticket, err := s.repo.FindTicket(orderID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil, errors.New("订单不存在")
+		}
+		return nil, nil, err
+	}
+	if carNo != "" && ticket.PlateNumber != "" && ticket.PlateNumber != carNo {
+		return nil, nil, errors.New("OrderId与CarNo不匹配")
+	}
+	record, err := s.repo.FindVehicleRecord(ticket.VehicleRecordID)
+	if err != nil {
+		return nil, nil, errors.New("订单对应的停车记录不存在")
+	}
+	if device.ParkingLotID != 0 && device.ParkingLotID != record.ParkingLotID {
+		return nil, nil, errors.New("设备无权查询该停车场订单")
+	}
+	return ticket, record, nil
+}
+
+func (s *Service) matchesExistingPayment(ticket *dao.DigitalTicket, entry, method string, money float64) bool {
+	payment, err := s.repo.FindLatestPayment(ticket.VehicleRecordID, entry)
+	return err == nil && payment.PaymentMethod == method && moneyEqual(payment.Amount, money)
+}
+
+// Log persists protocol metadata for field troubleshooting. The caller must
+// pass a redacted body: device tokens and login passwords must never be saved.
+func (s *Service) Log(endpoint, deviceID, sourceIP string, body interface{}, result int, remark string) {
+	payload, err := json.Marshal(body)
+	if err != nil {
+		payload = []byte("{}")
+	}
+	s.repo.Log(&dao.CentralPaymentRequestLog{
+		Endpoint: endpoint, DeviceID: deviceID, SourceIP: sourceIP,
+		RequestBody: string(payload), Result: result, Remark: remark,
+	})
+}

+ 52 - 0
internal/modules/central-payment/service/service_test.go

@@ -0,0 +1,52 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/central-payment/model/request"
+	utils "wails-app/internal/pkg"
+)
+
+func TestDeviceLoginAndTokenAuthentication(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open("file:central_payment_login?mode=memory&cache=shared"), &gorm.Config{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := db.AutoMigrate(&dao.CentralPaymentDevice{}, &dao.CentralPaymentToken{}, &dao.CentralPaymentRequestLog{}); err != nil {
+		t.Fatal(err)
+	}
+	previousDB := global.GVA_DB
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = previousDB })
+
+	device := dao.CentralPaymentDevice{
+		DeviceID: "POF-TEST-01", Username: "pof-test", PasswordHash: utils.BcryptHash("test-password"),
+		Enabled: true, PaymentEntry: PaymentEntryCentral,
+	}
+	if err := db.Create(&device).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := New()
+	token, err := svc.Login(request.LoginRequest{UserName: "pof-test", Password: "test-password"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if token == "" {
+		t.Fatal("expected a token")
+	}
+	authenticated, err := svc.Authenticate(token)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if authenticated.DeviceID != device.DeviceID {
+		t.Fatalf("authenticated device = %q, want %q", authenticated.DeviceID, device.DeviceID)
+	}
+	if _, err := svc.Login(request.LoginRequest{UserName: "pof-test", Password: "wrong"}); err == nil {
+		t.Fatal("expected invalid password to fail")
+	}
+}

+ 3 - 1
internal/modules/incident/repository/repo.go

@@ -89,7 +89,9 @@ func (r *IncidentRepository) ExistsRecent(deviceCode, category, plateNumber, rfi
 	if plateNumber != "" {
 		db = db.Where("plate_number = ?", plateNumber)
 	} else {
-		db = db.Where("rfid_tag = ?", rfidTag)
+		// GORM maps RFIDTag to rf_id_tag. Keep this condition model-based so it
+		// cannot drift from the actual database column name.
+		db = db.Where(&dao.IncidentRecord{RFIDTag: rfidTag})
 	}
 	var count int64
 	err := db.Count(&count).Error

+ 5 - 1
internal/modules/incident/service/service.go

@@ -368,7 +368,11 @@ func (s *IncidentService) RecordIncident(req RecordIncidentRequest) uint {
 		exists, err = s.repo.ExistsRecent(req.DeviceCode, req.Category, req.PlateNumber, req.RFIDTag, 5)
 	}
 	if err != nil {
-		logWarn("异常去重查询失败,继续记录", zap.Error(err))
+		// A failed deduplication check must not be treated as a new incident.
+		// Device readers can report the same tag repeatedly; continuing here
+		// would turn one schema or database fault into an unbounded write storm.
+		logWarn("异常去重查询失败,跳过记录", zap.Error(err))
+		return 0
 	} else if exists {
 		return 0
 	}

+ 23 - 0
internal/modules/incident/service/service_test.go

@@ -173,6 +173,29 @@ func TestRecordIncidentBestEffortAndDedup(t *testing.T) {
 	require.Zero(t, id5)
 }
 
+func TestRecordIncidentRFIDDedupUsesModelColumn(t *testing.T) {
+	svc := setupIncidentTest(t)
+	req := RecordIncidentRequest{
+		Category: CategoryNoEntryExit, Source: SourcePassage, DeviceCode: "RFID-01",
+		RFIDTag: "e20047159e306026709a0109", Description: "无入场记录",
+	}
+
+	require.NotZero(t, svc.RecordIncident(req))
+	require.Zero(t, svc.RecordIncident(req))
+	require.Equal(t, int64(1), countIncidents(t))
+}
+
+func TestRecordIncidentSkipsWhenDedupQueryFails(t *testing.T) {
+	svc := setupIncidentTest(t)
+	require.NoError(t, global.GVA_DB.Migrator().DropTable(&dao.IncidentRecord{}))
+
+	id := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryNoEntryExit, Source: SourcePassage, DeviceCode: "RFID-01",
+		RFIDTag: "e20047159e306026709a0109", Description: "无入场记录",
+	})
+	require.Zero(t, id)
+}
+
 func TestDeviceOfflineDedupAndResolve(t *testing.T) {
 	svc := setupIncidentTest(t)
 

+ 1 - 1
internal/modules/payment/service/service.go

@@ -191,7 +191,7 @@ func (s *PaymentService) SetMethodEnabled(id uint, enabled bool) error {
 		return errors.New("支付方式不存在")
 	}
 	if method.Code == PaymentCash && !enabled {
-		return errors.New("人工支付是基础功能,不能停用")
+		return errors.New("现金支付是基础功能,不能停用")
 	}
 	return s.repo.SetMethodEnabled(id, enabled)
 }

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

@@ -28,7 +28,7 @@ func setupPaymentServiceTest(t *testing.T) *PaymentService {
 	global.GVA_DB = db
 	require.NoError(t, db.AutoMigrate(&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}, &dao.VehicleRecord{}, &dao.PaymentRecord{}))
 	require.NoError(t, db.Create(&[]dao.PaymentMethodConfig{
-		{Code: PaymentCash, Name: "人工支付", InputMode: InputModeManualAmount, Scenes: []string{SceneCounter}, Enabled: true, Sort: 10, IsSystem: true},
+		{Code: PaymentCash, Name: "现金支付", InputMode: InputModeManualAmount, Scenes: []string{SceneCounter}, Enabled: true, Sort: 10, IsSystem: true},
 		{Code: PaymentPOS, Name: "POS机支付", InputMode: InputModeConfirm, Scenes: []string{SceneCounter}, Enabled: true, Sort: 20, IsSystem: true},
 		{Code: PaymentTicketQR, Name: "小票扫码支付", InputMode: InputModeExternal, Scenes: []string{SceneTicket}, Enabled: true, Sort: 30, IsSystem: true},
 	}).Error)
@@ -51,7 +51,7 @@ func TestCashCannotBeDisabledOrDeleted(t *testing.T) {
 	method, err := svc.repo.GetMethodByCode(nil, PaymentCash)
 	require.NoError(t, err)
 
-	require.EqualError(t, svc.SetMethodEnabled(method.ID, false), "人工支付是基础功能,不能停用")
+	require.EqualError(t, svc.SetMethodEnabled(method.ID, false), "现金支付是基础功能,不能停用")
 	require.EqualError(t, svc.DeleteMethod(method.ID), "系统内置支付方式不能删除")
 }
 

+ 1 - 1
internal/service/vehicle/vehicle_record.go

@@ -47,7 +47,7 @@ func (s *VehicleRecordService) ListVehicleRecordsByPlateNumber(plateNumber strin
 
 // ListVehicleRecords 获取所有车辆记录列表
 func (s *VehicleRecordService) ListVehicleRecords(info request.VehicleRecordRequest) (list []dao.VehicleRecord, total int64, err error) {
-	db := global.GVA_DB.Debug().Model(&dao.VehicleRecord{})
+	db := global.GVA_DB.Model(&dao.VehicleRecord{})
 
 	limit := info.PageSize
 	offset := info.PageSize * (info.Page - 1)

+ 33 - 2
main.go

@@ -3,11 +3,16 @@ package main
 import (
 	"context"
 	"embed"
+	"fmt"
 	"io/fs"
 	"log"
 	"net/http"
 	"net/http/httputil"
 	"net/url"
+	"os"
+	"path/filepath"
+	"runtime/debug"
+	"time"
 
 	"wails-app/internal"
 
@@ -21,6 +26,12 @@ import (
 var assets embed.FS
 
 func main() {
+	defer func() {
+		if recovered := recover(); recovered != nil {
+			writeStartupError(fmt.Sprintf("panic: %v\n%s", recovered, debug.Stack()))
+		}
+	}()
+
 	// Initialize the Gin backend (non-blocking goroutine, listens on :8888)
 	internal.InitBackend()
 
@@ -34,7 +45,8 @@ func main() {
 	// Embedded frontend static files
 	distFS, err := fs.Sub(assets, "frontend/dist")
 	if err != nil {
-		log.Fatal("failed to get embedded frontend:", err)
+		writeStartupError(fmt.Sprintf("failed to get embedded frontend: %v", err))
+		return
 	}
 	fileServer := http.FileServer(http.FS(distFS))
 
@@ -81,6 +93,25 @@ func main() {
 	})
 
 	if err != nil {
-		log.Fatal(err)
+		writeStartupError(fmt.Sprintf("Wails failed to start: %v", err))
+		log.Print(err)
+	}
+}
+
+// writeStartupError runs before the configured logger may exist. Keep the
+// diagnostic beside the executable so production deployment failures are visible.
+func writeStartupError(message string) {
+	directory, err := os.Executable()
+	if err == nil {
+		directory = filepath.Dir(directory)
+	} else if directory, err = os.Getwd(); err != nil {
+		return
+	}
+
+	file, err := os.OpenFile(filepath.Join(directory, "startup-error.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+	if err != nil {
+		return
 	}
+	defer file.Close()
+	_, _ = fmt.Fprintf(file, "[%s] %s\n\n", time.Now().Format(time.RFC3339), message)
 }