第一期:家庭自学打卡系统

- 家长手机号登录 + 孩子设备一次性激活(长期会话)
- 周模板自动生成每日任务快照,支持拍照凭证直传对象存储
- 连击计算(空白日自动达成)、家长补卡带标记
- 家长端三标签:今日概览 / 任务模板 / 设备管理
- Docker 独立输出,经 Traefik 接入 jump.hn12301.net

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-02 23:19:13 +08:00
co-authored by Claude Opus 4.8
commit af7a144d58
57 changed files with 8875 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
// 会话管理:Cookie 里只放随机令牌,数据库里只存令牌的哈希
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { and, eq, gt } from "drizzle-orm";
import { db } from "@/db";
import { sessions, users } from "@/db/schema";
export const SESSION_COOKIE = "sj_session";
const DAY_MS = 86400_000;
function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
export type SessionUser = {
id: number;
familyId: number;
role: "parent" | "child";
displayName: string;
};
// 创建会话并写入 Cookie。家长 30 天,孩子设备建议传 730 天
export async function createSession(
userId: number,
deviceLabel: string | null,
days: number,
) {
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + days * DAY_MS);
await db.insert(sessions).values({
userId,
tokenHash: hashToken(token),
deviceLabel,
expiresAt: expires,
});
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
// 生产环境(https)强制 secure;本机 http 开发时关闭
secure: process.env.NODE_ENV === "production",
path: "/",
expires,
});
}
// 读取当前会话用户;无效或过期返回 null
export async function getSessionUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const rows = await db
.select({
id: users.id,
familyId: users.familyId,
role: users.role,
displayName: users.displayName,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())))
.limit(1);
return rows[0] ?? null;
}
// 销毁当前会话(登出)
export async function destroySession() {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
store.delete(SESSION_COOKIE);
}
+24
View File
@@ -0,0 +1,24 @@
// 日期工具:全站统一按 Asia/Shanghai 时区计算"今天",与服务器环境变量无关
// 返回 YYYY-MM-DD 格式的当天日期(上海时区)
export function todayStr(): string {
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" });
}
// 由 YYYY-MM-DD 计算星期几:0=周日 …… 6=周六(与模板 dow 字段一致)
export function dowOf(dateStr: string): number {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
}
// 今天往前推 n 天的日期字符串
export function daysAgo(n: number): string {
const now = new Date();
// 先定位上海时区的今天零点,再回退 n 天
const today = new Date(
new Date().toLocaleDateString("en-US", { timeZone: "Asia/Shanghai" }),
);
today.setDate(today.getDate() - n);
void now;
return today.toLocaleDateString("en-CA");
}
+38
View File
@@ -0,0 +1,38 @@
// 对象存储签名工具:浏览器直传用查询串签名(浏览器无法设置 Date 请求头)
import crypto from "node:crypto";
const REGION = process.env.OSS_REGION ?? "oss-cn-hangzhou";
const BUCKET = process.env.OSS_BUCKET ?? "";
const ID = process.env.OSS_ACCESS_KEY_ID ?? "";
const SECRET = process.env.OSS_ACCESS_KEY_SECRET ?? "";
const HOST = `${BUCKET}.${REGION}.aliyuncs.com`;
// 生成带签名的临时访问地址
function signUrl(method: string, key: string, seconds: number, contentType = "") {
const expires = Math.floor(Date.now() / 1000) + seconds;
const resource = `/${BUCKET}/${key}`;
const str = `${method}\n\n${contentType}\n${expires}\n${resource}`;
const signature = crypto.createHmac("sha1", SECRET).update(str).digest("base64");
const qs = new URLSearchParams({
OSSAccessKeyId: ID,
Expires: String(expires),
Signature: signature,
});
return `https://${HOST}/${encodeURIComponent(key)}?${qs}`;
}
// 直传地址:浏览器收到后直接 fetch PUT,有效期 10 分钟
export function uploadUrl(key: string, contentType: string) {
return signUrl("PUT", key, 600, contentType);
}
// 展示地址:签名 GET,有效期 5 分钟,用完由页面刷新重新签发
export function viewUrl(key: string) {
return signUrl("GET", key, 300);
}
// 照片键名:photos/孩子ID/日期/随机16进制.jpg
export function photoKey(childId: number, date: string) {
return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`;
}
+47
View File
@@ -0,0 +1,47 @@
// 连击计算:从今天往前数连续"达成"的天数
// 达成 = 当天至少完成 1 项,或当天根本没有任务(模板空白日自动达成)
import { eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { taskInstances, users } from "@/db/schema";
import { daysAgo, todayStr } from "@/lib/dates";
export async function streakOf(childId: number): Promise<number> {
const [child] = await db
.select({ createdAt: users.createdAt })
.from(users)
.where(eq(users.id, childId))
.limit(1);
if (!child) return 0;
// 账号创建日(上海时区)之前的天数不计入,否则连击会无限回溯
const startDay = new Date(child.createdAt).toLocaleDateString("en-CA", {
timeZone: "Asia/Shanghai",
});
// 一次取出全部历史:每天是否存在完成项
const rows = await db
.select({
date: taskInstances.date,
hasDone: sql<number>`MAX(${taskInstances.status} = 'done')`,
})
.from(taskInstances)
.where(eq(taskInstances.childUserId, childId))
.groupBy(taskInstances.date);
const doneByDate = new Map<string, boolean>();
for (const r of rows) doneByDate.set(r.date, r.hasDone === 1);
let streak = 0;
const today = todayStr();
for (let i = 0; ; i++) {
const day = daysAgo(i);
if (day < startDay) break;
const achieved = !doneByDate.has(day) || doneByDate.get(day) === true;
if (!achieved) {
if (day === today) continue; // 今天还没打完:不计数,但不断连
break;
}
streak++;
}
return streak;
}
+92
View File
@@ -0,0 +1,92 @@
// 每日任务:从周模板生成当天快照(幂等),查询当日任务与完成情况
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { photos, taskInstances, taskTemplates } from "@/db/schema";
import { dowOf } from "@/lib/dates";
import { viewUrl } from "@/lib/oss";
export type TaskView = {
id: number;
title: string;
requiresPhoto: boolean;
status: "pending" | "done";
isMakeup: boolean;
photoUrls: string[];
};
// 确保某天的任务快照存在:第一次访问当天时由模板生成,之后读快照
export async function ensureDay(childId: number, date: string) {
await db.transaction(async (tx) => {
const existing = await tx
.select({ id: taskInstances.id })
.from(taskInstances)
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)))
.limit(1);
if (existing.length > 0) return;
const templates = await tx
.select()
.from(taskTemplates)
.where(and(eq(taskTemplates.childUserId, childId), eq(taskTemplates.dow, dowOf(date))))
.orderBy(asc(taskTemplates.sortOrder));
if (templates.length > 0) {
await tx.insert(taskInstances).values(
templates.map((t) => ({
childUserId: childId,
date,
title: t.title,
requiresPhoto: t.requiresPhoto,
sortOrder: t.sortOrder,
sourceTemplateId: t.id,
})),
);
}
});
}
// 孩子端当日任务视图(含照片签名地址)
export async function dayTasks(childId: number, date: string): Promise<TaskView[]> {
await ensureDay(childId, date);
const rows = await db
.select({
id: taskInstances.id,
title: taskInstances.title,
requiresPhoto: taskInstances.requiresPhoto,
status: taskInstances.status,
isMakeup: taskInstances.isMakeup,
})
.from(taskInstances)
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)))
.orderBy(asc(taskInstances.sortOrder));
const taskIds = rows.map((r) => r.id);
const photoRows =
taskIds.length > 0
? await db
.select({ instanceId: photos.taskInstanceId, ossKey: photos.ossKey })
.from(photos)
.where(inArray(photos.taskInstanceId, taskIds))
: [];
const byTask = new Map<number, string[]>();
for (const p of photoRows) {
const list = byTask.get(p.instanceId) ?? [];
list.push(viewUrl(p.ossKey));
byTask.set(p.instanceId, list);
}
return rows.map((r) => ({ ...r, photoUrls: byTask.get(r.id) ?? [] }));
}
// 家长端概览用:某孩子某天的完成数/总数
export async function daySummary(childId: number, date: string) {
const rows = await db
.select({ id: taskInstances.id, status: taskInstances.status })
.from(taskInstances)
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)));
return {
total: rows.length,
done: rows.filter((r) => r.status === "done").length,
};
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
// 合并 Tailwind 类名,冲突时后者覆盖前者
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}