新增 /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>
246 lines
12 KiB
TypeScript
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),
|
|
]);
|