2026-07-23-digital-ticket-plan.md 11 KB

数字票(Digital Ticket)— 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.

Goal: 引入数字票统一抽象,所有进场方式创建 digital_ticket,5 状态机驱动停车流程

Architecture: 新增 internal/modules/digital-ticket/ 模块。VehicleEntry 调用 CreateTicket 创建票。状态转换原子化,事件日志 JSON 追加。


文件结构

文件 操作
internal/dao/digital_ticket.go 新建
internal/modules/digital-ticket/api.go 新建
internal/modules/digital-ticket/service/service.go 新建
internal/modules/digital-ticket/repository/repo.go 新建
internal/service/vehicle/vehicle.go 修改
internal/initialize/gorm.go 修改
internal/initialize/router.go 修改

Task 1: 数据层 + 服务层

  • Step 1: DAO

Write internal/dao/digital_ticket.go:

package dao

import (
	"time"
	"wails-app/internal/global"
)

type DigitalTicket struct {
	global.GVA_MODEL
	TicketNo        string     `gorm:"size:64;uniqueIndex" json:"ticket_no"`
	PlateNumber     string     `gorm:"size:20" json:"plate_number"`
	TriggerMode     string     `gorm:"size:20" json:"trigger_mode"` // rfid / plate_recognition / manual
	State           string     `gorm:"size:20;default:created" json:"state"`
	VehicleRecordID uint       `json:"vehicle_record_id"`
	EntryTime       time.Time  `json:"entry_time"`
	ExitTime        *time.Time `json:"exit_time"`
	Fee             float64    `json:"fee"`
	PaymentMethod   string     `gorm:"size:20" json:"payment_method"`
	PaymentOrderNo  string     `gorm:"size:64" json:"payment_order_no"`
	PaidAt          *time.Time `json:"paid_at"`
	ExpireAt        *time.Time `json:"expire_at"`
	EventLog        string     `gorm:"type:text" json:"event_log"` // JSON array
}

func (DigitalTicket) TableName() string { return "digital_ticket" }
  • Step 2: AutoMigrate

Edit internal/initialize/gorm.go, add after dao.MonthlyCard{}:

dao.MonthlyCard{},
dao.DigitalTicket{},
  • Step 3: Repository

Write internal/modules/digital-ticket/repository/repo.go:

package repository

import (
	"wails-app/internal/dao"
	"wails-app/internal/global"
)

type TicketRepository struct{}

func (r *TicketRepository) Create(ticket *dao.DigitalTicket) error {
	return global.GVA_DB.Create(ticket).Error
}

func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, error) {
	var t dao.DigitalTicket
	err := global.GVA_DB.Where("ticket_no = ?", ticketNo).First(&t).Error
	if err != nil { return nil, err }
	return &t, nil
}

func (r *TicketRepository) UpdateState(id uint, state string, updates map[string]interface{}) error {
	updates["state"] = state
	return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
}

type TicketListQuery struct {
	PlateNumber string `form:"plate_number"`
	State       string `form:"state"`
	Page        int    `form:"page"`
	PageSize    int    `form:"page_size"`
}

func (r *TicketRepository) List(q TicketListQuery) ([]dao.DigitalTicket, int64, error) {
	db := global.GVA_DB.Model(&dao.DigitalTicket{})
	if q.PlateNumber != "" { db = db.Where("plate_number LIKE ?", "%"+q.PlateNumber+"%") }
	if q.State != "" { db = db.Where("state = ?", q.State) }
	var total int64; db.Count(&total)
	if q.Page <= 0 { q.Page = 1 }; if q.PageSize <= 0 { q.PageSize = 10 }
	var list []dao.DigitalTicket
	err := db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error
	return list, total, err
}
  • Step 4: Service

Write internal/modules/digital-ticket/service/service.go:

package service

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"time"
	"wails-app/internal/dao"
	"wails-app/internal/global"
	"wails-app/internal/modules/digital-ticket/repository"
)

const (
	StateCreated        = "created"
	StatePendingPayment = "pending_payment"
	StatePaid           = "paid"
	StateExited         = "exited"
	StateExpired        = "expired"
)

type TicketService struct {
	repo *repository.TicketRepository
}

func NewTicketService() *TicketService { return &TicketService{repo: &repository.TicketRepository{}} }

func genTicketNo() string {
	b := make([]byte, 16); rand.Read(b)
	return hex.EncodeToString(b)
}

func appendEvent(log string, event map[string]interface{}) string {
	var events []map[string]interface{}
	json.Unmarshal([]byte(log), &events)
	events = append(events, event)
	data, _ := json.Marshal(events)
	return string(data)
}

// Create 创建数字票(所有进场方式统一入口)
func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
	log := appendEvent("[]", map[string]interface{}{"at": time.Now().Format(time.RFC3339), "event": "created", "trigger": triggerMode})
	ticket := &dao.DigitalTicket{
		TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
		State: StateCreated, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
	}
	if err := s.repo.Create(ticket); err != nil { return nil, err }
	// created → pending_payment
	s.Transition(ticket.ID, StatePendingPayment, nil)
	return ticket, nil
}

