Compare commits

..

10 Commits

Author SHA1 Message Date
zetaloop 7b191c5d6e docs: 清理早期 mock 审计文档并对齐 README 与 PLAN 2026-05-03 18:58:42 +08:00
zetaloop 4f878340e6 feat: 支持生产容器化部署 2026-05-03 09:21:16 +08:00
zetaloop 48effb4eeb chore: bump dependencies 2026-05-03 07:58:04 +08:00
zetaloop a0b61fbc44 refactor(dashboard): fetch current player via /players/me 2026-05-03 07:58:04 +08:00
zetaloop 38ff65d51f refactor: drop Number() coercion on snowflake ids 2026-05-03 07:58:04 +08:00
zetaloop 88eb9727b5 refactor(shop): aggregate services from embedded player profiles 2026-05-03 06:02:05 +08:00
zetaloop 7acde68d45 refactor(disputes): align type with backend and derive timeline in page 2026-05-03 06:02:05 +08:00
zetaloop be329865b3 refactor: drop unused local search catalog 2026-05-03 06:02:05 +08:00
zetaloop 0e7270aa8d refactor: align client state and ui with backend contract 2026-05-03 06:02:05 +08:00
zetaloop a3f0b49112 feat(chat): implement WebSocket chat client with useChatSocket hook
Replace the stubbed chat API with a real WebSocket hook that
connects to /ws/chat and supports DM creation, messaging,
session join/leave, and history retrieval. Keep REST stub
functions for backward compatibility with existing pages
that require listChatSessions/getChatSessionById imports.
2026-05-01 17:33:29 +08:00
29 changed files with 661 additions and 2380 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitignore
.next
node_modules
.env*
!.env.example
.vscode
.DS_Store
README*
*.md
.serena
.sisyphus
.claude
.idea
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
FROM node:25-alpine AS base
RUN npm install -g --force corepack@latest && corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod=false
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=::
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+2
View File
@@ -1,5 +1,7 @@
# 聚玩 — 产品设计计划 # 聚玩 — 产品设计计划
> 本文描述产品设计意图,**不代表已实现的功能**。资金托管/释放、争议仲裁、自动派单、浏览器推送等条目目前仍属于规划方向,前端可能有占位 UI 但后端尚未提供完整实现。
## 一、用户系统 ## 一、用户系统
一个账号,三种身份:消费者、打手、店主。每个身份有独立的主页,身份切换是全局的(切换后整个应用的视角和导航都随之改变)。 一个账号,三种身份:消费者、打手、店主。每个身份有独立的主页,身份切换是全局的(切换后整个应用的视角和导航都随之改变)。
+4 -14
View File
@@ -16,8 +16,8 @@
### 前置要求 ### 前置要求
- Node.js 20+ - Node.js 25+
- pnpm 9+ - pnpm 10+
### 安装依赖 ### 安装依赖
@@ -140,19 +140,9 @@ async rewrites() {
## 部署 ## 部署
### 构建 Docker 镜像 本仓库作为后端仓库(`juwan-backend`)的子模块接入 dev compose,通过其中的 Envoy 网关同源对外提供。
```bash `Dockerfile` 使用 Next.js standalone 输出。后端 `deploy/dev/build.py` 会扫描出 `frontend` target 一并构建为 `juwan/frontend:dev`,随 compose 拉起,浏览器从 `http://localhost:18080` 访问。
docker build -t juwan-frontend:latest .
docker tag juwan-frontend:latest 103.236.53.208:4418/library/juwan-frontend:latest
docker push 103.236.53.208:4418/library/juwan-frontend:latest
```
### 部署到 Kubernetes
```bash
kubectl apply -f deploy/k8s/frontend.yaml
```
## 开发规范 ## 开发规范
+38 -13
View File
@@ -5,9 +5,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state" import { EmptyState } from "@/components/ui/empty-state"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge" import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge"
import { listOrders, listPlayers, listServices, listShops } from "@/lib/api" import { getMyPlayer, getMyShop, getShopIncomeStats, listOrders } from "@/lib/api"
import { statusLabels } from "@/lib/constants" import { statusLabels } from "@/lib/constants"
import type { Player, PlayerService, Shop } from "@/lib/types" import type { Player, Shop } from "@/lib/types"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { CheckCircle, Clock, DollarSign, ListOrdered, Star, TrendingUp, Users } from "lucide-react" import { CheckCircle, Clock, DollarSign, ListOrdered, Star, TrendingUp, Users } from "lucide-react"
import Link from "next/link" import Link from "next/link"
@@ -38,25 +38,29 @@ export default function DashboardPage() {
const [player, setPlayer] = useState<Player | null>(null) const [player, setPlayer] = useState<Player | null>(null)
const [shop, setShop] = useState<Shop | null>(null) const [shop, setShop] = useState<Shop | null>(null)
const [services, setServices] = useState<PlayerService[]>([])
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([]) const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([])
const [monthlyIncome, setMonthlyIncome] = useState<string>("0")
const recentOrders = orders.slice(0, 3) const recentOrders = orders.slice(0, 3)
useEffect(() => { useEffect(() => {
if (!user?.id) {
setPlayer(null)
setShop(null)
return
}
let cancelled = false let cancelled = false
Promise.all([listPlayers(), listShops(), listServices()]) Promise.all([getMyPlayer(), getMyShop()])
.then(([players, shops, services]) => { .then(([player, shop]) => {
if (cancelled) return if (cancelled) return
setPlayer(players.find((item) => item.user.id === user?.id) ?? null) setPlayer(player ?? null)
setShop(shops.find((item) => item.owner.id === user?.id) ?? null) setShop(shop ?? null)
setServices(services)
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setPlayer(null) setPlayer(null)
setShop(null) setShop(null)
setServices([])
}) })
return () => { return () => {
@@ -88,13 +92,34 @@ export default function DashboardPage() {
} }
}, [orderRole]) }, [orderRole])
useEffect(() => {
if (!shop) {
setMonthlyIncome("0")
return
}
let cancelled = false
getShopIncomeStats(shop.id)
.then((stats) => {
if (cancelled) return
setMonthlyIncome(stats.monthlyIncome)
})
.catch(() => {
if (cancelled) return
setMonthlyIncome("0")
})
return () => {
cancelled = true
}
}, [shop])
const totalOrders = isOwner ? (shop?.totalOrders ?? 0) : (player?.totalOrders ?? 0) const totalOrders = isOwner ? (shop?.totalOrders ?? 0) : (player?.totalOrders ?? 0)
const rating = isOwner ? (shop?.rating ?? 0) : (player?.rating ?? 0) const rating = isOwner ? (shop?.rating ?? 0) : (player?.rating ?? 0)
const playerCount = shop?.playerCount ?? 0 const playerCount = shop?.playerCount ?? 0
const completionRate = player?.completionRate ?? 0 const completionRate = player?.completionRate ?? 0
const serviceCount = player const serviceCount = player?.services.length ?? 0
? services.filter((service) => String(service.playerId) === String(player.id)).length
: 0
return ( return (
<div className="container mx-auto max-w-6xl px-4 py-8 space-y-8"> <div className="container mx-auto max-w-6xl px-4 py-8 space-y-8">
@@ -149,7 +174,7 @@ export default function DashboardPage() {
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{isOwner ? "——" : serviceCount}</div> <div className="text-2xl font-bold">{isOwner ? `¥${monthlyIncome}` : serviceCount}</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+3 -4
View File
@@ -5,7 +5,7 @@ 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 { EmptyState } from "@/components/ui/empty-state" import { EmptyState } from "@/components/ui/empty-state"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { getShopById, listPlayersByShop, listReviews, listServices } from "@/lib/api" import { getShopById, listPlayersByShop, listReviews } from "@/lib/api"
import { getShopSections } from "@/lib/domain/shop-template" import { getShopSections } from "@/lib/domain/shop-template"
import { Gamepad2, Megaphone, ShoppingBag, Star, Users } from "lucide-react" import { Gamepad2, Megaphone, ShoppingBag, Star, Users } from "lucide-react"
import Image from "next/image" import Image from "next/image"
@@ -24,9 +24,8 @@ export default async function ShopPage({ params }: PageProps) {
notFound() notFound()
} }
const [shopPlayers, allServices] = await Promise.all([listPlayersByShop(shop.id), listServices()]) const shopPlayers = await listPlayersByShop(shop.id)
const playerIds = new Set(shopPlayers.map((player) => String(player.id))) const shopServices = shopPlayers.flatMap((player) => player.services)
const shopServices = allServices.filter((service) => playerIds.has(String(service.playerId)))
const shopReviews = await listReviews() const shopReviews = await listReviews()
const sortedSections = getShopSections(shop).filter((s) => s.enabled) const sortedSections = getShopSections(shop).filter((s) => s.enabled)
+70 -118
View File
@@ -1,14 +1,12 @@
"use client" "use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state" import { EmptyState } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { getChatSessionById, listChatMessages } from "@/lib/api" import { uploadFile } from "@/lib/api"
import { sendImageMessage, sendTextMessage } from "@/lib/api/chat" import { useChatSocket } from "@/lib/hooks/use-chat-socket"
import { notifyInfo } from "@/lib/toast" import { notifyInfo } from "@/lib/toast"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
@@ -18,61 +16,46 @@ import Link from "next/link"
import { use, useEffect, useRef, useState } from "react" import { use, useEffect, useRef, useState } from "react"
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) const { id: targetUserId } = use(params)
const [loading, setLoading] = useState(true)
const [session, setSession] = useState<
Awaited<ReturnType<typeof getChatSessionById>> | undefined
>(undefined)
const [messages, setMessages] = useState<Awaited<ReturnType<typeof listChatMessages>>>([])
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [uploading, setUploading] = useState(false)
const imageInputRef = useRef<HTMLInputElement>(null) const imageInputRef = useRef<HTMLInputElement>(null)
const { user } = useAuthStore() const { user } = useAuthStore()
const {
connected,
sessionId,
messages,
error,
createDM,
joinSession,
leaveSession,
sendTextMessage,
sendImageMessage,
} = useChatSocket()
const cacheKey = user?.id ? `chat:dm:${user.id}:${targetUserId}` : null
useEffect(() => { useEffect(() => {
let cancelled = false if (!connected || !cacheKey) return
void Promise.all([ const cached = window.localStorage.getItem(cacheKey)
Promise.resolve(getChatSessionById(id)), if (cached) {
Promise.resolve(listChatMessages(id)), joinSession(cached)
]) } else {
.then(([nextSession, nextMessages]) => { createDM(targetUserId)
if (cancelled) return
setSession(nextSession)
setMessages(nextMessages)
})
.catch(() => {
if (cancelled) return
setSession(undefined)
setMessages([])
})
.finally(() => {
if (cancelled) return
setLoading(false)
})
return () => {
cancelled = true
} }
}, [id]) }, [connected, cacheKey, createDM, joinSession, targetUserId])
if (loading) { useEffect(() => {
return ( if (!sessionId || !cacheKey) return
<div className="container mx-auto max-w-2xl px-4 py-8"> window.localStorage.setItem(cacheKey, sessionId)
<EmptyState title="加载中" description="正在读取会话内容..." icon={MessageSquare} /> }, [sessionId, cacheKey])
</div>
)
}
if (!session) { useEffect(
return ( () => () => {
<div className="container mx-auto max-w-2xl px-4 py-8"> if (sessionId) leaveSession(sessionId)
<EmptyState },
title="会话不存在" [leaveSession, sessionId],
description="该会话可能已被删除或暂不可访问。" )
icon={MessageSquare}
/>
</div>
)
}
if (!user?.id) { if (!user?.id) {
return ( return (
@@ -86,68 +69,47 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
) )
} }
const userId = user.id
const isParticipant = session.participants.some((participant) => participant.id === userId)
if (!isParticipant) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState
title="无法查看会话"
description="仅会话参与方可查看并发送消息。"
icon={MessageSquare}
/>
</div>
)
}
const other = session.participants.find((p) => p.id !== userId) ?? session.participants[0]
return ( return (
<div className="container mx-auto max-w-2xl px-4 py-8 h-[calc(100vh-3.5rem)] flex flex-col"> <div className="container mx-auto flex h-[calc(100vh-3.5rem)] max-w-2xl flex-col px-4 py-8">
<Card className="flex-1 flex flex-col overflow-hidden border-border/80 shadow-sm"> <Card className="flex flex-1 flex-col overflow-hidden border-border/80 shadow-sm">
<div className="border-b border-border/60 px-4 py-3 flex items-center gap-3 bg-background"> <div className="flex items-center gap-3 border-b border-border/60 bg-background px-4 py-3">
<Link href="/chat" className="text-muted-foreground hover:text-foreground"> <Link href="/chat" className="text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Link> </Link>
<Avatar className="h-8 w-8"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<AvatarImage src={other.avatar} /> <MessageSquare className="h-4 w-4" />
<AvatarFallback>{other.nickname[0]}</AvatarFallback> </div>
</Avatar>
<div> <div>
<span className="text-sm font-medium">{other.nickname}</span> <span className="text-sm font-medium"> {targetUserId}</span>
<div className="flex items-center gap-1"> <p className="text-xs text-muted-foreground">
<Badge {connected ? (sessionId ? "已连接" : "正在创建会话") : "连接中"}
variant={session.type === "order" ? "info" : "neutral"} </p>
className="text-[10px] px-1.5 py-0 font-normal"
>
{session.type === "order" ? "订单会话" : "咨询会话"}
</Badge>
</div>
</div> </div>
</div> </div>
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 p-4">
<div className="space-y-4"> <div className="space-y-4">
{error && <p className="text-center text-xs text-destructive">{error}</p>}
{messages.length === 0 && (
<EmptyState
title="暂无消息"
description="发送第一条消息开始沟通。"
icon={MessageSquare}
/>
)}
{messages.map((msg) => { {messages.map((msg) => {
if (msg.type === "system") { if (msg.type === "system") {
return ( return (
<div key={msg.id} className="text-center"> <div key={msg.id} className="text-center">
<span className="text-xs text-muted-foreground bg-muted/60 px-2 py-1 rounded-full"> <span className="rounded-full bg-muted/60 px-2 py-1 text-xs text-muted-foreground">
{msg.content} {msg.content}
</span> </span>
</div> </div>
) )
} }
const isMine = msg.senderId === userId const isMine = msg.senderId === user.id
const sender = session.participants.find(
(participant) => participant.id === msg.senderId,
)
return ( return (
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}> <div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={sender?.avatar} />
<AvatarFallback>{(sender?.nickname ?? "?")[0]}</AvatarFallback>
</Avatar>
<div className={cn("max-w-[70%]", isMine && "text-right")}> <div className={cn("max-w-[70%]", isMine && "text-right")}>
{msg.type === "image" ? ( {msg.type === "image" ? (
<Image <Image
@@ -156,7 +118,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
width={256} width={256}
height={192} height={192}
unoptimized unoptimized
className="inline-block rounded-lg max-h-48 max-w-64 border" className="inline-block max-h-48 max-w-64 rounded-lg border"
/> />
) : ( ) : (
<p <p
@@ -170,7 +132,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
{msg.content} {msg.content}
</p> </p>
)} )}
<p className="text-[10px] text-muted-foreground mt-1"> <p className="mt-1 text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", { {new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@@ -183,7 +145,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
</div> </div>
</ScrollArea> </ScrollArea>
<div className="border-t border-border/60 p-4 bg-background"> <div className="border-t border-border/60 bg-background p-4">
<input <input
ref={imageInputRef} ref={imageInputRef}
type="file" type="file"
@@ -193,41 +155,30 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
const target = event.currentTarget const target = event.currentTarget
const file = target.files?.[0] const file = target.files?.[0]
if (!file) return if (!file) return
const imageUrl = URL.createObjectURL(file)
void Promise.resolve(sendImageMessage(session.id, imageUrl)) setUploading(true)
.then((result) => { void uploadFile(file, "chat")
if (!result.ok) { .then((imageUrl) => sendImageMessage(imageUrl))
notifyInfo(result.error.msg) .catch(() => notifyInfo("图片发送失败"))
return
}
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
})
.finally(() => { .finally(() => {
setUploading(false)
target.value = "" target.value = ""
}) })
}} }}
/> />
<form <form
className="flex gap-2" className="flex gap-2"
onSubmit={(e) => { onSubmit={(event) => {
e.preventDefault() event.preventDefault()
const text = input.trim() const text = input.trim()
if (!text) return if (!text || !sessionId) return
sendTextMessage(text)
void Promise.resolve(sendTextMessage(session.id, text)).then((result) => { setInput("")
if (!result.ok) {
notifyInfo(result.error.msg)
return
}
setInput("")
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
})
}} }}
> >
<Input <Input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(event) => setInput(event.target.value)}
placeholder="输入消息..." placeholder="输入消息..."
className="flex-1 border-border/60" className="flex-1 border-border/60"
/> />
@@ -235,11 +186,12 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
type="button" type="button"
size="icon" size="icon"
variant="outline" variant="outline"
disabled={!sessionId || uploading}
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
> >
<ImagePlus className="h-4 w-4" /> <ImagePlus className="h-4 w-4" />
</Button> </Button>
<Button type="submit" size="icon" disabled={!input.trim()}> <Button type="submit" size="icon" disabled={!sessionId || !input.trim()}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
</form> </form>
+79 -49
View File
@@ -1,84 +1,114 @@
"use client" "use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { EmptyState } from "@/components/ui/empty-state" import { EmptyState } from "@/components/ui/empty-state"
import { listChatSessions } from "@/lib/api" import { getPlayerById, listOrders } from "@/lib/api"
import { isActiveOrder } from "@/lib/domain/order-filters"
import type { UserRole } from "@/lib/types"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { MessageSquare } from "lucide-react" import { MessageSquare } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
type ChatEntry = {
orderId: string
targetUserId: string
title: string
description: string
}
function orderRole(role: UserRole): "consumer" | "player" | undefined {
if (role === "consumer" || role === "player") return role
return undefined
}
export default function ChatListPage() { export default function ChatListPage() {
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([]) const currentRole = useAuthStore((state) => state.currentRole)
const userId = useAuthStore((state) => state.user?.id) const role = orderRole(currentRole)
const [entries, setEntries] = useState<ChatEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
void (async () => { void (async () => {
if (!role) {
setEntries([])
setLoading(false)
return
}
setLoading(true)
try { try {
const result = await Promise.resolve(listChatSessions()) const orders = (await listOrders({ role })).filter((order) => isActiveOrder(order.status))
if (!cancelled) setSessions(result) const nextEntries = await Promise.all(
orders.map(async (order) => {
if (role === "consumer") {
const player = await getPlayerById(String(order.playerId))
if (!player) return null
return {
orderId: String(order.id),
targetUserId: player.user.id,
title: player.user.nickname,
description: order.service.title,
}
}
return {
orderId: String(order.id),
targetUserId: String(order.consumerId),
title: `客户 ${order.consumerId}`,
description: order.service.title,
}
}),
)
if (!cancelled)
setEntries(nextEntries.filter((entry): entry is ChatEntry => entry !== null))
} catch { } catch {
if (!cancelled) setSessions([]) if (!cancelled) setEntries([])
} finally {
if (!cancelled) setLoading(false)
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [role])
return ( return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6"> <div className="container mx-auto max-w-2xl space-y-6 px-4 py-8">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
{sessions.length > 0 ? ( {entries.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm"> <div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm">
{sessions.map((session) => { {entries.map((entry) => (
const other = <Link
session.participants.find((participant) => participant.id !== userId) ?? key={entry.orderId}
session.participants[0] href={`/chat/${entry.targetUserId}?orderId=${entry.orderId}`}
return ( className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10"
<Link >
key={session.id} <div className="flex items-center gap-3 p-4">
href={`/chat/${session.id}`} <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10" <MessageSquare className="h-4 w-4" />
>
<div className="flex items-center gap-3 p-4">
<Avatar className="h-10 w-10">
<AvatarImage src={other.avatar} />
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{other.nickname}</span>
<Badge
variant={session.type === "order" ? "info" : "neutral"}
className="text-[10px] px-1.5 py-0 font-normal"
>
{session.type === "order" ? "订单" : "咨询"}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">{session.lastMessage}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{session.unreadCount > 0 && (
<Badge className="h-4 min-w-4 px-1 flex items-center justify-center rounded-full text-[10px]">
{session.unreadCount}
</Badge>
)}
</div>
</div> </div>
</Link> <div className="min-w-0 flex-1">
) <div className="flex items-center gap-2">
})} <span className="text-sm font-medium">{entry.title}</span>
<Badge variant="info" className="px-1.5 py-0 text-[10px] font-normal">
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">{entry.description}</p>
</div>
</div>
</Link>
))}
</div> </div>
) : ( ) : (
<EmptyState <EmptyState
title="暂无消息" title={loading ? "消息加载中" : "暂无消息"}
description="订单沟通和咨询会话会显示在这里。" description="进行中的订单沟通会显示在这里。"
icon={MessageSquare} icon={MessageSquare}
/> />
)} )}
+1 -9
View File
@@ -44,15 +44,7 @@ const disputeStatusVariants: Record<string, StatusBadgeProps["status"]> = {
appealed: "info", appealed: "info",
} }
function deriveMinimalTimeline<TCreatedAt>(dispute: { function deriveMinimalTimeline(dispute: { id: string; status: string; createdAt: string }) {
id: string
status: string
createdAt: TCreatedAt
timeline?: { id: string; content: string; createdAt: TCreatedAt }[]
}) {
const existing = dispute.timeline
if (existing?.length) return existing
const steps = [ const steps = [
{ status: "open", content: "争议已提交" }, { status: "open", content: "争议已提交" },
{ status: "reviewing", content: "平台审核中" }, { status: "reviewing", content: "平台审核中" },
-41
View File
@@ -1,41 +0,0 @@
import { deny } from "@/lib/decision"
import type { ApiDecision } from "@/lib/errors"
import type { ChatMessage, ChatSession } from "@/lib/types"
export type ListChatSessionsOptions = {
offset?: number
limit?: number
}
export type ListChatMessagesOptions = {
offset?: number
limit?: number
}
const unavailable = "聊天接口暂未开放"
export async function listChatSessions(_options?: ListChatSessionsOptions): Promise<ChatSession[]> {
return []
}
export async function getChatSessionById(_sessionId: string): Promise<ChatSession | undefined> {
return undefined
}
export async function listChatMessages(
_sessionId: string,
_options?: ListChatMessagesOptions,
): Promise<ChatMessage[]> {
return []
}
export async function sendTextMessage(_sessionId: string, _content: string): Promise<ApiDecision> {
return deny(404, unavailable)
}
export async function sendImageMessage(
_sessionId: string,
_imageUrl: string,
): Promise<ApiDecision> {
return deny(404, unavailable)
}
+9 -60
View File
@@ -4,20 +4,6 @@ import type { Dispute } from "@/lib/types"
import { httpJson } from "./http" import { httpJson } from "./http"
export type DisputeTimelineItem = {
id: string
content: string
createdAt: string
}
export type DisputeRecord = Dispute & {
respondentReason?: string
respondentEvidence: string[]
appealReason?: string
appealedAt?: string
timeline: DisputeTimelineItem[]
}
export type ListDisputesOptions = { export type ListDisputesOptions = {
offset?: number offset?: number
limit?: number limit?: number
@@ -64,49 +50,12 @@ function unwrapDispute(value: unknown): unknown {
return value return value
} }
function deriveMinimalTimeline(dispute: { function normalizeDispute(value: unknown): Dispute {
id: string const dispute = unwrapDispute(value) as Dispute
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 { return {
...dispute, ...dispute,
evidence, evidence: Array.isArray(dispute.evidence) ? dispute.evidence : [],
respondentEvidence, respondentEvidence: Array.isArray(dispute.respondentEvidence) ? dispute.respondentEvidence : [],
timeline,
} }
} }
@@ -118,20 +67,20 @@ function denyFromError(error: unknown): ApiDecision {
return deny(apiError.code, apiError.msg) return deny(apiError.code, apiError.msg)
} }
export async function listDisputes(options?: ListDisputesOptions): Promise<DisputeRecord[]> { export async function listDisputes(options?: ListDisputesOptions): Promise<Dispute[]> {
const res = await httpJson<Paginated<DisputeRecord> | DisputeRecord[]>( const res = await httpJson<Paginated<Dispute> | Dispute[]>(
withOffsetLimit("/api/v1/disputes", options), withOffsetLimit("/api/v1/disputes", options),
{ cache: "no-store" }, { cache: "no-store" },
) )
return unwrapItems<DisputeRecord>(res).map((item) => normalizeDisputeRecord(item)) return unwrapItems<Dispute>(res).map((item) => normalizeDispute(item))
} }
export async function getDisputeByOrderId(orderId: string): Promise<DisputeRecord | undefined> { export async function getDisputeByOrderId(orderId: string): Promise<Dispute | undefined> {
try { try {
const res = await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(orderId)}/dispute`, { const res = await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(orderId)}/dispute`, {
cache: "no-store", cache: "no-store",
}) })
return normalizeDisputeRecord(res) return normalizeDispute(res)
} catch (error) { } catch (error) {
const apiError = isApiError(error) ? error : toApiError(error) const apiError = isApiError(error) ? error : toApiError(error)
if (apiError.code === 404) return undefined if (apiError.code === 404) return undefined
+26
View File
@@ -12,6 +12,29 @@ function getCookieValue(name: string): string | null {
return null return null
} }
let csrfReady: Promise<void> | null = null
async function prepareCsrf() {
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
csrfReady ??= fetch("/healthz", {
cache: "no-store",
credentials: "include",
})
.then(() => {})
.finally(() => {
csrfReady = null
})
await csrfReady
if (!getCookieValue("__Host-XSRF-TOKEN")) {
await fetch("/api/v1/games?offset=0&limit=1", {
cache: "no-store",
credentials: "include",
})
}
}
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> { async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
const text = await res.text() const text = await res.text()
if (!text) return { json: null, text: "" } if (!text) return { json: null, text: "" }
@@ -70,6 +93,8 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
formData.set("type", type) formData.set("type", type)
formData.set("file", file) formData.set("file", file)
await prepareCsrf()
const headers = new Headers() const headers = new Headers()
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN") const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken) if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
@@ -78,6 +103,7 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
method: "POST", method: "POST",
headers, headers,
body: formData, body: formData,
credentials: "include",
}) })
const { json, text } = await readJsonBody(res) const { json, text } = await readJsonBody(res)
+31 -4
View File
@@ -60,6 +60,34 @@ function getCookieValue(name: string): string | null {
return null return null
} }
let csrfReady: Promise<void> | null = null
function needsCsrf(method: string) {
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"
}
async function prepareCsrf(path: string) {
if (typeof document === "undefined") return
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
csrfReady ??= fetch("/healthz", {
cache: "no-store",
credentials: "include",
})
.then(() => {})
.finally(() => {
csrfReady = null
})
await csrfReady
if (!getCookieValue("__Host-XSRF-TOKEN") && path !== "/healthz") {
await fetch("/api/v1/games?offset=0&limit=1", {
cache: "no-store",
credentials: "include",
})
}
}
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } { function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
if (typeof value !== "object" || value === null) return false if (typeof value !== "object" || value === null) return false
const v = value as { code?: unknown; message?: unknown } const v = value as { code?: unknown; message?: unknown }
@@ -91,10 +119,8 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
} }
const method = (rest.method ?? "GET").toUpperCase() const method = (rest.method ?? "GET").toUpperCase()
if ( if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) {
(method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") && await prepareCsrf(path)
!headers.has("XSRF-TOKEN")
) {
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN") const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken) if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
} }
@@ -103,6 +129,7 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
...rest, ...rest,
headers, headers,
body, body,
credentials: rest.credentials ?? "include",
}) })
const { json: data, text } = await readJsonBody(res) const { json: data, text } = await readJsonBody(res)
+1 -2
View File
@@ -1,6 +1,5 @@
export { login, logout, register, resetPassword } from "./auth" export { login, logout, register, resetPassword } from "./auth"
export { sendForgotPasswordCode } from "./auth-extra" export { sendForgotPasswordCode } from "./auth-extra"
export { getChatSessionById, listChatMessages, listChatSessions } from "./chat"
export { requestWithAuth } from "./client" export { requestWithAuth } from "./client"
export { addComment, listCommentsByPost, toggleCommentLike } from "./comments" export { addComment, listCommentsByPost, toggleCommentLike } from "./comments"
export { getDisputeByOrderId, listDisputes } from "./disputes" export { getDisputeByOrderId, listDisputes } from "./disputes"
@@ -14,7 +13,7 @@ export {
markNotificationAsRead, markNotificationAsRead,
} from "./notifications" } from "./notifications"
export { getOrderById, listOrders } from "./orders" export { getOrderById, listOrders } from "./orders"
export { getPlayerById, listPlayers, listPlayersByShop } from "./players" export { getMyPlayer, getPlayerById, listPlayers, listPlayersByShop } from "./players"
export { createPost, getPostById, listPosts, listPostsByAuthor, togglePostLike } from "./posts" export { createPost, getPostById, listPosts, listPostsByAuthor, togglePostLike } from "./posts"
export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./reviews" export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./reviews"
export { export {
+6 -6
View File
@@ -80,18 +80,18 @@ export async function getOrderById(orderId: string): Promise<Order | undefined>
} }
interface CreatePaidOrderInput { interface CreatePaidOrderInput {
playerId: string | number playerId: string
serviceId: string | number serviceId: string
shopId?: string | number shopId?: string
quantity: number quantity: number
note?: string note?: string
} }
function createOrderJson(input: CreatePaidOrderInput) { function createOrderJson(input: CreatePaidOrderInput) {
return { return {
playerId: Number(input.playerId), playerId: input.playerId,
serviceId: Number(input.serviceId), serviceId: input.serviceId,
...(input.shopId ? { shopId: Number(input.shopId) } : {}), ...(input.shopId ? { shopId: input.shopId } : {}),
quantity: input.quantity, quantity: input.quantity,
...(input.note ? { note: input.note } : {}), ...(input.note ? { note: input.note } : {}),
} }
+14
View File
@@ -35,6 +35,20 @@ export async function getPlayerById(playerId: string): Promise<Player | undefine
} }
} }
export async function getMyPlayer(): Promise<Player | undefined> {
try {
return await httpJson<Player>("/api/v1/players/me", { cache: "no-store" })
} catch (error) {
if (error instanceof Error && error.message === "UNAUTHORIZED") {
throw error
}
if (isApiError(error) && error.code === 404) {
return undefined
}
throw error
}
}
export async function listPlayersByShop(shopId: string): Promise<Player[]> { export async function listPlayersByShop(shopId: string): Promise<Player[]> {
const players = await listPlayers() const players = await listPlayers()
return players.filter((player) => String(player.shopId) === shopId) return players.filter((player) => String(player.shopId) === shopId)
+1 -1
View File
@@ -29,7 +29,7 @@ export type ServiceInput = {
function serviceJson(input: ServiceInput) { function serviceJson(input: ServiceInput) {
return { return {
gameId: Number(input.gameId), gameId: input.gameId,
title: input.title, title: input.title,
description: input.description, description: input.description,
price: input.price, price: input.price,
+1 -1
View File
@@ -174,7 +174,7 @@ export async function inviteShopPlayer(shopId: string, playerId: string): Promis
await httpJson<unknown>(`/api/v1/shops/${encodeURIComponent(shopId)}/invitations`, { await httpJson<unknown>(`/api/v1/shops/${encodeURIComponent(shopId)}/invitations`, {
method: "POST", method: "POST",
json: { json: {
playerId: Number(playerId), playerId,
}, },
}) })
} }
+227
View File
@@ -0,0 +1,227 @@
"use client"
import { useAuthStore } from "@/store/auth"
import { useCallback, useEffect, useRef, useState } from "react"
export type ChatSocketMessage = {
id: string
sessionId: string
senderId: string
type: "text" | "image" | "system"
content: string
createdAt: string
}
type WsEnvelope = {
type: string
sessionId?: number
senderId?: number
content?: string
msgType?: string
data?: unknown
}
type ChatSocketState = {
connected: boolean
sessionId: string | null
messages: ChatSocketMessage[]
error: string | null
createDM: (targetUserId: string) => void
joinSession: (nextSessionId: string) => void
leaveSession: (nextSessionId: string) => void
sendTextMessage: (content: string) => void
sendImageMessage: (imageUrl: string) => void
requestHistory: (nextSessionId: string) => void
}
function getChatSocketUrl() {
const configured = process.env.NEXT_PUBLIC_BACKEND_URL
const origin =
configured ||
(process.env.NODE_ENV === "development" ? "http://localhost:18080" : window.location.origin)
const url = new URL(origin)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = "/ws/chat"
url.search = ""
return url.toString()
}
function unixToIso(value: unknown) {
if (typeof value !== "number" || value <= 0) return new Date().toISOString()
return new Date(value * 1000).toISOString()
}
function normalizeMessage(value: unknown): ChatSocketMessage | null {
if (typeof value !== "object" || value === null) return null
const item = value as {
id?: unknown
sessionId?: unknown
senderId?: unknown
type?: unknown
content?: unknown
createdAt?: unknown
}
if (typeof item.content !== "string") return null
const type = item.type === "image" || item.type === "system" ? item.type : "text"
return {
id: String(item.id ?? `${item.sessionId ?? ""}-${item.senderId ?? ""}-${item.createdAt ?? ""}`),
sessionId: String(item.sessionId ?? ""),
senderId: String(item.senderId ?? ""),
type,
content: item.content,
createdAt: unixToIso(item.createdAt),
}
}
function liveMessage(msg: WsEnvelope): ChatSocketMessage | null {
if (msg.type !== "message" || !msg.content || msg.sessionId === undefined) return null
const data = typeof msg.data === "object" && msg.data !== null ? msg.data : {}
const messageId = "messageId" in data ? (data as { messageId?: unknown }).messageId : undefined
const type = msg.msgType === "image" ? "image" : "text"
return {
id: String(messageId ?? `${msg.sessionId}-${msg.senderId ?? ""}-${Date.now()}`),
sessionId: String(msg.sessionId),
senderId: String(msg.senderId ?? ""),
type,
content: msg.content,
createdAt: new Date().toISOString(),
}
}
function jsonWithInt64(type: string, fields: Record<string, string | undefined>) {
const parts = [`"type":${JSON.stringify(type)}`]
for (const [key, value] of Object.entries(fields)) {
if (!value) continue
parts.push(`"${key}":${value}`)
}
return `{${parts.join(",")}}`
}
function jsonMessage(sessionId: string, content: string, msgType: "text" | "image") {
const payload = JSON.stringify({ type: "message", content, msgType })
return payload.replace(/^\{/, `{"sessionId":${sessionId},`)
}
export function useChatSocket(): ChatSocketState {
const wsRef = useRef<WebSocket | null>(null)
const [connected, setConnected] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatSocketMessage[]>([])
const [error, setError] = useState<string | null>(null)
const userId = useAuthStore((state) => state.user?.id)
useEffect(() => {
if (!userId) return
const ws = new WebSocket(getChatSocketUrl())
wsRef.current = ws
ws.onopen = () => {
if (wsRef.current !== ws) return
setConnected(true)
setError(null)
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data) as WsEnvelope
if (msg.type === "dm_created" || msg.type === "group_created" || msg.type === "joined") {
if (msg.sessionId !== undefined) setSessionId(String(msg.sessionId))
if (msg.sessionId !== undefined)
ws.send(jsonWithInt64("history", { sessionId: String(msg.sessionId) }))
return
}
if (msg.type === "history") {
const nextMessages = Array.isArray(msg.data)
? msg.data
.map(normalizeMessage)
.filter((item): item is ChatSocketMessage => item !== null)
: []
setMessages(nextMessages)
return
}
if (msg.type === "error") {
setError(msg.content ?? "聊天服务错误")
return
}
const nextMessage = liveMessage(msg)
if (nextMessage) setMessages((prev) => [...prev, nextMessage])
}
ws.onclose = () => {
if (wsRef.current !== ws) return
setConnected(false)
wsRef.current = null
}
ws.onerror = () => {
if (wsRef.current !== ws) return
setError("聊天连接失败")
ws.close()
}
return () => {
if (wsRef.current === ws) wsRef.current = null
ws.close()
}
}, [userId])
const sendRaw = useCallback((payload: string) => {
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) ws.send(payload)
}, [])
const createDM = useCallback(
(targetUserId: string) => sendRaw(jsonWithInt64("create_dm", { targetId: targetUserId })),
[sendRaw],
)
const joinSession = useCallback(
(nextSessionId: string) => sendRaw(jsonWithInt64("join", { sessionId: nextSessionId })),
[sendRaw],
)
const leaveSession = useCallback(
(nextSessionId: string) => sendRaw(jsonWithInt64("leave", { sessionId: nextSessionId })),
[sendRaw],
)
const requestHistory = useCallback(
(nextSessionId: string) => sendRaw(jsonWithInt64("history", { sessionId: nextSessionId })),
[sendRaw],
)
const sendTextMessage = useCallback(
(content: string) => {
if (!sessionId) return
sendRaw(jsonMessage(sessionId, content, "text"))
},
[sendRaw, sessionId],
)
const sendImageMessage = useCallback(
(imageUrl: string) => {
if (!sessionId) return
sendRaw(jsonMessage(sessionId, imageUrl, "image"))
},
[sendRaw, sessionId],
)
return {
connected,
sessionId,
messages,
error,
createDM,
joinSession,
leaveSession,
sendTextMessage,
sendImageMessage,
requestHistory,
}
}
-189
View File
@@ -1,189 +0,0 @@
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
import type { Player, PlayerService, Shop } from "@/lib/types"
export interface SearchCatalogParams {
q?: string
selectedGames?: string[]
min?: string
max?: string
onlyOnline?: boolean
minRating?: string
sort?: SearchSort
limit?: number
offset?: number
}
export interface SearchCatalogData {
players: Player[]
shops: Shop[]
services: PlayerService[]
}
type InternalResultItem = SearchResultItem & { __index: number }
function minPriceFromServices(services: PlayerService[]) {
return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price))
}
function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] {
if (services.length === 0) return "局"
return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
}
export function searchCatalog(
params: SearchCatalogParams,
data: SearchCatalogData,
): SearchResponse {
const q = params.q ?? ""
const query = q ? q.toLowerCase() : ""
const selectedGames = params.selectedGames ?? []
const min = params.min ?? ""
const max = params.max ?? ""
const minP = min ? Number(min) : 0
const maxP = max ? Number(max) : Infinity
const onlyOnline = params.onlyOnline ?? false
const minRating = Number(params.minRating ?? "0")
const sort = params.sort ?? "composite"
const offset = Math.max(0, params.offset ?? 0)
const limit = Math.max(0, params.limit ?? 12)
const shopDerivedById = new Map<
string,
{
minPrice: number
unit: PlayerService["unit"]
games: string[]
hasAvailable: boolean
}
>()
for (const shop of data.shops) {
const shopPlayers = data.players.filter((player) => player.shopId === shop.id)
const playerIds = new Set(shopPlayers.map((player) => player.id))
const shopServices = data.services.filter((service) => playerIds.has(service.playerId))
const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0
const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局"
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
const hasAvailable = shopPlayers.some((player) => player.status === "available")
shopDerivedById.set(shop.id, {
minPrice: shopMinPrice,
unit: shopUnit,
games: shopGames,
hasAvailable,
})
}
let nextIndex = 0
const results: InternalResultItem[] = []
for (const player of data.players) {
results.push({
__index: nextIndex++,
type: "player",
player,
rating: player.rating,
orders: player.totalOrders,
minPrice: minPriceFromServices(player.services),
unit: unitFromCheapestService(player.services),
})
}
for (const shop of data.shops) {
const derived = shopDerivedById.get(shop.id)
if (!derived) continue
results.push({
__index: nextIndex++,
type: "shop",
shop,
rating: Number(shop.rating),
orders: shop.totalOrders,
minPrice: derived.minPrice,
unit: derived.unit,
games: derived.games,
hasAvailable: derived.hasAvailable,
})
}
const filtered = results.filter((item) => {
if (query) {
if (item.type === "player") {
const matchName = item.player.user.nickname.toLowerCase().includes(query)
const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query))
const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchTags && !matchGames) return false
} else {
const matchName = item.shop.name.toLowerCase().includes(query)
const matchDescription = item.shop.description.toLowerCase().includes(query)
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchDescription && !matchGames) return false
}
}
if (selectedGames.length > 0) {
if (item.type === "player") {
const hasGame = item.player.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
} else {
const hasGame = item.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
}
}
if (item.minPrice < minP) return false
if (max && item.minPrice > maxP) return false
if (onlyOnline) {
if (item.type === "player") {
if (item.player.status !== "available") return false
} else {
if (!item.hasAvailable) return false
}
}
if (item.type === "player") {
if (item.player.rating < minRating) return false
} else {
if (Number(item.shop.rating) < minRating) return false
}
return true
})
filtered.sort((a, b) => {
let diff = 0
switch (sort) {
case "rating":
diff = b.rating - a.rating
break
case "price_asc":
diff = a.minPrice - b.minPrice
break
case "price_desc":
diff = b.minPrice - a.minPrice
break
case "orders":
diff = b.orders - a.orders
break
case "composite": {
const scoreA = a.rating * Math.log10(a.orders + 1)
const scoreB = b.rating * Math.log10(b.orders + 1)
diff = scoreB - scoreA
break
}
default:
diff = 0
}
if (diff !== 0) return diff
return a.__index - b.__index
})
const total = filtered.length
const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item)
return { items, meta: { total, offset, limit } }
}
+11 -1
View File
@@ -120,11 +120,21 @@ export interface Review {
export interface Dispute { export interface Dispute {
id: SnowflakeId id: SnowflakeId
orderId: SnowflakeId orderId: SnowflakeId
initiatorId?: SnowflakeId
initiatorName?: string
respondentId?: SnowflakeId
reason: string reason: string
evidence: string[] evidence: string[]
status: "open" | "reviewing" | "resolved" | "appealed" status: "open" | "reviewing" | "resolved" | "appealed"
result?: "full_refund" | "full_payment" | "partial_refund" result?: "full_refund" | "full_payment" | "partial_refund"
respondentReason?: string
respondentEvidence: string[]
appealReason?: string
appealedAt?: string
resolvedBy?: SnowflakeId
resolvedAt?: string
createdAt: string createdAt: string
updatedAt: string
} }
export interface ChatParticipant { export interface ChatParticipant {
@@ -168,7 +178,7 @@ export interface Post {
content: string content: string
images: string[] images: string[]
tags: string[] tags: string[]
linkedOrderId?: number linkedOrderId?: SnowflakeId
pinned: boolean pinned: boolean
likeCount: number likeCount: number
commentCount: number commentCount: number
+5
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next" import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone",
async rewrites() { async rewrites() {
// 仅在开发环境启用 API 代理 // 仅在开发环境启用 API 代理
if (process.env.NODE_ENV !== "development") { if (process.env.NODE_ENV !== "development") {
@@ -14,6 +15,10 @@ const nextConfig: NextConfig = {
source: "/api/:path*", source: "/api/:path*",
destination: `${backendUrl}/api/:path*`, destination: `${backendUrl}/api/:path*`,
}, },
{
source: "/healthz",
destination: `${backendUrl}/healthz`,
},
] ]
}, },
} }
+4 -3
View File
@@ -2,6 +2,7 @@
"name": "juwan-frontend", "name": "juwan-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
@@ -19,7 +20,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@tanstack/react-query": "^5.100.6", "@tanstack/react-query": "^5.100.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -29,10 +30,10 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-hook-form": "^7.74.0", "react-hook-form": "^7.75.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"zod": "^4.4.1", "zod": "^4.4.2",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
+75 -75
View File
@@ -10,13 +10,13 @@ importers:
dependencies: dependencies:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.74.0(react@19.2.5)) version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
'@standard-schema/spec': '@standard-schema/spec':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.100.6 specifier: ^5.100.8
version: 5.100.6(react@19.2.5) version: 5.100.8(react@19.2.5)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -45,8 +45,8 @@ importers:
specifier: 19.2.5 specifier: 19.2.5
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
react-hook-form: react-hook-form:
specifier: ^7.74.0 specifier: ^7.75.0
version: 7.74.0(react@19.2.5) version: 7.75.0(react@19.2.5)
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -54,8 +54,8 @@ importers:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
zod: zod:
specifier: ^4.4.1 specifier: ^4.4.2
version: 4.4.1 version: 4.4.2
zustand: zustand:
specifier: ^5.0.12 specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
@@ -122,8 +122,8 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/compat-data@7.29.0': '@babel/compat-data@7.29.3':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/core@7.29.0': '@babel/core@7.29.0':
@@ -142,8 +142,8 @@ packages:
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-create-class-features-plugin@7.28.6': '@babel/helper-create-class-features-plugin@7.29.3':
resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0 '@babel/core': ^7.0.0
@@ -200,8 +200,8 @@ packages:
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2': '@babel/parser@7.29.3':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -720,8 +720,8 @@ packages:
'@cfworker/json-schema': '@cfworker/json-schema':
optional: true optional: true
'@mswjs/interceptors@0.41.7': '@mswjs/interceptors@0.41.8':
resolution: {integrity: sha512-D0nkS5+sx87mYpxFqASImCineYoEl9wGlUPrzkuS0ohzG8wfophLpE+I76qGJ0slLAVI19do5SI9pWJNCVf4fg==} resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
@@ -1786,11 +1786,11 @@ packages:
'@tailwindcss/postcss@4.2.4': '@tailwindcss/postcss@4.2.4':
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
'@tanstack/query-core@5.100.6': '@tanstack/query-core@5.100.8':
resolution: {integrity: sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==} resolution: {integrity: sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==}
'@tanstack/react-query@5.100.6': '@tanstack/react-query@5.100.8':
resolution: {integrity: sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==} resolution: {integrity: sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==}
peerDependencies: peerDependencies:
react: ^18 || ^19 react: ^18 || ^19
@@ -2147,8 +2147,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.10.24: baseline-browser-mapping@2.10.25:
resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==} resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -2417,8 +2417,8 @@ packages:
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.345: electron-to-chromium@1.5.349:
resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -3366,8 +3366,8 @@ packages:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11: nanoid@3.3.12:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@@ -3585,8 +3585,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postcss@8.5.12: postcss@8.5.13:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0: powershell-utils@0.1.0:
@@ -3664,8 +3664,8 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.5 react: ^19.2.5
react-hook-form@7.74.0: react-hook-form@7.75.0:
resolution: {integrity: sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==} resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
@@ -4014,11 +4014,11 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tldts-core@7.0.29: tldts-core@7.0.30:
resolution: {integrity: sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==} resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
tldts@7.0.29: tldts@7.0.30:
resolution: {integrity: sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==} resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
hasBin: true hasBin: true
to-regex-range@5.0.1: to-regex-range@5.0.1:
@@ -4337,8 +4337,8 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.4.1: zod@4.4.2:
resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==}
zustand@5.0.12: zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
@@ -4368,7 +4368,7 @@ snapshots:
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/compat-data@7.29.0': {} '@babel/compat-data@7.29.3': {}
'@babel/core@7.29.0': '@babel/core@7.29.0':
dependencies: dependencies:
@@ -4377,7 +4377,7 @@ snapshots:
'@babel/helper-compilation-targets': 7.28.6 '@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.29.2 '@babel/helpers': 7.29.2
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/template': 7.28.6 '@babel/template': 7.28.6
'@babel/traverse': 7.29.0 '@babel/traverse': 7.29.0
'@babel/types': 7.29.0 '@babel/types': 7.29.0
@@ -4392,7 +4392,7 @@ snapshots:
'@babel/generator@7.29.1': '@babel/generator@7.29.1':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
@@ -4404,13 +4404,13 @@ snapshots:
'@babel/helper-compilation-targets@7.28.6': '@babel/helper-compilation-targets@7.28.6':
dependencies: dependencies:
'@babel/compat-data': 7.29.0 '@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1 '@babel/helper-validator-option': 7.27.1
browserslist: 4.28.2 browserslist: 4.28.2
lru-cache: 5.1.1 lru-cache: 5.1.1
semver: 6.3.1 semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-annotate-as-pure': 7.27.3
@@ -4481,7 +4481,7 @@ snapshots:
'@babel/template': 7.28.6 '@babel/template': 7.28.6
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/parser@7.29.2': '@babel/parser@7.29.3':
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
@@ -4507,7 +4507,7 @@ snapshots:
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
'@babel/helper-plugin-utils': 7.28.6 '@babel/helper-plugin-utils': 7.28.6
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
@@ -4528,7 +4528,7 @@ snapshots:
'@babel/template@7.28.6': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/traverse@7.29.0': '@babel/traverse@7.29.0':
@@ -4536,7 +4536,7 @@ snapshots:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1 '@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0 '@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/template': 7.28.6 '@babel/template': 7.28.6
'@babel/types': 7.29.0 '@babel/types': 7.29.0
debug: 4.4.3 debug: 4.4.3
@@ -4726,10 +4726,10 @@ snapshots:
dependencies: dependencies:
hono: 4.12.16 hono: 4.12.16
'@hookform/resolvers@5.2.2(react-hook-form@7.74.0(react@19.2.5))': '@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
react-hook-form: 7.74.0(react@19.2.5) react-hook-form: 7.75.0(react@19.2.5)
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
@@ -4912,7 +4912,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@mswjs/interceptors@0.41.7': '@mswjs/interceptors@0.41.8':
dependencies: dependencies:
'@open-draft/deferred-promise': 2.2.0 '@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0 '@open-draft/logger': 0.3.0
@@ -5909,14 +5909,14 @@ snapshots:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.2.4 '@tailwindcss/node': 4.2.4
'@tailwindcss/oxide': 4.2.4 '@tailwindcss/oxide': 4.2.4
postcss: 8.5.12 postcss: 8.5.13
tailwindcss: 4.2.4 tailwindcss: 4.2.4
'@tanstack/query-core@5.100.6': {} '@tanstack/query-core@5.100.8': {}
'@tanstack/react-query@5.100.6(react@19.2.5)': '@tanstack/react-query@5.100.8(react@19.2.5)':
dependencies: dependencies:
'@tanstack/query-core': 5.100.6 '@tanstack/query-core': 5.100.8
react: 19.2.5 react: 19.2.5
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
@@ -6291,7 +6291,7 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.24: {} baseline-browser-mapping@2.10.25: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
@@ -6322,9 +6322,9 @@ snapshots:
browserslist@4.28.2: browserslist@4.28.2:
dependencies: dependencies:
baseline-browser-mapping: 2.10.24 baseline-browser-mapping: 2.10.25
caniuse-lite: 1.0.30001791 caniuse-lite: 1.0.30001791
electron-to-chromium: 1.5.345 electron-to-chromium: 1.5.349
node-releases: 2.0.38 node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2) update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -6534,7 +6534,7 @@ snapshots:
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.345: {} electron-to-chromium@1.5.349: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
@@ -6802,11 +6802,11 @@ snapshots:
eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)): eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
hermes-parser: 0.25.1 hermes-parser: 0.25.1
zod: 4.4.1 zod: 4.4.2
zod-validation-error: 4.0.2(zod@4.4.1) zod-validation-error: 4.0.2(zod@4.4.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7595,7 +7595,7 @@ snapshots:
msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3): msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3):
dependencies: dependencies:
'@inquirer/confirm': 6.0.12(@types/node@25.6.0) '@inquirer/confirm': 6.0.12(@types/node@25.6.0)
'@mswjs/interceptors': 0.41.7 '@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0 '@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6 '@types/statuses': 2.0.6
cookie: 1.1.1 cookie: 1.1.1
@@ -7619,7 +7619,7 @@ snapshots:
mute-stream@3.0.0: {} mute-stream@3.0.0: {}
nanoid@3.3.11: {} nanoid@3.3.12: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
@@ -7636,7 +7636,7 @@ snapshots:
dependencies: dependencies:
'@next/env': 16.2.4 '@next/env': 16.2.4
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.24 baseline-browser-mapping: 2.10.25
caniuse-lite: 1.0.30001791 caniuse-lite: 1.0.30001791
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.5 react: 19.2.5
@@ -7838,13 +7838,13 @@ snapshots:
postcss@8.4.31: postcss@8.4.31:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.12
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postcss@8.5.12: postcss@8.5.13:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.12
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -7964,7 +7964,7 @@ snapshots:
react: 19.2.5 react: 19.2.5
scheduler: 0.27.0 scheduler: 0.27.0
react-hook-form@7.74.0(react@19.2.5): react-hook-form@7.75.0(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@@ -8181,7 +8181,7 @@ snapshots:
shadcn@4.6.0(@types/node@25.6.0)(typescript@6.0.3): shadcn@4.6.0(@types/node@25.6.0)(typescript@6.0.3):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@dotenvx/dotenvx': 1.64.0 '@dotenvx/dotenvx': 1.64.0
@@ -8203,7 +8203,7 @@ snapshots:
node-fetch: 3.3.2 node-fetch: 3.3.2
open: 11.0.0 open: 11.0.0
ora: 8.2.0 ora: 8.2.0
postcss: 8.5.12 postcss: 8.5.13
postcss-selector-parser: 7.1.1 postcss-selector-parser: 7.1.1
prompts: 2.4.2 prompts: 2.4.2
recast: 0.23.11 recast: 0.23.11
@@ -8439,11 +8439,11 @@ snapshots:
tinyrainbow@3.1.0: {} tinyrainbow@3.1.0: {}
tldts-core@7.0.29: {} tldts-core@7.0.30: {}
tldts@7.0.29: tldts@7.0.30:
dependencies: dependencies:
tldts-core: 7.0.29 tldts-core: 7.0.30
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
@@ -8453,7 +8453,7 @@ snapshots:
tough-cookie@6.0.1: tough-cookie@6.0.1:
dependencies: dependencies:
tldts: 7.0.29 tldts: 7.0.30
ts-api-utils@2.5.0(typescript@6.0.3): ts-api-utils@2.5.0(typescript@6.0.3):
dependencies: dependencies:
@@ -8622,7 +8622,7 @@ snapshots:
esbuild: 0.27.7 esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
postcss: 8.5.12 postcss: 8.5.13
rollup: 4.60.2 rollup: 4.60.2
tinyglobby: 0.2.16 tinyglobby: 0.2.16
optionalDependencies: optionalDependencies:
@@ -8757,13 +8757,13 @@ snapshots:
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76
zod-validation-error@4.0.2(zod@4.4.1): zod-validation-error@4.0.2(zod@4.4.2):
dependencies: dependencies:
zod: 4.4.1 zod: 4.4.2
zod@3.25.76: {} zod@3.25.76: {}
zod@4.4.1: {} zod@4.4.2: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
optionalDependencies: optionalDependencies:
+8 -3
View File
@@ -22,6 +22,7 @@ interface PersistedAuth {
currentRole: UserRole currentRole: UserRole
verifiedRoles: UserRole[] verifiedRoles: UserRole[]
verificationStatus: Partial<Record<UserRole, VerificationStatus>> verificationStatus: Partial<Record<UserRole, VerificationStatus>>
notificationPrefs: NotificationPrefs
themePreference: ThemePreference themePreference: ThemePreference
} }
@@ -50,6 +51,7 @@ function persistCurrent(state: AuthState) {
currentRole: state.currentRole, currentRole: state.currentRole,
verifiedRoles: state.verifiedRoles, verifiedRoles: state.verifiedRoles,
verificationStatus: state.verificationStatus, verificationStatus: state.verificationStatus,
notificationPrefs: state.notificationPrefs,
themePreference: state.themePreference, themePreference: state.themePreference,
}) })
} }
@@ -95,13 +97,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
persistCurrent(get()) persistCurrent(get())
} }
}, },
setNotificationPref: (type, enabled) => setNotificationPref: (type, enabled) => {
set((state) => ({ set((state) => ({
notificationPrefs: { notificationPrefs: {
...state.notificationPrefs, ...state.notificationPrefs,
[type]: enabled, [type]: enabled,
}, },
})), }))
persistCurrent(get())
},
setThemePreference: (theme) => { setThemePreference: (theme) => {
set({ themePreference: theme }) set({ themePreference: theme })
persistCurrent(get()) persistCurrent(get())
@@ -138,6 +142,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
currentRole: user.role, currentRole: user.role,
verifiedRoles: nextVerifiedRoles, verifiedRoles: nextVerifiedRoles,
verificationStatus: nextVerificationStatus, verificationStatus: nextVerificationStatus,
notificationPrefs: state.notificationPrefs,
themePreference: nextTheme, themePreference: nextTheme,
}) })
@@ -160,7 +165,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
verifiedRoles: ["consumer"], verifiedRoles: ["consumer"],
verificationStatus: { consumer: "approved" }, verificationStatus: { consumer: "approved" },
verificationReasons: {}, verificationReasons: {},
notificationPrefs: defaultNotificationPrefs, notificationPrefs: persisted?.notificationPrefs ?? defaultNotificationPrefs,
themePreference: "system", themePreference: "system",
user: null, user: null,
}) })
-417
View File
@@ -1,417 +0,0 @@
import type { SearchCatalogData } from "@/lib/search/search-catalog"
import { searchCatalog } from "@/lib/search/search-catalog"
import { describe, expect, it } from "vitest"
const createdAt = "2025-01-01T00:00:00.000Z"
const players: SearchCatalogData["players"] = [
{
id: "1006",
user: {
id: "2006",
username: "u1006",
nickname: "Winter",
avatar: "/avatars/u1006.png",
role: "player",
createdAt,
},
rating: 4.8,
totalOrders: 120,
completionRate: 0.98,
status: "available",
games: ["王者荣耀"],
services: [
{
id: "s-1006-wzry",
playerId: "1006",
gameId: "g-wzry",
gameName: "王者荣耀",
title: "王者荣耀陪练",
description: "",
price: 30,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3002",
gender: true,
tags: ["moba"],
},
{
id: "1007",
user: {
id: "2007",
username: "u1007",
nickname: "u7",
avatar: "/avatars/u1007.png",
role: "player",
createdAt,
},
rating: 4.0,
totalOrders: 20,
completionRate: 0.9,
status: "busy",
games: ["英雄联盟"],
services: [
{
id: "s-1007-lol",
playerId: "1007",
gameId: "g-lol",
gameName: "英雄联盟",
title: "英雄联盟陪练",
description: "",
price: 18,
unit: "局",
availability: ["weekdays"],
},
],
shopId: "3003",
gender: true,
tags: [],
},
{
id: "1008",
user: {
id: "2008",
username: "u1008",
nickname: "Ace",
avatar: "/avatars/u1008.png",
role: "player",
createdAt,
},
rating: 4.7,
totalOrders: 80,
completionRate: 0.97,
status: "available",
games: ["CS2"],
services: [
{
id: "s-1008-cs2",
playerId: "1008",
gameId: "g-cs2",
gameName: "CS2",
title: "CS2代练",
description: "",
price: 10,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3001",
gender: false,
tags: ["fps"],
},
{
id: "1009",
user: {
id: "2009",
username: "u1009",
nickname: "u9",
avatar: "/avatars/u1009.png",
role: "player",
createdAt,
},
rating: 3.5,
totalOrders: 5,
completionRate: 0.85,
status: "offline",
games: ["英雄联盟"],
services: [
{
id: "s-1009-lol",
playerId: "1009",
gameId: "g-lol",
gameName: "英雄联盟",
title: "英雄联盟陪练",
description: "",
price: 12,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3003",
gender: true,
tags: [],
},
]
const shops: SearchCatalogData["shops"] = [
{
id: "3001",
owner: {
id: "4001",
username: "owner3001",
nickname: "Owner 3001",
avatar: "/avatars/owner3001.png",
role: "owner",
createdAt,
},
name: "CS2 Hub",
description: "",
rating: "4.6",
totalOrders: 300,
playerCount: 1,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
{
id: "3002",
owner: {
id: "4002",
username: "owner3002",
nickname: "Owner 3002",
avatar: "/avatars/owner3002.png",
role: "owner",
createdAt,
},
name: "Yuki Studio",
description: "",
rating: "4.2",
totalOrders: 50,
playerCount: 1,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
{
id: "3003",
owner: {
id: "4003",
username: "owner3003",
nickname: "Owner 3003",
avatar: "/avatars/owner3003.png",
role: "owner",
createdAt,
},
name: "Quiet Shop",
description: "",
rating: "3.8",
totalOrders: 10,
playerCount: 2,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
]
const services: SearchCatalogData["services"] = players.flatMap((p) => p.services)
const data: SearchCatalogData = {
players,
shops,
services,
}
describe("searchCatalog", () => {
describe("q matching (case-insensitive)", () => {
it("matches player nickname", () => {
const res = searchCatalog({ q: "winter", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
})
it("matches player nickname case-insensitively", () => {
const res = searchCatalog({ q: "WINTER", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
})
it("matches shop name", () => {
const res = searchCatalog({ q: "yuki", limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3002")).toBe(true)
})
it("matches player game name", () => {
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
// 1008 has CS2 in games
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1008")).toBe(true)
})
it("matches shop derived games", () => {
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
it("returns all items when q is empty", () => {
const res = searchCatalog({ limit: 50 }, data)
expect(res.meta.total).toBe(players.length + shops.length)
})
})
describe("selectedGames ANY-match", () => {
it("filters players by game", () => {
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe(
true,
)
expect(playerItems.length).toBeGreaterThan(0)
})
it("filters shops by derived games from services", () => {
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
it("uses ANY-match (OR) for multiple games", () => {
const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data)
// Should include players with either game
expect(res.meta.total).toBeGreaterThan(0)
const playerItems = res.items.filter((i) => i.type === "player")
for (const item of playerItems) {
if (item.type === "player") {
expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe(
true,
)
}
}
})
})
describe("onlyOnline semantics", () => {
it("filters players by status === available", () => {
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
for (const item of playerItems) {
if (item.type === "player") {
expect(item.player.status).toBe("available")
}
}
// u7 is busy, u9 is offline — they should be excluded
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1007")).toBe(false)
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1009")).toBe(false)
})
it("filters shops by hasAvailable (any player available)", () => {
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
for (const item of shopItems) {
if (item.type === "shop") {
expect(item.hasAvailable).toBe(true)
}
}
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3003")).toBe(false)
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
})
describe("price min/max parsing", () => {
it("filters by min price", () => {
const res = searchCatalog({ min: "25", limit: 50 }, data)
for (const item of res.items) {
expect(item.minPrice).toBeGreaterThanOrEqual(25)
}
})
it("filters by max price", () => {
const res = searchCatalog({ max: "20", limit: 50 }, data)
for (const item of res.items) {
expect(item.minPrice).toBeLessThanOrEqual(20)
}
})
it("NaN min does not filter (min='abc' → minP=NaN)", () => {
const resAll = searchCatalog({ limit: 50 }, data)
const resNaN = searchCatalog({ min: "abc", limit: 50 }, data)
// NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min
// But items with minPrice < 0 would pass too — effectively no min filter
expect(resNaN.meta.total).toBe(resAll.meta.total)
})
it("NaN max does not filter (max='abc' → maxP=NaN)", () => {
const resAll = searchCatalog({ limit: 50 }, data)
const resNaN = searchCatalog({ max: "abc", limit: 50 }, data)
// max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false
expect(resNaN.meta.total).toBe(resAll.meta.total)
})
})
describe("sort options", () => {
it("sorts by rating descending", () => {
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating)
}
})
it("sorts by orders descending", () => {
const res = searchCatalog({ sort: "orders", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders)
}
})
it("sorts by price ascending", () => {
const res = searchCatalog({ sort: "price_asc", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice)
}
})
it("sorts by price descending", () => {
const res = searchCatalog({ sort: "price_desc", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice)
}
})
it("sorts by composite formula descending", () => {
const res = searchCatalog({ sort: "composite", limit: 50 }, data)
const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1))
for (let i = 1; i < scores.length; i++) {
expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i])
}
})
it("uses stable tie-breaker by insertion order", () => {
// Create data with identical ratings to test tie-breaking
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
// Items with same rating should preserve insertion order (players before shops)
const sameRating = res.items.filter((i) => i.rating === res.items[0].rating)
if (sameRating.length > 1) {
// They should be in original insertion order
expect(sameRating.length).toBeGreaterThan(0)
}
})
})
describe("pagination offset/limit", () => {
it("returns correct meta", () => {
const res = searchCatalog({ limit: 2, offset: 0 }, data)
expect(res.meta.limit).toBe(2)
expect(res.meta.offset).toBe(0)
expect(res.items.length).toBe(2)
expect(res.meta.total).toBe(players.length + shops.length)
})
it("offset skips items", () => {
const all = searchCatalog({ limit: 50 }, data)
const page2 = searchCatalog({ limit: 2, offset: 2 }, data)
expect(page2.items[0]).toEqual(all.items[2])
expect(page2.items[1]).toEqual(all.items[3])
})
it("offset beyond total returns empty items", () => {
const res = searchCatalog({ limit: 10, offset: 100 }, data)
expect(res.items.length).toBe(0)
expect(res.meta.total).toBeGreaterThan(0)
})
})
})
-766
View File
@@ -1,766 +0,0 @@
# 聚玩 (Juwan) API 设计文档
## 1. 文档概述
本文档为“聚玩”游戏陪玩平台的后端 API 设计规范,用于前后端联调与上线部署。
- **基础路径 (Base URL)**: `/api/v1`
- **数据格式**: `application/json`
## 2. 全局约定
### 2.1 认证与授权 (Auth)
- 采用 Cookie `JToken` 进行认证(Phase 2 接入后端时实现)。
- 当前前端仍以本地数据实现为主,不涉及真实后端交互;接入后端后会删除本地数据实现。
- 接口权限标识:
- 🔒:需要登录认证。
- 🛡️:需要管理员权限。
### 2.2 错误响应格式
所有失败的请求均返回统一的 JSON 结构,并附带 HTTP 状态码(如 400, 401, 403, 404, 500)。
```json
{
"code": 401,
"msg": "请先登录"
}
```
**常见错误码 (code)**
- `401`: 未登录或 Token 失效
- `403`: 角色权限不足或非参与者
- `404`: 资源不存在
- `400`: 参数校验失败、状态不合法、重复请求等
- `500`: 服务器内部错误
### 2.3 分页规范
采用 `offset``limit` 进行分页(与前端 `SearchResponse` 保持一致)。
**请求参数**`?offset=0&limit=20`
**响应格式**
```json
{
"items": [...],
"meta": {
"total": 100,
"offset": 0,
"limit": 20
}
}
```
### 2.4 文件上传与访问
- **上传端点**: `POST /api/v1/upload`
- **Content-Type**: `multipart/form-data`
- **参数**: `file` (文件), `type` (枚举: `avatar`, `chat`, `post`, `verification`, `dispute`)
- **响应**: 返回文件的 CDN URL 或文件 ID。
- **访问端点**: `GET /api/v1/files/:fileId` (获取文件内容)
### 2.5 时间戳格式
所有时间字段均采用 ISO 8601 格式的 UTC 时间字符串,例如:`2024-03-20T12:00:00.000Z`
### 2.6 角色系统 (UserRole)
- `consumer`: 普通消费者(老板)
- `player`: 陪玩打手
- `owner`: 店铺店长
- `admin`: 平台管理员(隐藏角色,用于后台管理)
### 2.7 ID 规范
所有 ID 均由后端使用 Snowflake 算法生成,类型为 `int64`。在前端交互和 JSON 序列化时,统一作为 `string` 处理,以避免精度丢失。
---
## 3. 数据模型
### 3.1 User (用户)
| 字段 | 类型 | 说明 |
| ------------------ | ---------- | ---------------------------------------------------- |
| id | string | 用户唯一标识 |
| username | string | 登录用户名 |
| email | string | 邮箱地址 (可选) |
| nickname | string | 昵称 |
| avatar | string | 头像 URL |
| role | UserRole | 当前激活的角色 |
| verifiedRoles | UserRole[] | 已认证的角色列表 |
| verificationStatus | object | 各角色的认证状态 (`pending`, `approved`, `rejected`) |
| bio | string | 个人简介 (可选) |
| createdAt | string | 注册时间 |
### 3.2 Game (游戏)
| 字段 | 类型 | 说明 |
| -------- | ------ | ----------------------------------- |
| id | string | 游戏 ID |
| name | string | 游戏名称 |
| icon | string | 游戏图标 URL |
| category | string | 分类 (`MOBA`, `FPS`, `动作`, `RPG`) |
### 3.3 Player & PlayerService (打手与服务)
**PlayerService**:
| 字段 | 类型 | 说明 |
| ------------ | -------- | --------------------------- |
| id | string | 服务 ID |
| playerId | string | 打手 ID |
| gameId | string | 游戏 ID |
| gameName | string | 游戏名称 |
| title | string | 服务标题 |
| description | string | 服务描述 |
| price | number | 价格 |
| unit | string | 计价单位 (`局`, `星`, `次`) |
| rankRange | string | 段位范围 (可选) |
| availability | string[] | 可接单时间段 |
**Player**:
| 字段 | 类型 | 说明 |
| -------------- | --------------- | ------------------------------------- |
| id | string | 打手 ID |
| user | User | 关联的用户信息 |
| rating | number | 综合评分 |
| totalOrders | number | 总接单数 |
| completionRate | number | 完单率 (0-1) |
| status | string | 状态 (`available`, `busy`, `offline`) |
| games | string[] | 擅长游戏 ID 列表 |
| services | PlayerService[] | 提供的服务列表 |
| shopId | string | 所属店铺 ID (可选) |
| shopName | string | 所属店铺名称 (可选) |
| tags | string[] | 个人标签 |
### 3.4 Shop (店铺)
| 字段 | 类型 | 说明 |
| ---------------------- | -------- | -------------------------------- |
| id | string | 店铺 ID |
| owner | User | 店长信息 |
| name | string | 店铺名称 |
| banner | string | 店铺招牌图 (可选) |
| description | string | 店铺简介 |
| rating | number | 综合评分 |
| totalOrders | number | 总单量 |
| playerCount | number | 打手数量 |
| commissionType | string | 抽成方式 (`fixed`, `percentage`) |
| commissionValue | number | 抽成数值 |
| allowMultiShop | boolean | 是否允许打手兼职 |
| allowIndependentOrders | boolean | 是否允许打手私下接单 |
| dispatchMode | string | 派单模式 (`manual`, `auto`) |
| announcements | string[] | 店铺公告列表 |
| templateConfig | object | 店铺主页模板配置 |
### 3.5 Order (订单)
| 字段 | 类型 | 说明 |
| ------------ | ------------- | ------------------- |
| id | string | 订单 ID |
| consumerId | string | 消费者 ID |
| consumerName | string | 消费者昵称 |
| playerId | string | 打手 ID |
| playerName | string | 打手昵称 |
| shopId | string | 店铺 ID (可选) |
| shopName | string | 店铺名称 (可选) |
| service | PlayerService | 购买的服务快照 |
| status | OrderStatus | 订单状态 |
| totalPrice | number | 订单总价 |
| note | string | 备注 (可选) |
| createdAt | string | 创建时间 |
| acceptedAt | string | 接单时间 (可选) |
| closedAt | string | 申请结算时间 (可选) |
| completedAt | string | 完成时间 (可选) |
**OrderStatus 枚举**: `pending_payment`, `pending_accept`, `in_progress`, `pending_close`, `pending_review`, `disputed`, `completed`, `cancelled`
### 3.6 Review (评价)
| 字段 | 类型 | 说明 |
| -------------- | ------- | -------------------------------------- |
| id | string | 评价 ID |
| orderId | string | 关联订单 ID |
| fromUserId | string | 评价人 ID |
| fromUserName | string | 评价人昵称 |
| fromUserAvatar | string | 评价人头像 |
| toUserId | string | 被评价人 ID |
| rating | number | 评分 (1-5) |
| content | string | 评价内容 (可选) |
| sealed | boolean | 是否处于密封状态(双方未互评前不可见) |
| createdAt | string | 评价时间 |
### 3.7 Dispute & DisputeRecord (争议)
| 字段 | 类型 | 说明 |
| ------------------ | -------- | ---------------------------------------------------------- |
| id | string | 争议 ID |
| orderId | string | 关联订单 ID |
| initiatorId | string | 发起人 ID |
| initiatorName | string | 发起人昵称 |
| reason | string | 争议原因 |
| evidence | string[] | 证据图片 URL 列表 |
| status | string | 状态 (`open`, `reviewing`, `resolved`, `appealed`) |
| result | string | 仲裁结果 (`full_refund`, `full_payment`, `partial_refund`) |
| respondentReason | string | 被诉方回应理由 (可选) |
| respondentEvidence | string[] | 被诉方证据 (可选) |
| appealReason | string | 申诉理由 (可选) |
| appealedAt | string | 申诉时间 (可选) |
| timeline | object[] | 争议处理时间线 |
| createdAt | string | 创建时间 |
### 3.8 ChatSession & ChatMessage (聊天)
**ChatSession**:
| 字段 | 类型 | 说明 |
| ------------- | -------- | ---------------------------------- |
| id | string | 会话 ID |
| type | string | 会话类型 (`order`, `consultation`) |
| orderId | string | 关联订单 ID (仅 order 类型) |
| participants | object[] | 参与者列表 `{id, name, avatar}` |
| lastMessage | string | 最后一条消息内容 |
| lastMessageAt | string | 最后一条消息时间 |
| unreadCount | number | 未读消息数 |
| readonly | boolean | 是否只读(如订单完成/咨询超时后) |
**ChatMessage**:
| 字段 | 类型 | 说明 |
| ------------ | ------ | ------------------------------------ |
| id | string | 消息 ID |
| sessionId | string | 会话 ID |
| senderId | string | 发送者 ID |
| senderName | string | 发送者昵称 |
| senderAvatar | string | 发送者头像 |
| type | string | 消息类型 (`text`, `image`, `system`) |
| content | string | 消息内容 |
| createdAt | string | 发送时间 |
### 3.9 Post & Comment (社区)
**Post**:
| 字段 | 类型 | 说明 |
| ------------- | -------- | ---------------------- |
| id | string | 帖子 ID |
| author | User | 作者信息 |
| authorRole | UserRole | 作者发帖时的角色 |
| title | string | 标题 |
| content | string | 内容 |
| images | string[] | 图片列表 |
| tags | string[] | 标签列表 |
| linkedOrderId | string | 关联的订单 ID (秀单帖) |
| quotedPostId | string | 引用的帖子 ID (引用帖) |
| likeCount | number | 点赞数 |
| commentCount | number | 评论数 |
| liked | boolean | 当前用户是否已赞 |
| pinned | boolean | 是否被作者置顶 |
| createdAt | string | 发布时间 |
**Comment**:
| 字段 | 类型 | 说明 |
| --------- | ------- | ---------------- |
| id | string | 评论 ID |
| postId | string | 关联帖子 ID |
| author | User | 评论者信息 |
| content | string | 评论内容 |
| likeCount | number | 点赞数 |
| liked | boolean | 当前用户是否已赞 |
| createdAt | string | 评论时间 |
### 3.10 WalletTransaction (钱包流水)
| 字段 | 类型 | 说明 |
| ----------- | ------ | ----------------------------------------------------------- |
| id | string | 流水 ID |
| type | string | 类型 (`topup`, `payment`, `income`, `withdrawal`, `refund`) |
| amount | number | 金额 (正数为收入/充值/退款,负数为支出/提现) |
| description | string | 描述 |
| orderId | string | 关联订单 ID (可选,用于结构化统计,替代前端正则匹配) |
| createdAt | string | 发生时间 |
### 3.11 Favorite (收藏)
| 字段 | 类型 | 说明 |
| ---------- | ------ | ------------------------------- |
| id | string | 收藏 ID |
| userId | string | 用户 ID |
| targetType | string | 收藏目标类型 (`player`, `shop`) |
| targetId | string | 收藏目标 ID |
| createdAt | string | 收藏时间 |
### 3.12 Notification (通知)
| 字段 | 类型 | 说明 |
| --------- | ------- | ----------------------------------------- |
| id | string | 通知 ID |
| type | string | 通知类型 (`order`, `community`, `system`) |
| title | string | 通知标题 |
| content | string | 通知内容 |
| read | boolean | 是否已读 |
| link | string | 关联跳转链接 (可选) |
| createdAt | string | 通知时间 |
---
## 4. 认证与用户
| 方法 | 路径 | 权限 | 说明 |
| ------ | ------------------------------------- | ---- | --------------------------------- |
| POST | `/auth/register` | | 用户注册 (username + email + password + vcode) |
| POST | `/auth/login` | | 用户登录 (username + password) |
| POST | `/auth/logout` | 🔒 | 退出登录,清除 Cookie |
| POST | `/auth/forgot-password/send` | | 忘记密码(发送验证码到邮箱) |
| POST | `/auth/reset-password` | | 重置密码 (email + vcode + newPassword) |
| POST | `/email/verification-code/send` | | 发送邮箱验证码 |
| GET | `/users/me` | 🔒 | 获取当前登录用户信息 |
| PUT | `/users/me` | 🔒 | 更新个人资料 (昵称, 头像, 简介等) |
| POST | `/users/me/switch-role` | 🔒 | 切换当前激活角色 |
| POST | `/users/me/verification` | 🔒 | 提交角色认证材料 |
| GET | `/users/me/verification` | 🔒 | 获取认证状态 |
| PUT | `/users/me/preferences/notifications` | 🔒 | 更新通知偏好设置 |
| PUT | `/users/me/preferences/theme` | 🔒 | 更新主题偏好设置 |
| GET | `/users/:id` | | 获取指定用户信息 |
| POST | `/users/:id/follow` | 🔒 | 关注用户 |
| DELETE | `/users/:id/follow` | 🔒 | 取消关注用户 |
**请求示例:登录**
```json
// POST /api/v1/auth/login
{
"username": "zhangsan",
"password": "..."
}
```
**响应示例:登录成功**
```json
{
"user": {
"id": "u1",
"username": "zhangsan",
"email": "zhangsan@example.com",
"nickname": "张三",
"avatar": "https://cdn.juwan.com/avatars/u1.jpg",
"role": "consumer",
"verifiedRoles": ["consumer", "player"],
"verificationStatus": { "consumer": "approved", "player": "approved" },
"createdAt": "2024-01-15T08:00:00.000Z"
}
}
```
**请求示例:提交认证材料**
```json
// POST /api/v1/users/me/verification
{
"role": "player",
"materials": {
"idCardFront": "https://cdn.juwan.com/uploads/xxx.jpg",
"idCardBack": "https://cdn.juwan.com/uploads/yyy.jpg",
"gameScreenshot": "https://cdn.juwan.com/uploads/zzz.jpg"
}
}
```
**请求示例:切换角色**
```json
// POST /api/v1/users/me/switch-role
{ "role": "player" }
```
**请求示例:更新通知偏好**
```json
// PUT /api/v1/users/me/preferences/notifications
{ "order": true, "community": true, "system": false }
```
---
## 5. 游戏数据
| 方法 | 路径 | 权限 | 说明 |
| ---- | ------------ | ---- | ----------------------- |
| GET | `/games` | | 获取游戏列表 (支持分页) |
| GET | `/games/:id` | | 获取指定游戏详情 |
---
## 6. 打手与服务
| 方法 | 路径 | 权限 | 说明 |
| ------ | ----------------------- | ---- | ------------------------------------------------- |
| GET | `/players` | | 获取打手列表 (支持分页、筛选) |
| GET | `/players/:id` | | 获取打手公开主页详情 |
| PUT | `/players/me/status` | 🔒 | 更新打手接单状态 (`available`, `busy`, `offline`) |
| GET | `/services` | | 获取所有服务列表 |
| GET | `/services/:id` | | 获取服务详情 |
| GET | `/players/:id/services` | | 获取指定打手的服务列表 |
| POST | `/services` | 🔒 | 创建服务 (仅 player) |
| PUT | `/services/:id` | 🔒 | 更新服务 (仅 player) |
| DELETE | `/services/:id` | 🔒 | 删除服务 (仅 player) |
---
## 7. 店铺系统
| 方法 | 路径 | 权限 | 说明 |
| ------ | ------------------------------- | ---- | --------------------------- |
| GET | `/shops` | | 获取店铺列表 |
| GET | `/shops/:id` | | 获取店铺公开主页详情 |
| GET | `/users/:id/shop` | | 获取指定店长的店铺 |
| POST | `/shops` | 🔒 | 创建店铺 (仅 owner) |
| PUT | `/shops/:id` | 🔒 | 更新店铺基础信息及规则 |
| PUT | `/shops/:id/template` | 🔒 | 更新店铺主页模板配置 |
| PUT | `/shops/:id/announcements` | 🔒 | 更新店铺公告 |
| POST | `/shops/:id/invitations` | 🔒 | 邀请打手加入店铺 |
| POST | `/shops/invitations/:id/accept` | 🔒 | 打手接受店铺邀请 |
| DELETE | `/shops/:id/players/:playerId` | 🔒 | 将打手移出店铺 |
| GET | `/shops/:id/income-stats` | 🔒 | 获取店铺收入统计 (仅 owner) |
| GET | `/shops/mine` | 🔒 | 获取当前登录店主的店铺详情 |
| GET | `/shops/:id/players` | | 获取店铺下的打手列表 |
| POST | `/shops/:id/announcements` | 🔒 | 新增店铺公告 |
| DELETE | `/shops/:id/announcements/:index` | 🔒 | 删除店铺公告 |
| DELETE | `/shops/invitations/:id` | 🔒 | 拒绝店铺邀请 (打手调用) |
**请求示例:更新店铺规则**
```json
// PUT /api/v1/shops/:id
{
"name": "星耀电竞工作室",
"description": "专业英雄联盟代练工作室",
"commissionType": "percentage",
"commissionValue": 15,
"allowMultiShop": false,
"allowIndependentOrders": true,
"dispatchMode": "auto"
}
```
**请求示例:更新店铺模板**
```json
// PUT /api/v1/shops/:id/template
{
"sections": [
{ "type": "banner", "enabled": true, "order": 0 },
{ "type": "intro", "enabled": true, "order": 1 },
{ "type": "services", "enabled": true, "order": 2 },
{ "type": "players", "enabled": true, "order": 3 },
{ "type": "announcements", "enabled": false, "order": 4 },
{ "type": "reviews", "enabled": true, "order": 5 }
]
}
```
**响应示例:店铺收入统计**
```json
// GET /api/v1/shops/:id/income-stats
{
"monthlyIncome": 12800.00,
"pendingSettlement": 3200.00,
"totalWithdrawn": 54000.00,
"totalOrders": 156,
"completedOrders": 142
}
```
---
## 8. 订单系统
### 8.1 订单状态机 (Order State Machine)
| 当前状态 | 触发动作 | 下一状态 | 允许的角色 | 副作用 |
| ----------------- | ----------------------------- | ---------------- | ---------------- | -------------------------------------------------- |
| `pending_payment` | `PAY` | `pending_accept` | consumer | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT |
| `pending_accept` | `ACCEPT` | `in_progress` | player, owner | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
| `pending_accept` | `CANCEL_PRE_ACCEPT` | `cancelled` | consumer | CLEAR_TIMEOUT |
| `pending_accept` | `AUTO_TIMEOUT_PENDING_ACCEPT` | `cancelled` | system | CLEAR_TIMEOUT |
| `in_progress` | `REQUEST_CLOSE` | `pending_close` | consumer, player | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
| `in_progress` | `OPEN_DISPUTE` | `disputed` | consumer, player | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
| `pending_close` | `CONFIRM_CLOSE` | `pending_review` | consumer, player | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
| `pending_close` | `OPEN_DISPUTE` | `disputed` | consumer, player | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
| `pending_close` | `AUTO_TIMEOUT_PENDING_CLOSE` | `pending_review` | system | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
| `pending_review` | `SUBMIT_REVIEW` | `completed` | consumer, player | CLEAR_TIMEOUT, PAYOUT_INCOME, SYNC_CHAT_SESSION |
| `pending_review` | `AUTO_TIMEOUT_PENDING_REVIEW` | `completed` | system | CLEAR_TIMEOUT, PAYOUT_INCOME, SYNC_CHAT_SESSION |
| `disputed` | `RESOLVE_DISPUTE` | `pending_review` | owner, admin | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
### 8.2 超时配置 (Timeout Configs)
后端需实现定时任务(如 Redis 延迟队列)来处理以下超时逻辑:
- `ORDER_ACCEPT_TIMEOUT_MS`: 待接单超时自动取消(默认 5 分钟)。
- `ORDER_CLOSE_TIMEOUT_MS`: 申请结算后对方未确认,超时自动进入待评价(默认 24 小时)。
- `ORDER_REVIEW_TIMEOUT_MS`: 待评价超时自动好评并完成订单(默认 72 小时)。
### 8.3 订单接口
| 方法 | 路径 | 权限 | 说明 |
| ---- | --------------------------- | ---- | ----------------------------------- |
| GET | `/orders` | 🔒 | 获取当前用户的订单列表 |
| GET | `/orders/:id` | 🔒 | 获取订单详情 |
| POST | `/orders` | 🔒 | 创建订单 (状态: `pending_payment`) |
| POST | `/orders/paid` | 🔒 | 创建并直接支付订单 (快捷下单) |
| POST | `/orders/:id/pay` | 🔒 | 支付订单 (`PAY`) |
| POST | `/orders/:id/accept` | 🔒 | 接单 (`ACCEPT`) |
| POST | `/orders/:id/request-close` | 🔒 | 申请结算 (`REQUEST_CLOSE`) |
| POST | `/orders/:id/confirm-close` | 🔒 | 确认结算 (`CONFIRM_CLOSE`) |
| POST | `/orders/:id/cancel` | 🔒 | 接单前取消 (`CANCEL_PRE_ACCEPT`) |
| POST | `/orders/:id/reorder` | 🔒 | 再来一单 (基于原订单快速创建新订单) |
`GET /orders` 支持查询参数:`role` (consumer/player/owner)、`status` (状态过滤)、`offset``limit`。后端根据 Token 中的 userId 和 role 参数自动过滤,不允许查看他人订单。
**请求示例:创建并支付订单 (快捷下单)**
```json
// POST /api/v1/orders/paid
{
"playerId": "p1",
"shopId": "shop1",
"serviceId": "svc1",
"quantity": 3,
"note": "希望晚上8点后开始"
}
```
**响应示例:订单创建成功**
```json
{
"order": {
"id": "ord-20240320-001",
"consumerId": "u1",
"consumerName": "张三",
"playerId": "p1",
"playerName": "小明",
"shopId": "shop1",
"shopName": "星耀电竞",
"service": { "id": "svc1", "title": "英雄联盟代练", "price": 50, "unit": "局" },
"status": "pending_accept",
"totalPrice": 150,
"createdAt": "2024-03-20T12:00:00.000Z"
}
}
```
**请求示例:再来一单**
```json
// POST /api/v1/orders/:id/reorder
// 无请求体,后端基于原订单的 playerId、serviceId、shopId 创建新订单
```
---
## 9. 争议仲裁
| 方法 | 路径 | 权限 | 说明 |
| ---- | ------------------------ | ---- | ------------------------- |
| GET | `/disputes` | 🔒 | 获取当前用户的争议列表 |
| GET | `/orders/:id/dispute` | 🔒 | 获取指定订单的争议详情 |
| POST | `/orders/:id/dispute` | 🔒 | 发起争议 (`OPEN_DISPUTE`) |
| POST | `/disputes/:id/response` | 🔒 | 被诉方提交回应及证据 |
| POST | `/disputes/:id/appeal` | 🔒 | 对仲裁结果发起申诉 |
**争议处理时间线 (Timeline)**:
- `created`: 争议发起
- `response`: 被诉方回应
- `reviewing`: 店长/平台介入审查
- `resolved`: 给出仲裁结果
- `appealed`: 发起申诉,转交平台管理员
**请求示例:发起争议**
```json
// POST /api/v1/orders/:id/dispute
{
"reason": "打手未按约定时间上线",
"evidence": [
"https://cdn.juwan.com/uploads/evidence1.jpg",
"https://cdn.juwan.com/uploads/evidence2.jpg"
]
}
```
**请求示例:被诉方回应**
```json
// POST /api/v1/disputes/:id/response
{
"reason": "当时网络故障,已与客户沟通并补时",
"evidence": ["https://cdn.juwan.com/uploads/response1.jpg"]
}
```
**请求示例:申诉**
```json
// POST /api/v1/disputes/:id/appeal
{
"reason": "仲裁结果不合理,请求平台复核"
}
```
---
## 10. 评价系统
**密封机制 (Sealed Mechanics)**:
评价提交后默认为 `sealed: true`。只有当双方都完成评价,或者评价超时(`ORDER_REVIEW_TIMEOUT_MS`)后,评价才会解封并公开显示。
| 方法 | 路径 | 权限 | 说明 |
| ---- | --------------------- | ---- | ------------------------------------ |
| POST | `/orders/:id/review` | 🔒 | 提交评价 (`SUBMIT_REVIEW`) |
| GET | `/reviews` | | 获取公开评价列表 |
| GET | `/orders/:id/reviews` | 🔒 | 获取指定订单的评价(受密封机制限制) |
| GET | `/users/:id/reviews` | | 获取指定用户收到的评价 |
---
## 11. 聊天系统
### 11.1 会话类型
- **`order`**: 订单会话。随订单创建而建立,订单完成后变为只读。
- **`consultation`**: 咨询会话。用户在下单前与打手沟通。24小时无回复自动关闭。可升级为订单会话。
| 方法 | 路径 | 权限 | 说明 |
| ---- | ----------------------------- | ---- | ----------------------------------- |
| GET | `/chat/sessions` | 🔒 | 获取当前用户的会话列表 |
| GET | `/chat/sessions/:id` | 🔒 | 获取会话详情 |
| GET | `/chat/sessions/:id/messages` | 🔒 | 获取会话历史消息 (分页) |
| POST | `/chat/sessions/order` | 🔒 | 确保订单会话存在 (不存在则创建) |
| POST | `/chat/sessions/consultation` | 🔒 | 创建咨询会话 |
| POST | `/chat/sessions/:id/upgrade` | 🔒 | 将咨询会话升级为订单会话 |
| POST | `/chat/sessions/:id/messages` | 🔒 | 发送消息 (文本/图片) |
| GET | `/shops/:id/chat-sessions` | 🔒 | 店长查看员工的业务会话 (需员工同意) |
---
## 12. 社区系统
| 方法 | 路径 | 权限 | 说明 |
| ------ | --------------------- | ---- | ------------------------------------- |
| GET | `/posts` | | 获取帖子列表 (支持分页、标签筛选) |
| GET | `/posts/:id` | | 获取帖子详情 |
| POST | `/posts` | 🔒 | 发布帖子 (支持普通帖、秀单帖、引用帖) |
| POST | `/posts/:id/like` | 🔒 | 点赞帖子 |
| DELETE | `/posts/:id/like` | 🔒 | 取消点赞帖子 |
| POST | `/posts/:id/pin` | 🔒 | 作者置顶帖子 (最多 N 条) |
| DELETE | `/posts/:id/pin` | 🔒 | 取消置顶 |
| GET | `/posts/:id/comments` | | 获取帖子评论列表 |
| POST | `/posts/:id/comments` | 🔒 | 发表评论 |
| POST | `/comments/:id/like` | 🔒 | 点赞评论 |
| DELETE | `/comments/:id/like` | 🔒 | 取消点赞评论 |
| GET | `/users/:id/posts` | | 获取指定用户的帖子列表 |
`GET /posts` 支持查询参数:`tags` (标签过滤)、`gameId` (游戏过滤)、`sortBy` (new/hot)、`offset``limit`
**请求示例:发布秀单帖**
```json
// POST /api/v1/posts
{
"title": "超棒的代练体验",
"content": "从银到铂金,只用了三天!",
"images": ["https://cdn.juwan.com/uploads/post1.jpg"],
"tags": ["英雄联盟", "代练"],
"linkedOrderId": "ord-20240320-001"
}
```
---
## 13. 收藏与关注
| 方法 | 路径 | 权限 | 说明 |
| ------ | ---------------------------- | ---- | ---------------------------------- |
| GET | `/favorites` | 🔒 | 获取当前用户的收藏列表 (打手/店铺) |
| POST | `/favorites` | 🔒 | 添加收藏 |
| DELETE | `/favorites/:id` | 🔒 | 取消收藏 |
| GET | `/users/:id/favorites/check` | 🔒 | 检查是否已收藏指定目标 |
**请求示例:添加收藏**
```json
// POST /api/v1/favorites
{ "targetType": "player", "targetId": "p1" }
```
**检查收藏状态**
```
GET /api/v1/users/u1/favorites/check?targetType=player&targetId=p1
→ { "favorited": true }
```
---
## 14. 搜索与发现
| 方法 | 路径 | 权限 | 说明 |
| ---- | --------- | ---- | ------------ |
| GET | `/search` | | 统一搜索接口 |
**请求参数**:
- `q`: 搜索关键词
- `selectedGames`: 游戏 ID 数组
- `min`, `max`: 价格区间
- `onlyOnline`: 是否仅看在线 (`true`/`false`)
- `minRating`: 最低评分
- `sort`: 排序方式 (`composite`, `rating`, `orders`, `price_asc`, `price_desc`)
- `offset`, `limit`: 分页参数
| GET | `/recommendations/home` | | 首页推荐信息流 (混合打手与店铺卡片) |
**响应示例:搜索结果**
```json
{
"items": [
{
"type": "player",
"player": { "id": "p1", "user": {...}, "rating": 4.8, "status": "available", ... },
"minPrice": 30,
"unit": "局",
"rating": 4.8,
"orders": 256
},
{
"type": "shop",
"shop": { "id": "shop1", "name": "星耀电竞", ... },
"minPrice": 25,
"unit": "局",
"rating": 4.6,
"orders": 1024,
"games": ["英雄联盟", "CS2"],
"hasAvailable": true
}
],
"meta": { "total": 42, "offset": 0, "limit": 12 }
}
```
---
## 15. 钱包与资金
### 15.1 收入计算公式 (Income Calculation)
订单完成后,系统根据店铺规则计算打手实际收入:
- **无店铺 (独立接单)**: `income = totalPrice`
- **比例抽成 (percentage)**: `income = totalPrice * (1 - commissionValue / 100)`
- **固定抽成 (fixed)**: `income = Math.max(0, totalPrice - commissionValue)`
剩余部分作为店铺收入(若有店铺)。
### 15.2 钱包接口
| 方法 | 路径 | 权限 | 说明 |
| ---- | ---------------------- | ---- | ------------------------------------- |
| GET | `/wallet/balance` | 🔒 | 获取当前余额 |
| GET | `/wallet/transactions` | 🔒 | 获取资金流水 (包含结构化的 `orderId`) |
| POST | `/wallet/topup` | 🔒 | 充值 |
| POST | `/wallet/withdraw` | 🔒 | 提现 |
---
## 16. 通知系统
| 方法 | 路径 | 权限 | 说明 |
| ---- | ---------------------------------- | ---- | ------------------ |
| GET | `/notifications` | 🔒 | 获取通知列表 |
| PUT | `/notifications/:id/read` | 🔒 | 标记单条通知为已读 |
| PUT | `/notifications/read-all` | 🔒 | 标记所有通知为已读 |
| POST | `/notifications/push-subscription` | 🔒 | 订阅 Web Push 推送 |
---
## 17. 管理后台 (Admin)
| 方法 | 路径 | 权限 | 说明 |
| ---- | ---------------------------------- | ---- | -------------------------- |
| GET | `/admin/verifications` | 🛡️ | 获取待审核的认证申请 |
| POST | `/admin/verifications/:id/approve` | 🛡️ | 批准认证申请 |
| POST | `/admin/verifications/:id/reject` | 🛡️ | 拒绝认证申请 |
| GET | `/admin/disputes` | 🛡️ | 获取需要平台介入的争议列表 |
| POST | `/admin/disputes/:id/resolve` | 🛡️ | 平台管理员给出最终仲裁结果 |
---
## 18. WebSocket 事件
客户端连接到 `wss://api.juwan.com/api/v1/ws`,通过 JWT 鉴权。
**下发事件类型**:
- `chat:message`: 收到新聊天消息。
- `order:status_changed`: 订单状态变更(触发前端重新拉取订单详情)。
- `notification:new`: 收到新系统通知。
- `dispute:updated`: 争议状态或时间线更新。
- `wallet:balance_changed`: 余额变动通知。
---
## 19. 安全与校验清单
1. **数据隔离 (Data Isolation)**:
- 用户只能查询自己的订单、钱包流水、私聊会话。
- 店长只能查询本店铺的订单和员工数据。
2. **并发控制 (Concurrency Control)**:
- 订单状态流转必须使用乐观锁或数据库事务,防止并发导致状态机错乱。
- 钱包扣款必须保证原子性,防止超扣。
3. **权限校验 (Authorization)**:
- 严格校验 `Actor``role`。例如,只有 `player` 才能创建服务,只有 `owner` 才能修改店铺规则。
- 订单操作必须校验操作者是否为该订单的 `consumerId``playerId` 或关联的 `shopId` 的店长。
4. **幂等性 (Idempotency)**:
- 支付、接单、结算等核心操作必须保证幂等性,重复请求返回 `IDEMPOTENT_NOOP`
-344
View File
@@ -1,344 +0,0 @@
# 隐蔽未实现接口与逻辑清单
本报告聚焦于"看起来已实现、实际未接通或存在断层"的功能点。这些问题在 mock 演示阶段不易察觉,切换真实后端后会表现为数据丢失、页面空态、交互无效等事故。
与第一份报告(《静态模拟数据残留审计报告》)互补,本报告不再重复 mock 数据源、store 初始化、伪 API 层等已知问题。
---
## 一、登录态与持久化缺失
### 1.1 记住登录状态 — 纯展示控件
登录页渲染了"记住登录状态"复选框,但该控件未被表单注册,无任何读取或写入逻辑。
| 位置 | 内容 |
| ------------------------------ | -------------------------------------------------------------------------- |
| `app/(auth)/login/page.tsx:86` | `<Checkbox id="remember" />` — 未绑定 `register("remember")``useState` |
### 1.2 登录态无持久化 — 刷新即丢
全仓库未发现 `localStorage``sessionStorage``cookie` 写入,也未使用 `zustand/middleware``persist``store/auth.ts``login()` 仅写内存态,页面刷新后回到未登录状态。
**影响**:切真实后端后,如果前端仍依赖 Zustand 内存态判断登录,刷新会导致用户被踢出。
---
## 二、身份与实体模型错位
### 2.1 当前用户与店铺/打手 ID 不匹配 — 后台大面积空态
登录固定为 `mockUsers[0]`id=`u1`),但 mock 数据中的店铺 owner 是 `u10`/`u11`/`u12`,打手是 `u5`/`u6` 起。
| 位置 | 内容 |
| -------------------------------------- | ---------------------------------------------- |
| `lib/mock/users.ts:119` | `currentUser = mockUsers[0]` — 固定 `u1` |
| `lib/mock/shops.ts:7` 起 | 店铺 owner 为 `u10``u11``u12` |
| `lib/mock/players.ts:7` 起 | 打手 user 从 `u5` 开始 |
| `lib/domain/resolve-current-shop.ts:5` | `shops.find(shop => shop.owner.id === userId)` |
**结果**:用户切换到店主身份后,`resolveOwnerShop` 始终返回 `null`,以下页面全部显示"当前账号没有可管理的店铺":
- `app/(dashboard)/dashboard/shop/employees/page.tsx:67`
- `app/(dashboard)/dashboard/shop/rules/page.tsx:29`
- `app/(dashboard)/dashboard/shop/income/page.tsx:28`
- `app/(dashboard)/dashboard/shop/page.tsx` 同理
- `app/(dashboard)/dashboard/shop/templates/page.tsx` 同理
- `app/(dashboard)/dashboard/shop/orders/page.tsx` 同理
### 2.2 打手主页链接指向不存在的打手
导航栏在 player 身份下跳转 `/player/${user.id}``components/header.tsx:88`),但 `user.id``u1``mockPlayers` 中没有 id 为 `u1` 的打手,会触发 `notFound()``app/(main)/player/[id]/page.tsx:25`)。
---
## 三、用户动作无持久化 — 刷新即回退
以下交互在前端有即时反馈,但数据仅存在于 Zustand 内存态,刷新页面或换设备后全部丢失。
### 3.1 点赞
| 位置 | 行为 |
| --------------------------------------- | --------------------------------------------------------------------- |
| `components/post-like-button.tsx:21-32` | 调用 `togglePostLike``store/posts.ts:50-61` 本地翻转 `liked` 并 ±1 |
### 3.2 评论
| 位置 | 行为 |
| ------------------------------------- | ------------------------------------------------ |
| `components/post-comments.tsx:29-52` | 调用 `addComment``store/comments.ts` 本地追加 |
| `components/post-comment-count.tsx:3` | 读 `useCommentStore` 本地计数 |
### 3.3 收藏
| 位置 | 行为 |
| -------------------------------------- | ----------------------------------------------------- |
| `components/favorite-button.tsx:29-33` | 调用 `toggleFavorite``store/favorites.ts` 本地增删 |
### 3.4 通知已读
| 位置 | 行为 |
| -------------------------------------- | ----------------------------------------------------------------------- |
| `app/(account)/notifications/page.tsx` | 调用 `markAsRead` / `markAllAsRead``store/notifications.ts` 本地标记 |
| `components/header.tsx:81-83` | 未读数通过 `.filter(n => !n.read).length` 本地计算 |
### 3.5 设置保存
| 位置 | 行为 |
| --------------------------------- | ---------------------------------------------------- |
| `app/(account)/settings/page.tsx` | 昵称、简介、通知偏好等修改仅写 `useAuthStore` 内存态 |
---
## 四、上传功能 — 占位或 Object URL
所有"上传"操作要么是纯占位 UI,要么使用 `URL.createObjectURL` 生成本地临时链接,刷新后失效。
### 4.1 头像上传
| 位置 | 行为 |
| ------------------------------------ | ------------------------------------------------------------- |
| `app/(account)/settings/page.tsx:75` | `setAvatar(URL.createObjectURL(file))` — 本地预览,无上传请求 |
### 4.2 聊天图片发送
| 位置 | 行为 |
| ------------------------------------ | --------------------------------------------------------------------------------- |
| `app/(order)/chat/[id]/page.tsx:152` | `sendImageMessage(session.id, URL.createObjectURL(file))` — blob URL 作为消息内容 |
### 4.3 争议证据上传
| 位置 | 行为 |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `app/(order)/dispute/[id]/page.tsx:88` | `URL.createObjectURL(file)` 生成预览,提交时传入 store |
| `app/(order)/dispute/[id]/page.tsx:102` | `URL.revokeObjectURL` 移除时释放 — 说明开发者意识到了临时性,但未替换为真实上传 |
### 4.4 身份认证证明材料 — 纯占位
| 位置 | 行为 |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `app/(account)/verify/page.tsx:170-181` | 三个 `<div>` 占位块(身份证正面/反面/游戏截图),有 `cursor-pointer` 样式但无 `<input type="file">`、无 `onClick`、无状态绑定 |
### 4.5 发帖图片 — 假计数 + 固定路径
| 位置 | 行为 |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `app/(main)/post/new/page.tsx:46` | `imageCount` 状态仅做数字加减 |
| `app/(main)/post/new/page.tsx:84` | 提交时 `images: Array.from({ length: imageCount }).map(() => "/posts/p1-1.jpg")` — 无论"上传"几张,全部指向同一张固定图片 |
---
## 五、客户端与服务端数据隔离
部分页面是 Server Component(或在服务端执行的函数),通过 `lib/api/*` 读取数据;而写入操作发生在客户端 Zustand store。在 Next.js 的 SSR/RSC 模式下,服务端无法读取客户端 store 的最新状态。
### 5.1 发帖 → 帖子详情
| 写入 | 读取 |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `app/(main)/post/new/page.tsx:79``store/posts.ts:44` (client) | `app/(main)/post/[id]/page.tsx:17``lib/api/posts.ts:10``usePostStore.getState()` (server?) |
### 5.2 店铺模板保存 → 店铺主页
| 写入 | 读取 |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `app/(dashboard)/dashboard/shop/templates/page.tsx:120``store/shops.ts` (client) | `app/(main)/shop/[id]/page.tsx:29``shop.templateConfig.sections` (server) |
### 5.3 服务发布 → 打手详情页
| 写入 | 读取 |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `app/(dashboard)/dashboard/services/new/page.tsx:132``store/services.ts` (client) | `app/(main)/player/[id]/page.tsx:29-32` — 优先读 `player.services`(mock 内嵌数据),仅当为空时才 fallback 到 `listServicesByPlayer` |
**特别注意**:打手详情页的 `player.services && player.services.length > 0` 判断(`app/(main)/player/[id]/page.tsx:30`)意味着只要 mock 数据中打手自带了 services,新发布的服务就永远不会显示。这是一个数据遮蔽问题,不仅仅是隔离问题。
---
## 六、纯前端筛选/排序/统计
以下逻辑在前端内存中对全量数据做 filter/sort/slicemock 阶段数据量小时体验正常,接后端引入分页、权限、跨端一致性后会出现偏差。
### 6.1 社区列表 — 内存排序与筛选
| 位置 | 行为 |
| ------------------------------------- | ---------------------------------------------------------- |
| `app/(main)/community/page.tsx:22-34` | 全量 `posts.filter().sort()` 实现"最新/最热"和游戏标签筛选 |
### 6.2 订单列表 — 内存角色过滤 + Tab 过滤
| 位置 | 行为 |
| ------------------------------------ | ----------------------------------------------------------------- |
| `app/(order)/orders/page.tsx:91-103` | 先按 `consumerId/playerId/shopId` 过滤角色视角,再按 tab 过滤状态 |
### 6.3 管理后台概览 — 硬取首项 + 截断
| 位置 | 行为 |
| --------------------------------------- | --------------------------------------------------------- |
| `app/(dashboard)/dashboard/page.tsx:17` | `listPlayers()[0]` — 始终取第一个打手的数据作为"我的"数据 |
| `app/(dashboard)/dashboard/page.tsx:18` | `listShops()[0]` — 始终取第一个店铺 |
| `app/(dashboard)/dashboard/page.tsx:19` | `listOrders().slice(0, 3)` — 最近订单截前 3 条 |
### 6.4 首页推荐 — 全量渲染
| 位置 | 行为 |
| --------------------------- | ---------------------------------------------------------------------- |
| `app/(main)/page.tsx:12-14` | `listPlayers()` / `listShops()` 全量获取后直接渲染,无推荐算法、无分页 |
### 6.5 收入统计 — 正则匹配交易描述关联订单
| 位置 | 行为 |
| ------------------------------------------------------ | -------------------------------------------------------------------- |
| `app/(dashboard)/dashboard/shop/income/page.tsx:51-53` | `transaction.description.match(/ord[-\d]+/)` 从描述文本中提取订单 ID |
**风险**:后端描述格式变化时,统计数据会静默丢失,不报错。应由后端提供结构化的 `orderId` 字段。
---
## 七、消息列表未按用户过滤
| 位置 | 行为 |
| -------------------------------------- | ----------------------------------------------------------- |
| `app/(order)/chat/page.tsx:12` | `sessions` 直接全量渲染,未过滤当前用户是否为参与者 |
| `app/(order)/chat/page.tsx:21-23` | 仅用 `participant.id !== userId` 找"对方",但不排除无关会话 |
| `app/(order)/chat/[id]/page.tsx:52-59` | 会话详情页才做参与者校验 |
**结果**:列表页会展示当前用户不参与的会话,点进去才提示无权查看。
---
## 八、店铺规则 — 可保存但不执行
### 8.1 规则字段仅做展示
| 字段 | 保存位置 | 执行位置 |
| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------- |
| `allowMultiShop` | `app/(dashboard)/dashboard/shop/rules/page.tsx:50``updateShop` | 无 — 未发现任何校验逻辑 |
| `allowIndependentOrders` | 同上 | 无 — 未发现任何校验逻辑 |
| `dispatchMode` | 同上 | `store/orders.ts:407` — 仅影响前端自动派单模拟 |
| `commissionType` / `commissionValue` | 同上 | `lib/domain/income.ts:22-31` — 仅影响前端收入计算展示 |
### 8.2 员工邀请 — 无校验直接重绑定
| 位置 | 行为 |
| --------------------------------------------------------- | --------------------------------------------------------- |
| `app/(dashboard)/dashboard/shop/employees/page.tsx:74-79` | 点击"邀请打手"直接调用 `assignToShop` + `playerCount + 1` |
| `store/players.ts:13-17` | `assignToShop` 仅修改 `shopId`/`shopName` 字段,无校验 |
**缺失**:未检查打手是否已属于其他店铺、是否符合 `allowMultiShop` 规则、是否需要打手同意。
### 8.3 公告编辑 — `window.prompt` 无审计
| 位置 | 行为 |
| --------------------------------------------- | ------------------------------------------ |
| `app/(dashboard)/dashboard/shop/page.tsx:157` | `window.prompt("", announcement)` 编辑公告 |
| `app/(dashboard)/dashboard/shop/page.tsx:175` | `window.prompt("", "")` 新增公告 |
---
## 九、社区列表的点赞/评论入口缺失
| 位置 | 行为 |
| --------------------------------------- | -------------------------------------------------------------- |
| `app/(main)/community/page.tsx:145-154` | 点赞和评论图标是 `<span>` 内的纯展示元素,无 `onClick`,无链接 |
帖子详情页(`components/post-like-button.tsx``components/post-comments.tsx`)有完整的点赞和评论交互,但社区列表页的卡片上这些图标仅做数字展示,用户无法在列表页直接操作。
---
## 十、置顶/精选 — 有字段和展示,无操作入口
| 位置 | 内容 |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `lib/types.ts:177` | `Post` 类型定义 `pinned: boolean` |
| `lib/mock/posts.ts:17` 等 | mock 帖子中有 `pinned: true` |
| `app/(main)/community/page.tsx:104` | 展示 `<Pin>` 图标 |
| `store/posts.ts:40` | `createPost` 强制 `pinned: false` |
| `store/posts.ts:17-22` | store 接口仅有 `createPost` / `togglePostLike` / `incrementCommentCount`,无 pin/unpin 方法 |
`PLAN.md:141` 规划了"用户自己置顶,最多 N 条",但目前无任何操作路径可以改变 `pinned` 状态。
---
## 十一、关注与推送 — 文案存在,实现缺席
### 11.1 关注
| 位置 | 内容 |
| ------------------------------------- | ------------------------------- |
| `PLAN.md:9` | "消费者可以收藏/关注打手或店铺" |
| `app/(account)/settings/page.tsx:234` | 通知偏好文案"点赞、评论、关注" |
全仓库未发现 `follow`/`关注` 相关的 store、api、页面动作。
### 11.2 浏览器推送
| 位置 | 内容 |
| ------------- | ------------------------------- |
| `PLAN.md:216` | "站内通知 + 用户可选浏览器推送" |
全仓库未发现 `Notification.requestPermission``serviceWorker``PushSubscription``pushManager` 等 Web Push 相关代码。现有通知体系仅为本地生成 + 本地已读。
---
## 十二、硬编码展示值
这些数值直接写在 JSX 中,不来自任何 store 或 API 计算。
| 位置 | 内容 |
| --------------------------------------- | -------------------------------------------------------- |
| `app/(account)/wallet/page.tsx:154` | `¥1,280.00`(本月收入) |
| `app/(account)/wallet/page.tsx:158` | `¥320.00`(待结算) |
| `app/(account)/wallet/page.tsx:162` | `¥5,400.00`(已提现) |
| `app/(dashboard)/dashboard/page.tsx:79` | `¥12,800`(店主本月收入) |
| `app/(auth)/layout.tsx:4-8` | `12,000+` 认证打手 / `98.6%` 好评率 / `50,000+` 完成订单 |
| `app/(order)/dispute/[id]/page.tsx:377` | UI 文案含"模拟处理结果"字样 |
---
## 十三、未使用的基础设施
### 13.1 `requestWithAuth` — 定义未调用
| 位置 | 内容 |
| --------------------- | ----------------------------------------------- |
| `lib/api/client.ts:9` | 定义了 `requestWithAuth<T>(executor, options?)` |
| 全仓库 | `rg "requestWithAuth("` 命中 0 处调用 |
### 13.2 `usePlayerStatusStore` — 定义未调用
| 位置 | 内容 |
| --------------------------- | ----------------------------------------------------------- |
| `store/player-status.ts:11` | 定义了 `usePlayerStatusStore`,含 `statuses``setStatus` |
| 全仓库 | `rg "usePlayerStatusStore"` 仅命中定义处 |
`PLAN.md:107` 规划了"打手有并发接单上限,搜索结果和打手详情页展示'可接单/忙碌'状态",该 store 疑似为此功能的未完成基础设施。
---
## 迁移优先级建议
### P0 — 上线前必须解决(数据安全/业务正确性)
1. 登录态持久化(刷新丢失 = 用户无法正常使用)
2. 身份与实体 ID 对齐(否则后台全部空态)
3. 所有用户写入动作接入后端持久化(点赞/评论/收藏/设置/通知已读)
4. 上传功能替换为真实文件上传(头像/聊天图片/争议证据/认证材料/发帖图片)
5. 移除 UI 中的"模拟"字样(`dispute/[id]/page.tsx:377`
### P1 — 切后端时必须改造(数据一致性)
6. 消息列表按当前用户过滤会话
7. 筛选/排序/统计改为后端分页查询(社区/订单/首页推荐/收入统计)
8. 打手详情页移除 `player.services` 优先读取逻辑,统一从服务列表查询
9. 店铺规则执行逻辑落地(`allowMultiShop`/`allowIndependentOrders` 校验)
10. 员工邀请增加校验流程
11. 收入统计改用结构化字段关联订单
### P2 — 功能补齐或明确下线
12. 记住登录状态功能实现或移除控件
13. 置顶/精选操作入口
14. 关注功能
15. 浏览器推送
16. 打手在线状态(`usePlayerStatusStore` 接入或移除)
17. 公告编辑替换 `window.prompt` 为正式表单
-260
View File
@@ -1,260 +0,0 @@
# 静态模拟数据残留审计报告
## 总体结论
项目当前没有任何真实后端 HTTP 请求。全仓库仅 1 处 `fetch(``lib/api/search.ts:34`),请求的是本地 Next.js Route `/api/search`,而该 Route 本身也以 mock 数据为源。`@tanstack/react-query``QueryClientProvider` 已挂载(`app/providers.tsx`),但全仓库 0 处 `useQuery`/`useMutation` 调用。无 `.env` 文件,无 mock/real 环境切换开关。
---
## 一、静态实体数据源 `lib/mock/*.ts`
15 个文件,定义了全部业务实体的硬编码数组,是整个模拟体系的根:
| 文件 | 导出 | 实体类型 |
| ------------------------------ | --------------------------------------- | ------------ |
| `lib/mock/users.ts:3` | `mockUsers: User[]` | 用户 |
| `lib/mock/users.ts:119` | `currentUser = mockUsers[0]` | 当前登录用户 |
| `lib/mock/games.ts:3` | `mockGames: Game[]` | 游戏 |
| `lib/mock/services.ts:3` | `mockServices: PlayerService[]` | 陪玩服务 |
| `lib/mock/players.ts:5` | `mockPlayers: Player[]` | 打手 |
| `lib/mock/shops.ts:4` | `mockShops: Shop[]` | 店铺 |
| `lib/mock/orders.ts:4` | `mockOrders: Order[]` | 订单 |
| `lib/mock/disputes.ts:3` | `mockDisputes: Dispute[]` | 争议 |
| `lib/mock/reviews.ts:3` | `mockReviews: Review[]` | 评价 |
| `lib/mock/posts.ts:4` | `mockPosts: Post[]` | 帖子 |
| `lib/mock/comments.ts:4` | `mockComments: Comment[]` | 评论 |
| `lib/mock/chat.ts:3` | `mockChatSessions: ChatSession[]` | 聊天会话 |
| `lib/mock/chat.ts:95` | `mockChatMessages: ChatMessage[]` | 聊天消息 |
| `lib/mock/favorites.ts:3` | `mockFavorites: Favorite[]` | 收藏 |
| `lib/mock/notifications.ts:3` | `mockNotifications: Notification[]` | 通知 |
| `lib/mock/transactions.ts:3` | `mockTransactions: WalletTransaction[]` | 交易流水 |
| `lib/mock/transactions.ts:139` | `walletBalance = 275` | 钱包余额 |
聚合出口:`lib/mock/index.ts`
---
## 二、Store 层 — mock 初始化 + 前端本地状态机
12 个 Zustand store 全部以 mock 数据初始化,运行时在前端内存中增删改查并生成 ID/时间戳:
| Store 文件 | 初始化行 | mock 来源 |
| --------------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
| `store/shops.ts:14` | `shops: mockShops` | `mockShops` |
| `store/players.ts:12` | `players: mockPlayers` | `mockPlayers` |
| `store/services.ts:14` | `services: mockServices` | `mockServices` |
| `store/orders.ts:315` | `orders: mockOrders` | `mockOrders` |
| `store/disputes.ts:203` | `disputes: mockDisputes.map(asRecord)` | `mockDisputes` |
| `store/reviews.ts:51` | `reviews: mockReviews` | `mockReviews` + `mockUsers`:24 |
| `store/posts.ts:25` | `posts: mockPosts` | `mockPosts` |
| `store/comments.ts:13` | `comments: mockComments` | `mockComments` |
| `store/chat.ts:23-24` | `sessions: mockChatSessions, messages: mockChatMessages` | `mockChatSessions` + `mockChatMessages` + `mockUsers`:15 |
| `store/favorites.ts:14` | `favorites: mockFavorites` | `mockFavorites` |
| `store/notifications.ts:21` | `notifications: mockNotifications` | `mockNotifications` |
| `store/wallet.ts:20-21` | `balance: walletBalance, transactions: mockTransactions` | `walletBalance` + `mockTransactions` |
所有 store 还通过 `generateId()` 在前端本地生成实体 ID`store/orders.ts:318,350``store/services.ts:21``store/wallet.ts:29,45,71,102,132``store/disputes.ts:231` 等),切后端后 ID 应由服务端分配。
---
## 三、伪 API 层 `lib/api/*.ts`
15 个模块名义上是 API 层,实际全部是同步读写本地 store 或直接返回 mock:
| 文件 | 实际行为 | 风险 |
| ---------------------------- | -------------------------------------------------- | --------------------------- |
| `lib/api/users.ts:1,4,8,12` | 直接 `import { currentUser, mockUsers }` 并 return | 高 — 直接返回 mock |
| `lib/api/games.ts:1,4,8` | 直接 `import { mockGames }` 并 return | 高 — 直接返回 mock |
| `lib/api/orders.ts:11` | `useOrderStore.getState().orders` | 中 — 读本地 store |
| `lib/api/services.ts:4` | `useServiceStore.getState().services` | 中 — 读本地 store |
| `lib/api/players.ts:4` | `usePlayerStore.getState().players` | 中 — 读本地 store |
| `lib/api/shops.ts:4` | `useShopStore.getState().shops` | 中 — 读本地 store |
| `lib/api/posts.ts:7` | `usePostStore.getState().posts` | 中 — 读写本地 store |
| `lib/api/comments.ts:8` | `useCommentStore.getState().comments` | 中 — 读写本地 store |
| `lib/api/chat.ts:6` | `useChatStore.getState().sessions` | 中 — 读写本地 store |
| `lib/api/favorites.ts:4` | `useFavoriteStore.getState().favorites` | 中 — 读本地 store |
| `lib/api/notifications.ts:7` | `useNotificationStore.getState().notifications` | 中 — 读写本地 store |
| `lib/api/transactions.ts:4` | `useWalletStore.getState().transactions` | 中 — 读本地 store |
| `lib/api/reviews.ts:6` | `useReviewStore.getState().reviews` | 中 — 读写本地 store |
| `lib/api/disputes.ts:6` | `useDisputeStore.getState().disputes` | 中 — 读写本地 store |
| `lib/api/search.ts:34` | `fetch('/api/search?...')` | 唯一 fetch,但后端仍是 mock |
| `lib/api/client.ts:9` | `requestWithAuth` 仅包装执行器做未登录拦截 | 无网络请求 |
---
## 四、唯一 HTTP 链路 — 搜索
```
lib/api/search.ts:34 → fetch(`/api/search?${params}`)
app/api/search/route.ts:1 → import { mockPlayers, mockServices, mockShops } from "@/lib/mock"
app/api/search/route.ts:51 → players: mockPlayers, shops: mockShops, services: mockServices
```
搜索是项目里唯一走了 HTTP 请求的链路,但 Next.js Route Handler 的数据源仍然是 mock。
---
## 五、前端状态自动推进(Demo 定时器)
这是切后端时事故概率最高的部分 — 前端 `setTimeout` 自动推进业务状态,真实后端应由服务端事件驱动。
**定时常量:**
```
lib/config/demo-timers.ts:1 ORDER_ACCEPT_TIMEOUT_MS = 30_000
lib/config/demo-timers.ts:2 ORDER_CLOSE_TIMEOUT_MS = 30_000
lib/config/demo-timers.ts:3 ORDER_REVIEW_TIMEOUT_MS = 30_000
lib/config/demo-timers.ts:5 DISPUTE_TO_REVIEWING_MS = 5_000
lib/config/demo-timers.ts:6 DISPUTE_TO_RESOLVED_MS = 10_000
```
**订单自动流转:**
| 位置 | 行为 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `store/orders.ts:172-206` | `scheduleOrderTimeout` — 订单进入 pending_accept/pending_close/pending_review 后启动 setTimeout 自动超时流转 |
| `store/orders.ts:405-413` | 自动派单模拟 — 当店铺 `dispatchMode === "auto"` 时,`setTimeout(3000)` 自动调用 `acceptOrder` |
**争议自动推进:**
| 位置 | 行为 |
| --------------------------- | ----------------------------------------------------------------------------------- |
| `store/disputes.ts:142-163` | `setTimeout(DISPUTE_TO_REVIEWING_MS)` — 自动将争议从 open → reviewing |
| `store/disputes.ts:165-197` | `setTimeout(DISPUTE_TO_RESOLVED_MS)` — 自动将争议从 reviewing → resolved 并回写订单 |
---
## 六、认证/表单流程模拟
| 位置 | 行为 |
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `app/(auth)/login/page.tsx:35-36` | `await new Promise(r => setTimeout(r, 500))` + `login(getCurrentUserForLogin(), ...)` |
| `app/(auth)/register/page.tsx:50-51` | 同上 |
| `components/login-dialog.tsx:44-45` | 同上 |
| `app/(auth)/forgot-password/page.tsx:27` | `await new Promise(r => setTimeout(r, 500))` + toast |
| `app/(account)/verify/page.tsx:41-52` | `submitWithMockApproval` — 提交认证后 `setTimeout(3000)` 自动调用 `approveVerification` |
`getCurrentUserForLogin()` 最终来自 `lib/api/users.ts:12``lib/mock/users.ts:119``currentUser = mockUsers[0]`,即无论输入什么账号密码,登录的永远是同一个硬编码用户。
---
## 七、硬编码展示值
| 位置 | 内容 |
| --------------------------------------- | -------------------------------------------------------------------------- |
| `app/(account)/wallet/page.tsx:154` | `¥1,280.00`(本月收入,写死) |
| `app/(account)/wallet/page.tsx:158` | `¥320.00`(待结算,写死) |
| `app/(account)/wallet/page.tsx:162` | `¥5,400.00`(已提现,写死) |
| `app/(auth)/layout.tsx:4-8` | `12,000+` 认证打手 / `98.6%` 好评率 / `50,000+` 完成订单(营销数字,写死) |
| `app/(order)/dispute/[id]/page.tsx:377` | UI 文案含"模拟处理结果"字样 |
---
## 八、页面数据源分类(34 个页面)
**Store Only14 个)— 完全不经过 lib/api,直接读写 store**
```
app/(account)/notifications/page.tsx
app/(account)/settings/page.tsx
app/(account)/verify/page.tsx [含 setTimeout 模拟]
app/(account)/wallet/page.tsx
app/(dashboard)/dashboard/services/page.tsx
app/(dashboard)/dashboard/shop/employees/page.tsx
app/(dashboard)/dashboard/shop/orders/page.tsx
app/(dashboard)/dashboard/shop/page.tsx
app/(dashboard)/dashboard/shop/rules/page.tsx
app/(dashboard)/dashboard/shop/templates/page.tsx [含 setTimeout]
app/(main)/post/new/page.tsx
app/(order)/chat/page.tsx
app/(order)/order/[id]/page.tsx
app/(order)/orders/page.tsx
```
**API + Store 混合(9 个)— 经过 lib/api 但 lib/api 本身也是读 store**
```
app/(auth)/login/page.tsx [含 setTimeout 模拟登录]
app/(auth)/register/page.tsx [含 setTimeout 模拟注册]
app/(dashboard)/dashboard/page.tsx
app/(dashboard)/dashboard/services/new/page.tsx
app/(dashboard)/dashboard/shop/income/page.tsx
app/(order)/chat/[id]/page.tsx
app/(order)/dispute/[id]/page.tsx
app/(order)/order/new/page.tsx [含 setTimeout]
app/(order)/review/[id]/page.tsx
```
**API Only7 个)— 仅经过 lib/api,但 lib/api 底层仍是本地数据:**
```
app/(main)/page.tsx
app/(main)/community/page.tsx
app/(main)/player/[id]/page.tsx
app/(main)/post/[id]/page.tsx
app/(main)/search/page.tsx [唯一真正 fetch 的页面]
app/(main)/shop/[id]/page.tsx
app/(main)/user/[id]/page.tsx
```
**无 API/Store(4 个)— 纯静态或纯前端模拟:**
```
app/(auth)/forgot-password/page.tsx [setTimeout 模拟]
app/(main)/help/page.tsx [纯静态内容]
app/(main)/privacy/page.tsx [纯静态内容]
app/(main)/terms/page.tsx [纯静态内容]
```
---
## 九、组件层直接读写业务 Store(绕过 lib/api
`useAuthStore`/`useLoginDialogStore` 外,以下共享组件直接操作业务 store:
| 组件 | 直接引用的 store |
| ------------------------------------- | ----------------------------------------------- |
| `components/order-actions.tsx:14-16` | `useChatStore`, `useOrderStore`, `useShopStore` |
| `components/favorite-button.tsx:6` | `useFavoriteStore` |
| `components/post-like-button.tsx:5` | `usePostStore` |
| `components/post-comments.tsx:5` | `useCommentStore` |
| `components/post-comment-count.tsx:3` | `useCommentStore` |
| `components/header.tsx:19-20` | `useNotificationStore`, `useShopStore` |
---
## 十、已排除项(确认不存在)
- 无 `.env` / `.env.local` / `.env.development` 等环境配置文件
- 无 mock/real 环境切换开关(`NEXT_PUBLIC_*MOCK*``USE_MOCK` 等)
- 无运行时 MSW 拦截代码(`msw` 仅作为 `@vitest/mocker` 的 peer dep 出现在 lockfile
- 无 `axios``axios-mock-adapter``json-server``miragejs` 使用
- 无运行时 `.json` 文件作为数据源导入
- 无 `Promise.resolve` 模拟异步返回
- 无 Next.js `rewrites`/`redirects`/proxy 配置
- `useQuery`/`useMutation`/`useSWR` 全仓库 0 处调用
---
## 十一、隐蔽未实现接口(更像真功能,实际仍在前端自转)
这些条目即使去掉了明显的 mock 字样,也会在切真实后端后造成严重偏差:UI 提示可用或可保存,实际没有任何可被后端接入的请求/数据契约/权限边界。
| 类型 | 现象 | 关键证据(单行) | 切后端后常见失败模式 |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| Client Store 与 Server Component 断层 | 管理页或发布页写入 Zustand 后,跳转到详情/公开页看不到改动 | `app/(main)/post/new/page.tsx:79`createPost 写前端 store / `app/(main)/post/[id]/page.tsx:17`(详情页从 server 侧读 getPostById | 新创建内容在列表可见,详情页 404 或展示旧数据;店铺模板预览不反映保存结果 |
| 图片/文件上传未落到接口 | 多处上传只生成本地 blob URL 或占位图,刷新或换设备即失效 | `app/(account)/settings/page.tsx:75``app/(order)/chat/[id]/page.tsx:152``app/(order)/dispute/[id]/page.tsx:88``app/(main)/post/new/page.tsx:84``app/(account)/verify/page.tsx:170` | 后端接入后出现上传缺字段、图片无法复现、消息图片仅本机可见 |
| 会话列表未按参与者过滤 | 消息列表直接渲染全部会话,只在详情页才做参与者校验 | `app/(order)/chat/page.tsx:12` | 列表出现无关会话,后端接入时需要补齐按用户查询与分页 |
| 店铺规则字段缺少业务约束 | 规则页可保存 allowMultiShop 等字段,员工邀请直接改归属,不存在申请/同意流 | `app/(dashboard)/dashboard/shop/rules/page.tsx:48` / `app/(dashboard)/dashboard/shop/employees/page.tsx:75` / `store/players.ts:13` | 规则与权限不生效,导致运营规则与实际行为脱节 |
| 智能派单与流程推进为固定定时器 | 自动派单固定 3 秒自动接单;待接单/结单/评价自动超时推进;争议自动进入 reviewing/resolved | `store/orders.ts:186``store/orders.ts:408``store/disputes.ts:142` | 与真实状态机/消息队列/人工审核不一致,产生错误状态与资金流偏差 |
| 交易明细与订单关联靠字符串解析 | 收入统计用正则从 transaction.description 里提取 ord id | `app/(dashboard)/dashboard/shop/income/page.tsx:51` | 后端字段结构化后该逻辑失效,统计漏算或误算 |
| 身份切换与实体模型不对齐 | 登录永远是固定用户 u1(consumer),UI 允许切换到 player/owner;导航用 user.id 直接拼 player/shop 路由 | `app/(auth)/login/page.tsx:36` / `lib/mock/users.ts:119` / `components/header.tsx:85` / `lib/mock/players.ts:7` / `lib/mock/shops.ts:7` | 打手/店主页面长期空态或 404;店铺后台永远提示无可管理店铺 |
| 看似走 API,实际仍为 mock | `/search` 会发起 fetch,但 `/api/search` 路由以 mockPlayers/mockShops/mockServices 作为数据源 | `lib/api/search.ts:34` / `app/api/search/route.ts:1` | 接真实后端时容易遗漏替换点,导致线上仍走假数据 |
---
## 复扫命令
后续持续监控可用以下 6 条命令覆盖主要信号:
```bash
rg -n '@/lib/mock' app lib store components
rg -n '\bfetch\(' app lib store components
rg -n 'setTimeout\(' app lib store components
rg -n 'from "@/store/' app components
rg -n 'URL\.createObjectURL' app components
rg -n 'window\.prompt' app
```