diff --git a/app/(order)/dispute/[id]/page.tsx b/app/(order)/dispute/[id]/page.tsx index c4250ad..30a387d 100644 --- a/app/(order)/dispute/[id]/page.tsx +++ b/app/(order)/dispute/[id]/page.tsx @@ -16,6 +16,7 @@ import { import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" 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 { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" @@ -342,7 +343,9 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }

争议已提交,请等待平台处理

-

平台将在 3 个工作日内审核你的争议申请。

+

+ 平台将在约 {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} 秒内给出模拟处理结果。 +

返回订单详情 @@ -434,7 +437,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }

· 提交争议后,订单资金将继续托管

· 聊天记录将作为证据保留

-

· 平台将在 3 个工作日内审核

+

· 平台将在约 {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} 秒内完成模拟审核

· 对仲裁结果不满可申诉一次

diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index 082b661..ba07590 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -6,6 +6,7 @@ import { use, useEffect, useMemo, useState } from "react" import OrderActions from "@/components/order-actions" import { Badge } from "@/components/ui/badge" 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 { statusLabels } from "@/lib/constants" import type { OrderStatus } from "@/lib/types" @@ -85,11 +86,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri order.status === "pending_accept" ? new Date(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" - ? `若 30 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)` - : `若 30 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)` + ? `若 ${Math.floor(ORDER_ACCEPT_TIMEOUT_MS / 1000)} 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)` + : `若 ${Math.floor(ORDER_CLOSE_TIMEOUT_MS / 1000)} 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)` })() return ( diff --git a/lib/config/demo-timers.ts b/lib/config/demo-timers.ts new file mode 100644 index 0000000..ca87024 --- /dev/null +++ b/lib/config/demo-timers.ts @@ -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 diff --git a/lib/domain/order-machine.ts b/lib/domain/order-machine.ts new file mode 100644 index 0000000..f064076 --- /dev/null +++ b/lib/domain/order-machine.ts @@ -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> +> = { + 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), + } +} diff --git a/store/disputes.ts b/store/disputes.ts index 07fb543..a239f55 100644 --- a/store/disputes.ts +++ b/store/disputes.ts @@ -1,4 +1,5 @@ import { create } from "zustand" +import { DISPUTE_TO_RESOLVED_MS, DISPUTE_TO_REVIEWING_MS } from "@/lib/config/demo-timers" import { generateId } from "@/lib/id" import { mockDisputes } from "@/lib/mock" import type { Dispute } from "@/lib/types" @@ -108,7 +109,7 @@ export const useDisputeStore = create((set, get) => { } }), })) - }, 5000) + }, DISPUTE_TO_REVIEWING_MS) const toResolved = setTimeout(() => { set((state) => ({ @@ -133,7 +134,7 @@ export const useDisputeStore = create((set, get) => { }), })) clearProgressTimers(disputeId) - }, 10000) + }, DISPUTE_TO_RESOLVED_MS) progressTimers.set(disputeId, [toReviewing, toResolved]) } diff --git a/store/orders.ts b/store/orders.ts index b166b81..92205d0 100644 --- a/store/orders.ts +++ b/store/orders.ts @@ -1,4 +1,5 @@ import { create } from "zustand" +import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers" import { generateId } from "@/lib/id" import { mockOrders } from "@/lib/mock" import type { Order, OrderStatus, PlayerService } from "@/lib/types" @@ -39,6 +40,8 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) { return } + const timeoutMs = status === "pending_accept" ? ORDER_ACCEPT_TIMEOUT_MS : ORDER_CLOSE_TIMEOUT_MS + const timer = setTimeout(() => { const state = useOrderStore.getState() const order = state.orders.find((item) => item.id === orderId) @@ -56,7 +59,7 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) { } orderTimeouts.delete(orderId) - }, 30000) + }, timeoutMs) orderTimeouts.set(orderId, timer) }