Files
BYW-VoiceHub/server/services/notificationService.ts
T
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

831 lines
23 KiB
TypeScript
Raw 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) {
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
}
}