Files

984 lines
42 KiB
TypeScript
Raw Permalink 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, integer, pgEnum, pgTable, serial, text, timestamp, uuid, varchar, unique, index, uniqueIndex, jsonb} 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', '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 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'),
studentId: text('studentId'),
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(),
meowNickname: text('meowNickname'),
meowBoundAt: timestamp('meowBoundAt'),
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),
]);
// 认证会话表
export const authSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
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'),
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.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 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', {
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 songs = pgTable('Song', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().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(),
playedAt: timestamp('playedAt').defaultNow(),
semester: text('semester'),
preferredPlayTimeId: integer('preferredPlayTimeId'),
cover: text('cover'),
playUrl: text('playUrl'),
musicPlatform: text('musicPlatform'),
musicId: text('musicId'),
submissionNote: text('submissionNote'),
submissionNotePublic: boolean('submissionNotePublic').default(false).notNull(),
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),
]);
// 投票表
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 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(),
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.publishedAt, table.songId),
index('schedule_published_date_idx').on(table.publishedAt, table.playDate),
]);
// 排期备选池表
export const scheduleSongPool = pgTable('schedule_song_pool', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
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) => [
index('schedule_song_pool_song_id_idx').on(table.songId),
index('schedule_song_pool_target_date_idx').on(table.targetPlayDate),
]);
// 期望播出日期表
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', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
type: text('type').notNull(),
message: text('message').notNull(),
read: boolean('read').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.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(),
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'),
enableSubmissionLimit: boolean('enableSubmissionLimit').default(false).notNull(),
dailySubmissionLimit: integer('dailySubmissionLimit'),
weeklySubmissionLimit: integer('weeklySubmissionLimit'),
monthlySubmissionLimit: integer('monthlySubmissionLimit'),
showBlacklistKeywords: boolean('showBlacklistKeywords').default(false).notNull(),
hideStudentInfo: boolean('hideStudentInfo').default(true).notNull(),
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('forceBlockAllRequests').default(false).notNull(),
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
enableSubmissionRemarks: boolean('enableSubmissionRemarks').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'),
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(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(),
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('[]'),
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
githubClientId: text('githubClientId'),
githubClientSecret: text('githubClientSecret'),
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
casdoorServerUrl: text('casdoorServerUrl'),
casdoorClientId: text('casdoorClientId'),
casdoorClientSecret: text('casdoorClientSecret'),
casdoorOrganizationName: text('casdoorOrganizationName'),
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'),
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'),
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'),
statisticsCode: text('statisticsCode'),
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
defaultTheme: text('defaultTheme').default('ClassicDark').notNull(),
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'),
kugouApiBaseUrl: text('kugouApiBaseUrl'),
kugouAdminCookie: text('kugouAdminCookie'),
kugouAdminToken: text('kugouAdminToken'),
kugouAdminUser: jsonb('kugouAdminUser').$type<Record<string, any>>(),
kugouAdminBoundAt: timestamp('kugouAdminBoundAt'),
});
export const kuGouPlaylistSyncs = pgTable('KuGouPlaylistSync', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
playDate: timestamp('playDate').notNull(),
semesterId: integer('semesterId'),
playlistId: text('playlistId'),
playlistName: text('playlistName').notNull(),
playlistUrl: text('playlistUrl'),
totalSongs: integer('totalSongs').default(0).notNull(),
addedSongs: integer('addedSongs').default(0).notNull(),
missingSongs: jsonb('missingSongs').default('[]').$type<any[]>().notNull(),
status: text('status').default('pending').notNull(),
error: text('error'),
triggeredByUserId: integer('triggeredByUserId'),
rawResponse: jsonb('rawResponse').default('{}').$type<Record<string, any>>().notNull(),
}, (table) => [
index('kugou_playlist_sync_play_date_idx').on(table.playDate),
]);
// 歌曲黑名单表
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'),
});
// 卡密表
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(),
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(),
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(),
expected: bigint({ mode: "number" }).default(0).notNull(),
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(),
});
// 联合投稿审计日志表
export const collaborationLogs = pgTable('collaboration_logs', {
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(),
});
// 歌曲重播申请表
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(),
}, (t) => ({
unq: unique().on(t.songId, t.userId),
}));
// 第三方身份关联表
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'),
createdAt: timestamp('createdAt').defaultNow().notNull(),
}, (t) => ({
unq: unique().on(t.provider, t.providerUserId),
}));
/**
* 微信订阅消息的授权记账表。
*
* 为什么需要它:微信**不提供**「这个用户还剩几条订阅授权」的查询接口,
* 而「一次性订阅」是发一条扣一条。所以这里按 (userId, templateId) 记账:
* 用户在小程序里点「允许」时 grantedCount +1,服务端发送成功时 sentCount +1,
* 被微信拒绝(如 43101)时 blockedCount +1 并记下 errcode。
*
* ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、上报可能没发出去、
* 微信侧额度也可能过期,真值只有发一条收到 43101 才知道。
*
* **所以这张表只用于展示和排查,绝不参与「发不发」的决策** —— 早先的版本拿它做额度
* 预检(记账为 0 就跳过不发送),结果记账一旦和微信侧不同步,通知就被无声地全部丢掉。
* 现在服务端一律照常尝试发送,让微信回 errcode 来裁决。
*/
export const wechatSubscribeGrants = pgTable('WechatSubscribeGrant', {
id: serial('id').primaryKey(),
userId: integer('userId').notNull(),
templateId: text('templateId').notNull(),
/** 对应的模板键(如 SONG_SELECTED),模板被换掉后这里可能对不上,仅作展示用 */
kind: text('kind'),
grantedCount: integer('grantedCount').default(0).notNull(),
sentCount: integer('sentCount').default(0).notNull(),
blockedCount: integer('blockedCount').default(0).notNull(),
lastGrantedAt: timestamp('lastGrantedAt'),
lastSentAt: timestamp('lastSentAt'),
lastBlockedAt: timestamp('lastBlockedAt'),
lastErrorCode: integer('lastErrorCode'),
lastErrorMessage: text('lastErrorMessage'),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
}, (t) => ({
unq: unique().on(t.userId, t.templateId),
}));
export const changelogEntries = pgTable('ChangelogEntry', {
id: serial('id').primaryKey(),
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 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),
statusLogs: many(userStatusLogs),
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 }) => ({
user: one(users, {
fields: [userIdentities.userId],
references: [users.id],
}),
}));
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],
references: [users.id],
}),
preferredPlayTime: one(playTimes, {
fields: [songs.preferredPlayTimeId],
references: [playTimes.id],
}),
votes: many(votes),
comments: many(songComments),
schedules: many(schedules),
notifications: many(notifications),
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 }) => ({
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 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],
references: [users.id],
}),
song: one(songs, {
fields: [notifications.songId],
references: [songs.id],
}),
sender: one(users, {
fields: [notifications.senderId],
references: [users.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),
}));
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 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;
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 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;
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 RequestTime = typeof requestTimes.$inferSelect;
export type NewRequestTime = typeof requestTimes.$inferInsert;
export type UserIdentity = typeof userIdentities.$inferSelect;
export type NewUserIdentity = typeof userIdentities.$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;