- point_rule 规则表 + point_log 流水表(0008 迁移),唯一键防刷:同一事件首次达标才发放,重复静默返回 0 - 打卡分值随任务设置(模板/实例 points 字段,默认 10);测试类 6 种事件按规则表取值,各有默认分(单词 20/数学课 15/语法点 15/周测 50×2/考试通过 100) - 发放埋点 6 处:打卡、单词检测、数学课、语法知识点、英语/数学周测、考试通过;周测达标联动打卡实例不重复计分 - 孩子端:/kid 总积分徽章,新增 /points 积分页(总积分+流水),测验结果页即时显示「+N 积分」,周测结果页刷新后从流水取回已发积分 - 家长端:功能管理新增「积分管理」Section(规则分值配置+每孩子总积分/最近流水);任务管理内联编辑任务分值 - 修复:drizzle 将 mysql2 重复键错误包进 cause,沿错误链识别 ER_DUP_ENTRY Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { db } from "@/db";
|
|
import { photos, taskInstances } from "@/db/schema";
|
|
import { todayStr } from "@/lib/dates";
|
|
import { photoKey, uploadUrl } from "@/lib/oss";
|
|
import { getSessionUser } from "@/lib/auth";
|
|
import { awardPoints } from "@/lib/points";
|
|
|
|
// 取当前孩子会话,非法则抛错(客户端会显示)
|
|
async function requireChild() {
|
|
const user = await getSessionUser();
|
|
if (!user || user.role !== "child") throw new Error("会话无效,请重新激活设备");
|
|
return user;
|
|
}
|
|
|
|
async function ownedTask(childId: number, instanceId: number) {
|
|
const [task] = await db
|
|
.select()
|
|
.from(taskInstances)
|
|
.where(and(eq(taskInstances.id, instanceId), eq(taskInstances.childUserId, childId)))
|
|
.limit(1);
|
|
if (!task) throw new Error("任务不存在");
|
|
return task;
|
|
}
|
|
|
|
// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾);首次勾选发积分
|
|
export async function toggleTask(instanceId: number, done: boolean): Promise<{ pointsEarned: number }> {
|
|
const child = await requireChild();
|
|
const task = await ownedTask(child.id, instanceId);
|
|
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
|
|
|
|
if (done && task.requiresPhoto) {
|
|
const [photo] = await db
|
|
.select({ id: photos.id })
|
|
.from(photos)
|
|
.where(eq(photos.taskInstanceId, instanceId))
|
|
.limit(1);
|
|
if (!photo) throw new Error("这个任务要先拍照凭证哦");
|
|
}
|
|
|
|
await db
|
|
.update(taskInstances)
|
|
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
|
|
.where(eq(taskInstances.id, instanceId));
|
|
|
|
// xuchao: 首次完成才发分(唯一键防刷),分值取任务快照上的设置
|
|
const pointsEarned = done
|
|
? await awardPoints(child.id, "checkin", `inst-${instanceId}`, task.points, task.title)
|
|
: 0;
|
|
|
|
revalidatePath("/kid");
|
|
revalidatePath("/points");
|
|
return { pointsEarned };
|
|
}
|
|
|
|
// 签发照片直传地址(浏览器 PUT 到对象存储)
|
|
export async function getUploadUrl(instanceId: number) {
|
|
const child = await requireChild();
|
|
const task = await ownedTask(child.id, instanceId);
|
|
const count = await db
|
|
.select({ id: photos.id })
|
|
.from(photos)
|
|
.where(eq(photos.taskInstanceId, instanceId));
|
|
if (count.length >= 3) throw new Error("最多上传 3 张照片");
|
|
|
|
const key = photoKey(child.id, task.date);
|
|
return { url: uploadUrl(key, "image/jpeg"), key };
|
|
}
|
|
|
|
// 直传完成后登记照片
|
|
export async function confirmPhoto(instanceId: number, key: string) {
|
|
const child = await requireChild();
|
|
await ownedTask(child.id, instanceId);
|
|
if (!key.startsWith(`photos/${child.id}/`)) throw new Error("照片路径非法");
|
|
await db.insert(photos).values({ taskInstanceId: instanceId, ossKey: key });
|
|
revalidatePath("/kid");
|
|
}
|