feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+423 -262
View File
@@ -1,17 +1,12 @@
import {bigint, boolean, index, integer, pgEnum, pgTable, serial, text, timestamp, uniqueIndex, uuid, varchar, unique} from 'drizzle-orm/pg-core';
import {bigint, boolean, integer, pgEnum, pgTable, serial, text, timestamp, uuid, varchar, unique, index, uniqueIndex} from 'drizzle-orm/pg-core';
import {relations, sql} from 'drizzle-orm';
// 枚举定义
export const blacklistTypeEnum = pgEnum('BlacklistType', ['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE']);
export const userStatusEnum = pgEnum('user_status', ['active', 'pending', 'withdrawn', 'graduate', 'rejected']);
export const userStatusEnum = pgEnum('user_status', ['active', 'withdrawn', 'graduate', 'pending', 'rejected']);
export const collaboratorStatusEnum = pgEnum('collaborator_status', ['PENDING', 'ACCEPTED', 'REJECTED']);
export const replayRequestStatusEnum = pgEnum('replay_request_status', ['PENDING', 'FULFILLED', 'REJECTED']);
export const cardCodeStatusEnum = pgEnum('card_code_status', [
'AVAILABLE',
'LOCKED',
'REDEEMED',
'INVALID'
]);
export const cardCodeStatusEnum = pgEnum('card_code_status', ['AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID']);
// 用户表
export const users = pgTable('User', {
@@ -22,6 +17,7 @@ export const users = pgTable('User', {
name: text('name'),
grade: text('grade'),
class: text('class'),
studentId: text('studentId'),
role: text('role').default('USER').notNull(),
password: text('password').notNull(),
email: text('email'),
@@ -30,27 +26,31 @@ export const users = pgTable('User', {
lastLoginIp: text('lastLoginIp'),
passwordChangedAt: timestamp('passwordChangedAt'),
forcePasswordChange: boolean('forcePasswordChange').default(false).notNull(),
tokenVersion: integer('tokenVersion').default(0).notNull(),
meowNickname: text('meowNickname'),
meowBoundAt: timestamp('meowBoundAt'),
avatarProvider: text('avatarProvider'),
avatarProviderUserId: text('avatarProviderUserId'),
status: userStatusEnum('status').default('active').notNull(),
statusChangedAt: timestamp('statusChangedAt').defaultNow(),
statusChangedBy: integer('statusChangedBy'),
// 注册时可选填写的备注,管理员审核时可修改
tokenVersion: integer('tokenVersion').default(0).notNull(),
avatarProvider: text('avatarProvider'),
avatarProviderUserId: text('avatarProviderUserId'),
remark: text('remark'),
}, (table) => [uniqueIndex('User_username_unique').on(table.username)]);
}, (table) => [
uniqueIndex('User_username_unique').on(table.username),
]);
// 登录会话表,id 与 JWT 的 jti 一致
// 认证会话表
export const authSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
id: uuid('id').primaryKey().defaultRandom(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
sessionToken: varchar('session_token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
isRevoked: boolean('is_revoked').default(false).notNull(),
browser: text('browser'),
operatingSystem: text('operating_system'),
device: text('device'),
@@ -58,12 +58,23 @@ export const authSessions = pgTable('auth_sessions', {
revokedAt: timestamp('revoked_at', { withTimezone: true }),
revokedReason: text('revoked_reason')
}, (table) => [
index('auth_sessions_user_active_idx').on(table.userId, table.revokedAt, table.expiresAt),
index('auth_sessions_last_active_idx').on(table.lastActiveAt)
index('auth_sessions_user_active_idx').on(table.userId, table.isRevoked, table.expiresAt),
index('auth_sessions_expires_at_idx').on(table.expiresAt),
index('auth_sessions_last_active_idx').on(table.lastActiveAt),
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
]);
export type AuthSession = typeof authSessions.$inferSelect;
export type NewAuthSession = typeof authSessions.$inferInsert;
// 年级班级配置表
export const gradeClasses = pgTable('GradeClass', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
grade: text('grade').notNull(),
class: text('class').notNull(),
enabled: boolean('enabled').default(true).notNull(),
}, (table) => [
unique('grade_class_unique').on(table.grade, table.class),
]);
// 播出时段表
export const playTimes = pgTable('PlayTime', {
@@ -77,25 +88,15 @@ export const playTimes = pgTable('PlayTime', {
description: text('description'),
});
// 年级班级配置表(选择器选项来源,优先于从用户提取)
export const gradeClass = pgTable(
'GradeClass',
{
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
grade: text('grade').notNull(),
class: text('class').notNull(),
},
(table) => [unique('GradeClass_grade_class_unique').on(table.grade, table.class)],
);
// 歌曲表
export const songs = pgTable('Song', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
title: text('title').notNull(),
kgHash: varchar('kg_hash', { length: 64 }),
title: varchar('title', { length: 100 }).notNull(),
singer: varchar('singer', { length: 100 }),
coverUrl: varchar('cover_url', { length: 255 }),
artist: text('artist').notNull(),
requesterId: integer('requesterId').notNull(),
played: boolean('played').default(false).notNull(),
@@ -106,17 +107,18 @@ export const songs = pgTable('Song', {
playUrl: text('playUrl'),
musicPlatform: text('musicPlatform'),
musicId: text('musicId'),
durationSeconds: integer('durationSeconds'),
submissionNote: text('submissionNote'),
submissionNotePublic: boolean('submissionNotePublic').default(false).notNull(),
// 公开留言审核状态:'pending' | 'approved' | 'rejected' | null(null 表示未启用审核或不涉及)
submissionNotePublicStatus: text('submissionNotePublicStatus'),
hitRequestId: integer(),
expectedPlayDate: text('expectedPlayDate'),
expectedPlaySequence: integer('expectedPlaySequence'),
durationSeconds: integer('durationSeconds'),
submissionNotePublicStatus: text('submissionNotePublicStatus'),
cardCodeId: integer('cardCodeId').references(() => cardCodes.id, { onDelete: 'set null' }),
}, (table) => [
index('song_card_code_id_idx').on(table.cardCodeId),
index('song_semester_created_at_idx').on(table.semester, table.createdAt),
index('song_requester_id_idx').on(table.requesterId)
index('song_requester_id_idx').on(table.requesterId),
]);
// 投票表
@@ -127,9 +129,18 @@ export const votes = pgTable('Vote', {
userId: integer('userId').notNull(),
}, (table) => [
unique('vote_song_user_unique').on(table.songId, table.userId),
index('vote_user_song_idx').on(table.userId, table.songId)
index('vote_user_song_idx').on(table.userId, table.songId),
]);
export const songComments = pgTable('SongComment', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
songId: integer('songId').notNull(),
userId: integer('userId').notNull(),
content: text('content').notNull(),
});
// 排期表
export const schedules = pgTable('Schedule', {
id: serial('id').primaryKey(),
@@ -140,27 +151,39 @@ export const schedules = pgTable('Schedule', {
played: boolean('played').default(false).notNull(),
sequence: integer('sequence').default(1).notNull(),
playTimeId: integer('playTimeId'),
// 草稿支持字段
isDraft: boolean('isDraft').default(false).notNull(),
publishedAt: timestamp('publishedAt'),
replayRequestId: integer('replay_request_id'),
}, (table) => [
index('schedule_published_song_idx').on(table.isDraft, table.songId, table.playDate),
index('schedule_published_date_idx').on(table.isDraft, table.playDate)
index('schedule_published_song_idx').on(table.publishedAt, table.songId),
index('schedule_published_date_idx').on(table.publishedAt, table.playDate),
]);
// 排期备选池表
export const scheduleSongPool = pgTable('ScheduleSongPool', {
export const scheduleSongPool = pgTable('schedule_song_pool', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
songId: integer('songId').notNull().references(() => songs.id, { onDelete: 'cascade' }),
addedBy: integer('addedBy').references(() => users.id, { onDelete: 'set null' })
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
songId: integer('song_id').notNull().references(() => songs.id, { onDelete: 'cascade' }),
targetPlayDate: timestamp('target_play_date'),
priority: integer('priority').default(0).notNull(),
addedBy: integer('added_by'),
note: text('note'),
status: text('status').default('PENDING').notNull(),
}, (table) => [
unique('schedule_song_pool_song_unique').on(table.songId)
index('schedule_song_pool_song_id_idx').on(table.songId),
index('schedule_song_pool_target_date_idx').on(table.targetPlayDate),
]);
export type ScheduleSongPool = typeof scheduleSongPool.$inferSelect;
export type NewScheduleSongPool = typeof scheduleSongPool.$inferInsert;
// 期望播出日期表
export const expectedPlayDates = pgTable('ExpectedPlayDate', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
date: text('date').notNull().unique(),
availableSequences: text('availableSequences').notNull(),
enabled: boolean('enabled').default(true).notNull(),
});
// 通知表
export const notifications = pgTable('Notification', {
@@ -168,27 +191,21 @@ export const notifications = pgTable('Notification', {
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
type: text('type').notNull(),
batchId: text('batchId'),
source: text('source').default('SYSTEM').notNull(),
senderId: integer('senderId'),
senderName: text('senderName'),
senderUsername: text('senderUsername'),
title: text('title'),
message: text('message').notNull(),
important: boolean('important').default(false).notNull(),
read: boolean('read').default(false).notNull(),
userDeleted: boolean('userDeleted').default(false).notNull(),
userId: integer('userId').notNull(),
songId: integer('songId'),
batchId: uuid('batch_id'),
source: text('source').default('SYSTEM').notNull(),
senderId: integer('sender_id'),
senderName: text('sender_name'),
senderUsername: text('sender_username'),
title: text('title'),
important: boolean('important').default(false).notNull(),
userDeleted: boolean('user_deleted').default(false).notNull(),
}, (table) => [
index('notification_user_important_read_created_idx').on(
table.userId,
table.userDeleted,
table.important,
table.read,
table.createdAt
),
index('notification_batch_id_idx').on(table.batchId)
index('notification_user_important_read_created_idx').on(table.userId, table.important, table.read, table.createdAt),
index('notification_batch_id_idx').on(table.batchId),
]);
// 通知设置表
@@ -219,30 +236,22 @@ export const systemSettings = pgTable('SystemSettings', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
instanceId: text('instance_id'),
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
enablePlayTimeSelection: boolean('enablePlayTimeSelection').default(false).notNull(),
siteTitle: text('siteTitle'),
siteLogoUrl: text('siteLogoUrl'),
schoolLogoHomeUrl: text('schoolLogoHomeUrl'),
schoolLogoPrintUrl: text('schoolLogoPrintUrl'),
siteDescription: text('siteDescription'),
siteVersion: text('siteVersion'),
submissionGuidelines: text('submissionGuidelines'),
icpNumber: text('icpNumber'),
gonganNumber: text('gonganNumber'),
showBeianIcon: boolean('showBeianIcon').default(false).notNull(),
enableSubmissionLimit: boolean('enableSubmissionLimit').default(false).notNull(),
dailySubmissionLimit: integer('dailySubmissionLimit'),
weeklySubmissionLimit: integer('weeklySubmissionLimit'),
monthlySubmissionLimit: integer('monthlySubmissionLimit'),
// 用户可见播出排期范围
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
showBlacklistKeywords: boolean('showBlacklistKeywords').default(false).notNull(),
hideStudentInfo: boolean('hideStudentInfo').default(true).notNull(),
// SMTP 邮件配置
smtpEnabled: boolean('smtpEnabled').default(false).notNull(),
smtpHost: text('smtpHost'),
smtpPort: integer('smtpPort').default(587),
@@ -252,63 +261,75 @@ export const systemSettings = pgTable('SystemSettings', {
smtpFromEmail: text('smtpFromEmail'),
smtpFromName: text('smtpFromName').default('校园广播站'),
enableRequestTimeLimitation: boolean('enableRequestTimeLimitation').default(false).notNull(),
forceBlockAllRequests: boolean().default(false).notNull(),
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
forceBlockAllRequests: boolean('forceBlockAllRequests').default(false).notNull(),
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
// 卡密点歌相关开关(用于允许用户使用卡密或强制使用卡密投稿)
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
// 重复投稿限制:对同一首歌/同一歌手,在其进入排期后的设定时间窗口内禁止再次投稿
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
instanceId: text('instanceId'),
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
// 验证码配置
captchaProvider: text('captchaProvider').default('graphic').notNull(),
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
submissionRestrictionGraduateEnabled: boolean('submissionRestrictionGraduateEnabled').default(false).notNull(),
submissionRestrictionWithdrawnEnabled: boolean('submissionRestrictionWithdrawnEnabled').default(false).notNull(),
captchaProvider: text('captchaProvider').default('TURNSTILE'),
captchaEnabled: boolean('captchaEnabled').default(false).notNull(),
turnstileSiteKey: text('turnstileSiteKey'),
turnstileSecretKey: text('turnstileSecretKey'),
// 注册配置
allowRegister: boolean('allowRegister').default(false).notNull(),
registerRequiresApproval: boolean('registerRequiresApproval').default(true).notNull(),
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(true).notNull(),
// 注册邮箱(选填→管理员开关控制;需 SMTP 已配置)
allowRegister: boolean('allowRegister').default(true).notNull(),
registerRequireEmail: boolean('registerRequireEmail').default(false).notNull(),
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
registerDefaultRole: text('registerDefaultRole').default('USER'),
registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
registerRequiresApproval: boolean('registerRequiresApproval').default(false).notNull(),
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(false).notNull(),
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
// 注册时必须选择年级班级(本地注册与第三方创建账户均强制)
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
// 投稿公开留言审核
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
// OAuth 配置
oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
allowOAuthRegistration: boolean('allowOAuthRegistration').default(false).notNull(),
oauthRedirectUri: text('oauthRedirectUri'),
oauthStateSecret: text('oauthStateSecret'),
oauthProviders: text('oauthProviders').default('[]'),
// GitHub OAuth
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
githubClientId: text('githubClientId'),
githubClientSecret: text('githubClientSecret'),
// Casdoor OAuth
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
casdoorServerUrl: text('casdoorServerUrl'),
casdoorClientId: text('casdoorClientId'),
casdoorClientSecret: text('casdoorClientSecret'),
casdoorOrganizationName: text('casdoorOrganizationName'),
// Google OAuth
googleOAuthEnabled: boolean('googleOAuthEnabled').default(false).notNull(),
googleClientId: text('googleClientId'),
googleClientSecret: text('googleClientSecret'),
// 聚合登陆
aggregateOAuthEnabled: boolean('aggregateOAuthEnabled').default(false).notNull(),
aggregateOAuthAppId: text('aggregateOAuthAppId'),
aggregateOAuthAppKey: text('aggregateOAuthAppKey'),
aggregateOAuthLoginType: text('aggregateOAuthLoginType').default('qq'),
aggregateOAuthEndpoint: text('aggregateOAuthEndpoint'),
// Custom OAuth2
customOAuthEnabled: boolean('customOAuthEnabled').default(false).notNull(),
customOAuthDisplayName: text('customOAuthDisplayName'),
customOAuthAuthorizeUrl: text('customOAuthAuthorizeUrl'),
@@ -322,24 +343,17 @@ export const systemSettings = pgTable('SystemSettings', {
customOAuthNameField: text('customOAuthNameField'),
customOAuthEmailField: text('customOAuthEmailField'),
customOAuthAvatarField: text('customOAuthAvatarField'),
// 图形验证码
captchaEnabled: boolean('captchaEnabled').default(false).notNull(),
captchaMaxFailures: integer('captchaMaxFailures').default(3).notNull(),
// 自动备份配置
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
autoBackupConfig: text('autoBackupConfig'),
// 站点统计代码(任意站点统计平台的 HTML/JS 片段,注入 SSR 页面 <head>)
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
statisticsCode: text('statisticsCode'),
// 主题管理配置
defaultTheme: text('defaultTheme').default('System').notNull(),
enabledThemes: text('enabledThemes').default('["System","ClassicDark","ClassicLight","ModernLight"]').notNull(),
// 平台管理配置
enabledPlatforms: text('enabledPlatforms').default('["netease","tencent","bilibili","migu"]'),
platformOrder: text('platformOrder').default('["netease","tencent","bilibili","migu"]'),
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
defaultTheme: text('defaultTheme').default('ClassicLight').notNull(),
enabledThemes: text('enabledThemes').default('[\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'),
});
// 歌曲黑名单表
@@ -354,6 +368,124 @@ export const songBlacklists = pgTable('SongBlacklist', {
createdBy: integer('createdBy'),
});
// 卡密表
export const cardCodes = pgTable('card_codes', {
id: serial('id').primaryKey(),
code: varchar('code', { length: 64 }).notNull().unique(),
status: cardCodeStatusEnum('status').default('AVAILABLE').notNull(),
type: text('type').notNull(),
value: integer('value').default(1).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
lockedAt: timestamp('locked_at', { withTimezone: true }),
lockedBy: integer('locked_by'),
batchId: varchar('batch_id', { length: 64 }),
note: text('note'),
}, (table) => [
index('card_codes_status_idx').on(table.status),
index('card_codes_batch_id_idx').on(table.batchId),
index('card_codes_expires_at_idx').on(table.expiresAt),
]);
// 卡密兑换记录表
export const cardCodeRedemptions = pgTable('card_code_redemptions', {
id: serial('id').primaryKey(),
cardCodeId: integer('card_code_id').notNull().references(() => cardCodes.id, { onDelete: 'cascade' }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).defaultNow().notNull(),
songId: integer('song_id').references(() => songs.id, { onDelete: 'set null' }),
purpose: text('purpose').notNull(),
}, (table) => [
index('card_code_redemptions_user_id_idx').on(table.userId),
index('card_code_redemptions_card_code_id_idx').on(table.cardCodeId),
]);
// OAuth 提供商表
export const oauthProviders = pgTable('oauth_providers', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
displayName: text('displayName'),
enabled: boolean('enabled').default(true).notNull(),
clientId: text('clientId'),
clientSecret: text('clientSecret'),
authorizationEndpoint: text('authorizationEndpoint'),
tokenEndpoint: text('tokenEndpoint'),
userInfoEndpoint: text('userInfoEndpoint'),
scope: text('scope'),
redirectUri: text('redirectUri'),
usernameClaim: text('usernameClaim').default('preferred_username'),
nameClaim: text('nameClaim').default('name'),
emailClaim: text('emailClaim').default('email'),
avatarClaim: text('avatarClaim'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
sortOrder: integer('sortOrder').default(0).notNull(),
icon: text('icon'),
});
// 用户 Passkeys 表
export const userPasskeys = pgTable('user_passkeys', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name'),
publicKey: text('public_key').notNull(),
credentialDeviceType: text('credential_device_type').notNull(),
credentialBackedUp: boolean('credential_backed_up').notNull(),
transports: text('transports'),
counter: integer('counter').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
}, (table) => [
index('user_passkeys_user_id_idx').on(table.userId),
]);
// Passkey Challenges 表
export const passkeyChallenges = pgTable('passkey_challenges', {
id: uuid('id').primaryKey().defaultRandom(),
challenge: text('challenge').notNull(),
type: text('type').notNull(),
userId: integer('user_id'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('passkey_challenges_challenge_idx').on(table.challenge),
index('passkey_challenges_expires_at_idx').on(table.expiresAt),
]);
// 备份记录表
export const backupRecords = pgTable('backup_records', {
id: serial('id').primaryKey(),
filename: text('filename').notNull(),
filePath: text('file_path').notNull(),
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
type: text('type').default('MANUAL').notNull(),
status: text('status').default('COMPLETED').notNull(),
errorMessage: text('error_message'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdBy: integer('created_by'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
}, (table) => [
index('backup_records_created_at_idx').on(table.createdAt),
index('backup_records_status_idx').on(table.status),
]);
// 投稿限制豁免表
export const submissionRestrictionExemptions = pgTable('submission_restriction_exemptions', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
exemptionType: text('exemption_type').notNull(),
reason: text('reason'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdBy: integer('created_by'),
}, (table) => [
unique('submission_restriction_exemptions_user_type_unique').on(table.userId, table.exemptionType),
index('submission_restriction_exemptions_user_id_idx').on(table.userId),
]);
// API Keys表
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(),
@@ -368,7 +500,6 @@ export const apiKeys = pgTable('api_keys', {
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
createdByUserId: integer('created_by_user_id').notNull(),
usageCount: integer('usage_count').default(0).notNull(),
});
// API Key权限表
@@ -399,9 +530,6 @@ export const apiLogs = pgTable('api_logs', {
export const userStatusLogs = pgTable('user_status_logs', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull(),
// 审计快照:用户被删除后仍可追溯(如注册审核拒绝)
username: text('username'),
name: text('name'),
oldStatus: userStatusEnum('old_status'),
newStatus: userStatusEnum('new_status').notNull(),
reason: text('reason'),
@@ -418,34 +546,29 @@ export const requestTimes = pgTable("RequestTime", {
endTime: timestamp({ mode: 'string' }).notNull(),
enabled: boolean().default(true).notNull(),
description: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
expected: bigint({ mode: "number" }).default(0).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
accepted: bigint({ mode: "number" }).default(0).notNull(),
past: boolean().default(false).notNull(),
});
// 联合投稿人表
export const songCollaborators = pgTable('song_collaborators', {
id: uuid('id').primaryKey().defaultRandom(),
songId: integer('song_id').notNull(),
userId: integer('user_id').notNull(),
status: collaboratorStatusEnum('status').default('PENDING').notNull(),
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
updatedAt: timestamp('updated_at', {withTimezone: true}).defaultNow().notNull(),
}, (table) => [
index('song_collaborators_song_status_idx').on(table.songId, table.status),
index('song_collaborators_user_status_idx').on(table.userId, table.status)
]);
id: uuid('id').primaryKey().defaultRandom(),
songId: integer('song_id').notNull(),
userId: integer('user_id').notNull(),
status: collaboratorStatusEnum('status').default('PENDING').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// 联合投稿审计日志表
export const collaborationLogs = pgTable('collaboration_logs', {
id: uuid('id').primaryKey().defaultRandom(),
collaboratorId: uuid('collaborator_id').notNull(),
action: varchar('action', {length: 50}).notNull(), // INVITE, ACCEPT, REJECT, REMOVE
operatorId: integer('operator_id').notNull(),
ipAddress: text('ip_address'),
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
collaboratorId: uuid('collaborator_id').notNull(),
action: varchar('action', { length: 50 }).notNull(),
operatorId: integer('operator_id').notNull(),
ipAddress: text('ip_address'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// 歌曲重播申请表
@@ -453,20 +576,12 @@ export const songReplayRequests = pgTable('song_replay_requests', {
id: serial('id').primaryKey(),
songId: integer('song_id').notNull(),
userId: integer('user_id').notNull(),
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
updatedAt: timestamp('updated_at', {withTimezone: true}).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
status: replayRequestStatusEnum('status').default('PENDING').notNull(),
preferredPlayTimeId: integer('preferred_play_time_id'),
submissionNote: text('submission_note'),
submissionNotePublic: boolean('submission_note_public').default(false).notNull(),
submissionNotePublicStatus: text('submission_note_public_status'),
}, (table) => [
// 同一用户对同一首歌同时最多一条待处理申请;历史申请(FULFILLED/REJECTED)允许多条
uniqueIndex('song_replay_requests_pending_song_user_unique')
.on(table.songId, table.userId)
.where(sql`${table.status} = 'PENDING'`),
index('song_replay_requests_user_status_song_idx').on(table.userId, table.status, table.songId)
]);
}, (t) => ({
unq: unique().on(t.songId, t.userId),
}));
// 第三方身份关联表
export const userIdentities = pgTable('UserIdentity', {
@@ -475,53 +590,58 @@ export const userIdentities = pgTable('UserIdentity', {
provider: text('provider').notNull(),
providerUserId: text('providerUserId').notNull(),
providerUsername: text('providerUsername'),
avatar: text('avatar'),
createdAt: timestamp('createdAt').defaultNow().notNull(),
}, (t) => ({
unq: unique().on(t.provider, t.providerUserId),
}));
export const passwordAuditLogs = pgTable('PasswordAuditLog', {
export const changelogEntries = pgTable('ChangelogEntry', {
id: serial('id').primaryKey(),
userId: integer('userId').notNull(),
actorId: integer('actorId'),
action: text('action').notNull(),
success: boolean('success').notNull(),
ipAddress: text('ipAddress'),
userAgent: text('userAgent'),
failureReason: text('failureReason'),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => ({
userCreatedIdx: index('PasswordAuditLog_user_created_idx').on(table.userId, table.createdAt)
}));
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
version: text('version').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').default(false).notNull(),
publishedAt: timestamp('publishedAt'),
createdBy: integer('createdBy'),
});
export const passwordRateLimits = pgTable('PasswordRateLimit', {
key: text('key').primaryKey(),
count: integer('count').notNull(),
resetAt: timestamp('resetAt', { withTimezone: true }).notNull()
}, (table) => ({
resetIdx: index('PasswordRateLimit_reset_idx').on(table.resetAt)
}));
// 邮件模板表
export const emailTemplates = pgTable('EmailTemplate', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
key: varchar('key', { length: 100 }).notNull(),
name: varchar('name', { length: 200 }).notNull(),
subject: varchar('subject', { length: 300 }).notNull(),
html: text('html').notNull(),
updatedByUserId: integer('updatedByUserId'),
});
// 关系定义
export const usersRelations = relations(users, ({ many, one }) => ({
songs: many(songs),
votes: many(votes),
notifications: many(notifications),
songComments: many(songComments),
identities: many(userIdentities),
notificationSettings: one(notificationSettings, {
fields: [users.id],
references: [notificationSettings.userId],
}),
apiKeys: many(apiKeys),
sessions: many(authSessions),
statusLogs: many(userStatusLogs),
collaborations: many(songCollaborators),
collaborations: many(songCollaborators),
replayRequests: many(songReplayRequests),
statusChangedByUser: one(users, {
fields: [users.statusChangedBy],
references: [users.id],
}),
authSessions: many(authSessions),
passkeys: many(userPasskeys),
cardCodeRedemptions: many(cardCodeRedemptions),
submissionRestrictionExemptions: many(submissionRestrictionExemptions),
}));
export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
@@ -531,6 +651,20 @@ export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
}),
}));
export const authSessionsRelations = relations(authSessions, ({ one }) => ({
user: one(users, {
fields: [authSessions.userId],
references: [users.id],
}),
}));
export const userPasskeysRelations = relations(userPasskeys, ({ one }) => ({
user: one(users, {
fields: [userPasskeys.userId],
references: [users.id],
}),
}));
export const songsRelations = relations(songs, ({ one, many }) => ({
requester: one(users, {
fields: [songs.requesterId],
@@ -541,10 +675,28 @@ export const songsRelations = relations(songs, ({ one, many }) => ({
references: [playTimes.id],
}),
votes: many(votes),
comments: many(songComments),
schedules: many(schedules),
notifications: many(notifications),
collaborators: many(songCollaborators),
replayRequests: many(songReplayRequests),
collaborators: many(songCollaborators),
replayRequests: many(songReplayRequests),
cardCode: one(cardCodes, {
fields: [songs.cardCodeId],
references: [cardCodes.id],
}),
scheduleSongPool: many(scheduleSongPool),
cardCodeRedemptions: many(cardCodeRedemptions),
}));
export const songCommentsRelations = relations(songComments, ({ one }) => ({
song: one(songs, {
fields: [songComments.songId],
references: [songs.id],
}),
user: one(users, {
fields: [songComments.userId],
references: [users.id],
}),
}));
export const votesRelations = relations(votes, ({ one }) => ({
@@ -569,6 +721,13 @@ export const schedulesRelations = relations(schedules, ({ one }) => ({
}),
}));
export const scheduleSongPoolRelations = relations(scheduleSongPool, ({ one }) => ({
song: one(songs, {
fields: [scheduleSongPool.songId],
references: [songs.id],
}),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, {
fields: [notifications.userId],
@@ -578,6 +737,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.songId],
references: [songs.id],
}),
sender: one(users, {
fields: [notifications.senderId],
references: [users.id],
}),
}));
export const notificationSettingsRelations = relations(notificationSettings, ({ one }) => ({
@@ -592,7 +755,6 @@ export const playTimesRelations = relations(playTimes, ({ many }) => ({
schedules: many(schedules),
}));
// API框架关系定义
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
createdByUser: one(users, {
fields: [apiKeys.createdByUserId],
@@ -627,61 +789,90 @@ export const userStatusLogsRelations = relations(userStatusLogs, ({ one }) => ({
}),
}));
export const songCollaboratorsRelations = relations(songCollaborators, ({one, many}) => ({
song: one(songs, {
fields: [songCollaborators.songId],
references: [songs.id],
}),
user: one(users, {
fields: [songCollaborators.userId],
references: [users.id],
}),
logs: many(collaborationLogs),
export const songCollaboratorsRelations = relations(songCollaborators, ({ one, many }) => ({
song: one(songs, {
fields: [songCollaborators.songId],
references: [songs.id],
}),
user: one(users, {
fields: [songCollaborators.userId],
references: [users.id],
}),
logs: many(collaborationLogs),
}));
export const collaborationLogsRelations = relations(collaborationLogs, ({one}) => ({
collaborator: one(songCollaborators, {
fields: [collaborationLogs.collaboratorId],
references: [songCollaborators.id],
}),
operator: one(users, {
fields: [collaborationLogs.operatorId],
references: [users.id],
}),
export const collaborationLogsRelations = relations(collaborationLogs, ({ one }) => ({
collaborator: one(songCollaborators, {
fields: [collaborationLogs.collaboratorId],
references: [songCollaborators.id],
}),
operator: one(users, {
fields: [collaborationLogs.operatorId],
references: [users.id],
}),
}));
export const songReplayRequestsRelations = relations(songReplayRequests, ({one}) => ({
song: one(songs, {
fields: [songReplayRequests.songId],
references: [songs.id],
}),
user: one(users, {
fields: [songReplayRequests.userId],
references: [users.id],
}),
export const songReplayRequestsRelations = relations(songReplayRequests, ({ one }) => ({
song: one(songs, {
fields: [songReplayRequests.songId],
references: [songs.id],
}),
user: one(users, {
fields: [songReplayRequests.userId],
references: [users.id],
}),
}));
// 邮件模板表(仅存储自定义覆盖,内置模板在代码中定义)
export const emailTemplates = pgTable('EmailTemplate', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
key: varchar('key', { length: 100 }).notNull(),
name: varchar('name', { length: 200 }).notNull(),
subject: varchar('subject', { length: 300 }).notNull(),
html: text('html').notNull(),
updatedByUserId: integer('updatedByUserId'),
});
export const cardCodesRelations = relations(cardCodes, ({ many }) => ({
songs: many(songs),
redemptions: many(cardCodeRedemptions),
}));
export const cardCodeRedemptionsRelations = relations(cardCodeRedemptions, ({ one }) => ({
cardCode: one(cardCodes, {
fields: [cardCodeRedemptions.cardCodeId],
references: [cardCodes.id],
}),
user: one(users, {
fields: [cardCodeRedemptions.userId],
references: [users.id],
}),
song: one(songs, {
fields: [cardCodeRedemptions.songId],
references: [songs.id],
}),
}));
export const submissionRestrictionExemptionsRelations = relations(submissionRestrictionExemptions, ({ one }) => ({
user: one(users, {
fields: [submissionRestrictionExemptions.userId],
references: [users.id],
}),
createdByUser: one(users, {
fields: [submissionRestrictionExemptions.createdBy],
references: [users.id],
}),
}));
// 导出所有表的类型
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type AuthSession = typeof authSessions.$inferSelect;
export type NewAuthSession = typeof authSessions.$inferInsert;
export type GradeClass = typeof gradeClasses.$inferSelect;
export type NewGradeClass = typeof gradeClasses.$inferInsert;
export type Song = typeof songs.$inferSelect;
export type NewSong = typeof songs.$inferInsert;
export type Vote = typeof votes.$inferSelect;
export type NewVote = typeof votes.$inferInsert;
export type SongComment = typeof songComments.$inferSelect;
export type NewSongComment = typeof songComments.$inferInsert;
export type Schedule = typeof schedules.$inferSelect;
export type NewSchedule = typeof schedules.$inferInsert;
export type ScheduleSongPool = typeof scheduleSongPool.$inferSelect;
export type NewScheduleSongPool = typeof scheduleSongPool.$inferInsert;
export type ExpectedPlayDate = typeof expectedPlayDates.$inferSelect;
export type NewExpectedPlayDate = typeof expectedPlayDates.$inferInsert;
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
export type NotificationSettings = typeof notificationSettings.$inferSelect;
@@ -694,6 +885,20 @@ export type SystemSettings = typeof systemSettings.$inferSelect;
export type NewSystemSettings = typeof systemSettings.$inferInsert;
export type SongBlacklist = typeof songBlacklists.$inferSelect;
export type NewSongBlacklist = typeof songBlacklists.$inferInsert;
export type CardCode = typeof cardCodes.$inferSelect;
export type NewCardCode = typeof cardCodes.$inferInsert;
export type CardCodeRedemption = typeof cardCodeRedemptions.$inferSelect;
export type NewCardCodeRedemption = typeof cardCodeRedemptions.$inferInsert;
export type OAuthProvider = typeof oauthProviders.$inferSelect;
export type NewOAuthProvider = typeof oauthProviders.$inferInsert;
export type UserPasskey = typeof userPasskeys.$inferSelect;
export type NewUserPasskey = typeof userPasskeys.$inferInsert;
export type PasskeyChallenge = typeof passkeyChallenges.$inferSelect;
export type NewPasskeyChallenge = typeof passkeyChallenges.$inferInsert;
export type BackupRecord = typeof backupRecords.$inferSelect;
export type NewBackupRecord = typeof backupRecords.$inferInsert;
export type SubmissionRestrictionExemption = typeof submissionRestrictionExemptions.$inferSelect;
export type NewSubmissionRestrictionExemption = typeof submissionRestrictionExemptions.$inferInsert;
export type ApiKey = typeof apiKeys.$inferSelect;
export type NewApiKey = typeof apiKeys.$inferInsert;
export type ApiKeyPermission = typeof apiKeyPermissions.$inferSelect;
@@ -708,55 +913,11 @@ export type CollaborationLog = typeof collaborationLogs.$inferSelect;
export type NewCollaborationLog = typeof collaborationLogs.$inferInsert;
export type SongReplayRequest = typeof songReplayRequests.$inferSelect;
export type NewSongReplayRequest = typeof songReplayRequests.$inferInsert;
export type EmailTemplate = typeof emailTemplates.$inferSelect;
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;export type RequestTime = typeof requestTimes.$inferSelect;
export type RequestTime = typeof requestTimes.$inferSelect;
export type NewRequestTime = typeof requestTimes.$inferInsert;
export type UserIdentity = typeof userIdentities.$inferSelect;
export type NewUserIdentity = typeof userIdentities.$inferInsert;
// 卡密表
export const cardCodes = pgTable('CardCode', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
code: text('code').notNull().unique(),
status: cardCodeStatusEnum('status').default('AVAILABLE').notNull(),
lockedBy: integer('lockedBy').references(() => users.id, { onDelete: 'set null' }),
lockedAt: timestamp('lockedAt'),
redeemedBy: integer('redeemedBy').references(() => users.id, { onDelete: 'set null' }),
redeemedAt: timestamp('redeemedAt'),
note: text('note'),
});
// 卡密兑换日志表
export const cardCodeRedeemLogs = pgTable('CardCodeRedeemLog', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
cardCodeId: integer('cardCodeId').references(() => cardCodes.id, { onDelete: 'set null' }),
codeSnapshot: text('codeSnapshot').notNull(),
redeemedBy: integer('redeemedBy').notNull().references(() => users.id, { onDelete: 'restrict' }),
redeemedAt: timestamp('redeemedAt').defaultNow().notNull(),
source: text('source').default('UNKNOWN').notNull(),
songId: integer('songId').references(() => songs.id, { onDelete: 'set null' })
}, (table) => [
index('card_code_redeem_log_card_code_id_idx').on(table.cardCodeId)
]);
export type CardCode = typeof cardCodes.$inferSelect;
export type NewCardCode = typeof cardCodes.$inferInsert;
// 自动备份历史记录表
export const backupHistory = pgTable('BackupHistory', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
filename: text('filename').notNull(),
totalRecords: integer('totalRecords').default(0).notNull(),
backupSize: integer('backupSize').default(0).notNull(),
methods: text('methods').notNull(), // JSON: { method: string; success: boolean; error?: string }[]
success: boolean('success').default(false).notNull(),
triggeredBy: text('triggeredBy'), // 'api' | 'manual'
}, (table) => [
index('backup_history_created_at_idx').on(table.createdAt)
]);
export type BackupHistory = typeof backupHistory.$inferSelect;
export type NewBackupHistory = typeof backupHistory.$inferInsert;
export type ChangelogEntry = typeof changelogEntries.$inferSelect;
export type NewChangelogEntry = typeof changelogEntries.$inferInsert;
export type EmailTemplate = typeof emailTemplates.$inferSelect;
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;