|
@@ -0,0 +1,349 @@
|
|
|
|
|
+# 数字票(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`:
|
|
|
|
|
+
|
|
|
|
|
+```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{}`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+dao.MonthlyCard{},
|
|
|
|
|
+dao.DigitalTicket{},
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Repository**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/modules/digital-ticket/repository/repo.go`:
|
|
|
|
|
+
|
|
|
|
|
+```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`:
|
|
|
|
|
+
|
|
|
|
|
+```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: 编译+提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`:
|
|
|
|
|
+
|
|
|
|
|
+```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:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+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:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+// 创建数字票
|
|
|
|
|
+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 + 编译+提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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集成——所有入场统一创建数字票"
|
|
|
|
|
+```
|