Checkpoint: v3.0 - 新增训练视频教程库(分类浏览、自评系统)、训练提醒通知(多类型提醒、浏览器推送)、通知记录管理、去除冗余文字。65个测试全部通过。

这个提交包含在:
Manus
2026-03-14 08:28:57 -04:00
父节点 2c418b482e
当前提交 27083d5af9
修改 19 个文件,包含 2856 行新增32 行删除

查看文件

@@ -10,6 +10,10 @@ import {
ratingHistory, InsertRatingHistory,
dailyCheckins, InsertDailyCheckin,
userBadges, InsertUserBadge,
tutorialVideos, InsertTutorialVideo,
tutorialProgress, InsertTutorialProgress,
trainingReminders, InsertTrainingReminder,
notificationLog, InsertNotificationLog,
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -429,6 +433,233 @@ export async function getLeaderboard(sortBy: "ntrpRating" | "totalMinutes" | "to
}).from(users).orderBy(desc(sortColumn)).limit(limit);
}
// ===== TUTORIAL OPERATIONS =====
export const TUTORIAL_SEED_DATA: Omit<InsertTutorialVideo, "id">[] = [
{
title: "正手击球基础",
category: "forehand",
skillLevel: "beginner",
description: "学习正手击球的基本站位、握拍方式和挥拍轨迹,建立稳定的正手基础。",
keyPoints: JSON.stringify(["东方式或半西方式握拍", "侧身引拍,肩膀转动90度", "从低到高的挥拍轨迹", "随挥至对侧肩膀", "重心转移从后脚到前脚"]),
commonMistakes: JSON.stringify(["手腕过度发力", "没有转体", "击球点太靠后", "随挥不充分"]),
duration: 300,
sortOrder: 1,
},
{
title: "反手击球基础",
category: "backhand",
skillLevel: "beginner",
description: "掌握单手和双手反手的核心技术,包括握拍转换和击球时机。",
keyPoints: JSON.stringify(["双手反手更适合初学者", "早引拍,肩膀充分转动", "击球点在身体前方", "保持手臂伸展"]),
commonMistakes: JSON.stringify(["只用手臂发力", "击球点太迟", "缺少随挥", "脚步不到位"]),
duration: 300,
sortOrder: 2,
},
{
title: "发球技术",
category: "serve",
skillLevel: "beginner",
description: "从抛球、引拍到击球的完整发球动作分解与练习。",
keyPoints: JSON.stringify(["稳定的抛球是关键", "大陆式握拍", "引拍时身体充分弓身", "最高点击球", "手腕内旋加速"]),
commonMistakes: JSON.stringify(["抛球不稳定", "手臂弯曲击球", "重心没有向前", "发力时机不对"]),
duration: 360,
sortOrder: 3,
},
{
title: "截击技术",
category: "volley",
skillLevel: "intermediate",
description: "网前截击的站位、准备姿势和击球技巧。",
keyPoints: JSON.stringify(["分腿弯膝准备姿势", "拍头保持在视线前方", "短促的击球动作", "步伐迎向球"]),
commonMistakes: JSON.stringify(["挥拍幅度太大", "站位太远", "拍面角度不对", "重心太高"]),
duration: 240,
sortOrder: 4,
},
{
title: "脚步移动训练",
category: "footwork",
skillLevel: "beginner",
description: "网球基础脚步训练,包括分步、交叉步、滑步和回位。",
keyPoints: JSON.stringify(["分步判断球的方向", "交叉步快速移动", "小碎步调整位置", "击球后快速回中"]),
commonMistakes: JSON.stringify(["脚步懒散不移动", "重心太高", "回位太慢", "没有分步"]),
duration: 240,
sortOrder: 5,
},
{
title: "正手上旋",
category: "forehand",
skillLevel: "intermediate",
description: "掌握正手上旋球的发力技巧和拍面角度控制。",
keyPoints: JSON.stringify(["半西方式或西方式握拍", "从低到高的刷球动作", "加速手腕内旋", "随挥结束在头部上方"]),
commonMistakes: JSON.stringify(["拍面太开放", "没有刷球动作", "随挥不充分"]),
duration: 300,
sortOrder: 6,
},
{
title: "发球变化(切削/上旋)",
category: "serve",
skillLevel: "advanced",
description: "高级发球技术,包括切削发球和Kick发球的动作要领。",
keyPoints: JSON.stringify(["切削发球:侧旋切球", "Kick发球从下到上刷球", "抛球位置根据发球类型调整", "手腕加速是关键"]),
commonMistakes: JSON.stringify(["抛球位置没有变化", "旋转不足", "发力方向错误"]),
duration: 360,
sortOrder: 7,
},
{
title: "影子挥拍练习",
category: "shadow",
skillLevel: "beginner",
description: "不需要球的挥拍练习,专注于动作轨迹和肌肉记忆。",
keyPoints: JSON.stringify(["慢动作分解每个环节", "关注脚步和重心转移", "对着镜子检查姿势", "逐渐加快速度"]),
commonMistakes: JSON.stringify(["动作太快不规范", "忽略脚步", "没有完整的随挥"]),
duration: 180,
sortOrder: 8,
},
{
title: "墙壁练习技巧",
category: "wall",
skillLevel: "beginner",
description: "利用墙壁进行的各种练习方法,提升控球和反应能力。",
keyPoints: JSON.stringify(["保持适当距离", "控制力量和方向", "交替练习正反手", "注意脚步移动"]),
commonMistakes: JSON.stringify(["力量太大控制不住", "站位太近或太远", "只练习一种击球"]),
duration: 240,
sortOrder: 9,
},
{
title: "体能训练",
category: "fitness",
skillLevel: "beginner",
description: "网球专项体能训练,提升爆发力、敏捷性和耐力。",
keyPoints: JSON.stringify(["核心力量训练", "下肢爆发力练习", "敏捷性梯子训练", "拉伸和灵活性"]),
commonMistakes: JSON.stringify(["忽略热身", "训练过度", "动作不标准"]),
duration: 300,
sortOrder: 10,
},
{
title: "比赛策略基础",
category: "strategy",
skillLevel: "intermediate",
description: "网球比赛中的基本战术和策略运用。",
keyPoints: JSON.stringify(["控制球场深度", "变换节奏和方向", "利用对手弱点", "网前战术时机"]),
commonMistakes: JSON.stringify(["打法单一", "没有计划", "心态波动大"]),
duration: 300,
sortOrder: 11,
},
];
export async function seedTutorials() {
const db = await getDb();
if (!db) return;
const existing = await db.select().from(tutorialVideos).limit(1);
if (existing.length > 0) return; // Already seeded
for (const t of TUTORIAL_SEED_DATA) {
await db.insert(tutorialVideos).values(t);
}
}
export async function getTutorials(category?: string, skillLevel?: string) {
const db = await getDb();
if (!db) return [];
let conditions = [eq(tutorialVideos.isPublished, 1)];
if (category) conditions.push(eq(tutorialVideos.category, category));
if (skillLevel) conditions.push(eq(tutorialVideos.skillLevel, skillLevel as any));
return db.select().from(tutorialVideos).where(and(...conditions)).orderBy(tutorialVideos.sortOrder);
}
export async function getTutorialById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(tutorialVideos).where(eq(tutorialVideos.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserTutorialProgress(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(tutorialProgress).where(eq(tutorialProgress.userId, userId));
}
export async function updateTutorialProgress(userId: number, tutorialId: number, data: { watched?: number; selfScore?: number; notes?: string; comparisonVideoId?: number }) {
const db = await getDb();
if (!db) return;
const existing = await db.select().from(tutorialProgress)
.where(and(eq(tutorialProgress.userId, userId), eq(tutorialProgress.tutorialId, tutorialId)))
.limit(1);
if (existing.length > 0) {
await db.update(tutorialProgress).set(data).where(eq(tutorialProgress.id, existing[0].id));
} else {
await db.insert(tutorialProgress).values({ userId, tutorialId, ...data });
}
}
// ===== TRAINING REMINDER OPERATIONS =====
export async function getUserReminders(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(trainingReminders).where(eq(trainingReminders.userId, userId)).orderBy(desc(trainingReminders.createdAt));
}
export async function createReminder(data: InsertTrainingReminder) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(trainingReminders).values(data);
return result[0].insertId;
}
export async function updateReminder(reminderId: number, userId: number, data: Partial<InsertTrainingReminder>) {
const db = await getDb();
if (!db) return;
await db.update(trainingReminders).set(data).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
export async function deleteReminder(reminderId: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(trainingReminders).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
export async function toggleReminder(reminderId: number, userId: number, isActive: number) {
const db = await getDb();
if (!db) return;
await db.update(trainingReminders).set({ isActive }).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
// ===== NOTIFICATION OPERATIONS =====
export async function getUserNotifications(userId: number, limit = 50) {
const db = await getDb();
if (!db) return [];
return db.select().from(notificationLog).where(eq(notificationLog.userId, userId)).orderBy(desc(notificationLog.createdAt)).limit(limit);
}
export async function createNotification(data: InsertNotificationLog) {
const db = await getDb();
if (!db) return;
await db.insert(notificationLog).values(data);
}
export async function markNotificationRead(notificationId: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notificationLog).set({ isRead: 1 }).where(and(eq(notificationLog.id, notificationId), eq(notificationLog.userId, userId)));
}
export async function markAllNotificationsRead(userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notificationLog).set({ isRead: 1 }).where(eq(notificationLog.userId, userId));
}
export async function getUnreadNotificationCount(userId: number) {
const db = await getDb();
if (!db) return 0;
const result = await db.select({ count: sql<number>`count(*)` }).from(notificationLog)
.where(and(eq(notificationLog.userId, userId), eq(notificationLog.isRead, 0)));
return result[0]?.count || 0;
}
// ===== STATS HELPERS =====
export async function getUserStats(userId: number) {

查看文件

@@ -554,3 +554,222 @@ describe("BADGE_DEFINITIONS via badge.definitions endpoint", () => {
expect(badges.length).toBeGreaterThanOrEqual(20);
});
});
// ===== TUTORIAL TESTS =====
describe("tutorial.list", () => {
it("works without authentication (public)", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
try {
const result = await caller.tutorial.list({});
expect(Array.isArray(result)).toBe(true);
} catch (e: any) {
// DB error expected, but should not be auth error
expect(e.code).not.toBe("UNAUTHORIZED");
}
});
it("accepts category filter", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
try {
await caller.tutorial.list({ category: "forehand" });
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
it("accepts skillLevel filter", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
try {
await caller.tutorial.list({ skillLevel: "beginner" });
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
});
describe("tutorial.progress", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.tutorial.progress()).rejects.toThrow();
});
});
describe("tutorial.updateProgress input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.tutorial.updateProgress({ tutorialId: 1 })
).rejects.toThrow();
});
it("requires tutorialId", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(
caller.tutorial.updateProgress({ tutorialId: undefined as any })
).rejects.toThrow();
});
it("accepts optional watched, selfScore, notes", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
try {
await caller.tutorial.updateProgress({
tutorialId: 1,
watched: 1,
selfScore: 4,
notes: "Great tutorial",
});
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
});
// ===== REMINDER TESTS =====
describe("reminder.list", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.reminder.list()).rejects.toThrow();
});
});
describe("reminder.create input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.reminder.create({
reminderType: "training",
title: "Test",
timeOfDay: "08:00",
daysOfWeek: [1, 2, 3],
})
).rejects.toThrow();
});
it("accepts empty title (no min validation on server)", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
try {
await caller.reminder.create({
reminderType: "training",
title: "",
timeOfDay: "08:00",
daysOfWeek: [1],
});
} catch (e: any) {
// DB error expected, but input validation should pass
expect(e.message).not.toContain("invalid_type");
}
});
it("accepts any string as reminderType (no enum validation on server)", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
try {
await caller.reminder.create({
reminderType: "custom_type",
title: "Test",
timeOfDay: "08:00",
daysOfWeek: [1],
});
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
it("accepts valid reminder creation", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
try {
await caller.reminder.create({
reminderType: "training",
title: "Morning Training",
message: "Time to practice!",
timeOfDay: "08:00",
daysOfWeek: [1, 2, 3, 4, 5],
});
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
expect(e.message).not.toContain("invalid_enum_value");
}
});
});
describe("reminder.toggle input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.reminder.toggle({ reminderId: 1, isActive: 1 })
).rejects.toThrow();
});
});
describe("reminder.delete input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.reminder.delete({ reminderId: 1 })
).rejects.toThrow();
});
});
// ===== NOTIFICATION TESTS =====
describe("notification.list", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.notification.list()).rejects.toThrow();
});
});
describe("notification.unreadCount", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.notification.unreadCount()).rejects.toThrow();
});
});
describe("notification.markRead input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.notification.markRead({ notificationId: 1 })
).rejects.toThrow();
});
});
describe("notification.markAllRead", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.notification.markAllRead()).rejects.toThrow();
});
});

