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

docs: 数字票实现计划

lq преди 1 месец
родител
ревизия
5fb50b9847
променени са 1 файла, в които са добавени 349 реда и са изтрити 0 реда
  1. 349 0
      docs/superpowers/plans/2026-07-23-digital-ticket-plan.md

+ 349 - 0
docs/superpowers/plans/2026-07-23-digital-ticket-plan.md

@@ -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集成——所有入场统一创建数字票"
+```