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 }})
|
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
|
||||||
|
@click="batchReject"
|
||||||
|
>
|
||||||
|
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
|
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
|
||||||
@click="batchDelete"
|
@click="batchDelete"
|
||||||
@@ -408,7 +414,7 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||||
<h3 class="text-xl font-black text-zinc-100">驳回歌曲</h3>
|
<h3 class="text-xl font-black text-zinc-100">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
@click="cancelReject"
|
@click="cancelReject"
|
||||||
@@ -418,7 +424,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-8 space-y-6">
|
<div class="p-8 space-y-6">
|
||||||
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
<div v-if="!isBatchReject" class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
|
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
|
||||||
>
|
>
|
||||||
@@ -429,6 +435,17 @@
|
|||||||
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
|
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center border border-amber-500/10"
|
||||||
|
>
|
||||||
|
<ListMusic :size="18" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-zinc-100 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
|
||||||
|
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
@@ -961,6 +978,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Trash2,
|
Trash2,
|
||||||
Music,
|
Music,
|
||||||
|
ListMusic,
|
||||||
Heart,
|
Heart,
|
||||||
Download,
|
Download,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -1040,6 +1058,7 @@ const showRejectDialog = ref(false)
|
|||||||
const rejectLoading = ref(false)
|
const rejectLoading = ref(false)
|
||||||
const rejectReason = ref('')
|
const rejectReason = ref('')
|
||||||
const addToBlacklist = ref(false)
|
const addToBlacklist = ref(false)
|
||||||
|
const isBatchReject = ref(false)
|
||||||
const rejectSongInfo = ref({
|
const rejectSongInfo = ref({
|
||||||
id: null,
|
id: null,
|
||||||
title: '',
|
title: '',
|
||||||
@@ -1475,6 +1494,7 @@ const rejectSong = (songId) => {
|
|||||||
const song = songs.value.find((s) => s.id === songId)
|
const song = songs.value.find((s) => s.id === songId)
|
||||||
if (!song) return
|
if (!song) return
|
||||||
|
|
||||||
|
isBatchReject.value = false
|
||||||
rejectSongInfo.value = {
|
rejectSongInfo.value = {
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title || '',
|
title: song.title || '',
|
||||||
@@ -1487,6 +1507,22 @@ const rejectSong = (songId) => {
|
|||||||
showRejectDialog.value = true
|
showRejectDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量驳回歌曲
|
||||||
|
const batchReject = () => {
|
||||||
|
if (selectedSongs.value.length === 0) return
|
||||||
|
|
||||||
|
isBatchReject.value = true
|
||||||
|
rejectSongInfo.value = {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
requester: ''
|
||||||
|
}
|
||||||
|
rejectReason.value = ''
|
||||||
|
addToBlacklist.value = false
|
||||||
|
showRejectDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 确认驳回
|
// 确认驳回
|
||||||
const confirmReject = async () => {
|
const confirmReject = async () => {
|
||||||
if (!rejectReason.value.trim()) {
|
if (!rejectReason.value.trim()) {
|
||||||
@@ -1496,6 +1532,19 @@ const confirmReject = async () => {
|
|||||||
|
|
||||||
rejectLoading.value = true
|
rejectLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
if (isBatchReject.value) {
|
||||||
|
await $fetch('/api/admin/songs/batch-reject', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
songIds: selectedSongs.value,
|
||||||
|
reason: rejectReason.value.trim(),
|
||||||
|
addToBlacklist: addToBlacklist.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await refreshSongs(true)
|
||||||
|
selectedSongs.value = []
|
||||||
|
showNotification(`批量驳回成功,共处理 ${selectedSongs.value.length} 首歌曲`, 'success')
|
||||||
|
} else {
|
||||||
await $fetch('/api/admin/songs/reject', {
|
await $fetch('/api/admin/songs/reject', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -1512,10 +1561,11 @@ const confirmReject = async () => {
|
|||||||
selectedSongs.value.splice(index, 1)
|
selectedSongs.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
showRejectDialog.value = false
|
|
||||||
|
|
||||||
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
||||||
} catch (error) {
|
}
|
||||||
|
|
||||||
|
showRejectDialog.value = false
|
||||||
|
} catch (error: any) {
|
||||||
console.error('驳回歌曲失败:', error)
|
console.error('驳回歌曲失败:', error)
|
||||||
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
|
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1528,6 +1578,7 @@ const cancelReject = () => {
|
|||||||
showRejectDialog.value = false
|
showRejectDialog.value = false
|
||||||
rejectReason.value = ''
|
rejectReason.value = ''
|
||||||
addToBlacklist.value = false
|
addToBlacklist.value = false
|
||||||
|
isBatchReject.value = false
|
||||||
rejectSongInfo.value = {
|
rejectSongInfo.value = {
|
||||||
id: null,
|
id: null,
|
||||||
title: '',
|
title: '',
|
||||||
|
|||||||
@@ -26,7 +26,17 @@
|
|||||||
<AuthProvidersGoogleIcon />
|
<AuthProvidersGoogleIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 其他提供商可在此添加 -->
|
<button
|
||||||
|
v-if="config.public.oauth.aggregate"
|
||||||
|
type="button"
|
||||||
|
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#12B7F5] hover:text-white hover:border-[#12B7F5]"
|
||||||
|
title="使用 QQ / 微信登录"
|
||||||
|
@click="loginWith('aggregate')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,7 +49,6 @@ const hasEnabledProviders = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loginWith = (provider: string) => {
|
const loginWith = (provider: string) => {
|
||||||
// 外部导航到 API 端点
|
|
||||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ const siteConfig = ref({
|
|||||||
siteVersion: '',
|
siteVersion: '',
|
||||||
submissionGuidelines: '',
|
submissionGuidelines: '',
|
||||||
icpNumber: '',
|
icpNumber: '',
|
||||||
gonganNumber: ''
|
gonganNumber: '',
|
||||||
|
defaultTheme: null,
|
||||||
|
enabledThemes: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
@@ -57,7 +59,9 @@ export const useSiteConfig = () => {
|
|||||||
gonganNumber: '',
|
gonganNumber: '',
|
||||||
enableReplayRequests: false,
|
enableReplayRequests: false,
|
||||||
enableCollaborativeSubmission: true,
|
enableCollaborativeSubmission: true,
|
||||||
enableSubmissionRemarks: false
|
enableSubmissionRemarks: false,
|
||||||
|
defaultTheme: 'System',
|
||||||
|
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
|
||||||
}
|
}
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,6 +100,32 @@ export const useSiteConfig = () => {
|
|||||||
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
||||||
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
||||||
|
|
||||||
|
function parseEnabledThemes(raw) {
|
||||||
|
if (Array.isArray(raw)) return raw.filter(Boolean)
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const s = raw.trim()
|
||||||
|
if (!s) return []
|
||||||
|
if (s.startsWith('[') || s.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s)
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||||
|
if (parsed && Array.isArray(parsed.items)) return parsed.items.filter(Boolean)
|
||||||
|
} catch (_) {
|
||||||
|
// fallback to comma split
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTheme = computed(() =>
|
||||||
|
typeof siteConfig.value.defaultTheme === 'string' && siteConfig.value.defaultTheme
|
||||||
|
? siteConfig.value.defaultTheme
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
const enabledThemes = computed(() => parseEnabledThemes(siteConfig.value.enabledThemes))
|
||||||
|
|
||||||
// 初始化配置(仅在客户端执行)
|
// 初始化配置(仅在客户端执行)
|
||||||
const initSiteConfig = async () => {
|
const initSiteConfig = async () => {
|
||||||
if (typeof window !== 'undefined' && !isLoaded.value) {
|
if (typeof window !== 'undefined' && !isLoaded.value) {
|
||||||
@@ -126,6 +156,8 @@ export const useSiteConfig = () => {
|
|||||||
enableCollaborativeSubmission,
|
enableCollaborativeSubmission,
|
||||||
enableSubmissionRemarks,
|
enableSubmissionRemarks,
|
||||||
smtpEnabled,
|
smtpEnabled,
|
||||||
|
defaultTheme,
|
||||||
|
enabledThemes,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
initSiteConfig,
|
initSiteConfig,
|
||||||
refreshSiteConfig
|
refreshSiteConfig
|
||||||
|
|||||||
+108
-40
@@ -1,4 +1,4 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
const STORAGE_KEY = 'voicehub:theme'
|
const STORAGE_KEY = 'voicehub:theme'
|
||||||
type ThemeName = string & {}
|
type ThemeName = string & {}
|
||||||
@@ -15,6 +15,25 @@ function getMetaThemeColor() {
|
|||||||
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseEnabledThemesRaw(raw: unknown): string[] {
|
||||||
|
if (Array.isArray(raw)) return raw.filter(Boolean) as string[]
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const s = raw.trim()
|
||||||
|
if (!s) return []
|
||||||
|
if (s.startsWith('[') || s.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s)
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter(Boolean) as string[]
|
||||||
|
if (parsed && Array.isArray((parsed as any).items)) return (parsed as any).items.filter(Boolean) as string[]
|
||||||
|
} catch (_) {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
function applyTheme(theme: string) {
|
||||||
if (!isBrowser()) return
|
if (!isBrowser()) return
|
||||||
|
|
||||||
@@ -24,11 +43,12 @@ function applyTheme(theme: string) {
|
|||||||
root.setAttribute('data-theme', theme)
|
root.setAttribute('data-theme', theme)
|
||||||
if (appRoot) appRoot.setAttribute('data-theme', theme)
|
if (appRoot) appRoot.setAttribute('data-theme', theme)
|
||||||
|
|
||||||
if (theme === 'dark') {
|
const effective = resolveEffectiveTheme(theme)
|
||||||
|
if (effective === 'dark') {
|
||||||
root.classList.add('dark')
|
root.classList.add('dark')
|
||||||
root.classList.remove('light')
|
root.classList.remove('light')
|
||||||
root.style.colorScheme = 'dark'
|
root.style.colorScheme = 'dark'
|
||||||
} else if (theme === 'light') {
|
} else if (effective === 'light') {
|
||||||
root.classList.add('light')
|
root.classList.add('light')
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
root.style.colorScheme = 'light'
|
root.style.colorScheme = 'light'
|
||||||
@@ -39,11 +59,7 @@ function applyTheme(theme: string) {
|
|||||||
|
|
||||||
const meta = getMetaThemeColor()
|
const meta = getMetaThemeColor()
|
||||||
if (meta) {
|
if (meta) {
|
||||||
if (theme === 'light') {
|
meta.content = effective === 'light' ? '#f8fafc' : '#111111'
|
||||||
meta.content = '#f8fafc'
|
|
||||||
} else {
|
|
||||||
meta.content = '#111111'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -59,18 +75,37 @@ function detectSystemTheme(): 'dark' | 'light' {
|
|||||||
return prefersDark ? 'dark' : 'light'
|
return prefersDark ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveTheme(theme: string): 'dark' | 'light' | string {
|
||||||
|
if (theme === 'System' || theme === 'system') {
|
||||||
|
return detectSystemTheme()
|
||||||
|
}
|
||||||
|
if (theme === 'dark' || theme.endsWith('Dark')) return 'dark'
|
||||||
|
if (theme === 'light' || theme.endsWith('Light')) return 'light'
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const { isLoaded: siteConfigLoaded, defaultTheme: siteDefaultTheme, enabledThemes: siteEnabledThemes, initSiteConfig } = useSiteConfig()
|
||||||
|
|
||||||
const enabledThemes = computed<string[]>(() => {
|
const enabledThemes = computed<string[]>(() => {
|
||||||
const list = (config.public?.enabledThemes as unknown) as string[] | undefined
|
if (siteConfigLoaded.value && siteEnabledThemes.value.length > 0) {
|
||||||
return Array.isArray(list) && list.length > 0 ? list : ['dark', 'light']
|
return [...new Set(siteEnabledThemes.value)]
|
||||||
|
}
|
||||||
|
const fromRuntime = parseEnabledThemesRaw(config.public?.enabledThemes)
|
||||||
|
if (fromRuntime.length > 0) return fromRuntime
|
||||||
|
return ['System', 'dark', 'light']
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultTheme = computed<string>(() => {
|
const defaultTheme = computed<string>(() => {
|
||||||
const value = config.public?.defaultTheme as string | undefined
|
if (siteConfigLoaded.value && siteDefaultTheme.value) {
|
||||||
if (value && enabledThemes.value.includes(value)) return value
|
return siteDefaultTheme.value
|
||||||
return enabledThemes.value.includes('dark') ? 'dark' : enabledThemes.value[0] || 'dark'
|
}
|
||||||
|
const fromRuntime = config.public?.defaultTheme
|
||||||
|
if (typeof fromRuntime === 'string' && fromRuntime) {
|
||||||
|
return fromRuntime
|
||||||
|
}
|
||||||
|
return 'System'
|
||||||
})
|
})
|
||||||
|
|
||||||
const theme = computed<string>({
|
const theme = computed<string>({
|
||||||
@@ -79,22 +114,23 @@ export function useTheme() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isDark = computed(() => {
|
const isDark = computed(() => {
|
||||||
const t = theme.value
|
return resolveEffectiveTheme(theme.value) === 'dark'
|
||||||
if (t === 'dark') return true
|
|
||||||
if (t === 'light') return false
|
|
||||||
return detectSystemTheme() === 'dark'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isThemeEnabled(name: string) {
|
function isThemeEnabled(name: string) {
|
||||||
return enabledThemes.value.includes(name)
|
return enabledThemes.value.includes(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(next: string, { persist = true } = {}) {
|
function firstValidTheme(candidate: string): string {
|
||||||
let candidate = next
|
const list = enabledThemes.value
|
||||||
if (!enabledThemes.value.includes(candidate)) {
|
if (list.includes(candidate)) return candidate
|
||||||
candidate = defaultTheme.value
|
if (list.includes(defaultTheme.value)) return defaultTheme.value
|
||||||
|
return list[0] ?? 'System'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTheme(next: string, { persist = true } = {}) {
|
||||||
|
const candidate = firstValidTheme(next)
|
||||||
|
|
||||||
if (currentTheme.value === candidate && initialized.value) return
|
if (currentTheme.value === candidate && initialized.value) return
|
||||||
|
|
||||||
currentTheme.value = candidate
|
currentTheme.value = candidate
|
||||||
@@ -110,7 +146,7 @@ export function useTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['dark', 'light']
|
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['System', 'dark', 'light']
|
||||||
const current = theme.value
|
const current = theme.value
|
||||||
const idx = order.indexOf(current)
|
const idx = order.indexOf(current)
|
||||||
const next = order[(idx + 1) % order.length]
|
const next = order[(idx + 1) % order.length]
|
||||||
@@ -121,8 +157,31 @@ export function useTheme() {
|
|||||||
setTheme(defaultTheme.value, { persist: false })
|
setTheme(defaultTheme.value, { persist: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function onSystemThemeChange() {
|
||||||
|
const stored = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
if (!stored) {
|
||||||
|
applyTheme(theme.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
if (initialized.value || !isBrowser()) return
|
if (initialized.value || !isBrowser()) return
|
||||||
|
|
||||||
|
if (!siteConfigLoaded.value) {
|
||||||
|
try {
|
||||||
|
await initSiteConfig()
|
||||||
|
await nextTick()
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
|
|
||||||
let stored: string | null = null
|
let stored: string | null = null
|
||||||
@@ -132,33 +191,42 @@ export function useTheme() {
|
|||||||
stored = null
|
stored = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stored && enabledThemes.value.includes(stored)) {
|
if (stored) {
|
||||||
|
const valid = firstValidTheme(stored)
|
||||||
|
if (valid === stored || enabledThemes.value.includes(stored)) {
|
||||||
setTheme(stored)
|
setTheme(stored)
|
||||||
|
watchSystemTheme()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTheme(defaultTheme.value, { persist: false })
|
setTheme(defaultTheme.value, { persist: false })
|
||||||
|
watchSystemTheme()
|
||||||
|
}
|
||||||
|
|
||||||
if (window.matchMedia) {
|
function watchSystemTheme() {
|
||||||
|
if (!isBrowser() || !window.matchMedia) return
|
||||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
const onChange = () => {
|
if (typeof mql.addEventListener === 'function') {
|
||||||
let s: string | null = null
|
mql.addEventListener('change', onSystemThemeChange)
|
||||||
try {
|
} else if (typeof (mql as any).addListener === 'function') {
|
||||||
s = localStorage.getItem(STORAGE_KEY)
|
;(mql as any).addListener(onSystemThemeChange)
|
||||||
} catch {
|
|
||||||
s = null
|
|
||||||
}
|
}
|
||||||
if (!s) {
|
}
|
||||||
|
|
||||||
|
watch(defaultTheme, () => {
|
||||||
|
if (!initialized.value) return
|
||||||
|
const stored = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
if (!stored) {
|
||||||
setTheme(defaultTheme.value, { persist: false })
|
setTheme(defaultTheme.value, { persist: false })
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
if (typeof mql.addEventListener === 'function') {
|
|
||||||
mql.addEventListener('change', onChange)
|
|
||||||
} else if (typeof mql.addListener === 'function') {
|
|
||||||
mql.addListener(onChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
CREATE TYPE "public"."card_code_status" AS ENUM('AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID');--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."BlacklistType" ADD VALUE 'LANGUAGE';--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."BlacklistType" ADD VALUE 'GENRE';--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."user_status" ADD VALUE 'pending';--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."user_status" ADD VALUE 'rejected';--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"session_token" varchar(255) NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"last_active_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"is_revoked" boolean DEFAULT false NOT NULL,
|
||||||
|
"browser" text,
|
||||||
|
"operating_system" text,
|
||||||
|
"device" text,
|
||||||
|
"login_method" text DEFAULT 'password' NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"revoked_reason" text,
|
||||||
|
CONSTRAINT "auth_sessions_session_token_unique" UNIQUE("session_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "backup_records" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"filename" text NOT NULL,
|
||||||
|
"file_path" text NOT NULL,
|
||||||
|
"file_size" bigint NOT NULL,
|
||||||
|
"type" text DEFAULT 'MANUAL' NOT NULL,
|
||||||
|
"status" text DEFAULT 'COMPLETED' NOT NULL,
|
||||||
|
"error_message" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"created_by" integer,
|
||||||
|
"expires_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "card_code_redemptions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"card_code_id" integer NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"redeemed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"song_id" integer,
|
||||||
|
"purpose" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "card_codes" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"code" varchar(64) NOT NULL,
|
||||||
|
"status" "card_code_status" DEFAULT 'AVAILABLE' NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"value" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"locked_at" timestamp with time zone,
|
||||||
|
"locked_by" integer,
|
||||||
|
"batch_id" varchar(64),
|
||||||
|
"note" text,
|
||||||
|
CONSTRAINT "card_codes_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ChangelogEntry" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"version" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"published" boolean DEFAULT false NOT NULL,
|
||||||
|
"publishedAt" timestamp,
|
||||||
|
"createdBy" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ExpectedPlayDate" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"date" text NOT NULL,
|
||||||
|
"availableSequences" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
CONSTRAINT "ExpectedPlayDate_date_unique" UNIQUE("date")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "GradeClass" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"grade" text NOT NULL,
|
||||||
|
"class" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
CONSTRAINT "grade_class_unique" UNIQUE("grade","class")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "oauth_providers" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"displayName" text,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"clientId" text,
|
||||||
|
"clientSecret" text,
|
||||||
|
"authorizationEndpoint" text,
|
||||||
|
"tokenEndpoint" text,
|
||||||
|
"userInfoEndpoint" text,
|
||||||
|
"scope" text,
|
||||||
|
"redirectUri" text,
|
||||||
|
"usernameClaim" text DEFAULT 'preferred_username',
|
||||||
|
"nameClaim" text DEFAULT 'name',
|
||||||
|
"emailClaim" text DEFAULT 'email',
|
||||||
|
"avatarClaim" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"sortOrder" integer DEFAULT 0 NOT NULL,
|
||||||
|
"icon" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "passkey_challenges" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"challenge" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "schedule_song_pool" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"song_id" integer NOT NULL,
|
||||||
|
"target_play_date" timestamp,
|
||||||
|
"priority" integer DEFAULT 0 NOT NULL,
|
||||||
|
"added_by" integer,
|
||||||
|
"note" text,
|
||||||
|
"status" text DEFAULT 'PENDING' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "SongComment" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"songId" integer NOT NULL,
|
||||||
|
"userId" integer NOT NULL,
|
||||||
|
"content" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "submission_restriction_exemptions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"exemption_type" text NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_by" integer,
|
||||||
|
CONSTRAINT "submission_restriction_exemptions_user_type_unique" UNIQUE("user_id","exemption_type")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_passkeys" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"public_key" text NOT NULL,
|
||||||
|
"credential_device_type" text NOT NULL,
|
||||||
|
"credential_backed_up" boolean NOT NULL,
|
||||||
|
"transports" text,
|
||||||
|
"counter" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_used_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "forcePasswordChange" SET DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "batch_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "source" text DEFAULT 'SYSTEM' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "sender_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "sender_name" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "sender_username" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "title" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "important" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "user_deleted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "replay_request_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD COLUMN "expectedPlayDate" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD COLUMN "expectedPlaySequence" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD COLUMN "durationSeconds" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD COLUMN "submissionNotePublicStatus" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD COLUMN "cardCodeId" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "siteVersion" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionNoteRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "instanceId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "telemetryEnabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "showBeianIcon" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysDisplay" integer DEFAULT 7 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysEditable" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysRequestable" integer DEFAULT 14 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysBeforeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysBefore" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysAfterEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "scheduleDaysAfter" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "forcePasswordChangeOnFirstLogin" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "enableSubmissionRestriction" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionScope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "sameSongRestrictionHours" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "sameArtistRestrictionHours" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeSubmissionRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeVoteEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "cardCodeReplayEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "enableCardCodeRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "requireCardCodeForRequests" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "enableCardCodeLimitBypass" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionGradeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionClassEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionGraduateEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "submissionRestrictionWithdrawnEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "captchaProvider" text DEFAULT 'TURNSTILE';--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "captchaEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSiteKey" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "turnstileSecretKey" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "allowRegister" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequireEmail" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequireEmailVerify" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerDefaultRole" text DEFAULT 'USER';--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerEnabledGradeClasses" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthRegisterRequiresApproval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerEmailRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "registerRequiresGradeClass" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowRegister" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowLogin" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthAllowBind" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "allowOAuthRegistration" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthRedirectUri" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthStateSecret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "oauthProviders" text DEFAULT '[]';--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "githubOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "githubClientId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "githubClientSecret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorServerUrl" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorClientId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorClientSecret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "casdoorOrganizationName" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "googleOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "googleClientId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "googleClientSecret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthAppId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthAppKey" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthLoginType" text DEFAULT 'qq';--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "aggregateOAuthEndpoint" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthDisplayName" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthAuthorizeUrl" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthTokenUrl" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUserInfoUrl" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthScope" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthClientId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthClientSecret" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUserIdField" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthUsernameField" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthNameField" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthEmailField" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "customOAuthAvatarField" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "captchaMaxFailures" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupIntervalHours" integer DEFAULT 24 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupRetentionDays" integer DEFAULT 30 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "autoBackupConfig" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCode" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "statisticsCodeEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "defaultTheme" text DEFAULT 'System' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "enabledThemes" text DEFAULT '["System","ClassicDark","ClassicLight","ModernLight"]' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "enabledPlatforms" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "SystemSettings" ADD COLUMN "platformOrder" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ADD COLUMN "studentId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ADD COLUMN "tokenVersion" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatarProvider" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatarProviderUserId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "User" ADD COLUMN "remark" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_card_code_id_card_codes_id_fk" FOREIGN KEY ("card_code_id") REFERENCES "public"."card_codes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "card_code_redemptions" ADD CONSTRAINT "card_code_redemptions_song_id_Song_id_fk" FOREIGN KEY ("song_id") REFERENCES "public"."Song"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule_song_pool" ADD CONSTRAINT "schedule_song_pool_song_id_Song_id_fk" FOREIGN KEY ("song_id") REFERENCES "public"."Song"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "submission_restriction_exemptions" ADD CONSTRAINT "submission_restriction_exemptions_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_passkeys" ADD CONSTRAINT "user_passkeys_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "auth_sessions_user_active_idx" ON "auth_sessions" USING btree ("user_id","is_revoked","expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "auth_sessions_expires_at_idx" ON "auth_sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "auth_sessions_last_active_idx" ON "auth_sessions" USING btree ("last_active_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "auth_sessions_session_token_unique" ON "auth_sessions" USING btree ("session_token");--> statement-breakpoint
|
||||||
|
CREATE INDEX "backup_records_created_at_idx" ON "backup_records" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "backup_records_status_idx" ON "backup_records" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "card_code_redemptions_user_id_idx" ON "card_code_redemptions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "card_code_redemptions_card_code_id_idx" ON "card_code_redemptions" USING btree ("card_code_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "card_codes_status_idx" ON "card_codes" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "card_codes_batch_id_idx" ON "card_codes" USING btree ("batch_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "card_codes_expires_at_idx" ON "card_codes" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "passkey_challenges_challenge_idx" ON "passkey_challenges" USING btree ("challenge");--> statement-breakpoint
|
||||||
|
CREATE INDEX "passkey_challenges_expires_at_idx" ON "passkey_challenges" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "schedule_song_pool_song_id_idx" ON "schedule_song_pool" USING btree ("song_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "schedule_song_pool_target_date_idx" ON "schedule_song_pool" USING btree ("target_play_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_restriction_exemptions_user_id_idx" ON "submission_restriction_exemptions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_passkeys_user_id_idx" ON "user_passkeys" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "Song" ADD CONSTRAINT "Song_cardCodeId_card_codes_id_fk" FOREIGN KEY ("cardCodeId") REFERENCES "public"."card_codes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "notification_user_important_read_created_idx" ON "Notification" USING btree ("userId","important","read","createdAt");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notification_batch_id_idx" ON "Notification" USING btree ("batch_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "schedule_published_song_idx" ON "Schedule" USING btree ("publishedAt","songId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "schedule_published_date_idx" ON "Schedule" USING btree ("publishedAt","playDate");--> statement-breakpoint
|
||||||
|
CREATE INDEX "song_card_code_id_idx" ON "Song" USING btree ("cardCodeId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "song_semester_created_at_idx" ON "Song" USING btree ("semester","createdAt");--> statement-breakpoint
|
||||||
|
CREATE INDEX "song_requester_id_idx" ON "Song" USING btree ("requesterId");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "User_username_unique" ON "User" USING btree ("username");--> statement-breakpoint
|
||||||
|
CREATE INDEX "vote_user_song_idx" ON "Vote" USING btree ("userId","songId");--> statement-breakpoint
|
||||||
|
ALTER TABLE "Vote" ADD CONSTRAINT "vote_song_user_unique" UNIQUE("songId","userId");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
|||||||
"when": 1776518400000,
|
"when": 1776518400000,
|
||||||
"tag": "20260418112000_add_song_comments",
|
"tag": "20260418112000_add_song_comments",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788684403309,
|
||||||
|
"tag": "20260906084643_auto-migration",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+63
-3
@@ -51,9 +51,16 @@ export const authSessions = pgTable('auth_sessions', {
|
|||||||
userAgent: text('user_agent'),
|
userAgent: text('user_agent'),
|
||||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
|
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
isRevoked: boolean('is_revoked').default(false).notNull(),
|
isRevoked: boolean('is_revoked').default(false).notNull(),
|
||||||
|
browser: text('browser'),
|
||||||
|
operatingSystem: text('operating_system'),
|
||||||
|
device: text('device'),
|
||||||
|
loginMethod: text('login_method').default('password').notNull(),
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
revokedReason: text('revoked_reason')
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('auth_sessions_user_id_idx').on(table.userId),
|
index('auth_sessions_user_active_idx').on(table.userId, table.isRevoked, table.expiresAt),
|
||||||
index('auth_sessions_expires_at_idx').on(table.expiresAt),
|
index('auth_sessions_expires_at_idx').on(table.expiresAt),
|
||||||
|
index('auth_sessions_last_active_idx').on(table.lastActiveAt),
|
||||||
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
|
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -258,16 +265,29 @@ export const systemSettings = pgTable('SystemSettings', {
|
|||||||
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
|
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
|
||||||
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
|
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
|
||||||
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
|
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
|
||||||
|
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
|
||||||
instanceId: text('instanceId'),
|
instanceId: text('instanceId'),
|
||||||
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
|
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
|
||||||
showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
|
showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
|
||||||
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
|
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
|
||||||
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
|
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
|
||||||
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
|
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
|
||||||
|
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
|
||||||
|
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
|
||||||
|
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
|
||||||
|
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
|
||||||
|
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
|
||||||
|
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
|
||||||
|
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
|
||||||
|
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
|
||||||
|
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
|
||||||
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
|
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
|
||||||
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
|
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
|
||||||
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
|
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
|
||||||
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
|
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
|
||||||
|
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
|
||||||
|
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
|
||||||
|
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
|
||||||
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
|
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
|
||||||
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
|
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
|
||||||
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
|
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
|
||||||
@@ -282,16 +302,56 @@ export const systemSettings = pgTable('SystemSettings', {
|
|||||||
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
|
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
|
||||||
registerDefaultRole: text('registerDefaultRole').default('USER'),
|
registerDefaultRole: text('registerDefaultRole').default('USER'),
|
||||||
registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
|
registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
|
||||||
|
registerRequiresApproval: boolean('registerRequiresApproval').default(false).notNull(),
|
||||||
|
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(false).notNull(),
|
||||||
|
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
|
||||||
|
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
|
||||||
oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
|
oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
|
||||||
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
|
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
|
||||||
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
|
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
|
||||||
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
|
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
|
||||||
|
allowOAuthRegistration: boolean('allowOAuthRegistration').default(false).notNull(),
|
||||||
|
oauthRedirectUri: text('oauthRedirectUri'),
|
||||||
|
oauthStateSecret: text('oauthStateSecret'),
|
||||||
|
oauthProviders: text('oauthProviders').default('[]'),
|
||||||
|
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
|
||||||
|
githubClientId: text('githubClientId'),
|
||||||
|
githubClientSecret: text('githubClientSecret'),
|
||||||
|
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
|
||||||
|
casdoorServerUrl: text('casdoorServerUrl'),
|
||||||
|
casdoorClientId: text('casdoorClientId'),
|
||||||
|
casdoorClientSecret: text('casdoorClientSecret'),
|
||||||
|
casdoorOrganizationName: text('casdoorOrganizationName'),
|
||||||
|
googleOAuthEnabled: boolean('googleOAuthEnabled').default(false).notNull(),
|
||||||
|
googleClientId: text('googleClientId'),
|
||||||
|
googleClientSecret: text('googleClientSecret'),
|
||||||
|
aggregateOAuthEnabled: boolean('aggregateOAuthEnabled').default(false).notNull(),
|
||||||
|
aggregateOAuthAppId: text('aggregateOAuthAppId'),
|
||||||
|
aggregateOAuthAppKey: text('aggregateOAuthAppKey'),
|
||||||
|
aggregateOAuthLoginType: text('aggregateOAuthLoginType').default('qq'),
|
||||||
|
aggregateOAuthEndpoint: text('aggregateOAuthEndpoint'),
|
||||||
|
customOAuthEnabled: boolean('customOAuthEnabled').default(false).notNull(),
|
||||||
|
customOAuthDisplayName: text('customOAuthDisplayName'),
|
||||||
|
customOAuthAuthorizeUrl: text('customOAuthAuthorizeUrl'),
|
||||||
|
customOAuthTokenUrl: text('customOAuthTokenUrl'),
|
||||||
|
customOAuthUserInfoUrl: text('customOAuthUserInfoUrl'),
|
||||||
|
customOAuthScope: text('customOAuthScope'),
|
||||||
|
customOAuthClientId: text('customOAuthClientId'),
|
||||||
|
customOAuthClientSecret: text('customOAuthClientSecret'),
|
||||||
|
customOAuthUserIdField: text('customOAuthUserIdField'),
|
||||||
|
customOAuthUsernameField: text('customOAuthUsernameField'),
|
||||||
|
customOAuthNameField: text('customOAuthNameField'),
|
||||||
|
customOAuthEmailField: text('customOAuthEmailField'),
|
||||||
|
customOAuthAvatarField: text('customOAuthAvatarField'),
|
||||||
|
captchaMaxFailures: integer('captchaMaxFailures').default(3).notNull(),
|
||||||
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
|
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
|
||||||
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
|
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
|
||||||
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
|
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
|
||||||
|
autoBackupConfig: text('autoBackupConfig'),
|
||||||
statisticsCode: text('statisticsCode'),
|
statisticsCode: text('statisticsCode'),
|
||||||
defaultTheme: text('defaultTheme').default('light'),
|
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
||||||
enabledThemes: text('enabledThemes').default('light,dark'),
|
defaultTheme: text('defaultTheme').default('System').notNull(),
|
||||||
|
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
|
||||||
enabledPlatforms: text('enabledPlatforms'),
|
enabledPlatforms: text('enabledPlatforms'),
|
||||||
platformOrder: text('platformOrder'),
|
platformOrder: text('platformOrder'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
...(process.env.NODE_ENV === 'development' || process.env.npm_lifecycle_event?.includes('lint')
|
...((process.env.NODE_ENV === 'development' || process.env.npm_lifecycle_event?.includes('lint'))
|
||||||
? ['@nuxt/eslint']
|
? ['@nuxt/eslint']
|
||||||
|
: []),
|
||||||
|
...((process.env.PWA_ENABLED || 'false') === 'true'
|
||||||
|
? ['@vite-pwa/nuxt']
|
||||||
: [])
|
: [])
|
||||||
],
|
],
|
||||||
|
|
||||||
|
pwa: (process.env.PWA_ENABLED || 'false') === 'true'
|
||||||
|
? {
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: process.env.NUXT_PUBLIC_SITE_TITLE || '校园广播站点歌系统',
|
||||||
|
short_name: process.env.NUXT_PUBLIC_SITE_TITLE || 'VoiceHub',
|
||||||
|
description: process.env.NUXT_PUBLIC_SITE_DESCRIPTION || '校园广播站点歌系统',
|
||||||
|
display: (process.env.PWA_DISPLAY as any) || 'standalone',
|
||||||
|
background_color: process.env.PWA_BACKGROUND_COLOR || '#000000',
|
||||||
|
theme_color: process.env.PWA_THEME_COLOR || '#111111',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/images/logo.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/logo.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/',
|
||||||
|
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico,webp,woff2}']
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
installPrompt: true,
|
||||||
|
periodicSyncForUpdates: 3600
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: process.env.NODE_ENV === 'development'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
|
||||||
// 引入全局CSS
|
// 引入全局CSS
|
||||||
css: [
|
css: [
|
||||||
'~/assets/css/variables.css',
|
'~/assets/css/variables.css',
|
||||||
@@ -150,6 +190,10 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
// 移动端优化
|
// 移动端优化
|
||||||
{ name: 'theme-color', content: '#111111' },
|
{ name: 'theme-color', content: '#111111' },
|
||||||
|
{
|
||||||
|
name: 'x-default-theme',
|
||||||
|
content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'System'
|
||||||
|
},
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
||||||
{ name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' },
|
{ name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' },
|
||||||
@@ -162,22 +206,25 @@ export default defineNuxtConfig({
|
|||||||
tagPosition: 'head',
|
tagPosition: 'head',
|
||||||
children: [
|
children: [
|
||||||
`(function(){try{`,
|
`(function(){try{`,
|
||||||
` var k='voicehub:theme', d=document, r=d.documentElement, s=null;`,
|
` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null;`,
|
||||||
` try{s=localStorage.getItem(k)}catch(e){s=null}`,
|
` try{s=localStorage.getItem(k)}catch(e){s=null}`,
|
||||||
` if(!s){`,
|
` if(!s){`,
|
||||||
` try{`,
|
` try{`,
|
||||||
` var meta=document.querySelector('meta[name="x-default-theme"]');`,
|
` var meta=document.querySelector('meta[name="x-default-theme"]');`,
|
||||||
` if(meta && meta.content){s=meta.content}`,
|
` if(meta && meta.content){s=meta.content}`,
|
||||||
` }catch(e){s=null}`,
|
` }catch(e){s=null}`,
|
||||||
` if(!s){s=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
|
` if(!s){s='System'}`,
|
||||||
` }`,
|
` }`,
|
||||||
` r.setAttribute('data-theme', s);`,
|
` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
|
||||||
` if(s==='dark'){r.classList.add('dark'); r.style.colorScheme='dark'}`,
|
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
|
||||||
` else if(s==='light'){r.classList.add('light'); r.style.colorScheme='light'}`,
|
` else{eff='dark'}`,
|
||||||
` else{r.style.colorScheme='light dark'}`,
|
` r.setAttribute('data-theme', (typeof s==='string'?s:eff));`,
|
||||||
|
` if(eff==='dark'){r.classList.add('dark'); r.classList.remove('light'); r.style.colorScheme='dark'}`,
|
||||||
|
` else if(eff==='light'){r.classList.add('light'); r.classList.remove('dark'); r.style.colorScheme='light'}`,
|
||||||
|
` else{r.classList.remove('dark','light'); r.style.colorScheme='light dark'}`,
|
||||||
` try{`,
|
` try{`,
|
||||||
` var tm=d.querySelector('meta[name="theme-color"]');`,
|
` var tm=d.querySelector('meta[name="theme-color"]');`,
|
||||||
` if(tm){tm.content=(s==='light'?'#f8fafc':'#111111')}`,
|
` if(tm){tm.content=(eff==='light'?'#f8fafc':'#111111')}`,
|
||||||
` }catch(e){}`,
|
` }catch(e){}`,
|
||||||
`}catch(e){}})();`
|
`}catch(e){}})();`
|
||||||
].join('')
|
].join('')
|
||||||
|
|||||||
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-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@sentry/node": "^10.73.0",
|
||||||
|
"@sentry/vue": "^10.73.0",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@simplewebauthn/server": "^13.3.3",
|
"@simplewebauthn/server": "^13.3.3",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@ungap/structured-clone": "^1.3.0",
|
"@ungap/structured-clone": "^1.3.0",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
|
"@vite-pwa/nuxt": "^1.1.1",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bezier-easing": "^2.1.0",
|
"bezier-easing": "^2.1.0",
|
||||||
@@ -105,6 +108,7 @@
|
|||||||
"node": ">=22.11.0"
|
"node": ">=22.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@clack/prompts": "^1.1.0",
|
||||||
"@nuxt/eslint": "^1.17.0",
|
"@nuxt/eslint": "^1.17.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
|
|||||||
@@ -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 { db, userIdentities, eq, and, users } from '~/drizzle/db'
|
||||||
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||||
import { getBeijingTime } from '~/utils/timeUtils'
|
import { getBeijingTime } from '~/utils/timeUtils'
|
||||||
|
|
||||||
@@ -100,7 +101,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
.catch((err) => console.error('Error updating user login info:', err))
|
.catch((err) => console.error('Error updating user login info:', err))
|
||||||
|
|
||||||
// 生成Token
|
// 生成Token
|
||||||
const authToken = JWTEnhanced.generateToken(user.id, user.role)
|
const { token: authToken, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||||
|
await createAuthSession(event, {
|
||||||
|
userId: user.id,
|
||||||
|
jti,
|
||||||
|
loginMethod: type === 'totp' ? 'totp_2fa' : 'email_2fa'
|
||||||
|
})
|
||||||
|
|
||||||
const isSecure =
|
const isSecure =
|
||||||
getRequestURL(event).protocol === 'https:' ||
|
getRequestURL(event).protocol === 'https:' ||
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { parseState, getRedirectUri } from '~~/server/utils/oauth'
|
|||||||
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
||||||
import { db, eq, userIdentities } from '~/drizzle/db'
|
import { db, eq, userIdentities } from '~/drizzle/db'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||||
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
|
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
|
||||||
|
|
||||||
@@ -142,7 +143,13 @@ async function handleUserLoginOrBind(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role)
|
const { token, jti } = JWTEnhanced.generateTokenWithJti(existingIdentity.user.id, existingIdentity.user.role)
|
||||||
|
await createAuthSession(event, {
|
||||||
|
userId: existingIdentity.user.id,
|
||||||
|
jti,
|
||||||
|
loginMethod: 'oauth',
|
||||||
|
oauthProvider: provider
|
||||||
|
})
|
||||||
setCookie(event, 'auth-token', token, {
|
setCookie(event, 'auth-token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { db, eq, users, userIdentities } from '~/drizzle/db'
|
import { db, eq, users, userIdentities } from '~/drizzle/db'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||||
import {
|
import {
|
||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
@@ -86,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
recordLoginSuccess(username, clientIp)
|
recordLoginSuccess(username, clientIp)
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||||
|
await createAuthSession(event, {
|
||||||
|
userId: user.id,
|
||||||
|
jti,
|
||||||
|
loginMethod: 'oauth',
|
||||||
|
oauthProvider: payload.provider
|
||||||
|
})
|
||||||
setCookie(event, 'auth-token', token, {
|
setCookie(event, 'auth-token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
|
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
import {
|
import {
|
||||||
getAccountLockRemainingTime,
|
getAccountLockRemainingTime,
|
||||||
getIPBlockRemainingTime,
|
getIPBlockRemainingTime,
|
||||||
@@ -187,7 +188,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
.catch((err) => console.error('Error updating user login info:', err))
|
.catch((err) => console.error('Error updating user login info:', err))
|
||||||
|
|
||||||
// 生成JWT
|
// 生成JWT
|
||||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||||
|
await createAuthSession(event, { userId: user.id, jti, loginMethod: 'password' })
|
||||||
|
|
||||||
// 自动判断是否需要secure
|
// 自动判断是否需要secure
|
||||||
const isSecure =
|
const isSecure =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { db, eq, users } from '~/drizzle/db'
|
import { db, eq, users } from '~/drizzle/db'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post'
|
import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -109,7 +110,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 签发 JWT Token
|
// 签发 JWT Token
|
||||||
const token = JWTEnhanced.generateToken(newUser.id, newUser.role)
|
const { token, jti } = JWTEnhanced.generateTokenWithJti(newUser.id, newUser.role)
|
||||||
|
await createAuthSession(event, { userId: newUser.id, jti, loginMethod: 'password' })
|
||||||
|
|
||||||
const isSecure =
|
const isSecure =
|
||||||
getRequestURL(event).protocol === 'https:' ||
|
getRequestURL(event).protocol === 'https:' ||
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from '~/drizzle/db'
|
|||||||
import { users } from '~/drizzle/schema'
|
import { users } from '~/drizzle/schema'
|
||||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { isSessionRevokedByJti, touchAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
|
|
||||||
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
|
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
|
||||||
|
|
||||||
@@ -19,9 +20,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证JWT令牌
|
// 验证JWT令牌
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number }
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number; jti?: string }
|
||||||
const userId = decoded.userId
|
const userId = decoded.userId
|
||||||
|
|
||||||
|
if (await isSessionRevokedByJti(decoded.jti)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: '会话已撤销,请重新登录'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
touchAuthSession(decoded.jti).catch(() => undefined)
|
||||||
|
|
||||||
// 优先从Redis缓存获取用户认证状态
|
// 优先从Redis缓存获取用户认证状态
|
||||||
if (isRedisReady()) {
|
if (isRedisReady()) {
|
||||||
const cachedUser = await executeRedisCommand(async () => {
|
const cachedUser = await executeRedisCommand(async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
|||||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||||
import { createError, defineEventHandler, setCookie } from 'h3'
|
import { createError, defineEventHandler, setCookie } from 'h3'
|
||||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||||
|
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
@@ -84,7 +85,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
.where(eq(userIdentities.id, identity.id))
|
.where(eq(userIdentities.id, identity.id))
|
||||||
|
|
||||||
// 签发登录 Token
|
// 签发登录 Token
|
||||||
const token = JWTEnhanced.generateToken(identity.user.id, identity.user.role)
|
const { token, jti } = JWTEnhanced.generateTokenWithJti(identity.user.id, identity.user.role)
|
||||||
|
await createAuthSession(event, { userId: identity.user.id, jti, loginMethod: 'webauthn' })
|
||||||
setCookie(event, 'auth-token', token, {
|
setCookie(event, 'auth-token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||||
import { db } from '~/drizzle/db'
|
import { db } from '~/drizzle/db'
|
||||||
import { playTimes, schedules, songCollaborators, songs, users } from '~/drizzle/schema'
|
import { playTimes, schedules, songCollaborators, songs, systemSettings, users } from '~/drizzle/schema'
|
||||||
import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm'
|
import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm'
|
||||||
import { formatDateTime } from '~/utils/timeUtils'
|
import { formatDateTime } from '~/utils/timeUtils'
|
||||||
import { openApiCache } from '~~/server/utils/open-api-cache'
|
import { openApiCache } from '~~/server/utils/open-api-cache'
|
||||||
import { CACHE_CONSTANTS } from '~~/server/config/constants'
|
import { CACHE_CONSTANTS } from '~~/server/config/constants'
|
||||||
import { cacheService } from '~~/server/services/cacheService'
|
import { cacheService } from '~~/server/services/cacheService'
|
||||||
import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis'
|
import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis'
|
||||||
|
import {
|
||||||
|
resolveScheduleVisibilityRange,
|
||||||
|
isPlayDateInRange,
|
||||||
|
type VisibilitySettingsLike
|
||||||
|
} from '~~/server/utils/schedule-visibility'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -36,6 +41,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
const sortBy = (query.sortBy as string) || 'playDate'
|
const sortBy = (query.sortBy as string) || 'playDate'
|
||||||
const sortOrder = (query.sortOrder as string) || 'asc'
|
const sortOrder = (query.sortOrder as string) || 'asc'
|
||||||
|
|
||||||
|
// 读取系统设置:排期可见范围
|
||||||
|
let visibilityRange = { startAt: null as Date | null, endAt: null as Date | null }
|
||||||
|
try {
|
||||||
|
const systemSettingsDataResult = await db.select().from(systemSettings).limit(1)
|
||||||
|
const systemSettingsData = systemSettingsDataResult[0] || null
|
||||||
|
const visibilitySettings: VisibilitySettingsLike = {
|
||||||
|
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
|
||||||
|
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
|
||||||
|
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
|
||||||
|
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
|
||||||
|
}
|
||||||
|
visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Schedules API] 读取排期可见范围设置失败,将展示全部:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyVisibilityFilter = (items: any[]): any[] => {
|
||||||
|
if (!visibilityRange.startAt && !visibilityRange.endAt) return items
|
||||||
|
return items.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
|
||||||
|
}
|
||||||
|
|
||||||
// 优先从普通API缓存获取数据
|
// 优先从普通API缓存获取数据
|
||||||
|
|
||||||
// 尝试从CacheService获取排期数据
|
// 尝试从CacheService获取排期数据
|
||||||
@@ -49,6 +75,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (semester) {
|
if (semester) {
|
||||||
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
||||||
}
|
}
|
||||||
|
filteredSchedules = applyVisibilityFilter(filteredSchedules)
|
||||||
|
|
||||||
// 转换为开放API格式
|
// 转换为开放API格式
|
||||||
const result = {
|
const result = {
|
||||||
@@ -105,11 +132,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (redisData) {
|
if (redisData) {
|
||||||
|
const filteredRedisData = applyVisibilityFilter(redisData)
|
||||||
// 转换为开放API格式
|
// 转换为开放API格式
|
||||||
const result = {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
schedules: redisData.map((schedule: any) => ({
|
schedules: filteredRedisData.map((schedule: any) => ({
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
playDate: schedule.playDate,
|
playDate: schedule.playDate,
|
||||||
sequence: schedule.sequence,
|
sequence: schedule.sequence,
|
||||||
@@ -133,7 +161,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
})),
|
})),
|
||||||
total: redisData.length,
|
total: filteredRedisData.length,
|
||||||
semester: semester || null
|
semester: semester || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +203,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!)
|
conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (visibilityRange.startAt) {
|
||||||
|
conditions.push(gte(schedules.playDate, visibilityRange.startAt)!)
|
||||||
|
}
|
||||||
|
if (visibilityRange.endAt) {
|
||||||
|
conditions.push(lt(schedules.playDate, visibilityRange.endAt)!)
|
||||||
|
}
|
||||||
|
|
||||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
// 计算偏移量
|
// 计算偏移量
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ const publicFields = [
|
|||||||
'enableSubmissionRemarks',
|
'enableSubmissionRemarks',
|
||||||
'enableRequestTimeLimitation',
|
'enableRequestTimeLimitation',
|
||||||
'forceBlockAllRequests',
|
'forceBlockAllRequests',
|
||||||
'smtpEnabled'
|
'smtpEnabled',
|
||||||
|
'defaultTheme',
|
||||||
|
'enabledThemes',
|
||||||
|
'scheduleDaysBeforeEnabled',
|
||||||
|
'scheduleDaysBefore',
|
||||||
|
'scheduleDaysAfterEnabled',
|
||||||
|
'scheduleDaysAfter'
|
||||||
]
|
]
|
||||||
|
|
||||||
const filterPublicSettings = (data: any) => {
|
const filterPublicSettings = (data: any) => {
|
||||||
@@ -81,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
enableRequestTimeLimitation: false,
|
enableRequestTimeLimitation: false,
|
||||||
forceBlockAllRequests: false,
|
forceBlockAllRequests: false,
|
||||||
enableReplayRequests: false,
|
enableReplayRequests: false,
|
||||||
smtpEnabled: false
|
smtpEnabled: false,
|
||||||
|
defaultTheme: 'System',
|
||||||
|
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
|
||||||
|
scheduleDaysBeforeEnabled: false,
|
||||||
|
scheduleDaysBefore: 30,
|
||||||
|
scheduleDaysAfterEnabled: false,
|
||||||
|
scheduleDaysAfter: 14
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { db } from '~/drizzle/db'
|
import { db } from '~/drizzle/db'
|
||||||
import { songs, users, songCollaborators, collaborationLogs } from '~/drizzle/schema'
|
import {
|
||||||
import { eq, or } from 'drizzle-orm'
|
songs,
|
||||||
|
users,
|
||||||
|
songCollaborators,
|
||||||
|
collaborationLogs,
|
||||||
|
systemSettings,
|
||||||
|
songBlacklists
|
||||||
|
} from '~/drizzle/schema'
|
||||||
|
import { and, eq, inArray, or } from 'drizzle-orm'
|
||||||
import { cacheService } from '~~/server/services/cacheService'
|
import { cacheService } from '~~/server/services/cacheService'
|
||||||
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
|
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
|
||||||
|
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +130,38 @@ export default defineEventHandler(async (event) => {
|
|||||||
? body.submissionNote.trim()
|
? body.submissionNote.trim()
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
if ('submissionNotePublic' in body) {
|
||||||
|
const wantPublic = updateData.submissionNote ? body.submissionNotePublic !== false : false
|
||||||
|
updateData.submissionNotePublic = wantPublic
|
||||||
|
|
||||||
|
// 自动审查(若 systemSettings 打开 submissionNoteRequiresApproval)
|
||||||
|
try {
|
||||||
|
const sysSetRes = await db.select().from(systemSettings).limit(1)
|
||||||
|
const sysSet = sysSetRes[0]
|
||||||
|
if (sysSet?.submissionNoteRequiresApproval && wantPublic && updateData.submissionNote) {
|
||||||
|
const bl = await db
|
||||||
|
.select()
|
||||||
|
.from(songBlacklists)
|
||||||
|
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
|
||||||
|
const mod = moderateSubmissionNote(
|
||||||
|
updateData.submissionNote,
|
||||||
|
wantPublic,
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
title: updateData.title || existingSong.title,
|
||||||
|
artist: updateData.artist || existingSong.artist
|
||||||
|
},
|
||||||
|
bl
|
||||||
|
)
|
||||||
|
updateData.submissionNotePublic = mod.notePublic
|
||||||
|
updateData.submissionNotePublicStatus = mod.publicStatus
|
||||||
|
} else {
|
||||||
|
updateData.submissionNotePublicStatus = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Update API] 投稿留言自动审查失败,保持用户设置:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentRequesterId = updateData.requesterId || existingSong.requesterId
|
const currentRequesterId = updateData.requesterId || existingSong.requesterId
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
users,
|
users,
|
||||||
votes
|
votes
|
||||||
} from '~/drizzle/schema'
|
} from '~/drizzle/schema'
|
||||||
import { and, count, desc, eq, inArray } from 'drizzle-orm'
|
import { and, count, desc, eq, gte, inArray, lt, like, or } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
resolveScheduleVisibilityRange,
|
||||||
|
isPlayDateInRange,
|
||||||
|
type VisibilitySettingsLike
|
||||||
|
} from '~~/server/utils/schedule-visibility'
|
||||||
import { cacheService } from '~~/server/services/cacheService'
|
import { cacheService } from '~~/server/services/cacheService'
|
||||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||||
import { formatDateTime } from '~/utils/timeUtils'
|
import { formatDateTime } from '~/utils/timeUtils'
|
||||||
@@ -38,6 +43,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
const systemSettingsData = systemSettingsDataResult[0] || null
|
const systemSettingsData = systemSettingsDataResult[0] || null
|
||||||
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
|
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
|
||||||
|
const visibilitySettings: VisibilitySettingsLike = {
|
||||||
|
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
|
||||||
|
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
|
||||||
|
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
|
||||||
|
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
|
||||||
|
}
|
||||||
|
const visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
|
||||||
|
|
||||||
// 初始化缓存服务
|
// 初始化缓存服务
|
||||||
|
|
||||||
@@ -73,7 +85,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return cachedData
|
const filtered = visibilityRange.startAt || visibilityRange.endAt
|
||||||
|
? cachedData.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
|
||||||
|
: cachedData
|
||||||
|
const resultData = JSON.parse(JSON.stringify(filtered)) as PublicScheduleItem[]
|
||||||
|
if (shouldHideStudentInfo && !isAdmin) {
|
||||||
|
maskPublicScheduleData(resultData)
|
||||||
|
}
|
||||||
|
return resultData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +110,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
||||||
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
|
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
|
||||||
}
|
}
|
||||||
|
// 应用排期可见范围过滤
|
||||||
|
if (visibilityRange.startAt || visibilityRange.endAt) {
|
||||||
|
filteredSchedules = filteredSchedules.filter((s) =>
|
||||||
|
isPlayDateInRange(s.playDate, visibilityRange)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
|
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
|
||||||
if (isRedisReady()) {
|
if (isRedisReady()) {
|
||||||
@@ -116,6 +141,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
|
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
|
||||||
|
const scheduleWhere: any[] = [eq(schedules.isDraft, false)]
|
||||||
|
if (visibilityRange.startAt) {
|
||||||
|
scheduleWhere.push(gte(schedules.playDate, visibilityRange.startAt)!)
|
||||||
|
}
|
||||||
|
if (visibilityRange.endAt) {
|
||||||
|
scheduleWhere.push(lt(schedules.playDate, visibilityRange.endAt)!)
|
||||||
|
}
|
||||||
const schedulesData = await db
|
const schedulesData = await db
|
||||||
.select({
|
.select({
|
||||||
id: schedules.id,
|
id: schedules.id,
|
||||||
@@ -153,10 +185,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||||
.where(eq(schedules.isDraft, false)) // 只查询已发布的排期
|
.where(and(...scheduleWhere)) // 只查询已发布的排期(+可见范围)
|
||||||
.orderBy(schedules.playDate)
|
.orderBy(schedules.playDate)
|
||||||
|
|
||||||
// 获取每首歌的投票数
|
|
||||||
const voteCountsQuery = await db
|
const voteCountsQuery = await db
|
||||||
.select({
|
.select({
|
||||||
songId: votes.songId,
|
songId: votes.songId,
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import {
|
|||||||
playTimes,
|
playTimes,
|
||||||
requestTimes,
|
requestTimes,
|
||||||
semesters,
|
semesters,
|
||||||
|
songBlacklists,
|
||||||
songCollaborators,
|
songCollaborators,
|
||||||
songs,
|
songs,
|
||||||
systemSettings
|
systemSettings
|
||||||
} from '~/drizzle/db'
|
} from '~/drizzle/db'
|
||||||
import { and, eq, gt, gte, lt, lte, sql } from 'drizzle-orm'
|
import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
|
||||||
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
|
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
|
||||||
import { isLimitReached } from '~~/server/utils/submissionLimit'
|
import { isLimitReached } from '~~/server/utils/submissionLimit'
|
||||||
import { getBeijingTimeISOString } from '~/utils/timeUtils'
|
import { getBeijingTimeISOString } from '~/utils/timeUtils'
|
||||||
|
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// 检查用户认证
|
// 检查用户认证
|
||||||
@@ -132,6 +134,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
const systemSettingsData = systemSettingsResult[0]
|
const systemSettingsData = systemSettingsResult[0]
|
||||||
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||||
|
|
||||||
|
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
|
||||||
|
let moderationBlacklists: any[] = []
|
||||||
|
try {
|
||||||
|
moderationBlacklists = await db
|
||||||
|
.select()
|
||||||
|
.from(songBlacklists)
|
||||||
|
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
|
||||||
|
moderationBlacklists = []
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 检查强制关闭
|
// 1. 检查强制关闭
|
||||||
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
|
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -243,8 +257,34 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
const submissionNote =
|
const submissionNote =
|
||||||
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
|
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
|
||||||
const submissionNotePublic =
|
const rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
|
||||||
submissionNote !== null ? body.submissionNotePublic !== false : false
|
const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
|
||||||
|
|
||||||
|
let finalSubmissionNotePublic = rawSubmissionNotePublic
|
||||||
|
let submissionNotePublicStatus: string | null = null
|
||||||
|
|
||||||
|
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
|
||||||
|
const mod = moderateSubmissionNote(
|
||||||
|
submissionNote,
|
||||||
|
rawSubmissionNotePublic,
|
||||||
|
requiresApproval,
|
||||||
|
{ title: body.title, artist: body.artist, genre: body.genre || null },
|
||||||
|
moderationBlacklists
|
||||||
|
)
|
||||||
|
finalSubmissionNotePublic = mod.notePublic
|
||||||
|
submissionNotePublicStatus = mod.publicStatus
|
||||||
|
if (mod.rejectedReasons.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
|
||||||
|
mod.rejectedReasons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (submissionNote) {
|
||||||
|
// 没开自动审查,也记录一下状态
|
||||||
|
submissionNotePublicStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionNotePublic = finalSubmissionNotePublic
|
||||||
|
|
||||||
// 准备发送通知的列表
|
// 准备发送通知的列表
|
||||||
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
|
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
|
||||||
@@ -367,6 +407,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
playUrl: body.playUrl || null,
|
playUrl: body.playUrl || null,
|
||||||
submissionNote,
|
submissionNote,
|
||||||
submissionNotePublic,
|
submissionNotePublic,
|
||||||
|
submissionNotePublicStatus,
|
||||||
hitRequestId: hitRequestTime?.id || null,
|
hitRequestId: hitRequestTime?.id || null,
|
||||||
expectedPlayDate: body.expectedPlayDate || null,
|
expectedPlayDate: body.expectedPlayDate || null,
|
||||||
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
|
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
|
||||||
|
|||||||
@@ -180,3 +180,13 @@ export const RISK_CONTROL_CONSTANTS = {
|
|||||||
SONG_IP_DOMINANCE_PERCENT: 0.6,
|
SONG_IP_DOMINANCE_PERCENT: 0.6,
|
||||||
SONG_IP_MIN_SAMPLE: 8
|
SONG_IP_MIN_SAMPLE: 8
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export const SERVER_ERROR_CODES = {
|
||||||
|
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
|
||||||
|
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
|
||||||
|
SONG_NOT_FOUND: 'SONG_NOT_FOUND',
|
||||||
|
SONG_OPERATION_FAILED: 'SONG_OPERATION_FAILED',
|
||||||
|
SONG_BATCH_REJECT_IDS_REQUIRED: 'SONG_BATCH_REJECT_IDS_REQUIRED',
|
||||||
|
SONG_BATCH_REJECT_LIMIT_EXCEEDED: 'SONG_BATCH_REJECT_LIMIT_EXCEEDED',
|
||||||
|
SONG_BATCH_REJECT_REASON_REQUIRED: 'SONG_BATCH_REJECT_REASON_REQUIRED'
|
||||||
|
} as const
|
||||||
|
|||||||
@@ -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 {
|
static generateToken(userId: number, role: string): string {
|
||||||
|
return this.generateTokenWithJti(userId, role).token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成JWT token 并返回 jti(会话表关联需要)
|
||||||
|
*/
|
||||||
|
static generateTokenWithJti(userId: number, role: string): { token: string; jti: string } {
|
||||||
if (!this.JWT_SECRET) {
|
if (!this.JWT_SECRET) {
|
||||||
throw new Error('JWT_SECRET environment variable is not set')
|
throw new Error('JWT_SECRET environment variable is not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一的JWT ID
|
|
||||||
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
// JWT载荷
|
|
||||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
jti
|
jti
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成token
|
|
||||||
const token = jwt.sign(payload, this.JWT_SECRET, {
|
const token = jwt.sign(payload, this.JWT_SECRET, {
|
||||||
expiresIn: this.TOKEN_EXPIRES
|
expiresIn: this.TOKEN_EXPIRES
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`Generated JWT token for user ${userId}`)
|
console.log(`Generated JWT token for user ${userId}`)
|
||||||
return token
|
return { token, jti }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { createError } from 'h3'
|
||||||
|
import { createHmac, randomUUID } from 'node:crypto'
|
||||||
|
import { db } from '~/drizzle/db'
|
||||||
|
import { systemSettings } from '~/drizzle/schema'
|
||||||
|
import { asc } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
isSafeAggregateOAuthUrl,
|
||||||
|
getAggregateOAuthLoginTypesOrDefault
|
||||||
|
} from './oauth-providers'
|
||||||
|
|
||||||
export interface OAuthUserInfo {
|
export interface OAuthUserInfo {
|
||||||
id: string
|
id: string
|
||||||
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
|
|||||||
google: googleStrategy
|
google: googleStrategy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAggregateSettings() {
|
||||||
|
const row = await db
|
||||||
|
.select({
|
||||||
|
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
|
||||||
|
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
|
||||||
|
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
|
||||||
|
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
|
||||||
|
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
|
||||||
|
})
|
||||||
|
.from(systemSettings)
|
||||||
|
.orderBy(asc(systemSettings.id))
|
||||||
|
.limit(1)
|
||||||
|
return row[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpoint(endpoint: string): string {
|
||||||
|
return endpoint.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateSign(params: Record<string, string>, appKey: string): string {
|
||||||
|
const sorted = Object.keys(params)
|
||||||
|
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${params[k]}`)
|
||||||
|
.join('&')
|
||||||
|
return createHmac('sha256', appKey).update(sorted).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregateStrategy: OAuthStrategy = {
|
||||||
|
async getAuthorizeUrl(redirectUri: string, state: string) {
|
||||||
|
const settings = await loadAggregateSettings()
|
||||||
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||||
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||||
|
}
|
||||||
|
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
|
||||||
|
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
||||||
|
const loginType = loginTypes[0] || 'qq'
|
||||||
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const nonce = randomUUID().replace(/-/g, '')
|
||||||
|
const baseParams: Record<string, string> = {
|
||||||
|
app_id: settings.aggregateOAuthAppId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state,
|
||||||
|
login_type: loginType,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||||
|
const query = new URLSearchParams({ ...baseParams, sign }).toString()
|
||||||
|
return `${endpoint}/oauth/authorize?${query}`
|
||||||
|
},
|
||||||
|
|
||||||
|
async exchangeToken(code: string, redirectUri: string) {
|
||||||
|
const settings = await loadAggregateSettings()
|
||||||
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||||
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||||
|
}
|
||||||
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const nonce = randomUUID().replace(/-/g, '')
|
||||||
|
const baseParams: Record<string, string> = {
|
||||||
|
app_id: settings.aggregateOAuthAppId,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...baseParams, sign },
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
})
|
||||||
|
if (tokenResponse.error) {
|
||||||
|
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
|
||||||
|
}
|
||||||
|
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('未能获取访问令牌')
|
||||||
|
}
|
||||||
|
return accessToken
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Aggregate OAuth token exchange failed', e)
|
||||||
|
throw new Error(e.message || '令牌请求失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserInfo(accessToken: string) {
|
||||||
|
const settings = await loadAggregateSettings()
|
||||||
|
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
|
||||||
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||||
|
}
|
||||||
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const nonce = randomUUID().replace(/-/g, '')
|
||||||
|
const baseParams: Record<string, string> = {
|
||||||
|
app_id: settings.aggregateOAuthAppId,
|
||||||
|
access_token: accessToken,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...baseParams, sign },
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
|
||||||
|
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
|
||||||
|
name: userInfo.name || userInfo.nickname || undefined,
|
||||||
|
email: userInfo.email || undefined,
|
||||||
|
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Aggregate OAuth user info failed', e)
|
||||||
|
throw new Error('获取用户信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strategies.aggregate = aggregateStrategy
|
||||||
|
|
||||||
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
||||||
const strategy = strategies[provider]
|
const strategy = strategies[provider]
|
||||||
if (!strategy) {
|
if (!strategy) {
|
||||||
|
|||||||
@@ -261,6 +261,12 @@ export const disconnectRedis = () => redisPool.disconnect()
|
|||||||
export const getRedisClient = () => redisPool.getClient()
|
export const getRedisClient = () => redisPool.getClient()
|
||||||
export const isRedisReady = () => redisPool.isReady()
|
export const isRedisReady = () => redisPool.isReady()
|
||||||
export const getRedisStats = () => redisPool.getStats()
|
export const getRedisStats = () => redisPool.getStats()
|
||||||
|
|
||||||
|
export const REDIS_KEY_PREFIX = 'voicehub'
|
||||||
|
|
||||||
|
export function buildRedisKey(...segments: string[]): string {
|
||||||
|
return [REDIS_KEY_PREFIX, ...segments].filter(Boolean).join(':')
|
||||||
|
}
|
||||||
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
|
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
|
||||||
redisPool.executeCommand(command, fallback)
|
redisPool.executeCommand(command, fallback)
|
||||||
|
|
||||||
|
|||||||
@@ -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