init: init prok

This commit is contained in:
2026-04-16 15:34:47 +08:00
commit db74381f13
56 changed files with 5850 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
import { Router } from "express";
import { z } from "zod";
import multer from "multer";
import path from "path";
import { fileURLToPath } from "url";
import { prisma } from "@stamp/shared";
import { requireAdmin } from "../middleware/admin.js";
import { generateQRCodeDataURL } from "../lib/qrcode.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, "../../uploads");
const storage = multer.diskStorage({
destination: uploadsDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
const router = Router();
router.use(requireAdmin);
// ===== Stamps CRUD =====
router.get("/stamps", async (_req, res) => {
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: stamps });
});
const stampSchema = z.object({
name: z.string().min(1, "图章名称不能为空"),
note: z.string().optional(),
imageColor: z.string().optional(),
imageGrey: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/stamps", async (req, res) => {
const parsed = stampSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const stamp = await prisma.stamp.create({
data: {
name: parsed.data.name,
note: parsed.data.note,
imageColor: parsed.data.imageColor ?? "",
imageGrey: parsed.data.imageGrey ?? "",
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: stamp });
});
router.put("/stamps/:id", async (req, res) => {
const parsed = stampSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const stamp = await prisma.stamp.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
res.json({ success: true, data: stamp });
});
router.delete("/stamps/:id", async (req, res) => {
await prisma.stamp.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
// Upload stamp image
router.post("/stamps/:id/upload", upload.single("image"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择图片" } });
return;
}
const field = req.body.field as string;
if (field !== "imageColor" && field !== "imageGrey") {
res.status(400).json({ success: false, error: { code: "INVALID_FIELD", message: "field 必须是 imageColor 或 imageGrey" } });
return;
}
const imagePath = `/uploads/${req.file.filename}`;
const stamp = await prisma.stamp.update({
where: { id: req.params.id },
data: { [field]: imagePath },
}).catch(() => null);
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
res.json({ success: true, data: { path: imagePath } });
});
// Generate QR code
router.get("/stamps/:id/qrcode", async (req, res) => {
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 siteUrl = process.env.SITE_URL || "http://localhost:5173";
const collectUrl = `${siteUrl}/collect/${stamp.id}`;
const qrDataUrl = await generateQRCodeDataURL(collectUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
});
// ===== Redemption Rules CRUD =====
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({
name: z.string().min(1, "奖品名称不能为空"),
description: z.string().optional(),
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
});
router.post("/rules", async (req, res) => {
const parsed = ruleSchema.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,
},
});
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 });
});
// ===== 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 } } },
orderBy: { redeemedAt: "desc" },
});
res.json({ success: true, data: records });
});
router.get("/stats", async (_req, res) => {
const [userCount, collectionCount, redemptionCount] = await Promise.all([
prisma.user.count(),
prisma.collection.count(),
prisma.redemption.count(),
]);
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
});
export default router;

View File

@@ -0,0 +1,66 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "@stamp/shared";
import { signToken } from "../lib/jwt.js";
import { requireAuth } from "../middleware/auth.js";
const router = Router();
const phoneRegex = /^1[3-9]\d{9}$/;
const registerSchema = z.object({
username: z.string().min(1, "用户名不能为空").max(20, "用户名最长 20 字符"),
phone: z.string().regex(phoneRegex, "手机号格式不正确"),
});
const loginSchema = z.object({
phone: z.string().regex(phoneRegex, "手机号格式不正确"),
});
router.post("/register", async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const { username, phone } = parsed.data;
const existing = await prisma.user.findFirst({
where: { OR: [{ username }, { phone }] },
});
if (existing) {
const field = existing.username === username ? "用户名" : "手机号";
res.status(409).json({ success: false, error: { code: "DUPLICATE", message: `${field}已被注册` } });
return;
}
const user = await prisma.user.create({ data: { username, phone } });
const token = signToken(user.id);
res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } });
});
router.post("/login", async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.findUnique({ where: { phone: parsed.data.phone } });
if (!user) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "该手机号未注册" } });
return;
}
const token = signToken(user.id);
res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } });
});
router.get("/me", requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "用户不存在" } });
return;
}
res.json({ success: true, data: { id: user.id, username: user.username, phone: user.phone } });
});
export default router;

View File

@@ -0,0 +1,79 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "@stamp/shared";
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 格式不正确"),
});
router.post("/redeem", requireAuth, async (req, res) => {
const parsed = redeemSchema.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.findUnique({ where: { id: parsed.data.ruleId, enabled: true } });
if (!rule) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
return;
}
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) => {
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount },
});
await tx.collection.deleteMany({ where: { userId: req.userId! } });
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 } } },
orderBy: { redeemedAt: "desc" },
});
const data = records.map((r) => ({
id: r.id,
ruleName: r.rule.name,
stampCount: r.stampCount,
redeemedAt: r.redeemedAt.toISOString(),
}));
res.json({ success: true, data });
});
export default router;

View File

@@ -0,0 +1,85 @@
import { Router } from "express";
import { prisma } from "@stamp/shared";
import { optionalAuth, requireAuth } from "../middleware/auth.js";
const router = Router();
router.get("/", optionalAuth, async (req, res) => {
const stamps = await prisma.stamp.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
});
let collections: Set<string> = new Set();
let collectionMap: Map<string, Date> = new Map();
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 data = stamps.map((s) => ({
id: s.id,
name: s.name,
note: s.note,
imageColor: s.imageColor,
imageGrey: s.imageGrey,
sortOrder: s.sortOrder,
collected: collections.has(s.id),
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
}));
res.json({ success: true, data });
});
router.get("/:id", async (req, res) => {
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;
}
res.json({
success: true,
data: {
id: stamp.id,
name: stamp.name,
note: stamp.note,
imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey,
sortOrder: stamp.sortOrder,
},
});
});
router.post("/:id/collect", requireAuth, async (req, res) => {
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id, enabled: true } });
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
}
const existing = await prisma.collection.findUnique({
where: { userId_stampId: { userId: req.userId!, stampId: stamp.id } },
});
if (existing) {
res.status(409).json({ success: false, error: { code: "ALREADY_COLLECTED", message: "你已经收集了这枚图章" } });
return;
}
const collection = await prisma.collection.create({
data: { userId: req.userId!, stampId: stamp.id },
});
res.json({
success: true,
data: { id: collection.id, stampId: collection.stampId, collectedAt: collection.collectedAt.toISOString() },
});
});
export default router;