feat(disputes): migrate disputes and reviews to backend API

This commit is contained in:
zetaloop
2026-03-01 16:25:33 +08:00
parent 9739c94bdc
commit f189ec9846
7 changed files with 437 additions and 120 deletions
+173 -34
View File
@@ -1,50 +1,189 @@
import { deny } from "@/lib/decision"
import { useAuthStore } from "@/store/auth"
import { useDisputeStore } from "@/store/disputes"
import { allow, deny } from "@/lib/decision"
import { isApiError, toApiError, type ApiDecision } from "@/lib/errors"
import type { Dispute } from "@/lib/types"
export function listDisputes() {
return useDisputeStore.getState().disputes
import { httpJson } from "./http"
export type DisputeTimelineItem = {
id: string
content: string
createdAt: string
}
export function getDisputeByOrderId(orderId: string) {
return useDisputeStore.getState().disputes.find((dispute) => dispute.orderId === orderId)
export type DisputeRecord = Dispute & {
respondentReason?: string
respondentEvidence: string[]
appealReason?: string
appealedAt?: string
timeline: DisputeTimelineItem[]
}
export function submitDispute(input: { orderId: string; reason: string; evidence: string[] }) {
const user = useAuthStore.getState().user
if (!user?.id || !user.nickname) {
return { decision: deny(401, "请先登录") }
export type ListDisputesOptions = {
offset?: number
limit?: number
}
type Paginated<T> = {
items: T[]
meta?: {
total: number
offset: number
limit: number
}
return useDisputeStore.getState().submitDispute({
orderId: input.orderId,
initiatorId: user.id,
initiatorName: user.nickname,
reason: input.reason,
evidence: input.evidence,
})
}
export function submitDisputeResponse(input: {
function withOffsetLimit(path: string, options?: ListDisputesOptions): string {
const offset = options?.offset ?? 0
const limit = options?.limit ?? 1000
const searchParams = new URLSearchParams({
offset: String(offset),
limit: String(limit),
})
return `${path}?${searchParams.toString()}`
}
function unwrapItems<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[]
if (typeof value !== "object" || value === null) throw new Error("Invalid response")
if ("items" in value) {
const envelope = value as { items?: unknown }
if (Array.isArray(envelope.items)) return envelope.items as T[]
}
throw new Error("Invalid response")
}
function unwrapDispute(value: unknown): unknown {
if (typeof value !== "object" || value === null) return value
if ("dispute" in value) {
const envelope = value as { dispute?: unknown }
return envelope.dispute
}
return value
}
function deriveMinimalTimeline(dispute: {
id: string
status: string
createdAt: string
timeline?: DisputeTimelineItem[]
}): DisputeTimelineItem[] {
const existing = dispute.timeline
if (existing?.length) return existing
const steps = [
{ status: "open", content: "争议已提交" },
{ status: "reviewing", content: "平台审核中" },
{ status: "resolved", content: "争议已解决" },
{ status: "appealed", content: "已发起申诉" },
]
const currentIndex = steps.findIndex((step) => step.status === dispute.status)
const lastIndex = currentIndex >= 0 ? currentIndex : 0
return steps.slice(0, lastIndex + 1).map((step) => ({
id: `${dispute.id}-${step.status}`,
content: step.content,
createdAt: dispute.createdAt,
}))
}
function normalizeDisputeRecord(value: unknown): DisputeRecord {
const dispute = unwrapDispute(value) as DisputeRecord
const respondentEvidence = Array.isArray(dispute.respondentEvidence)
? dispute.respondentEvidence
: []
const evidence = Array.isArray(dispute.evidence) ? dispute.evidence : []
const timeline = deriveMinimalTimeline({
id: dispute.id,
status: dispute.status,
createdAt: dispute.createdAt,
timeline: dispute.timeline,
})
return {
...dispute,
evidence,
respondentEvidence,
timeline,
}
}
function denyFromError(error: unknown): ApiDecision {
if (error instanceof Error && error.message === "UNAUTHORIZED") {
return deny(401, "请先登录")
}
const apiError = toApiError(error)
return deny(apiError.code, apiError.msg)
}
export async function listDisputes(options?: ListDisputesOptions): Promise<DisputeRecord[]> {
const res = await httpJson<Paginated<DisputeRecord> | DisputeRecord[]>(
withOffsetLimit("/api/v1/disputes", options),
{ cache: "no-store" },
)
return unwrapItems<DisputeRecord>(res).map((item) => normalizeDisputeRecord(item))
}
export async function getDisputeByOrderId(orderId: string): Promise<DisputeRecord | undefined> {
try {
const res = await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(orderId)}/dispute`, {
cache: "no-store",
})
return normalizeDisputeRecord(res)
} catch (error) {
const apiError = isApiError(error) ? error : toApiError(error)
if (apiError.code === 404) return undefined
throw error
}
}
export async function submitDispute(input: {
orderId: string
reason: string
evidence: string[]
}): Promise<{ decision: ApiDecision }> {
try {
await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(input.orderId)}/dispute`, {
method: "POST",
cache: "no-store",
json: { reason: input.reason, evidence: input.evidence },
})
return { decision: allow() }
} catch (error) {
return { decision: denyFromError(error) }
}
}
export async function submitDisputeResponse(input: {
disputeId: string
reason: string
evidence: string[]
}) {
const userId = useAuthStore.getState().user?.id
if (!userId) {
return deny(401, "请先登录")
}): Promise<ApiDecision> {
try {
await httpJson<unknown>(`/api/v1/disputes/${encodeURIComponent(input.disputeId)}/response`, {
method: "POST",
cache: "no-store",
json: { reason: input.reason, evidence: input.evidence },
})
return allow()
} catch (error) {
return denyFromError(error)
}
return useDisputeStore
.getState()
.submitResponse(input.disputeId, userId, input.reason, input.evidence)
}
export function submitDisputeAppeal(input: { disputeId: string; reason: string }) {
const userId = useAuthStore.getState().user?.id
if (!userId) {
return deny(401, "请先登录")
export async function submitDisputeAppeal(input: {
disputeId: string
reason: string
}): Promise<ApiDecision> {
try {
await httpJson<unknown>(`/api/v1/disputes/${encodeURIComponent(input.disputeId)}/appeal`, {
method: "POST",
cache: "no-store",
json: { reason: input.reason },
})
return allow()
} catch (error) {
return denyFromError(error)
}
return useDisputeStore.getState().submitAppeal(input.disputeId, userId, input.reason)
}
+85 -25
View File
@@ -1,29 +1,89 @@
import { deny } from "@/lib/decision"
import { useAuthStore } from "@/store/auth"
import { useReviewStore } from "@/store/reviews"
import { allow, deny } from "@/lib/decision"
import { toApiError, type ApiDecision } from "@/lib/errors"
import type { Review } from "@/lib/types"
export function listReviews() {
return useReviewStore.getState().reviews
}
import { httpJson } from "./http"
export function listReviewsByOrder(orderId: string) {
return useReviewStore.getState().reviews.filter((review) => review.orderId === orderId)
}
export function listReviewsByTargetUser(userId: string) {
return useReviewStore.getState().reviews.filter((review) => review.toUserId === userId)
}
export function submitReview(input: { orderId: string; rating: number; content?: string }) {
const userId = useAuthStore.getState().user?.id
if (!userId) {
return deny(401, "请先登录")
type Paginated<T> = {
items: T[]
meta: {
total: number
offset: number
limit: number
}
}
export type ListReviewsOptions = {
offset?: number
limit?: number
}
function withOffsetLimit(path: string, options?: ListReviewsOptions): string {
const offset = options?.offset ?? 0
const limit = options?.limit ?? 1000
const searchParams = new URLSearchParams({
offset: String(offset),
limit: String(limit),
})
return `${path}?${searchParams.toString()}`
}
function unwrapItems<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[]
if (typeof value !== "object" || value === null) {
throw new Error("Invalid response")
}
if ("items" in value) {
const envelope = value as { items?: unknown }
if (Array.isArray(envelope.items)) return envelope.items as T[]
}
throw new Error("Invalid response")
}
export async function listReviews(options?: ListReviewsOptions): Promise<Review[]> {
const res = await httpJson<Paginated<Review> | Review[]>(
withOffsetLimit("/api/v1/reviews", options),
{
cache: "no-store",
},
)
return unwrapItems<Review>(res)
}
export async function listReviewsByOrder(orderId: string): Promise<Review[]> {
const res = await httpJson<Paginated<Review> | Review[]>(
`/api/v1/orders/${encodeURIComponent(orderId)}/reviews`,
{ cache: "no-store" },
)
return unwrapItems<Review>(res)
}
export async function listReviewsByTargetUser(userId: string): Promise<Review[]> {
const res = await httpJson<Paginated<Review> | Review[]>(
`/api/v1/users/${encodeURIComponent(userId)}/reviews`,
{ cache: "no-store" },
)
return unwrapItems<Review>(res)
}
export async function submitReview(input: {
orderId: string
rating: number
content?: string
}): Promise<ApiDecision> {
try {
await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(input.orderId)}/review`, {
method: "POST",
cache: "no-store",
json: { rating: input.rating, content: input.content },
})
return allow()
} catch (error) {
if (error instanceof Error && error.message === "UNAUTHORIZED") {
return deny(401, "请先登录")
}
const apiError = toApiError(error)
return deny(apiError.code, apiError.msg)
}
return useReviewStore.getState().submitReview({
orderId: input.orderId,
fromUserId: userId,
rating: input.rating,
content: input.content,
})
}