duoqi-api/src/db/schema.ts
Wang Zhuoxuan a2282975ca feat: 集成阿里云融合认证实现手机号一键登录与登录方式管理
- 新增 POST /auth/fusion/token 获取 SDK 鉴权 Token
- 新增 POST /auth/fusion/verify 用 verifyToken 换取手机号并登录/注册
- 新增 GET /auth/providers 按平台返回可用登录方式列表
- 新增 PUT /admin/auth-providers 管理端热切换第三方登录开关
- 新增 appSettings 表存储运行时配置,支持不重启生效
- 修复 schema 中超长外键名称导致的 db:push 失败
2026-05-27 22:50:11 +08:00

547 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`),
});