// Transition 状态转换(原子化,校验合法性)
func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
	ticket, err := s.repo.GetByTicketNo(fmt.Sprintf("%d", id))
	if err != nil { ticket = &dao.DigitalTicket{Model: global.GVA_MODEL{ID: id}} }

	validTransitions := map[string][]string{
		StateCreated:        {StatePendingPayment},
		StatePendingPayment: {StatePaid, StateExpired},
		StatePaid:           {StateExited, StateExpired},
		StateExited:         {},
		StateExpired:        {},
	}
	allowed := false
	for _, s := range validTransitions[ticket.State] { if s == toState { allowed = true; break } }
	if !allowed { return errors.New("状态转换不合法: " + ticket.State + " → " + toState) }

	updates := map[string]interface{}{}
	if toState == StatePaid {
		updates["paid_at"] = time.Now()
	}
	if toState == StateExited {
		now := time.Now(); updates["exit_time"] = now
	}
	if toState == StateExpired {
		updates["expire_at"] = time.Now()
	}
	for k, v := range extra { updates[k] = v }

	// 追加事件日志
	event := map[string]interface{}{"at": time.Now().Format(time.RFC3339), "event": toState}
	for k, v := range extra { event[k] = v }
	updates["event_log"] = appendEvent(ticket.EventLog, event)

	return s.repo.UpdateState(id, toState, updates)
}

func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
	return s.repo.GetByTicketNo(no)
}

func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
	return s.repo.List(q)
}
  • Step 5: 编译+提交
mkdir -p internal/modules/digital-ticket/service internal/modules/digital-ticket/repository
go build -o build/bin/smart-parking.exe .
git add internal/dao/digital_ticket.go internal/modules/digital-ticket/ internal/initialize/gorm.go
git commit -m "feat: 数字票模块——DAO/repo/service,5状态机+事件日志"

Task 2: API + 路由 + 集成

  • Step 1: api.go

Write internal/modules/digital-ticket/api.go:

package digital_ticket

import (
	"github.com/gin-gonic/gin"
	"wails-app/internal/model/common/response"
	"wails-app/internal/modules/digital-ticket/repository"
	"wails-app/internal/modules/digital-ticket/service"
)

var ticketSvc = service.NewTicketService()

func GetTicket(c *gin.Context) {
	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
	if err != nil { response.FailWithMessage("票不存在", c); return }
	response.OkWithData(t, c)
}

func CalculateFee(c *gin.Context) {
	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
	if err != nil { response.FailWithMessage("票不存在", c); return }
	if err := ticketSvc.Transition(t.ID, service.StatePendingPayment, nil); err != nil {
		response.FailWithMessage(err.Error(), c); return
	}
	response.OkWithMessage("计费完成", c)
}

type payReq struct {
	PaymentMethod  string  `json:"payment_method"`
	PaymentOrderNo string  `json:"payment_order_no"`
	PaidAmount     float64 `json:"paid_amount"`
}

func PayTicket(c *gin.Context) {
	var req payReq
	if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c); return }
	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
	if err != nil { response.FailWithMessage("票不存在", c); return }

	extra := map[string]interface{}{
		"payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
		"fee": t.Fee, "paid_amount": req.PaidAmount,
	}
	if err := ticketSvc.Transition(t.ID, service.StatePaid, extra); err != nil {
		response.FailWithMessage(err.Error(), c); return
	}
	// 同时更新 payment_method 和 payment_order_no
	global.GVA_DB.Model(t).Updates(map[string]interface{}{
		"payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
	})
	response.OkWithMessage("支付成功", c)
}

func ExitTicket(c *gin.Context) {
	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
	if err != nil { response.FailWithMessage("票不存在", c); return }
	if err := ticketSvc.Transition(t.ID, service.StateExited, nil); err != nil {
		response.FailWithMessage(err.Error(), c); return
	}
	response.OkWithMessage("已离场", c)
}

func ListTickets(c *gin.Context) {
	var q repository.TicketListQuery
	if err := c.ShouldBindQuery(&q); err != nil { response.FailWithMessage(err.Error(), c); return }
	list, total, err := ticketSvc.List(q)
	if err != nil { response.FailWithMessage(err.Error(), c); return }
	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
}

func SetupDigitalTicketRouter(router *gin.RouterGroup) {
	dt := router.Group("/digital-ticket")
	dt.GET("/:ticketNo", GetTicket)
	dt.POST("/:ticketNo/calculate", CalculateFee)
	dt.POST("/:ticketNo/pay", PayTicket)
	dt.POST("/:ticketNo/exit", ExitTicket)
	dt.GET("/list", ListTickets)
}

Need to add "wails-app/internal/global" import for PayTicket.

  • Step 2: 注册路由

Edit internal/initialize/router.go, import "wails-app/internal/modules/digital-ticket", add:

digital_ticket.SetupDigitalTicketRouter(PrivateGroup)
  • Step 3: VehicleEntry 集成

Edit internal/service/vehicle/vehicle.go. After creating the vehicle_record (after err = global.GVA_DB.Create(&record).Error and error check), add:

// 创建数字票
ticketSvc := ticket_service.NewTicketService()
triggerMode := "manual"
if req.RFIDTag != "" { triggerMode = "rfid" }
ticketSvc.Create(req.PlateNumber, triggerMode, record.ID)

Need import ticket_service "wails-app/internal/modules/digital-ticket/service".

  • Step 4: Casbin + 编译+提交
go build -o build/bin/smart-parking.exe .
sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO casbin_rule (ptype,v0,v1,v2) VALUES ('p','888','/digital-ticket','GET'),('p','618','/digital-ticket','GET'),('p','888','/digital-ticket','POST'),('p','618','/digital-ticket','POST');"
git add internal/modules/digital-ticket/api.go internal/initialize/router.go internal/service/vehicle/vehicle.go
git commit -m "feat: 数字票API+VehicleEntry集成——所有入场统一创建数字票"