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:
@@ -1,4 +1,12 @@
|
||||
import { createError } from 'h3'
|
||||
import { createHmac, randomUUID } from 'node:crypto'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { asc } from 'drizzle-orm'
|
||||
import {
|
||||
isSafeAggregateOAuthUrl,
|
||||
getAggregateOAuthLoginTypesOrDefault
|
||||
} from './oauth-providers'
|
||||
|
||||
export interface OAuthUserInfo {
|
||||
id: string
|
||||
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
|
||||
google: googleStrategy
|
||||
}
|
||||
|
||||
async function loadAggregateSettings() {
|
||||
const row = await db
|
||||
.select({
|
||||
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
|
||||
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
|
||||
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
|
||||
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
|
||||
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
|
||||
})
|
||||
.from(systemSettings)
|
||||
.orderBy(asc(systemSettings.id))
|
||||
.limit(1)
|
||||
return row[0] ?? null
|
||||
}
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
return endpoint.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function aggregateSign(params: Record<string, string>, appKey: string): string {
|
||||
const sorted = Object.keys(params)
|
||||
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
|
||||
.sort()
|
||||
.map((k) => `${k}=${params[k]}`)
|
||||
.join('&')
|
||||
return createHmac('sha256', appKey).update(sorted).digest('hex')
|
||||
}
|
||||
|
||||
const aggregateStrategy: OAuthStrategy = {
|
||||
async getAuthorizeUrl(redirectUri: string, state: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
|
||||
}
|
||||
|
||||
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
||||
const loginType = loginTypes[0] || 'qq'
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
login_type: loginType,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
const query = new URLSearchParams({ ...baseParams, sign }).toString()
|
||||
return `${endpoint}/oauth/authorize?${query}`
|
||||
},
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
|
||||
try {
|
||||
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: { ...baseParams, sign },
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
if (tokenResponse.error) {
|
||||
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
|
||||
}
|
||||
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('未能获取访问令牌')
|
||||
}
|
||||
return accessToken
|
||||
} catch (e: any) {
|
||||
console.error('Aggregate OAuth token exchange failed', e)
|
||||
throw new Error(e.message || '令牌请求失败')
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo(accessToken: string) {
|
||||
const settings = await loadAggregateSettings()
|
||||
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
|
||||
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
||||
}
|
||||
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = randomUUID().replace(/-/g, '')
|
||||
const baseParams: Record<string, string> = {
|
||||
app_id: settings.aggregateOAuthAppId,
|
||||
access_token: accessToken,
|
||||
timestamp,
|
||||
nonce
|
||||
}
|
||||
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
||||
|
||||
try {
|
||||
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
|
||||
method: 'POST',
|
||||
body: { ...baseParams, sign },
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
return {
|
||||
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
|
||||
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
|
||||
name: userInfo.name || userInfo.nickname || undefined,
|
||||
email: userInfo.email || undefined,
|
||||
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Aggregate OAuth user info failed', e)
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strategies.aggregate = aggregateStrategy
|
||||
|
||||
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
||||
const strategy = strategies[provider]
|
||||
if (!strategy) {
|
||||
|
||||
Reference in New Issue
Block a user