- 新增 POST /auth/fusion/token 获取 SDK 鉴权 Token - 新增 POST /auth/fusion/verify 用 verifyToken 换取手机号并登录/注册 - 新增 GET /auth/providers 按平台返回可用登录方式列表 - 新增 PUT /admin/auth-providers 管理端热切换第三方登录开关 - 新增 appSettings 表存储运行时配置,支持不重启生效 - 修复 schema 中超长外键名称导致的 db:push 失败
547 lines
36 KiB
TypeScript
547 lines
36 KiB
TypeScript
import {
|
||
mysqlTable,
|
||
char,
|
||
mysqlEnum,
|
||
varchar,
|
||
int,
|
||
tinyint,
|
||
smallint,
|
||
decimal,
|
||
text,
|
||
json,
|
||
date,
|
||
datetime,
|
||
uniqueIndex,
|
||
foreignKey,
|
||
index,
|
||
} from 'drizzle-orm/mysql-core';
|
||
import { sql } from 'drizzle-orm';
|
||
|
||
// ── Users ──────────────────────────────────────────────────────────
|
||
|
||
// 用户账号与学习状态数据。
|
||
export const users = mysqlTable('users', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
authType: mysqlEnum('auth_type', ['huawei', 'guest', 'phone', 'apple', 'google']).notNull(), // 登录方式。
|
||
authId: varchar('auth_id', { length: 255 }).notNull(), // 第三方平台或游客身份标识。
|
||
nickname: varchar('nickname', { length: 50 }), // 用户昵称。
|
||
avatarUrl: varchar('avatar_url', { length: 500 }), // 头像图片地址。
|
||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'), // 订阅等级。
|
||
xpTotal: int('xp_total').default(0), // 累计经验值。
|
||
streakDays: int('streak_days').default(0), // 当前连续学习天数。
|
||
streakLastDate: date('streak_last_date'), // 最近一次计入连续学习的日期。
|
||
heartsRemaining: tinyint('hearts_remaining').default(5), // 当前剩余红心数。
|
||
heartsLastRestore: datetime('hearts_last_restore'), // 最近一次红心自然恢复时间。
|
||
dailyXpGoal: smallint('daily_xp_goal').default(50), // 每日经验目标。
|
||
dailyXpEarned: smallint('daily_xp_earned').default(0), // 当日已获得经验值。
|
||
dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。
|
||
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。
|
||
activeTrackId: varchar('active_track_id', { length: 50 }), // 当前学习路径。
|
||
dailyAttemptsLeft: smallint('daily_attempts_left').default(5), // 当日剩余挑战次数。
|
||
dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。
|
||
checkInDays: int('check_in_days').default(0), // 累计签到天数。
|
||
lastCheckInDate: date('last_check_in_date'), // 最近一次签到日期。
|
||
streakProtectedUntil: datetime('streak_protected_until'), // 连续学习保护失效时间。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_auth').on(table.authType, table.authId),
|
||
]);
|
||
|
||
// ── Categories ─────────────────────────────────────────────────────
|
||
|
||
// 题目分类与学习主题数据。
|
||
export const categories = mysqlTable('categories', {
|
||
id: varchar('id', { length: 50 }).primaryKey(),
|
||
name: varchar('name', { length: 100 }).notNull(), // 分类名称。
|
||
slug: varchar('slug', { length: 100 }).notNull(), // 面向 URL 或导入数据的唯一标识。
|
||
parentId: varchar('parent_id', { length: 50 }), // 父级分类。
|
||
sortOrder: int('sort_order').default(0), // 排序权重,数值越小越靠前。
|
||
questionCount: int('question_count').default(0), // 分类下题目数量缓存。
|
||
status: mysqlEnum('status', ['active', 'inactive']).default('active'), // 分类启用状态。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_slug').on(table.slug),
|
||
]);
|
||
|
||
// ── Questions ──────────────────────────────────────────────────────
|
||
|
||
// 题库题目与答题统计数据。
|
||
export const questions = mysqlTable('questions', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
stem: json('stem').notNull(), // 题干内容,支持多语言或富媒体结构。
|
||
contentType: mysqlEnum('content_type', ['text', 'image', 'video', 'audio']).notNull(), // 题目内容类型。
|
||
correctAnswer: varchar('correct_answer', { length: 500 }).notNull(), // 正确答案。
|
||
distractors: json('distractors').notNull(), // 干扰选项列表。
|
||
categoryId: varchar('category_id', { length: 50 }).notNull(),
|
||
difficulty: tinyint('difficulty'), // 人工配置的难度等级。
|
||
dynamicDifficulty: decimal('dynamic_difficulty', { precision: 3, scale: 1 }), // 根据答题表现计算的动态难度。
|
||
source: mysqlEnum('source', ['system', 'ugc']).default('system'), // 题目来源。
|
||
creatorId: char('creator_id', { length: 36 }),
|
||
status: mysqlEnum('status', ['draft', 'reviewing', 'published', 'archived']).default('draft'), // 题目发布状态。
|
||
stats: json('stats').$type<{ timesAnswered: number; correctRate: number; avgTimeMs: number }>()
|
||
.default({ timesAnswered: 0, correctRate: 0, avgTimeMs: 0 }), // 答题次数、正确率和平均耗时统计。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }),
|
||
]);
|
||
|
||
// ── Knowledge Cards ────────────────────────────────────────────────
|
||
|
||
// 题目关联的知识讲解卡片数据。
|
||
export const knowledgeCards = mysqlTable('knowledge_cards', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
questionId: char('question_id', { length: 36 }).notNull(),
|
||
summary: varchar('summary', { length: 300 }).notNull(), // 答题后的知识点摘要。
|
||
deepDive: text('deep_dive'), // 延伸讲解内容。
|
||
sourceRef: varchar('source_ref', { length: 500 }), // 参考来源或出处。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_question').on(table.questionId),
|
||
foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }),
|
||
]);
|
||
|
||
// ── User Progress ──────────────────────────────────────────────────
|
||
|
||
// 用户单题答题记录数据。
|
||
export const userProgress = mysqlTable('user_progress', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
questionId: char('question_id', { length: 36 }).notNull(),
|
||
correct: tinyint('correct').notNull(), // 本次答题是否正确。
|
||
timeMs: int('time_ms'), // 答题耗时,单位毫秒。
|
||
answeredAt: datetime('answered_at').default(sql`CURRENT_TIMESTAMP`), // 答题时间。
|
||
}, (table) => [
|
||
index('idx_user_answered').on(table.userId, table.answeredAt),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }),
|
||
]);
|
||
|
||
// ── Skill Tree ─────────────────────────────────────────────────────
|
||
|
||
// 分类下的学习关卡树数据。
|
||
export const skillTree = mysqlTable('skill_tree', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
categoryId: varchar('category_id', { length: 50 }).notNull(),
|
||
title: varchar('title', { length: 100 }).notNull(), // 关卡标题。
|
||
parentId: char('parent_id', { length: 36 }),
|
||
sortOrder: int('sort_order').default(0), // 同级关卡排序权重。
|
||
questionsRequired: tinyint('questions_required').default(4), // 完成本关需要回答的题目数。
|
||
passThreshold: tinyint('pass_threshold').default(2), // 判定通关所需正确题数。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
}, (table) => [
|
||
foreignKey({ columns: [table.categoryId], foreignColumns: [categories.id] }),
|
||
]);
|
||
|
||
// ── User Chapter Progress ──────────────────────────────────────────
|
||
|
||
// 用户在学习关卡中的进度数据。
|
||
export const userChapterProgress = mysqlTable('user_chapter_progress', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
chapterId: char('chapter_id', { length: 36 }).notNull(),
|
||
status: mysqlEnum('status', ['locked', 'unlocked', 'passed', 'perfect']).default('locked'), // 用户在关卡中的进度状态。
|
||
bestCorrectCount: tinyint('best_correct_count').default(0), // 历史最好正确题数。
|
||
attempts: int('attempts').default(0), // 累计挑战次数。
|
||
completedAt: datetime('completed_at'), // 最近一次通关时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_user_chapter').on(table.userId, table.chapterId),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
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<readonly string[]>().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<Record<string, unknown>>(), // 完成结算后的奖励快照。
|
||
progressBefore: json('progress_before').$type<Record<string, unknown>>(), // 创建或结算前的资源快照。
|
||
progressAfter: json('progress_after').$type<Record<string, unknown>>(), // 完成结算后的资源快照。
|
||
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<Record<string, unknown>>(), // 返回客户端的本题裁决快照。
|
||
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] }),
|
||
]);
|
||
|
||
// ── Wallets and Inventory ──────────────────────────────────────────
|
||
|
||
// 用户金币钱包,所有余额变化必须通过流水记录追踪。
|
||
export const userWallets = mysqlTable('user_wallets', {
|
||
userId: char('user_id', { length: 36 }).primaryKey(),
|
||
coinsBalance: int('coins_balance').default(0), // 当前金币余额。
|
||
lifetimeCoinsEarned: int('lifetime_coins_earned').default(0), // 历史累计获得金币。
|
||
lifetimeCoinsSpent: int('lifetime_coins_spent').default(0), // 历史累计消耗金币。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// 用户道具库存,以 item_id 聚合当前可用数量和最近有效期。
|
||
export const userInventoryItems = mysqlTable('user_inventory_items', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
itemId: mysqlEnum('item_id', ['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 道具标识。
|
||
quantity: int('quantity').default(0), // 当前库存数量。
|
||
activeUntil: datetime('active_until'), // 时效型道具的生效截止时间。
|
||
metadata: json('metadata').$type<Record<string, unknown>>(), // 装扮、头像框等展示权益的扩展信息。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_inventory_user_item').on(table.userId, table.itemId),
|
||
index('idx_inventory_user_active').on(table.userId, table.activeUntil),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// 钱包和道具库存流水,记录金币与道具的获得、消耗和调整来源。
|
||
export const inventoryTransactions = mysqlTable('inventory_transactions', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
inventoryItemId: char('inventory_item_id', { length: 36 }),
|
||
itemId: mysqlEnum('item_id', ['coins', 'streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 流水涉及的资源。
|
||
direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。
|
||
quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。
|
||
balanceAfter: int('balance_after'), // 变更后的金币余额或道具库存。
|
||
sourceType: mysqlEnum('source_type', ['challenge', 'daily_task', 'level_up', 'theme_node', 'chest', 'shop_purchase', 'ad_recovery', 'subscription', 'admin_grant', 'system_adjust', 'leaderboard_settlement']).notNull(), // 资源变化来源。
|
||
sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID,如挑战组、订单或广告会话。
|
||
idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。
|
||
snapshot: json('snapshot').$type<Record<string, unknown>>(), // 本次变更的上下文快照。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_inventory_transaction_idempotency').on(table.userId, table.idempotencyKey),
|
||
index('idx_inventory_transaction_user_created').on(table.userId, table.createdAt),
|
||
index('idx_inventory_transaction_source').on(table.sourceType, table.sourceId),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
foreignKey({ name: 'fk_inv_tx_item', columns: [table.inventoryItemId], foreignColumns: [userInventoryItems.id] }),
|
||
]);
|
||
|
||
// ── Reward Ledger ─────────────────────────────────────────────────
|
||
|
||
// 统一奖励结算流水,记录奖励来源、幂等边界、快照和发放前后状态。
|
||
export const rewardLedger = mysqlTable('reward_ledger', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
sourceType: mysqlEnum('source_type', ['challenge_answer', 'challenge_completion', 'daily_task', 'streak_milestone', 'level_up', 'theme_node', 'knowledge_card', 'chest', 'shop_purchase', 'ad_recovery', 'leaderboard_settlement', 'subscription', 'admin_grant', 'system_adjust']).notNull(), // 奖励来源。
|
||
sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID。
|
||
idempotencyKey: varchar('idempotency_key', { length: 160 }).notNull(), // 奖励结算幂等 key。
|
||
status: mysqlEnum('status', ['pending', 'settling', 'completed', 'failed', 'reversed']).default('pending'), // 奖励结算状态。
|
||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>().notNull(), // 计划发放或已发放的奖励快照。
|
||
resourceDeltas: json('resource_deltas').$type<Record<string, unknown>>(), // XP、金币、红心、道具等资源变化。
|
||
stateBefore: json('state_before').$type<Record<string, unknown>>(), // 发放前用户资源状态。
|
||
stateAfter: json('state_after').$type<Record<string, unknown>>(), // 发放后用户资源状态。
|
||
failureReason: varchar('failure_reason', { length: 120 }), // 结算失败或回滚原因。
|
||
settledAt: datetime('settled_at'), // 奖励完成结算时间。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_reward_ledger_user_idempotency').on(table.userId, table.idempotencyKey),
|
||
index('idx_reward_ledger_user_status_created').on(table.userId, table.status, table.createdAt),
|
||
index('idx_reward_ledger_source').on(table.sourceType, table.sourceId),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// ── Daily Progress ────────────────────────────────────────────────
|
||
|
||
// 用户每日游戏化进度,聚合首组挑战、每日任务和高奖励次数。
|
||
export const userDailyProgress = mysqlTable('user_daily_progress', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
progressDate: date('progress_date').notNull(), // 统计日期,服务端按 UTC 自然日写入。
|
||
timezone: varchar('timezone', { length: 50 }).default('UTC'), // 客户端或服务端用于展示的时区。
|
||
firstChallengeSessionId: char('first_challenge_session_id', { length: 36 }), // 当日首组完成挑战。
|
||
firstChallengeCompletedAt: datetime('first_challenge_completed_at'), // 当日首组挑战完成时间。
|
||
challengeSessionsCompleted: smallint('challenge_sessions_completed').default(0), // 当日完成挑战组数。
|
||
highRewardSessionsMax: smallint('high_reward_sessions_max').default(3), // 当日高奖励挑战次数上限。
|
||
highRewardSessionsUsed: smallint('high_reward_sessions_used').default(0), // 当日已消耗高奖励次数。
|
||
highRewardSessionsRestored: smallint('high_reward_sessions_restored').default(0), // 当日通过广告等方式恢复的高奖励次数。
|
||
dailyTasksCompleted: smallint('daily_tasks_completed').default(0), // 当日已完成任务数。
|
||
dailyTasksRewardClaimed: smallint('daily_tasks_reward_claimed').default(0), // 当日已领取任务奖励数。
|
||
xpEarned: int('xp_earned').default(0), // 当日获得 XP。
|
||
coinsEarned: int('coins_earned').default(0), // 当日获得金币。
|
||
streakCounted: tinyint('streak_counted').default(0), // 当日是否已计入连续学习。
|
||
metadata: json('metadata').$type<Record<string, unknown>>(), // 每日宝箱、降级倍率等扩展状态。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_daily_progress_user_date').on(table.userId, table.progressDate),
|
||
index('idx_daily_progress_date').on(table.progressDate),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
foreignKey({ name: 'fk_daily_progress_session', columns: [table.firstChallengeSessionId], foreignColumns: [challengeSessions.id] }),
|
||
]);
|
||
|
||
// 用户每日任务进度,用于幂等记录任务完成和奖励领取。
|
||
export const userDailyTasks = mysqlTable('user_daily_tasks', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
dailyProgressId: char('daily_progress_id', { length: 36 }).notNull(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
taskDate: date('task_date').notNull(), // 任务归属日期,服务端按 UTC 自然日写入。
|
||
taskId: varchar('task_id', { length: 80 }).notNull(), // 每日任务配置标识。
|
||
taskType: mysqlEnum('task_type', ['complete_challenge', 'earn_xp', 'answer_correct', 'review_explanation', 'use_item', 'watch_ad']).notNull(), // 任务类型。
|
||
targetCount: smallint('target_count').default(1), // 任务目标次数。
|
||
currentCount: smallint('current_count').default(0), // 当前完成次数。
|
||
status: mysqlEnum('status', ['active', 'completed', 'reward_claimed', 'expired']).default('active'), // 任务状态。
|
||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 任务奖励快照。
|
||
completedAt: datetime('completed_at'), // 任务完成时间。
|
||
rewardClaimedAt: datetime('reward_claimed_at'), // 奖励领取时间。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_daily_task_user_date_task').on(table.userId, table.taskDate, table.taskId),
|
||
index('idx_daily_task_progress_status').on(table.dailyProgressId, table.status),
|
||
foreignKey({ columns: [table.dailyProgressId], foreignColumns: [userDailyProgress.id] }),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// ── Question Ratings ──────────────────────────────────────────────
|
||
|
||
// 用户对题目的好坏反馈数据。
|
||
export const questionRatings = mysqlTable('question_ratings', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
questionId: char('question_id', { length: 36 }).notNull(),
|
||
rating: mysqlEnum('rating', ['good', 'bad']).notNull(), // 用户对题目的反馈评价。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_user_question_rating').on(table.userId, table.questionId),
|
||
]);
|
||
|
||
// ── User Feedback ──────────────────────────────────────────────────
|
||
|
||
// 用户提交的产品反馈数据。
|
||
export const userFeedback = mysqlTable('user_feedback', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
content: text('content').notNull(), // 用户提交的反馈内容。
|
||
contact: varchar('contact', { length: 255 }), // 用户留下的联系方式。
|
||
pageContext: varchar('page_context', { length: 200 }), // 反馈来源页面或场景。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
});
|
||
|
||
// ── Achievements ──────────────────────────────────────────────────
|
||
|
||
// 可解锁的成就配置数据。
|
||
export const achievements = mysqlTable('achievements', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
type: mysqlEnum('type', ['knowledge', 'behavior']).notNull(), // 成就类型。
|
||
name: varchar('name', { length: 100 }).notNull(), // 成就名称。
|
||
description: varchar('description', { length: 300 }).notNull(), // 成就说明。
|
||
iconUrl: varchar('icon_url', { length: 500 }), // 成就图标地址。
|
||
condition: json('condition').notNull(), // 解锁条件配置。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
});
|
||
|
||
// 用户已解锁成就数据。
|
||
export const userAchievements = mysqlTable('user_achievements', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
achievementId: char('achievement_id', { length: 36 }).notNull(),
|
||
unlockedAt: datetime('unlocked_at').default(sql`CURRENT_TIMESTAMP`), // 解锁时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_user_achievement').on(table.userId, table.achievementId),
|
||
]);
|
||
|
||
// ── Leaderboard Snapshots ──────────────────────────────────────────
|
||
|
||
// 用户排行榜周期快照数据。
|
||
export const leaderboardSnapshots = mysqlTable('leaderboard_snapshots', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
tier: mysqlEnum('tier', ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic']).notNull(), // 排行榜段位。
|
||
weeklyXp: int('weekly_xp').default(0), // 本周累计经验值。
|
||
rank: int('rank'), // 当前排名。
|
||
groupId: varchar('group_id', { length: 80 }), // 周榜分组 ID。
|
||
league: varchar('league', { length: 50 }), // 所属联赛或榜单分组。
|
||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 周结算奖励预览或实际发放快照。
|
||
settledAt: datetime('settled_at'), // 周榜结算时间。
|
||
weekStart: date('week_start').notNull(), // 统计周开始日期,按 UTC 自然周一。
|
||
weekEnd: date('week_end').notNull(), // 统计周结束日期,按 UTC 自然周日。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_leaderboard_snapshot_user_week').on(table.userId, table.weekStart),
|
||
index('idx_leaderboard_snapshot_group_rank').on(table.groupId, table.weekStart, table.rank),
|
||
]);
|
||
|
||
// 用户每周 XP 统计,作为当前周排行榜和历史快照的累计数据源。
|
||
export const userWeeklyXp = mysqlTable('user_weekly_xp', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
weekStart: date('week_start').notNull(), // UTC 自然周一。
|
||
weekEnd: date('week_end').notNull(), // UTC 自然周日。
|
||
timezone: varchar('timezone', { length: 50 }).default('UTC'), // 周期展示时区。
|
||
xpEarned: int('xp_earned').default(0), // 本周累计 XP。
|
||
challengeSessionsCompleted: int('challenge_sessions_completed').default(0), // 本周完成挑战组数。
|
||
groupId: varchar('group_id', { length: 80 }), // 分配到的周榜分组 ID。
|
||
rank: int('rank'), // 当前组内排名缓存。
|
||
settled: tinyint('settled').default(0), // 本周是否已完成结算。
|
||
settledAt: datetime('settled_at'), // 结算时间。
|
||
lastXpAt: datetime('last_xp_at'), // 最近一次 XP 累加时间。
|
||
nextRefreshAt: datetime('next_refresh_at'), // 下一次周榜刷新时间。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_weekly_xp_user_week').on(table.userId, table.weekStart),
|
||
index('idx_weekly_xp_group_rank').on(table.groupId, table.weekStart, table.xpEarned),
|
||
index('idx_weekly_xp_week_settled').on(table.weekStart, table.settled),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// ── Subscriptions ──────────────────────────────────────────────────
|
||
|
||
// 用户订阅权益与平台购买数据。
|
||
export const subscriptions = mysqlTable('subscriptions', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
tier: mysqlEnum('tier', ['free', 'pro', 'proplus']).default('free'), // 订阅等级。
|
||
platform: mysqlEnum('platform', ['huawei', 'apple', 'google']), // 购买平台。
|
||
purchaseToken: varchar('purchase_token', { length: 500 }), // 平台购买凭证。
|
||
expiresAt: datetime('expires_at'), // 订阅到期时间。
|
||
autoRenew: tinyint('auto_renew').default(0), // 是否自动续订。
|
||
status: mysqlEnum('status', ['active', 'expired', 'cancelled']).default('active'), // 订阅状态。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_subscription_user').on(table.userId),
|
||
]);
|
||
|
||
// ── Rewarded Ad Recovery Sessions ─────────────────────────────────
|
||
|
||
// 激励广告恢复奖励的创建与结算会话数据。
|
||
export const adRecoverySessions = mysqlTable('ad_recovery_sessions', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
userId: char('user_id', { length: 36 }).notNull(),
|
||
type: mysqlEnum('type', ['hearts', 'bonusAttempts', 'streakProtection']).notNull(), // 广告恢复奖励类型。
|
||
status: mysqlEnum('status', ['pending', 'settling', 'completed', 'failed', 'expired']).default('pending'), // 会话处理状态。
|
||
clientRequestId: varchar('client_request_id', { length: 80 }).notNull(), // 客户端创建会话时的幂等请求号。
|
||
completeRequestId: varchar('complete_request_id', { length: 80 }), // 客户端完成结算时的幂等请求号。
|
||
platform: mysqlEnum('platform', ['ios', 'android', 'harmony', 'web']).notNull(), // 发起广告恢复的平台。
|
||
adProvider: varchar('ad_provider', { length: 50 }).notNull(), // 广告服务提供方。
|
||
adPlacementId: varchar('ad_placement_id', { length: 120 }).notNull(), // 广告位标识。
|
||
providerRewardToken: varchar('provider_reward_token', { length: 500 }), // 广告平台返回的奖励凭证。
|
||
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(), // 本次奖励结算快照。
|
||
progressBefore: json('progress_before').$type<Record<string, unknown>>(), // 结算前的用户进度快照。
|
||
progressAfter: json('progress_after').$type<Record<string, unknown>>(), // 结算后的用户进度快照。
|
||
failureReason: varchar('failure_reason', { length: 80 }), // 业务失败原因。
|
||
providerError: varchar('provider_error', { length: 500 }), // 广告平台错误信息。
|
||
duplicateCount: int('duplicate_count').default(0), // 重复完成请求次数。
|
||
expiresAt: datetime('expires_at').notNull(), // 会话过期时间。
|
||
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_ad_recovery_user_client_request').on(table.userId, table.clientRequestId),
|
||
index('idx_ad_recovery_user_type_status_created').on(table.userId, table.type, table.status, table.createdAt),
|
||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// ── Account Migrations ─────────────────────────────────────────────
|
||
|
||
// 游客账号关联正式账号的迁移记录,用于幂等追踪。
|
||
export const accountMigrations = mysqlTable('account_migrations', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
guestUserId: char('guest_user_id', { length: 36 }).notNull(),
|
||
formalUserId: char('formal_user_id', { length: 36 }),
|
||
provider: mysqlEnum('provider', ['apple', 'google', 'phone']).notNull(),
|
||
providerUserId: varchar('provider_user_id', { length: 255 }).notNull(),
|
||
clientMigrationId: varchar('client_migration_id', { length: 80 }).notNull(),
|
||
status: mysqlEnum('status', ['in_progress', 'completed', 'failed']).default('in_progress'),
|
||
migrationSummary: json('migration_summary').$type<{
|
||
policy: string;
|
||
imported: Record<string, number>;
|
||
skipped: Record<string, number>;
|
||
conflicts: readonly string[];
|
||
}>(),
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||
completedAt: datetime('completed_at'),
|
||
}, (table) => [
|
||
uniqueIndex('uk_migration_guest_client').on(table.guestUserId, table.clientMigrationId),
|
||
uniqueIndex('uk_migration_guest_provider').on(table.guestUserId, table.provider),
|
||
foreignKey({ columns: [table.guestUserId], foreignColumns: [users.id] }),
|
||
foreignKey({ columns: [table.formalUserId], foreignColumns: [users.id] }),
|
||
]);
|
||
|
||
// ── Admin Audit Log ────────────────────────────────────────────────
|
||
|
||
// 管理端操作审计日志数据。
|
||
export const adminAuditLog = mysqlTable('admin_audit_log', {
|
||
id: int('id').primaryKey().autoincrement(),
|
||
adminId: varchar('admin_id', { length: 36 }).notNull(),
|
||
action: varchar('action', { length: 10 }).notNull(), // 操作类型。
|
||
resource: varchar('resource', { length: 500 }), // 被操作的资源。
|
||
details: json('details'), // 操作详情。
|
||
ipAddress: varchar('ip_address', { length: 45 }), // 操作来源 IP。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
});
|
||
|
||
// ── Admin Users ───────────────────────────────────────────────────────
|
||
|
||
// 管理端账号数据。
|
||
export const adminUsers = mysqlTable('admin_users', {
|
||
id: char('id', { length: 36 }).primaryKey(),
|
||
username: varchar('username', { length: 50 }).notNull(), // 管理员登录名。
|
||
passwordHash: varchar('password_hash', { length: 255 }).notNull(), // 密码哈希。
|
||
role: mysqlEnum('role', ['admin', 'super_admin']).default('admin'), // 管理员角色。
|
||
isActive: tinyint('is_active').default(1), // 账号是否启用。
|
||
lastLoginAt: datetime('last_login_at'), // 最近登录时间。
|
||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
|
||
}, (table) => [
|
||
uniqueIndex('uk_admin_username').on(table.username),
|
||
]);
|
||
|
||
// ── App Settings ───────────────────────────────────────────────────
|
||
|
||
// 应用运行时配置,支持热重载(修改后立即生效,无需重启)。
|
||
export const appSettings = mysqlTable('app_settings', {
|
||
key: varchar('key', { length: 100 }).primaryKey(),
|
||
value: text('value').notNull(),
|
||
description: varchar('description', { length: 300 }),
|
||
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
|
||
});
|