refactor(order): add transition evaluator and timer constants
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
|
||||||
import { Label } from "@/components/ui/label"
|
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"
|
||||||
@@ -342,7 +343,9 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
|
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
|
||||||
<AlertTriangle className="h-12 w-12 mx-auto text-yellow-500" />
|
<AlertTriangle className="h-12 w-12 mx-auto text-yellow-500" />
|
||||||
<h2 className="text-xl font-bold">争议已提交,请等待平台处理</h2>
|
<h2 className="text-xl font-bold">争议已提交,请等待平台处理</h2>
|
||||||
<p className="text-sm text-muted-foreground">平台将在 3 个工作日内审核你的争议申请。</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
平台将在约 {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} 秒内给出模拟处理结果。
|
||||||
|
</p>
|
||||||
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
|
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
|
||||||
返回订单详情
|
返回订单详情
|
||||||
</Link>
|
</Link>
|
||||||
@@ -434,7 +437,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
|
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
|
||||||
<p>· 提交争议后,订单资金将继续托管</p>
|
<p>· 提交争议后,订单资金将继续托管</p>
|
||||||
<p>· 聊天记录将作为证据保留</p>
|
<p>· 聊天记录将作为证据保留</p>
|
||||||
<p>· 平台将在 3 个工作日内审核</p>
|
<p>· 平台将在约 {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} 秒内完成模拟审核</p>
|
||||||
<p>· 对仲裁结果不满可申诉一次</p>
|
<p>· 对仲裁结果不满可申诉一次</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { use, useEffect, useMemo, useState } from "react"
|
|||||||
import OrderActions from "@/components/order-actions"
|
import OrderActions from "@/components/order-actions"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { statusLabels } from "@/lib/constants"
|
import { statusLabels } from "@/lib/constants"
|
||||||
import type { OrderStatus } from "@/lib/types"
|
import type { OrderStatus } from "@/lib/types"
|
||||||
@@ -85,11 +86,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
order.status === "pending_accept"
|
order.status === "pending_accept"
|
||||||
? new Date(order.createdAt).getTime()
|
? new Date(order.createdAt).getTime()
|
||||||
: new Date(order.closedAt ?? order.createdAt).getTime()
|
: new Date(order.closedAt ?? order.createdAt).getTime()
|
||||||
const remainSeconds = Math.max(0, 30 - Math.floor((nowTs - base) / 1000))
|
const timeoutMs =
|
||||||
|
order.status === "pending_accept" ? ORDER_ACCEPT_TIMEOUT_MS : ORDER_CLOSE_TIMEOUT_MS
|
||||||
|
const remainSeconds = Math.max(0, Math.ceil((timeoutMs - (nowTs - base)) / 1000))
|
||||||
|
|
||||||
return order.status === "pending_accept"
|
return order.status === "pending_accept"
|
||||||
? `若 30 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)`
|
? `若 ${Math.floor(ORDER_ACCEPT_TIMEOUT_MS / 1000)} 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)`
|
||||||
: `若 30 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)`
|
: `若 ${Math.floor(ORDER_CLOSE_TIMEOUT_MS / 1000)} 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)`
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const ORDER_ACCEPT_TIMEOUT_MS = 30_000
|
||||||
|
export const ORDER_CLOSE_TIMEOUT_MS = 30_000
|
||||||
|
export const ORDER_REVIEW_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export const DISPUTE_TO_REVIEWING_MS = 5_000
|
||||||
|
export const DISPUTE_TO_RESOLVED_MS = 10_000
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { allow, deny, requireAuth } from "@/lib/policy/assert"
|
||||||
|
import type { Actor } from "@/lib/policy/actor"
|
||||||
|
import type { PolicyDecision } from "@/lib/policy/decision"
|
||||||
|
import type { OrderStatus } from "@/lib/types"
|
||||||
|
|
||||||
|
export type OrderAction =
|
||||||
|
| "PAY"
|
||||||
|
| "ACCEPT"
|
||||||
|
| "REQUEST_CLOSE"
|
||||||
|
| "CONFIRM_CLOSE"
|
||||||
|
| "CANCEL_PRE_ACCEPT"
|
||||||
|
| "OPEN_DISPUTE"
|
||||||
|
| "RESOLVE_DISPUTE"
|
||||||
|
| "SUBMIT_REVIEW"
|
||||||
|
| "AUTO_TIMEOUT_PENDING_ACCEPT"
|
||||||
|
| "AUTO_TIMEOUT_PENDING_CLOSE"
|
||||||
|
| "AUTO_TIMEOUT_PENDING_REVIEW"
|
||||||
|
|
||||||
|
export type OrderTransitionSideEffectType =
|
||||||
|
| "CLEAR_TIMEOUT"
|
||||||
|
| "SCHEDULE_TIMEOUT"
|
||||||
|
| "SYNC_CHAT_SESSION"
|
||||||
|
| "PAYOUT_INCOME"
|
||||||
|
|
||||||
|
export interface OrderTransitionSideEffect {
|
||||||
|
type: OrderTransitionSideEffectType
|
||||||
|
status?: OrderStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransitionContext {
|
||||||
|
actor?: Actor | null
|
||||||
|
order: {
|
||||||
|
status: OrderStatus
|
||||||
|
}
|
||||||
|
action: OrderAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderTransitionResult {
|
||||||
|
decision: PolicyDecision
|
||||||
|
nextStatus?: OrderStatus
|
||||||
|
sideEffects: OrderTransitionSideEffect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderTransitionTable: Record<
|
||||||
|
OrderStatus,
|
||||||
|
Partial<Record<OrderAction, OrderStatus>>
|
||||||
|
> = {
|
||||||
|
pending_payment: {
|
||||||
|
PAY: "pending_accept",
|
||||||
|
},
|
||||||
|
pending_accept: {
|
||||||
|
ACCEPT: "in_progress",
|
||||||
|
CANCEL_PRE_ACCEPT: "cancelled",
|
||||||
|
AUTO_TIMEOUT_PENDING_ACCEPT: "cancelled",
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
REQUEST_CLOSE: "pending_close",
|
||||||
|
OPEN_DISPUTE: "disputed",
|
||||||
|
},
|
||||||
|
pending_close: {
|
||||||
|
CONFIRM_CLOSE: "pending_review",
|
||||||
|
OPEN_DISPUTE: "disputed",
|
||||||
|
AUTO_TIMEOUT_PENDING_CLOSE: "pending_review",
|
||||||
|
},
|
||||||
|
pending_review: {
|
||||||
|
SUBMIT_REVIEW: "completed",
|
||||||
|
AUTO_TIMEOUT_PENDING_REVIEW: "completed",
|
||||||
|
},
|
||||||
|
disputed: {
|
||||||
|
RESOLVE_DISPUTE: "pending_review",
|
||||||
|
},
|
||||||
|
completed: {},
|
||||||
|
cancelled: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoAction(action: OrderAction): boolean {
|
||||||
|
return action.startsWith("AUTO_TIMEOUT_")
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRolePermission(action: OrderAction, actor?: Actor | null): PolicyDecision {
|
||||||
|
if (isAutoAction(action)) {
|
||||||
|
return allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDecision = requireAuth(actor)
|
||||||
|
if (!authDecision.ok) {
|
||||||
|
return authDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actor) {
|
||||||
|
return deny("AUTH_REQUIRED", "请先登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "PAY" || action === "CANCEL_PRE_ACCEPT") {
|
||||||
|
return actor.role === "consumer" ? allow() : deny("ROLE_FORBIDDEN", "仅客户可执行该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "ACCEPT") {
|
||||||
|
return actor.role === "player" || actor.role === "owner"
|
||||||
|
? allow()
|
||||||
|
: deny("ROLE_FORBIDDEN", "仅打手或店主可执行该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "RESOLVE_DISPUTE") {
|
||||||
|
return actor.role === "owner" ? allow() : deny("ROLE_FORBIDDEN", "仅店主可执行该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.role === "consumer" || actor.role === "player"
|
||||||
|
? allow()
|
||||||
|
: deny("ROLE_FORBIDDEN", "当前身份不可执行该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSideEffects(nextStatus: OrderStatus): OrderTransitionSideEffect[] {
|
||||||
|
const sideEffects: OrderTransitionSideEffect[] = [{ type: "CLEAR_TIMEOUT" }]
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextStatus === "pending_accept" ||
|
||||||
|
nextStatus === "pending_close" ||
|
||||||
|
nextStatus === "pending_review"
|
||||||
|
) {
|
||||||
|
sideEffects.push({ type: "SCHEDULE_TIMEOUT", status: nextStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus !== "pending_payment" && nextStatus !== "cancelled") {
|
||||||
|
sideEffects.push({ type: "SYNC_CHAT_SESSION" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "completed") {
|
||||||
|
sideEffects.push({ type: "PAYOUT_INCOME" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return sideEffects
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateOrderTransition(context: TransitionContext): OrderTransitionResult {
|
||||||
|
const roleDecision = checkRolePermission(context.action, context.actor)
|
||||||
|
if (!roleDecision.ok) {
|
||||||
|
return {
|
||||||
|
decision: roleDecision,
|
||||||
|
sideEffects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = orderTransitionTable[context.order.status][context.action]
|
||||||
|
if (!nextStatus) {
|
||||||
|
return {
|
||||||
|
decision: deny("INVALID_STATUS", "当前状态不可执行该操作"),
|
||||||
|
sideEffects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === context.order.status) {
|
||||||
|
return {
|
||||||
|
decision: deny("IDEMPOTENT_NOOP", "状态未变化"),
|
||||||
|
sideEffects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decision: allow(),
|
||||||
|
nextStatus,
|
||||||
|
sideEffects: buildSideEffects(nextStatus),
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
import { DISPUTE_TO_RESOLVED_MS, DISPUTE_TO_REVIEWING_MS } from "@/lib/config/demo-timers"
|
||||||
import { generateId } from "@/lib/id"
|
import { generateId } from "@/lib/id"
|
||||||
import { mockDisputes } from "@/lib/mock"
|
import { mockDisputes } from "@/lib/mock"
|
||||||
import type { Dispute } from "@/lib/types"
|
import type { Dispute } from "@/lib/types"
|
||||||
@@ -108,7 +109,7 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}, 5000)
|
}, DISPUTE_TO_REVIEWING_MS)
|
||||||
|
|
||||||
const toResolved = setTimeout(() => {
|
const toResolved = setTimeout(() => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -133,7 +134,7 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
clearProgressTimers(disputeId)
|
clearProgressTimers(disputeId)
|
||||||
}, 10000)
|
}, DISPUTE_TO_RESOLVED_MS)
|
||||||
|
|
||||||
progressTimers.set(disputeId, [toReviewing, toResolved])
|
progressTimers.set(disputeId, [toReviewing, toResolved])
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
|
||||||
import { generateId } from "@/lib/id"
|
import { generateId } from "@/lib/id"
|
||||||
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"
|
||||||
@@ -39,6 +40,8 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeoutMs = status === "pending_accept" ? ORDER_ACCEPT_TIMEOUT_MS : ORDER_CLOSE_TIMEOUT_MS
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const state = useOrderStore.getState()
|
const state = useOrderStore.getState()
|
||||||
const order = state.orders.find((item) => item.id === orderId)
|
const order = state.orders.find((item) => item.id === orderId)
|
||||||
@@ -56,7 +59,7 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderTimeouts.delete(orderId)
|
orderTimeouts.delete(orderId)
|
||||||
}, 30000)
|
}, timeoutMs)
|
||||||
|
|
||||||
orderTimeouts.set(orderId, timer)
|
orderTimeouts.set(orderId, timer)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user