feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
This commit is contained in:
2026-09-06 17:03:20 +08:00
parent 43e2a05666
commit bd8df1f026
53 changed files with 10486 additions and 245 deletions
+56 -5
View File
@@ -61,6 +61,12 @@
> >
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }}) <RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
</button> </button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchReject"
>
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
</button>
<button <button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest" class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchDelete" @click="batchDelete"
@@ -408,7 +414,7 @@
@click.stop @click.stop
> >
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between"> <div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">驳回歌曲</h3> <h3 class="text-xl font-black text-zinc-100">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
<button <button
class="text-zinc-500 hover:text-zinc-300 transition-colors" class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="cancelReject" @click="cancelReject"
@@ -418,7 +424,7 @@
</div> </div>
<div class="p-8 space-y-6"> <div class="p-8 space-y-6">
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4"> <div v-if="!isBatchReject" class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div <div
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10" class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
> >
@@ -429,6 +435,17 @@
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p> <p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
</div> </div>
</div> </div>
<div v-else class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center border border-amber-500/10"
>
<ListMusic :size="18" />
</div>
<div>
<h4 class="font-bold text-zinc-100 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1" <label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
@@ -961,6 +978,7 @@ import {
X, X,
Trash2, Trash2,
Music, Music,
ListMusic,
Heart, Heart,
Download, Download,
ChevronDown, ChevronDown,
@@ -1040,6 +1058,7 @@ const showRejectDialog = ref(false)
const rejectLoading = ref(false) const rejectLoading = ref(false)
const rejectReason = ref('') const rejectReason = ref('')
const addToBlacklist = ref(false) const addToBlacklist = ref(false)
const isBatchReject = ref(false)
const rejectSongInfo = ref({ const rejectSongInfo = ref({
id: null, id: null,
title: '', title: '',
@@ -1475,6 +1494,7 @@ const rejectSong = (songId) => {
const song = songs.value.find((s) => s.id === songId) const song = songs.value.find((s) => s.id === songId)
if (!song) return if (!song) return
isBatchReject.value = false
rejectSongInfo.value = { rejectSongInfo.value = {
id: song.id, id: song.id,
title: song.title || '', title: song.title || '',
@@ -1487,6 +1507,22 @@ const rejectSong = (songId) => {
showRejectDialog.value = true showRejectDialog.value = true
} }
// 批量驳回歌曲
const batchReject = () => {
if (selectedSongs.value.length === 0) return
isBatchReject.value = true
rejectSongInfo.value = {
id: null,
title: '',
artist: '',
requester: ''
}
rejectReason.value = ''
addToBlacklist.value = false
showRejectDialog.value = true
}
// 确认驳回 // 确认驳回
const confirmReject = async () => { const confirmReject = async () => {
if (!rejectReason.value.trim()) { if (!rejectReason.value.trim()) {
@@ -1496,6 +1532,19 @@ const confirmReject = async () => {
rejectLoading.value = true rejectLoading.value = true
try { try {
if (isBatchReject.value) {
await $fetch('/api/admin/songs/batch-reject', {
method: 'POST',
body: {
songIds: selectedSongs.value,
reason: rejectReason.value.trim(),
addToBlacklist: addToBlacklist.value
}
})
await refreshSongs(true)
selectedSongs.value = []
showNotification(`批量驳回成功,共处理 ${selectedSongs.value.length} 首歌曲`, 'success')
} else {
await $fetch('/api/admin/songs/reject', { await $fetch('/api/admin/songs/reject', {
method: 'POST', method: 'POST',
body: { body: {
@@ -1512,10 +1561,11 @@ const confirmReject = async () => {
selectedSongs.value.splice(index, 1) selectedSongs.value.splice(index, 1)
} }
showRejectDialog.value = false
showNotification('歌曲驳回成功,已通知投稿人', 'success') showNotification('歌曲驳回成功,已通知投稿人', 'success')
} catch (error) { }
showRejectDialog.value = false
} catch (error: any) {
console.error('驳回歌曲失败:', error) console.error('驳回歌曲失败:', error)
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error') showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
} finally { } finally {
@@ -1528,6 +1578,7 @@ const cancelReject = () => {
showRejectDialog.value = false showRejectDialog.value = false
rejectReason.value = '' rejectReason.value = ''
addToBlacklist.value = false addToBlacklist.value = false
isBatchReject.value = false
rejectSongInfo.value = { rejectSongInfo.value = {
id: null, id: null,
title: '', title: '',
+11 -2
View File
@@ -26,7 +26,17 @@
<AuthProvidersGoogleIcon /> <AuthProvidersGoogleIcon />
</button> </button>
<!-- 其他提供商可在此添加 --> <button
v-if="config.public.oauth.aggregate"
type="button"
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#12B7F5] hover:text-white hover:border-[#12B7F5]"
title="使用 QQ / 微信登录"
@click="loginWith('aggregate')"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
</svg>
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -39,7 +49,6 @@ const hasEnabledProviders = computed(() => {
}) })
const loginWith = (provider: string) => { const loginWith = (provider: string) => {
// 外部导航到 API 端点
navigateTo(`/api/auth/${provider}`, { external: true }) navigateTo(`/api/auth/${provider}`, { external: true })
} }
</script> </script>
+34 -2
View File
@@ -19,7 +19,9 @@ const siteConfig = ref({
siteVersion: '', siteVersion: '',
submissionGuidelines: '', submissionGuidelines: '',
icpNumber: '', icpNumber: '',
gonganNumber: '' gonganNumber: '',
defaultTheme: null,
enabledThemes: null
}) })
const isLoaded = ref(false) const isLoaded = ref(false)
@@ -57,7 +59,9 @@ export const useSiteConfig = () => {
gonganNumber: '', gonganNumber: '',
enableReplayRequests: false, enableReplayRequests: false,
enableCollaborativeSubmission: true, enableCollaborativeSubmission: true,
enableSubmissionRemarks: false enableSubmissionRemarks: false,
defaultTheme: 'System',
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
} }
isLoaded.value = true isLoaded.value = true
} finally { } finally {
@@ -96,6 +100,32 @@ export const useSiteConfig = () => {
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true) const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled) const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
function parseEnabledThemes(raw) {
if (Array.isArray(raw)) return raw.filter(Boolean)
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
if (s.startsWith('[') || s.startsWith('{')) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
if (parsed && Array.isArray(parsed.items)) return parsed.items.filter(Boolean)
} catch (_) {
// fallback to comma split
}
}
return s.split(',').map((x) => x.trim()).filter(Boolean)
}
return []
}
const defaultTheme = computed(() =>
typeof siteConfig.value.defaultTheme === 'string' && siteConfig.value.defaultTheme
? siteConfig.value.defaultTheme
: null
)
const enabledThemes = computed(() => parseEnabledThemes(siteConfig.value.enabledThemes))
// 初始化配置(仅在客户端执行) // 初始化配置(仅在客户端执行)
const initSiteConfig = async () => { const initSiteConfig = async () => {
if (typeof window !== 'undefined' && !isLoaded.value) { if (typeof window !== 'undefined' && !isLoaded.value) {
@@ -126,6 +156,8 @@ export const useSiteConfig = () => {
enableCollaborativeSubmission, enableCollaborativeSubmission,
enableSubmissionRemarks, enableSubmissionRemarks,
smtpEnabled, smtpEnabled,
defaultTheme,
enabledThemes,
fetchSiteConfig, fetchSiteConfig,
initSiteConfig, initSiteConfig,
refreshSiteConfig refreshSiteConfig
+108 -40
View File
@@ -1,4 +1,4 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
const STORAGE_KEY = 'voicehub:theme' const STORAGE_KEY = 'voicehub:theme'
type ThemeName = string & {} type ThemeName = string & {}
@@ -15,6 +15,25 @@ function getMetaThemeColor() {
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]') return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
} }
function parseEnabledThemesRaw(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter(Boolean) as string[]
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
if (s.startsWith('[') || s.startsWith('{')) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed)) return parsed.filter(Boolean) as string[]
if (parsed && Array.isArray((parsed as any).items)) return (parsed as any).items.filter(Boolean) as string[]
} catch (_) {
// fallback
}
}
return s.split(',').map((x) => x.trim()).filter(Boolean)
}
return []
}
function applyTheme(theme: string) { function applyTheme(theme: string) {
if (!isBrowser()) return if (!isBrowser()) return
@@ -24,11 +43,12 @@ function applyTheme(theme: string) {
root.setAttribute('data-theme', theme) root.setAttribute('data-theme', theme)
if (appRoot) appRoot.setAttribute('data-theme', theme) if (appRoot) appRoot.setAttribute('data-theme', theme)
if (theme === 'dark') { const effective = resolveEffectiveTheme(theme)
if (effective === 'dark') {
root.classList.add('dark') root.classList.add('dark')
root.classList.remove('light') root.classList.remove('light')
root.style.colorScheme = 'dark' root.style.colorScheme = 'dark'
} else if (theme === 'light') { } else if (effective === 'light') {
root.classList.add('light') root.classList.add('light')
root.classList.remove('dark') root.classList.remove('dark')
root.style.colorScheme = 'light' root.style.colorScheme = 'light'
@@ -39,11 +59,7 @@ function applyTheme(theme: string) {
const meta = getMetaThemeColor() const meta = getMetaThemeColor()
if (meta) { if (meta) {
if (theme === 'light') { meta.content = effective === 'light' ? '#f8fafc' : '#111111'
meta.content = '#f8fafc'
} else {
meta.content = '#111111'
}
} }
try { try {
@@ -59,18 +75,37 @@ function detectSystemTheme(): 'dark' | 'light' {
return prefersDark ? 'dark' : 'light' return prefersDark ? 'dark' : 'light'
} }
function resolveEffectiveTheme(theme: string): 'dark' | 'light' | string {
if (theme === 'System' || theme === 'system') {
return detectSystemTheme()
}
if (theme === 'dark' || theme.endsWith('Dark')) return 'dark'
if (theme === 'light' || theme.endsWith('Light')) return 'light'
return theme
}
export function useTheme() { export function useTheme() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const { isLoaded: siteConfigLoaded, defaultTheme: siteDefaultTheme, enabledThemes: siteEnabledThemes, initSiteConfig } = useSiteConfig()
const enabledThemes = computed<string[]>(() => { const enabledThemes = computed<string[]>(() => {
const list = (config.public?.enabledThemes as unknown) as string[] | undefined if (siteConfigLoaded.value && siteEnabledThemes.value.length > 0) {
return Array.isArray(list) && list.length > 0 ? list : ['dark', 'light'] return [...new Set(siteEnabledThemes.value)]
}
const fromRuntime = parseEnabledThemesRaw(config.public?.enabledThemes)
if (fromRuntime.length > 0) return fromRuntime
return ['System', 'dark', 'light']
}) })
const defaultTheme = computed<string>(() => { const defaultTheme = computed<string>(() => {
const value = config.public?.defaultTheme as string | undefined if (siteConfigLoaded.value && siteDefaultTheme.value) {
if (value && enabledThemes.value.includes(value)) return value return siteDefaultTheme.value
return enabledThemes.value.includes('dark') ? 'dark' : enabledThemes.value[0] || 'dark' }
const fromRuntime = config.public?.defaultTheme
if (typeof fromRuntime === 'string' && fromRuntime) {
return fromRuntime
}
return 'System'
}) })
const theme = computed<string>({ const theme = computed<string>({
@@ -79,22 +114,23 @@ export function useTheme() {
}) })
const isDark = computed(() => { const isDark = computed(() => {
const t = theme.value return resolveEffectiveTheme(theme.value) === 'dark'
if (t === 'dark') return true
if (t === 'light') return false
return detectSystemTheme() === 'dark'
}) })
function isThemeEnabled(name: string) { function isThemeEnabled(name: string) {
return enabledThemes.value.includes(name) return enabledThemes.value.includes(name)
} }
function setTheme(next: string, { persist = true } = {}) { function firstValidTheme(candidate: string): string {
let candidate = next const list = enabledThemes.value
if (!enabledThemes.value.includes(candidate)) { if (list.includes(candidate)) return candidate
candidate = defaultTheme.value if (list.includes(defaultTheme.value)) return defaultTheme.value
return list[0] ?? 'System'
} }
function setTheme(next: string, { persist = true } = {}) {
const candidate = firstValidTheme(next)
if (currentTheme.value === candidate && initialized.value) return if (currentTheme.value === candidate && initialized.value) return
currentTheme.value = candidate currentTheme.value = candidate
@@ -110,7 +146,7 @@ export function useTheme() {
} }
function toggleTheme() { function toggleTheme() {
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['dark', 'light'] const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['System', 'dark', 'light']
const current = theme.value const current = theme.value
const idx = order.indexOf(current) const idx = order.indexOf(current)
const next = order[(idx + 1) % order.length] const next = order[(idx + 1) % order.length]
@@ -121,8 +157,31 @@ export function useTheme() {
setTheme(defaultTheme.value, { persist: false }) setTheme(defaultTheme.value, { persist: false })
} }
function init() { function onSystemThemeChange() {
const stored = (() => {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
})()
if (!stored) {
applyTheme(theme.value)
}
}
async function init() {
if (initialized.value || !isBrowser()) return if (initialized.value || !isBrowser()) return
if (!siteConfigLoaded.value) {
try {
await initSiteConfig()
await nextTick()
} catch (_) {
/* ignore */
}
}
initialized.value = true initialized.value = true
let stored: string | null = null let stored: string | null = null
@@ -132,33 +191,42 @@ export function useTheme() {
stored = null stored = null
} }
if (stored && enabledThemes.value.includes(stored)) { if (stored) {
const valid = firstValidTheme(stored)
if (valid === stored || enabledThemes.value.includes(stored)) {
setTheme(stored) setTheme(stored)
watchSystemTheme()
return return
} }
}
setTheme(defaultTheme.value, { persist: false }) setTheme(defaultTheme.value, { persist: false })
watchSystemTheme()
}
if (window.matchMedia) { function watchSystemTheme() {
if (!isBrowser() || !window.matchMedia) return
const mql = window.matchMedia('(prefers-color-scheme: dark)') const mql = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => { if (typeof mql.addEventListener === 'function') {
let s: string | null = null mql.addEventListener('change', onSystemThemeChange)
try { } else if (typeof (mql as any).addListener === 'function') {
s = localStorage.getItem(STORAGE_KEY) ;(mql as any).addListener(onSystemThemeChange)
} catch {
s = null
} }
if (!s) { }
watch(defaultTheme, () => {
if (!initialized.value) return
const stored = (() => {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
})()
if (!stored) {
setTheme(defaultTheme.value, { persist: false }) setTheme(defaultTheme.value, { persist: false })
} }
} })
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange)
} else if (typeof mql.addListener === 'function') {
mql.addListener(onChange)
}
}
}
return { return {
theme, theme,
@@ -0,0 +1,318 @@
CREATE TYPE "public"."card_code_status" AS ENUM('AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID');--> statement-breakpoint
ALTER TYPE "public"."BlacklistType" ADD VALUE 'LANGUAGE';--> statement-breakpoint
ALTER TYPE "public"."BlacklistType" ADD VALUE 'GENRE';--> statement-breakpoint
ALTER TYPE "public"."user_status" ADD VALUE 'pending';--> statement-breakpoint
ALTER TYPE "public"."user_status" ADD VALUE 'rejected';--> statement-breakpoint
CREATE TABLE "auth_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" integer NOT NULL,
"session_token" varchar(255) NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"last_active_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_revoked" boolean DEFAULT false NOT NULL,
"browser" text,
"operating_system" text,
"device" text,
"login_method" text DEFAULT 'password' NOT NULL,
"revoked_at" timestamp with time zone,
"revoked_reason" text,
CONSTRAINT "auth_sessions_session_token_unique" UNIQUE("session_token")
);
--> statement-breakpoint
CREATE TABLE "backup_records" (
"id" serial PRIMARY KEY NOT NULL,
"filename" text NOT NULL,
"file_path" text NOT NULL,
"file_size" bigint NOT NULL,
"type" text DEFAULT 'MANUAL' NOT NULL,
"status" text DEFAULT 'COMPLETED' NOT NULL,
"error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_by" integer,
"expires_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "card_code_redemptions" (
"id" serial PRIMARY KEY NOT NULL,
"card_code_id" integer NOT NULL,
"user_id" integer NOT NULL,
"redeemed_at" timestamp with time zone DEFAULT now() NOT NULL,
"song_id" integer,
"purpose" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "card_codes" (
"id" serial PRIMARY KEY NOT NULL,
"code" varchar(64) NOT NULL,
"status" "card_code_status" DEFAULT 'AVAILABLE' NOT NULL,
"type" text NOT NULL,
"value" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone,
"locked_at" timestamp with time zone,
"locked_by" integer,
"batch_id" varchar(64),
"note" text,
CONSTRAINT "card_codes_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE "ChangelogEntry" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"version" text NOT NULL,
"title" text NOT NULL,
"content" text NOT NULL,
"published" boolean DEFAULT false NOT NULL,
"publishedAt" timestamp,
"createdBy" integer
);
--> statement-breakpoint
CREATE TABLE "ExpectedPlayDate" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"date" text NOT NULL,
"availableSequences" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
CONSTRAINT "ExpectedPlayDate_date_unique" UNIQUE("date")
);
--> 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,
"enabled" boolean DEFAULT true NOT NULL,
CONSTRAINT "grade_class_unique" UNIQUE("grade","class")
);
--> statement-breakpoint
CREATE TABLE "oauth_providers" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"displayName" text,
"enabled" boolean DEFAULT true NOT NULL,
"clientId" text,
"clientSecret" text,
"authorizationEndpoint" text,
"tokenEndpoint" text,
"userInfoEndpoint" text,
"scope" text,
"redirectUri" text,
"usernameClaim" text DEFAULT 'preferred_username',
"nameClaim" text DEFAULT 'name',
"emailClaim" text DEFAULT 'email',
"avatarClaim" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"icon" text
);
--> statement-breakpoint
CREATE TABLE "passkey_challenges" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"challenge" text NOT NULL,
"type" text NOT NULL,
"user_id" integer,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "schedule_song_pool" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"song_id" integer NOT NULL,
"target_play_date" timestamp,
"priority" integer DEFAULT 0 NOT NULL,
"added_by" integer,
"note" text,
"status" text DEFAULT 'PENDING' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "SongComment" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"songId" integer NOT NULL,
"userId" integer NOT NULL,
"content" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "submission_restriction_exemptions" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"exemption_type" text NOT NULL,
"reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone,
"created_by" integer,
CONSTRAINT "submission_restriction_exemptions_user_type_unique" UNIQUE("user_id","exemption_type")
);
--> statement-breakpoint
CREATE TABLE "user_passkeys" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text,
"public_key" text NOT NULL,
"credential_device_type" text NOT NULL,
"credential_backed_up" boolean NOT NULL,
"transports" text,
"counter" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "forcePasswordChange" SET DEFAULT false;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "batch_id" uuid;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "source" text DEFAULT 'SYSTEM' NOT NULL;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "sender_id" integer;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "sender_name" text;--> statement-breakpoint
ALTER TABLE "Notification" ADD COLUMN "sender_username" 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 "user_deleted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "Schedule" ADD COLUMN "replay_request_id" integer;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "expectedPlayDate" text;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "expectedPlaySequence" integer;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "durationSeconds" integer;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "submissionNotePublicStatus" text;--> statement-breakpoint
ALTER TABLE "Song" ADD COLUMN "cardCodeId" integer;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "siteVersion" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionNoteRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "instanceId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "telemetryEnabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "showBeianIcon" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysDisplay" integer DEFAULT 7 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysEditable" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysRequestable" integer DEFAULT 14 NOT NULL;--> statement-breakpoint
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;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "forcePasswordChangeOnFirstLogin" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "enableSubmissionRestriction" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionScope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "sameSongRestrictionHours" integer;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "sameArtistRestrictionHours" integer;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeSubmissionRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeVoteEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeReplayEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "enableCardCodeRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "requireCardCodeForRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "enableCardCodeLimitBypass" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionGradeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionClassEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionGraduateEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionWithdrawnEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "captchaProvider" text DEFAULT 'TURNSTILE';--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "captchaEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSiteKey" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSecretKey" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "allowRegister" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequireEmail" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequireEmailVerify" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerDefaultRole" text DEFAULT 'USER';--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerEnabledGradeClasses" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthRegisterRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerEmailRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresGradeClass" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowRegister" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowLogin" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowBind" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "allowOAuthRegistration" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthRedirectUri" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthStateSecret" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "oauthProviders" text DEFAULT '[]';--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "githubOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "githubClientId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "githubClientSecret" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorServerUrl" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorClientId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorClientSecret" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorOrganizationName" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "googleOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "googleClientId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "googleClientSecret" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthAppId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthAppKey" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthLoginType" text DEFAULT 'qq';--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthEndpoint" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthDisplayName" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthAuthorizeUrl" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthTokenUrl" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUserInfoUrl" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthScope" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthClientId" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthClientSecret" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUserIdField" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUsernameField" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthNameField" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthEmailField" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthAvatarField" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "captchaMaxFailures" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupIntervalHours" integer DEFAULT 24 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupRetentionDays" integer DEFAULT 30 NOT NULL;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupConfig" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCode" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCodeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
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;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "enabledPlatforms" text;--> statement-breakpoint
ALTER TABLE "SystemSettings" ADD COLUMN "platformOrder" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "studentId" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "tokenVersion" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "avatarProvider" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "avatarProviderUserId" text;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "remark" text;--> statement-breakpoint
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;--> statement-breakpoint
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_card_code_id_card_codes_id_fk" FOREIGN KEY ("card_code_id") REFERENCES "public"."card_codes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_song_id_Song_id_fk" FOREIGN KEY ("song_id") REFERENCES "public"."Song"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule_song_pool" ADD CONSTRAINT "schedule_song_pool_song_id_Song_id_fk" FOREIGN KEY ("song_id") REFERENCES "public"."Song"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "submission_restriction_exemptions" ADD CONSTRAINT "submission_restriction_exemptions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_passkeys" ADD CONSTRAINT "user_passkeys_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "auth_sessions_user_active_idx" ON "auth_sessions" USING btree ("user_id","is_revoked","expires_at");--> statement-breakpoint
CREATE INDEX "auth_sessions_expires_at_idx" ON "auth_sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "auth_sessions_last_active_idx" ON "auth_sessions" USING btree ("last_active_at");--> statement-breakpoint
CREATE UNIQUE INDEX "auth_sessions_session_token_unique" ON "auth_sessions" USING btree ("session_token");--> statement-breakpoint
CREATE INDEX "backup_records_created_at_idx" ON "backup_records" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "backup_records_status_idx" ON "backup_records" USING btree ("status");--> statement-breakpoint
CREATE INDEX "card_code_redemptions_user_id_idx" ON "card_code_redemptions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "card_code_redemptions_card_code_id_idx" ON "card_code_redemptions" USING btree ("card_code_id");--> statement-breakpoint
CREATE INDEX "card_codes_status_idx" ON "card_codes" USING btree ("status");--> statement-breakpoint
CREATE INDEX "card_codes_batch_id_idx" ON "card_codes" USING btree ("batch_id");--> statement-breakpoint
CREATE INDEX "card_codes_expires_at_idx" ON "card_codes" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "passkey_challenges_challenge_idx" ON "passkey_challenges" USING btree ("challenge");--> statement-breakpoint
CREATE INDEX "passkey_challenges_expires_at_idx" ON "passkey_challenges" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "schedule_song_pool_song_id_idx" ON "schedule_song_pool" USING btree ("song_id");--> statement-breakpoint
CREATE INDEX "schedule_song_pool_target_date_idx" ON "schedule_song_pool" USING btree ("target_play_date");--> statement-breakpoint
CREATE INDEX "submission_restriction_exemptions_user_id_idx" ON "submission_restriction_exemptions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "user_passkeys_user_id_idx" ON "user_passkeys" USING btree ("user_id");--> statement-breakpoint
ALTER TABLE "Song" ADD CONSTRAINT "Song_cardCodeId_card_codes_id_fk" FOREIGN KEY ("cardCodeId") REFERENCES "public"."card_codes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notification_user_important_read_created_idx" ON "Notification" USING btree ("userId","important","read","createdAt");--> statement-breakpoint
CREATE INDEX "notification_batch_id_idx" ON "Notification" USING btree ("batch_id");--> statement-breakpoint
CREATE INDEX "schedule_published_song_idx" ON "Schedule" USING btree ("publishedAt","songId");--> statement-breakpoint
CREATE INDEX "schedule_published_date_idx" ON "Schedule" USING btree ("publishedAt","playDate");--> statement-breakpoint
CREATE INDEX "song_card_code_id_idx" ON "Song" USING btree ("cardCodeId");--> 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 UNIQUE INDEX "User_username_unique" ON "User" USING btree ("username");--> statement-breakpoint
CREATE INDEX "vote_user_song_idx" ON "Vote" USING btree ("userId","songId");--> statement-breakpoint
ALTER TABLE "Vote" ADD CONSTRAINT "vote_song_user_unique" UNIQUE("songId","userId");
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,13 @@
"when": 1776518400000, "when": 1776518400000,
"tag": "20260418112000_add_song_comments", "tag": "20260418112000_add_song_comments",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1788684403309,
"tag": "20260906084643_auto-migration",
"breakpoints": true
} }
] ]
} }
+63 -3
View File
@@ -51,9 +51,16 @@ export const authSessions = pgTable('auth_sessions', {
userAgent: text('user_agent'), userAgent: text('user_agent'),
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(), lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
isRevoked: boolean('is_revoked').default(false).notNull(), isRevoked: boolean('is_revoked').default(false).notNull(),
browser: text('browser'),
operatingSystem: text('operating_system'),
device: text('device'),
loginMethod: text('login_method').default('password').notNull(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
revokedReason: text('revoked_reason')
}, (table) => [ }, (table) => [
index('auth_sessions_user_id_idx').on(table.userId), index('auth_sessions_user_active_idx').on(table.userId, table.isRevoked, table.expiresAt),
index('auth_sessions_expires_at_idx').on(table.expiresAt), index('auth_sessions_expires_at_idx').on(table.expiresAt),
index('auth_sessions_last_active_idx').on(table.lastActiveAt),
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken), uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
]); ]);
@@ -258,16 +265,29 @@ export const systemSettings = pgTable('SystemSettings', {
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(), enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(), enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(), enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
instanceId: text('instanceId'), instanceId: text('instanceId'),
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(), telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
showBeianIcon: boolean('showBeianIcon').default(true).notNull(), showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(), scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(), scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(), scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(), cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(), cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(), cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(), cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(), submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(), submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(), submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
@@ -282,16 +302,56 @@ export const systemSettings = pgTable('SystemSettings', {
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(), registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
registerDefaultRole: text('registerDefaultRole').default('USER'), registerDefaultRole: text('registerDefaultRole').default('USER'),
registerEnabledGradeClasses: text('registerEnabledGradeClasses'), registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
registerRequiresApproval: boolean('registerRequiresApproval').default(false).notNull(),
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(false).notNull(),
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
oauthEnabled: boolean('oauthEnabled').default(false).notNull(), oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(), oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(), oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(), oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
allowOAuthRegistration: boolean('allowOAuthRegistration').default(false).notNull(),
oauthRedirectUri: text('oauthRedirectUri'),
oauthStateSecret: text('oauthStateSecret'),
oauthProviders: text('oauthProviders').default('[]'),
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
githubClientId: text('githubClientId'),
githubClientSecret: text('githubClientSecret'),
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
casdoorServerUrl: text('casdoorServerUrl'),
casdoorClientId: text('casdoorClientId'),
casdoorClientSecret: text('casdoorClientSecret'),
casdoorOrganizationName: text('casdoorOrganizationName'),
googleOAuthEnabled: boolean('googleOAuthEnabled').default(false).notNull(),
googleClientId: text('googleClientId'),
googleClientSecret: text('googleClientSecret'),
aggregateOAuthEnabled: boolean('aggregateOAuthEnabled').default(false).notNull(),
aggregateOAuthAppId: text('aggregateOAuthAppId'),
aggregateOAuthAppKey: text('aggregateOAuthAppKey'),
aggregateOAuthLoginType: text('aggregateOAuthLoginType').default('qq'),
aggregateOAuthEndpoint: text('aggregateOAuthEndpoint'),
customOAuthEnabled: boolean('customOAuthEnabled').default(false).notNull(),
customOAuthDisplayName: text('customOAuthDisplayName'),
customOAuthAuthorizeUrl: text('customOAuthAuthorizeUrl'),
customOAuthTokenUrl: text('customOAuthTokenUrl'),
customOAuthUserInfoUrl: text('customOAuthUserInfoUrl'),
customOAuthScope: text('customOAuthScope'),
customOAuthClientId: text('customOAuthClientId'),
customOAuthClientSecret: text('customOAuthClientSecret'),
customOAuthUserIdField: text('customOAuthUserIdField'),
customOAuthUsernameField: text('customOAuthUsernameField'),
customOAuthNameField: text('customOAuthNameField'),
customOAuthEmailField: text('customOAuthEmailField'),
customOAuthAvatarField: text('customOAuthAvatarField'),
captchaMaxFailures: integer('captchaMaxFailures').default(3).notNull(),
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(), autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(), autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(), autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
autoBackupConfig: text('autoBackupConfig'),
statisticsCode: text('statisticsCode'), statisticsCode: text('statisticsCode'),
defaultTheme: text('defaultTheme').default('light'), statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
enabledThemes: text('enabledThemes').default('light,dark'), defaultTheme: text('defaultTheme').default('System').notNull(),
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'), enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'), platformOrder: text('platformOrder'),
}); });
+29
View File
@@ -0,0 +1,29 @@
import * as Sentry from '@sentry/vue'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const dsn = config.public.sentry?.dsn
if (!dsn) return
const app = nuxtApp.vueApp
const router = nuxtApp.$router
Sentry.init({
app,
dsn,
environment: config.sentry?.environment || 'production',
release: config.sentry?.release || undefined,
tracesSampleRate: config.public.sentry?.tracesSampleRate ?? 1.0,
integrations: router
? [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration()
]
: [
Sentry.replayIntegration()
],
replaysSessionSampleRate: config.public.sentry?.replaySessionSampleRate ?? 0,
replaysOnErrorSampleRate: config.public.sentry?.replayOnErrorSampleRate ?? 0
})
})
+2
View File
@@ -0,0 +1,2 @@
export const PASSWORD_MIN_LENGTH = 6
export const PASSWORD_MAX_LENGTH = 64
+55 -8
View File
@@ -40,11 +40,51 @@ export default defineNuxtConfig({
}, },
modules: [ modules: [
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
...(process.env.NODE_ENV === 'development' || process.env.npm_lifecycle_event?.includes('lint') ...((process.env.NODE_ENV === 'development' || process.env.npm_lifecycle_event?.includes('lint'))
? ['@nuxt/eslint'] ? ['@nuxt/eslint']
: []),
...((process.env.PWA_ENABLED || 'false') === 'true'
? ['@vite-pwa/nuxt']
: []) : [])
], ],
pwa: (process.env.PWA_ENABLED || 'false') === 'true'
? {
registerType: 'autoUpdate',
manifest: {
name: process.env.NUXT_PUBLIC_SITE_TITLE || '校园广播站点歌系统',
short_name: process.env.NUXT_PUBLIC_SITE_TITLE || 'VoiceHub',
description: process.env.NUXT_PUBLIC_SITE_DESCRIPTION || '校园广播站点歌系统',
display: (process.env.PWA_DISPLAY as any) || 'standalone',
background_color: process.env.PWA_BACKGROUND_COLOR || '#000000',
theme_color: process.env.PWA_THEME_COLOR || '#111111',
icons: [
{
src: '/images/logo.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/images/logo.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico,webp,woff2}']
},
client: {
installPrompt: true,
periodicSyncForUpdates: 3600
},
devOptions: {
enabled: process.env.NODE_ENV === 'development'
}
}
: {},
// 引入全局CSS // 引入全局CSS
css: [ css: [
'~/assets/css/variables.css', '~/assets/css/variables.css',
@@ -150,6 +190,10 @@ export default defineNuxtConfig({
}, },
// 移动端优化 // 移动端优化
{ name: 'theme-color', content: '#111111' }, { name: 'theme-color', content: '#111111' },
{
name: 'x-default-theme',
content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'System'
},
{ name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' }, { name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' },
@@ -162,22 +206,25 @@ export default defineNuxtConfig({
tagPosition: 'head', tagPosition: 'head',
children: [ children: [
`(function(){try{`, `(function(){try{`,
` var k='voicehub:theme', d=document, r=d.documentElement, s=null;`, ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null;`,
` try{s=localStorage.getItem(k)}catch(e){s=null}`, ` try{s=localStorage.getItem(k)}catch(e){s=null}`,
` if(!s){`, ` if(!s){`,
` try{`, ` try{`,
` var meta=document.querySelector('meta[name="x-default-theme"]');`, ` var meta=document.querySelector('meta[name="x-default-theme"]');`,
` if(meta && meta.content){s=meta.content}`, ` if(meta && meta.content){s=meta.content}`,
` }catch(e){s=null}`, ` }catch(e){s=null}`,
` if(!s){s=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`, ` if(!s){s='System'}`,
` }`, ` }`,
` r.setAttribute('data-theme', s);`, ` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
` if(s==='dark'){r.classList.add('dark'); r.style.colorScheme='dark'}`, ` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
` else if(s==='light'){r.classList.add('light'); r.style.colorScheme='light'}`, ` else{eff='dark'}`,
` else{r.style.colorScheme='light dark'}`, ` r.setAttribute('data-theme', (typeof s==='string'?s:eff));`,
` if(eff==='dark'){r.classList.add('dark'); r.classList.remove('light'); r.style.colorScheme='dark'}`,
` else if(eff==='light'){r.classList.add('light'); r.classList.remove('dark'); r.style.colorScheme='light'}`,
` else{r.classList.remove('dark','light'); r.style.colorScheme='light dark'}`,
` try{`, ` try{`,
` var tm=d.querySelector('meta[name="theme-color"]');`, ` var tm=d.querySelector('meta[name="theme-color"]');`,
` if(tm){tm.content=(s==='light'?'#f8fafc':'#111111')}`, ` if(tm){tm.content=(eff==='light'?'#f8fafc':'#111111')}`,
` }catch(e){}`, ` }catch(e){}`,
`}catch(e){}})();` `}catch(e){}})();`
].join('') ].join('')
+3775 -142
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -50,6 +50,8 @@
"@pixi/filter-bulge-pinch": "^5.1.1", "@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3", "@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3", "@pixi/sprite": "^7.4.3",
"@sentry/node": "^10.73.0",
"@sentry/vue": "^10.73.0",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3", "@simplewebauthn/server": "^13.3.3",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@@ -60,6 +62,7 @@
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@ungap/structured-clone": "^1.3.0", "@ungap/structured-clone": "^1.3.0",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"@vite-pwa/nuxt": "^1.1.1",
"axios": "^1.20.0", "axios": "^1.20.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bezier-easing": "^2.1.0", "bezier-easing": "^2.1.0",
@@ -105,6 +108,7 @@
"node": ">=22.11.0" "node": ">=22.11.0"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^1.1.0",
"@nuxt/eslint": "^1.17.0", "@nuxt/eslint": "^1.17.0",
"eslint": "^9.39.5", "eslint": "^9.39.5",
"prettier": "^3.9.6", "prettier": "^3.9.6",
+135
View File
@@ -0,0 +1,135 @@
import { db } from '~/drizzle/db'
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { cacheService } from '../../../services/cacheService'
import { createSongRejectedNotification } from '../../../services/notificationService'
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
const MAX_BATCH_REJECT_COUNT = 1000
export default defineEventHandler(async (event) => {
const user = requireSongAdmin(event)
const body = await readBody(event)
const songIds = Array.isArray(body?.songIds)
? [...new Set(body.songIds.filter((id: any) => Number.isInteger(id) && id > 0))]
: []
const reason = typeof body?.reason === 'string' ? body.reason.trim() : ''
const addToBlacklist = body?.addToBlacklist === true
if (songIds.length === 0) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_IDS_REQUIRED, '歌曲ID列表不能为空')
}
if (songIds.length > MAX_BATCH_REJECT_COUNT) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_BATCH_REJECT_LIMIT_EXCEEDED,
`单次最多驳回 ${MAX_BATCH_REJECT_COUNT} 首歌曲`,
{ params: [MAX_BATCH_REJECT_COUNT] }
)
}
if (!reason) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_REASON_REQUIRED, '驳回原因不能为空')
}
try {
let rejectedSongs: any[] = []
await db.transaction(async (tx) => {
const existingSongs = await tx.select().from(songs).where(inArray(songs.id, songIds))
if (existingSongs.length === 0) {
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在或已被删除')
}
const existingIds = existingSongs.map((song) => song.id)
console.log(`开始批量驳回歌曲,共 ${existingIds.length} 首`)
if (addToBlacklist) {
const blacklistValues = [...new Set(existingSongs.map((song: any) => `${song.title} - ${song.artist}`))]
const existingBlacklist = await tx
.select({ value: songBlacklists.value })
.from(songBlacklists)
.where(and(eq(songBlacklists.type, 'SONG'), inArray(songBlacklists.value, blacklistValues)))
const existingSet = new Set(existingBlacklist.map((item) => item.value))
const valuesToInsert = blacklistValues.filter((value) => !existingSet.has(value))
for (const value of valuesToInsert) {
try {
await tx.insert(songBlacklists).values({
type: 'SONG',
value,
reason: `歌曲驳回: ${reason}`,
createdBy: user.id
})
} catch (error: any) {
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
}
}
console.log(`黑名单添加处理完成`)
}
await tx.delete(votes).where(inArray(votes.songId, existingIds))
console.log(`删除了投票记录`)
await tx.delete(schedules).where(inArray(schedules.songId, existingIds))
console.log(`删除了排期记录`)
const hitRequestIds = [...new Set(existingSongs.map((song: any) => song.hitRequestId).filter(Boolean))]
for (const hitRequestId of hitRequestIds) {
const count = existingSongs.filter((song: any) => song.hitRequestId === hitRequestId).length
try {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - ${count})`
})
.where(eq(requestTimes.id, hitRequestId as any))
} catch (error: any) {
console.error(`减少投稿时段 ${hitRequestId} 接纳数量失败: ${error.message}`)
}
}
rejectedSongs = await tx.delete(songs).where(inArray(songs.id, existingIds)).returning()
console.log(`歌曲批量删除完成`)
})
const songsToNotify = rejectedSongs.filter((song) => song.requesterId)
for (const song of songsToNotify) {
createSongRejectedNotification(
song.requesterId,
{ title: song.title, artist: song.artist },
reason
).catch((error: any) => {
console.error(`发送驳回通知失败: ${error.message}`)
})
}
await cacheService.clearSongsCache()
await cacheService.clearSchedulesCache()
console.log(`批量驳回操作完成,共 ${rejectedSongs.length} 首`)
return {
success: true,
message: '歌曲批量驳回成功',
data: {
rejected: rejectedSongs.length,
missing: songIds.length - rejectedSongs.length,
notificationSent: songsToNotify.length
}
}
} catch (error: any) {
console.error('批量驳回歌曲失败:', error)
if (error.statusCode) {
throw error
}
throw createApiError(500, SERVER_ERROR_CODES.SONG_OPERATION_FAILED, '批量驳回失败: ' + error.message)
}
})
+7 -1
View File
@@ -3,6 +3,7 @@ const { authenticator } = otplib
import { db, userIdentities, eq, and, users } from '~/drizzle/db' import { db, userIdentities, eq, and, users } from '~/drizzle/db'
import { twoFactorCodes } from '~~/server/utils/twoFactorStore' import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { getClientIP } from '~~/server/utils/ip-utils' import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTime } from '~/utils/timeUtils' import { getBeijingTime } from '~/utils/timeUtils'
@@ -100,7 +101,12 @@ export default defineEventHandler(async (event) => {
.catch((err) => console.error('Error updating user login info:', err)) .catch((err) => console.error('Error updating user login info:', err))
// 生成Token // 生成Token
const authToken = JWTEnhanced.generateToken(user.id, user.role) const { token: authToken, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
await createAuthSession(event, {
userId: user.id,
jti,
loginMethod: type === 'totp' ? 'totp_2fa' : 'email_2fa'
})
const isSecure = const isSecure =
getRequestURL(event).protocol === 'https:' || getRequestURL(event).protocol === 'https:' ||
+8 -1
View File
@@ -2,6 +2,7 @@ import { parseState, getRedirectUri } from '~~/server/utils/oauth'
import { generateBindingToken } from '~~/server/utils/oauth-token' import { generateBindingToken } from '~~/server/utils/oauth-token'
import { db, eq, userIdentities } from '~/drizzle/db' import { db, eq, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies' import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService' import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
@@ -142,7 +143,13 @@ async function handleUserLoginOrBind(
} }
// 登录 // 登录
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role) const { token, jti } = JWTEnhanced.generateTokenWithJti(existingIdentity.user.id, existingIdentity.user.role)
await createAuthSession(event, {
userId: existingIdentity.user.id,
jti,
loginMethod: 'oauth',
oauthProvider: provider
})
setCookie(event, 'auth-token', token, { setCookie(event, 'auth-token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
+8 -1
View File
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities } from '~/drizzle/db' import { db, eq, users, userIdentities } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { verifyBindingToken } from '~~/server/utils/oauth-token' import { verifyBindingToken } from '~~/server/utils/oauth-token'
import { import {
isAccountLocked, isAccountLocked,
@@ -86,7 +87,13 @@ export default defineEventHandler(async (event) => {
recordLoginSuccess(username, clientIp) recordLoginSuccess(username, clientIp)
// 登录 // 登录
const token = JWTEnhanced.generateToken(user.id, user.role) const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
await createAuthSession(event, {
userId: user.id,
jti,
loginMethod: 'oauth',
oauthProvider: payload.provider
})
setCookie(event, 'auth-token', token, { setCookie(event, 'auth-token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
+3 -1
View File
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { db, eq, users, userIdentities, and } from '~/drizzle/db' import { db, eq, users, userIdentities, and } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { import {
getAccountLockRemainingTime, getAccountLockRemainingTime,
getIPBlockRemainingTime, getIPBlockRemainingTime,
@@ -187,7 +188,8 @@ export default defineEventHandler(async (event) => {
.catch((err) => console.error('Error updating user login info:', err)) .catch((err) => console.error('Error updating user login info:', err))
// 生成JWT // 生成JWT
const token = JWTEnhanced.generateToken(user.id, user.role) const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
await createAuthSession(event, { userId: user.id, jti, loginMethod: 'password' })
// 自动判断是否需要secure // 自动判断是否需要secure
const isSecure = const isSecure =
+3 -1
View File
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { db, eq, users } from '~/drizzle/db' import { db, eq, users } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post' import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -109,7 +110,8 @@ export default defineEventHandler(async (event) => {
} }
// 签发 JWT Token // 签发 JWT Token
const token = JWTEnhanced.generateToken(newUser.id, newUser.role) const { token, jti } = JWTEnhanced.generateTokenWithJti(newUser.id, newUser.role)
await createAuthSession(event, { userId: newUser.id, jti, loginMethod: 'password' })
const isSecure = const isSecure =
getRequestURL(event).protocol === 'https:' || getRequestURL(event).protocol === 'https:' ||
+10 -1
View File
@@ -3,6 +3,7 @@ import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema' import { users } from '~/drizzle/schema'
import { executeRedisCommand, isRedisReady } from '../../utils/redis' import { executeRedisCommand, isRedisReady } from '../../utils/redis'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { isSessionRevokedByJti, touchAuthSession } from '~~/server/utils/create-auth-session'
// 用户认证缓存(永久缓存,登出或权限变更时主动失效) // 用户认证缓存(永久缓存,登出或权限变更时主动失效)
@@ -19,9 +20,17 @@ export default defineEventHandler(async (event) => {
} }
// 验证JWT令牌 // 验证JWT令牌
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number } const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number; jti?: string }
const userId = decoded.userId const userId = decoded.userId
if (await isSessionRevokedByJti(decoded.jti)) {
throw createError({
statusCode: 401,
statusMessage: '会话已撤销,请重新登录'
})
}
touchAuthSession(decoded.jti).catch(() => undefined)
// 优先从Redis缓存获取用户认证状态 // 优先从Redis缓存获取用户认证状态
if (isRedisReady()) { if (isRedisReady()) {
const cachedUser = await executeRedisCommand(async () => { const cachedUser = await executeRedisCommand(async () => {
@@ -4,6 +4,7 @@ import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
import { db, eq, and, userIdentities } from '~/drizzle/db' import { db, eq, and, userIdentities } from '~/drizzle/db'
import { createError, defineEventHandler, setCookie } from 'h3' import { createError, defineEventHandler, setCookie } from 'h3'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced' import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
@@ -84,7 +85,8 @@ export default defineEventHandler(async (event) => {
.where(eq(userIdentities.id, identity.id)) .where(eq(userIdentities.id, identity.id))
// 签发登录 Token // 签发登录 Token
const token = JWTEnhanced.generateToken(identity.user.id, identity.user.role) const { token, jti } = JWTEnhanced.generateTokenWithJti(identity.user.id, identity.user.role)
await createAuthSession(event, { userId: identity.user.id, jti, loginMethod: 'webauthn' })
setCookie(event, 'auth-token', token, { setCookie(event, 'auth-token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
+38 -3
View File
@@ -1,12 +1,17 @@
import { createError, defineEventHandler, getQuery } from 'h3' import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db' import { db } from '~/drizzle/db'
import { playTimes, schedules, songCollaborators, songs, users } from '~/drizzle/schema' import { playTimes, schedules, songCollaborators, songs, systemSettings, users } from '~/drizzle/schema'
import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm' import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm'
import { formatDateTime } from '~/utils/timeUtils' import { formatDateTime } from '~/utils/timeUtils'
import { openApiCache } from '~~/server/utils/open-api-cache' import { openApiCache } from '~~/server/utils/open-api-cache'
import { CACHE_CONSTANTS } from '~~/server/config/constants' import { CACHE_CONSTANTS } from '~~/server/config/constants'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis' import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis'
import {
resolveScheduleVisibilityRange,
isPlayDateInRange,
type VisibilitySettingsLike
} from '~~/server/utils/schedule-visibility'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -36,6 +41,27 @@ export default defineEventHandler(async (event) => {
const sortBy = (query.sortBy as string) || 'playDate' const sortBy = (query.sortBy as string) || 'playDate'
const sortOrder = (query.sortOrder as string) || 'asc' const sortOrder = (query.sortOrder as string) || 'asc'
// 读取系统设置:排期可见范围
let visibilityRange = { startAt: null as Date | null, endAt: null as Date | null }
try {
const systemSettingsDataResult = await db.select().from(systemSettings).limit(1)
const systemSettingsData = systemSettingsDataResult[0] || null
const visibilitySettings: VisibilitySettingsLike = {
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
}
visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
} catch (err) {
console.warn('[Schedules API] 读取排期可见范围设置失败,将展示全部:', err)
}
const applyVisibilityFilter = (items: any[]): any[] => {
if (!visibilityRange.startAt && !visibilityRange.endAt) return items
return items.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
}
// 优先从普通API缓存获取数据 // 优先从普通API缓存获取数据
// 尝试从CacheService获取排期数据 // 尝试从CacheService获取排期数据
@@ -49,6 +75,7 @@ export default defineEventHandler(async (event) => {
if (semester) { if (semester) {
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester) filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
} }
filteredSchedules = applyVisibilityFilter(filteredSchedules)
// 转换为开放API格式 // 转换为开放API格式
const result = { const result = {
@@ -105,11 +132,12 @@ export default defineEventHandler(async (event) => {
}) })
if (redisData) { if (redisData) {
const filteredRedisData = applyVisibilityFilter(redisData)
// 转换为开放API格式 // 转换为开放API格式
const result = { const result = {
success: true, success: true,
data: { data: {
schedules: redisData.map((schedule: any) => ({ schedules: filteredRedisData.map((schedule: any) => ({
id: schedule.id, id: schedule.id,
playDate: schedule.playDate, playDate: schedule.playDate,
sequence: schedule.sequence, sequence: schedule.sequence,
@@ -133,7 +161,7 @@ export default defineEventHandler(async (event) => {
} }
: null : null
})), })),
total: redisData.length, total: filteredRedisData.length,
semester: semester || null semester: semester || null
} }
} }
@@ -175,6 +203,13 @@ export default defineEventHandler(async (event) => {
conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!) conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!)
} }
if (visibilityRange.startAt) {
conditions.push(gte(schedules.playDate, visibilityRange.startAt)!)
}
if (visibilityRange.endAt) {
conditions.push(lt(schedules.playDate, visibilityRange.endAt)!)
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 计算偏移量 // 计算偏移量
+14 -2
View File
@@ -26,7 +26,13 @@ const publicFields = [
'enableSubmissionRemarks', 'enableSubmissionRemarks',
'enableRequestTimeLimitation', 'enableRequestTimeLimitation',
'forceBlockAllRequests', 'forceBlockAllRequests',
'smtpEnabled' 'smtpEnabled',
'defaultTheme',
'enabledThemes',
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter'
] ]
const filterPublicSettings = (data: any) => { const filterPublicSettings = (data: any) => {
@@ -81,7 +87,13 @@ export default defineEventHandler(async (event) => {
enableRequestTimeLimitation: false, enableRequestTimeLimitation: false,
forceBlockAllRequests: false, forceBlockAllRequests: false,
enableReplayRequests: false, enableReplayRequests: false,
smtpEnabled: false smtpEnabled: false,
defaultTheme: 'System',
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 30,
scheduleDaysAfterEnabled: false,
scheduleDaysAfter: 14
}) })
.returning() .returning()
+42 -2
View File
@@ -1,8 +1,16 @@
import { db } from '~/drizzle/db' import { db } from '~/drizzle/db'
import { songs, users, songCollaborators, collaborationLogs } from '~/drizzle/schema' import {
import { eq, or } from 'drizzle-orm' songs,
users,
songCollaborators,
collaborationLogs,
systemSettings,
songBlacklists
} from '~/drizzle/schema'
import { and, eq, inArray, or } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService' import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -122,6 +130,38 @@ export default defineEventHandler(async (event) => {
? body.submissionNote.trim() ? body.submissionNote.trim()
: null : null
} }
if ('submissionNotePublic' in body) {
const wantPublic = updateData.submissionNote ? body.submissionNotePublic !== false : false
updateData.submissionNotePublic = wantPublic
// 自动审查(若 systemSettings 打开 submissionNoteRequiresApproval)
try {
const sysSetRes = await db.select().from(systemSettings).limit(1)
const sysSet = sysSetRes[0]
if (sysSet?.submissionNoteRequiresApproval && wantPublic && updateData.submissionNote) {
const bl = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
const mod = moderateSubmissionNote(
updateData.submissionNote,
wantPublic,
true,
{
title: updateData.title || existingSong.title,
artist: updateData.artist || existingSong.artist
},
bl
)
updateData.submissionNotePublic = mod.notePublic
updateData.submissionNotePublicStatus = mod.publicStatus
} else {
updateData.submissionNotePublicStatus = null
}
} catch (err) {
console.warn('[Update API] 投稿留言自动审查失败,保持用户设置:', err)
}
}
const currentRequesterId = updateData.requesterId || existingSong.requesterId const currentRequesterId = updateData.requesterId || existingSong.requesterId
+35 -4
View File
@@ -11,7 +11,12 @@ import {
users, users,
votes votes
} from '~/drizzle/schema' } from '~/drizzle/schema'
import { and, count, desc, eq, inArray } from 'drizzle-orm' import { and, count, desc, eq, gte, inArray, lt, like, or } from 'drizzle-orm'
import {
resolveScheduleVisibilityRange,
isPlayDateInRange,
type VisibilitySettingsLike
} from '~~/server/utils/schedule-visibility'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
import { executeRedisCommand, isRedisReady } from '../../utils/redis' import { executeRedisCommand, isRedisReady } from '../../utils/redis'
import { formatDateTime } from '~/utils/timeUtils' import { formatDateTime } from '~/utils/timeUtils'
@@ -38,6 +43,13 @@ export default defineEventHandler(async (event) => {
.limit(1) .limit(1)
const systemSettingsData = systemSettingsDataResult[0] || null const systemSettingsData = systemSettingsDataResult[0] || null
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
const visibilitySettings: VisibilitySettingsLike = {
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
}
const visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
// 初始化缓存服务 // 初始化缓存服务
@@ -73,7 +85,14 @@ export default defineEventHandler(async (event) => {
}) })
if (cachedData) { if (cachedData) {
return cachedData const filtered = visibilityRange.startAt || visibilityRange.endAt
? cachedData.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
: cachedData
const resultData = JSON.parse(JSON.stringify(filtered)) as PublicScheduleItem[]
if (shouldHideStudentInfo && !isAdmin) {
maskPublicScheduleData(resultData)
}
return resultData
} }
} }
@@ -91,6 +110,12 @@ export default defineEventHandler(async (event) => {
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester) filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`) console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
} }
// 应用排期可见范围过滤
if (visibilityRange.startAt || visibilityRange.endAt) {
filteredSchedules = filteredSchedules.filter((s) =>
isPlayDateInRange(s.playDate, visibilityRange)
)
}
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化) // 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
if (isRedisReady()) { if (isRedisReady()) {
@@ -116,6 +141,13 @@ export default defineEventHandler(async (event) => {
} }
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据) // 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
const scheduleWhere: any[] = [eq(schedules.isDraft, false)]
if (visibilityRange.startAt) {
scheduleWhere.push(gte(schedules.playDate, visibilityRange.startAt)!)
}
if (visibilityRange.endAt) {
scheduleWhere.push(lt(schedules.playDate, visibilityRange.endAt)!)
}
const schedulesData = await db const schedulesData = await db
.select({ .select({
id: schedules.id, id: schedules.id,
@@ -153,10 +185,9 @@ export default defineEventHandler(async (event) => {
.leftJoin(songs, eq(schedules.songId, songs.id)) .leftJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id)) .leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id)) .leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(eq(schedules.isDraft, false)) // 只查询已发布的排期 .where(and(...scheduleWhere)) // 只查询已发布的排期(+可见范围)
.orderBy(schedules.playDate) .orderBy(schedules.playDate)
// 获取每首歌的投票数
const voteCountsQuery = await db const voteCountsQuery = await db
.select({ .select({
songId: votes.songId, songId: votes.songId,
+44 -3
View File
@@ -4,14 +4,16 @@ import {
playTimes, playTimes,
requestTimes, requestTimes,
semesters, semesters,
songBlacklists,
songCollaborators, songCollaborators,
songs, songs,
systemSettings systemSettings
} from '~/drizzle/db' } from '~/drizzle/db'
import { and, eq, gt, gte, lt, lte, sql } from 'drizzle-orm' import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService' import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit' import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils' import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// 检查用户认证 // 检查用户认证
@@ -132,6 +134,18 @@ export default defineEventHandler(async (event) => {
const systemSettingsData = systemSettingsResult[0] const systemSettingsData = systemSettingsResult[0]
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
let moderationBlacklists: any[] = []
try {
moderationBlacklists = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
} catch (err) {
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
moderationBlacklists = []
}
// 1. 检查强制关闭 // 1. 检查强制关闭
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) { if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({ throw createError({
@@ -243,8 +257,34 @@ export default defineEventHandler(async (event) => {
} }
const submissionNote = const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
const submissionNotePublic = const rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
submissionNote !== null ? body.submissionNotePublic !== false : false const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
let finalSubmissionNotePublic = rawSubmissionNotePublic
let submissionNotePublicStatus: string | null = null
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
const mod = moderateSubmissionNote(
submissionNote,
rawSubmissionNotePublic,
requiresApproval,
{ title: body.title, artist: body.artist, genre: body.genre || null },
moderationBlacklists
)
finalSubmissionNotePublic = mod.notePublic
submissionNotePublicStatus = mod.publicStatus
if (mod.rejectedReasons.length > 0) {
console.log(
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
mod.rejectedReasons
)
}
} else if (submissionNote) {
// 没开自动审查,也记录一下状态
submissionNotePublicStatus = null
}
const submissionNotePublic = finalSubmissionNotePublic
// 准备发送通知的列表 // 准备发送通知的列表
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = [] const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
@@ -367,6 +407,7 @@ export default defineEventHandler(async (event) => {
playUrl: body.playUrl || null, playUrl: body.playUrl || null,
submissionNote, submissionNote,
submissionNotePublic, submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null, hitRequestId: hitRequestTime?.id || null,
expectedPlayDate: body.expectedPlayDate || null, expectedPlayDate: body.expectedPlayDate || null,
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
+10
View File
@@ -180,3 +180,13 @@ export const RISK_CONTROL_CONSTANTS = {
SONG_IP_DOMINANCE_PERCENT: 0.6, SONG_IP_DOMINANCE_PERCENT: 0.6,
SONG_IP_MIN_SAMPLE: 8 SONG_IP_MIN_SAMPLE: 8
} as const } as const
export const SERVER_ERROR_CODES = {
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
SONG_NOT_FOUND: 'SONG_NOT_FOUND',
SONG_OPERATION_FAILED: 'SONG_OPERATION_FAILED',
SONG_BATCH_REJECT_IDS_REQUIRED: 'SONG_BATCH_REJECT_IDS_REQUIRED',
SONG_BATCH_REJECT_LIMIT_EXCEEDED: 'SONG_BATCH_REJECT_LIMIT_EXCEEDED',
SONG_BATCH_REJECT_REASON_REQUIRED: 'SONG_BATCH_REJECT_REASON_REQUIRED'
} as const
+24
View File
@@ -0,0 +1,24 @@
import * as Sentry from '@sentry/node'
export default defineNitroPlugin((nitroApp) => {
const config = useRuntimeConfig()
const dsn = config.sentry?.serverDsn || config.sentry?.dsn
if (!dsn) return
Sentry.init({
dsn,
environment: config.sentry?.environment || process.env.NODE_ENV || 'production',
release: config.sentry?.release || undefined,
tracesSampleRate: config.sentry?.errorSampleRate ?? 1.0,
sampleRate: config.sentry?.errorSampleRate ?? 1.0
})
nitroApp.hooks.hook('error', (error) => {
Sentry.captureException(error)
})
nitroApp.hooks.hook('request', (event) => {
event.context.sentry = Sentry
})
})
+20
View File
@@ -0,0 +1,20 @@
import { inArray } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { cardCodes, cardCodeRedemptions, songs } from '~/drizzle/schema'
export async function deleteCardCodesByIds(ids: number[]) {
return await db.transaction(async (tx) => {
await tx.update(songs)
.set({ cardCodeId: null })
.where(inArray(songs.cardCodeId, ids))
await tx.delete(cardCodeRedemptions)
.where(inArray(cardCodeRedemptions.cardCodeId, ids))
const deleted = await tx.delete(cardCodes)
.where(inArray(cardCodes.id, ids))
.returning()
return deleted
})
}
+35
View File
@@ -0,0 +1,35 @@
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
interface AdminPasswordViolation {
code: string
message: string
}
export function getAdminPasswordViolation(password: unknown): AdminPasswordViolation | null {
if (typeof password !== 'string' || !password) {
return { code: 'AUTH_NEW_PASSWORD_REQUIRED', message: '新密码不能为空' }
}
if (password.length < PASSWORD_MIN_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_SHORT',
message: `密码长度不能少于${PASSWORD_MIN_LENGTH}个字符`
}
}
if (password.length > PASSWORD_MAX_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_LONG',
message: `密码长度不能超过${PASSWORD_MAX_LENGTH}位`
}
}
if (new TextEncoder().encode(password).length > 72) {
return {
code: 'AUTH_PASSWORD_TOO_MANY_BYTES',
message: '密码有效长度不能超过72字节'
}
}
return null
}
+15
View File
@@ -0,0 +1,15 @@
import { createError } from 'h3'
export function createApiError(
statusCode: number,
code: string,
message: string,
data?: Record<string, unknown>
) {
return createError({
statusCode,
statusMessage: code,
message,
data: { code, ...(data || {}) }
})
}
+99
View File
@@ -0,0 +1,99 @@
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface RoutePolicy {
path: string
methods: readonly HttpMethod[]
}
const GET_METHODS = ['GET', 'HEAD'] as const
const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const
export const PASSWORD_CHANGE_ALLOWED_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/auth/verify', methods: GET_METHODS },
{ path: '/api/auth/logout', methods: ['POST'] },
{ path: '/api/auth/change-password', methods: ['POST'] },
{ path: '/api/auth/set-initial-password', methods: ['POST'] },
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
{ path: '/api/site-config', methods: GET_METHODS }
]
export const PUBLIC_API_EXACT_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/auth/login', methods: ['POST'] },
{ path: '/api/auth/register', methods: ['POST'] },
{ path: '/api/auth/email-code', methods: ['POST'] },
{ path: '/api/auth/grade-class-options', methods: GET_METHODS },
{ path: '/api/auth/oauth-register-options', methods: GET_METHODS },
{ path: '/api/auth/bind', methods: ['POST'] },
{ path: '/api/auth/oauth-register', methods: ['POST'] },
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
{ path: '/api/auth/forgot-password', methods: ['POST'] },
{ path: '/api/auth/reset-password', methods: ['POST'] },
{ path: '/api/auth/captcha', methods: GET_METHODS },
{ path: '/api/semesters/current', methods: GET_METHODS },
{ path: '/api/play-times', methods: GET_METHODS },
{ path: '/api/schedules/public', methods: GET_METHODS },
{ path: '/api/songs/count', methods: GET_METHODS },
{ path: '/api/songs/public', methods: GET_METHODS },
{ path: '/api/site-config', methods: GET_METHODS },
{ path: '/api/system/location', methods: GET_METHODS },
{ path: '/api/auth/webauthn/login', methods: ['POST'] },
{ path: '/api/music/resolve-url', methods: ['POST'] },
{ path: '/api/music/state', methods: ['POST'] },
{ path: '/api/music/websocket', methods: GET_METHODS },
{ path: '/api/sys/time', methods: GET_METHODS }
]
export const PUBLIC_API_PREFIX_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/proxy/', methods: GET_METHODS },
{ path: '/api/api-enhanced/', methods: [...GET_METHODS, ...WRITE_METHODS] },
{ path: '/api/native-api/', methods: [...GET_METHODS, 'POST'] },
{ path: '/api/open/', methods: [...GET_METHODS, ...WRITE_METHODS] },
{ path: '/api/auth/webauthn/login/', methods: ['POST'] }
]
function matchesMethod(policy: RoutePolicy, method: string): boolean {
return policy.methods.includes(method.toUpperCase() as HttpMethod)
}
export function isPublicApiPath(pathname: string, method: string): boolean {
return (
PUBLIC_API_EXACT_ROUTES.some(
(policy) => pathname === policy.path && matchesMethod(policy, method)
) ||
PUBLIC_API_PREFIX_ROUTES.some(
(policy) => pathname.startsWith(policy.path) && matchesMethod(policy, method)
)
)
}
export function isAllowedDuringPasswordChange(pathname: string, method: string): boolean {
return PASSWORD_CHANGE_ALLOWED_ROUTES.some(
(policy) => pathname === policy.path && matchesMethod(policy, method)
)
}
export function shouldBlockDuringPasswordChange(
pathname: string,
method: string,
requirePasswordChange: boolean
): boolean {
return (
requirePasswordChange &&
!isPublicApiPath(pathname, method) &&
!isAllowedDuringPasswordChange(pathname, method)
)
}
export function shouldBypassPublicApiAuthentication(
pathname: string,
method: string,
hasToken: boolean
): boolean {
return isPublicApiPath(pathname, method) && !hasToken
}
export function canBindOAuthIdentity(requirePasswordChange: boolean): boolean {
return !requirePasswordChange
}
+68
View File
@@ -0,0 +1,68 @@
import { createError, getQuery, readBody } from 'h3'
import { z } from 'zod'
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
const MAX_DELETE_COUNT = 500
const idSchema = z.preprocess(
(value) => (typeof value === 'number' || typeof value === 'string' ? value : undefined),
z.coerce.number().int().positive()
)
const deleteCardCodesSchema = z.object({
id: idSchema.optional(),
ids: z.preprocess(
(value) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
return value
},
z.array(idSchema).max(MAX_DELETE_COUNT, `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券`).optional()
)
})
export const handleOpenCardCodeDelete = async (event: any) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
try {
const query = getQuery(event)
const body = await readBody(event).catch(() => ({})) ?? {}
const validatedData = deleteCardCodesSchema.parse({ ...query, ...body })
const rawIds = [
...(validatedData.id !== undefined ? [validatedData.id] : []),
...(validatedData.ids || [])
]
const ids = Array.from(new Set(rawIds))
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
}
if (ids.length > MAX_DELETE_COUNT) {
throw createError({ statusCode: 400, message: `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券` })
}
const deletedRows = await deleteCardCodesByIds(ids)
return {
success: true,
message: '点歌券删除成功',
data: {
cardCodes: deletedRows,
deleted: deletedRows.length,
requested: ids.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
if (err.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
})
}
console.error('开放API删除点歌券失败', err)
throw createError({ statusCode: 500, message: '删除点歌券失败' })
}
}
+83
View File
@@ -0,0 +1,83 @@
import { getHeader } from 'h3'
import { db, eq, and } from '~/drizzle/db'
import { authSessions } from '~/drizzle/schema'
import { parseUserAgent, type LoginMethod } from './user-agent-info'
import { getClientIP } from './ip-utils'
import { randomUUID } from 'node:crypto'
export interface CreateAuthSessionOptions {
userId: number
jti: string
loginMethod: LoginMethod
expiresAt?: Date
oauthProvider?: string
}
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
export async function createAuthSession(
event: any,
options: CreateAuthSessionOptions
): Promise<string> {
const { userId, jti, loginMethod, oauthProvider } = options
const ua = getHeader(event, 'user-agent')
const { browser, operatingSystem, device } = parseUserAgent(ua)
const ip = getClientIP(event)
const now = new Date()
const expiresAt = options.expiresAt ?? new Date(now.getTime() + DEFAULT_SESSION_TTL_MS)
try {
let method = loginMethod
if (oauthProvider && method === 'oauth') {
method = `oauth:${oauthProvider}` as any
}
await db.insert(authSessions).values({
id: randomUUID(),
userId,
sessionToken: jti,
browser,
operatingSystem,
device,
loginMethod: method,
ipAddress: ip,
userAgent: ua ?? null,
createdAt: now,
lastActiveAt: now,
expiresAt,
isRevoked: false
})
} catch (error) {
console.warn('[AuthSession] 创建会话失败,继续无会话登录:', error)
}
return jti
}
export async function isSessionRevokedByJti(jti: string | undefined): Promise<boolean> {
if (!jti) return false
try {
const row = await db.query.authSessions.findFirst({
where: and(eq(authSessions.sessionToken, jti)),
columns: { isRevoked: true, expiresAt: true }
})
if (!row) return false
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return true
return Boolean(row.isRevoked)
} catch (error) {
console.warn('[AuthSession] 会话状态校验失败,默认放行:', error)
return false
}
}
export async function touchAuthSession(jti: string | undefined): Promise<void> {
if (!jti) return
try {
await db
.update(authSessions)
.set({ lastActiveAt: new Date() })
.where(eq(authSessions.sessionToken, jti))
} catch (_error) {
// ignore
}
}
+171
View File
@@ -0,0 +1,171 @@
import { randomInt, timingSafeEqual } from 'node:crypto'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { getRedisClient, buildRedisKey } from '~~/server/utils/redis'
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000
const EMAIL_CODE_COOLDOWN_MS = 60 * 1000
const EMAIL_CODE_MAX_ATTEMPTS = 5
const EMAIL_CODE_DAILY_LIMIT = 5
const EMAIL_CODE_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000
type EmailCodeEntry = {
code: string
expiresAt: number
createdAt: number
attempts: number
}
const emailCodes = new Map<string, EmailCodeEntry>()
const dailySendCounts = new Map<string, { count: number; dayKey: string }>()
const normalizeEmail = (email: string) => email.trim().toLowerCase()
const getDayKey = () => {
const now = new Date(getServerTimestamp())
return `${now.getUTCFullYear()}-${now.getUTCMonth() + 1}-${now.getUTCDate()}`
}
const codeRedisKey = (email: string) => buildRedisKey('email-code', 'code', normalizeEmail(email))
const attemptsRedisKey = (email: string) => buildRedisKey('email-code', 'attempts', normalizeEmail(email))
const cooldownRedisKey = (email: string) => buildRedisKey('email-code', 'cooldown', normalizeEmail(email))
const dailyRedisKey = (email: string) => buildRedisKey('email-code', 'daily', normalizeEmail(email), getDayKey())
const VERIFY_EMAIL_CODE_SCRIPT = `
local stored = redis.call('GET', KEYS[1])
if not stored then
return { 0, 0 }
end
local attempts = redis.call('INCR', KEYS[2])
if attempts == 1 then
redis.call('EXPIRE', KEYS[2], 300)
end
if ARGV[1] == stored then
redis.call('DEL', KEYS[1], KEYS[2])
return { 1, attempts }
end
if attempts >= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1], KEYS[2])
return { 2, attempts }
end
return { 0, attempts }
`
const safeCodeEqual = (a: string, b: string): boolean => {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function generateEmailCode(): string {
return String(randomInt(100000, 1000000))
}
export async function storeEmailCode(email: string, code: string): Promise<void> {
const normalized = normalizeEmail(email)
const now = getServerTimestamp()
emailCodes.set(normalized, {
code,
expiresAt: now + EMAIL_CODE_TTL_MS,
createdAt: now,
attempts: 0
})
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
if (!record || record.dayKey !== dayKey) {
dailySendCounts.set(normalized, { count: 1, dayKey })
} else {
record.count++
}
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (!client) return
const ttlSeconds = Math.ceil(EMAIL_CODE_TTL_MS / 1000)
await client.set(codeRedisKey(email), code, { EX: ttlSeconds })
await client.set(cooldownRedisKey(email), '1', {
EX: Math.ceil(EMAIL_CODE_COOLDOWN_MS / 1000)
})
const dailyKey = dailyRedisKey(email)
const dailyCount = await client.incr(dailyKey)
if (dailyCount === 1) {
await client.expire(dailyKey, Math.ceil(EMAIL_CODE_DAILY_WINDOW_MS / 1000))
}
} catch (error) {
console.error('[EmailCode] Redis 写入失败,已回退内存存储:', error)
}
}
export async function isEmailCodeCooldownActive(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (client) {
return Boolean(await client.exists(cooldownRedisKey(email)))
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const entry = emailCodes.get(normalizeEmail(email))
return Boolean(entry && getServerTimestamp() - entry.createdAt < EMAIL_CODE_COOLDOWN_MS)
}
export async function isEmailCodeDailyLimitReached(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = getRedisClient()
if (client) {
const count = await client.get(dailyRedisKey(email))
return Number(count || 0) >= EMAIL_CODE_DAILY_LIMIT
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const normalized = normalizeEmail(email)
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
return Boolean(record && record.dayKey === dayKey && record.count >= EMAIL_CODE_DAILY_LIMIT)
}
export async function verifyEmailCode(email: string, code: string): Promise<boolean> {
const trimmedCode = code.trim()
let client
try {
client = getRedisClient()
if (client) {
const result = await client.eval(VERIFY_EMAIL_CODE_SCRIPT, {
keys: [codeRedisKey(email), attemptsRedisKey(email)],
arguments: [trimmedCode, String(EMAIL_CODE_MAX_ATTEMPTS)]
})
if (Array.isArray(result)) {
return Number(result[0]) === 1
}
}
} catch (error) {
console.error('[EmailCode] Redis 校验失败,已回退内存校验:', error)
}
const normalized = normalizeEmail(email)
const entry = emailCodes.get(normalized)
if (!entry) return false
if (getServerTimestamp() > entry.expiresAt) {
emailCodes.delete(normalized)
return false
}
if (safeCodeEqual(entry.code, trimmedCode)) {
emailCodes.delete(normalized)
return true
}
entry.attempts++
if (entry.attempts >= EMAIL_CODE_MAX_ATTEMPTS) {
emailCodes.delete(normalized)
}
return false
}
export function cleanupExpiredEmailCodes(): void {
const now = getServerTimestamp()
for (const [email, entry] of emailCodes) {
if (now > entry.expiresAt) emailCodes.delete(email)
}
}
+36
View File
@@ -0,0 +1,36 @@
export const smartSort = (a: string, b: string) => {
const gradeOrder: Record<string, number> = {
'初一': 1, '初二': 2, '初三': 3,
'高一': 4, '高二': 5, '高三': 6,
'大一': 7, '大二': 8, '大三': 9, '大四': 10,
'教师': 99, '教职工': 99
}
const weightA = gradeOrder[a]
const weightB = gradeOrder[b]
if (weightA !== undefined && weightB !== undefined) return weightA - weightB
if (weightA !== undefined) return -1
if (weightB !== undefined) return 1
return a.localeCompare(b, 'zh-CN', { numeric: true })
}
export interface GradeClassOption {
grade: string
class: string
}
export const resolveGradeClassOptions = (
configItems: GradeClassOption[],
fallbackItems: GradeClassOption[]
): GradeClassOption[] => {
const source = configItems.length > 0 ? configItems : fallbackItems
return source
.filter((item): item is GradeClassOption => Boolean(item.grade?.trim()) && Boolean(item.class?.trim()))
.sort((a, b) => {
const gradeResult = smartSort(a.grade, b.grade)
return gradeResult || smartSort(a.class, b.class)
})
}
+45
View File
@@ -0,0 +1,45 @@
import { and, isNotNull, eq, ne, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { users, gradeClasses } from '~/drizzle/schema'
import { resolveGradeClassOptions, type GradeClassOption } from './grade-class-core'
export async function fetchGradeClassOptions(): Promise<GradeClassOption[]> {
const [configRows, userRows] = await Promise.all([
db.select({
grade: gradeClasses.grade,
class: gradeClasses.class
}).from(gradeClasses),
db
.selectDistinct({
grade: users.grade,
class: users.class
})
.from(users)
.where(and(eq(users.status, 'active'), isNotNull(users.grade), isNotNull(users.class)))
])
return resolveGradeClassOptions(configRows, userRows)
}
export async function isGradeClassValid(grade: string, studentClass: string): Promise<boolean> {
const configHit = await db.query.gradeClasses.findFirst({
where: (t, { eq: eq_, and: and_ }) => and_(eq_(t.grade, grade), eq_(t.class, studentClass)),
columns: { id: true }
})
if (configHit) return true
const configCount = await db
.select({ count: () => sql<number>`count(*)::int` })
.from(gradeClasses)
.where(and(ne(gradeClasses.grade, ''), ne(gradeClasses.class, '')))
if ((configCount[0]?.count || 0) > 0) return false
const userHit = await db.query.users.findFirst({
where: (t, { eq: eq_, and: and_ }) =>
and_(eq_(t.status, 'active'), eq_(t.grade, grade), eq_(t.class, studentClass)),
columns: { id: true }
})
return Boolean(userHit)
}
@@ -0,0 +1,72 @@
export const NOTIFICATION_TITLE_MAX_LENGTH = 200
export const NOTIFICATION_CONTENT_MAX_LENGTH = 20000
export const NOTIFICATION_SOURCES = {
SYSTEM: 'SYSTEM',
ADMIN_MANUAL: 'ADMIN_MANUAL'
} as const
export type NotificationSenderInput = {
id: number
name?: string | null
username?: string | null
}
export type NotificationSenderSnapshot = {
senderId: number | null
senderName: string | null
senderUsername: string | null
}
export const resolveNotificationSource = (sender: NotificationSenderInput | null) =>
sender ? NOTIFICATION_SOURCES.ADMIN_MANUAL : NOTIFICATION_SOURCES.SYSTEM
export const createNotificationSenderSnapshot = (
sender: NotificationSenderInput | null = null
): NotificationSenderSnapshot => ({
senderId: sender?.id ?? null,
senderName: sender?.name?.trim() || null,
senderUsername: sender?.username?.trim() || null
})
export const serializeNotificationSender = (snapshot: NotificationSenderSnapshot) =>
snapshot.senderId
? {
id: snapshot.senderId,
name: snapshot.senderName,
username: snapshot.senderUsername
}
: null
export const shouldCheckImportantNotification = (
authenticated: boolean,
userId?: number | null,
requirePasswordChange = false
): boolean =>
authenticated && Number.isInteger(userId) && Number(userId) > 0 && !requirePasswordChange
export const canSendSystemNotification = (role?: string): boolean =>
role === 'ADMIN' || role === 'SUPER_ADMIN'
export const resolveImportantFlag = (value: unknown): boolean | null => {
if (value === undefined) return false
return typeof value === 'boolean' ? value : null
}
export const shouldDeliverSystemNotification = (
important: boolean,
notificationsEnabled?: boolean
): boolean => important || notificationsEnabled !== false
export const shouldRetainNotificationHistory = (type: string, source: string): boolean =>
type === 'SYSTEM_NOTICE' && source === NOTIFICATION_SOURCES.ADMIN_MANUAL
export const createNotificationReadUpdate = (updatedAt = new Date()) => ({
read: true as const,
updatedAt
})
export const createNotificationUserDeleteUpdate = (updatedAt = new Date()) => ({
userDeleted: true as const,
read: true as const,
updatedAt
})
+33
View File
@@ -0,0 +1,33 @@
interface InitialPasswordUserState {
requirePasswordChange?: boolean | null
}
interface StoredPasswordState {
password?: string | null
passwordChangedAt?: Date | string | null
}
export interface PasswordSetupState {
hasSetPassword: boolean
needsInitialPasswordSetup: boolean
}
export function getPasswordSetupState(
storedPassword: StoredPasswordState,
requirePasswordChange: boolean
): PasswordSetupState {
const hasSetPassword = !!storedPassword.password
return {
hasSetPassword,
needsInitialPasswordSetup:
!storedPassword.passwordChangedAt && (requirePasswordChange || !hasSetPassword)
}
}
export function canSetInitialPassword(
user: InitialPasswordUserState,
storedPassword: StoredPasswordState
): boolean {
return getPasswordSetupState(storedPassword, !!user.requirePasswordChange)
.needsInitialPasswordSetup
}
+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto'
import { asc, eq, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { SYSTEM_SETTINGS_DEFAULTS } from './system-settings-defaults'
import { getServerTimestamp } from './serverTime'
export interface InstanceIdInfo {
instanceId: string
persisted: boolean
}
let cachedInstanceIdInfo: InstanceIdInfo | null = null
let cachedInstanceIdExpiresAt = 0
let pendingInstanceIdPromise: Promise<string> | null = null
let pendingInstanceIdInfoPromise: Promise<InstanceIdInfo> | null = null
const TEMPORARY_INSTANCE_ID_CACHE_TTL_MS = 5 * 60 * 1000
const loadOrCreateInstanceId = async (): Promise<string> => {
const settingsResult = await db
.select({ id: systemSettings.id, instanceId: systemSettings.instanceId })
.from(systemSettings)
.orderBy(asc(systemSettings.id))
.limit(1)
const settings = settingsResult[0]
if (settings?.instanceId) {
return settings.instanceId
}
const instanceId = randomUUID()
if (!settings) {
await db
.insert(systemSettings)
.values({ id: 1, ...SYSTEM_SETTINGS_DEFAULTS, instanceId })
.onConflictDoUpdate({
target: systemSettings.id,
set: { instanceId, updatedAt: new Date() }
})
await db.execute(sql`
SELECT setval(pg_get_serial_sequence('"SystemSettings"', 'id'), (SELECT MAX(id) FROM "SystemSettings"))
`)
} else {
await db
.update(systemSettings)
.set({ instanceId, updatedAt: new Date() })
.where(eq(systemSettings.id, settings.id))
}
return instanceId
}
const getCachedInstanceIdInfo = (): InstanceIdInfo | null => {
if (!cachedInstanceIdInfo) {
return null
}
if (cachedInstanceIdInfo.persisted || cachedInstanceIdExpiresAt > getServerTimestamp()) {
return cachedInstanceIdInfo
}
return null
}
export const getInstanceIdInfo = async (): Promise<InstanceIdInfo> => {
const cached = getCachedInstanceIdInfo()
if (cached) {
return cached
}
if (!pendingInstanceIdInfoPromise) {
pendingInstanceIdInfoPromise = loadOrCreateInstanceId()
.then((instanceId) => {
const info = { instanceId, persisted: true }
cachedInstanceIdInfo = info
cachedInstanceIdExpiresAt = Number.POSITIVE_INFINITY
return info
})
.catch((error) => {
const fallbackInstanceId = cachedInstanceIdInfo?.persisted === false
? cachedInstanceIdInfo.instanceId
: randomUUID()
const info = { instanceId: fallbackInstanceId, persisted: false }
cachedInstanceIdInfo = info
cachedInstanceIdExpiresAt = getServerTimestamp() + TEMPORARY_INSTANCE_ID_CACHE_TTL_MS
console.warn('[Instance ID] Failed to persist instance ID, using temporary fallback value:', error)
return info
})
.finally(() => {
pendingInstanceIdInfoPromise = null
})
}
return pendingInstanceIdInfoPromise
}
export const getInstanceId = async (): Promise<string> => {
if (!pendingInstanceIdPromise) {
pendingInstanceIdPromise = getInstanceIdInfo()
.then(({ instanceId }) => instanceId)
.finally(() => {
pendingInstanceIdPromise = null
})
}
return pendingInstanceIdPromise
}
+9 -5
View File
@@ -38,30 +38,34 @@ export class JWTEnhanced {
} }
/** /**
* 生成JWT token * 生成JWT token(返回 token 字符串,兼容旧调用)
*/ */
static generateToken(userId: number, role: string): string { static generateToken(userId: number, role: string): string {
return this.generateTokenWithJti(userId, role).token
}
/**
* 生成JWT token 并返回 jti(会话表关联需要)
*/
static generateTokenWithJti(userId: number, role: string): { token: string; jti: string } {
if (!this.JWT_SECRET) { if (!this.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is not set') throw new Error('JWT_SECRET environment variable is not set')
} }
// 生成唯一的JWT ID
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// JWT载荷
const payload: Omit<JWTPayload, 'iat' | 'exp'> = { const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId, userId,
role, role,
jti jti
} }
// 生成token
const token = jwt.sign(payload, this.JWT_SECRET, { const token = jwt.sign(payload, this.JWT_SECRET, {
expiresIn: this.TOKEN_EXPIRES expiresIn: this.TOKEN_EXPIRES
}) })
console.log(`Generated JWT token for user ${userId}`) console.log(`Generated JWT token for user ${userId}`)
return token return { token, jti }
} }
/** /**
+108
View File
@@ -0,0 +1,108 @@
export const NOTIFICATION_HISTORY_STATUSES = ['ALL', 'READ', 'UNREAD'] as const
export type NotificationHistoryStatus = (typeof NOTIFICATION_HISTORY_STATUSES)[number]
export const NOTIFICATION_HISTORY_TYPES = ['ALL', 'IMPORTANT', 'NORMAL'] as const
export const NOTIFICATION_HISTORY_SORT_ORDERS = ['DESC', 'ASC'] as const
export const NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH = 100
export type NotificationHistoryType = (typeof NOTIFICATION_HISTORY_TYPES)[number]
export type NotificationHistorySortOrder = (typeof NOTIFICATION_HISTORY_SORT_ORDERS)[number]
export const resolveNotificationHistoryFilters = (values: {
keyword?: unknown
type?: unknown
sender?: unknown
sortOrder?: unknown
}): {
keyword: string
type: NotificationHistoryType
senderId: number | null
sortOrder: NotificationHistorySortOrder
} | null => {
if (values.keyword !== undefined && typeof values.keyword !== 'string') return null
const keyword = typeof values.keyword === 'string' ? values.keyword.trim() : ''
if (keyword.length > NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH) return null
if (values.type !== undefined && typeof values.type !== 'string') return null
const type = typeof values.type === 'string' && values.type.trim()
? values.type.trim().toUpperCase()
: 'ALL'
if (!NOTIFICATION_HISTORY_TYPES.includes(type as NotificationHistoryType)) return null
let senderId: number | null = null
if (values.sender !== undefined && values.sender !== null && values.sender !== '') {
if (typeof values.sender !== 'string') return null
const parsedSenderId = Number(values.sender.trim())
if (!Number.isInteger(parsedSenderId) || parsedSenderId <= 0) return null
senderId = parsedSenderId
}
if (values.sortOrder !== undefined && typeof values.sortOrder !== 'string') return null
const sortOrder = typeof values.sortOrder === 'string' && values.sortOrder.trim()
? values.sortOrder.trim().toUpperCase()
: 'DESC'
if (!NOTIFICATION_HISTORY_SORT_ORDERS.includes(sortOrder as NotificationHistorySortOrder)) {
return null
}
return {
keyword,
type: type as NotificationHistoryType,
senderId,
sortOrder: sortOrder as NotificationHistorySortOrder
}
}
export const resolveNotificationHistoryStatus = (
value: unknown
): NotificationHistoryStatus | null => {
if (value === undefined || value === null || value === '') return 'ALL'
if (typeof value !== 'string') return null
const normalized = value.trim().toUpperCase()
return NOTIFICATION_HISTORY_STATUSES.includes(normalized as NotificationHistoryStatus)
? (normalized as NotificationHistoryStatus)
: null
}
export const resolveNotificationHistoryPagination = (
pageValue: unknown,
limitValue: unknown
) => {
const parsedPage = Number(pageValue)
const parsedLimit = Number(limitValue)
const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20
return {
page,
limit,
offset: (page - 1) * limit
}
}
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const LEGACY_BATCH_PATTERN = /^legacy-([1-9]\d*)$/
export const resolveNotificationBatchReference = (value: unknown) => {
if (typeof value !== 'string') return null
const normalized = value.trim()
const legacyMatch = normalized.match(LEGACY_BATCH_PATTERN)
if (legacyMatch) {
return {
batchId: null,
notificationId: Number(legacyMatch[1])
}
}
if (!UUID_PATTERN.test(normalized)) return null
return {
batchId: normalized.toLowerCase(),
notificationId: null
}
}
+85
View File
@@ -0,0 +1,85 @@
import { db, eq, userIdentities, users } from '~/drizzle/db'
import { getBeijingTime } from '~/utils/timeUtils'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
export interface OAuthIdentityPayload {
provider: string
providerUserId: string
providerUsername?: string | null
avatar?: string | null
}
export interface ExistingOAuthIdentity {
id: number
avatar: string | null
}
export async function syncOAuthIdentityAvatar(
tx: DbTransaction,
user: { id: number; avatarProvider?: string | null; avatarProviderUserId?: string | null },
existing: ExistingOAuthIdentity | null | undefined,
payload: OAuthIdentityPayload
): Promise<{ created: boolean; avatarSourceSet: boolean }> {
const currentIdentities = await tx.query.userIdentities.findMany({
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const currentAvatarSource = resolveAvatarSource(user, currentIdentities)
let created = false
if (existing) {
if (payload.avatar && existing.avatar !== payload.avatar) {
await tx
.update(userIdentities)
.set({ avatar: payload.avatar })
.where(eq(userIdentities.id, existing.id))
}
} else {
await tx.insert(userIdentities).values({
userId: user.id,
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername,
avatar: payload.avatar || null,
createdAt: getBeijingTime()
})
created = true
}
let avatarSourceSet = false
if (!currentAvatarSource) {
const updatedIdentities = await tx.query.userIdentities.findMany({
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const nextAvatarSource = resolveAvatarSource(user, updatedIdentities)
if (nextAvatarSource) {
await tx
.update(users)
.set({
avatarProvider: nextAvatarSource.provider,
avatarProviderUserId: nextAvatarSource.providerUserId
})
.where(eq(users.id, user.id))
avatarSourceSet = true
}
}
return { created, avatarSourceSet }
}
+126
View File
@@ -0,0 +1,126 @@
export type OAuthProvider = 'github' | 'casdoor' | 'google' | 'oauth2' | 'aggregate'
export const AGGREGATE_OAUTH_LOGIN_TYPES = [
'qq',
'wx',
'alipay',
'sina',
'baidu',
'douyin',
'huawei',
'xiaomi',
'gitee',
'gitea',
'bilibili',
'kuaishou'
] as const
export type AggregateOAuthLoginType = (typeof AGGREGATE_OAUTH_LOGIN_TYPES)[number]
const isPrivateIPv4 = (hostname: string): boolean => {
const parts = hostname.split('.').map(Number)
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false
}
const [first, second] = parts
return (
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
)
}
const isPrivateIPv6 = (hostname: string): boolean => {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (!normalized.includes(':')) return false
if (normalized.startsWith('::ffff:')) {
return isPrivateIPv4(normalized.slice('::ffff:'.length))
}
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
/^fe[89ab]/.test(normalized)
)
}
const isInternalHostname = (hostname: string): boolean => {
const normalized = hostname.toLowerCase()
if (normalized.includes(':')) return isPrivateIPv6(normalized)
if (isPrivateIPv4(normalized)) return true
return (
normalized === 'localhost' ||
!normalized.includes('.') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.lan') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.home.arpa')
)
}
export const isSafeAggregateOAuthUrl = (value: string): boolean => {
try {
const url = new URL(value)
if (url.username || url.password) return false
if (url.protocol === 'https:') return true
if (url.protocol !== 'http:') return false
return isInternalHostname(url.hostname)
} catch {
return false
}
}
export const normalizeAggregateOAuthLoginTypes = (value: unknown): AggregateOAuthLoginType[] => {
let values: unknown[] = []
if (Array.isArray(value)) {
values = value
} else if (typeof value === 'string' && value.trim()) {
const normalized = value.trim()
try {
const parsed = JSON.parse(normalized)
values = Array.isArray(parsed) ? parsed : [parsed]
} catch {
values = normalized.split(',')
}
}
return [
...new Set(
values
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item): item is AggregateOAuthLoginType =>
AGGREGATE_OAUTH_LOGIN_TYPES.includes(item as AggregateOAuthLoginType)
)
)
]
}
export const getAggregateOAuthLoginTypesOrDefault = (value: unknown): AggregateOAuthLoginType[] => {
const normalized = normalizeAggregateOAuthLoginTypes(value)
const hasConfiguredValue =
(Array.isArray(value) && value.length > 0) ||
(typeof value === 'string' && value.trim().length > 0)
return normalized.length > 0 || hasConfiguredValue ? normalized : ['qq']
}
export const SUPPORTED_OAUTH_PROVIDERS: OAuthProvider[] = [
'github',
'casdoor',
'google',
'oauth2',
'aggregate'
]
export const isSupportedOAuthProvider = (provider: string): provider is OAuthProvider => {
return SUPPORTED_OAUTH_PROVIDERS.includes(provider as OAuthProvider)
}
+141
View File
@@ -1,4 +1,12 @@
import { createError } from 'h3' import { createError } from 'h3'
import { createHmac, randomUUID } from 'node:crypto'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { asc } from 'drizzle-orm'
import {
isSafeAggregateOAuthUrl,
getAggregateOAuthLoginTypesOrDefault
} from './oauth-providers'
export interface OAuthUserInfo { export interface OAuthUserInfo {
id: string id: string
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
google: googleStrategy google: googleStrategy
} }
async function loadAggregateSettings() {
const row = await db
.select({
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
})
.from(systemSettings)
.orderBy(asc(systemSettings.id))
.limit(1)
return row[0] ?? null
}
function normalizeEndpoint(endpoint: string): string {
return endpoint.replace(/\/+$/, '')
}
function aggregateSign(params: Record<string, string>, appKey: string): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&')
return createHmac('sha256', appKey).update(sorted).digest('hex')
}
const aggregateStrategy: OAuthStrategy = {
async getAuthorizeUrl(redirectUri: string, state: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
}
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
const loginType = loginTypes[0] || 'qq'
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
response_type: 'code',
redirect_uri: redirectUri,
state,
login_type: loginType,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
const query = new URLSearchParams({ ...baseParams, sign }).toString()
return `${endpoint}/oauth/authorize?${query}`
},
async exchangeToken(code: string, redirectUri: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
try {
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
method: 'POST',
body: { ...baseParams, sign },
headers: { Accept: 'application/json' }
})
if (tokenResponse.error) {
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
}
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
if (!accessToken) {
throw new Error('未能获取访问令牌')
}
return accessToken
} catch (e: any) {
console.error('Aggregate OAuth token exchange failed', e)
throw new Error(e.message || '令牌请求失败')
}
},
async getUserInfo(accessToken: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
access_token: accessToken,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
try {
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
method: 'POST',
body: { ...baseParams, sign },
headers: { Accept: 'application/json' }
})
return {
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
name: userInfo.name || userInfo.nickname || undefined,
email: userInfo.email || undefined,
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
}
} catch (e: any) {
console.error('Aggregate OAuth user info failed', e)
throw new Error('获取用户信息失败')
}
}
}
strategies.aggregate = aggregateStrategy
export const getOAuthStrategy = (provider: string): OAuthStrategy => { export const getOAuthStrategy = (provider: string): OAuthStrategy => {
const strategy = strategies[provider] const strategy = strategies[provider]
if (!strategy) { if (!strategy) {
+6
View File
@@ -261,6 +261,12 @@ export const disconnectRedis = () => redisPool.disconnect()
export const getRedisClient = () => redisPool.getClient() export const getRedisClient = () => redisPool.getClient()
export const isRedisReady = () => redisPool.isReady() export const isRedisReady = () => redisPool.isReady()
export const getRedisStats = () => redisPool.getStats() export const getRedisStats = () => redisPool.getStats()
export const REDIS_KEY_PREFIX = 'voicehub'
export function buildRedisKey(...segments: string[]): string {
return [REDIS_KEY_PREFIX, ...segments].filter(Boolean).join(':')
}
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) => export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
redisPool.executeCommand(command, fallback) redisPool.executeCommand(command, fallback)
+15
View File
@@ -0,0 +1,15 @@
import { createApiError } from './apiError'
import { SERVER_ERROR_CODES } from '../config/constants'
const SONG_ADMIN_ROLES = ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN']
export function requireSongAdmin(event: any) {
const user = event.context?.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.AUTH_UNAUTHORIZED, '请先登录')
}
if (!SONG_ADMIN_ROLES.includes(user.role)) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_FORBIDDEN, '没有权限执行此操作')
}
return user
}
+52
View File
@@ -0,0 +1,52 @@
export interface ScheduleVisibilityRange {
startAt: Date | null
endAt: Date | null
}
export interface VisibilitySettingsLike {
scheduleDaysBeforeEnabled?: boolean | null
scheduleDaysBefore?: number | null
scheduleDaysAfterEnabled?: boolean | null
scheduleDaysAfter?: number | null
}
export function resolveScheduleVisibilityRange(
settings: VisibilitySettingsLike | null | undefined,
referenceDate = new Date()
): ScheduleVisibilityRange {
const today = new Date(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate()
)
let startAt: Date | null = null
let endAt: Date | null = null
if (settings?.scheduleDaysBeforeEnabled) {
const days = Math.max(0, Number(settings.scheduleDaysBefore) || 0)
const d = new Date(today)
d.setDate(d.getDate() - days)
startAt = d
}
if (settings?.scheduleDaysAfterEnabled) {
const days = Math.max(0, Number(settings.scheduleDaysAfter) || 0)
const d = new Date(today)
d.setDate(d.getDate() + days + 1)
endAt = d
}
return { startAt, endAt }
}
export function isPlayDateInRange(
playDate: Date | string | null | undefined,
range: ScheduleVisibilityRange
): boolean {
if (!playDate) return true
const d = new Date(playDate)
if (range.startAt && d.getTime() < range.startAt.getTime()) return false
if (range.endAt && d.getTime() >= range.endAt.getTime()) return false
return true
}
+3
View File
@@ -0,0 +1,3 @@
export function getServerTimestamp(): number {
return Date.now()
}
@@ -0,0 +1,150 @@
import type { SongBlacklist } from '~/drizzle/schema'
export type BlacklistType = 'KEYWORD' | 'LANGUAGE' | 'GENRE' | 'SONG'
export interface ModerationContext {
title?: string | null
artist?: string | null
genre?: string | null
}
export interface ModerationResult {
notePublic: boolean
publicStatus: 'approved' | 'rejected' | null
rejectedReasons: string[]
matchedRules: { type: BlacklistType; value: string }[]
}
function escapeRegex(s: string): string {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function isCJK(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text)
}
function isLatin(text: string): boolean {
return /[A-Za-z]/.test(text)
}
function isKana(text: string): boolean {
return /[\u3040-\u30ff]/.test(text)
}
function isHangul(text: string): boolean {
return /[\uac00-\ud7af]/.test(text)
}
function isCyrillic(text: string): boolean {
return /[\u0400-\u04ff]/.test(text)
}
function isArabic(text: string): boolean {
return /[\u0600-\u06ff]/.test(text)
}
function detectLanguages(text: string): string[] {
const result: string[] = []
if (!text) return result
const normalized = text.toLowerCase()
if (isCJK(text)) result.push('zh', '中文', 'chinese', 'cjk')
if (isLatin(text)) result.push('en', '英文', 'english', 'latin')
if (isKana(text)) result.push('ja', '日文', 'japanese')
if (isHangul(text)) result.push('ko', '韩文', 'korean')
if (isCyrillic(text)) result.push('ru', '俄文', 'russian', 'cyrillic')
if (isArabic(text)) result.push('ar', '阿拉伯文', 'arabic')
return result
}
export function moderateSubmissionNote(
note: string | null,
wantPublic: boolean,
requiresApproval: boolean,
song: ModerationContext,
blacklistRows: SongBlacklist[]
): ModerationResult {
if (!note || !wantPublic) {
return {
notePublic: wantPublic && !!note,
publicStatus: null,
rejectedReasons: [],
matchedRules: []
}
}
const noteLower = note.toLowerCase()
const noteForKeyword = note.toLowerCase()
const title = (song.title || '').toLowerCase()
const artist = (song.artist || '').toLowerCase()
const genre = (song.genre || '').toLowerCase()
const rejectedReasons: string[] = []
const matchedRules: { type: BlacklistType; value: string }[] = []
const noteLanguages = detectLanguages(note)
const textLanguages = new Set([
...noteLanguages,
...detectLanguages(song.title || ''),
...detectLanguages(song.artist || ''),
...detectLanguages(song.genre || '')
])
for (const row of blacklistRows) {
const t = row.type as BlacklistType
const rawValue = row.value
if (!rawValue) continue
const value = rawValue.trim()
if (!value) continue
const valueLower = value.toLowerCase()
if (t === 'KEYWORD') {
// 命中 note / title / artist / genre 的其中一个都算
const haystacks = [noteForKeyword, title, artist, genre].filter(Boolean) as string[]
const exact = haystacks.some((h) => h.includes(valueLower))
const asRegex = (() => {
try {
const re = new RegExp(escapeRegex(value), 'i')
return haystacks.some((h) => re.test(h))
} catch (_) {
return false
}
})()
if (exact || asRegex) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中敏感词:${value}`)
}
} else if (t === 'LANGUAGE') {
const v = valueLower
const checkList = [
v,
...v.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
]
const hit = checkList.some((lang) => textLanguages.has(lang))
if (hit) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中语言限制:${value}`)
}
} else if (t === 'GENRE') {
// 匹配流派:genre 字段或 note/title/artist 中的流派关键词
const combinedForGenre = `${genre} ${noteForKeyword} ${title} ${artist}`.trim()
if (!combinedForGenre) continue
const tokens = valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
const hit = tokens.some((tok) => combinedForGenre.includes(tok))
if (hit) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中流派限制:${value}`)
}
} else {
// SONG 不参与 note 审查
continue
}
}
const rejected = matchedRules.length > 0
return {
notePublic: rejected ? false : (requiresApproval ? false : wantPublic),
publicStatus: requiresApproval || matchedRules.length > 0 ? (rejected ? 'rejected' : 'approved') : null,
rejectedReasons,
matchedRules
}
}
+70
View File
@@ -0,0 +1,70 @@
export const SYSTEM_SETTINGS_DEFAULTS = {
siteTitle: '校园广播站点歌系统',
siteVersion: '1.7.1.0',
enableSubmissionLimit: false,
showBlacklistKeywords: false,
hideStudentInfo: true,
smtpEnabled: false,
smtpPort: 587,
smtpSecure: false,
smtpFromName: '校园广播站',
enableRequestTimeLimitation: false,
forceBlockAllRequests: false,
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
submissionNoteRequiresApproval: false,
telemetryEnabled: true,
showBeianIcon: true,
scheduleDaysDisplay: 7,
scheduleDaysEditable: 3,
scheduleDaysRequestable: 14,
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 1,
scheduleDaysAfterEnabled: false,
scheduleDaysAfter: 1,
forcePasswordChangeOnFirstLogin: false,
enableSubmissionRestriction: false,
submissionRestrictionScope: 'all',
cardCodeEnabled: false,
cardCodeSubmissionRequired: false,
cardCodeVoteEnabled: false,
cardCodeReplayEnabled: false,
enableCardCodeRequests: false,
requireCardCodeForRequests: false,
enableCardCodeLimitBypass: false,
submissionRestrictionEnabled: false,
submissionRestrictionGradeEnabled: false,
submissionRestrictionClassEnabled: false,
submissionRestrictionGraduateEnabled: false,
submissionRestrictionWithdrawnEnabled: false,
captchaProvider: 'TURNSTILE',
captchaEnabled: false,
allowRegister: true,
registerRequireEmail: false,
registerRequireEmailVerify: false,
registerDefaultRole: 'USER',
registerRequiresApproval: false,
oauthRegisterRequiresApproval: false,
registerEmailRequired: false,
registerRequiresGradeClass: false,
oauthEnabled: false,
oauthAllowRegister: true,
oauthAllowLogin: true,
oauthAllowBind: true,
allowOAuthRegistration: false,
oauthProviders: '[]',
githubOAuthEnabled: false,
casdoorOAuthEnabled: false,
googleOAuthEnabled: false,
aggregateOAuthEnabled: false,
aggregateOAuthLoginType: 'qq',
customOAuthEnabled: false,
captchaMaxFailures: 3,
autoBackupEnabled: false,
autoBackupIntervalHours: 24,
autoBackupRetentionDays: 30,
statisticsCodeEnabled: false,
defaultTheme: 'System',
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
} as const
+71
View File
@@ -0,0 +1,71 @@
export interface UserAgentInfo {
browser: string | null
operatingSystem: string | null
device: string | null
}
const BROWSER_PATTERNS: Array<[RegExp, string]> = [
[/Edg\//i, 'Microsoft Edge'],
[/OPR\//i, 'Opera'],
[/Chrome\//i, 'Chrome'],
[/Safari\//i, 'Safari'],
[/Firefox\//i, 'Firefox'],
[/MSIE |Trident\//i, 'Internet Explorer']
]
const OS_PATTERNS: Array<[RegExp, string]> = [
[/Windows NT 10\.0/i, 'Windows 10/11'],
[/Windows NT 6\.3/i, 'Windows 8.1'],
[/Windows NT 6\.2/i, 'Windows 8'],
[/Windows NT 6\.1/i, 'Windows 7'],
[/Windows/i, 'Windows'],
[/Mac OS X ([\d_.]+)/i, 'macOS'],
[/iPhone OS ([\d_]+)/i, 'iOS'],
[/iPad.*CPU OS ([\d_]+)/i, 'iPadOS'],
[/Android ([\d.]+)/i, 'Android'],
[/Linux/i, 'Linux']
]
const DEVICE_PATTERNS: Array<[RegExp, string]> = [
[/iPhone/i, 'iPhone'],
[/iPad/i, 'iPad'],
[/Android.*Mobile/i, 'Android Phone'],
[/Android/i, 'Android Tablet'],
[/Windows Phone/i, 'Windows Phone'],
[/Macintosh/i, 'Mac'],
[/Windows/i, 'Windows PC'],
[/Linux/i, 'Linux PC']
]
export function parseUserAgent(userAgent: string | null | undefined): UserAgentInfo {
const ua = (userAgent ?? '').trim()
if (!ua) return { browser: null, operatingSystem: null, device: null }
let browser: string | null = null
for (const [regex, name] of BROWSER_PATTERNS) {
if (regex.test(ua)) {
browser = name
break
}
}
let operatingSystem: string | null = null
for (const [regex, name] of OS_PATTERNS) {
if (regex.test(ua)) {
operatingSystem = name
break
}
}
let device: string | null = null
for (const [regex, name] of DEVICE_PATTERNS) {
if (regex.test(ua)) {
device = name
break
}
}
return { browser, operatingSystem, device }
}
export type LoginMethod = 'password' | 'oauth' | 'webauthn' | 'totp_2fa' | 'email_2fa'
+45
View File
@@ -0,0 +1,45 @@
interface AvatarUserState {
avatarProvider?: string | null
avatarProviderUserId?: string | null
}
interface IdentityRow {
provider: string
providerUserId: string
avatar?: string | null
createdAt: Date | string
}
export interface AvatarSource {
provider: string | null
providerUserId: string | null
}
export function resolveAvatarSource(
user: AvatarUserState,
identities: IdentityRow[]
): AvatarSource | null {
if (user.avatarProvider) {
return {
provider: user.avatarProvider,
providerUserId: user.avatarProviderUserId ?? null
}
}
const sorted = [...identities].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return at - bt
})
for (const identity of sorted) {
if (identity.avatar) {
return {
provider: identity.provider,
providerUserId: identity.providerUserId
}
}
}
return null
}