130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { and, eq, sql } from 'drizzle-orm'
|
|
import { schedules, songReplayRequests } from '~/drizzle/schema'
|
|
|
|
interface FulfillReplayRequestsParams {
|
|
tx: any
|
|
songId: number
|
|
scheduleId: number
|
|
at: Date
|
|
/** 草稿排期显式绑定的申请 ID;未提供或已失效时回退到最早的待处理申请 */
|
|
preferredRequestId?: number | null
|
|
/** 重发排期前的旧绑定;无任何待处理申请时回写该绑定,避免重播标识丢失 */
|
|
fallbackRequestId?: number | null
|
|
}
|
|
|
|
interface FulfilledReplayRequests {
|
|
/** 排期最终绑定的申请 ID */
|
|
replayRequestId: number
|
|
/** 本次被履行的全部申请人(去重),用于发送通知 */
|
|
replayRequesterIds: number[]
|
|
}
|
|
|
|
interface RestoreReplayRequestsParams {
|
|
tx: any
|
|
songIds: number[]
|
|
at: Date
|
|
}
|
|
|
|
/**
|
|
* 发布排期时履行该歌曲的全部待处理重播申请,并把排期绑定到其中一条申请上。
|
|
* 歌曲被安排播放即视为所有申请人的诉求都已满足,避免残留永远无人处理的 PENDING 记录。
|
|
* 采用单条 UPDATE + RETURNING,避免先查后改窗口内新增的申请被履行却漏发通知。
|
|
* 若没有任何待处理申请:优先回写 fallbackRequestId(重发排期场景),否则清除失效绑定并返回 null。
|
|
*/
|
|
export async function fulfillReplayRequestsForSchedule({
|
|
tx,
|
|
songId,
|
|
scheduleId,
|
|
at,
|
|
preferredRequestId,
|
|
fallbackRequestId
|
|
}: FulfillReplayRequestsParams): Promise<FulfilledReplayRequests | null> {
|
|
const fulfilledRequests: Array<{ id: number; userId: number; createdAt: Date }> = await tx
|
|
.update(songReplayRequests)
|
|
.set({
|
|
status: 'FULFILLED',
|
|
updatedAt: at
|
|
})
|
|
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.status, 'PENDING')))
|
|
.returning({
|
|
id: songReplayRequests.id,
|
|
userId: songReplayRequests.userId,
|
|
createdAt: songReplayRequests.createdAt
|
|
})
|
|
|
|
// 按先到先得排序,便于回退绑定最早的申请
|
|
fulfilledRequests.sort(
|
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
)
|
|
const earliestRequest = fulfilledRequests[0]
|
|
if (!earliestRequest) {
|
|
// 无待处理申请:重发排期时保留原绑定,否则清除草稿残留的失效绑定
|
|
const restoredBinding = fallbackRequestId || null
|
|
await tx
|
|
.update(schedules)
|
|
.set({ replayRequestId: restoredBinding, updatedAt: at })
|
|
.where(eq(schedules.id, scheduleId))
|
|
return restoredBinding ? { replayRequestId: restoredBinding, replayRequesterIds: [] } : null
|
|
}
|
|
|
|
// 优先使用草稿显式绑定的申请,否则绑定最早的待处理申请
|
|
const boundRequest =
|
|
(preferredRequestId &&
|
|
fulfilledRequests.find((request) => request.id === preferredRequestId)) ||
|
|
earliestRequest
|
|
|
|
await tx
|
|
.update(schedules)
|
|
.set({
|
|
replayRequestId: boundRequest.id,
|
|
updatedAt: at
|
|
})
|
|
.where(eq(schedules.id, scheduleId))
|
|
|
|
return {
|
|
replayRequestId: boundRequest.id,
|
|
replayRequesterIds: [...new Set(fulfilledRequests.map((request) => request.userId))]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 排期被删除或歌曲撤销已播放后,把重播申请恢复为待处理。
|
|
* insert-only 模型下同一 (song_id, user_id) 可能累积多条已履行记录,
|
|
* 仅恢复每人最新一条,且该用户已有待处理申请时跳过,避免违反部分唯一索引。
|
|
* 返回实际恢复的申请数量。
|
|
*/
|
|
export async function restoreReplayRequestsToPending({
|
|
tx,
|
|
songIds,
|
|
at
|
|
}: RestoreReplayRequestsParams): Promise<number> {
|
|
const validSongIds = songIds.filter((id) => Number.isInteger(id) && id > 0)
|
|
if (validSongIds.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
// drizzle 原生 SQL 模板不能序列化 JS 数组和 Date(列为 timestamp 无时区),逐个参数化并传 ISO 字符串
|
|
const songIdList = sql.join(
|
|
validSongIds.map((id) => sql`${id}`),
|
|
sql`, `
|
|
)
|
|
|
|
const restored = await tx.execute(sql`
|
|
UPDATE song_replay_requests AS r
|
|
SET status = 'PENDING', updated_at = ${at.toISOString()}
|
|
WHERE r.id IN (
|
|
SELECT DISTINCT ON (song_id, user_id) id
|
|
FROM song_replay_requests
|
|
WHERE song_id IN (${songIdList}) AND status = 'FULFILLED'
|
|
ORDER BY song_id, user_id, created_at DESC
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM song_replay_requests p
|
|
WHERE p.song_id = r.song_id AND p.user_id = r.user_id AND p.status = 'PENDING'
|
|
)
|
|
RETURNING r.id
|
|
`)
|
|
|
|
return Array.isArray(restored) ? restored.length : Number(restored?.count || 0)
|
|
}
|