duoqi-api/src/db/schema.ts
Wang Zhuoxuan 3991a02a8c feat: 添加管理员用户名密码登录功能
新增 /v1/admin/auth/login 接口,支持用户名密码登录获取 JWT Token。
- 添加 admin_users 表存储管理员账号和哈希密码
- 使用 bcryptjs 进行密码哈希(cost=10)
- JWT Token 认证优先,保留 ADMIN_TOKEN 作为向后兼容
- 记录登录审计日志到 admin_audit_log
- 种子数据创建默认管理员(username: admin, password: admin123)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:25:31 +08:00

246 lines
12 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'),
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(),
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] }),
]);
// ── 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'),
league: varchar('league', { length: 50 }),
weekStart: date('week_start'),
weekEnd: date('week_end'),
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
}, (table) => [
index('idx_user_week').on(table.userId, table.weekStart),
]);
// ── 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),
]);
// ── 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 }),
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),
]);