Files
juwan-frontend/store/orders.ts
T
zetaloop 452004b194 refactor: remove demo timers and client-side timeout simulation
Remove lib/config/demo-timers.ts and all usages across stores
and pages. Order timeout scheduling, dispute auto-progression,
and hardcoded countdown displays are removed — timeouts are
now handled server-side by the backend.
2026-05-01 17:32:06 +08:00

390 lines
13 KiB
TypeScript

import type { Actor } from "@/lib/actor"
import { allow, deny } from "@/lib/decision"
import { evaluateOrderTransition, type OrderAction } from "@/lib/domain/order-machine"
import type { ApiDecision } from "@/lib/errors"
import { generateId } from "@/lib/id"
import type { Order, OrderStatus } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { useNotificationStore } from "@/store/notifications"
import { usePlayerStore } from "@/store/players"
import { useServiceStore } from "@/store/services"
import { useWalletStore } from "@/store/wallet"
import { create } from "zustand"
interface CreateOrderInput {
playerId: string
serviceId: string
shopId?: string
quantity: number
note?: string
}
type CreatePaidOrderInput = CreateOrderInput
interface OrderMutationResult {
decision: ApiDecision
order?: Order
}
interface OrderState {
orders: Order[]
createOrder: (input: CreateOrderInput, actor: Actor) => OrderMutationResult
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 pendingCreations = new Set<string>()
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): ApiDecision {
if (action.startsWith("AUTO_TIMEOUT_") || (action === "RESOLVE_DISPUTE" && !actor?.userId)) {
return allow()
}
if (!actor?.userId) {
return deny(401, "请先登录")
}
if (order.status === "disputed" && action !== "RESOLVE_DISPUTE") {
return deny(400, "争议处理中,暂不可执行此操作")
}
if (action === "PAY" || action === "CANCEL_PRE_ACCEPT" || action === "CONFIRM_CLOSE") {
return order.consumerId === actor.userId ? allow() : deny(403, "仅下单客户可执行该操作")
}
if (action === "ACCEPT") {
return order.playerId === actor.userId || isOrderOwnerActor(order, actor)
? allow()
: deny(403, "仅该订单打手或所属店主可执行接单")
}
if (action === "REQUEST_CLOSE" || action === "OPEN_DISPUTE" || action === "SUBMIT_REVIEW") {
return isParticipant(order, actor.userId) ? allow() : deny(403, "仅订单参与方可执行该操作")
}
if (action === "RESOLVE_DISPUTE") {
return isOrderOwnerActor(order, actor) ? allow() : deny(403, "仅订单所属店主可执行该操作")
}
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":
case "pending_review":
return {
...order,
status,
}
case "completed":
return {
...order,
status,
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 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.service.title} 已开始服务`,
},
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(404, "订单不存在") }
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(404, "订单不存在") }
}
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: [],
createOrder: (input, actor) => {
if (actor.role !== "consumer") {
return { decision: deny(403, "仅客户可下单") }
}
const consumer = useAuthStore.getState().user
if (!consumer || consumer.id !== actor.userId) {
return { decision: deny(403, "仅本人可下单") }
}
const service = useServiceStore
.getState()
.services.find((item) => item.id === input.serviceId)
if (!service) {
return { decision: deny(404, "服务不存在") }
}
const player = usePlayerStore.getState().players.find((item) => item.id === input.playerId)
if (!player) {
return { decision: deny(404, "打手不存在") }
}
if (service.playerId !== player.id) {
return { decision: deny(400, "服务与打手不匹配") }
}
const resolvedShopId = input.shopId ?? player.shopId
if (input.shopId && player.shopId && input.shopId !== player.shopId) {
return { decision: deny(400, "店铺信息与打手不匹配") }
}
const quantity = Number.isFinite(input.quantity) ? Math.floor(input.quantity) : Number.NaN
if (!quantity || quantity < 1) {
return { decision: deny(400, "数量不合法") }
}
const totalPrice = service.price * quantity
const order: Order = {
id: generateId("ord"),
consumerId: consumer.id,
playerId: player.id,
shopId: resolvedShopId,
service,
status: "pending_payment",
totalPrice,
note: input.note?.trim() ? input.note.trim() : undefined,
createdAt: new Date().toISOString(),
}
set((state) => ({
orders: [order, ...state.orders],
}))
return { decision: allow(), order }
},
createPaidOrder: (input, actor) => {
if (actor.role !== "consumer") {
return { decision: deny(403, "仅客户可下单支付") }
}
const consumer = useAuthStore.getState().user
if (!consumer || consumer.id !== actor.userId) {
return { decision: deny(403, "仅本人可下单支付") }
}
const service = useServiceStore
.getState()
.services.find((item) => item.id === input.serviceId)
if (!service) {
return { decision: deny(404, "服务不存在") }
}
const player = usePlayerStore.getState().players.find((item) => item.id === input.playerId)
if (!player) {
return { decision: deny(404, "打手不存在") }
}
if (service.playerId !== player.id) {
return { decision: deny(400, "服务与打手不匹配") }
}
const resolvedShopId = input.shopId ?? player.shopId
if (input.shopId && player.shopId && input.shopId !== player.shopId) {
return { decision: deny(400, "店铺信息与打手不匹配") }
}
const quantity = Number.isFinite(input.quantity) ? Math.floor(input.quantity) : Number.NaN
if (!quantity || quantity < 1) {
return { decision: deny(400, "数量不合法") }
}
const totalPrice = service.price * quantity
const dedupeKey = `${consumer.id}-${service.id}`
if (pendingCreations.has(dedupeKey)) {
return { decision: deny(400, "订单正在创建中,请勿重复提交") }
}
pendingCreations.add(dedupeKey)
const orderId = generateId("ord")
const paid = useWalletStore.getState().deductBalance(orderId, totalPrice)
if (!paid) {
pendingCreations.delete(dedupeKey)
return { decision: deny(400, "余额不足或订单已支付") }
}
const order: Order = {
id: orderId,
consumerId: consumer.id,
playerId: player.id,
shopId: resolvedShopId,
service,
status: "pending_accept",
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(404, "订单不存在") }
const paid = useWalletStore.getState().deductBalance(orderId, order.totalPrice)
if (!paid) return { decision: deny(400, "余额不足或订单已支付") }
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"),
}
})