diff --git a/app/(order)/chat/[id]/page.tsx b/app/(order)/chat/[id]/page.tsx index 0b3b977..2f14909 100644 --- a/app/(order)/chat/[id]/page.tsx +++ b/app/(order)/chat/[id]/page.tsx @@ -150,7 +150,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin const file = event.target.files?.[0] if (!file) return const result = sendImageMessage(session.id, URL.createObjectURL(file)) - if (result && !result.ok) notifyInfo(result.message ?? "发送失败") + if (!result.ok) notifyInfo(result.error.msg) event.target.value = "" }} /> @@ -162,8 +162,8 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin if (!text) return const result = sendTextMessage(session.id, text) - if (result && !result.ok) { - notifyInfo(result.message ?? "发送失败") + if (!result.ok) { + notifyInfo(result.error.msg) return } setInput("") diff --git a/app/(order)/dispute/[id]/page.tsx b/app/(order)/dispute/[id]/page.tsx index d537f3e..59d5c09 100644 --- a/app/(order)/dispute/[id]/page.tsx +++ b/app/(order)/dispute/[id]/page.tsx @@ -112,7 +112,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } evidence: files, }) if (!result.decision.ok) { - notifyInfo(result.decision.message ?? "提交争议失败") + notifyInfo(result.decision.error.msg) return } router.replace(`/dispute/${id}?submitted=1`) @@ -288,7 +288,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } evidence: responseFiles, }) if (!decision.ok) { - notifyInfo(decision.message ?? "提交回应失败") + notifyInfo(decision.error.msg) } }} disabled={!responseReason.trim()} @@ -336,7 +336,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } reason: appealReason, }) if (!decision.ok) { - notifyInfo(decision.message ?? "提交申诉失败") + notifyInfo(decision.error.msg) } }} disabled={!appealReason.trim()} diff --git a/app/(order)/order/new/page.tsx b/app/(order)/order/new/page.tsx index a9effaa..ceeadd6 100644 --- a/app/(order)/order/new/page.tsx +++ b/app/(order)/order/new/page.tsx @@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" +import type { Actor } from "@/lib/actor" import { getPlayerById, getServiceById } from "@/lib/api" -import type { Actor } from "@/lib/policy/actor" import { notifySuccess } from "@/lib/toast" import { useRequireAuth } from "@/lib/use-require-auth" import { useAuthStore } from "@/store/auth" diff --git a/app/(order)/review/[id]/page.tsx b/app/(order)/review/[id]/page.tsx index 8782fed..54dcb47 100644 --- a/app/(order)/review/[id]/page.tsx +++ b/app/(order)/review/[id]/page.tsx @@ -154,7 +154,7 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }> return } - notifyInfo(decision.message ?? "评价提交失败") + notifyInfo(decision.error.msg) }} > 提交评价 diff --git a/components/order-actions.tsx b/components/order-actions.tsx index a0dc6a6..febd655 100644 --- a/components/order-actions.tsx +++ b/components/order-actions.tsx @@ -8,6 +8,7 @@ import { payOrder, requestClose, } from "@/lib/api/orders" +import type { ApiDecision } from "@/lib/errors" import { notifyInfo, notifySuccess } from "@/lib/toast" import type { OrderStatus } from "@/lib/types" import { useAuthStore } from "@/store/auth" @@ -59,17 +60,14 @@ export default function OrderActions({ const isConsumer = order?.consumerId === currentUserId const isPlayer = order?.playerId === currentUserId - const handleDecision = useCallback( - (okMessage: string, result: { decision: { ok: boolean; message?: string } }) => { - if (result.decision.ok) { - showFeedback(okMessage) - return - } + const handleDecision = useCallback((okMessage: string, result: { decision: ApiDecision }) => { + if (result.decision.ok) { + showFeedback(okMessage) + return + } - notifyInfo(result.decision.message ?? "当前操作不允许") - }, - [], - ) + notifyInfo(result.decision.error.msg) + }, []) return (
diff --git a/lib/policy/actor.ts b/lib/actor.ts similarity index 100% rename from lib/policy/actor.ts rename to lib/actor.ts diff --git a/lib/api/chat.ts b/lib/api/chat.ts index 24c8a9a..a8a1b0d 100644 --- a/lib/api/chat.ts +++ b/lib/api/chat.ts @@ -1,4 +1,4 @@ -import { allow, deny } from "@/lib/policy/assert" +import { allow, deny } from "@/lib/decision" import { useAuthStore } from "@/store/auth" import { useChatStore } from "@/store/chat" @@ -16,18 +16,18 @@ export function listChatMessages(sessionId: string) { export function sendTextMessage(sessionId: string, content: string) { const userId = useAuthStore.getState().user?.id - if (!userId) return deny("AUTH_REQUIRED", "请先登录") + if (!userId) return deny(401, "请先登录") const chatState = useChatStore.getState() const session = chatState.sessions.find((item) => item.id === sessionId) - if (!session) return deny("NOT_FOUND", "会话不存在") - if (session.readonly) return deny("INVALID_STATUS", "当前会话只读") + if (!session) return deny(404, "会话不存在") + if (session.readonly) return deny(400, "当前会话只读") if (!session.participants.some((participant) => participant.id === userId)) { - return deny("NOT_PARTICIPANT", "仅会话参与方可发送消息") + return deny(403, "仅会话参与方可发送消息") } if (!content.trim()) { - return deny("VALIDATION_FAILED", "消息不能为空") + return deny(400, "消息不能为空") } chatState.sendTextMessage(sessionId, userId, content) @@ -36,18 +36,18 @@ export function sendTextMessage(sessionId: string, content: string) { export function sendImageMessage(sessionId: string, imageUrl: string) { const userId = useAuthStore.getState().user?.id - if (!userId) return deny("AUTH_REQUIRED", "请先登录") + if (!userId) return deny(401, "请先登录") const chatState = useChatStore.getState() const session = chatState.sessions.find((item) => item.id === sessionId) - if (!session) return deny("NOT_FOUND", "会话不存在") - if (session.readonly) return deny("INVALID_STATUS", "当前会话只读") + if (!session) return deny(404, "会话不存在") + if (session.readonly) return deny(400, "当前会话只读") if (!session.participants.some((participant) => participant.id === userId)) { - return deny("NOT_PARTICIPANT", "仅会话参与方可发送消息") + return deny(403, "仅会话参与方可发送消息") } if (!imageUrl.trim()) { - return deny("VALIDATION_FAILED", "图片地址无效") + return deny(400, "图片地址无效") } chatState.sendImageMessage(sessionId, userId, imageUrl) diff --git a/lib/api/comments.ts b/lib/api/comments.ts index 0b54c0b..b890946 100644 --- a/lib/api/comments.ts +++ b/lib/api/comments.ts @@ -1,5 +1,5 @@ import { addNotification } from "@/lib/api/notifications" -import { allow, deny } from "@/lib/policy/assert" +import { allow, deny } from "@/lib/decision" import { useAuthStore } from "@/store/auth" import { useCommentStore } from "@/store/comments" import { usePostStore } from "@/store/posts" @@ -15,17 +15,17 @@ export function listCommentsByPost(postId: string) { export function addComment(postId: string, content: string) { const user = useAuthStore.getState().user if (!user) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } const post = usePostStore.getState().posts.find((item) => item.id === postId) if (!post) { - return deny("NOT_FOUND", "帖子不存在") + return deny(404, "帖子不存在") } const comment = useCommentStore.getState().addComment(postId, user, content) if (!comment) { - return deny("VALIDATION_FAILED", "评论内容不能为空") + return deny(400, "评论内容不能为空") } usePostStore.getState().incrementCommentCount(postId) @@ -43,12 +43,12 @@ export function addComment(postId: string, content: string) { export function toggleCommentLike(commentId: string) { const user = useAuthStore.getState().user if (!user) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } const comment = useCommentStore.getState().comments.find((item) => item.id === commentId) if (!comment) { - return deny("NOT_FOUND", "评论不存在") + return deny(404, "评论不存在") } useCommentStore.getState().toggleCommentLike(commentId) diff --git a/lib/api/disputes.ts b/lib/api/disputes.ts index 5e534c1..b11a0dc 100644 --- a/lib/api/disputes.ts +++ b/lib/api/disputes.ts @@ -1,4 +1,4 @@ -import { deny } from "@/lib/policy/assert" +import { deny } from "@/lib/decision" import { useAuthStore } from "@/store/auth" import { useDisputeStore } from "@/store/disputes" @@ -13,7 +13,7 @@ export function getDisputeByOrderId(orderId: string) { export function submitDispute(input: { orderId: string; reason: string; evidence: string[] }) { const user = useAuthStore.getState().user if (!user?.id || !user.nickname) { - return { decision: deny("AUTH_REQUIRED", "请先登录") } + return { decision: deny(401, "请先登录") } } return useDisputeStore.getState().submitDispute({ @@ -32,7 +32,7 @@ export function submitDisputeResponse(input: { }) { const userId = useAuthStore.getState().user?.id if (!userId) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } return useDisputeStore @@ -43,7 +43,7 @@ export function submitDisputeResponse(input: { export function submitDisputeAppeal(input: { disputeId: string; reason: string }) { const userId = useAuthStore.getState().user?.id if (!userId) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } return useDisputeStore.getState().submitAppeal(input.disputeId, userId, input.reason) diff --git a/lib/api/notifications.ts b/lib/api/notifications.ts index 803539e..e5a6a5c 100644 --- a/lib/api/notifications.ts +++ b/lib/api/notifications.ts @@ -1,4 +1,4 @@ -import { allow, deny } from "@/lib/policy/assert" +import { allow, deny } from "@/lib/decision" import type { Notification } from "@/lib/types" import { useAuthStore } from "@/store/auth" import { useNotificationStore } from "@/store/notifications" @@ -21,7 +21,7 @@ export function addNotification(input: { link?: string }) { if (!isNotificationEnabled(input.type)) { - return deny("IDEMPOTENT_NOOP", "该类通知已关闭") + return deny(400, "该类通知已关闭") } useNotificationStore.getState().addNotification(input) diff --git a/lib/api/orders.ts b/lib/api/orders.ts index bf353f5..4654eae 100644 --- a/lib/api/orders.ts +++ b/lib/api/orders.ts @@ -1,7 +1,7 @@ +import type { Actor } from "@/lib/actor" +import { allow, deny } from "@/lib/decision" import { resolveOwnerShop } from "@/lib/domain/resolve-current-shop" -import type { Actor } from "@/lib/policy/actor" -import { allow, deny } from "@/lib/policy/assert" -import type { PolicyDecision } from "@/lib/policy/decision" +import type { ApiDecision } from "@/lib/errors" import type { PlayerService } from "@/lib/types" import { useAuthStore } from "@/store/auth" import { useOrderStore } from "@/store/orders" @@ -31,10 +31,10 @@ interface CreatePaidOrderInput { note?: string } -function resolveActorContext(): { actor?: Actor; decision: PolicyDecision } { +function resolveActorContext(): { actor?: Actor; decision: ApiDecision } { const auth = useAuthStore.getState() if (!auth.user?.id) { - return { decision: deny("AUTH_REQUIRED", "请先登录") } + return { decision: deny(401, "请先登录") } } const shopId = diff --git a/lib/api/posts.ts b/lib/api/posts.ts index abcece1..4fb5a0a 100644 --- a/lib/api/posts.ts +++ b/lib/api/posts.ts @@ -1,5 +1,5 @@ import { addNotification } from "@/lib/api/notifications" -import { allow, deny } from "@/lib/policy/assert" +import { allow, deny } from "@/lib/decision" import { useAuthStore } from "@/store/auth" import { usePostStore } from "@/store/posts" @@ -18,12 +18,12 @@ export function listPostsByAuthor(userId: string) { export function togglePostLike(postId: string) { const user = useAuthStore.getState().user if (!user) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } const post = usePostStore.getState().posts.find((item) => item.id === postId) if (!post) { - return deny("NOT_FOUND", "帖子不存在") + return deny(404, "帖子不存在") } const shouldNotify = !post.liked diff --git a/lib/api/reviews.ts b/lib/api/reviews.ts index 1a7a18e..fd7c946 100644 --- a/lib/api/reviews.ts +++ b/lib/api/reviews.ts @@ -1,4 +1,4 @@ -import { deny } from "@/lib/policy/assert" +import { deny } from "@/lib/decision" import { useAuthStore } from "@/store/auth" import { useReviewStore } from "@/store/reviews" @@ -17,7 +17,7 @@ export function listReviewsByTargetUser(userId: string) { export function submitReview(input: { orderId: string; rating: number; content?: string }) { const userId = useAuthStore.getState().user?.id if (!userId) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } return useReviewStore.getState().submitReview({ diff --git a/lib/decision.ts b/lib/decision.ts new file mode 100644 index 0000000..c972ffd --- /dev/null +++ b/lib/decision.ts @@ -0,0 +1,18 @@ +import type { Actor } from "@/lib/actor" +import type { ApiDecision } from "@/lib/errors" + +export function allow(): ApiDecision { + return { ok: true } +} + +export function deny(code: number, msg: string): ApiDecision { + return { ok: false, error: { code, msg } } +} + +export function requireAuth(actor: Actor | null | undefined): ApiDecision { + if (!actor?.userId) { + return deny(401, "请先登录") + } + + return allow() +} diff --git a/lib/domain/order-machine.ts b/lib/domain/order-machine.ts index 5afbb10..e0c0032 100644 --- a/lib/domain/order-machine.ts +++ b/lib/domain/order-machine.ts @@ -1,6 +1,6 @@ -import type { Actor } from "@/lib/policy/actor" -import { allow, deny, requireAuth } from "@/lib/policy/assert" -import type { PolicyDecision } from "@/lib/policy/decision" +import type { Actor } from "@/lib/actor" +import { allow, deny, requireAuth } from "@/lib/decision" +import type { ApiDecision } from "@/lib/errors" import type { OrderStatus } from "@/lib/types" export type OrderAction = @@ -36,7 +36,7 @@ interface TransitionContext { } export interface OrderTransitionResult { - decision: PolicyDecision + decision: ApiDecision nextStatus?: OrderStatus sideEffects: OrderTransitionSideEffect[] } @@ -77,7 +77,7 @@ function isAutoAction(action: OrderAction): boolean { return action.startsWith("AUTO_TIMEOUT_") } -function checkRolePermission(action: OrderAction, actor?: Actor | null): PolicyDecision { +function checkRolePermission(action: OrderAction, actor?: Actor | null): ApiDecision { if (isAutoAction(action) || (action === "RESOLVE_DISPUTE" && !actor?.userId)) { return allow() } @@ -88,26 +88,26 @@ function checkRolePermission(action: OrderAction, actor?: Actor | null): PolicyD } if (!actor) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } if (action === "PAY" || action === "CANCEL_PRE_ACCEPT") { - return actor.role === "consumer" ? allow() : deny("ROLE_FORBIDDEN", "仅客户可执行该操作") + return actor.role === "consumer" ? allow() : deny(403, "仅客户可执行该操作") } if (action === "ACCEPT") { return actor.role === "player" || actor.role === "owner" ? allow() - : deny("ROLE_FORBIDDEN", "仅打手或店主可执行该操作") + : deny(403, "仅打手或店主可执行该操作") } if (action === "RESOLVE_DISPUTE") { - return actor.role === "owner" ? allow() : deny("ROLE_FORBIDDEN", "仅店主可执行该操作") + return actor.role === "owner" ? allow() : deny(403, "仅店主可执行该操作") } return actor.role === "consumer" || actor.role === "player" ? allow() - : deny("ROLE_FORBIDDEN", "当前身份不可执行该操作") + : deny(403, "当前身份不可执行该操作") } function buildSideEffects(nextStatus: OrderStatus): OrderTransitionSideEffect[] { @@ -144,14 +144,14 @@ export function evaluateOrderTransition(context: TransitionContext): OrderTransi const nextStatus = orderTransitionTable[context.order.status][context.action] if (!nextStatus) { return { - decision: deny("INVALID_STATUS", "当前状态不可执行该操作"), + decision: deny(400, "当前状态不可执行该操作"), sideEffects: [], } } if (nextStatus === context.order.status) { return { - decision: deny("IDEMPOTENT_NOOP", "状态未变化"), + decision: deny(400, "状态未变化"), sideEffects: [], } } diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..f117965 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,20 @@ +export type ApiError = { code: number; msg: string } + +export type ApiDecision = { ok: true } | { ok: false; error: ApiError } + +export function isApiError(value: unknown): value is ApiError { + if (typeof value !== "object" || value === null) return false + + const v = value as { code?: unknown; msg?: unknown } + return typeof v.code === "number" && typeof v.msg === "string" +} + +export function toApiError(err: unknown): ApiError { + if (isApiError(err)) return err + if (err instanceof Error) return { code: 500, msg: err.message } + if (typeof err === "string") return { code: 500, msg: err } + + return { code: 500, msg: "Unknown error" } +} + +export type ApiResult = { ok: true; data: T } | { ok: false; error: ApiError } diff --git a/lib/policy/assert.ts b/lib/policy/assert.ts deleted file mode 100644 index cbab0e1..0000000 --- a/lib/policy/assert.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Actor } from "@/lib/policy/actor" -import type { PolicyDecision, ReasonCode } from "@/lib/policy/decision" - -export function allow(): PolicyDecision { - return { ok: true } -} - -export function deny(reasonCode: ReasonCode, message?: string): PolicyDecision { - return { ok: false, reasonCode, message } -} - -export function requireAuth(actor: Actor | null | undefined): PolicyDecision { - if (!actor?.userId) { - return deny("AUTH_REQUIRED", "请先登录") - } - - return allow() -} diff --git a/lib/policy/decision.ts b/lib/policy/decision.ts deleted file mode 100644 index cf9039d..0000000 --- a/lib/policy/decision.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type ReasonCode = - | "AUTH_REQUIRED" - | "NOT_FOUND" - | "NOT_PARTICIPANT" - | "ROLE_FORBIDDEN" - | "INVALID_STATUS" - | "ALREADY_DONE" - | "DISPUTE_LOCKED" - | "PAYMENT_FAILED" - | "IDEMPOTENT_NOOP" - | "VALIDATION_FAILED" - | "DUPLICATE_REQUEST" - -export interface PolicyDecision { - ok: boolean - reasonCode?: ReasonCode - message?: string -} diff --git a/store/disputes.ts b/store/disputes.ts index f622521..786023c 100644 --- a/store/disputes.ts +++ b/store/disputes.ts @@ -1,9 +1,9 @@ +import type { Actor } from "@/lib/actor" import { DISPUTE_TO_RESOLVED_MS, DISPUTE_TO_REVIEWING_MS } from "@/lib/config/demo-timers" +import { allow, deny } from "@/lib/decision" +import type { ApiDecision } from "@/lib/errors" import { generateId } from "@/lib/id" import { mockDisputes } from "@/lib/mock" -import type { Actor } from "@/lib/policy/actor" -import { allow, deny } from "@/lib/policy/assert" -import type { PolicyDecision } from "@/lib/policy/decision" import type { Dispute } from "@/lib/types" import { useAuthStore } from "@/store/auth" import { useNotificationStore } from "@/store/notifications" @@ -36,7 +36,7 @@ interface SubmitDisputeInput { } interface DisputeMutationResult { - decision: PolicyDecision + decision: ApiDecision dispute?: DisputeRecord } @@ -49,8 +49,8 @@ interface DisputeState { actorId: string, reason: string, evidence: string[], - ) => PolicyDecision - submitAppeal: (disputeId: string, actorId: string, reason: string) => PolicyDecision + ) => ApiDecision + submitAppeal: (disputeId: string, actorId: string, reason: string) => ApiDecision } const progressTimers = new Map[]>() @@ -205,20 +205,20 @@ export const useDisputeStore = create((set, get) => { submitDispute: (input) => { const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId) if (!order) { - return { decision: deny("NOT_FOUND", "订单不存在") } + return { decision: deny(404, "订单不存在") } } if (order.status !== "in_progress" && order.status !== "pending_close") { - return { decision: deny("INVALID_STATUS", "当前阶段不可发起争议") } + return { decision: deny(400, "当前阶段不可发起争议") } } if (!input.reason.trim()) { - return { decision: deny("VALIDATION_FAILED", "请填写争议原因") } + return { decision: deny(400, "请填写争议原因") } } const actor = resolveParticipantActor(input.orderId, input.initiatorId) if (!actor) { - return { decision: deny("NOT_PARTICIPANT", "仅订单参与方可发起争议") } + return { decision: deny(403, "仅订单参与方可发起争议") } } const markResult = useOrderStore.getState().markDisputed(input.orderId, actor) @@ -254,28 +254,28 @@ export const useDisputeStore = create((set, get) => { submitResponse: (disputeId, actorId, reason, evidence) => { const dispute = get().disputes.find((item) => item.id === disputeId) if (!dispute) { - return deny("NOT_FOUND", "争议不存在") + return deny(404, "争议不存在") } const actor = resolveParticipantActor(dispute.orderId, actorId) if (!actor) { - return deny("NOT_PARTICIPANT", "仅订单参与方可提交回应") + return deny(403, "仅订单参与方可提交回应") } if (actorId === dispute.initiatorId) { - return deny("ROLE_FORBIDDEN", "争议发起方不可提交回应") + return deny(403, "争议发起方不可提交回应") } if (dispute.respondentReason) { - return deny("ALREADY_DONE", "回应已提交") + return deny(400, "回应已提交") } if (dispute.status !== "open" && dispute.status !== "reviewing") { - return deny("INVALID_STATUS", "当前状态不可提交回应") + return deny(400, "当前状态不可提交回应") } if (!reason.trim()) { - return deny("VALIDATION_FAILED", "请填写回应理由") + return deny(400, "请填写回应理由") } set((state) => ({ @@ -306,24 +306,24 @@ export const useDisputeStore = create((set, get) => { submitAppeal: (disputeId, actorId, reason) => { const dispute = get().disputes.find((item) => item.id === disputeId) if (!dispute) { - return deny("NOT_FOUND", "争议不存在") + return deny(404, "争议不存在") } const actor = resolveParticipantActor(dispute.orderId, actorId) if (!actor) { - return deny("NOT_PARTICIPANT", "仅订单参与方可提交申诉") + return deny(403, "仅订单参与方可提交申诉") } if (dispute.status !== "resolved") { - return deny("INVALID_STATUS", "当前状态不可申诉") + return deny(400, "当前状态不可申诉") } if (dispute.appealedAt) { - return deny("ALREADY_DONE", "该争议已申诉过") + return deny(400, "该争议已申诉过") } if (!reason.trim()) { - return deny("VALIDATION_FAILED", "请填写申诉理由") + return deny(400, "请填写申诉理由") } set((state) => ({ diff --git a/store/orders.ts b/store/orders.ts index 08b9e0a..39e0ebc 100644 --- a/store/orders.ts +++ b/store/orders.ts @@ -1,14 +1,14 @@ +import type { Actor } from "@/lib/actor" import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS, ORDER_REVIEW_TIMEOUT_MS, } from "@/lib/config/demo-timers" +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 { mockOrders } from "@/lib/mock" -import type { Actor } from "@/lib/policy/actor" -import { allow, deny } from "@/lib/policy/assert" -import type { PolicyDecision } from "@/lib/policy/decision" import type { Order, OrderStatus, PlayerService } from "@/lib/types" import { useAuthStore } from "@/store/auth" import { useChatStore } from "@/store/chat" @@ -35,7 +35,7 @@ interface CreatePaidOrderInput extends CreateOrderInput { } interface OrderMutationResult { - decision: PolicyDecision + decision: ApiDecision order?: Order } @@ -73,41 +73,35 @@ 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 { +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("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } if (order.status === "disputed" && action !== "RESOLVE_DISPUTE") { - return deny("DISPUTE_LOCKED", "争议处理中,暂不可执行此操作") + return deny(400, "争议处理中,暂不可执行此操作") } if (action === "PAY" || action === "CANCEL_PRE_ACCEPT" || action === "CONFIRM_CLOSE") { - return order.consumerId === actor.userId - ? allow() - : deny("NOT_PARTICIPANT", "仅下单客户可执行该操作") + return order.consumerId === actor.userId ? allow() : deny(403, "仅下单客户可执行该操作") } if (action === "ACCEPT") { return order.playerId === actor.userId || isOrderOwnerActor(order, actor) ? allow() - : deny("NOT_PARTICIPANT", "仅该订单打手或所属店主可执行接单") + : deny(403, "仅该订单打手或所属店主可执行接单") } if (action === "REQUEST_CLOSE" || action === "OPEN_DISPUTE" || action === "SUBMIT_REVIEW") { - return isParticipant(order, actor.userId) - ? allow() - : deny("NOT_PARTICIPANT", "仅订单参与方可执行该操作") + return isParticipant(order, actor.userId) ? allow() : deny(403, "仅订单参与方可执行该操作") } if (action === "RESOLVE_DISPUTE") { - return isOrderOwnerActor(order, actor) - ? allow() - : deny("ROLE_FORBIDDEN", "仅订单所属店主可执行该操作") + return isOrderOwnerActor(order, actor) ? allow() : deny(403, "仅订单所属店主可执行该操作") } return allow() @@ -261,7 +255,7 @@ export const useOrderStore = create((set, get) => { actor?: Actor, ): OrderMutationResult => { const order = get().orders.find((item) => item.id === orderId) - if (!order) return { decision: deny("NOT_FOUND", "订单不存在") } + if (!order) return { decision: deny(404, "订单不存在") } const actorDecision = validateActorForAction(order, action, actor) if (!actorDecision.ok) return { decision: actorDecision } @@ -285,7 +279,7 @@ export const useOrderStore = create((set, get) => { })) if (!updatedOrder || !previousOrder) { - return { decision: deny("NOT_FOUND", "订单不存在") } + return { decision: deny(404, "订单不存在") } } if (previousOrder.status !== "completed" && updatedOrder.status === "completed") { @@ -339,19 +333,19 @@ export const useOrderStore = create((set, get) => { }, createPaidOrder: (input, actor) => { if (actor.role !== "consumer" || actor.userId !== input.consumerId) { - return { decision: deny("ROLE_FORBIDDEN", "仅客户可下单支付") } + return { decision: deny(403, "仅客户可下单支付") } } const dedupeKey = `${input.consumerId}-${input.service.id}` if (pendingCreations.has(dedupeKey)) { - return { decision: deny("DUPLICATE_REQUEST", "订单正在创建中,请勿重复提交") } + return { decision: deny(400, "订单正在创建中,请勿重复提交") } } 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", "余额不足或订单已支付") } + return { decision: deny(400, "余额不足或订单已支付") } } const order: Order = { @@ -380,9 +374,9 @@ export const useOrderStore = create((set, get) => { }, payOrder: (orderId, actor) => { const order = get().orders.find((item) => item.id === orderId) - if (!order) return { decision: deny("NOT_FOUND", "订单不存在") } + if (!order) return { decision: deny(404, "订单不存在") } const paid = useWalletStore.getState().deductBalance(orderId, order.totalPrice) - if (!paid) return { decision: deny("PAYMENT_FAILED", "余额不足或订单已支付") } + if (!paid) return { decision: deny(400, "余额不足或订单已支付") } return applyTransition(orderId, "PAY", actor) }, acceptOrder: (orderId, actor) => applyTransition(orderId, "ACCEPT", actor), diff --git a/store/reviews.ts b/store/reviews.ts index 5e926dc..ff6bf95 100644 --- a/store/reviews.ts +++ b/store/reviews.ts @@ -1,7 +1,7 @@ +import { allow, deny } from "@/lib/decision" +import type { ApiDecision } from "@/lib/errors" import { generateId } from "@/lib/id" import { mockReviews, mockUsers } from "@/lib/mock" -import { allow, deny } from "@/lib/policy/assert" -import type { PolicyDecision } from "@/lib/policy/decision" import type { Review } from "@/lib/types" import { useOrderStore } from "@/store/orders" import { create } from "zustand" @@ -15,7 +15,7 @@ interface SubmitReviewInput { interface ReviewState { reviews: Review[] - submitReview: (input: SubmitReviewInput) => PolicyDecision + submitReview: (input: SubmitReviewInput) => ApiDecision getReviewsByOrder: (orderId: string) => Review[] hasUserReviewed: (orderId: string, userId: string) => boolean } @@ -54,30 +54,30 @@ export const useReviewStore = create((set, get) => ({ get().reviews.some((review) => review.orderId === orderId && review.fromUserId === userId), submitReview: (input) => { if (!input.fromUserId) { - return deny("AUTH_REQUIRED", "请先登录") + return deny(401, "请先登录") } if (!Number.isFinite(input.rating) || input.rating < 1 || input.rating > 5) { - return deny("VALIDATION_FAILED", "评分范围应为 1-5") + return deny(400, "评分范围应为 1-5") } const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId) if (!order) { - return deny("NOT_FOUND", "订单不存在") + return deny(404, "订单不存在") } if (order.status !== "pending_review") { - return deny("INVALID_STATUS", "仅待评价订单可提交评价") + return deny(400, "仅待评价订单可提交评价") } const relation = resolveOrderUser(input.orderId, input.fromUserId) if (!relation) { - return deny("NOT_PARTICIPANT", "仅订单参与方可评价") + return deny(403, "仅订单参与方可评价") } const exists = get().hasUserReviewed(input.orderId, input.fromUserId) if (exists) { - return deny("ALREADY_DONE", "该订单已提交过评价") + return deny(400, "该订单已提交过评价") } const fromUser = resolveUser(input.fromUserId) diff --git a/tests/order-machine.test.ts b/tests/order-machine.test.ts index 0dcd263..4d4f3f6 100644 --- a/tests/order-machine.test.ts +++ b/tests/order-machine.test.ts @@ -1,5 +1,5 @@ +import type { Actor } from "@/lib/actor" import { evaluateOrderTransition } from "@/lib/domain/order-machine" -import type { Actor } from "@/lib/policy/actor" import type { UserRole } from "@/lib/types" import { describe, expect, it } from "vitest" @@ -32,7 +32,7 @@ describe("evaluateOrderTransition", () => { expect(result.decision).toMatchObject({ ok: false, - reasonCode: "INVALID_STATUS", + error: { code: 400 }, }) expect(result.nextStatus).toBeUndefined() }) @@ -46,7 +46,7 @@ describe("evaluateOrderTransition", () => { expect(result.decision).toMatchObject({ ok: false, - reasonCode: "ROLE_FORBIDDEN", + error: { code: 403 }, }) }) @@ -117,7 +117,7 @@ describe("evaluateOrderTransition", () => { expect(result.decision).toMatchObject({ ok: false, - reasonCode: "ROLE_FORBIDDEN", + error: { code: 403 }, }) }) }) diff --git a/tests/policy-decision.test.ts b/tests/policy-decision.test.ts index 63b8084..b8b82fd 100644 --- a/tests/policy-decision.test.ts +++ b/tests/policy-decision.test.ts @@ -1,4 +1,4 @@ -import { allow, deny, requireAuth } from "@/lib/policy/assert" +import { allow, deny, requireAuth } from "@/lib/decision" import { describe, expect, it } from "vitest" describe("policy decision helpers", () => { @@ -6,24 +6,22 @@ describe("policy decision helpers", () => { expect(allow()).toEqual({ ok: true }) }) - it("returns reason code for deny", () => { - expect(deny("ROLE_FORBIDDEN", "forbidden")).toEqual({ + it("returns error for deny", () => { + expect(deny(403, "forbidden")).toEqual({ ok: false, - reasonCode: "ROLE_FORBIDDEN", - message: "forbidden", + error: { code: 403, msg: "forbidden" }, }) }) it("requires auth actor", () => { expect(requireAuth(undefined)).toEqual({ ok: false, - reasonCode: "AUTH_REQUIRED", - message: "请先登录", + error: { code: 401, msg: "请先登录" }, }) expect( requireAuth({ - userId: "u1", + userId: "1001", role: "consumer", }), ).toEqual({ ok: true })