feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /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:
@@ -61,6 +61,12 @@
|
||||
>
|
||||
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
|
||||
</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
|
||||
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"
|
||||
@@ -408,7 +414,7 @@
|
||||
@click.stop
|
||||
>
|
||||
<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
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="cancelReject"
|
||||
@@ -418,7 +424,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
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>
|
||||
</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">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
@@ -961,6 +978,7 @@ import {
|
||||
X,
|
||||
Trash2,
|
||||
Music,
|
||||
ListMusic,
|
||||
Heart,
|
||||
Download,
|
||||
ChevronDown,
|
||||
@@ -1040,6 +1058,7 @@ const showRejectDialog = ref(false)
|
||||
const rejectLoading = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const addToBlacklist = ref(false)
|
||||
const isBatchReject = ref(false)
|
||||
const rejectSongInfo = ref({
|
||||
id: null,
|
||||
title: '',
|
||||
@@ -1475,6 +1494,7 @@ const rejectSong = (songId) => {
|
||||
const song = songs.value.find((s) => s.id === songId)
|
||||
if (!song) return
|
||||
|
||||
isBatchReject.value = false
|
||||
rejectSongInfo.value = {
|
||||
id: song.id,
|
||||
title: song.title || '',
|
||||
@@ -1487,6 +1507,22 @@ const rejectSong = (songId) => {
|
||||
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 () => {
|
||||
if (!rejectReason.value.trim()) {
|
||||
@@ -1496,26 +1532,40 @@ const confirmReject = async () => {
|
||||
|
||||
rejectLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/songs/reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songId: rejectSongInfo.value.id,
|
||||
reason: rejectReason.value.trim(),
|
||||
addToBlacklist: addToBlacklist.value
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songId: rejectSongInfo.value.id,
|
||||
reason: rejectReason.value.trim(),
|
||||
addToBlacklist: addToBlacklist.value
|
||||
}
|
||||
})
|
||||
|
||||
await refreshSongs(true)
|
||||
|
||||
const index = selectedSongs.value.indexOf(rejectSongInfo.value.id)
|
||||
if (index > -1) {
|
||||
selectedSongs.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
|
||||
await refreshSongs(true)
|
||||
|
||||
const index = selectedSongs.value.indexOf(rejectSongInfo.value.id)
|
||||
if (index > -1) {
|
||||
selectedSongs.value.splice(index, 1)
|
||||
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
||||
}
|
||||
|
||||
showRejectDialog.value = false
|
||||
|
||||
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('驳回歌曲失败:', error)
|
||||
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
|
||||
} finally {
|
||||
@@ -1528,6 +1578,7 @@ const cancelReject = () => {
|
||||
showRejectDialog.value = false
|
||||
rejectReason.value = ''
|
||||
addToBlacklist.value = false
|
||||
isBatchReject.value = false
|
||||
rejectSongInfo.value = {
|
||||
id: null,
|
||||
title: '',
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
<AuthProvidersGoogleIcon />
|
||||
</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>
|
||||
</template>
|
||||
@@ -39,7 +49,6 @@ const hasEnabledProviders = computed(() => {
|
||||
})
|
||||
|
||||
const loginWith = (provider: string) => {
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,9 @@ const siteConfig = ref({
|
||||
siteVersion: '',
|
||||
submissionGuidelines: '',
|
||||
icpNumber: '',
|
||||
gonganNumber: ''
|
||||
gonganNumber: '',
|
||||
defaultTheme: null,
|
||||
enabledThemes: null
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
@@ -57,7 +59,9 @@ export const useSiteConfig = () => {
|
||||
gonganNumber: '',
|
||||
enableReplayRequests: false,
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false
|
||||
enableSubmissionRemarks: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
|
||||
}
|
||||
isLoaded.value = true
|
||||
} finally {
|
||||
@@ -96,6 +100,32 @@ export const useSiteConfig = () => {
|
||||
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
||||
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 () => {
|
||||
if (typeof window !== 'undefined' && !isLoaded.value) {
|
||||
@@ -126,6 +156,8 @@ export const useSiteConfig = () => {
|
||||
enableCollaborativeSubmission,
|
||||
enableSubmissionRemarks,
|
||||
smtpEnabled,
|
||||
defaultTheme,
|
||||
enabledThemes,
|
||||
fetchSiteConfig,
|
||||
initSiteConfig,
|
||||
refreshSiteConfig
|
||||
|
||||
+112
-44
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'voicehub:theme'
|
||||
type ThemeName = string & {}
|
||||
@@ -15,6 +15,25 @@ function getMetaThemeColor() {
|
||||
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) {
|
||||
if (!isBrowser()) return
|
||||
|
||||
@@ -24,11 +43,12 @@ function applyTheme(theme: string) {
|
||||
root.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.remove('light')
|
||||
root.style.colorScheme = 'dark'
|
||||
} else if (theme === 'light') {
|
||||
} else if (effective === 'light') {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
root.style.colorScheme = 'light'
|
||||
@@ -39,11 +59,7 @@ function applyTheme(theme: string) {
|
||||
|
||||
const meta = getMetaThemeColor()
|
||||
if (meta) {
|
||||
if (theme === 'light') {
|
||||
meta.content = '#f8fafc'
|
||||
} else {
|
||||
meta.content = '#111111'
|
||||
}
|
||||
meta.content = effective === 'light' ? '#f8fafc' : '#111111'
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -59,18 +75,37 @@ function detectSystemTheme(): '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() {
|
||||
const config = useRuntimeConfig()
|
||||
const { isLoaded: siteConfigLoaded, defaultTheme: siteDefaultTheme, enabledThemes: siteEnabledThemes, initSiteConfig } = useSiteConfig()
|
||||
|
||||
const enabledThemes = computed<string[]>(() => {
|
||||
const list = (config.public?.enabledThemes as unknown) as string[] | undefined
|
||||
return Array.isArray(list) && list.length > 0 ? list : ['dark', 'light']
|
||||
if (siteConfigLoaded.value && siteEnabledThemes.value.length > 0) {
|
||||
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 value = config.public?.defaultTheme as string | undefined
|
||||
if (value && enabledThemes.value.includes(value)) return value
|
||||
return enabledThemes.value.includes('dark') ? 'dark' : enabledThemes.value[0] || 'dark'
|
||||
if (siteConfigLoaded.value && siteDefaultTheme.value) {
|
||||
return siteDefaultTheme.value
|
||||
}
|
||||
const fromRuntime = config.public?.defaultTheme
|
||||
if (typeof fromRuntime === 'string' && fromRuntime) {
|
||||
return fromRuntime
|
||||
}
|
||||
return 'System'
|
||||
})
|
||||
|
||||
const theme = computed<string>({
|
||||
@@ -79,21 +114,22 @@ export function useTheme() {
|
||||
})
|
||||
|
||||
const isDark = computed(() => {
|
||||
const t = theme.value
|
||||
if (t === 'dark') return true
|
||||
if (t === 'light') return false
|
||||
return detectSystemTheme() === 'dark'
|
||||
return resolveEffectiveTheme(theme.value) === 'dark'
|
||||
})
|
||||
|
||||
function isThemeEnabled(name: string) {
|
||||
return enabledThemes.value.includes(name)
|
||||
}
|
||||
|
||||
function firstValidTheme(candidate: string): string {
|
||||
const list = enabledThemes.value
|
||||
if (list.includes(candidate)) return candidate
|
||||
if (list.includes(defaultTheme.value)) return defaultTheme.value
|
||||
return list[0] ?? 'System'
|
||||
}
|
||||
|
||||
function setTheme(next: string, { persist = true } = {}) {
|
||||
let candidate = next
|
||||
if (!enabledThemes.value.includes(candidate)) {
|
||||
candidate = defaultTheme.value
|
||||
}
|
||||
const candidate = firstValidTheme(next)
|
||||
|
||||
if (currentTheme.value === candidate && initialized.value) return
|
||||
|
||||
@@ -110,7 +146,7 @@ export function useTheme() {
|
||||
}
|
||||
|
||||
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 idx = order.indexOf(current)
|
||||
const next = order[(idx + 1) % order.length]
|
||||
@@ -121,8 +157,31 @@ export function useTheme() {
|
||||
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 (!siteConfigLoaded.value) {
|
||||
try {
|
||||
await initSiteConfig()
|
||||
await nextTick()
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
|
||||
let stored: string | null = null
|
||||
@@ -132,34 +191,43 @@ export function useTheme() {
|
||||
stored = null
|
||||
}
|
||||
|
||||
if (stored && enabledThemes.value.includes(stored)) {
|
||||
setTheme(stored)
|
||||
return
|
||||
if (stored) {
|
||||
const valid = firstValidTheme(stored)
|
||||
if (valid === stored || enabledThemes.value.includes(stored)) {
|
||||
setTheme(stored)
|
||||
watchSystemTheme()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
watchSystemTheme()
|
||||
}
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => {
|
||||
let s: string | null = null
|
||||
try {
|
||||
s = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
s = null
|
||||
}
|
||||
if (!s) {
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
}
|
||||
}
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', onChange)
|
||||
} else if (typeof mql.addListener === 'function') {
|
||||
mql.addListener(onChange)
|
||||
}
|
||||
function watchSystemTheme() {
|
||||
if (!isBrowser() || !window.matchMedia) return
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', onSystemThemeChange)
|
||||
} else if (typeof (mql as any).addListener === 'function') {
|
||||
;(mql as any).addListener(onSystemThemeChange)
|
||||
}
|
||||
}
|
||||
|
||||
watch(defaultTheme, () => {
|
||||
if (!initialized.value) return
|
||||
const stored = (() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!stored) {
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
theme,
|
||||
currentTheme,
|
||||
|
||||
@@ -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,
|
||||
"tag": "20260418112000_add_song_comments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1788684403309,
|
||||
"tag": "20260906084643_auto-migration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+63
-3
@@ -51,9 +51,16 @@ export const authSessions = pgTable('auth_sessions', {
|
||||
userAgent: text('user_agent'),
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
isRevoked: boolean('is_revoked').default(false).notNull(),
|
||||
browser: text('browser'),
|
||||
operatingSystem: text('operating_system'),
|
||||
device: text('device'),
|
||||
loginMethod: text('login_method').default('password').notNull(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
revokedReason: text('revoked_reason')
|
||||
}, (table) => [
|
||||
index('auth_sessions_user_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_last_active_idx').on(table.lastActiveAt),
|
||||
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
|
||||
]);
|
||||
|
||||
@@ -258,16 +265,29 @@ export const systemSettings = pgTable('SystemSettings', {
|
||||
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
|
||||
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
|
||||
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
|
||||
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
|
||||
instanceId: text('instanceId'),
|
||||
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
|
||||
showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
|
||||
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
|
||||
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
|
||||
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
|
||||
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
|
||||
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
|
||||
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
|
||||
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
|
||||
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
|
||||
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
|
||||
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
|
||||
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
|
||||
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
|
||||
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
|
||||
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
|
||||
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
|
||||
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
|
||||
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
|
||||
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
|
||||
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
|
||||
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
|
||||
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
|
||||
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
|
||||
@@ -282,16 +302,56 @@ export const systemSettings = pgTable('SystemSettings', {
|
||||
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
|
||||
registerDefaultRole: text('registerDefaultRole').default('USER'),
|
||||
registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
|
||||
registerRequiresApproval: boolean('registerRequiresApproval').default(false).notNull(),
|
||||
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(false).notNull(),
|
||||
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
|
||||
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
|
||||
oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
|
||||
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
|
||||
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
|
||||
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
|
||||
allowOAuthRegistration: boolean('allowOAuthRegistration').default(false).notNull(),
|
||||
oauthRedirectUri: text('oauthRedirectUri'),
|
||||
oauthStateSecret: text('oauthStateSecret'),
|
||||
oauthProviders: text('oauthProviders').default('[]'),
|
||||
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
|
||||
githubClientId: text('githubClientId'),
|
||||
githubClientSecret: text('githubClientSecret'),
|
||||
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
|
||||
casdoorServerUrl: text('casdoorServerUrl'),
|
||||
casdoorClientId: text('casdoorClientId'),
|
||||
casdoorClientSecret: text('casdoorClientSecret'),
|
||||
casdoorOrganizationName: text('casdoorOrganizationName'),
|
||||
googleOAuthEnabled: boolean('googleOAuthEnabled').default(false).notNull(),
|
||||
googleClientId: text('googleClientId'),
|
||||
googleClientSecret: text('googleClientSecret'),
|
||||
aggregateOAuthEnabled: boolean('aggregateOAuthEnabled').default(false).notNull(),
|
||||
aggregateOAuthAppId: text('aggregateOAuthAppId'),
|
||||
aggregateOAuthAppKey: text('aggregateOAuthAppKey'),
|
||||
aggregateOAuthLoginType: text('aggregateOAuthLoginType').default('qq'),
|
||||
aggregateOAuthEndpoint: text('aggregateOAuthEndpoint'),
|
||||
customOAuthEnabled: boolean('customOAuthEnabled').default(false).notNull(),
|
||||
customOAuthDisplayName: text('customOAuthDisplayName'),
|
||||
customOAuthAuthorizeUrl: text('customOAuthAuthorizeUrl'),
|
||||
customOAuthTokenUrl: text('customOAuthTokenUrl'),
|
||||
customOAuthUserInfoUrl: text('customOAuthUserInfoUrl'),
|
||||
customOAuthScope: text('customOAuthScope'),
|
||||
customOAuthClientId: text('customOAuthClientId'),
|
||||
customOAuthClientSecret: text('customOAuthClientSecret'),
|
||||
customOAuthUserIdField: text('customOAuthUserIdField'),
|
||||
customOAuthUsernameField: text('customOAuthUsernameField'),
|
||||
customOAuthNameField: text('customOAuthNameField'),
|
||||
customOAuthEmailField: text('customOAuthEmailField'),
|
||||
customOAuthAvatarField: text('customOAuthAvatarField'),
|
||||
captchaMaxFailures: integer('captchaMaxFailures').default(3).notNull(),
|
||||
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
|
||||
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
|
||||
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
|
||||
autoBackupConfig: text('autoBackupConfig'),
|
||||
statisticsCode: text('statisticsCode'),
|
||||
defaultTheme: text('defaultTheme').default('light'),
|
||||
enabledThemes: text('enabledThemes').default('light,dark'),
|
||||
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
||||
defaultTheme: text('defaultTheme').default('System').notNull(),
|
||||
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
|
||||
enabledPlatforms: text('enabledPlatforms'),
|
||||
platformOrder: text('platformOrder'),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PASSWORD_MIN_LENGTH = 6
|
||||
export const PASSWORD_MAX_LENGTH = 64
|
||||
+55
-8
@@ -40,11 +40,51 @@ export default defineNuxtConfig({
|
||||
},
|
||||
modules: [
|
||||
'@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']
|
||||
: []),
|
||||
...((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: [
|
||||
'~/assets/css/variables.css',
|
||||
@@ -150,6 +190,10 @@ export default defineNuxtConfig({
|
||||
},
|
||||
// 移动端优化
|
||||
{ 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-status-bar-style', content: 'black-translucent' },
|
||||
{ name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' },
|
||||
@@ -162,22 +206,25 @@ export default defineNuxtConfig({
|
||||
tagPosition: 'head',
|
||||
children: [
|
||||
`(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}`,
|
||||
` if(!s){`,
|
||||
` try{`,
|
||||
` var meta=document.querySelector('meta[name="x-default-theme"]');`,
|
||||
` if(meta && meta.content){s=meta.content}`,
|
||||
` }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==='dark'){r.classList.add('dark'); r.style.colorScheme='dark'}`,
|
||||
` else if(s==='light'){r.classList.add('light'); r.style.colorScheme='light'}`,
|
||||
` else{r.style.colorScheme='light dark'}`,
|
||||
` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
|
||||
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
|
||||
` else{eff='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{`,
|
||||
` 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){}})();`
|
||||
].join('')
|
||||
|
||||
Generated
+3775
-142
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,8 @@
|
||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||
"@pixi/filter-color-matrix": "^7.4.3",
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@sentry/node": "^10.73.0",
|
||||
"@sentry/vue": "^10.73.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@@ -60,6 +62,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vite-pwa/nuxt": "^1.1.1",
|
||||
"axios": "^1.20.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bezier-easing": "^2.1.0",
|
||||
@@ -105,6 +108,7 @@
|
||||
"node": ">=22.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@nuxt/eslint": "^1.17.0",
|
||||
"eslint": "^9.39.5",
|
||||
"prettier": "^3.9.6",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -3,6 +3,7 @@ const { authenticator } = otplib
|
||||
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
|
||||
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
|
||||
@@ -100,7 +101,12 @@ export default defineEventHandler(async (event) => {
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成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 =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { parseState, getRedirectUri } from '~~/server/utils/oauth'
|
||||
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { db, eq, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
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, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import {
|
||||
isAccountLocked,
|
||||
@@ -86,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
||||
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, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import {
|
||||
getAccountLockRemainingTime,
|
||||
getIPBlockRemainingTime,
|
||||
@@ -187,7 +188,8 @@ export default defineEventHandler(async (event) => {
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成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
|
||||
const isSecure =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users } from '~/drizzle/db'
|
||||
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'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -109,7 +110,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 签发 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 =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isSessionRevokedByJti, touchAuthSession } from '~~/server/utils/create-auth-session'
|
||||
|
||||
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
|
||||
|
||||
@@ -19,9 +20,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 验证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
|
||||
|
||||
if (await isSessionRevokedByJti(decoded.jti)) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '会话已撤销,请重新登录'
|
||||
})
|
||||
}
|
||||
touchAuthSession(decoded.jti).catch(() => undefined)
|
||||
|
||||
// 优先从Redis缓存获取用户认证状态
|
||||
if (isRedisReady()) {
|
||||
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 { createError, defineEventHandler, setCookie } from 'h3'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
@@ -84,7 +85,8 @@ export default defineEventHandler(async (event) => {
|
||||
.where(eq(userIdentities.id, identity.id))
|
||||
|
||||
// 签发登录 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, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
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 { formatDateTime } from '~/utils/timeUtils'
|
||||
import { openApiCache } from '~~/server/utils/open-api-cache'
|
||||
import { CACHE_CONSTANTS } from '~~/server/config/constants'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis'
|
||||
import {
|
||||
resolveScheduleVisibilityRange,
|
||||
isPlayDateInRange,
|
||||
type VisibilitySettingsLike
|
||||
} from '~~/server/utils/schedule-visibility'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -36,6 +41,27 @@ export default defineEventHandler(async (event) => {
|
||||
const sortBy = (query.sortBy as string) || 'playDate'
|
||||
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缓存获取数据
|
||||
|
||||
// 尝试从CacheService获取排期数据
|
||||
@@ -49,6 +75,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (semester) {
|
||||
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
||||
}
|
||||
filteredSchedules = applyVisibilityFilter(filteredSchedules)
|
||||
|
||||
// 转换为开放API格式
|
||||
const result = {
|
||||
@@ -105,11 +132,12 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
if (redisData) {
|
||||
const filteredRedisData = applyVisibilityFilter(redisData)
|
||||
// 转换为开放API格式
|
||||
const result = {
|
||||
success: true,
|
||||
data: {
|
||||
schedules: redisData.map((schedule: any) => ({
|
||||
schedules: filteredRedisData.map((schedule: any) => ({
|
||||
id: schedule.id,
|
||||
playDate: schedule.playDate,
|
||||
sequence: schedule.sequence,
|
||||
@@ -133,7 +161,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
: null
|
||||
})),
|
||||
total: redisData.length,
|
||||
total: filteredRedisData.length,
|
||||
semester: semester || null
|
||||
}
|
||||
}
|
||||
@@ -175,6 +203,13 @@ export default defineEventHandler(async (event) => {
|
||||
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
|
||||
|
||||
// 计算偏移量
|
||||
|
||||
@@ -26,7 +26,13 @@ const publicFields = [
|
||||
'enableSubmissionRemarks',
|
||||
'enableRequestTimeLimitation',
|
||||
'forceBlockAllRequests',
|
||||
'smtpEnabled'
|
||||
'smtpEnabled',
|
||||
'defaultTheme',
|
||||
'enabledThemes',
|
||||
'scheduleDaysBeforeEnabled',
|
||||
'scheduleDaysBefore',
|
||||
'scheduleDaysAfterEnabled',
|
||||
'scheduleDaysAfter'
|
||||
]
|
||||
|
||||
const filterPublicSettings = (data: any) => {
|
||||
@@ -81,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
||||
enableRequestTimeLimitation: false,
|
||||
forceBlockAllRequests: false,
|
||||
enableReplayRequests: false,
|
||||
smtpEnabled: false
|
||||
smtpEnabled: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
|
||||
scheduleDaysBeforeEnabled: false,
|
||||
scheduleDaysBefore: 30,
|
||||
scheduleDaysAfterEnabled: false,
|
||||
scheduleDaysAfter: 14
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, songCollaborators, collaborationLogs } from '~/drizzle/schema'
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import {
|
||||
songs,
|
||||
users,
|
||||
songCollaborators,
|
||||
collaborationLogs,
|
||||
systemSettings,
|
||||
songBlacklists
|
||||
} from '~/drizzle/schema'
|
||||
import { and, eq, inArray, or } from 'drizzle-orm'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
|
||||
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -122,6 +130,38 @@ export default defineEventHandler(async (event) => {
|
||||
? body.submissionNote.trim()
|
||||
: 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
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
users,
|
||||
votes
|
||||
} 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 { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||
import { formatDateTime } from '~/utils/timeUtils'
|
||||
@@ -38,6 +43,13 @@ export default defineEventHandler(async (event) => {
|
||||
.limit(1)
|
||||
const systemSettingsData = systemSettingsDataResult[0] || null
|
||||
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) {
|
||||
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)
|
||||
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
|
||||
}
|
||||
// 应用排期可见范围过滤
|
||||
if (visibilityRange.startAt || visibilityRange.endAt) {
|
||||
filteredSchedules = filteredSchedules.filter((s) =>
|
||||
isPlayDateInRange(s.playDate, visibilityRange)
|
||||
)
|
||||
}
|
||||
|
||||
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
|
||||
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
|
||||
.select({
|
||||
id: schedules.id,
|
||||
@@ -153,10 +185,9 @@ export default defineEventHandler(async (event) => {
|
||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||
.where(eq(schedules.isDraft, false)) // 只查询已发布的排期
|
||||
.where(and(...scheduleWhere)) // 只查询已发布的排期(+可见范围)
|
||||
.orderBy(schedules.playDate)
|
||||
|
||||
// 获取每首歌的投票数
|
||||
const voteCountsQuery = await db
|
||||
.select({
|
||||
songId: votes.songId,
|
||||
|
||||
@@ -4,14 +4,16 @@ import {
|
||||
playTimes,
|
||||
requestTimes,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songs,
|
||||
systemSettings
|
||||
} 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 { isLimitReached } from '~~/server/utils/submissionLimit'
|
||||
import { getBeijingTimeISOString } from '~/utils/timeUtils'
|
||||
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
@@ -132,6 +134,18 @@ export default defineEventHandler(async (event) => {
|
||||
const systemSettingsData = systemSettingsResult[0]
|
||||
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. 检查强制关闭
|
||||
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
|
||||
throw createError({
|
||||
@@ -243,8 +257,34 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const submissionNote =
|
||||
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
|
||||
const submissionNotePublic =
|
||||
submissionNote !== null ? body.submissionNotePublic !== false : false
|
||||
const rawSubmissionNotePublic = 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 }[] = []
|
||||
@@ -367,6 +407,7 @@ export default defineEventHandler(async (event) => {
|
||||
playUrl: body.playUrl || null,
|
||||
submissionNote,
|
||||
submissionNotePublic,
|
||||
submissionNotePublicStatus,
|
||||
hitRequestId: hitRequestTime?.id || null,
|
||||
expectedPlayDate: body.expectedPlayDate || null,
|
||||
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
|
||||
|
||||
@@ -180,3 +180,13 @@ export const RISK_CONTROL_CONSTANTS = {
|
||||
SONG_IP_DOMINANCE_PERCENT: 0.6,
|
||||
SONG_IP_MIN_SAMPLE: 8
|
||||
} 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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 || {}) }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '删除点歌券失败' })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -38,30 +38,34 @@ export class JWTEnhanced {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWT token
|
||||
* 生成JWT token(返回 token 字符串,兼容旧调用)
|
||||
*/
|
||||
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) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
|
||||
// 生成唯一的JWT ID
|
||||
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// JWT载荷
|
||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||
userId,
|
||||
role,
|
||||
jti
|
||||
}
|
||||
|
||||
// 生成token
|
||||
const token = jwt.sign(payload, this.JWT_SECRET, {
|
||||
expiresIn: this.TOKEN_EXPIRES
|
||||
})
|
||||
|
||||
console.log(`Generated JWT token for user ${userId}`)
|
||||
return token
|
||||
return { token, jti }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
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 {
|
||||
id: string
|
||||
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
|
||||
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 => {
|
||||
const strategy = strategies[provider]
|
||||
if (!strategy) {
|
||||
|
||||
@@ -261,6 +261,12 @@ export const disconnectRedis = () => redisPool.disconnect()
|
||||
export const getRedisClient = () => redisPool.getClient()
|
||||
export const isRedisReady = () => redisPool.isReady()
|
||||
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>) =>
|
||||
redisPool.executeCommand(command, fallback)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user