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:
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user