init: init prok
This commit is contained in:
197
packages/server/src/routes/admin.ts
Normal file
197
packages/server/src/routes/admin.ts
Normal 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;
|
||||
66
packages/server/src/routes/auth.ts
Normal file
66
packages/server/src/routes/auth.ts
Normal 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;
|
||||
79
packages/server/src/routes/redemption.ts
Normal file
79
packages/server/src/routes/redemption.ts
Normal 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;
|
||||
85
packages/server/src/routes/stamps.ts
Normal file
85
packages/server/src/routes/stamps.ts
Normal 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;
|
||||
Reference in New Issue
Block a user