feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
import {drizzle} from 'drizzle-orm/postgres-js';
import {and, asc, count, desc, eq, exists, gt, gte, lt, lte, ne, or, sql} from 'drizzle-orm';
import postgres from 'postgres';
import * as schema from './schema.ts';
import {config} from 'dotenv';
import path from 'path';
import {fileURLToPath} from 'url';
// 加载环境变量(优先使用工作目录的 .env,确保构建后运行时能正确加载)
config({ path: path.resolve(process.cwd(), '.env') });
// 检查环境变量
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is not set');
}
// 创建PostgreSQL连接
const connectionString = process.env.DATABASE_URL;
// 检测数据库类型
const getDatabaseHostname = (value: string) => {
try {
return new URL(value).hostname.toLowerCase().replace(/\.$/, '');
} catch {
return '';
}
};
const isDomainOrSubdomain = (hostname: string, domain: string) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
};
const databaseHostname = getDatabaseHostname(connectionString);
const isNeonDatabase =
isDomainOrSubdomain(databaseHostname, 'neon.tech') ||
isDomainOrSubdomain(databaseHostname, 'neon.database.com');
// Serverless 部署(每个函数实例独立建池,必须压低单实例连接数,防止实例数×池大小超过数据库 max_connections)
const isServerlessDatabase = !!process.env.VERCEL || !!process.env.NETLIFY;
// 根据数据库类型选择配置
const getDatabaseConfig = () => {
if (isNeonDatabase) {
// Neon Database Serverless 优化配置
return {
max: 1, // Serverless 环境下每个实例保持最小连接数,利用 Neon 自身的连接池
idle_timeout: 0, // 立即释放空闲连接,适应 Serverless 的快速冻结特性
connect_timeout: 10, // Neon 连接速度快,减少超时时间
max_lifetime: 3600, // 连接最大生命周期(1小时)
ssl: 'require', // Neon 默认需要 SSL
prepare: false, // 禁用预处理语句以提高兼容性
transform: {
undefined: null, // 将undefined转换为null
},
connection: {
application_name: 'voicehub-app'
},
onnotice: process.env.NODE_ENV === 'development' ? console.log : undefined,
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
};
} else {
// 标准 PostgreSQL 数据库配置
return {
// Serverless 下单实例池压到 2:1 vCPU 数据库同时只能执行一条查询,大池只会耗尽 max_connections
max: isServerlessDatabase ? 2 : process.env.NODE_ENV === 'production' ? 10 : 5,
idle_timeout: 20, // 增加空闲超时时间
// 连接失败快速抛出,避免 30 秒挂起拖垮函数实例并引发重试风暴
connect_timeout: isServerlessDatabase ? 10 : 30,
max_lifetime: 3600, // 连接最大生命周期(1小时)
ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false,
prepare: false, // 禁用预处理语句以提高兼容性
transform: {
undefined: null, // 将undefined转换为null
},
connection: {
application_name: 'voicehub-app'
},
onnotice: process.env.NODE_ENV === 'development' ? console.log : undefined,
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
};
}
};
const client = postgres(connectionString, getDatabaseConfig());
// 创建Drizzle数据库实例
export const db = drizzle(client, { schema });
// 导出连接客户端(用于手动查询或关闭连接)
export { client };
// 导出schema以便在其他地方使用
export * from './schema.ts';
// 导出drizzle-orm函数
export {eq, ne, and, gt, gte, lt, lte, count, exists, desc, asc, or, sql};
// 数据库连接测试函数
export async function testConnection() {
try {
await client`SELECT 1`;
console.log('✅ Database connection successful');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error);
return false;
}
}
// 获取数据库连接状态
export function getConnectionStatus() {
const isConnected = !client.ended;
return {
isConnected,
connected: isConnected,
status: isConnected ? 'connected' : 'disconnected',
maxConnections: client.options.max,
idleTimeout: client.options.idle_timeout,
connectTimeout: client.options.connect_timeout
};
}
// 连接管理 - 根据数据库类型自适应
let idleTimer: NodeJS.Timeout | null = null;
// Neon 数据库使用更短的空闲时间以支持自动启停,普通 PostgreSQL 使用更长的空闲时间
const IDLE_TIMEOUT = isNeonDatabase ? 5 * 60 * 1000 : 10 * 60 * 1000; // Neon: 5分钟,PostgreSQL: 10分钟
// 重置空闲计时器
function resetIdleTimer() {
if (idleTimer) {
clearTimeout(idleTimer);
}
// 只在生产环境启用自动断开
if (process.env.NODE_ENV === 'production') {
idleTimer = setTimeout(async () => {
try {
if (!client.ended) {
const dbType = isNeonDatabase ? 'Neon' : 'PostgreSQL';
console.log(`🔄 Auto-closing idle ${dbType} database connections${isNeonDatabase ? ' for Serverless optimization' : ''}`);
await client.end({ timeout: isNeonDatabase ? 5 : 10 });
}
} catch (error) {
console.error('❌ Error during auto-close:', error);
}
}, IDLE_TIMEOUT);
}
}
// 包装数据库操作以支持自动启停
export function withAutoReconnect<T extends any[], R>(
operation: (...args: T) => Promise<R>
) {
return async (...args: T): Promise<R> => {
resetIdleTimer();
try {
return await operation(...args);
} catch (error: any) {
// 如果连接已关闭,记录信息
if (error?.code === 'CONNECTION_ENDED' || client.ended) {
const dbType = isNeonDatabase ? 'Neon' : 'PostgreSQL';
console.log(`🔄 ${dbType} database connection ended${isNeonDatabase ? ', Neon will auto-reconnect on next query' : ', will reconnect on next query'}`);
}
throw error;
}
};
}
// 优雅关闭数据库连接
export async function closeConnection() {
try {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (!client.ended) {
await client.end({ timeout: 10 });
console.log('✅ Database connection closed gracefully');
}
} catch (error) {
console.error('❌ Error closing database connection:', error);
}
}
// 设置优雅关闭处理
if (typeof process !== 'undefined') {
const gracefulShutdown = async () => {
console.log('🔄 Shutting down database connections...');
await closeConnection();
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
process.on('beforeExit', gracefulShutdown);
}
@@ -0,0 +1,132 @@
DO $$ BEGIN
CREATE TYPE "public"."BlacklistType" AS ENUM('SONG', 'KEYWORD');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE TABLE "NotificationSettings" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"userId" integer NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"songRequestEnabled" boolean DEFAULT true NOT NULL,
"songVotedEnabled" boolean DEFAULT true NOT NULL,
"songPlayedEnabled" boolean DEFAULT true NOT NULL,
"refreshInterval" integer DEFAULT 60 NOT NULL,
"songVotedThreshold" integer DEFAULT 1 NOT NULL,
CONSTRAINT "NotificationSettings_userId_unique" UNIQUE("userId")
);
--> statement-breakpoint
CREATE TABLE "Notification" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"type" text NOT NULL,
"message" text NOT NULL,
"read" boolean DEFAULT false NOT NULL,
"userId" integer NOT NULL,
"songId" integer
);
--> statement-breakpoint
CREATE TABLE "PlayTime" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"name" text NOT NULL,
"startTime" text,
"endTime" text,
"enabled" boolean DEFAULT true NOT NULL,
"description" text
);
--> statement-breakpoint
CREATE TABLE "Schedule" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"songId" integer NOT NULL,
"playDate" timestamp NOT NULL,
"played" boolean DEFAULT false NOT NULL,
"sequence" integer DEFAULT 1 NOT NULL,
"playTimeId" integer
);
--> statement-breakpoint
CREATE TABLE "Semester" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"name" text NOT NULL,
"isActive" boolean DEFAULT false NOT NULL,
CONSTRAINT "Semester_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "SongBlacklist" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"type" "BlacklistType" NOT NULL,
"value" text NOT NULL,
"reason" text,
"isActive" boolean DEFAULT true NOT NULL,
"createdBy" integer
);
--> statement-breakpoint
CREATE TABLE "Song" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"title" text NOT NULL,
"artist" text NOT NULL,
"requesterId" integer NOT NULL,
"played" boolean DEFAULT false NOT NULL,
"playedAt" timestamp,
"semester" text,
"preferredPlayTimeId" integer,
"cover" text,
"musicPlatform" text,
"musicId" text
);
--> statement-breakpoint
CREATE TABLE "SystemSettings" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"enablePlayTimeSelection" boolean DEFAULT false NOT NULL,
"siteTitle" text,
"siteLogoUrl" text,
"schoolLogoHomeUrl" text,
"schoolLogoPrintUrl" text,
"siteDescription" text,
"submissionGuidelines" text,
"icpNumber" text,
"enableSubmissionLimit" boolean DEFAULT false NOT NULL,
"dailySubmissionLimit" integer,
"weeklySubmissionLimit" integer,
"showBlacklistKeywords" boolean DEFAULT false NOT NULL,
"hideStudentInfo" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "User" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"username" text NOT NULL,
"name" text,
"grade" text,
"class" text,
"role" text DEFAULT 'USER' NOT NULL,
"password" text NOT NULL,
"lastLogin" timestamp,
"lastLoginIp" text,
"passwordChangedAt" timestamp,
"forcePasswordChange" boolean DEFAULT true NOT NULL,
"meowNickname" text,
"meowBoundAt" timestamp,
CONSTRAINT "User_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "Vote" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"songId" integer NOT NULL,
"userId" integer NOT NULL
);
@@ -0,0 +1,14 @@
-- 为排期表添加草稿支持
-- 添加草稿状态标识字段
ALTER TABLE "Schedule" ADD COLUMN IF NOT EXISTS "isDraft" boolean DEFAULT false NOT NULL;
-- 添加发布时间字段(发布后记录发布时间)
ALTER TABLE "Schedule" ADD COLUMN IF NOT EXISTS "publishedAt" timestamp;
-- 为现有排期设置为已发布状态(因为现有的都应该是已发布的)
UPDATE "Schedule" SET "isDraft" = false, "publishedAt" = "updatedAt" WHERE "isDraft" IS NULL;
-- 创建索引以优化查询性能
CREATE INDEX IF NOT EXISTS "idx_schedule_is_draft" ON "Schedule" ("isDraft");
CREATE INDEX IF NOT EXISTS "idx_schedule_published_at" ON "Schedule" ("publishedAt");
CREATE INDEX IF NOT EXISTS "idx_schedule_draft_date" ON "Schedule" ("isDraft", "playDate");
@@ -0,0 +1,55 @@
-- 创建用户状态枚举类型
DO $$ BEGIN
CREATE TYPE "public"."user_status" AS ENUM('active', 'withdrawn');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
-- 添加用户状态相关字段到用户表
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "status" "user_status" DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "statusChangedAt" timestamp DEFAULT now();--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "statusChangedBy" integer;--> statement-breakpoint
-- 创建用户状态变更日志表
CREATE TABLE IF NOT EXISTS "user_status_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"old_status" "user_status",
"new_status" "user_status" NOT NULL,
"reason" text,
"operator_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL
);--> statement-breakpoint
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS "idx_user_status" ON "User" USING btree ("status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_user_status_logs_user_id" ON "user_status_logs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_user_status_logs_created_at" ON "user_status_logs" USING btree ("created_at" DESC);--> statement-breakpoint
-- 添加外键约束
DO $$ BEGIN
ALTER TABLE "User" ADD CONSTRAINT "User_statusChangedBy_User_id_fk" FOREIGN KEY ("statusChangedBy") REFERENCES "User"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_status_logs" ADD CONSTRAINT "user_status_logs_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_status_logs" ADD CONSTRAINT "user_status_logs_operator_id_User_id_fk" FOREIGN KEY ("operator_id") REFERENCES "User"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
-- 为现有用户初始化状态变更日志
INSERT INTO "user_status_logs" ("user_id", "old_status", "new_status", "reason", "created_at")
SELECT "id", NULL, 'active', '系统迁移初始化', "createdAt"
FROM "User"
WHERE NOT EXISTS (
SELECT 1
FROM "user_status_logs"
WHERE "user_status_logs"."user_id" = "User"."id"
AND "user_status_logs"."reason" = '系统迁移初始化'
);
@@ -0,0 +1 @@
ALTER TABLE "Semester" DROP CONSTRAINT "Semester_name_unique";
@@ -0,0 +1,68 @@
-- 创建API Keys表
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"description" text,
"key_hash" varchar(255) NOT NULL,
"key_prefix" varchar(10) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
"created_by_user_id" integer NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);--> statement-breakpoint
-- 创建API Key权限表
CREATE TABLE IF NOT EXISTS "api_key_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"api_key_id" uuid NOT NULL,
"permission" varchar(100) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "api_key_permissions_api_key_id_permission_unique" UNIQUE("api_key_id", "permission")
);--> statement-breakpoint
-- 创建API访问日志表
CREATE TABLE IF NOT EXISTS "api_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"api_key_id" uuid,
"endpoint" varchar(500) NOT NULL,
"method" varchar(10) NOT NULL,
"ip_address" inet NOT NULL,
"user_agent" text,
"status_code" integer NOT NULL,
"response_time_ms" integer NOT NULL,
"request_body" text,
"response_body" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"error_message" text
);--> statement-breakpoint
-- 创建外键约束
DO $$ BEGIN
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_created_by_user_id_User_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "User"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_key_permissions" ADD CONSTRAINT "api_key_permissions_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_logs" ADD CONSTRAINT "api_logs_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
-- 创建索引
CREATE INDEX IF NOT EXISTS "idx_api_keys_key_hash" ON "api_keys"("key_hash");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_keys_created_by" ON "api_keys"("created_by_user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_keys_active" ON "api_keys"("is_active") WHERE "is_active" = true;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_key_permissions_api_key_id" ON "api_key_permissions"("api_key_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_logs_api_key_id" ON "api_logs"("api_key_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_logs_created_at" ON "api_logs"("created_at" DESC);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_logs_status_code" ON "api_logs"("status_code");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_api_logs_endpoint" ON "api_logs"("endpoint");
@@ -0,0 +1,2 @@
ALTER TABLE "NotificationSettings" DROP CONSTRAINT "NotificationSettings_userId_unique";--> statement-breakpoint
ALTER TABLE "User" DROP CONSTRAINT "User_username_unique";
@@ -0,0 +1,20 @@
-- 添加用户邮箱字段
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "email" text;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false NOT NULL;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" timestamp;
-- 添加系统设置SMTP配置字段
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpEnabled" boolean DEFAULT false NOT NULL;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpHost" text;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpPort" integer DEFAULT 587;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpSecure" boolean DEFAULT false NOT NULL;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpUsername" text;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpPassword" text;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpFromEmail" text;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpFromName" text;
-- 添加通知设置邮件通知字段
ALTER TABLE "NotificationSettings" ADD COLUMN IF NOT EXISTS "emailSongRequestEnabled" boolean DEFAULT true NOT NULL;
ALTER TABLE "NotificationSettings" ADD COLUMN IF NOT EXISTS "emailSongVotedEnabled" boolean DEFAULT true NOT NULL;
ALTER TABLE "NotificationSettings" ADD COLUMN IF NOT EXISTS "emailSongPlayedEnabled" boolean DEFAULT true NOT NULL;
ALTER TABLE "NotificationSettings" ADD COLUMN IF NOT EXISTS "emailSystemNoticeEnabled" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,10 @@
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpHost" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpPort" integer DEFAULT 587;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpSecure" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpUsername" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpPassword" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpFromEmail" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "smtpFromName" text DEFAULT '校园广播站';--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "email" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false;
@@ -0,0 +1,10 @@
CREATE TABLE "EmailTemplate" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"key" varchar(100) NOT NULL,
"name" varchar(200) NOT NULL,
"subject" varchar(300) NOT NULL,
"html" text NOT NULL,
"updatedByUserId" integer
);
@@ -0,0 +1,2 @@
-- 添加歌曲播放地址字段
ALTER TABLE "Song" ADD COLUMN IF NOT EXISTS "playUrl" text;
@@ -0,0 +1,3 @@
ALTER TABLE "Schedule" ADD COLUMN IF NOT EXISTS "isDraft" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "Schedule" ADD COLUMN IF NOT EXISTS "publishedAt" timestamp;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN IF NOT EXISTS "playUrl" text;
@@ -0,0 +1,42 @@
CREATE TYPE "public"."collaborator_status" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');--> statement-breakpoint
CREATE TABLE "collaboration_logs"
(
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"collaborator_id" uuid NOT NULL,
"action" varchar(50) NOT NULL,
"operator_id" integer NOT NULL,
"ip_address" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "RequestTime"
(
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp(6) DEFAULT now() NOT NULL,
"updatedAt" timestamp(6) DEFAULT now() NOT NULL,
"name" text NOT NULL,
"startTime" timestamp NOT NULL,
"endTime" timestamp NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"description" text,
"expected" bigint DEFAULT 0 NOT NULL,
"accepted" bigint DEFAULT 0 NOT NULL,
"past" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "song_collaborators"
(
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"song_id" integer NOT NULL,
"user_id" integer NOT NULL,
"status" "collaborator_status" DEFAULT 'PENDING' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "Song"
ADD COLUMN "hitRequestId" integer;--> statement-breakpoint
ALTER TABLE "SystemSettings"
ADD COLUMN "enableRequestTimeLimitation" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings"
ADD COLUMN "forceBlockAllRequests" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS "song_replay_requests" (
"id" serial PRIMARY KEY NOT NULL,
"song_id" integer NOT NULL,
"user_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "song_replay_requests_song_id_user_id_unique" UNIQUE("song_id","user_id")
);
ALTER TABLE "SystemSettings" ADD COLUMN "enableReplayRequests" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
CREATE TYPE "public"."replay_request_status" AS ENUM('PENDING', 'FULFILLED', 'REJECTED');--> statement-breakpoint
ALTER TABLE "song_replay_requests" ADD COLUMN "status" "replay_request_status" DEFAULT 'PENDING' NOT NULL;
@@ -0,0 +1,11 @@
CREATE TABLE "UserIdentity" (
"id" serial PRIMARY KEY NOT NULL,
"userId" integer NOT NULL,
"provider" text NOT NULL,
"providerUserId" text NOT NULL,
"providerUsername" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "UserIdentity_provider_providerUserId_unique" UNIQUE("provider","providerUserId")
);
--> statement-breakpoint
ALTER TABLE "UserIdentity" ADD CONSTRAINT "UserIdentity_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "monthlySubmissionLimit" integer;
@@ -0,0 +1,2 @@
ALTER TABLE "SystemSettings" ADD COLUMN "gonganNumber" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "gonganLink" text;
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" DROP COLUMN "gonganLink";
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "enableCollaborativeSubmission" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "Song" ADD COLUMN "submissionNote" text;
ALTER TABLE "Song" ADD COLUMN "submissionNotePublic" boolean DEFAULT false NOT NULL;
ALTER TABLE "SystemSettings" ADD COLUMN "enableSubmissionRemarks" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,14 @@
ALTER TABLE "SystemSettings"
ADD COLUMN "customOAuthEnabled" boolean DEFAULT false NOT NULL,
ADD COLUMN "customOAuthDisplayName" text,
ADD COLUMN "customOAuthAuthorizeUrl" text,
ADD COLUMN "customOAuthTokenUrl" text,
ADD COLUMN "customOAuthUserInfoUrl" text,
ADD COLUMN "customOAuthScope" text,
ADD COLUMN "customOAuthClientId" text,
ADD COLUMN "customOAuthClientSecret" text,
ADD COLUMN "customOAuthUserIdField" text,
ADD COLUMN "customOAuthUsernameField" text,
ADD COLUMN "customOAuthNameField" text,
ADD COLUMN "customOAuthEmailField" text,
ADD COLUMN "customOAuthAvatarField" text;
@@ -0,0 +1,15 @@
ALTER TABLE "SystemSettings"
ADD COLUMN "oauthRedirectUri" text,
ADD COLUMN "oauthStateSecret" text,
ADD COLUMN "oauthProviders" text DEFAULT '[]',
ADD COLUMN "githubOAuthEnabled" boolean DEFAULT false NOT NULL,
ADD COLUMN "githubClientId" text,
ADD COLUMN "githubClientSecret" text,
ADD COLUMN "casdoorOAuthEnabled" boolean DEFAULT false NOT NULL,
ADD COLUMN "casdoorServerUrl" text,
ADD COLUMN "casdoorClientId" text,
ADD COLUMN "casdoorClientSecret" text,
ADD COLUMN "casdoorOrganizationName" text,
ADD COLUMN "googleOAuthEnabled" boolean DEFAULT false NOT NULL,
ADD COLUMN "googleClientId" text,
ADD COLUMN "googleClientSecret" text;
@@ -0,0 +1 @@
ALTER TYPE "public"."user_status" ADD VALUE IF NOT EXISTS 'graduate';
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "showBeianIcon" boolean DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "allowOAuthRegistration" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "SystemSettings" ADD COLUMN "captchaEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "captchaMaxFailures" integer DEFAULT 3 NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "SystemSettings" ADD COLUMN "captchaProvider" text DEFAULT 'graphic' NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSiteKey" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSecretKey" text;
@@ -0,0 +1,3 @@
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "instance_id" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "telemetryEnabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TYPE "user_status" ADD VALUE IF NOT EXISTS 'graduate';
@@ -0,0 +1,97 @@
DO $$ BEGIN
CREATE TYPE "public"."card_code_status" AS ENUM('AVAILABLE','LOCKED','REDEEMED','INVALID');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "CardCode" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"code" text NOT NULL,
"status" "card_code_status" DEFAULT 'AVAILABLE' NOT NULL,
"lockedBy" integer,
"lockedAt" timestamp,
"redeemedBy" integer,
"redeemedAt" timestamp,
"note" text
);--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN IF NOT EXISTS "cardCodeId" integer;--> statement-breakpoint
ALTER TABLE "SystemSettings"
ADD COLUMN IF NOT EXISTS "enableCardCodeRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings"
ADD COLUMN IF NOT EXISTS "requireCardCodeForRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "CardCode_code_unique" ON "CardCode" ("code");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCode"
ADD CONSTRAINT "CardCode_lockedBy_User_id_fk"
FOREIGN KEY ("lockedBy") REFERENCES "public"."User"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCode"
ADD CONSTRAINT "CardCode_redeemedBy_User_id_fk"
FOREIGN KEY ("redeemedBy") REFERENCES "public"."User"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Song"
ADD CONSTRAINT "Song_cardCodeId_CardCode_id_fk"
FOREIGN KEY ("cardCodeId") REFERENCES "public"."CardCode"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "CardCodeRedeemLog" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"cardCodeId" integer NOT NULL,
"codeSnapshot" text NOT NULL,
"redeemedBy" integer NOT NULL,
"redeemedAt" timestamp DEFAULT now() NOT NULL,
"source" text DEFAULT 'UNKNOWN' NOT NULL,
"songId" integer
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "CardCodeRedeemLog_cardCodeId_idx" ON "CardCodeRedeemLog" ("cardCodeId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "CardCodeRedeemLog_redeemedBy_idx" ON "CardCodeRedeemLog" ("redeemedBy");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "CardCodeRedeemLog_redeemedAt_idx" ON "CardCodeRedeemLog" ("redeemedAt");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCodeRedeemLog"
ADD CONSTRAINT "CardCodeRedeemLog_cardCodeId_CardCode_id_fk"
FOREIGN KEY ("cardCodeId") REFERENCES "public"."CardCode"("id")
ON DELETE RESTRICT ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCodeRedeemLog"
ADD CONSTRAINT "CardCodeRedeemLog_redeemedBy_User_id_fk"
FOREIGN KEY ("redeemedBy") REFERENCES "public"."User"("id")
ON DELETE RESTRICT ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCodeRedeemLog"
ADD CONSTRAINT "CardCodeRedeemLog_songId_Song_id_fk"
FOREIGN KEY ("songId") REFERENCES "public"."Song"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,18 @@
ALTER TABLE "CardCodeRedeemLog"
ALTER COLUMN "cardCodeId" DROP NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCodeRedeemLog"
DROP CONSTRAINT IF EXISTS "CardCodeRedeemLog_cardCodeId_CardCode_id_fk";
EXCEPTION
WHEN undefined_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "CardCodeRedeemLog"
ADD CONSTRAINT "CardCodeRedeemLog_cardCodeId_CardCode_id_fk"
FOREIGN KEY ("cardCodeId") REFERENCES "public"."CardCode"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,2 @@
CREATE INDEX "card_code_redeem_log_card_code_id_idx" ON "CardCodeRedeemLog" USING btree ("cardCodeId");--> statement-breakpoint
CREATE INDEX "song_card_code_id_idx" ON "Song" USING btree ("cardCodeId");
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "enableCardCodeLimitBypass" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,6 @@
ALTER TABLE "SystemSettings"
ADD COLUMN IF NOT EXISTS "aggregateOAuthEnabled" boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS "aggregateOAuthAppId" text,
ADD COLUMN IF NOT EXISTS "aggregateOAuthAppKey" text,
ADD COLUMN IF NOT EXISTS "aggregateOAuthLoginType" text DEFAULT 'qq',
ADD COLUMN IF NOT EXISTS "aggregateOAuthEndpoint" text DEFAULT 'https://a.idcfx.net/connect.php';
@@ -0,0 +1,22 @@
CREATE INDEX "schedule_published_song_idx" ON "Schedule" USING btree ("isDraft","songId","playDate");--> statement-breakpoint
CREATE INDEX "schedule_published_date_idx" ON "Schedule" USING btree ("isDraft","playDate");--> statement-breakpoint
CREATE INDEX "song_collaborators_song_status_idx" ON "song_collaborators" USING btree ("song_id","status");--> statement-breakpoint
CREATE INDEX "song_collaborators_user_status_idx" ON "song_collaborators" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "song_replay_requests_user_status_song_idx" ON "song_replay_requests" USING btree ("user_id","status","song_id");--> statement-breakpoint
CREATE INDEX "song_semester_created_at_idx" ON "Song" USING btree ("semester","createdAt");--> statement-breakpoint
CREATE INDEX "song_requester_id_idx" ON "Song" USING btree ("requesterId");--> statement-breakpoint
CREATE INDEX "vote_user_song_idx" ON "Vote" USING btree ("userId","songId");--> statement-breakpoint
-- 保留每个用户对每首歌曲最早的一条投票,清理旧版本并发产生的重复记录。
WITH ranked_votes AS (
SELECT
"id",
ROW_NUMBER() OVER (PARTITION BY "songId", "userId" ORDER BY "id") AS duplicate_rank
FROM "Vote"
)
DELETE FROM "Vote"
WHERE "id" IN (
SELECT "id"
FROM ranked_votes
WHERE duplicate_rank > 1
);--> statement-breakpoint
ALTER TABLE "Vote" ADD CONSTRAINT "vote_song_user_unique" UNIQUE("songId","userId");
@@ -0,0 +1,38 @@
-- 重播申请无感化:扩展申请元数据、排期绑定申请、放开唯一约束
-- 1. song_replay_requests 新增提交元数据字段(复用 songs 字段语义)
ALTER TABLE "song_replay_requests" ADD COLUMN IF NOT EXISTS "preferred_play_time_id" integer;
ALTER TABLE "song_replay_requests" ADD COLUMN IF NOT EXISTS "submission_note" text;
ALTER TABLE "song_replay_requests" ADD COLUMN IF NOT EXISTS "submission_note_public" boolean DEFAULT false NOT NULL;
-- 2. Schedule 新增重播申请绑定字段(无外键约束,历史数据可为 NULL)
ALTER TABLE "Schedule" ADD COLUMN IF NOT EXISTS "replay_request_id" integer;
-- 3. 放开 (song_id, user_id) 全量唯一约束,允许同一用户对同一首歌提交多条重播申请
ALTER TABLE "song_replay_requests" DROP CONSTRAINT IF EXISTS "song_replay_requests_song_id_user_id_unique";
-- 同一用户对同一首歌同时最多保留一条待处理申请
CREATE UNIQUE INDEX IF NOT EXISTS "song_replay_requests_pending_song_user_unique"
ON "song_replay_requests" ("song_id", "user_id")
WHERE "status" = 'PENDING';
-- 4. 尽力回填历史重播排期的申请绑定:
-- 每首歌取最新一条已履行申请,只绑定申请创建后最早发布的一条排期(一对一)
UPDATE "Schedule" sch
SET "replay_request_id" = m.request_id
FROM (
SELECT DISTINCT ON (rr.id) rr.id AS request_id, s.id AS schedule_id
FROM (
SELECT DISTINCT ON ("song_id") id, "song_id", "created_at"
FROM "song_replay_requests"
WHERE "status" = 'FULFILLED'
ORDER BY "song_id", "created_at" DESC
) rr
JOIN "Schedule" s ON s."songId" = rr."song_id"
AND s."replay_request_id" IS NULL
AND s."isDraft" = false
AND s."publishedAt" IS NOT NULL
AND s."publishedAt" >= rr."created_at"
ORDER BY rr.id, s."publishedAt" ASC, s.id ASC
) m
WHERE sch.id = m.schedule_id;
@@ -0,0 +1,27 @@
CREATE TABLE "PasswordAuditLog" (
"id" serial PRIMARY KEY NOT NULL,
"userId" integer NOT NULL,
"actorId" integer,
"action" text NOT NULL,
"success" boolean NOT NULL,
"ipAddress" text,
"userAgent" text,
"failureReason" text,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PasswordRateLimit" (
"key" text PRIMARY KEY NOT NULL,
"count" integer NOT NULL,
"resetAt" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "forcePasswordChange" SET DEFAULT false;--> statement-breakpoint
-- 回填存量数据:旧默认值 true 无法与管理员显式重置区分,统一清零,
-- 避免上线当天存量用户在开关关闭时仍被硬门控锁进改密页;
-- 此后强制标记仅由管理员显式重置产生,首次登录强制改密由系统开关控制。
UPDATE "User" SET "forcePasswordChange" = false WHERE "forcePasswordChange" = true;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "forcePasswordChangeOnFirstLogin" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "tokenVersion" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
CREATE INDEX "PasswordAuditLog_user_created_idx" ON "PasswordAuditLog" USING btree ("userId","createdAt");--> statement-breakpoint
CREATE INDEX "PasswordRateLimit_reset_idx" ON "PasswordRateLimit" USING btree ("resetAt");
@@ -0,0 +1,10 @@
ALTER TABLE "Notification" ADD COLUMN "batchId" text;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "source" text DEFAULT 'SYSTEM' NOT NULL;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "senderId" integer;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "senderName" text;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "senderUsername" text;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "title" text;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "important" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "userDeleted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX "notification_user_important_read_created_idx" ON "Notification" USING btree ("userId","userDeleted","important","read","createdAt");--> statement-breakpoint
CREATE INDEX "notification_batch_id_idx" ON "Notification" USING btree ("batchId");
@@ -0,0 +1,14 @@
CREATE TABLE "BackupHistory" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"filename" text NOT NULL,
"totalRecords" integer DEFAULT 0 NOT NULL,
"backupSize" integer DEFAULT 0 NOT NULL,
"methods" text NOT NULL,
"success" boolean DEFAULT false NOT NULL,
"triggeredBy" text
);
--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupConfig" text;--> statement-breakpoint
CREATE INDEX "backup_history_created_at_idx" ON "BackupHistory" USING btree ("createdAt");
@@ -0,0 +1,5 @@
ALTER TABLE "SystemSettings" ADD COLUMN "enabledPlatforms" text DEFAULT '["netease","tencent","bilibili","migu"]';--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "platformOrder" text DEFAULT '["netease","tencent","bilibili","migu"]';--> statement-breakpoint
-- 回填已有行的默认值
UPDATE "SystemSettings" SET "enabledPlatforms" = '["netease","tencent","bilibili","migu"]' WHERE "enabledPlatforms" IS NULL;
UPDATE "SystemSettings" SET "platformOrder" = '["netease","tencent","bilibili","migu"]' WHERE "platformOrder" IS NULL;
@@ -0,0 +1,21 @@
-- 歌曲时长
ALTER TABLE "Song" ADD COLUMN "durationSeconds" integer;
-- 自动排期备选池表(管理员手动维护的候选歌曲池)
CREATE TABLE IF NOT EXISTS "ScheduleSongPool" (
"id" serial PRIMARY KEY,
"createdAt" timestamp DEFAULT now() NOT NULL,
"songId" integer NOT NULL REFERENCES "Song" ("id") ON DELETE CASCADE,
"addedBy" integer REFERENCES "User" ("id") ON DELETE SET NULL,
CONSTRAINT "schedule_song_pool_song_unique" UNIQUE ("songId")
);
-- 音源平台默认排序
ALTER TABLE "SystemSettings" ALTER COLUMN "enabledPlatforms" SET DEFAULT '["netease","tencent","bilibili","migu"]';
ALTER TABLE "SystemSettings" ALTER COLUMN "platformOrder" SET DEFAULT '["netease","tencent","bilibili","migu"]';
-- 重复投稿限制字段(同一首歌/同一歌手进入排期后的禁止再次投稿时间窗口)
ALTER TABLE "SystemSettings" ADD COLUMN "enableSubmissionRestriction" boolean DEFAULT false NOT NULL;
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionScope" text DEFAULT 'all' NOT NULL;
ALTER TABLE "SystemSettings" ADD COLUMN "sameSongRestrictionHours" integer;
ALTER TABLE "SystemSettings" ADD COLUMN "sameArtistRestrictionHours" integer;
@@ -0,0 +1,2 @@
ALTER TABLE "SystemSettings" ADD COLUMN "defaultTheme" text DEFAULT 'System' NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "enabledThemes" text DEFAULT '["System","ClassicDark","ClassicLight","ModernLight"]' NOT NULL;
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "auth_sessions" (
"id" uuid PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_active_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"browser" text,
"operating_system" text,
"device" text,
"login_method" text DEFAULT 'password' NOT NULL,
"revoked_at" timestamp with time zone,
"revoked_reason" text
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "auth_sessions_user_active_idx" ON "auth_sessions" USING btree ("user_id","revoked_at","expires_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "auth_sessions_last_active_idx" ON "auth_sessions" USING btree ("last_active_at");
@@ -0,0 +1,4 @@
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysBeforeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysBefore" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysAfterEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysAfter" integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "UserIdentity" ADD COLUMN "avatar" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "avatarProvider" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "avatarProviderUserId" text;
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ALTER COLUMN "aggregateOAuthEndpoint" DROP DEFAULT;
@@ -0,0 +1,22 @@
ALTER TYPE "public"."user_status" ADD VALUE 'pending' BEFORE 'withdrawn';--> statement-breakpoint
ALTER TYPE "public"."user_status" ADD VALUE 'rejected';--> statement-breakpoint
CREATE TABLE "GradeClass" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"grade" text NOT NULL,
"class" text NOT NULL,
CONSTRAINT "GradeClass_grade_class_unique" UNIQUE("grade","class")
);
--> statement-breakpoint
ALTER TABLE "song_replay_requests" ADD COLUMN "submission_note_public_status" text;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "submissionNotePublicStatus" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "allowRegister" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresApproval" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthRegisterRequiresApproval" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerEmailRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionNoteRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user_status_logs" ADD COLUMN "username" text;--> statement-breakpoint
ALTER TABLE "user_status_logs" ADD COLUMN "name" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "remark" text;--> statement-breakpoint
CREATE UNIQUE INDEX "User_username_unique" ON "User" USING btree ("username");
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresGradeClass" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
ALTER TYPE "public"."BlacklistType" ADD VALUE 'LANGUAGE';--> statement-breakpoint
ALTER TYPE "public"."BlacklistType" ADD VALUE 'GENRE';
@@ -0,0 +1,2 @@
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCodeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCode" text;
@@ -0,0 +1,791 @@
{
"id": "7431b15e-7067-4715-87c6-cc4c83712e0b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.NotificationSettings": {
"name": "NotificationSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songRequestEnabled": {
"name": "songRequestEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songVotedEnabled": {
"name": "songVotedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songPlayedEnabled": {
"name": "songPlayedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"refreshInterval": {
"name": "refreshInterval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"songVotedThreshold": {
"name": "songVotedThreshold",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"NotificationSettings_userId_unique": {
"name": "NotificationSettings_userId_unique",
"nullsNotDistinct": false,
"columns": [
"userId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Notification": {
"name": "Notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"read": {
"name": "read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PlayTime": {
"name": "PlayTime",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"startTime": {
"name": "startTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"endTime": {
"name": "endTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Schedule": {
"name": "Schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"playDate": {
"name": "playDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"playTimeId": {
"name": "playTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Semester": {
"name": "Semester",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Semester_name_unique": {
"name": "Semester_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SongBlacklist": {
"name": "SongBlacklist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "BlacklistType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdBy": {
"name": "createdBy",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Song": {
"name": "Song",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"artist": {
"name": "artist",
"type": "text",
"primaryKey": false,
"notNull": true
},
"requesterId": {
"name": "requesterId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"playedAt": {
"name": "playedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"semester": {
"name": "semester",
"type": "text",
"primaryKey": false,
"notNull": false
},
"preferredPlayTimeId": {
"name": "preferredPlayTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"cover": {
"name": "cover",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicPlatform": {
"name": "musicPlatform",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicId": {
"name": "musicId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SystemSettings": {
"name": "SystemSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"enablePlayTimeSelection": {
"name": "enablePlayTimeSelection",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"siteTitle": {
"name": "siteTitle",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteLogoUrl": {
"name": "siteLogoUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoHomeUrl": {
"name": "schoolLogoHomeUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoPrintUrl": {
"name": "schoolLogoPrintUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteDescription": {
"name": "siteDescription",
"type": "text",
"primaryKey": false,
"notNull": false
},
"submissionGuidelines": {
"name": "submissionGuidelines",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icpNumber": {
"name": "icpNumber",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enableSubmissionLimit": {
"name": "enableSubmissionLimit",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"dailySubmissionLimit": {
"name": "dailySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"weeklySubmissionLimit": {
"name": "weeklySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"showBlacklistKeywords": {
"name": "showBlacklistKeywords",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"hideStudentInfo": {
"name": "hideStudentInfo",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grade": {
"name": "grade",
"type": "text",
"primaryKey": false,
"notNull": false
},
"class": {
"name": "class",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'USER'"
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lastLogin": {
"name": "lastLogin",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"lastLoginIp": {
"name": "lastLoginIp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"passwordChangedAt": {
"name": "passwordChangedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"forcePasswordChange": {
"name": "forcePasswordChange",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"meowNickname": {
"name": "meowNickname",
"type": "text",
"primaryKey": false,
"notNull": false
},
"meowBoundAt": {
"name": "meowBoundAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Vote": {
"name": "Vote",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.BlacklistType": {
"name": "BlacklistType",
"schema": "public",
"values": [
"SONG",
"KEYWORD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,783 @@
{
"id": "5412e543-4a9c-416f-87b1-b8a405a19172",
"prevId": "7431b15e-7067-4715-87c6-cc4c83712e0b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.NotificationSettings": {
"name": "NotificationSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songRequestEnabled": {
"name": "songRequestEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songVotedEnabled": {
"name": "songVotedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songPlayedEnabled": {
"name": "songPlayedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"refreshInterval": {
"name": "refreshInterval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"songVotedThreshold": {
"name": "songVotedThreshold",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"NotificationSettings_userId_unique": {
"name": "NotificationSettings_userId_unique",
"nullsNotDistinct": false,
"columns": [
"userId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Notification": {
"name": "Notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"read": {
"name": "read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PlayTime": {
"name": "PlayTime",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"startTime": {
"name": "startTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"endTime": {
"name": "endTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Schedule": {
"name": "Schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"playDate": {
"name": "playDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"playTimeId": {
"name": "playTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Semester": {
"name": "Semester",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SongBlacklist": {
"name": "SongBlacklist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "BlacklistType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdBy": {
"name": "createdBy",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Song": {
"name": "Song",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"artist": {
"name": "artist",
"type": "text",
"primaryKey": false,
"notNull": true
},
"requesterId": {
"name": "requesterId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"playedAt": {
"name": "playedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"semester": {
"name": "semester",
"type": "text",
"primaryKey": false,
"notNull": false
},
"preferredPlayTimeId": {
"name": "preferredPlayTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"cover": {
"name": "cover",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicPlatform": {
"name": "musicPlatform",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicId": {
"name": "musicId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SystemSettings": {
"name": "SystemSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"enablePlayTimeSelection": {
"name": "enablePlayTimeSelection",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"siteTitle": {
"name": "siteTitle",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteLogoUrl": {
"name": "siteLogoUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoHomeUrl": {
"name": "schoolLogoHomeUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoPrintUrl": {
"name": "schoolLogoPrintUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteDescription": {
"name": "siteDescription",
"type": "text",
"primaryKey": false,
"notNull": false
},
"submissionGuidelines": {
"name": "submissionGuidelines",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icpNumber": {
"name": "icpNumber",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enableSubmissionLimit": {
"name": "enableSubmissionLimit",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"dailySubmissionLimit": {
"name": "dailySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"weeklySubmissionLimit": {
"name": "weeklySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"showBlacklistKeywords": {
"name": "showBlacklistKeywords",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"hideStudentInfo": {
"name": "hideStudentInfo",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grade": {
"name": "grade",
"type": "text",
"primaryKey": false,
"notNull": false
},
"class": {
"name": "class",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'USER'"
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lastLogin": {
"name": "lastLogin",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"lastLoginIp": {
"name": "lastLoginIp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"passwordChangedAt": {
"name": "passwordChangedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"forcePasswordChange": {
"name": "forcePasswordChange",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"meowNickname": {
"name": "meowNickname",
"type": "text",
"primaryKey": false,
"notNull": false
},
"meowBoundAt": {
"name": "meowBoundAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Vote": {
"name": "Vote",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.BlacklistType": {
"name": "BlacklistType",
"schema": "public",
"values": [
"SONG",
"KEYWORD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,767 @@
{
"id": "b7406ed0-51d0-4217-92f0-0131b12db197",
"prevId": "5412e543-4a9c-416f-87b1-b8a405a19172",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.NotificationSettings": {
"name": "NotificationSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songRequestEnabled": {
"name": "songRequestEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songVotedEnabled": {
"name": "songVotedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"songPlayedEnabled": {
"name": "songPlayedEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"refreshInterval": {
"name": "refreshInterval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"songVotedThreshold": {
"name": "songVotedThreshold",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Notification": {
"name": "Notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"read": {
"name": "read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PlayTime": {
"name": "PlayTime",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"startTime": {
"name": "startTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"endTime": {
"name": "endTime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Schedule": {
"name": "Schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"playDate": {
"name": "playDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"playTimeId": {
"name": "playTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Semester": {
"name": "Semester",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SongBlacklist": {
"name": "SongBlacklist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"type": {
"name": "type",
"type": "BlacklistType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdBy": {
"name": "createdBy",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Song": {
"name": "Song",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"artist": {
"name": "artist",
"type": "text",
"primaryKey": false,
"notNull": true
},
"requesterId": {
"name": "requesterId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"played": {
"name": "played",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"playedAt": {
"name": "playedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"semester": {
"name": "semester",
"type": "text",
"primaryKey": false,
"notNull": false
},
"preferredPlayTimeId": {
"name": "preferredPlayTimeId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"cover": {
"name": "cover",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicPlatform": {
"name": "musicPlatform",
"type": "text",
"primaryKey": false,
"notNull": false
},
"musicId": {
"name": "musicId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.SystemSettings": {
"name": "SystemSettings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"enablePlayTimeSelection": {
"name": "enablePlayTimeSelection",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"siteTitle": {
"name": "siteTitle",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteLogoUrl": {
"name": "siteLogoUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoHomeUrl": {
"name": "schoolLogoHomeUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schoolLogoPrintUrl": {
"name": "schoolLogoPrintUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"siteDescription": {
"name": "siteDescription",
"type": "text",
"primaryKey": false,
"notNull": false
},
"submissionGuidelines": {
"name": "submissionGuidelines",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icpNumber": {
"name": "icpNumber",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enableSubmissionLimit": {
"name": "enableSubmissionLimit",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"dailySubmissionLimit": {
"name": "dailySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"weeklySubmissionLimit": {
"name": "weeklySubmissionLimit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"showBlacklistKeywords": {
"name": "showBlacklistKeywords",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"hideStudentInfo": {
"name": "hideStudentInfo",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grade": {
"name": "grade",
"type": "text",
"primaryKey": false,
"notNull": false
},
"class": {
"name": "class",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'USER'"
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lastLogin": {
"name": "lastLogin",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"lastLoginIp": {
"name": "lastLoginIp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"passwordChangedAt": {
"name": "passwordChangedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"forcePasswordChange": {
"name": "forcePasswordChange",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"meowNickname": {
"name": "meowNickname",
"type": "text",
"primaryKey": false,
"notNull": false
},
"meowBoundAt": {
"name": "meowBoundAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Vote": {
"name": "Vote",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"songId": {
"name": "songId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.BlacklistType": {
"name": "BlacklistType",
"schema": "public",
"values": [
"SONG",
"KEYWORD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1756291495622,
"tag": "0000_friendly_garia",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1756291496622,
"tag": "20250102000001_add_schedule_draft_support",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1756291497622,
"tag": "20250113000001_add_user_status_management",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1756299330141,
"tag": "20250827125530_little_james_howlett",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1756339201000,
"tag": "20250828000001_add_api_framework_tables",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1756368610365,
"tag": "20250828081010_careful_shinobi_shaw",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1757548801000,
"tag": "20250911000001_add_smtp_email_fields",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1757772919975,
"tag": "20250913141519_clumsy_maria_hill",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1757819341115,
"tag": "20250914030901_majestic_black_widow",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1759363201000,
"tag": "20251002000001_add_song_play_url",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1759395147253,
"tag": "20251002085227_deep_silver_sable",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1767777840967,
"tag": "20260107092400_add_collaboration_and_request_time",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1768042908810,
"tag": "20260110000001_add_replay_requests",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769766215307,
"tag": "20260130094335_add_replay_request_status",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1770895837437,
"tag": "20260212113037_add_user_identities_table",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1772187630689,
"tag": "20260227102030_add_monthly_submission_limit",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1772889825480,
"tag": "20260307132345_add_gongan_filing",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772890398452,
"tag": "20260307133318_remove_gongan_link",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773923154561,
"tag": "20260319122554_add_collaborative_submission_toggle",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773927000000,
"tag": "20260319133000_add_submission_remarks",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1774700000000,
"tag": "20260328120000_add_custom_oauth_provider",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774700600000,
"tag": "20260328121000_add_oauth_system_settings_columns",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1775136952492,
"tag": "20260402133552_remarkable_star_brand",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1775310943895,
"tag": "20260404135543_add_show_beian_icon",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1775393214709,
"tag": "20260405124654_add_allow_oauth_registration",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1778311885032,
"tag": "20260509073125_add_captcha_settings",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1778318043510,
"tag": "20260509091403_add_turnstile_support",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1779603809404,
"tag": "20260524062329_add_instance_id_and_telemetry_toggle",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1781481660000,
"tag": "20260614000100_add_card_code_request_system",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1782432001000,
"tag": "20260626000100_add_card_code_auto_delete",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1782527721417,
"tag": "20260627023521_add_card_code_foreign_key_indexes",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783690428462,
"tag": "20260710133348_add_card_code_limit_bypass",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1784419260000,
"tag": "20260718000100_add_aggregate_oauth_provider",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1784530957225,
"tag": "20260720070237_minimize-redis-add-song-query-indexes",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1785196801000,
"tag": "20260727000100_replay_request_seamless",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1785239348895,
"tag": "20260728114908_add_password_security",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1785492048291,
"tag": "20260731100048_notification-announcement",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1785578400000,
"tag": "20260801000000_add_auto_backup",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1786269600000,
"tag": "20260809000000_add_platform_management",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1786809600000,
"tag": "20260816000000_add_schedule_song_pool",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1787483779816,
"tag": "20260823000000_theme-settings",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1787497559680,
"tag": "20260823230554_add_auth_sessions",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1787587786102,
"tag": "20260824160946_add_schedule_visibility_range",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1787653675610,
"tag": "20260825102755_add-oauth-avatar",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1787655239368,
"tag": "20260825105359_drop_aggregate_oauth_endpoint_default",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1787715174545,
"tag": "20260826033254_add_registration_review_grade_class_and_note_review",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1787825301370,
"tag": "20260827100821_add_register_requires_grade_class",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1787880865098,
"tag": "20260828013425_add_song_type_blacklist",
"breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1787902641410,
"tag": "20260828073721_add_statistics_code",
"breakpoints": true
}
]
}
+13
View File
@@ -0,0 +1,13 @@
import { relations } from "drizzle-orm/relations";
import { user, userIdentity } from "./schema";
export const userIdentityRelations = relations(userIdentity, ({one}) => ({
user: one(user, {
fields: [userIdentity.userId],
references: [user.id]
}),
}));
export const userRelations = relations(user, ({many}) => ({
userIdentities: many(userIdentity),
}));
+415
View File
@@ -0,0 +1,415 @@
import { pgTable, serial, timestamp, text, boolean, integer, uuid, varchar, unique, uniqueIndex, index, bigint, foreignKey, pgEnum } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const blacklistType = pgEnum("BlacklistType", ['SONG', 'KEYWORD'])
export const cardCodeStatus = pgEnum("card_code_status", ['AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID'])
export const collaboratorStatus = pgEnum("collaborator_status", ['PENDING', 'ACCEPTED', 'REJECTED'])
export const replayRequestStatus = pgEnum("replay_request_status", ['PENDING', 'FULFILLED', 'REJECTED'])
export const userStatus = pgEnum("user_status", ['active', 'withdrawn', 'graduate'])
export const semester = pgTable("Semester", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
name: text().notNull(),
isActive: boolean().default(false).notNull(),
});
export const notification = pgTable("Notification", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
type: text().notNull(),
message: text().notNull(),
read: boolean().default(false).notNull(),
userId: integer().notNull(),
songId: integer(),
});
export const playTime = pgTable("PlayTime", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
name: text().notNull(),
startTime: text(),
endTime: text(),
enabled: boolean().default(true).notNull(),
description: text(),
});
export const songBlacklist = pgTable("SongBlacklist", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
type: blacklistType().notNull(),
value: text().notNull(),
reason: text(),
isActive: boolean().default(true).notNull(),
createdBy: integer(),
});
export const schedule = pgTable("Schedule", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
songId: integer().notNull(),
playDate: timestamp({ mode: 'string' }).notNull(),
played: boolean().default(false).notNull(),
sequence: integer().default(1).notNull(),
playTimeId: integer(),
isDraft: boolean().default(false).notNull(),
publishedAt: timestamp({ mode: 'string' }),
replayRequestId: integer(),
});
export const scheduleSongPool = pgTable("ScheduleSongPool", {
id: serial().primaryKey().notNull(),
createdAt: timestamp().defaultNow().notNull(),
songId: integer().notNull(),
addedBy: integer(),
});
export const notificationSettings = pgTable("NotificationSettings", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
userId: integer().notNull(),
enabled: boolean().default(true).notNull(),
songRequestEnabled: boolean().default(true).notNull(),
songVotedEnabled: boolean().default(true).notNull(),
songPlayedEnabled: boolean().default(true).notNull(),
refreshInterval: integer().default(60).notNull(),
songVotedThreshold: integer().default(1).notNull(),
});
export const apiLogs = pgTable("api_logs", {
id: uuid().defaultRandom().primaryKey().notNull(),
apiKeyId: uuid("api_key_id"),
endpoint: varchar({ length: 500 }).notNull(),
method: varchar({ 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, mode: 'string' }).defaultNow().notNull(),
errorMessage: text("error_message"),
});
export const song = pgTable("Song", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
title: text().notNull(),
artist: text().notNull(),
requesterId: integer().notNull(),
played: boolean().default(false).notNull(),
playedAt: timestamp({ mode: 'string' }).defaultNow(),
semester: text(),
preferredPlayTimeId: integer(),
cover: text(),
musicPlatform: text(),
musicId: text(),
durationSeconds: integer(),
playUrl: text(),
hitRequestId: integer(),
submissionNote: text(),
submissionNotePublic: boolean().default(false).notNull(),
cardCodeId: integer(),
});
export const vote = pgTable("Vote", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
songId: integer().notNull(),
userId: integer().notNull(),
});
export const apiKeyPermissions = pgTable("api_key_permissions", {
id: uuid().defaultRandom().primaryKey().notNull(),
apiKeyId: uuid("api_key_id").notNull(),
permission: varchar({ length: 100 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
});
export const userStatusLogs = pgTable("user_status_logs", {
id: serial().primaryKey().notNull(),
userId: integer("user_id").notNull(),
oldStatus: userStatus("old_status"),
newStatus: userStatus("new_status").notNull(),
reason: text(),
operatorId: integer("operator_id"),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
});
export const apiKeys = pgTable("api_keys", {
id: uuid().defaultRandom().primaryKey().notNull(),
name: varchar({ length: 255 }).notNull(),
description: text(),
keyHash: varchar("key_hash", { length: 255 }).notNull(),
keyPrefix: varchar("key_prefix", { length: 10 }).notNull(),
isActive: boolean("is_active").default(true).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }),
createdByUserId: integer("created_by_user_id").notNull(),
usageCount: integer("usage_count").default(0).notNull(),
}, (table) => [
unique("api_keys_key_hash_unique").on(table.keyHash),
]);
export const user = pgTable("User", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
username: text().notNull(),
name: text(),
grade: text(),
class: text(),
role: text().default('USER').notNull(),
password: text().notNull(),
lastLogin: timestamp({ mode: 'string' }),
lastLoginIp: text(),
passwordChangedAt: timestamp({ mode: 'string' }),
forcePasswordChange: boolean().default(false).notNull(),
tokenVersion: integer().default(0).notNull(),
meowNickname: text(),
meowBoundAt: timestamp({ mode: 'string' }),
status: userStatus().default('active').notNull(),
statusChangedAt: timestamp({ mode: 'string' }).defaultNow(),
statusChangedBy: integer(),
email: text(),
emailVerified: boolean().default(false),
});
export const passwordAuditLog = pgTable("PasswordAuditLog", {
id: serial().primaryKey().notNull(),
userId: integer().notNull(),
actorId: integer(),
action: text().notNull(),
success: boolean().notNull(),
ipAddress: text(),
userAgent: text(),
failureReason: text(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(),
}, (table) => [
index("PasswordAuditLog_user_created_idx").on(table.userId, table.createdAt),
]);
export const passwordRateLimit = pgTable("PasswordRateLimit", {
key: text().primaryKey().notNull(),
count: integer().notNull(),
resetAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
}, (table) => [
index("PasswordRateLimit_reset_idx").on(table.resetAt),
]);
export const emailTemplate = pgTable("EmailTemplate", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
key: varchar({ length: 100 }).notNull(),
name: varchar({ length: 200 }).notNull(),
subject: varchar({ length: 300 }).notNull(),
html: text().notNull(),
updatedByUserId: integer(),
});
export const collaborationLogs = pgTable("collaboration_logs", {
id: uuid().defaultRandom().primaryKey().notNull(),
collaboratorId: uuid("collaborator_id").notNull(),
action: varchar({ length: 50 }).notNull(),
operatorId: integer("operator_id").notNull(),
ipAddress: text("ip_address"),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
});
export const requestTime = 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().defaultRandom().primaryKey().notNull(),
songId: integer("song_id").notNull(),
userId: integer("user_id").notNull(),
status: collaboratorStatus().default('PENDING').notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
});
export const songReplayRequests = pgTable("song_replay_requests", {
id: serial().primaryKey().notNull(),
songId: integer("song_id").notNull(),
userId: integer("user_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
status: replayRequestStatus().default('PENDING').notNull(),
preferredPlayTimeId: integer("preferred_play_time_id"),
submissionNote: text("submission_note"),
submissionNotePublic: boolean("submission_note_public").default(false).notNull(),
}, (table) => [
uniqueIndex("song_replay_requests_pending_song_user_unique")
.on(table.songId, table.userId)
.where(sql`${table.status} = 'PENDING'`),
]);
export const userIdentity = pgTable("UserIdentity", {
id: serial().primaryKey().notNull(),
userId: integer().notNull(),
provider: text().notNull(),
providerUserId: text().notNull(),
providerUsername: text(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "UserIdentity_userId_User_id_fk"
}).onDelete("cascade"),
unique("UserIdentity_provider_providerUserId_unique").on(table.provider, table.providerUserId),
]);
export const cardCode = pgTable("CardCode", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
code: text().notNull(),
status: cardCodeStatus().default('AVAILABLE').notNull(),
lockedBy: integer(),
lockedAt: timestamp({ mode: 'string' }),
redeemedBy: integer(),
redeemedAt: timestamp({ mode: 'string' }),
note: text(),
}, (table) => [
foreignKey({
columns: [table.lockedBy],
foreignColumns: [user.id],
name: "CardCode_lockedBy_User_id_fk"
}).onDelete("set null"),
foreignKey({
columns: [table.redeemedBy],
foreignColumns: [user.id],
name: "CardCode_redeemedBy_User_id_fk"
}).onDelete("set null"),
unique("CardCode_code_unique").on(table.code),
]);
export const cardCodeRedeemLog = pgTable("CardCodeRedeemLog", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
cardCodeId: integer(),
codeSnapshot: text().notNull(),
redeemedBy: integer().notNull(),
redeemedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
source: text().default('UNKNOWN').notNull(),
songId: integer(),
}, (table) => [
foreignKey({
columns: [table.cardCodeId],
foreignColumns: [cardCode.id],
name: "CardCodeRedeemLog_cardCodeId_CardCode_id_fk"
}).onDelete("set null"),
foreignKey({
columns: [table.redeemedBy],
foreignColumns: [user.id],
name: "CardCodeRedeemLog_redeemedBy_User_id_fk"
}).onDelete("restrict"),
foreignKey({
columns: [table.songId],
foreignColumns: [song.id],
name: "CardCodeRedeemLog_songId_Song_id_fk"
}).onDelete("set null"),
]);
export const systemSettings = pgTable("SystemSettings", {
id: serial().primaryKey().notNull(),
createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(),
enablePlayTimeSelection: boolean().default(false).notNull(),
siteTitle: text(),
siteLogoUrl: text(),
schoolLogoHomeUrl: text(),
schoolLogoPrintUrl: text(),
siteDescription: text(),
submissionGuidelines: text(),
icpNumber: text(),
enableSubmissionLimit: boolean().default(false).notNull(),
dailySubmissionLimit: integer(),
weeklySubmissionLimit: integer(),
showBlacklistKeywords: boolean().default(false).notNull(),
hideStudentInfo: boolean().default(true).notNull(),
smtpEnabled: boolean().default(false).notNull(),
smtpHost: text(),
smtpPort: integer().default(587),
smtpSecure: boolean().default(false),
smtpUsername: text(),
smtpPassword: text(),
smtpFromEmail: text(),
smtpFromName: text().default('校园广播站'),
enableRequestTimeLimitation: boolean().default(false).notNull(),
forceBlockAllRequests: boolean().default(false).notNull(),
forcePasswordChangeOnFirstLogin: boolean().default(false).notNull(),
enableReplayRequests: boolean().default(false).notNull(),
monthlySubmissionLimit: integer(),
gonganNumber: text(),
enableCollaborativeSubmission: boolean().default(true).notNull(),
enableSubmissionRemarks: boolean().default(false).notNull(),
customOauthEnabled: boolean().default(false).notNull(),
customOauthDisplayName: text(),
customOauthAuthorizeUrl: text(),
customOauthTokenUrl: text(),
customOauthUserInfoUrl: text(),
customOauthScope: text(),
customOauthClientId: text(),
customOauthClientSecret: text(),
customOauthUserIdField: text(),
customOauthUsernameField: text(),
customOauthNameField: text(),
customOauthEmailField: text(),
customOauthAvatarField: text(),
oauthRedirectUri: text(),
oauthStateSecret: text(),
oauthProviders: text().default('[]'),
githubOauthEnabled: boolean().default(false).notNull(),
githubClientId: text(),
githubClientSecret: text(),
casdoorOauthEnabled: boolean().default(false).notNull(),
casdoorServerUrl: text(),
casdoorClientId: text(),
casdoorClientSecret: text(),
casdoorOrganizationName: text(),
googleOauthEnabled: boolean().default(false).notNull(),
googleClientId: text(),
googleClientSecret: text(),
aggregateOauthEnabled: boolean("aggregateOAuthEnabled").default(false).notNull(),
aggregateOauthAppId: text("aggregateOAuthAppId"),
aggregateOauthAppKey: text("aggregateOAuthAppKey"),
aggregateOauthLoginType: text("aggregateOAuthLoginType").default('qq'),
aggregateOauthEndpoint: text("aggregateOAuthEndpoint").default('https://a.idcfx.net/connect.php'),
showBeianIcon: boolean().default(false).notNull(),
allowOauthRegistration: boolean().default(false).notNull(),
captchaEnabled: boolean().default(false).notNull(),
captchaMaxFailures: integer().default(3).notNull(),
captchaProvider: text().default('graphic').notNull(),
turnstileSiteKey: text(),
turnstileSecretKey: text(),
instanceId: text("instance_id"),
telemetryEnabled: boolean().default(true).notNull(),
enableCardCodeRequests: boolean().default(false).notNull(),
requireCardCodeForRequests: boolean().default(false).notNull(),
});
+762
View File
@@ -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;