fix(dispute): enforce participant checks and phase constraints

This commit is contained in:
zetaloop
2026-02-22 15:21:32 +08:00
parent ca95165e1b
commit 1f2dc1434b
2 changed files with 164 additions and 25 deletions
+39 -6
View File
@@ -17,6 +17,7 @@ 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 { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
import { notifyInfo } from "@/lib/toast"
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"
@@ -108,13 +109,17 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
const handleSubmit = () => { const handleSubmit = () => {
if (!userId || !userName || !reason.trim()) return if (!userId || !userName || !reason.trim()) return
submitDispute({ const result = submitDispute({
orderId: id, orderId: id,
initiatorId: userId, initiatorId: userId,
initiatorName: userName, initiatorName: userName,
reason, reason,
evidence: files, evidence: files,
}) })
if (!result.decision.ok) {
notifyInfo(result.decision.message ?? "提交争议失败")
return
}
router.replace(`/dispute/${id}?submitted=1`) router.replace(`/dispute/${id}?submitted=1`)
} }
@@ -128,13 +133,26 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
) )
} }
const isParticipant = Boolean(
userId && (order.consumerId === userId || order.playerId === userId),
)
if (!isParticipant) {
return (
<div className="container mx-auto py-8 px-4 max-w-lg text-center text-muted-foreground">
访
</div>
)
}
if (existingDispute) { if (existingDispute) {
const isInitiator = userId === existingDispute.initiatorId const isInitiator = userId === existingDispute.initiatorId
const canRespond = const canRespond =
isParticipant &&
!isInitiator && !isInitiator &&
!existingDispute.respondentReason && !existingDispute.respondentReason &&
(existingDispute.status === "open" || existingDispute.status === "reviewing") (existingDispute.status === "open" || existingDispute.status === "reviewing")
const canAppeal = existingDispute.status === "resolved" && !existingDispute.appealedAt const canAppeal =
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
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">
@@ -267,9 +285,18 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
</div> </div>
)} )}
<Button <Button
onClick={() => onClick={() => {
submitResponse(existingDispute.id, responseReason, responseFiles) if (!userId) return
} const decision = submitResponse(
existingDispute.id,
userId,
responseReason,
responseFiles,
)
if (!decision.ok) {
notifyInfo(decision.message ?? "提交回应失败")
}
}}
disabled={!responseReason.trim()} disabled={!responseReason.trim()}
> >
@@ -308,7 +335,13 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
/> />
<Button <Button
variant="outline" variant="outline"
onClick={() => submitAppeal(existingDispute.id, appealReason)} onClick={() => {
if (!userId) return
const decision = submitAppeal(existingDispute.id, userId, appealReason)
if (!decision.ok) {
notifyInfo(decision.message ?? "提交申诉失败")
}
}}
disabled={!appealReason.trim()} disabled={!appealReason.trim()}
> >
+125 -19
View File
@@ -1,6 +1,9 @@
import { create } from "zustand" import { create } from "zustand"
import { DISPUTE_TO_RESOLVED_MS, DISPUTE_TO_REVIEWING_MS } from "@/lib/config/demo-timers" 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 { allow, deny } from "@/lib/policy/assert"
import type { Actor } from "@/lib/policy/actor"
import type { PolicyDecision } from "@/lib/policy/decision"
import { mockDisputes } from "@/lib/mock" import { mockDisputes } from "@/lib/mock"
import type { Dispute } from "@/lib/types" import type { Dispute } from "@/lib/types"
import { useOrderStore } from "@/store/orders" import { useOrderStore } from "@/store/orders"
@@ -30,12 +33,22 @@ interface SubmitDisputeInput {
evidence: string[] evidence: string[]
} }
interface DisputeMutationResult {
decision: PolicyDecision
dispute?: DisputeRecord
}
interface DisputeState { interface DisputeState {
disputes: DisputeRecord[] disputes: DisputeRecord[]
getDisputeByOrderId: (orderId: string) => DisputeRecord | undefined getDisputeByOrderId: (orderId: string) => DisputeRecord | undefined
submitDispute: (input: SubmitDisputeInput) => DisputeRecord submitDispute: (input: SubmitDisputeInput) => DisputeMutationResult
submitResponse: (disputeId: string, reason: string, evidence: string[]) => void submitResponse: (
submitAppeal: (disputeId: string, reason: string) => void disputeId: string,
actorId: string,
reason: string,
evidence: string[],
) => PolicyDecision
submitAppeal: (disputeId: string, actorId: string, reason: string) => PolicyDecision
} }
const progressTimers = new Map<string, ReturnType<typeof setTimeout>[]>() const progressTimers = new Map<string, ReturnType<typeof setTimeout>[]>()
@@ -49,6 +62,29 @@ function clearProgressTimers(disputeId: string) {
progressTimers.delete(disputeId) progressTimers.delete(disputeId)
} }
function resolveParticipantActor(orderId: string, userId: string): Actor | null {
const order = useOrderStore.getState().orders.find((item) => item.id === orderId)
if (!order) return null
if (order.consumerId === userId) {
return {
userId,
role: "consumer",
shopId: order.shopId,
}
}
if (order.playerId === userId) {
return {
userId,
role: "player",
shopId: order.shopId,
}
}
return null
}
function asRecord(dispute: Dispute): DisputeRecord { function asRecord(dispute: Dispute): DisputeRecord {
const timeline: DisputeTimelineItem[] = [ const timeline: DisputeTimelineItem[] = [
{ {
@@ -143,6 +179,29 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
disputes: mockDisputes.map(asRecord), disputes: mockDisputes.map(asRecord),
getDisputeByOrderId: (orderId) => get().disputes.find((dispute) => dispute.orderId === orderId), getDisputeByOrderId: (orderId) => get().disputes.find((dispute) => dispute.orderId === orderId),
submitDispute: (input) => { submitDispute: (input) => {
const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId)
if (!order) {
return { decision: deny("NOT_FOUND", "订单不存在") }
}
if (order.status !== "in_progress" && order.status !== "pending_close") {
return { decision: deny("INVALID_STATUS", "当前阶段不可发起争议") }
}
if (!input.reason.trim()) {
return { decision: deny("VALIDATION_FAILED", "请填写争议原因") }
}
const actor = resolveParticipantActor(input.orderId, input.initiatorId)
if (!actor) {
return { decision: deny("NOT_PARTICIPANT", "仅订单参与方可发起争议") }
}
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"),
@@ -165,22 +224,46 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
} }
set((state) => ({ disputes: [dispute, ...state.disputes] })) set((state) => ({ disputes: [dispute, ...state.disputes] }))
useOrderStore.getState().updateOrderStatus(input.orderId, "disputed")
scheduleProgress(dispute.id) scheduleProgress(dispute.id)
return dispute return { decision: allow(), dispute }
}, },
submitResponse: (disputeId, reason, evidence) => { submitResponse: (disputeId, actorId, reason, evidence) => {
if (!reason.trim()) return const dispute = get().disputes.find((item) => item.id === disputeId)
if (!dispute) {
return deny("NOT_FOUND", "争议不存在")
}
const actor = resolveParticipantActor(dispute.orderId, actorId)
if (!actor) {
return deny("NOT_PARTICIPANT", "仅订单参与方可提交回应")
}
if (actorId === dispute.initiatorId) {
return deny("ROLE_FORBIDDEN", "争议发起方不可提交回应")
}
if (dispute.respondentReason) {
return deny("ALREADY_DONE", "回应已提交")
}
if (dispute.status !== "open" && dispute.status !== "reviewing") {
return deny("INVALID_STATUS", "当前状态不可提交回应")
}
if (!reason.trim()) {
return deny("VALIDATION_FAILED", "请填写回应理由")
}
set((state) => ({ set((state) => ({
disputes: state.disputes.map((dispute) => { disputes: state.disputes.map((item) => {
if (dispute.id !== disputeId || dispute.respondentReason) return dispute if (item.id !== disputeId) return item
return { return {
...dispute, ...item,
respondentReason: reason.trim(), respondentReason: reason.trim(),
respondentEvidence: evidence, respondentEvidence: evidence,
timeline: [ timeline: [
...dispute.timeline, ...item.timeline,
{ {
id: generateId("timeline"), id: generateId("timeline"),
type: "response", type: "response",
@@ -191,22 +274,43 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
} }
}), }),
})) }))
return allow()
}, },
submitAppeal: (disputeId, reason) => { submitAppeal: (disputeId, actorId, reason) => {
if (!reason.trim()) return const dispute = get().disputes.find((item) => item.id === disputeId)
if (!dispute) {
return deny("NOT_FOUND", "争议不存在")
}
const actor = resolveParticipantActor(dispute.orderId, actorId)
if (!actor) {
return deny("NOT_PARTICIPANT", "仅订单参与方可提交申诉")
}
if (dispute.status !== "resolved") {
return deny("INVALID_STATUS", "当前状态不可申诉")
}
if (dispute.appealedAt) {
return deny("ALREADY_DONE", "该争议已申诉过")
}
if (!reason.trim()) {
return deny("VALIDATION_FAILED", "请填写申诉理由")
}
set((state) => ({ set((state) => ({
disputes: state.disputes.map((dispute) => { disputes: state.disputes.map((item) => {
if (dispute.id !== disputeId || dispute.appealedAt || dispute.status !== "resolved") { if (item.id !== disputeId) return item
return dispute
}
return { return {
...dispute, ...item,
status: "appealed", status: "appealed",
appealReason: reason.trim(), appealReason: reason.trim(),
appealedAt: new Date().toISOString(), appealedAt: new Date().toISOString(),
timeline: [ timeline: [
...dispute.timeline, ...item.timeline,
{ {
id: generateId("timeline"), id: generateId("timeline"),
type: "appealed", type: "appealed",
@@ -217,7 +321,9 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
} }
}), }),
})) }))
scheduleProgress(disputeId) scheduleProgress(disputeId)
return allow()
}, },
} }
}) })