Files
Henry_Du 308a2103ac fix(通知): 微信订阅消息的每条跳过路径都写日志,不再静默
以前 sendWechatSubscribeToUser 的四条提前返回(no-template / disabled-by-user /
no-wechat-openid / no-access-token)都不写日志,线上排查「通知没发出去」时
容器日志里一片空白,只能靠猜。现在每条都打一行 [WeChatSubscribe] ... 跳过:原因,
另外 createSongSelectedNotification 在「用户关了通知总开关」时也补了一行。

用途:发布一首新歌后执行 docker logs --timestamps voicehub-app | grep WeChatSubscribe
即可直接看到卡在哪一步(模板没配 / 用户没绑微信 / 拿不到 token / 没有额度 43101 / 字段 47003)。
2026-10-11 15:16:54 +08:00

846 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '~/drizzle/db'
import {
notifications,
notificationSettings,
playTimes,
schedules,
songCollaborators,
songs,
systemSettings,
users,
votes
} from '~/drizzle/schema'
import { and, eq, gte, inArray } from 'drizzle-orm'
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
import { buildWechatTimeValue, sendWechatSubscribeToUser } from './wechatSubscribeService'
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
/**
* 创建联合投稿邀请通知
*/
export async function createCollaborationInvitationNotification(
inviterId: number,
inviteeId: number,
songId: number,
songTitle: string
) {
try {
const inviter = await db
.select()
.from(users)
.where(eq(users.id, inviterId))
.limit(1)
.then((res) => res[0])
const message = `用户 ${inviter?.name || '未知用户'} 邀请您共同投稿歌曲《${songTitle}》。`
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: inviteeId,
type: 'COLLABORATION_INVITE',
message,
songId
})
.returning()
// 发送外部通知(MeoW, Email等)
try {
await sendMeowNotificationToUser(inviteeId, '收到联合投稿邀请', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
try {
await sendEmailNotificationToUser(
inviteeId,
'收到联合投稿邀请',
message,
undefined,
'notification.collaborationInvite',
{
inviterName: inviter?.name || '未知用户',
songTitle: songTitle
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notificationResult[0]
} catch (error) {
console.error('创建联合投稿邀请通知失败:', error)
return null
}
}
/**
* 创建联合投稿回复通知
*/
export async function createCollaborationResponseNotification(
inviteeId: number,
inviterId: number,
songTitle: string,
accepted: boolean
) {
try {
const invitee = await db
.select()
.from(users)
.where(eq(users.id, inviteeId))
.limit(1)
.then((res) => res[0])
const actionText = accepted ? '接受' : '拒绝'
const message = `用户 ${invitee?.name || '未知用户'} ${actionText}了您的歌曲《${songTitle}》的联合投稿邀请。`
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: inviterId,
type: 'COLLABORATION_RESPONSE',
message
// songId // 这里可能不需要songId,或者需要传进来
})
.returning()
return notificationResult[0]
} catch (error) {
console.error('创建联合投稿回复通知失败:', error)
return null
}
}
/**
* 创建歌曲被选中的通知
*/
export async function createSongSelectedNotification(
userId: number,
songId: number,
songInfo: {
title: string
artist: string
playDate: Date
/** 排期在该时段里的序号(微信「歌曲排期」模板的排期序号字段用) */
sequence?: number | null
},
ipAddress?: string
) {
try {
// 获取系统设置,检查是否启用播出时段功能
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
const systemConfig = systemSettingsResult[0]
const isPlayTimeEnabled = systemConfig?.enablePlayTimeSelection || false
// 获取排期对应的播出时段
const scheduleResult = await db
.select({
id: schedules.id,
songId: schedules.songId,
playDate: schedules.playDate,
playTimeId: schedules.playTimeId,
playTime: {
id: playTimes.id,
name: playTimes.name,
startTime: playTimes.startTime,
endTime: playTimes.endTime
}
})
.from(schedules)
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(and(eq(schedules.songId, songId), eq(schedules.playDate, songInfo.playDate)))
.limit(1)
const schedule = scheduleResult[0]
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.enabled) {
console.warn(
`[Notification] SONG_SELECTED → user ${userId} 跳过:该用户关闭了通知总开关,微信/MeoW/邮件都不会发`
)
return null
}
// 创建通知,根据播出时段功能启用状态决定显示内容
let message = `您投稿的歌曲《${songInfo.title}》已被安排播放,播放日期:${formatDate(songInfo.playDate)}。`
// 只有在启用播出时段功能且有播出时段信息时,才添加播出时段详情
if (isPlayTimeEnabled && schedule?.playTime) {
let timeInfo = ''
// 根据开始和结束时间的情况,格式化显示
if (schedule.playTime.startTime && schedule.playTime.endTime) {
timeInfo = `(${schedule.playTime.startTime}-${schedule.playTime.endTime})`
} else if (schedule.playTime.startTime) {
timeInfo = `(开始时间:${schedule.playTime.startTime})`
} else if (schedule.playTime.endTime) {
timeInfo = `(结束时间:${schedule.playTime.endTime})`
}
message += `播出时段:${schedule.playTime.name}${timeInfo ? ' ' + timeInfo : ''}。`
}
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'SONG_SELECTED',
message,
songId
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '收到新选中', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
/*
* 微信模板字段比站内通知挑数据:thing/date/time/character_string 各有格式要求,
* 空值或格式不对会让**整条**订阅消息被拒(47003),所以这里把每个字段都算好再传。
*/
const playTime = isPlayTimeEnabled && schedule?.playTime ? schedule.playTime : null
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', {
songTitle: songInfo.title,
// date 型:'YYYY-MM-DD'
playDate: formatDate(songInfo.playDate),
// time 型:'YYYY-MM-DD HH:mm',有排期时段就取它的开始时间('12:30-13:00' 也能解析)
playTime: buildWechatTimeValue(songInfo.playDate, playTime?.startTime || playTime?.endTime),
// phrase 型:5 个以内纯汉字;“已排期”只有 3 个字,clampField 会补全角空格
status: '已排期',
// character_string 型:只允许数字/字母,没有序号时不传(避免空值 47003)
sequence: songInfo.sequence == null ? undefined : String(songInfo.sequence),
message
})
} catch (error) {
console.error('发送微信订阅消息失败:', error)
}
// 同步发送 邮件通知
try {
await sendEmailNotificationToUser(
userId,
'歌曲被选中',
message,
ipAddress,
'notification.songSelected',
{
songTitle: songInfo.title,
playDate: formatDate(songInfo.playDate),
playTimeName: isPlayTimeEnabled && schedule?.playTime ? schedule.playTime.name : '',
playTimeRange:
isPlayTimeEnabled &&
schedule?.playTime &&
(schedule.playTime.startTime || schedule.playTime.endTime)
? `${schedule.playTime.startTime || ''}${schedule.playTime.startTime && schedule.playTime.endTime ? '-' : ''}${schedule.playTime.endTime || ''}`
: ''
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
// 格式化日期为 yyyy-MM-dd 格式(北京时间)
function formatDate(date: Date): string {
return formatDateTime(date, 'YYYY-MM-DD')
}
/**
* 创建歌曲已播放的通知
*/
export async function createSongPlayedNotification(songId: number, ipAddress?: string) {
try {
// 获取歌曲信息
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const song = songResult[0]
if (!song) {
return null
}
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, song.requesterId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songPlayedEnabled) {
return null
}
// 创建通知
const message = `您投稿的歌曲《${song.title}》已播放。`
// 获取所有关联用户(投稿人 + 联合投稿人)
const userIdsToNotify = [song.requesterId]
// 获取联合投稿人
const collaborators = await db
.select()
.from(songCollaborators)
.where(and(eq(songCollaborators.songId, songId), eq(songCollaborators.status, 'ACCEPTED')))
collaborators.forEach((c) => {
if (!userIdsToNotify.includes(c.userId)) {
userIdsToNotify.push(c.userId)
}
})
const notificationsCreated = []
for (const targetUserId of userIdsToNotify) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, targetUserId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songPlayedEnabled) {
continue
}
const userMessage =
targetUserId === song.requesterId
? message
: `您参与联合投稿的歌曲《${song.title}》已播放。`
const notificationResult = await db
.insert(notifications)
.values({
userId: targetUserId,
type: 'SONG_PLAYED',
message: userMessage,
songId: songId
})
.returning()
notificationsCreated.push(notificationResult[0])
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(targetUserId, '歌曲已播放', userMessage)
} catch (error) {
console.error(`发送 MeoW 通知失败 (User: ${targetUserId}):`, error)
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
await sendWechatSubscribeToUser(targetUserId, 'SONG_PLAYED', {
songTitle: song.title,
message: userMessage
})
} catch (error) {
console.error('发送微信订阅消息失败:', error)
}
// 同步发送 邮件通知
try {
await sendEmailNotificationToUser(
targetUserId,
'歌曲已播放',
userMessage,
ipAddress,
'notification.songPlayed',
{ songTitle: song.title }
)
} catch (error) {
console.error(`发送邮件通知失败 (User: ${targetUserId}):`, error)
}
} catch (err) {
console.error(`处理播放通知失败 (User: ${targetUserId}):`, err)
}
}
return notificationsCreated.length > 0 ? notificationsCreated[0] : null
} catch (err) {
return null
}
}
/**
* 创建歌曲获得投票的通知
*/
export async function createSongVotedNotification(
songId: number,
voterId: number,
ipAddress?: string
) {
try {
// 获取歌曲信息
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const song = songResult[0]
if (!song) {
return null
}
// 获取歌曲的投票信息
const songVotes = await db.select().from(votes).where(eq(votes.songId, songId))
// 获取投票用户信息
const voterResult = await db.select().from(users).where(eq(users.id, voterId)).limit(1)
const voter = voterResult[0]
if (!voter) {
return null
}
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, song.requesterId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songVotedEnabled) {
return null
}
// 不要给自己投票发通知
if (song.requesterId === voterId) {
return null
}
// 检查投票阈值
const threshold = settings?.songVotedThreshold || 1
if (songVotes.length % threshold !== 0) {
return null
}
const message = `您投稿的歌曲《${song.title}》获得了一个新的投票,当前共有 ${songVotes.length} 个投票。`
// 防重复通知:检查最近5分钟内是否有相同歌曲的投票通知
const fiveMinutesAgo = new Date(getBeijingTime().getTime() - 5 * 60 * 1000)
const existingNotificationResult = await db
.select()
.from(notifications)
.where(
and(
eq(notifications.userId, song.requesterId),
eq(notifications.type, 'SONG_VOTED'),
eq(notifications.songId, songId),
eq(notifications.read, false),
gte(notifications.createdAt, fiveMinutesAgo)
)
)
.orderBy(notifications.createdAt)
.limit(1)
const existingNotification = existingNotificationResult[0]
let notification
if (existingNotification) {
// 更新现有通知
const updateResult = await db
.update(notifications)
.set({
message
})
.where(eq(notifications.id, existingNotification.id))
.returning()
notification = updateResult[0]
} else {
// 创建新通知
const createResult = await db
.insert(notifications)
.values({
userId: song.requesterId,
type: 'SONG_VOTED',
message,
songId: songId
})
.returning()
notification = createResult[0]
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(song.requesterId, '收到新投票', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送 邮件通知
try {
await sendEmailNotificationToUser(
song.requesterId,
'收到新投票',
message,
ipAddress,
'notification.songVoted',
{ songTitle: song.title, votesCount: songVotes.length }
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 创建歌曲驳回通知
*/
export async function createSongRejectedNotification(
userId: number,
songInfo: { title: string; artist: string },
reason: string,
ipAddress?: string
) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知消息
const message = `您投稿的歌曲《${songInfo.title} - ${songInfo.artist}》已被管理员驳回。驳回原因:${reason}`
// 创建站内通知
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'SONG_REJECTED',
message
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '歌曲被驳回', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
await sendWechatSubscribeToUser(userId, 'SONG_REJECTED', {
songTitle: songInfo.title,
reason: reason,
message
})
} catch (error) {
console.error('发送微信订阅消息失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
userId,
'歌曲被驳回',
message,
ipAddress,
'notification.songRejected',
{
songTitle: songInfo.title,
songArtist: songInfo.artist,
reason: reason
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
console.error('创建歌曲驳回通知失败:', err)
return null
}
}
export async function createSubmissionNoteClearedNotification(
userIds: number[],
songInfo: { title: string; artist: string },
reason?: string,
ipAddress?: string
) {
try {
const uniqueUserIds = [
...new Set(userIds.filter((userId) => Number.isInteger(userId) && userId > 0))
]
if (uniqueUserIds.length === 0) {
return []
}
const title = '歌曲备注已被管理员清空'
const content = reason?.trim()
? `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。原因:${reason.trim()}`
: `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。`
return await createBatchSystemNotifications(uniqueUserIds, title, content, ipAddress)
} catch (error) {
console.error('创建歌曲备注清空通知失败:', error)
return []
}
}
/**
* 创建系统通知
*/
export async function createSystemNotification(
userId: number,
title: string,
content: string,
ipAddress?: string
) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: userId,
type: 'SYSTEM_NOTICE',
message: content
})
.returning()
const notification = notificationResult[0]
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, title, content)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
await sendWechatSubscribeToUser(userId, 'SYSTEM_NOTICE', {
songTitle: title,
message: content
})
} catch (error) {
console.error('发送微信订阅消息失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
userId,
title,
content,
undefined,
undefined,
undefined,
ipAddress
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 批量创建系统通知
*/
export async function createBatchSystemNotifications(
userIds: number[],
title: string,
content: string,
ipAddress?: string
) {
try {
if (!userIds.length) {
return []
}
// 获取用户通知设置
const userSettings = await db
.select()
.from(notificationSettings)
.where(inArray(notificationSettings.userId, userIds))
// 构建用户ID到通知设置的映射
const settingsMap = new Map()
userSettings.forEach((setting) => {
settingsMap.set(setting.userId, setting)
})
// 准备要创建的通知数据
const notificationsToCreate = []
for (const userId of userIds) {
const settings = settingsMap.get(userId)
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
continue
}
notificationsToCreate.push({
userId,
type: 'SYSTEM_NOTICE',
message: content
})
}
// 如果没有可发送的通知,直接返回
if (notificationsToCreate.length === 0) {
return []
}
// 批量创建通知
const createdNotifications = await db
.insert(notifications)
.values(notificationsToCreate)
.returning()
const notificationCount = { count: createdNotifications.length }
// 同步发送 MeoW 通知
let meowResults = { success: 0, failed: 0 }
try {
meowResults = await sendBatchMeowNotifications(userIds, title, content)
} catch (error) {
console.error('批量发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
let emailResults = { success: 0, failed: 0 }
try {
emailResults = await sendBatchEmailNotifications(
userIds,
title,
content,
undefined,
undefined,
ipAddress
)
} catch (error) {
console.error('批量发送邮件通知失败:', error)
}
return {
count: notificationCount.count,
total: userIds.length,
meowNotifications: meowResults,
emailNotifications: emailResults
}
} catch (err) {
return null
}
}
/**
* 创建重播申请拒绝通知
*/
export async function createReplayRequestRejectedNotification(
userId: number,
songInfo: { title: string; artist: string },
ipAddress?: string
) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知消息
const message = `您的重播申请《${songInfo.title}》已被管理员拒绝。`
// 创建站内通知
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'REPLAY_REJECTED',
message
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '重播申请已拒绝', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(userId, '重播申请已拒绝', message, ipAddress)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
console.error('创建重播申请拒绝通知失败:', err)
return null
}
}