refactor(order): rewrite store around state machine transitions

This commit is contained in:
zetaloop
2026-02-23 11:04:00 +08:00
parent f8c4c87c61
commit 385dac2d49
4 changed files with 411 additions and 104 deletions
+267 -64
View File
@@ -1,8 +1,13 @@
import { create } from "zustand"
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
import { evaluateOrderTransition, type OrderAction } from "@/lib/domain/order-machine"
import { generateId } from "@/lib/id"
import { allow, deny } from "@/lib/policy/assert"
import type { Actor } from "@/lib/policy/actor"
import type { PolicyDecision } from "@/lib/policy/decision"
import { mockOrders } from "@/lib/mock"
import type { Order, OrderStatus, PlayerService } from "@/lib/types"
import { useChatStore } from "@/store/chat"
import { useWalletStore } from "@/store/wallet"
interface CreateOrderInput {
@@ -18,9 +23,27 @@ interface CreateOrderInput {
status?: OrderStatus
}
interface CreatePaidOrderInput extends CreateOrderInput {
status?: never
}
interface OrderMutationResult {
decision: PolicyDecision
order?: Order
}
interface OrderState {
orders: Order[]
createOrder: (input: CreateOrderInput) => Order
createPaidOrder: (input: CreatePaidOrderInput, actor: Actor) => OrderMutationResult
payOrder: (orderId: string, actor: Actor) => OrderMutationResult
acceptOrder: (orderId: string, actor: Actor) => OrderMutationResult
requestClose: (orderId: string, actor: Actor) => OrderMutationResult
confirmClose: (orderId: string, actor: Actor) => OrderMutationResult
cancelPreAccept: (orderId: string, actor: Actor) => OrderMutationResult
markDisputed: (orderId: string, actor: Actor) => OrderMutationResult
autoTimeoutPendingAccept: (orderId: string) => OrderMutationResult
autoTimeoutPendingClose: (orderId: string) => OrderMutationResult
updateOrderStatus: (orderId: string, status: OrderStatus) => void
}
@@ -33,6 +56,110 @@ function clearOrderTimeout(orderId: string) {
orderTimeouts.delete(orderId)
}
function isParticipant(order: Order, userId: string) {
return order.consumerId === userId || order.playerId === userId
}
function isOrderOwnerActor(order: Order, actor: Actor) {
return actor.role === "owner" && Boolean(order.shopId) && actor.shopId === order.shopId
}
function validateActorForAction(order: Order, action: OrderAction, actor?: Actor): PolicyDecision {
if (action.startsWith("AUTO_TIMEOUT_")) {
return allow()
}
if (!actor?.userId) {
return deny("AUTH_REQUIRED", "请先登录")
}
if (order.status === "disputed" && action !== "RESOLVE_DISPUTE") {
return deny("DISPUTE_LOCKED", "争议处理中,暂不可执行此操作")
}
if (action === "PAY" || action === "CANCEL_PRE_ACCEPT" || action === "CONFIRM_CLOSE") {
return order.consumerId === actor.userId
? allow()
: deny("NOT_PARTICIPANT", "仅下单客户可执行该操作")
}
if (action === "ACCEPT") {
return order.playerId === actor.userId || isOrderOwnerActor(order, actor)
? allow()
: deny("NOT_PARTICIPANT", "仅该订单打手或所属店主可执行接单")
}
if (action === "REQUEST_CLOSE" || action === "OPEN_DISPUTE" || action === "SUBMIT_REVIEW") {
return isParticipant(order, actor.userId)
? allow()
: deny("NOT_PARTICIPANT", "仅订单参与方可执行该操作")
}
if (action === "RESOLVE_DISPUTE") {
return isOrderOwnerActor(order, actor)
? allow()
: deny("ROLE_FORBIDDEN", "仅订单所属店主可执行该操作")
}
return allow()
}
function applyStatus(order: Order, status: OrderStatus): Order {
const now = new Date().toISOString()
switch (status) {
case "in_progress":
return {
...order,
status,
acceptedAt: order.acceptedAt ?? now,
}
case "pending_close":
return {
...order,
status,
closedAt: order.closedAt ?? now,
}
case "pending_review":
return {
...order,
status,
closedAt: order.closedAt ?? now,
}
case "completed":
return {
...order,
status,
closedAt: order.closedAt ?? now,
completedAt: order.completedAt ?? now,
}
default:
return {
...order,
status,
}
}
}
function syncChatSession(order: Order, previousStatus: OrderStatus) {
const chatStore = useChatStore.getState()
if (order.status === "pending_payment") return
if (order.status === "cancelled") {
const sessionExists = chatStore.sessions.some(
(session) => session.type === "order" && session.orderId === order.id,
)
if (sessionExists) {
chatStore.ensureOrderSession(order)
}
return
}
if (previousStatus !== order.status) {
chatStore.ensureOrderSession(order)
}
}
function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
clearOrderTimeout(orderId)
@@ -51,11 +178,11 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
}
if (status === "pending_accept") {
state.updateOrderStatus(orderId, "cancelled")
state.autoTimeoutPendingAccept(orderId)
}
if (status === "pending_close") {
state.updateOrderStatus(orderId, "pending_review")
state.autoTimeoutPendingClose(orderId)
}
orderTimeouts.delete(orderId)
@@ -64,77 +191,153 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
orderTimeouts.set(orderId, timer)
}
export const useOrderStore = create<OrderState>((set) => ({
orders: mockOrders,
createOrder: (input) => {
const order: Order = {
id: generateId("ord"),
consumerId: input.consumerId,
consumerName: input.consumerName,
playerId: input.playerId,
playerName: input.playerName,
shopId: input.shopId,
shopName: input.shopName,
service: input.service,
status: input.status ?? "pending_payment",
totalPrice: input.totalPrice,
note: input.note?.trim() ? input.note.trim() : undefined,
createdAt: new Date().toISOString(),
export const useOrderStore = create<OrderState>((set, get) => {
const applyTransition = (
orderId: string,
action: OrderAction,
actor?: Actor,
): OrderMutationResult => {
const order = get().orders.find((item) => item.id === orderId)
if (!order) return { decision: deny("NOT_FOUND", "订单不存在") }
const actorDecision = validateActorForAction(order, action, actor)
if (!actorDecision.ok) return { decision: actorDecision }
const transition = evaluateOrderTransition({ actor, order, action })
if (!transition.decision.ok || !transition.nextStatus) {
return { decision: transition.decision }
}
const nextStatus = transition.nextStatus
let previousOrder: Order | undefined
let updatedOrder: Order | undefined
set((state) => ({
orders: [order, ...state.orders],
orders: state.orders.map((item) => {
if (item.id !== orderId) return item
previousOrder = item
updatedOrder = applyStatus(item, nextStatus)
return updatedOrder
}),
}))
scheduleOrderTimeout(order.id, order.status)
if (!updatedOrder || !previousOrder) {
return { decision: deny("NOT_FOUND", "订单不存在") }
}
return order
},
updateOrderStatus: (orderId, status) =>
set((state) => ({
orders: state.orders.map((order) => {
if (order.id !== orderId) return order
if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
useWalletStore.getState().addIncome(updatedOrder.id, updatedOrder.totalPrice)
}
const now = new Date().toISOString()
const shouldRefund =
previousOrder.status === "pending_accept" &&
updatedOrder.status === "cancelled" &&
(action === "CANCEL_PRE_ACCEPT" || action === "AUTO_TIMEOUT_PENDING_ACCEPT")
switch (status) {
case "in_progress":
return {
...order,
status,
acceptedAt: order.acceptedAt ?? now,
}
case "pending_close":
return {
...order,
status,
closedAt: order.closedAt ?? now,
}
case "pending_review":
return {
...order,
status,
closedAt: order.closedAt ?? now,
}
case "completed":
if (order.status !== "completed") {
useWalletStore.getState().addIncome(order.id, order.totalPrice)
}
return {
...order,
status,
closedAt: order.closedAt ?? now,
completedAt: order.completedAt ?? now,
}
default:
return {
...order,
status,
}
if (shouldRefund) {
useWalletStore.getState().refundPayment(updatedOrder.id, updatedOrder.totalPrice)
}
syncChatSession(updatedOrder, previousOrder.status)
return { decision: allow(), order: updatedOrder }
}
return {
orders: mockOrders,
createOrder: (input) => {
const order: Order = {
id: generateId("ord"),
consumerId: input.consumerId,
consumerName: input.consumerName,
playerId: input.playerId,
playerName: input.playerName,
shopId: input.shopId,
shopName: input.shopName,
service: input.service,
status: input.status ?? "pending_payment",
totalPrice: input.totalPrice,
note: input.note?.trim() ? input.note.trim() : undefined,
createdAt: new Date().toISOString(),
}
set((state) => ({
orders: [order, ...state.orders],
}))
scheduleOrderTimeout(order.id, order.status)
return order
},
createPaidOrder: (input, actor) => {
if (actor.role !== "consumer" || actor.userId !== input.consumerId) {
return { decision: deny("ROLE_FORBIDDEN", "仅客户可下单支付") }
}
const orderId = generateId("ord")
const paid = useWalletStore.getState().deductBalance(orderId, input.totalPrice)
if (!paid) {
return { decision: deny("PAYMENT_FAILED", "余额不足或订单已支付") }
}
const order: Order = {
id: orderId,
consumerId: input.consumerId,
consumerName: input.consumerName,
playerId: input.playerId,
playerName: input.playerName,
shopId: input.shopId,
shopName: input.shopName,
service: input.service,
status: "pending_accept",
totalPrice: input.totalPrice,
note: input.note?.trim() ? input.note.trim() : undefined,
createdAt: new Date().toISOString(),
}
set((state) => ({
orders: [order, ...state.orders],
}))
useChatStore.getState().ensureOrderSession(order)
return { decision: allow(), order }
},
payOrder: (orderId, actor) => applyTransition(orderId, "PAY", actor),
acceptOrder: (orderId, actor) => applyTransition(orderId, "ACCEPT", actor),
requestClose: (orderId, actor) => applyTransition(orderId, "REQUEST_CLOSE", actor),
confirmClose: (orderId, actor) => applyTransition(orderId, "CONFIRM_CLOSE", actor),
cancelPreAccept: (orderId, actor) => applyTransition(orderId, "CANCEL_PRE_ACCEPT", actor),
markDisputed: (orderId, actor) => applyTransition(orderId, "OPEN_DISPUTE", actor),
autoTimeoutPendingAccept: (orderId) => applyTransition(orderId, "AUTO_TIMEOUT_PENDING_ACCEPT"),
autoTimeoutPendingClose: (orderId) => applyTransition(orderId, "AUTO_TIMEOUT_PENDING_CLOSE"),
updateOrderStatus: (orderId, status) => {
set((state) => {
let previousOrder: Order | undefined
let updatedOrder: Order | undefined
const orders = state.orders.map((order) => {
if (order.id !== orderId) return order
previousOrder = order
updatedOrder = applyStatus(order, status)
return updatedOrder
})
if (previousOrder && updatedOrder) {
if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
useWalletStore.getState().addIncome(updatedOrder.id, updatedOrder.totalPrice)
}
if (previousOrder.status === "pending_accept" && updatedOrder.status === "cancelled") {
useWalletStore.getState().refundPayment(updatedOrder.id, updatedOrder.totalPrice)
}
syncChatSession(updatedOrder, previousOrder.status)
}
}),
})),
}))
return { orders }
})
},
}
})
useOrderStore.subscribe((state, prevState) => {
state.orders.forEach((order) => {