refactor: 兑换机制改为一图章一奖品并引入库存

- 废弃 RedemptionRule(集 N 换 1),新增 Prize 表与 Stamp 1:1 关联
- Redemption 记录直接绑定到 stampId + prizeId + prizeName 快照
- 兑换事务用 updateMany + stock>0 条件作乐观锁
- 兑换后保留 Collection 记录,图章持续彩色点亮并标记"已兑换"
- 用户端入口改为点击已收集图章弹出兑换,库存为 0 时按钮禁用
- 管理后台删除 /admin/rules,奖品编辑嵌入 StampForm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:30:28 +08:00
parent 52169ac71d
commit 394b643304
20 changed files with 581 additions and 642 deletions

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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<string> = new Set();
let collectionMap: Map<string, Date> = new Map();
const collectionMap = new Map<string, Date>();
const redeemedSet = new Set<string>();
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 });

View File

@@ -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) => {