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
+26 -24
View File
@@ -12,10 +12,10 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { getPlayerById, getServiceById } from "@/lib/api" import { getPlayerById, getServiceById } from "@/lib/api"
import type { Actor } from "@/lib/policy/actor"
import { notifySuccess } from "@/lib/toast" import { notifySuccess } from "@/lib/toast"
import { useRequireAuth } from "@/lib/use-require-auth" import { useRequireAuth } from "@/lib/use-require-auth"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { useOrderStore } from "@/store/orders" import { useOrderStore } from "@/store/orders"
import { useWalletStore } from "@/store/wallet" import { useWalletStore } from "@/store/wallet"
@@ -23,10 +23,8 @@ export default function NewOrderPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { requireAuth } = useRequireAuth() const { requireAuth } = useRequireAuth()
const createOrder = useOrderStore((state) => state.createOrder) const createPaidOrder = useOrderStore((state) => state.createPaidOrder)
const ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
const balance = useWalletStore((state) => state.balance) const balance = useWalletStore((state) => state.balance)
const deductBalance = useWalletStore((state) => state.deductBalance)
const serviceId = searchParams.get("serviceId") const serviceId = searchParams.get("serviceId")
const service = serviceId ? getServiceById(serviceId) : undefined const service = serviceId ? getServiceById(serviceId) : undefined
@@ -186,34 +184,38 @@ export default function NewOrderPage() {
size="lg" size="lg"
disabled={balance < totalPrice} disabled={balance < totalPrice}
onClick={() => onClick={() =>
requireAuth(async () => { requireAuth(() => {
await new Promise((resolve) => setTimeout(resolve, 500))
const authUser = useAuthStore.getState().user const authUser = useAuthStore.getState().user
if (!authUser) return if (!authUser) return
const order = createOrder({ const actor: Actor = {
consumerId: authUser.id, userId: authUser.id,
consumerName: authUser.nickname, role: "consumer",
playerId: player.id,
playerName: player.user.nickname,
shopId: player.shopId,
shopName: player.shopName,
service,
totalPrice,
note,
status: "pending_accept",
})
const paid = deductBalance(order.id, totalPrice)
if (!paid) {
return
} }
ensureOrderSession(order) const result = createPaidOrder(
{
consumerId: authUser.id,
consumerName: authUser.nickname,
playerId: player.id,
playerName: player.user.nickname,
shopId: player.shopId,
shopName: player.shopName,
service,
totalPrice,
note,
},
actor,
)
if (!result.decision.ok || !result.order) {
return
}
const nextOrder = result.order
setSubmitted(true) setSubmitted(true)
notifySuccess("下单成功") notifySuccess("下单成功")
setTimeout(() => { setTimeout(() => {
router.push(`/order/${order.id}`) router.push(`/order/${nextOrder.id}`)
}, 800) }, 800)
}) })
} }
+85 -16
View File
@@ -10,10 +10,12 @@ import {
XCircle, XCircle,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useEffect } from "react" import { useCallback, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { notifySuccess } from "@/lib/toast" import type { Actor } from "@/lib/policy/actor"
import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { OrderStatus } from "@/lib/types" import type { OrderStatus } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat" import { useChatStore } from "@/store/chat"
import { useOrderStore } from "@/store/orders" import { useOrderStore } from "@/store/orders"
import { useShopStore } from "@/store/shops" import { useShopStore } from "@/store/shops"
@@ -35,10 +37,21 @@ export default function OrderActions({
chatSessionId, chatSessionId,
serviceId, serviceId,
}: OrderActionsProps) { }: OrderActionsProps) {
const currentRole = useAuthStore((state) => state.currentRole)
const currentUserId = useAuthStore((state) => state.user?.id)
const payOrder = useOrderStore((state) => state.payOrder)
const acceptOrder = useOrderStore((state) => state.acceptOrder)
const requestClose = useOrderStore((state) => state.requestClose)
const confirmClose = useOrderStore((state) => state.confirmClose)
const cancelPreAccept = useOrderStore((state) => state.cancelPreAccept)
const order = useOrderStore((state) => state.orders.find((item) => item.id === orderId)) const order = useOrderStore((state) => state.orders.find((item) => item.id === orderId))
const updateOrderStatus = useOrderStore((state) => state.updateOrderStatus)
const sessions = useChatStore((state) => state.sessions) const sessions = useChatStore((state) => state.sessions)
const ensureOrderSession = useChatStore((state) => state.ensureOrderSession) const ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
const actorShopId = useShopStore((state) => {
if (!currentUserId || currentRole !== "owner") return undefined
const owned = state.shops.find((shop) => shop.owner.id === currentUserId)
return owned?.id
})
const dispatchMode = useShopStore((state) => { const dispatchMode = useShopStore((state) => {
if (!order?.shopId) return "manual" if (!order?.shopId) return "manual"
const shop = state.shops.find((item) => item.id === order.shopId) const shop = state.shops.find((item) => item.id === order.shopId)
@@ -49,6 +62,25 @@ export default function OrderActions({
sessions.find((session) => session.type === "order" && session.orderId === orderId)?.id sessions.find((session) => session.type === "order" && session.orderId === orderId)?.id
const status = order?.status ?? initialStatus const status = order?.status ?? initialStatus
const actor: Actor | undefined = currentUserId
? {
userId: currentUserId,
role: currentRole,
shopId: actorShopId,
}
: undefined
const handleDecision = useCallback(
(okMessage: string, result: { decision: { ok: boolean; message?: string } }) => {
if (result.decision.ok) {
showFeedback(okMessage)
return
}
notifyInfo(result.decision.message ?? "当前操作不允许")
},
[],
)
useEffect(() => { useEffect(() => {
if (chatSessionId || !order || resolvedChatSessionId) return if (chatSessionId || !order || resolvedChatSessionId) return
@@ -62,12 +94,17 @@ export default function OrderActions({
if (dispatchMode !== "auto") return if (dispatchMode !== "auto") return
const timer = setTimeout(() => { const timer = setTimeout(() => {
updateOrderStatus(orderId, "in_progress") const systemActor: Actor = {
showFeedback("系统已自动派单") userId: order.playerId,
role: "player",
shopId: order.shopId,
}
const result = acceptOrder(orderId, systemActor)
handleDecision("系统已自动派单", result)
}, 3000) }, 3000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [dispatchMode, order, orderId, updateOrderStatus]) }, [acceptOrder, dispatchMode, handleDecision, order, orderId])
return ( return (
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -76,8 +113,13 @@ export default function OrderActions({
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "cancelled") if (!actor) {
showFeedback("订单已取消") notifyInfo("请先登录")
return
}
const result = cancelPreAccept(orderId, actor)
handleDecision("订单已取消", result)
}} }}
> >
<XCircle className="mr-1 h-4 w-4" /> <XCircle className="mr-1 h-4 w-4" />
@@ -85,7 +127,13 @@ export default function OrderActions({
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "pending_accept") if (!actor) {
notifyInfo("请先登录")
return
}
const result = payOrder(orderId, actor)
handleDecision("订单支付成功", result)
}} }}
> >
<CheckCircle2 className="mr-1 h-4 w-4" /> <CheckCircle2 className="mr-1 h-4 w-4" />
@@ -99,8 +147,13 @@ export default function OrderActions({
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "cancelled") if (!actor) {
showFeedback("订单已取消") notifyInfo("请先登录")
return
}
const result = cancelPreAccept(orderId, actor)
handleDecision("订单已取消", result)
}} }}
> >
<XCircle className="mr-1 h-4 w-4" /> <XCircle className="mr-1 h-4 w-4" />
@@ -114,8 +167,13 @@ export default function OrderActions({
) : ( ) : (
<Button <Button
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "in_progress") if (!actor) {
showFeedback("已接单") notifyInfo("请先登录")
return
}
const result = acceptOrder(orderId, actor)
handleDecision("已接单", result)
}} }}
> >
<CheckCircle2 className="mr-1 h-4 w-4" /> <CheckCircle2 className="mr-1 h-4 w-4" />
@@ -138,8 +196,13 @@ export default function OrderActions({
<> <>
<Button <Button
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "pending_close") if (!actor) {
showFeedback("已发起结单") notifyInfo("请先登录")
return
}
const result = requestClose(orderId, actor)
handleDecision("已发起结单", result)
}} }}
> >
@@ -157,7 +220,13 @@ export default function OrderActions({
<> <>
<Button <Button
onClick={() => { onClick={() => {
updateOrderStatus(orderId, "pending_review") if (!actor) {
notifyInfo("请先登录")
return
}
const result = confirmClose(orderId, actor)
handleDecision("已确认结单", result)
}} }}
> >
+267 -64
View File
@@ -1,8 +1,13 @@
import { create } from "zustand" import { create } from "zustand"
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers" 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 { 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 { mockOrders } from "@/lib/mock"
import type { Order, OrderStatus, PlayerService } from "@/lib/types" import type { Order, OrderStatus, PlayerService } from "@/lib/types"
import { useChatStore } from "@/store/chat"
import { useWalletStore } from "@/store/wallet" import { useWalletStore } from "@/store/wallet"
interface CreateOrderInput { interface CreateOrderInput {
@@ -18,9 +23,27 @@ interface CreateOrderInput {
status?: OrderStatus status?: OrderStatus
} }
interface CreatePaidOrderInput extends CreateOrderInput {
status?: never
}
interface OrderMutationResult {
decision: PolicyDecision
order?: Order
}
interface OrderState { interface OrderState {
orders: Order[] orders: Order[]
createOrder: (input: CreateOrderInput) => 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 updateOrderStatus: (orderId: string, status: OrderStatus) => void
} }
@@ -33,6 +56,110 @@ function clearOrderTimeout(orderId: string) {
orderTimeouts.delete(orderId) 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) { function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
clearOrderTimeout(orderId) clearOrderTimeout(orderId)
@@ -51,11 +178,11 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
} }
if (status === "pending_accept") { if (status === "pending_accept") {
state.updateOrderStatus(orderId, "cancelled") state.autoTimeoutPendingAccept(orderId)
} }
if (status === "pending_close") { if (status === "pending_close") {
state.updateOrderStatus(orderId, "pending_review") state.autoTimeoutPendingClose(orderId)
} }
orderTimeouts.delete(orderId) orderTimeouts.delete(orderId)
@@ -64,77 +191,153 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
orderTimeouts.set(orderId, timer) orderTimeouts.set(orderId, timer)
} }
export const useOrderStore = create<OrderState>((set) => ({ export const useOrderStore = create<OrderState>((set, get) => {
orders: mockOrders, const applyTransition = (
createOrder: (input) => { orderId: string,
const order: Order = { action: OrderAction,
id: generateId("ord"), actor?: Actor,
consumerId: input.consumerId, ): OrderMutationResult => {
consumerName: input.consumerName, const order = get().orders.find((item) => item.id === orderId)
playerId: input.playerId, if (!order) return { decision: deny("NOT_FOUND", "订单不存在") }
playerName: input.playerName,
shopId: input.shopId, const actorDecision = validateActorForAction(order, action, actor)
shopName: input.shopName, if (!actorDecision.ok) return { decision: actorDecision }
service: input.service,
status: input.status ?? "pending_payment", const transition = evaluateOrderTransition({ actor, order, action })
totalPrice: input.totalPrice, if (!transition.decision.ok || !transition.nextStatus) {
note: input.note?.trim() ? input.note.trim() : undefined, return { decision: transition.decision }
createdAt: new Date().toISOString(),
} }
const nextStatus = transition.nextStatus
let previousOrder: Order | undefined
let updatedOrder: Order | undefined
set((state) => ({ 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 if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
}, useWalletStore.getState().addIncome(updatedOrder.id, updatedOrder.totalPrice)
updateOrderStatus: (orderId, status) => }
set((state) => ({
orders: state.orders.map((order) => {
if (order.id !== orderId) return order
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) { if (shouldRefund) {
case "in_progress": useWalletStore.getState().refundPayment(updatedOrder.id, updatedOrder.totalPrice)
return { }
...order,
status, syncChatSession(updatedOrder, previousOrder.status)
acceptedAt: order.acceptedAt ?? now, return { decision: allow(), order: updatedOrder }
} }
case "pending_close":
return { return {
...order, orders: mockOrders,
status, createOrder: (input) => {
closedAt: order.closedAt ?? now, const order: Order = {
} id: generateId("ord"),
case "pending_review": consumerId: input.consumerId,
return { consumerName: input.consumerName,
...order, playerId: input.playerId,
status, playerName: input.playerName,
closedAt: order.closedAt ?? now, shopId: input.shopId,
} shopName: input.shopName,
case "completed": service: input.service,
if (order.status !== "completed") { status: input.status ?? "pending_payment",
useWalletStore.getState().addIncome(order.id, order.totalPrice) totalPrice: input.totalPrice,
} note: input.note?.trim() ? input.note.trim() : undefined,
return { createdAt: new Date().toISOString(),
...order, }
status,
closedAt: order.closedAt ?? now, set((state) => ({
completedAt: order.completedAt ?? now, orders: [order, ...state.orders],
} }))
default:
return { scheduleOrderTimeout(order.id, order.status)
...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) => { useOrderStore.subscribe((state, prevState) => {
state.orders.forEach((order) => { state.orders.forEach((order) => {
+33
View File
@@ -9,6 +9,7 @@ interface WalletState {
topUp: (amount: number) => void topUp: (amount: number) => void
withdraw: (amount: number) => void withdraw: (amount: number) => void
deductBalance: (orderId: string, amount: number) => boolean deductBalance: (orderId: string, amount: number) => boolean
refundPayment: (orderId: string, amount: number) => boolean
addIncome: (orderId: string, amount: number) => void addIncome: (orderId: string, amount: number) => void
addTransaction: (transaction: WalletTransaction) => void addTransaction: (transaction: WalletTransaction) => void
} }
@@ -76,6 +77,38 @@ export const useWalletStore = create<WalletState>((set, get) => ({
})) }))
return true return true
}, },
refundPayment: (orderId, amount) => {
if (!Number.isFinite(amount) || amount <= 0) return false
const state = get()
const paid = state.transactions.some(
(transaction) => transaction.type === "payment" && transaction.description.includes(orderId),
)
const refunded = state.transactions.some(
(transaction) => transaction.type === "refund" && transaction.description.includes(orderId),
)
if (!paid || refunded) {
return false
}
const now = new Date().toISOString()
set((prev) => ({
balance: prev.balance + amount,
transactions: [
{
id: generateId("tx"),
type: "refund",
amount,
description: `订单 ${orderId} 退款`,
createdAt: now,
},
...prev.transactions,
],
}))
return true
},
addIncome: (orderId, amount) => { addIncome: (orderId, amount) => {
if (!Number.isFinite(amount) || amount <= 0) return if (!Number.isFinite(amount) || amount <= 0) return