424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { create } from "zustand"
|
|
import {
|
|
ORDER_ACCEPT_TIMEOUT_MS,
|
|
ORDER_CLOSE_TIMEOUT_MS,
|
|
ORDER_REVIEW_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 { useAuthStore } from "@/store/auth"
|
|
import { useChatStore } from "@/store/chat"
|
|
import { useNotificationStore } from "@/store/notifications"
|
|
import { useWalletStore } from "@/store/wallet"
|
|
import { useShopStore } from "@/store/shops"
|
|
|
|
interface CreateOrderInput {
|
|
consumerId: string
|
|
consumerName: string
|
|
playerId: string
|
|
playerName: string
|
|
shopId?: string
|
|
shopName?: string
|
|
service: PlayerService
|
|
totalPrice: number
|
|
note?: string
|
|
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
|
|
autoTimeoutPendingReview: (orderId: string) => OrderMutationResult
|
|
resolveDispute: (orderId: string) => OrderMutationResult
|
|
}
|
|
|
|
const orderTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
const pendingCreations = new Set<string>()
|
|
|
|
function clearOrderTimeout(orderId: string) {
|
|
const timer = orderTimeouts.get(orderId)
|
|
if (!timer) return
|
|
clearTimeout(timer)
|
|
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_") || (action === "RESOLVE_DISPUTE" && !actor?.userId)) {
|
|
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)
|
|
|
|
if (status !== "pending_accept" && status !== "pending_close" && status !== "pending_review") {
|
|
return
|
|
}
|
|
|
|
const timeoutMap: Partial<Record<OrderStatus, number>> = {
|
|
pending_accept: ORDER_ACCEPT_TIMEOUT_MS,
|
|
pending_close: ORDER_CLOSE_TIMEOUT_MS,
|
|
pending_review: ORDER_REVIEW_TIMEOUT_MS,
|
|
}
|
|
const timeoutMs = timeoutMap[status]!
|
|
|
|
const timer = setTimeout(() => {
|
|
const state = useOrderStore.getState()
|
|
const order = state.orders.find((item) => item.id === orderId)
|
|
if (!order || order.status !== status) {
|
|
orderTimeouts.delete(orderId)
|
|
return
|
|
}
|
|
|
|
if (status === "pending_accept") {
|
|
state.autoTimeoutPendingAccept(orderId)
|
|
} else if (status === "pending_close") {
|
|
state.autoTimeoutPendingClose(orderId)
|
|
} else if (status === "pending_review") {
|
|
state.autoTimeoutPendingReview(orderId)
|
|
}
|
|
|
|
orderTimeouts.delete(orderId)
|
|
}, timeoutMs)
|
|
|
|
orderTimeouts.set(orderId, timer)
|
|
}
|
|
|
|
function notifyOrderStatus(order: Order) {
|
|
if (!useAuthStore.getState().notificationPrefs.order) {
|
|
return
|
|
}
|
|
|
|
const mapping: Partial<Record<OrderStatus, { title: string; content: string }>> = {
|
|
pending_accept: {
|
|
title: "订单待接单",
|
|
content: `${order.service.title} 已支付,等待接单`,
|
|
},
|
|
in_progress: {
|
|
title: "订单已接单",
|
|
content: `${order.playerName} 已开始服务`,
|
|
},
|
|
pending_close: {
|
|
title: "订单发起结单",
|
|
content: `订单 ${order.id} 等待确认结单`,
|
|
},
|
|
pending_review: {
|
|
title: "订单待评价",
|
|
content: "服务已结束,可提交双向评价",
|
|
},
|
|
completed: {
|
|
title: "订单已完成",
|
|
content: `订单 ${order.id} 已完成`,
|
|
},
|
|
cancelled: {
|
|
title: "订单已取消",
|
|
content: `订单 ${order.id} 已取消`,
|
|
},
|
|
disputed: {
|
|
title: "订单进入争议",
|
|
content: "已发起争议,等待平台处理",
|
|
},
|
|
}
|
|
|
|
const payload = mapping[order.status]
|
|
if (!payload) {
|
|
return
|
|
}
|
|
|
|
useNotificationStore.getState().addNotification({
|
|
type: "order",
|
|
title: payload.title,
|
|
content: payload.content,
|
|
link: `/order/${order.id}`,
|
|
})
|
|
}
|
|
|
|
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: state.orders.map((item) => {
|
|
if (item.id !== orderId) return item
|
|
previousOrder = item
|
|
updatedOrder = applyStatus(item, nextStatus)
|
|
return updatedOrder
|
|
}),
|
|
}))
|
|
|
|
if (!updatedOrder || !previousOrder) {
|
|
return { decision: deny("NOT_FOUND", "订单不存在") }
|
|
}
|
|
|
|
if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
|
|
useWalletStore
|
|
.getState()
|
|
.addIncome(updatedOrder.id, updatedOrder.totalPrice, updatedOrder.shopId)
|
|
}
|
|
|
|
if (previousOrder.status !== updatedOrder.status) {
|
|
notifyOrderStatus(updatedOrder)
|
|
}
|
|
|
|
const shouldRefund =
|
|
previousOrder.status === "pending_accept" &&
|
|
updatedOrder.status === "cancelled" &&
|
|
(action === "CANCEL_PRE_ACCEPT" || action === "AUTO_TIMEOUT_PENDING_ACCEPT")
|
|
|
|
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 dedupeKey = `${input.consumerId}-${input.service.id}`
|
|
if (pendingCreations.has(dedupeKey)) {
|
|
return { decision: deny("DUPLICATE_REQUEST", "订单正在创建中,请勿重复提交") }
|
|
}
|
|
pendingCreations.add(dedupeKey)
|
|
const orderId = generateId("ord")
|
|
const paid = useWalletStore.getState().deductBalance(orderId, input.totalPrice)
|
|
if (!paid) {
|
|
pendingCreations.delete(dedupeKey)
|
|
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)
|
|
notifyOrderStatus(order)
|
|
setTimeout(() => pendingCreations.delete(dedupeKey), 2000)
|
|
return { decision: allow(), order }
|
|
},
|
|
payOrder: (orderId, actor) => {
|
|
const order = get().orders.find((item) => item.id === orderId)
|
|
if (!order) return { decision: deny("NOT_FOUND", "订单不存在") }
|
|
const paid = useWalletStore.getState().deductBalance(orderId, order.totalPrice)
|
|
if (!paid) return { decision: deny("PAYMENT_FAILED", "余额不足或订单已支付") }
|
|
return 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"),
|
|
autoTimeoutPendingReview: (orderId) => applyTransition(orderId, "AUTO_TIMEOUT_PENDING_REVIEW"),
|
|
resolveDispute: (orderId) => applyTransition(orderId, "RESOLVE_DISPUTE"),
|
|
}
|
|
})
|
|
|
|
useOrderStore.subscribe((state, prevState) => {
|
|
state.orders.forEach((order) => {
|
|
const prevOrder = prevState.orders.find((item) => item.id === order.id)
|
|
if (!prevOrder || prevOrder.status !== order.status) {
|
|
scheduleOrderTimeout(order.id, order.status)
|
|
if (order.status === "pending_accept" && order.shopId) {
|
|
const shop = useShopStore.getState().shops.find((s) => s.id === order.shopId)
|
|
if (shop?.dispatchMode === "auto") {
|
|
setTimeout(() => {
|
|
const current = useOrderStore.getState().orders.find((o) => o.id === order.id)
|
|
if (!current || current.status !== "pending_accept") return
|
|
const actor: Actor = { userId: order.playerId, role: "player", shopId: order.shopId }
|
|
useOrderStore.getState().acceptOrder(order.id, actor)
|
|
}, 3000)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
prevState.orders.forEach((order) => {
|
|
if (!state.orders.some((item) => item.id === order.id)) {
|
|
clearOrderTimeout(order.id)
|
|
}
|
|
})
|
|
})
|