diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 8d695d4..06e3189 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -30,7 +30,7 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| | G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 | -| G0-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 | +| G0-2 | 新增挑战组数据模型 | [x] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 | | G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 | | G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 | | G0-5 | 新增每日任务或每日进度模型 | [ ] | 可统计每日首组挑战、每日任务完成、每日高奖励次数 | diff --git a/src/db/schema.ts b/src/db/schema.ts index b936189..0a126c5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -153,6 +153,63 @@ export const userChapterProgress = mysqlTable('user_chapter_progress', { foreignKey({ columns: [table.chapterId], foreignColumns: [skillTree.id] }), ]); +// ── Challenge Sessions ───────────────────────────────────────────── + +// 用户挑战组会话数据,服务端以 5 题为一组裁决进度和奖励。 +export const challengeSessions = mysqlTable('challenge_sessions', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + trackId: varchar('track_id', { length: 50 }).notNull(), // 客户端选择的学习路线。 + categoryId: varchar('category_id', { length: 50 }).notNull(), // 本组题目所属主题分类。 + chapterId: char('chapter_id', { length: 36 }), // 绑定的技能树节点或章节。 + status: mysqlEnum('status', ['pending', 'in_progress', 'completed', 'abandoned', 'expired']).default('pending'), // 挑战组状态。 + clientRequestId: varchar('client_request_id', { length: 80 }).notNull(), // 创建挑战组的客户端幂等请求号。 + completeRequestId: varchar('complete_request_id', { length: 80 }), // 完成结算的客户端幂等请求号。 + questionIds: json('question_ids').$type().notNull(), // 本组题目 ID 快照,不包含正确答案。 + totalQuestions: tinyint('total_questions').default(5), // 本组题目总数。 + answeredCount: tinyint('answered_count').default(0), // 已提交答案数量。 + correctCount: tinyint('correct_count').default(0), // 当前正确数量。 + highRewardEligible: tinyint('high_reward_eligible').default(1), // 是否消耗并享受每日高奖励次数。 + rewardSnapshot: json('reward_snapshot').$type>(), // 完成结算后的奖励快照。 + progressBefore: json('progress_before').$type>(), // 创建或结算前的资源快照。 + progressAfter: json('progress_after').$type>(), // 完成结算后的资源快照。 + expiresAt: datetime('expires_at'), // 会话过期时间。 + completedAt: datetime('completed_at'), // 组内题目完成并结算的时间。 + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。 + updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。 +}, (table) => [ + uniqueIndex('uk_challenge_session_user_client_request').on(table.userId, table.clientRequestId), + uniqueIndex('uk_challenge_session_user_complete_request').on(table.userId, table.completeRequestId), + index('idx_challenge_session_user_status_created').on(table.userId, table.status, table.createdAt), + index('idx_challenge_session_chapter_status').on(table.chapterId, table.status), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), + foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }), + foreignKey({ columns: [table.chapterId], foreignColumns: [skillTree.id] }), +]); + +// 用户挑战组内的单题提交记录,支撑重复提交幂等和组完成结算。 +export const challengeSessionAnswers = mysqlTable('challenge_session_answers', { + id: char('id', { length: 36 }).primaryKey(), + sessionId: char('session_id', { length: 36 }).notNull(), + userId: char('user_id', { length: 36 }).notNull(), + questionId: char('question_id', { length: 36 }).notNull(), + submitRequestId: varchar('submit_request_id', { length: 80 }).notNull(), // 单题提交的客户端幂等请求号。 + answerOrder: tinyint('answer_order').notNull(), // 本题在挑战组中的顺序。 + answer: varchar('answer', { length: 500 }), // 用户提交答案。 + correct: tinyint('correct').notNull(), // 本次提交是否正确。 + timeMs: int('time_ms'), // 答题耗时,单位毫秒。 + comboCount: tinyint('combo_count').default(0), // 提交后组内连续答对数。 + resultSnapshot: json('result_snapshot').$type>(), // 返回客户端的本题裁决快照。 + submittedAt: datetime('submitted_at').default(sql`CURRENT_TIMESTAMP`), // 提交时间。 +}, (table) => [ + uniqueIndex('uk_challenge_answer_session_question').on(table.sessionId, table.questionId), + uniqueIndex('uk_challenge_answer_session_request').on(table.sessionId, table.submitRequestId), + index('idx_challenge_answer_user_submitted').on(table.userId, table.submittedAt), + foreignKey({ columns: [table.sessionId], foreignColumns: [challengeSessions.id] }), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), + foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }), +]); + // ── Question Ratings ────────────────────────────────────────────── // 用户对题目的好坏反馈数据。