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,36 @@
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import authRoutes from "./routes/auth.js";
import stampRoutes from "./routes/stamps.js";
import redemptionRoutes from "./routes/redemption.js";
import adminRoutes from "./routes/admin.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.SERVER_PORT || 3000;
app.use(cors());
app.use(express.json());
// Serve uploaded files
app.use("/uploads", express.static(path.join(__dirname, "../uploads")));
// Health check
app.get("/api/health", (_req, res) => {
res.json({ success: true, data: { status: "ok" } });
});
// User-facing routes
app.use("/api/auth", authRoutes);
app.use("/api/stamps", stampRoutes);
app.use("/api/redemption", redemptionRoutes);
// Admin routes
app.use("/api/admin", adminRoutes);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,16 @@
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET || "dev-secret";
const EXPIRES_IN = "7d";
export function signToken(userId: string): string {
return jwt.sign({ sub: userId }, SECRET, { expiresIn: EXPIRES_IN });
}
export function verifyToken(token: string): { sub: string } | null {
try {
return jwt.verify(token, SECRET) as { sub: string };
} catch {
return null;
}
}

View File

@@ -0,0 +1,12 @@
import QRCode from "qrcode";
export async function generateQRCodeDataURL(
url: string,
options?: { width?: number }
): Promise<string> {
return QRCode.toDataURL(url, {
width: options?.width ?? 300,
margin: 2,
color: { dark: "#1a1a2e", light: "#ffffff" },
});
}

View File

@@ -0,0 +1,11 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
const key = req.headers["x-admin-key"];
const expected = process.env.ADMIN_API_KEY;
if (!expected || key !== expected) {
res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "管理员权限不足" } });
return;
}
next();
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/jwt.js";
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "请先登录" } });
return;
}
const payload = verifyToken(header.slice(7));
if (!payload) {
res.status(401).json({ success: false, error: { code: "TOKEN_INVALID", message: "登录已过期,请重新登录" } });
return;
}
req.userId = payload.sub;
next();
}
export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (header?.startsWith("Bearer ")) {
const payload = verifyToken(header.slice(7));
if (payload) {
req.userId = payload.sub;
}
}
next();
}

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;

View File

@@ -0,0 +1,68 @@
import { prisma } from "@stamp/shared";
async function seed() {
console.log("Seeding database...");
// Create sample stamps
const stamps = await Promise.all([
prisma.stamp.create({
data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 1 },
}),
prisma.stamp.create({
data: { name: "老街风韵", note: "百年历史的商业老街", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 2 },
}),
prisma.stamp.create({
data: { name: "园林雅趣", note: "江南古典园林", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 3 },
}),
prisma.stamp.create({
data: { name: "茶馆时光", note: "百年老茶馆", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 4 },
}),
prisma.stamp.create({
data: { name: "水乡晨曲", note: "清晨的水乡渔市", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 5 },
}),
prisma.stamp.create({
data: { name: "戏台余韵", note: "古戏台与昆曲", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 6 },
}),
prisma.stamp.create({
data: { name: "巷弄深处", note: "青石板铺就的幽深小巷", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 7 },
}),
prisma.stamp.create({
data: { name: "月下拱桥", note: "夜晚灯火映照的拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 8 },
}),
prisma.stamp.create({
data: { name: "匠心工坊", note: "传统手工艺作坊", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 9 },
}),
]);
console.log(`Created ${stamps.length} stamps`);
// Create redemption rules
const rules = await Promise.all([
prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 },
}),
prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 },
}),
prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 },
}),
prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 },
}),
]);
console.log(`Created ${rules.length} redemption rules`);
// Print stamp IDs for QR code testing
console.log("\nStamp IDs for testing:");
stamps.forEach((s) => {
console.log(` ${s.name}: /collect/${s.id}`);
});
console.log("\nSeed complete!");
}
seed()
.catch(console.error)
.finally(() => prisma.$disconnect());