diff --git a/CLAUDE.md b/CLAUDE.md index 441eae4..981d487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品,兑换后图章清空,支持重复收集。 +CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品。兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换。 ## Commands @@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173 pnpm db:generate # Generate Prisma client after schema changes pnpm db:migrate # Create and apply migrations (prisma migrate dev) pnpm db:push # Push schema directly (dev only, no migration file) -pnpm db:seed # Seed sample data (9 stamps + 4 redemption rules) +pnpm db:seed # Seed sample data (16 stamps, each with a Prize of stock 100) # Build pnpm build # Build all packages @@ -48,7 +48,6 @@ All endpoints return: `{ success: boolean, data?: T, error?: { code: string, mes /collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point) /admin → AdminLogin /admin/stamps → Stamp CRUD + QR code generation -/admin/rules → Redemption rule CRUD /admin/redemptions → Redemption history + stats ``` @@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La ### Redemption Transaction -Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection. +Each `Stamp` has an optional `Prize` (1:1, `Prize.stampId @unique`). Redemption is atomic: inside `prisma.$transaction` we check the user has a `Collection` for the stamp, no existing `Redemption` for (user, stamp), the prize is `enabled`, then `prisma.prize.updateMany({ where: { id, stock: { gt: 0 } }, data: { stock: { decrement: 1 } } })` acts as a stock lock (throws `OUT_OF_STOCK` if zero rows updated) before creating the `Redemption` record with a `prizeName` snapshot. `Collection` rows are **not** deleted — the `@@unique([userId, stampId])` constraints on both `Collection` and `Redemption` naturally block re-collection and re-redemption of the same stamp. ## Critical: Tailwind CSS v4 Layer Architecture diff --git a/README.md b/README.md index b4d120e..7a21f61 100644 --- a/README.md +++ b/README.md @@ -32,5 +32,5 @@ packages/ server/ Express API(认证、图章、兑换、管理) web/ React SPA(移动端 H5 + PC 管理后台) prisma/ - schema.prisma 数据模型(User, Stamp, Collection, RedemptionRule, Redemption) + schema.prisma 数据模型(User, Stamp, Prize, Collection, Redemption) ``` diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 1b05765..292692b 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -26,7 +26,10 @@ router.use(requireAdmin); // ===== Stamps CRUD ===== router.get("/stamps", async (_req, res) => { - const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } }); + const stamps = await prisma.stamp.findMany({ + orderBy: { sortOrder: "asc" }, + include: { prize: true }, + }); res.json({ success: true, data: stamps }); }); @@ -121,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => { res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } }); }); -// ===== Redemption Rules CRUD ===== +// ===== Prize (per-stamp) ===== -router.get("/rules", async (_req, res) => { - const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } }); - res.json({ success: true, data: rules }); -}); - -const ruleSchema = z.object({ +const prizeSchema = z.object({ name: z.string().min(1, "奖品名称不能为空"), description: z.string().optional(), - threshold: z.number().int().min(1, "兑换门槛至少为 1"), + stock: z.number().int().min(0, "库存不能为负数"), enabled: z.boolean().optional(), - sortOrder: z.number().int().optional(), }); -router.post("/rules", async (req, res) => { - const parsed = ruleSchema.safeParse(req.body); +router.put("/stamps/:id/prize", async (req, res) => { + const parsed = prizeSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); return; } - const rule = await prisma.redemptionRule.create({ - data: { - name: parsed.data.name, - description: parsed.data.description, - threshold: parsed.data.threshold, - enabled: parsed.data.enabled ?? true, - sortOrder: parsed.data.sortOrder ?? 0, - }, + const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } }); + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + const data = { + name: parsed.data.name, + description: parsed.data.description ?? null, + stock: parsed.data.stock, + enabled: parsed.data.enabled ?? true, + }; + const prize = await prisma.prize.upsert({ + where: { stampId: stamp.id }, + create: { stampId: stamp.id, ...data }, + update: data, }); - res.json({ success: true, data: rule }); -}); - -router.put("/rules/:id", async (req, res) => { - const parsed = ruleSchema.partial().safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); - return; - } - const rule = await prisma.redemptionRule.update({ - where: { id: req.params.id }, - data: parsed.data, - }).catch(() => null); - if (!rule) { - res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "规则不存在" } }); - return; - } - res.json({ success: true, data: rule }); -}); - -router.delete("/rules/:id", async (req, res) => { - await prisma.redemptionRule.delete({ where: { id: req.params.id } }).catch(() => null); - res.json({ success: true, data: null }); + res.json({ success: true, data: prize }); }); // ===== Redemption Records & Stats ===== router.get("/redemptions", async (_req, res) => { const records = await prisma.redemption.findMany({ - include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } }, + include: { + user: { select: { username: true, phone: true } }, + stamp: { select: { name: true } }, + }, orderBy: { redeemedAt: "desc" }, }); - res.json({ success: true, data: records }); + const data = records.map((r) => ({ + id: r.id, + redeemedAt: r.redeemedAt, + user: r.user, + stampName: r.stamp.name, + prizeName: r.prizeName, + })); + res.json({ success: true, data }); }); router.get("/stats", async (_req, res) => { diff --git a/packages/server/src/routes/redemption.ts b/packages/server/src/routes/redemption.ts index 76c96eb..7ada161 100644 --- a/packages/server/src/routes/redemption.ts +++ b/packages/server/src/routes/redemption.ts @@ -5,17 +5,8 @@ import { requireAuth } from "../middleware/auth.js"; const router = Router(); -router.get("/rules", async (_req, res) => { - const rules = await prisma.redemptionRule.findMany({ - where: { enabled: true }, - orderBy: { sortOrder: "asc" }, - select: { id: true, name: true, description: true, threshold: true }, - }); - res.json({ success: true, data: rules }); -}); - const redeemSchema = z.object({ - ruleId: z.string().uuid("规则 ID 格式不正确"), + stampId: z.string().uuid("图章 ID 格式不正确"), }); router.post("/redeem", requireAuth, async (req, res) => { @@ -25,64 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => { return; } - const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } }); - if (!rule) { - res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } }); - return; + const { stampId } = parsed.data; + const userId = req.userId!; + + try { + const redemption = await prisma.$transaction(async (tx) => { + const collection = await tx.collection.findUnique({ + where: { userId_stampId: { userId, stampId } }, + }); + if (!collection) { + throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400); + } + + const already = await tx.redemption.findUnique({ + where: { userId_stampId: { userId, stampId } }, + }); + if (already) { + throw new RedeemError("ALREADY_REDEEMED", "你已经兑换过这枚图章对应的奖品", 409); + } + + const prize = await tx.prize.findUnique({ where: { stampId } }); + if (!prize || !prize.enabled) { + throw new RedeemError("PRIZE_UNAVAILABLE", "该图章暂无可兑换的奖品", 400); + } + + const decremented = await tx.prize.updateMany({ + where: { id: prize.id, stock: { gt: 0 } }, + data: { stock: { decrement: 1 } }, + }); + if (decremented.count === 0) { + throw new RedeemError("OUT_OF_STOCK", "奖品已兑完", 400); + } + + return tx.redemption.create({ + data: { + userId, + stampId, + prizeId: prize.id, + prizeName: prize.name, + }, + include: { stamp: { select: { name: true } } }, + }); + }); + + res.json({ + success: true, + data: { + id: redemption.id, + stampId: redemption.stampId, + stampName: redemption.stamp.name, + prizeName: redemption.prizeName, + redeemedAt: redemption.redeemedAt.toISOString(), + }, + }); + } catch (e) { + if (e instanceof RedeemError) { + res.status(e.status).json({ success: false, error: { code: e.code, message: e.message } }); + return; + } + throw e; } - - const collectionCount = await prisma.collection.count({ where: { userId: req.userId! } }); - if (collectionCount < rule.threshold) { - res.status(400).json({ - success: false, - error: { code: "INSUFFICIENT", message: `需要收集 ${rule.threshold} 枚图章,当前只有 ${collectionCount} 枚` }, - }); - return; - } - - const redemption = await prisma.$transaction(async (tx) => { - // Deduct the oldest N collections (chronological order by collectedAt) - const toDelete = await tx.collection.findMany({ - where: { userId: req.userId! }, - orderBy: { collectedAt: "asc" }, - take: rule.threshold, - select: { id: true }, - }); - await tx.collection.deleteMany({ - where: { id: { in: toDelete.map((c) => c.id) } }, - }); - const record = await tx.redemption.create({ - data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold }, - }); - return record; - }); - - res.json({ - success: true, - data: { - id: redemption.id, - ruleName: rule.name, - stampCount: redemption.stampCount, - redeemedAt: redemption.redeemedAt.toISOString(), - }, - }); }); router.get("/history", requireAuth, async (req, res) => { const records = await prisma.redemption.findMany({ where: { userId: req.userId! }, - include: { rule: { select: { name: true } } }, + include: { stamp: { select: { name: true } } }, orderBy: { redeemedAt: "desc" }, }); const data = records.map((r) => ({ id: r.id, - ruleName: r.rule.name, - stampCount: r.stampCount, + stampId: r.stampId, + stampName: r.stamp.name, + prizeName: r.prizeName, redeemedAt: r.redeemedAt.toISOString(), })); res.json({ success: true, data }); }); +class RedeemError extends Error { + constructor( + public code: string, + message: string, + public status: number, + ) { + super(message); + } +} + export default router; diff --git a/packages/server/src/routes/stamps.ts b/packages/server/src/routes/stamps.ts index b65cd4d..0c5f830 100644 --- a/packages/server/src/routes/stamps.ts +++ b/packages/server/src/routes/stamps.ts @@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => { const stamps = await prisma.stamp.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, + include: { prize: true }, }); - let collections: Set = new Set(); - let collectionMap: Map = new Map(); + const collectionMap = new Map(); + const redeemedSet = new Set(); if (req.userId) { - const userCollections = await prisma.collection.findMany({ - where: { userId: req.userId }, - select: { stampId: true, collectedAt: true }, - }); - userCollections.forEach((c) => { - collections.add(c.stampId); - collectionMap.set(c.stampId, c.collectedAt); - }); + const [userCollections, userRedemptions] = await Promise.all([ + prisma.collection.findMany({ + where: { userId: req.userId }, + select: { stampId: true, collectedAt: true }, + }), + prisma.redemption.findMany({ + where: { userId: req.userId }, + select: { stampId: true }, + }), + ]); + userCollections.forEach((c) => collectionMap.set(c.stampId, c.collectedAt)); + userRedemptions.forEach((r) => redeemedSet.add(r.stampId)); } const data = stamps.map((s) => ({ @@ -31,8 +36,18 @@ router.get("/", optionalAuth, async (req, res) => { imageColor: s.imageColor, imageGrey: s.imageGrey, sortOrder: s.sortOrder, - collected: collections.has(s.id), + collected: collectionMap.has(s.id), collectedAt: collectionMap.get(s.id)?.toISOString() ?? null, + redeemed: redeemedSet.has(s.id), + prize: s.prize + ? { + id: s.prize.id, + name: s.prize.name, + description: s.prize.description, + stock: s.prize.stock, + enabled: s.prize.enabled, + } + : null, })); res.json({ success: true, data }); diff --git a/packages/server/src/seed.ts b/packages/server/src/seed.ts index f51b7df..69aae4f 100644 --- a/packages/server/src/seed.ts +++ b/packages/server/src/seed.ts @@ -22,7 +22,7 @@ const stampData = [ async function seed() { console.log("Seeding database..."); - // Clear existing stamps (cascades to collections) + // Clear existing stamps (cascades to collections + prize) await prisma.stamp.deleteMany(); const stamps = await Promise.all( @@ -34,34 +34,20 @@ async function seed() { imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`, imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`, sortOrder: idx + 1, + prize: { + create: { + name: `${s.name} · 纪念章`, + description: `在「${s.name}」集到的专属纪念奖品`, + stock: 100, + enabled: true, + }, + }, }, }); }), ); - console.log(`Created ${stamps.length} stamps`); - - // Create redemption rules if none exist - const existingRules = await prisma.redemptionRule.count(); - if (existingRules === 0) { - const rules = await Promise.all([ - prisma.redemptionRule.create({ - data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 }, - }), - prisma.redemptionRule.create({ - data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 }, - }), - prisma.redemptionRule.create({ - data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 }, - }), - prisma.redemptionRule.create({ - data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 }, - }), - ]); - console.log(`Created ${rules.length} redemption rules`); - } else { - console.log(`Kept existing ${existingRules} redemption rules`); - } + console.log(`Created ${stamps.length} stamps with prizes`); console.log("\nStamp IDs for testing:"); stamps.forEach((s) => { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4313da1..8df8101 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,6 +4,14 @@ export type ApiResponse = { error?: { code: string; message: string }; }; +export type PrizeInfo = { + id: string; + name: string; + description: string | null; + stock: number; + enabled: boolean; +}; + export type StampWithStatus = { id: string; name: string; @@ -13,19 +21,15 @@ export type StampWithStatus = { sortOrder: number; collected: boolean; collectedAt: string | null; -}; - -export type RedemptionRuleInfo = { - id: string; - name: string; - description: string | null; - threshold: number; + redeemed: boolean; + prize: PrizeInfo | null; }; export type RedemptionRecord = { id: string; - ruleName: string; - stampCount: number; + stampId: string; + stampName: string; + prizeName: string; redeemedAt: string; }; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index c569501..68ed664 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -11,7 +11,6 @@ import Dashboard from "./admin/Dashboard"; import StampList from "./admin/StampList"; import ArticleList from "./admin/ArticleList"; import MusicList from "./admin/MusicList"; -import RuleList from "./admin/RuleList"; import UsersList from "./admin/UsersList"; import RedemptionLog from "./admin/RedemptionLog"; @@ -39,7 +38,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> diff --git a/packages/web/src/admin/AdminLayout.tsx b/packages/web/src/admin/AdminLayout.tsx index f373f18..1201031 100644 --- a/packages/web/src/admin/AdminLayout.tsx +++ b/packages/web/src/admin/AdminLayout.tsx @@ -6,9 +6,8 @@ const navItems = [ { path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" }, { path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" }, { path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" }, - { path: "/admin/rules", label: "兑换规则", eyebrow: "05", tag: "Rules" }, - { path: "/admin/users", label: "用户管理", eyebrow: "06", tag: "Users" }, - { path: "/admin/redemptions", label: "兑换记录", eyebrow: "07", tag: "Log" }, + { path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" }, + { path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" }, ]; export default function AdminLayout() { diff --git a/packages/web/src/admin/RedemptionLog.tsx b/packages/web/src/admin/RedemptionLog.tsx index 7319546..a6f0d30 100644 --- a/packages/web/src/admin/RedemptionLog.tsx +++ b/packages/web/src/admin/RedemptionLog.tsx @@ -5,11 +5,10 @@ import { TableCard, TableHeadRow } from "./StampList"; type RedemptionRecord = { id: string; - userId: string; - stampCount: number; redeemedAt: string; user: { username: string; phone: string }; - rule: { name: string }; + stampName: string; + prizeName: string; }; type Stats = { @@ -44,7 +43,7 @@ export default function RedemptionLog() { return ( <> @@ -119,7 +118,7 @@ export default function RedemptionLog() { ) : ( - + {records.map((r, i) => ( @@ -137,16 +136,10 @@ export default function RedemptionLog() { {r.user.phone} -
- {r.rule.name} + {r.stampName} - - −{r.stampCount} - - + + {r.prizeName} diff --git a/packages/web/src/admin/RuleForm.tsx b/packages/web/src/admin/RuleForm.tsx deleted file mode 100644 index b25389e..0000000 --- a/packages/web/src/admin/RuleForm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState, useEffect } from "react"; -import Modal from "./Modal"; -import { adminFetch } from "./adminApi"; -import { useToast } from "./Toast"; -import { Field, ErrorRow, FormFooter, fieldCls } from "./FormPrimitives"; - -type Rule = { - id: string; - name: string; - description: string | null; - threshold: number; - enabled: boolean; - sortOrder: number; -}; - -type Props = { - open: boolean; - id: string | null; - onClose: () => void; - onSaved: () => void; -}; - -export default function RuleForm({ open, id, onClose, onSaved }: Props) { - const toast = useToast(); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [threshold, setThreshold] = useState(1); - const [sortOrder, setSortOrder] = useState(0); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - - const isEdit = !!id; - - useEffect(() => { - if (!open) return; - setError(""); - if (!id) { - setName(""); setDescription(""); setThreshold(1); setSortOrder(0); - return; - } - adminFetch("/rules").then((rules) => { - const rule = rules.find((r) => r.id === id); - if (rule) { - setName(rule.name); - setDescription(rule.description || ""); - setThreshold(rule.threshold); - setSortOrder(rule.sortOrder); - } - }); - }, [open, id]); - - const handleSave = async () => { - setError(""); - if (!name.trim()) return setError("请输入奖品名称"); - setSaving(true); - try { - const body = { - name: name.trim(), - description: description.trim() || undefined, - threshold, - sortOrder, - }; - if (isEdit) { - await adminFetch(`/rules/${id}`, { method: "PUT", body: JSON.stringify(body) }); - } else { - await adminFetch("/rules", { method: "POST", body: JSON.stringify(body) }); - } - toast.show("已保存"); - onSaved(); - onClose(); - } catch (e) { - setError(e instanceof Error ? e.message : "保存失败"); - } finally { - setSaving(false); - } - }; - - return ( - -
- - setName(e.target.value)} - placeholder="如:城市限定明信片" - className={fieldCls} - /> - - - -