第一期:家庭自学打卡系统
- 家长手机号登录 + 孩子设备一次性激活(长期会话) - 周模板自动生成每日任务快照,支持拍照凭证直传对象存储 - 连击计算(空白日自动达成)、家长补卡带标记 - 家长端三标签:今日概览 / 任务模板 / 设备管理 - Docker 独立输出,经 Traefik 接入 jump.hn12301.net Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user