Files
SuperJump/src/app/parent/actions.ts
T
jacobxu666andClaude Haiku 4.5 c706f485cc
test / test (push) Successful in 37s
试卷题目配图(几何体):编辑器上传区+孩子端渲染+文件名校验+public挂卷持久化
uploadPaperFigure 存 public/exam/<卷>/<uuid>.ext;编辑器预览/移除;
quiz-block 渲染 q.figure;compose 挂 exam-figures 卷防重建丢失。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-20 16:08:27 +08:00

419 lines
16 KiB
TypeScript

"use server";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { activationCodes, examPapers, examRecords, familyInvites, sessions, taskInstances, taskTemplates, users, weeklyTest } from "@/db/schema";
import { SUBJECT_VALUES } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks";
import { getSessionUser } from "@/lib/auth";
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
import { validateQuestions } from "@/lib/question-validation";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
export type CreateCodeState = {
code?: string;
childName?: string;
error?: string;
} | null;
// 家长为指定孩子生成一次性激活码(15 分钟有效)
export async function createActivationCode(
_prev: CreateCodeState,
formData: FormData,
): Promise<CreateCodeState> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const childId = Number(formData.get("childId"));
const [child] = await db
.select()
.from(users)
.where(eq(users.id, childId))
.limit(1);
if (!child || child.role !== "child" || child.familyId !== parent.familyId) {
return { error: "孩子不存在" };
}
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.insert(activationCodes).values({
childUserId: child.id,
codeHash: hashCode(code),
createdBy: parent.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
return { code, childName: child.displayName };
}
// xuchao: 生成家庭邀请码(6 位,15 分钟一次性),对方凭码在 /register 注册加入
export async function generateFamilyInvite(): Promise<{ code?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.insert(familyInvites).values({
familyId: parent.familyId,
codeHash: hashCode(code),
createdBy: parent.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
return { code };
}
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
export async function makeupTask(instanceId: number, done: boolean) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const [row] = await db
.select({ date: taskInstances.date, familyId: users.familyId })
.from(taskInstances)
.innerJoin(users, eq(users.id, taskInstances.childUserId))
.where(eq(taskInstances.id, instanceId))
.limit(1);
if (!row || row.familyId !== parent.familyId) throw new Error("任务不存在");
if (row.date >= todayStr()) throw new Error("只能对过去的日期补卡");
await db
.update(taskInstances)
.set({ status: done ? "done" : "pending", isMakeup: done, doneAt: done ? new Date() : null })
.where(eq(taskInstances.id, instanceId));
revalidatePath("/parent");
}
// ===== 周模板管理 =====
const templateSchema = z.object({
childId: z.coerce.number().int().positive(),
dow: z.coerce.number().int().min(0).max(6).catch(0),
subject: z.enum(SUBJECT_VALUES).catch("other"),
mode: z.enum(["weekly", "date", "range", "week"]).catch("weekly"),
dateStart: z.string().nullish(),
dateEnd: z.string().nullish(),
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
requiresPhoto: z.boolean(),
// xuchao: 该任务完成可得积分,0~9999
points: z.coerce.number().int().min(0).max(9999).catch(10),
});
async function requireOwnerOfChild(childId: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const [child] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child")))
.limit(1);
if (!child) throw new Error("孩子不存在");
return parent;
}
// 添加模板任务(次日及以后生效,不改动已有历史)
export async function addTemplate(formData: FormData) {
const parsed = templateSchema.safeParse({
childId: formData.get("childId"),
dow: formData.get("dow"),
subject: formData.get("subject"),
mode: formData.get("mode"),
dateStart: formData.get("dateStart") || null,
dateEnd: formData.get("dateEnd") || null,
title: formData.get("title"),
requiresPhoto: formData.get("requiresPhoto") === "on",
points: formData.get("points") || 10,
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await requireOwnerOfChild(parsed.data.childId);
const [maxRow] = await db
.select({ maxSort: sql<number>`COALESCE(MAX(${taskTemplates.sortOrder}), -1)` })
.from(taskTemplates)
.where(eq(taskTemplates.childUserId, parsed.data.childId));
await db.insert(taskTemplates).values({
childUserId: parsed.data.childId,
dow: parsed.data.dow,
subject: parsed.data.subject,
mode: parsed.data.mode,
dateStart: parsed.data.dateStart || null,
dateEnd: parsed.data.dateEnd || null,
title: parsed.data.title,
requiresPhoto: parsed.data.requiresPhoto,
points: parsed.data.points,
sortOrder: maxRow.maxSort + 1,
});
await ensureDay(parsed.data.childId, todayStr());
revalidatePath("/parent");
}
// xuchao: 修改模板任务分值;当天未完成的快照同步更新,设置立即生效
export async function updateTemplatePoints(templateId: number, points: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const p = Math.max(0, Math.min(9999, Math.round(points)));
const [row] = await db
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
.from(taskTemplates)
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
.limit(1);
if (!row) throw new Error("任务不存在");
await db.update(taskTemplates).set({ points: p }).where(eq(taskTemplates.id, templateId));
await db
.update(taskInstances)
.set({ points: p })
.where(
and(
eq(taskInstances.sourceTemplateId, templateId),
eq(taskInstances.date, todayStr()),
eq(taskInstances.status, "pending"),
),
);
revalidatePath("/parent");
revalidatePath("/kid");
}
// xuchao: 保存测试类积分规则(只接受已知事件,分值 0~9999)
export async function savePointRules(values: { eventType: string; points: number }[]) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
for (const v of values) {
if (!(v.eventType in DEFAULT_RULE_POINTS)) continue;
await upsertPointRule(v.eventType, Math.max(0, Math.min(9999, Math.round(v.points))));
}
revalidatePath("/parent");
}
// 删除模板任务:删除后立即对账当天,孩子端未完成的对应任务会同步移除
export async function deleteTemplate(templateId: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const [row] = await db
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
.from(taskTemplates)
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
.limit(1);
if (!row) throw new Error("任务不存在");
await db.delete(taskTemplates).where(eq(taskTemplates.id, templateId));
await ensureDay(row.childUserId, todayStr());
revalidatePath("/parent");
}
// ===== 设备管理 =====
// 注销某台设备的会话(孩子那台 iPad 下次打开需要重新激活)
export async function revokeSession(sessionId: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const [row] = await db
.select({ id: sessions.id })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, sessionId), eq(users.familyId, parent.familyId)))
.limit(1);
if (!row) throw new Error("会话不存在");
await db.delete(sessions).where(eq(sessions.id, sessionId));
revalidatePath("/parent");
}
// ===== 孩子管理:增/删 =====
export async function addChild(formData: FormData): Promise<string | null> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
const name = String(formData.get("name") ?? "").trim();
if (!name) return "请填写孩子称呼";
const [mx] = await db
.select({ m: sql<number>`COALESCE(MAX(${users.sortOrder}), -1)` })
.from(users)
.where(eq(users.familyId, parent.familyId));
await db.insert(users).values({
familyId: parent.familyId,
role: "child",
displayName: name,
sortOrder: mx.m + 1,
});
revalidatePath("/parent");
revalidatePath("/learn");
return null;
}
export async function removeChild(childId: number): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child")))
.limit(1);
if (!row) return;
await db.delete(users).where(eq(users.id, childId));
revalidatePath("/parent");
revalidatePath("/learn");
}
// ===== 成绩与试卷管理:周测记录(不联动积分/打卡) =====
export async function updateWeeklyRecord(id: number, score: number, passed: boolean): Promise<string | null> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数";
const [row] = await db
.select({ familyId: users.familyId })
.from(weeklyTest)
.innerJoin(users, eq(users.id, weeklyTest.childUserId))
.where(eq(weeklyTest.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return "记录不存在";
await db.update(weeklyTest).set({ score, passed }).where(eq(weeklyTest.id, id));
revalidatePath("/parent/weekly-records");
revalidatePath("/parent");
return null;
}
export async function deleteWeeklyRecord(id: number): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db
.select({ familyId: users.familyId })
.from(weeklyTest)
.innerJoin(users, eq(users.id, weeklyTest.childUserId))
.where(eq(weeklyTest.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return;
await db.delete(weeklyTest).where(eq(weeklyTest.id, id));
revalidatePath("/parent/weekly-records");
revalidatePath("/parent");
}
// ===== 成绩与试卷管理:考试记录(不联动积分/打卡) =====
export async function updateExamRecord(id: number, score: number, passed: boolean): Promise<string | null> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数";
const [row] = await db
.select({ familyId: users.familyId })
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.where(eq(examRecords.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return "记录不存在";
await db.update(examRecords).set({ score, passed }).where(eq(examRecords.id, id));
revalidatePath("/parent/exam-records");
revalidatePath("/parent");
return null;
}
export async function deleteExamRecord(id: number): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db
.select({ familyId: users.familyId })
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.where(eq(examRecords.id, id))
.limit(1);
if (!row || row.familyId !== parent.familyId) return;
await db.delete(examRecords).where(eq(examRecords.id, id));
revalidatePath("/parent/exam-records");
revalidatePath("/parent");
}
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const f = (k: string) => String(formData.get(k) ?? "").trim();
const paperId = f("paperId");
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号只允许小写字母/数字/中划线,如 g7s1-m09-2" };
const title = f("title");
if (!title) return { error: "请填写试卷标题" };
const month = Number(formData.get("month"));
const seq = Number(formData.get("seq"));
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
const questions = f("questions");
const chk = validateQuestions(questions);
if (chk.error) return { error: chk.error };
await db
.insert(examPapers)
.values({
paperId,
subject: f("subject") || "math",
grade: f("grade") || "g7",
semester: f("semester") || "s1",
month,
examType: f("examType"),
title,
region: f("region") || null,
year: Number(formData.get("year")) || null,
duration: Number(formData.get("duration")) || null,
fullScore: Number(formData.get("fullScore")) || null,
sourceUrl: f("sourceUrl") || null,
summary: f("summary") || null,
questions: JSON.stringify(JSON.parse(questions)),
seq,
})
.onDuplicateKeyUpdate({
set: {
subject: f("subject") || "math",
grade: f("grade") || "g7",
semester: f("semester") || "s1",
month,
examType: f("examType"),
title,
region: f("region") || null,
year: Number(formData.get("year")) || null,
duration: Number(formData.get("duration")) || null,
fullScore: Number(formData.get("fullScore")) || null,
sourceUrl: f("sourceUrl") || null,
summary: f("summary") || null,
questions: JSON.stringify(JSON.parse(questions)),
seq,
},
});
revalidatePath("/parent/exams");
revalidatePath("/exam");
return { ok: true };
}
export async function deleteExamPaper(paperId: string): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
await db.delete(examPapers).where(eq(examPapers.paperId, paperId));
revalidatePath("/parent/exams");
revalidatePath("/exam");
}
// xuchao: 上传试卷题目配图(几何体等)——存 public/exam/<paperId>/<uuid>.<ext>,返回文件名写回 q.figure
// 生产容器 public/exam 挂卷持久化(compose volumes: exam-figures),重建不丢
export async function uploadPaperFigure(
paperId: string,
file: File,
): Promise<{ filename?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号不合法" };
if (!file.type.startsWith("image/")) return { error: "只能上传图片文件" };
const ext = file.name.toLowerCase().split(".").pop();
if (!["png", "jpg", "jpeg", "webp"].includes(ext ?? "")) return { error: "只支持 png/jpg/webp 图片" };
if (file.size > 8 * 1024 * 1024) return { error: "图片不能超过 8MB" };
const buf = Buffer.from(await file.arrayBuffer());
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(process.cwd(), "public", "exam", paperId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), buf);
return { filename };
}