Files
zhuangang/topics/backend/postgresql/examples/order-state/order.go
T

98 lines
2.8 KiB
Go

// Package orderstate 订单状态机:合法流转校验 + 乐观锁 + 审计日志(与状态修改在同一个事务里提交)。
package orderstate
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const Schema = `
CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
actor TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
// 允许的状态流转:只有这里列出的才合法
var transitions = map[string][]string{
"pending": {"paid", "cancelled"},
"paid": {"shipped", "refunding"},
"shipped": {"completed"},
"refunding": {"refunded", "paid"},
}
var (
ErrIllegal = errors.New("不允许的状态流转")
ErrConflict = errors.New("订单已被其他操作修改,请刷新后重试")
ErrNotFound = errors.New("订单不存在")
)
func allowed(from, to string) bool {
for _, s := range transitions[from] {
if s == to {
return true
}
}
return false
}
type Order struct {
ID int64
Status string
Version int
}
func Get(ctx context.Context, db *pgxpool.Pool, id int64) (Order, error) {
var o Order
err := db.QueryRow(ctx, `SELECT id, status, version FROM orders WHERE id = $1`, id).Scan(&o.ID, &o.Status, &o.Version)
if errors.Is(err, pgx.ErrNoRows) {
return o, ErrNotFound
}
return o, err
}
// Transition 把订单从 cur 状态改成 to。cur 是调用方读到的版本(乐观锁)。
func Transition(ctx context.Context, db *pgxpool.Pool, cur Order, to, actor, reason string) (Order, error) {
if !allowed(cur.Status, to) {
return cur, fmt.Errorf("%s -> %s: %w", cur.Status, to, ErrIllegal)
}
tx, err := db.Begin(ctx)
if err != nil {
return cur, err
}
defer tx.Rollback(ctx) // 提交成功后再回滚是无害的
tag, err := tx.Exec(ctx,
`UPDATE orders SET status = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND status = $3 AND version = $4`, to, cur.ID, cur.Status, cur.Version)
if err != nil {
return cur, err
}
if tag.RowsAffected() == 0 {
return cur, ErrConflict
}
if _, err := tx.Exec(ctx,
`INSERT INTO order_events (order_id, from_status, to_status, actor, reason) VALUES ($1, $2, $3, $4, $5)`,
cur.ID, cur.Status, to, actor, reason); err != nil {
return cur, err
}
if err := tx.Commit(ctx); err != nil {
return cur, err
}
return Order{ID: cur.ID, Status: to, Version: cur.Version + 1}, nil
}