查看文件

@@ -471,6 +471,120 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50);
}),
}),
// Tutorial video library
tutorial: router({
list: publicProcedure
.input(z.object({
category: z.string().optional(),
skillLevel: z.string().optional(),
}).optional())
.query(async ({ input }) => {
// Auto-seed tutorials on first request
await db.seedTutorials();
return db.getTutorials(input?.category, input?.skillLevel);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getTutorialById(input.id);
}),
progress: protectedProcedure.query(async ({ ctx }) => {
return db.getUserTutorialProgress(ctx.user.id);
}),
updateProgress: protectedProcedure
.input(z.object({
tutorialId: z.number(),
watched: z.number().optional(),
selfScore: z.number().optional(),
notes: z.string().optional(),
comparisonVideoId: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { tutorialId, ...data } = input;
await db.updateTutorialProgress(ctx.user.id, tutorialId, data);
return { success: true };
}),
}),
// Training reminders
reminder: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserReminders(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
reminderType: z.string(),
title: z.string(),
message: z.string().optional(),
timeOfDay: z.string(),
daysOfWeek: z.array(z.number()),
}))
.mutation(async ({ ctx, input }) => {
const reminderId = await db.createReminder({
userId: ctx.user.id,
...input,
});
return { reminderId };
}),
update: protectedProcedure
.input(z.object({
reminderId: z.number(),
title: z.string().optional(),
message: z.string().optional(),
timeOfDay: z.string().optional(),
daysOfWeek: z.array(z.number()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { reminderId, ...data } = input;
await db.updateReminder(reminderId, ctx.user.id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ reminderId: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.deleteReminder(input.reminderId, ctx.user.id);
return { success: true };
}),
toggle: protectedProcedure
.input(z.object({ reminderId: z.number(), isActive: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.toggleReminder(input.reminderId, ctx.user.id, input.isActive);
return { success: true };
}),
}),
// Notifications
notification: router({
list: protectedProcedure
.input(z.object({ limit: z.number().default(50) }).optional())
.query(async ({ ctx, input }) => {
return db.getUserNotifications(ctx.user.id, input?.limit || 50);
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
return db.getUnreadNotificationCount(ctx.user.id);
}),
markRead: protectedProcedure
.input(z.object({ notificationId: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.markNotificationRead(input.notificationId, ctx.user.id);
return { success: true };
}),
markAllRead: protectedProcedure.mutation(async ({ ctx }) => {
await db.markAllNotificationsRead(ctx.user.id);
return { success: true };
}),
}),
});
// NTRP Rating calculation function