diff --git a/app/api/postcards/[id]/claim/route.ts b/app/api/postcards/[id]/claim/route.ts index fd55e99..8fcb984 100644 --- a/app/api/postcards/[id]/claim/route.ts +++ b/app/api/postcards/[id]/claim/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { pool } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; +import { bumpReceivedCount, ensurePostcardHiddenColumn } from "@/lib/schema"; // POST /api/postcards/[id]/claim -> claim an available postcard export async function POST( @@ -43,17 +44,26 @@ export async function DELETE( const { id } = await params; + // 取消认领后隐藏权归零:hidden_by_claimer 是认领人专属的隐私开关, + // 认领关系解除后明信片重新回到广场公开池,不应该继续隐藏。 + const hiddenReady = await ensurePostcardHiddenColumn(); + const resetHidden = hiddenReady ? ", hidden_by_claimer = false" : ""; + + // 捕获取消前的状态:若原本是 received,取消认领同时撤销了签收,需 -1。 const result = await pool.query( ` - update postcards + with target as ( + select id, status from postcards + where id = $1 and claimer_id = $2 and status in ('claimed', 'received') + ) + update postcards p set status = 'available', claimer_id = null, claimed_at = null, - received_at = null - where id = $1 - and claimer_id = $2 - and status in ('claimed', 'received') - returning * + received_at = null${resetHidden} + from target + where p.id = target.id + returning p.*, target.status as prev_status `, [id, user.id] ); @@ -65,5 +75,11 @@ export async function DELETE( ); } - return NextResponse.json({ postcard: result.rows[0] }); + if (result.rows[0].prev_status === "received") { + await bumpReceivedCount(-1); // 取消认领时若已签收,累计 -1 + } + + const { prev_status, ...postcard } = result.rows[0]; + void prev_status; + return NextResponse.json({ postcard }); } diff --git a/app/api/postcards/[id]/hide/route.ts b/app/api/postcards/[id]/hide/route.ts new file mode 100644 index 0000000..4ce9070 --- /dev/null +++ b/app/api/postcards/[id]/hide/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { getCurrentUser } from "@/lib/auth"; +import { ensurePostcardHiddenColumn } from "@/lib/schema"; + +// 认领人对自己认领/已收到的明信片切换「隐藏」。 +// 隐藏后广场对所有人不可见,认领人仍能在「我的」看到。 +async function setHidden( + id: string, + userId: string, + hidden: boolean +): Promise<{ ok: boolean; postcard?: unknown }> { + const ready = await ensurePostcardHiddenColumn(); + if (!ready) return { ok: false }; + + const result = await pool.query( + ` + update postcards + set hidden_by_claimer = $3 + where id = $1 and claimer_id = $2 and status in ('claimed', 'received') + returning * + `, + [id, userId, hidden] + ); + if (result.rows.length === 0) return { ok: false }; + return { ok: true, postcard: result.rows[0] }; +} + +// POST -> 隐藏 +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 }); + + const { id } = await params; + const { ok, postcard } = await setHidden(id, user.id, true); + if (!ok) { + return NextResponse.json( + { error: "隐藏失败:只有认领人可隐藏自己认领的明信片" }, + { status: 409 } + ); + } + return NextResponse.json({ postcard }); +} + +// DELETE -> 取消隐藏 +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 }); + + const { id } = await params; + const { ok, postcard } = await setHidden(id, user.id, false); + if (!ok) { + return NextResponse.json( + { error: "取消隐藏失败:只有认领人可操作" }, + { status: 409 } + ); + } + return NextResponse.json({ postcard }); +} diff --git a/app/api/postcards/[id]/receive/route.ts b/app/api/postcards/[id]/receive/route.ts index ee518c4..901a759 100644 --- a/app/api/postcards/[id]/receive/route.ts +++ b/app/api/postcards/[id]/receive/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { pool } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; +import { bumpReceivedCount } from "@/lib/schema"; // POST /api/postcards/[id]/receive -> confirm receipt (claimer only) export async function POST( @@ -29,6 +30,7 @@ export async function POST( ); } + await bumpReceivedCount(1); // 累计签收 +1 return NextResponse.json({ postcard: result.rows[0] }); } @@ -59,5 +61,6 @@ export async function DELETE( ); } + await bumpReceivedCount(-1); // 取消签收 -1 return NextResponse.json({ postcard: result.rows[0] }); } diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts index ecd0801..ea69b15 100644 --- a/app/api/postcards/route.ts +++ b/app/api/postcards/route.ts @@ -2,12 +2,13 @@ import { NextResponse } from "next/server"; import { pool } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash"; -import { ensurePostcardMetadataColumns } from "@/lib/schema"; +import { ensurePostcardMetadataColumns, ensurePostcardHiddenColumn } from "@/lib/schema"; import type { PostcardCounts } from "@/lib/types"; -// GET /api/postcards -> all postcards (newest first) -// GET /api/postcards?status=available -// GET /api/postcards?mine=1 -> postcards claimed by the current user +// GET /api/postcards -> plaza, all postcards (newest first) +// GET /api/postcards?status=available -> plaza, filtered by status +// GET /api/postcards?scope=claimed -> "我的" 里我认领的明信片(分页) +// GET /api/postcards?scope=uploaded -> "我的" 里我上传的明信片(分页) function positiveInt(value: string | null, fallback: number) { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; @@ -19,18 +20,70 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const status = searchParams.get("status"); - const mine = searchParams.get("mine"); + const scope = searchParams.get("scope"); // "claimed" | "uploaded" | null (plaza) const page = positiveInt(searchParams.get("page"), 1); const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 21)); const offset = (page - 1) * pageSize; + if (scope === "claimed" || scope === "uploaded") { + const orderBy = + scope === "claimed" + ? "p.claimed_at desc nulls last, p.created_at desc" + : "p.created_at desc"; + const conditions = [`p.${scope === "claimed" ? "claimer_id" : "uploader_id"} = $1`]; + const params: unknown[] = [user.id]; + if (status && ["available", "claimed", "received"].includes(status)) { + params.push(status); + conditions.push(`p.status = $${params.length}`); + } + const where = `where ${conditions.join(" and ")}`; + params.push(pageSize, offset); + const limitParam = params.length - 1; + const offsetParam = params.length; + + const [result, claimedCount, uploadedCount] = await Promise.all([ + pool.query( + ` + select + p.*, + up.nickname as uploader_nickname, + cl.nickname as claimer_nickname + from postcards p + left join users up on p.uploader_id = up.id + left join users cl on p.claimer_id = cl.id + ${where} + order by ${orderBy} + limit $${limitParam} offset $${offsetParam} + `, + params + ), + pool.query("select count(*)::int as count from postcards where claimer_id = $1", [ + user.id, + ]), + pool.query("select count(*)::int as count from postcards where uploader_id = $1", [ + user.id, + ]), + ]); + + const scopeCounts = { + claimed: claimedCount.rows[0]?.count ?? 0, + uploaded: uploadedCount.rows[0]?.count ?? 0, + }; + const total = scopeCounts[scope]; + + return NextResponse.json({ + postcards: result.rows, + scopeCounts, + pagination: { page, pageSize, total }, + currentUserId: user.id, + }); + } + + // 广场视图:排除被认领人隐藏的明信片。 const baseConditions: string[] = []; const baseParams: unknown[] = []; - - if (mine === "1") { - baseParams.push(user.id); - baseConditions.push(`p.claimer_id = $${baseParams.length}`); - } + const hiddenReady = await ensurePostcardHiddenColumn(); + if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true"); const conditions = [...baseConditions]; const params = [...baseParams]; diff --git a/app/help/page.tsx b/app/help/page.tsx new file mode 100644 index 0000000..a53d4ab --- /dev/null +++ b/app/help/page.tsx @@ -0,0 +1,20 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import Nav from "@/components/nav"; +import { renderMarkdown } from "@/lib/markdown"; + +export const dynamic = "force-dynamic"; + +export default async function HelpPage() { + const filePath = path.join(process.cwd(), "content", "help.md"); + const markdown = await readFile(filePath, "utf8"); + + return ( + <> +