Files
BYW-VoiceHub/app/drizzle/schema.ts
T

763 lines
32 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 {bigint, boolean, index, integer, pgEnum, pgTable, serial, text, timestamp, uniqueIndex, uuid, varchar, unique} 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 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 users = pgTable('User', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
username: text('username').notNull(),
name: text('name'),
grade: text('grade'),
class: text('class'),
role: text('role').default('USER').notNull(),
password: text('password').notNull(),
email: text('email'),
emailVerified: boolean('emailVerified').default(false),
lastLogin: timestamp('lastLogin'),
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'),
// 注册时可选填写的备注,管理员审核时可修改
remark: text('remark'),
}, (table) => [uniqueIndex('User_username_unique').on(table.username)]);
// 登录会话表,id 与 JWT 的 jti 一致
export const authSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
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(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
browser: text('browser'),
operatingSystem: text('operating_system'),
device: text('device'),
loginMethod: text('login_method').default('password').notNull(),
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)
]);
export type AuthSession = typeof authSessions.$inferSelect;
export type NewAuthSession = typeof authSessions.$inferInsert;
// 播出时段表
export const playTimes = pgTable('PlayTime', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
name: text('name').notNull(),
startTime: text('startTime'),
endTime: text('endTime'),
enabled: boolean('enabled').default(true).notNull(),
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(),
artist: text('artist').notNull(),
requesterId: integer('requesterId').notNull(),
played: boolean('played').default(false).notNull(),
playedAt: timestamp('playedAt').defaultNow(),
semester: text('semester'),
preferredPlayTimeId: integer('preferredPlayTimeId'),
cover: text('cover'),
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(),
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)
]);
// 投票表
export const votes = pgTable('Vote', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
songId: integer('songId').notNull(),
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)
]);
// 排期表
export const schedules = pgTable('Schedule', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
songId: integer('songId').notNull(),
playDate: timestamp('playDate').notNull(),
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)
]);
// 排期备选池表
export const scheduleSongPool = pgTable('ScheduleSongPool', {
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' })
}, (table) => [
unique('schedule_song_pool_song_unique').on(table.songId)
]);
export type ScheduleSongPool = typeof scheduleSongPool.$inferSelect;
export type NewScheduleSongPool = typeof scheduleSongPool.$inferInsert;
// 通知表
export const notifications = pgTable('Notification', {
id: serial('id').primaryKey(),
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'),
}, (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)
]);
// 通知设置表
export const notificationSettings = pgTable('NotificationSettings', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
userId: integer('userId').notNull(),
enabled: boolean('enabled').default(true).notNull(),
songRequestEnabled: boolean('songRequestEnabled').default(true).notNull(),
songVotedEnabled: boolean('songVotedEnabled').default(true).notNull(),
songPlayedEnabled: boolean('songPlayedEnabled').default(true).notNull(),
refreshInterval: integer('refreshInterval').default(60).notNull(),
songVotedThreshold: integer('songVotedThreshold').default(1).notNull(),
});
// 学期表
export const semesters = pgTable('Semester', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
name: text('name').notNull(),
isActive: boolean('isActive').default(false).notNull(),
});
// 系统设置表
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'),
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),
smtpSecure: boolean('smtpSecure').default(false),
smtpUsername: text('smtpUsername'),
smtpPassword: text('smtpPassword'),
smtpFromEmail: text('smtpFromEmail'),
smtpFromName: text('smtpFromName').default('校园广播站'),
enableRequestTimeLimitation: boolean('enableRequestTimeLimitation').default(false).notNull(),
forceBlockAllRequests: boolean().default(false).notNull(),
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').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(),
// 重复投稿限制:对同一首歌/同一歌手,在其进入排期后的设定时间窗口内禁止再次投稿
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
// 验证码配置
captchaProvider: text('captchaProvider').default('graphic').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 已配置)
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
// 注册时必须选择年级班级(本地注册与第三方创建账户均强制)
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
// 投稿公开留言审核
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
// OAuth 配置
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'),
customOAuthTokenUrl: text('customOAuthTokenUrl'),
customOAuthUserInfoUrl: text('customOAuthUserInfoUrl'),
customOAuthScope: text('customOAuthScope'),
customOAuthClientId: text('customOAuthClientId'),
customOAuthClientSecret: text('customOAuthClientSecret'),
customOAuthUserIdField: text('customOAuthUserIdField'),
customOAuthUsernameField: text('customOAuthUsernameField'),
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(),
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"]'),
});
// 歌曲黑名单表
export const songBlacklists = pgTable('SongBlacklist', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
type: blacklistTypeEnum('type').notNull(),
value: text('value').notNull(),
reason: text('reason'),
isActive: boolean('isActive').default(true).notNull(),
createdBy: integer('createdBy'),
});
// API Keys表
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
keyHash: varchar('key_hash', { length: 255 }).notNull().unique(),
keyPrefix: varchar('key_prefix', { length: 10 }).notNull(),
isActive: boolean('is_active').default(true).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
createdByUserId: integer('created_by_user_id').notNull(),
usageCount: integer('usage_count').default(0).notNull(),
});
// API Key权限表
export const apiKeyPermissions = pgTable('api_key_permissions', {
id: uuid('id').primaryKey().defaultRandom(),
apiKeyId: uuid('api_key_id').notNull(),
permission: varchar('permission', { length: 100 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// API访问日志表
export const apiLogs = pgTable('api_logs', {
id: uuid('id').primaryKey().defaultRandom(),
apiKeyId: uuid('api_key_id'),
endpoint: varchar('endpoint', { length: 500 }).notNull(),
method: varchar('method', { length: 10 }).notNull(),
ipAddress: text('ip_address').notNull(),
userAgent: text('user_agent'),
statusCode: integer('status_code').notNull(),
responseTimeMs: integer('response_time_ms').notNull(),
requestBody: text('request_body'),
responseBody: text('response_body'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
errorMessage: text('error_message'),
});
// 用户状态变更日志表
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'),
operatorId: integer('operator_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const requestTimes = pgTable("RequestTime", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ precision: 6, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ precision: 6, mode: 'string' }).defaultNow().notNull(),
name: text().notNull(),
startTime: timestamp({ mode: 'string' }).notNull(),
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)
]);
// 联合投稿审计日志表
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(),
});
// 歌曲重播申请表
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(),
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)
]);
// 第三方身份关联表
export const userIdentities = pgTable('UserIdentity', {
id: serial('id').primaryKey(),
userId: integer('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
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', {
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)
}));
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 usersRelations = relations(users, ({ many, one }) => ({
songs: many(songs),
votes: many(votes),
notifications: many(notifications),
identities: many(userIdentities),
notificationSettings: one(notificationSettings, {
fields: [users.id],
references: [notificationSettings.userId],
}),
apiKeys: many(apiKeys),
sessions: many(authSessions),
statusLogs: many(userStatusLogs),
collaborations: many(songCollaborators),
replayRequests: many(songReplayRequests),
statusChangedByUser: one(users, {
fields: [users.statusChangedBy],
references: [users.id],
}),
}));
export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
user: one(users, {
fields: [userIdentities.userId],
references: [users.id],
}),
}));
export const songsRelations = relations(songs, ({ one, many }) => ({
requester: one(users, {
fields: [songs.requesterId],
references: [users.id],
}),
preferredPlayTime: one(playTimes, {
fields: [songs.preferredPlayTimeId],
references: [playTimes.id],
}),
votes: many(votes),
schedules: many(schedules),
notifications: many(notifications),
collaborators: many(songCollaborators),
replayRequests: many(songReplayRequests),
}));
export const votesRelations = relations(votes, ({ one }) => ({
song: one(songs, {
fields: [votes.songId],
references: [songs.id],
}),
user: one(users, {
fields: [votes.userId],
references: [users.id],
}),
}));
export const schedulesRelations = relations(schedules, ({ one }) => ({
song: one(songs, {
fields: [schedules.songId],
references: [songs.id],
}),
playTime: one(playTimes, {
fields: [schedules.playTimeId],
references: [playTimes.id],
}),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, {
fields: [notifications.userId],
references: [users.id],
}),
song: one(songs, {
fields: [notifications.songId],
references: [songs.id],
}),
}));
export const notificationSettingsRelations = relations(notificationSettings, ({ one }) => ({
user: one(users, {
fields: [notificationSettings.userId],
references: [users.id],
}),
}));
export const playTimesRelations = relations(playTimes, ({ many }) => ({
songs: many(songs),
schedules: many(schedules),
}));
// API框架关系定义
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
createdByUser: one(users, {
fields: [apiKeys.createdByUserId],
references: [users.id],
}),
permissions: many(apiKeyPermissions),
logs: many(apiLogs),
}));
export const apiKeyPermissionsRelations = relations(apiKeyPermissions, ({ one }) => ({
apiKey: one(apiKeys, {
fields: [apiKeyPermissions.apiKeyId],
references: [apiKeys.id],
}),
}));
export const apiLogsRelations = relations(apiLogs, ({ one }) => ({
apiKey: one(apiKeys, {
fields: [apiLogs.apiKeyId],
references: [apiKeys.id],
}),
}));
export const userStatusLogsRelations = relations(userStatusLogs, ({ one }) => ({
user: one(users, {
fields: [userStatusLogs.userId],
references: [users.id],
}),
operator: one(users, {
fields: [userStatusLogs.operatorId],
references: [users.id],
}),
}));
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 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 type User = typeof users.$inferSelect;
export type NewUser = typeof users.$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 Schedule = typeof schedules.$inferSelect;
export type NewSchedule = typeof schedules.$inferInsert;
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
export type NotificationSettings = typeof notificationSettings.$inferSelect;
export type NewNotificationSettings = typeof notificationSettings.$inferInsert;
export type PlayTime = typeof playTimes.$inferSelect;
export type NewPlayTime = typeof playTimes.$inferInsert;
export type Semester = typeof semesters.$inferSelect;
export type NewSemester = typeof semesters.$inferInsert;
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 ApiKey = typeof apiKeys.$inferSelect;
export type NewApiKey = typeof apiKeys.$inferInsert;
export type ApiKeyPermission = typeof apiKeyPermissions.$inferSelect;
export type NewApiKeyPermission = typeof apiKeyPermissions.$inferInsert;
export type ApiLog = typeof apiLogs.$inferSelect;
export type NewApiLog = typeof apiLogs.$inferInsert;
export type UserStatusLog = typeof userStatusLogs.$inferSelect;
export type NewUserStatusLog = typeof userStatusLogs.$inferInsert;
export type SongCollaborator = typeof songCollaborators.$inferSelect;
export type NewSongCollaborator = typeof songCollaborators.$inferInsert;
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 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;