refactor(orders): replace local state machine with minimal cache
Strip store/orders.ts to a thin local cache with setOrders and updateOrder only. Remove all client-side state transition logic, actor validation, chat sync, notification generation, and wallet integration — these are now handled by the backend API. Fix components/order-actions.tsx and stores that depended on the removed order store methods (markDisputed, autoTimeout*).
This commit is contained in:
@@ -5,9 +5,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { EmptyState } from "@/components/ui/empty-state"
|
import { EmptyState } from "@/components/ui/empty-state"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { StatusBadge } from "@/components/ui/status-badge"
|
import { StatusBadge } from "@/components/ui/status-badge"
|
||||||
import { getOrderById, listChatSessions, listReviewsByOrder } from "@/lib/api"
|
import { getOrderById, getPlayerById, getUserById, listReviewsByOrder } from "@/lib/api"
|
||||||
import { statusLabels } from "@/lib/constants"
|
import { statusLabels } from "@/lib/constants"
|
||||||
import type { OrderStatus } from "@/lib/types"
|
import type { OrderStatus, Player, User } from "@/lib/types"
|
||||||
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { use, useEffect, useState } from "react"
|
import { use, useEffect, useState } from "react"
|
||||||
@@ -46,32 +47,15 @@ const statusVariants: Record<OrderStatus, OrderStatusBadgeVariant> = {
|
|||||||
|
|
||||||
export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
|
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||||
const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
|
const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
|
||||||
|
const [chatTarget, setChatTarget] = useState<User | null>(null)
|
||||||
|
const [player, setPlayer] = useState<Player | null>(null)
|
||||||
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>(
|
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const sessions = await Promise.resolve(listChatSessions())
|
|
||||||
if (cancelled) return
|
|
||||||
setSessions(sessions)
|
|
||||||
} catch {
|
|
||||||
if (cancelled) return
|
|
||||||
setSessions([])
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -94,6 +78,42 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
if (!order || !currentUserId) {
|
||||||
|
setPlayer(null)
|
||||||
|
setChatTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPlayer(null)
|
||||||
|
setChatTarget(null)
|
||||||
|
const player = await getPlayerById(String(order.playerId))
|
||||||
|
if (!cancelled) setPlayer(player ?? null)
|
||||||
|
|
||||||
|
if (String(order.consumerId) === currentUserId) {
|
||||||
|
if (!cancelled) setChatTarget(player?.user ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player?.user.id === currentUserId) {
|
||||||
|
const consumer = await getUserById(String(order.consumerId))
|
||||||
|
if (!cancelled) setChatTarget(consumer ?? null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setPlayer(null)
|
||||||
|
if (!cancelled) setChatTarget(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [currentUserId, order])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -129,7 +149,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatSession = sessions.find((session) => session.type === "order" && session.orderId === id)
|
|
||||||
const statusSteps =
|
const statusSteps =
|
||||||
order.status === "disputed"
|
order.status === "disputed"
|
||||||
? disputedStatusSteps
|
? disputedStatusSteps
|
||||||
@@ -137,6 +156,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
? cancelledStatusSteps
|
? cancelledStatusSteps
|
||||||
: normalStatusSteps
|
: normalStatusSteps
|
||||||
const currentStepIndex = statusSteps.indexOf(order.status)
|
const currentStepIndex = statusSteps.indexOf(order.status)
|
||||||
|
const isPlayerParticipant = player?.user.id === currentUserId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
@@ -291,7 +311,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
order={order}
|
order={order}
|
||||||
onOrderChange={(next) => setOrder(next)}
|
onOrderChange={(next) => setOrder(next)}
|
||||||
initialStatus={order.status}
|
initialStatus={order.status}
|
||||||
chatSessionId={chatSession?.id}
|
chatTargetId={chatTarget?.id}
|
||||||
|
isPlayerParticipant={isPlayerParticipant}
|
||||||
serviceId={order.service.id}
|
serviceId={order.service.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-14
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { EmptyState } from "@/components/ui/empty-state"
|
import { EmptyState } from "@/components/ui/empty-state"
|
||||||
import { StatusBadge } from "@/components/ui/status-badge"
|
import { StatusBadge } from "@/components/ui/status-badge"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { listChatSessions, listOrders } from "@/lib/api"
|
import { getPlayerById, listOrders } from "@/lib/api"
|
||||||
import { statusLabels } from "@/lib/constants"
|
import { statusLabels } from "@/lib/constants"
|
||||||
import {
|
import {
|
||||||
isActiveOrder,
|
isActiveOrder,
|
||||||
@@ -111,7 +111,7 @@ function OrderListContent({
|
|||||||
const orderRole = getOrderRole(currentRole)
|
const orderRole = getOrderRole(currentRole)
|
||||||
const [tab, setTab] = useState<TabFilter | "pending">("all")
|
const [tab, setTab] = useState<TabFilter | "pending">("all")
|
||||||
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([])
|
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([])
|
||||||
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
|
const [chatTargets, setChatTargets] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -143,27 +143,41 @@ function OrderListContent({
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const items = await Promise.resolve(listChatSessions())
|
const entries = await Promise.all(
|
||||||
|
orders.map(async (order) => {
|
||||||
|
if (currentRole === "consumer") {
|
||||||
|
const player = await getPlayerById(String(order.playerId))
|
||||||
|
return [String(order.id), player?.user.id] as const
|
||||||
|
}
|
||||||
|
if (currentRole === "player")
|
||||||
|
return [String(order.id), String(order.consumerId)] as const
|
||||||
|
return [String(order.id), undefined] as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSessions(items)
|
setChatTargets(
|
||||||
|
Object.fromEntries(
|
||||||
|
entries.filter((entry): entry is readonly [string, string] => Boolean(entry[1])),
|
||||||
|
),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSessions([])
|
setChatTargets({})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [currentRole, orders])
|
||||||
|
|
||||||
const tabs =
|
const tabs =
|
||||||
currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs
|
currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs
|
||||||
|
|
||||||
const roleFiltered = orders.filter((order) => {
|
const roleFiltered = orders.filter((order) => {
|
||||||
if (currentRole === "consumer") return userId ? order.consumerId === userId : false
|
if (currentRole === "consumer") return userId ? String(order.consumerId) === userId : false
|
||||||
if (currentRole === "player") return userId ? order.playerId === userId : false
|
if (currentRole === "player") return true
|
||||||
return ownerShopId ? order.shopId === ownerShopId : false
|
return ownerShopId ? String(order.shopId) === ownerShopId : false
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = roleFiltered.filter((order) => {
|
const filtered = roleFiltered.filter((order) => {
|
||||||
@@ -241,13 +255,11 @@ function OrderListContent({
|
|||||||
if (order.status !== "in_progress" && order.status !== "pending_close") {
|
if (order.status !== "in_progress" && order.status !== "pending_close") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const session = sessions.find(
|
const chatTargetId = chatTargets[String(order.id)]
|
||||||
(item) => item.type === "order" && item.orderId === order.id,
|
if (!chatTargetId) return null
|
||||||
)
|
|
||||||
if (!session) return null
|
|
||||||
return (
|
return (
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href={`/chat/${session.id}`}>
|
<Link href={`/chat/${chatTargetId}?orderId=${order.id}`}>
|
||||||
<MessageSquare className="mr-1 h-3.5 w-3.5" />
|
<MessageSquare className="mr-1 h-3.5 w-3.5" />
|
||||||
聊天
|
聊天
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type { ApiDecision } from "@/lib/errors"
|
|||||||
import { notifyInfo, notifySuccess } from "@/lib/toast"
|
import { notifyInfo, notifySuccess } from "@/lib/toast"
|
||||||
import type { Order, OrderStatus } from "@/lib/types"
|
import type { Order, OrderStatus } from "@/lib/types"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { useOrderStore } from "@/store/orders"
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -31,7 +30,8 @@ interface OrderActionsProps {
|
|||||||
order?: Order
|
order?: Order
|
||||||
onOrderChange?: (order: Order) => void
|
onOrderChange?: (order: Order) => void
|
||||||
initialStatus: OrderStatus
|
initialStatus: OrderStatus
|
||||||
chatSessionId?: string
|
chatTargetId?: string
|
||||||
|
isPlayerParticipant?: boolean
|
||||||
serviceId: string
|
serviceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +44,14 @@ export default function OrderActions({
|
|||||||
order: orderProp,
|
order: orderProp,
|
||||||
onOrderChange,
|
onOrderChange,
|
||||||
initialStatus,
|
initialStatus,
|
||||||
chatSessionId,
|
chatTargetId,
|
||||||
|
isPlayerParticipant,
|
||||||
serviceId,
|
serviceId,
|
||||||
}: OrderActionsProps) {
|
}: OrderActionsProps) {
|
||||||
const currentUserId = useAuthStore((state) => state.user?.id)
|
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||||
const storeOrder = useOrderStore((state) => state.orders.find((item) => item.id === orderId))
|
const order = orderProp
|
||||||
const order = orderProp ?? storeOrder
|
|
||||||
const [dispatchMode, setDispatchMode] = useState<"manual" | "auto" | null>(null)
|
const [dispatchMode, setDispatchMode] = useState<"manual" | "auto" | null>(null)
|
||||||
const resolvedChatSessionId = chatSessionId
|
const resolvedChatTargetId = chatTargetId
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!order?.shopId) return
|
if (!order?.shopId) return
|
||||||
@@ -79,8 +79,9 @@ export default function OrderActions({
|
|||||||
}, [order?.shopId])
|
}, [order?.shopId])
|
||||||
|
|
||||||
const status = order?.status ?? initialStatus
|
const status = order?.status ?? initialStatus
|
||||||
const isConsumer = order?.consumerId === currentUserId
|
const isConsumer = String(order?.consumerId) === currentUserId
|
||||||
const isPlayer = order?.playerId === currentUserId
|
const isPlayer = isPlayerParticipant === true
|
||||||
|
const isParticipant = isConsumer || isPlayer
|
||||||
|
|
||||||
type ActionResult = { decision: ApiDecision; order?: Order }
|
type ActionResult = { decision: ApiDecision; order?: Order }
|
||||||
|
|
||||||
@@ -110,21 +111,6 @@ export default function OrderActions({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{status === "pending_payment" && isConsumer && (
|
{status === "pending_payment" && isConsumer && (
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-border/60"
|
|
||||||
onClick={() => {
|
|
||||||
if (!currentUserId) {
|
|
||||||
notifyInfo("请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runAction("订单已取消", cancelPreAccept(orderId))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XCircle className="mr-1 h-4 w-4" />
|
|
||||||
取消订单
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -137,7 +123,6 @@ export default function OrderActions({
|
|||||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||||
确认支付
|
确认支付
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "pending_accept" && (
|
{status === "pending_accept" && (
|
||||||
@@ -181,9 +166,9 @@ export default function OrderActions({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(status === "in_progress" || status === "pending_close") && resolvedChatSessionId && (
|
{(status === "in_progress" || status === "pending_close") && resolvedChatTargetId && (
|
||||||
<Button variant="outline" className="border-border/60" asChild>
|
<Button variant="outline" className="border-border/60" asChild>
|
||||||
<Link href={`/chat/${resolvedChatSessionId}`}>
|
<Link href={`/chat/${resolvedChatTargetId}?orderId=${orderId}`}>
|
||||||
<MessageSquare className="mr-1 h-4 w-4" />
|
<MessageSquare className="mr-1 h-4 w-4" />
|
||||||
聊天
|
聊天
|
||||||
</Link>
|
</Link>
|
||||||
@@ -192,6 +177,7 @@ export default function OrderActions({
|
|||||||
|
|
||||||
{status === "in_progress" && (
|
{status === "in_progress" && (
|
||||||
<>
|
<>
|
||||||
|
{isPlayer && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -204,12 +190,15 @@ export default function OrderActions({
|
|||||||
>
|
>
|
||||||
发起结单
|
发起结单
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{isParticipant && (
|
||||||
<Button variant="destructive" asChild>
|
<Button variant="destructive" asChild>
|
||||||
<Link href={`/dispute/${orderId}`}>
|
<Link href={`/dispute/${orderId}`}>
|
||||||
<AlertTriangle className="mr-1 h-4 w-4" />
|
<AlertTriangle className="mr-1 h-4 w-4" />
|
||||||
发起争议
|
发起争议
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -228,16 +217,18 @@ export default function OrderActions({
|
|||||||
确认结单
|
确认结单
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isParticipant && (
|
||||||
<Button variant="destructive" asChild>
|
<Button variant="destructive" asChild>
|
||||||
<Link href={`/dispute/${orderId}`}>
|
<Link href={`/dispute/${orderId}`}>
|
||||||
<AlertTriangle className="mr-1 h-4 w-4" />
|
<AlertTriangle className="mr-1 h-4 w-4" />
|
||||||
发起争议
|
发起争议
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "pending_review" && (
|
{status === "pending_review" && isParticipant && (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/review/${orderId}`}>
|
<Link href={`/review/${orderId}`}>
|
||||||
<Star className="mr-1 h-4 w-4" />
|
<Star className="mr-1 h-4 w-4" />
|
||||||
@@ -264,7 +255,7 @@ export default function OrderActions({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "disputed" && (
|
{status === "disputed" && isParticipant && (
|
||||||
<Button variant="outline" className="border-border/60" asChild>
|
<Button variant="outline" className="border-border/60" asChild>
|
||||||
<Link href={`/dispute/${orderId}`}>查看详情</Link>
|
<Link href={`/dispute/${orderId}`}>查看详情</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -108,11 +108,6 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
|
|||||||
return { decision: deny(403, "仅订单参与方可发起争议") }
|
return { decision: deny(403, "仅订单参与方可发起争议") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const markResult = useOrderStore.getState().markDisputed(input.orderId, actor)
|
|
||||||
if (!markResult.decision.ok) {
|
|
||||||
return { decision: markResult.decision }
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAt = new Date().toISOString()
|
const createdAt = new Date().toISOString()
|
||||||
const dispute: DisputeRecord = {
|
const dispute: DisputeRecord = {
|
||||||
id: generateId("disp"),
|
id: generateId("disp"),
|
||||||
|
|||||||
+8
-380
@@ -1,389 +1,17 @@
|
|||||||
import type { Actor } from "@/lib/actor"
|
import type { Order } from "@/lib/types"
|
||||||
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"
|
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 {
|
interface OrderState {
|
||||||
orders: Order[]
|
orders: Order[]
|
||||||
createOrder: (input: CreateOrderInput, actor: Actor) => OrderMutationResult
|
setOrders: (orders: Order[]) => void
|
||||||
createPaidOrder: (input: CreatePaidOrderInput, actor: Actor) => OrderMutationResult
|
updateOrder: (orderId: string, patch: Partial<Order>) => void
|
||||||
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>()
|
export const useOrderStore = create<OrderState>((set) => ({
|
||||||
|
|
||||||
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: [],
|
orders: [],
|
||||||
createOrder: (input, actor) => {
|
setOrders: (orders) => set({ orders }),
|
||||||
if (actor.role !== "consumer") {
|
updateOrder: (orderId, patch) =>
|
||||||
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) => ({
|
set((state) => ({
|
||||||
orders: [order, ...state.orders],
|
orders: state.orders.map((o) => (o.id === orderId ? { ...o, ...patch } : o)),
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { allow, deny } from "@/lib/decision"
|
|||||||
import type { ApiDecision } from "@/lib/errors"
|
import type { ApiDecision } from "@/lib/errors"
|
||||||
import { generateId } from "@/lib/id"
|
import { generateId } from "@/lib/id"
|
||||||
import type { Review } from "@/lib/types"
|
import type { Review } from "@/lib/types"
|
||||||
import { useOrderStore } from "@/store/orders"
|
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
|
||||||
interface SubmitReviewInput {
|
interface SubmitReviewInput {
|
||||||
@@ -33,21 +32,6 @@ export const useReviewStore = create<ReviewState>((set, get) => ({
|
|||||||
return deny(400, "评分范围应为 1-5")
|
return deny(400, "评分范围应为 1-5")
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId)
|
|
||||||
if (!order) {
|
|
||||||
return deny(404, "订单不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status !== "pending_review") {
|
|
||||||
return deny(400, "仅待评价订单可提交评价")
|
|
||||||
}
|
|
||||||
|
|
||||||
const isParticipant =
|
|
||||||
order.consumerId === input.fromUserId || order.playerId === input.fromUserId
|
|
||||||
if (!isParticipant) {
|
|
||||||
return deny(403, "仅订单参与方可评价")
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = get().hasUserReviewed(input.orderId, input.fromUserId)
|
const exists = get().hasUserReviewed(input.orderId, input.fromUserId)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return deny(400, "该订单已提交过评价")
|
return deny(400, "该订单已提交过评价")
|
||||||
@@ -75,7 +59,6 @@ export const useReviewStore = create<ReviewState>((set, get) => ({
|
|||||||
item.orderId === input.orderId ? { ...item, sealed: false } : item,
|
item.orderId === input.orderId ? { ...item, sealed: false } : item,
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
useOrderStore.getState().autoTimeoutPendingReview(input.orderId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allow()
|
return allow()
|
||||||
|
|||||||
Reference in New Issue
Block a user