fix(dispute): enforce participant checks and phase constraints
This commit is contained in:
@@ -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
@@ -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)
|
||||||
set((state) => ({
|
if (!dispute) {
|
||||||
disputes: state.disputes.map((dispute) => {
|
return deny("NOT_FOUND", "争议不存在")
|
||||||
if (dispute.id !== disputeId || dispute.appealedAt || dispute.status !== "resolved") {
|
|
||||||
return dispute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
disputes: state.disputes.map((item) => {
|
||||||
|
if (item.id !== disputeId) return item
|
||||||
|
|
||||||
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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user