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>(), 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().notNull(), status: text('status').default('pending').notNull(), error: text('error'), triggeredByUserId: integer('triggeredByUserId'), rawResponse: jsonb('rawResponse').default('{}').$type>().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 才知道。所以 `no-grant` 预检只用于「省一次请求」, * 不能当成权威判断(自测接口用 force 绕过)。 */ 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;