"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 { 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`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 { 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`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 { 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 { 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 { 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 { 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 { 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 { 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//.,返回文件名写回 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 }; }