feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user