订阅消息(小程序侧唯一能主动触达用户的通道): - 新增 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 变更后老用户需重新授权。
294 lines
9.2 KiB
TypeScript
294 lines
9.2 KiB
TypeScript
import { db } from '~/drizzle/db'
|
||
import { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
|
||
import { and, asc, count, eq } from 'drizzle-orm'
|
||
import { createSongSelectedNotification } from '~~/server/services/notificationService'
|
||
import { cacheService } from '~~/server/services/cacheService'
|
||
import { getBeijingTimestamp } from '~/utils/timeUtils'
|
||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
// 检查用户认证和权限
|
||
const user = event.context.user
|
||
|
||
if (!user) {
|
||
throw createError({
|
||
statusCode: 401,
|
||
message: '未授权访问'
|
||
})
|
||
}
|
||
|
||
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||
throw createError({
|
||
statusCode: 403,
|
||
message: '只有歌曲管理员及以上权限才能发布排期'
|
||
})
|
||
}
|
||
|
||
const body = await readBody(event)
|
||
|
||
if (!body.scheduleId) {
|
||
throw createError({
|
||
statusCode: 400,
|
||
message: '排期ID不能为空'
|
||
})
|
||
}
|
||
|
||
try {
|
||
// 检查草稿是否存在
|
||
const draftResult = await db
|
||
.select({
|
||
id: schedules.id,
|
||
songId: schedules.songId,
|
||
playDate: schedules.playDate,
|
||
sequence: schedules.sequence,
|
||
playTimeId: schedules.playTimeId,
|
||
isDraft: schedules.isDraft,
|
||
publishedAt: schedules.publishedAt,
|
||
song: {
|
||
id: songs.id,
|
||
title: songs.title,
|
||
artist: songs.artist,
|
||
requesterId: songs.requesterId
|
||
}
|
||
})
|
||
.from(schedules)
|
||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||
.where(
|
||
and(
|
||
eq(schedules.id, body.scheduleId),
|
||
eq(schedules.isDraft, true) // 确保是草稿状态
|
||
)
|
||
)
|
||
.limit(1)
|
||
|
||
const draft = draftResult[0]
|
||
|
||
if (!draft) {
|
||
throw createError({
|
||
statusCode: 404,
|
||
message: '找不到指定的排期草稿'
|
||
})
|
||
}
|
||
|
||
// 更新草稿为已发布状态
|
||
const publishedAt = new Date(getBeijingTimestamp())
|
||
|
||
const publishResult = await db
|
||
.update(schedules)
|
||
.set({
|
||
isDraft: false,
|
||
publishedAt: publishedAt,
|
||
updatedAt: publishedAt
|
||
})
|
||
.where(eq(schedules.id, body.scheduleId))
|
||
.returning()
|
||
|
||
const publishedSchedule = publishResult[0]
|
||
|
||
if (!publishedSchedule) {
|
||
throw createError({
|
||
statusCode: 500,
|
||
message: '发布排期失败'
|
||
})
|
||
}
|
||
|
||
// 获取客户端IP地址
|
||
const clientIP = getClientIP(event)
|
||
|
||
// 发布后发送通知(这是与草稿保存的主要区别)
|
||
await createSongSelectedNotification(
|
||
draft.song.requesterId,
|
||
draft.song.id,
|
||
{
|
||
title: draft.song.title,
|
||
artist: draft.song.artist,
|
||
playDate: draft.playDate,
|
||
// 微信「歌曲排期」订阅消息的排期序号字段要它
|
||
sequence: draft.sequence
|
||
},
|
||
clientIP
|
||
)
|
||
|
||
// 将该歌曲的所有待处理重播申请标记为已完成
|
||
const updatedRequests = await db
|
||
.update(songReplayRequests)
|
||
.set({
|
||
status: 'FULFILLED',
|
||
updatedAt: new Date()
|
||
})
|
||
.where(
|
||
and(eq(songReplayRequests.songId, draft.song.id), eq(songReplayRequests.status, 'PENDING'))
|
||
)
|
||
.returning()
|
||
|
||
if (updatedRequests.length > 0) {
|
||
console.log(`发布排期:将 ${updatedRequests.length} 个重播申请标记为 FULFILLED`)
|
||
}
|
||
|
||
// 清除相关缓存
|
||
try {
|
||
await cacheService.clearSchedulesCache()
|
||
await cacheService.clearSongsCache()
|
||
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(发布排期)')
|
||
} catch (cacheError) {
|
||
console.error('[Cache] 清除缓存失败:', cacheError)
|
||
}
|
||
|
||
// 重新缓存完整的排期列表
|
||
try {
|
||
// 获取所有已发布的排期数据
|
||
const schedulesData = await db
|
||
.select({
|
||
id: schedules.id,
|
||
playDate: schedules.playDate,
|
||
sequence: schedules.sequence,
|
||
played: schedules.played,
|
||
playTimeId: schedules.playTimeId,
|
||
isDraft: schedules.isDraft,
|
||
publishedAt: schedules.publishedAt,
|
||
songId: schedules.songId,
|
||
songTitle: songs.title,
|
||
songArtist: songs.artist,
|
||
songRequesterId: songs.requesterId,
|
||
songPlayed: songs.played,
|
||
songCover: songs.cover,
|
||
songMusicPlatform: songs.musicPlatform,
|
||
songMusicId: songs.musicId,
|
||
songSemester: songs.semester,
|
||
requesterName: users.name,
|
||
requesterGrade: users.grade,
|
||
requesterClass: users.class,
|
||
playTimeName: playTimes.name,
|
||
playTimeStartTime: playTimes.startTime,
|
||
playTimeEndTime: playTimes.endTime,
|
||
playTimeEnabled: playTimes.enabled
|
||
})
|
||
.from(schedules)
|
||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||
.innerJoin(users, eq(songs.requesterId, users.id))
|
||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||
.where(eq(schedules.isDraft, false)) // 只缓存已发布的排期
|
||
.orderBy(asc(schedules.playDate))
|
||
|
||
// 获取所有用户的姓名列表,用于检测同名用户
|
||
const allUsers = await db
|
||
.select({
|
||
id: users.id,
|
||
name: users.name,
|
||
grade: users.grade,
|
||
class: users.class
|
||
})
|
||
.from(users)
|
||
|
||
// 创建姓名到用户数组的映射
|
||
const nameToUsers = new Map()
|
||
allUsers.forEach((user) => {
|
||
if (user.name) {
|
||
if (!nameToUsers.has(user.name)) {
|
||
nameToUsers.set(user.name, [])
|
||
}
|
||
nameToUsers.get(user.name).push(user)
|
||
}
|
||
})
|
||
|
||
// 获取每首歌的投票数
|
||
const songIds = schedulesData.map((s) => s.songId)
|
||
const voteCounts = await Promise.all(
|
||
songIds.map(async (songId) => {
|
||
const voteCountResult = await db
|
||
.select({ count: count() })
|
||
.from(votes)
|
||
.where(eq(votes.songId, songId))
|
||
return { songId, count: voteCountResult[0].count }
|
||
})
|
||
)
|
||
|
||
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
|
||
|
||
// 转换数据格式
|
||
const formattedSchedules = schedulesData.map((schedule) => {
|
||
const dateOnly = schedule.playDate
|
||
|
||
// 处理投稿人姓名,如果是同名用户则添加后缀
|
||
let requesterName = schedule.requesterName || '未知用户'
|
||
|
||
const sameNameUsers = nameToUsers.get(requesterName)
|
||
if (sameNameUsers && sameNameUsers.length > 1) {
|
||
if (schedule.requesterGrade) {
|
||
const sameGradeUsers = sameNameUsers.filter(
|
||
(u: {
|
||
id: number
|
||
name: string | null
|
||
grade: string | null
|
||
class: string | null
|
||
}) => u.grade === schedule.requesterGrade
|
||
)
|
||
|
||
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
|
||
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
|
||
} else {
|
||
requesterName = `${requesterName}(${schedule.requesterGrade})`
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: schedule.id,
|
||
playDate: dateOnly.toISOString().split('T')[0],
|
||
sequence: schedule.sequence || 1,
|
||
played: schedule.played || false,
|
||
playTimeId: schedule.playTimeId,
|
||
isDraft: schedule.isDraft,
|
||
publishedAt: schedule.publishedAt,
|
||
playTime: schedule.playTimeName
|
||
? {
|
||
id: schedule.playTimeId,
|
||
name: schedule.playTimeName,
|
||
startTime: schedule.playTimeStartTime,
|
||
endTime: schedule.playTimeEndTime,
|
||
enabled: schedule.playTimeEnabled
|
||
}
|
||
: null,
|
||
song: {
|
||
id: schedule.songId,
|
||
title: schedule.songTitle,
|
||
artist: schedule.songArtist,
|
||
requester: requesterName,
|
||
voteCount: voteCountMap.get(schedule.songId) || 0,
|
||
played: schedule.songPlayed || false,
|
||
cover: schedule.songCover || null,
|
||
musicPlatform: schedule.songMusicPlatform || null,
|
||
musicId: schedule.songMusicId || null,
|
||
semester: schedule.songSemester || null
|
||
}
|
||
}
|
||
})
|
||
|
||
const completeSchedules = formattedSchedules
|
||
|
||
// 重新缓存完整的排期列表
|
||
if (completeSchedules && completeSchedules.length > 0) {
|
||
await cacheService.setSchedulesList(completeSchedules)
|
||
console.log(`[Cache] 已发布排期列表已重新缓存,数量: ${completeSchedules.length}`)
|
||
} else {
|
||
console.log('[Cache] 没有已发布排期数据需要缓存')
|
||
}
|
||
} catch (cacheError) {
|
||
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期发布:', cacheError)
|
||
}
|
||
|
||
return {
|
||
...draft,
|
||
isDraft: false,
|
||
publishedAt: publishedAt,
|
||
message: '排期发布成功,通知已发送'
|
||
}
|
||
} catch (error: any) {
|
||
console.error('发布排期失败:', error)
|
||
throw createError({
|
||
statusCode: 500,
|
||
message: error.message || '发布排期失败'
|
||
})
|
||
}
|
||
})
|