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 变更后老用户需重新授权。
This commit is contained in:
2026-10-11 00:04:14 +08:00
parent ebf1c3a127
commit 3eff710d5d
31 changed files with 659 additions and 189 deletions
+11 -6
View File
@@ -122,11 +122,16 @@ AGGREGATE_OAUTH_ENDPOINT=
WECHAT_MINI_APPID=
WECHAT_MINI_SECRET=
# 微信「订阅消息」模板(可选,但**想让通知发到微信里就必须配**)
# 公众平台「功能 → 订阅消息 → 选用模板」拿到模板 ID 后,按下面的 JSON 填:
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle","time2":"time","thing3":"message"}}}
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
# 内置模板(见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES):
# SONG_SELECTED「歌曲排期」—— 管理员把歌排进排期/发布排期时发
# SONG_REJECTED「歌曲驳回」—— 投稿被驳回时发
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的字段名):
# {"SONG_SELECTED":{"id":"模板ID","fields":{"thing2":"songTitle","time1":"playTime"}}}
# 键是通知类型(SONG_SELECTED / SONG_PLAYED / SONG_REJECTED / SYSTEM_NOTICE),
# fields 的键是模板里的字段名(thing1/time2/... 选用模板时能看到),值是变量名
# (内置变量:songTitle / message / time)。不配就等于关闭微信推送,其它渠道照常。
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;且只有绑定了微信的账号才有 openid。
# fields 的键是模板里的字段名(thing2/date4/time1/character_string3/phrase5,选用模板时能看到),
# 值是变量名(内置变量:songTitle / message / time / playDate / playTime / sequence / status)。
# 同键只给 id 不给 fields 时,会沿用内置的字段映射(不会退化成 thing1/time2/thing3)。
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度。
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
+154 -5
View File
@@ -519,7 +519,63 @@ jobs:
echo "环境准备完成"
cat /etc/os-release 2>/dev/null | head -4 || true
docker --version
docker buildx version || echo "buildx 未安装"
docker buildx version 2>&1 | head -1 || echo "buildx 子命令不可用"
- name: 确保 Docker Buildx 插件可用(缺失则装官方插件,否则回退 docker build)
run: |
set -euxo pipefail
# 症状:`docker buildx create --name xxx` 报
# unknown flag: --name / See 'docker --help'
# 打出的是 docker 的总帮助 → 说明 `buildx` 子命令没落到真插件上,docker CLI 把
# `--name` 当自己的顶层 flag 解析了。这里先探一次并按需装官方插件,不能等 build-image
# 作业才炸(各 job 是独立容器,装了也得在该 job 里再装一次)。
ensure_buildx() {
# 不用 `docker buildx version` 当唯一依据:这种“子命令没落到真插件上”的坏状态里
# 它仍可能返回 0。优先相信插件文件在位,其次才信命令。
if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \
|| [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \
|| { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then
echo "[buildx] 插件可用:$(docker buildx version 2>&1 | head -1)"
return 0
fi
echo "[buildx] 未发现可用插件,从官方 release 安装"
ARCH="$(uname -m)"
case "${ARCH}" in
x86_64|amd64) BUILDX_ARCH=amd64 ;;
aarch64|arm64) BUILDX_ARCH=arm64 ;;
*) echo "[buildx] 不支持的架构 ${ARCH},跳过安装"; return 1 ;;
esac
BUILDX_VER="v0.19.3"
BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}"
BUILDX_DIR="${HOME}/.docker/cli-plugins"
mkdir -p "${BUILDX_DIR}"
if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
"https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then
chmod +x "${BUILDX_DIR}/docker-buildx"
echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx"
else
rm -f "${BUILDX_DIR}/docker-buildx"
echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)"
fi
if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
echo "[buildx] 当前 docker 不认 ~/.docker/cli-plugins,改写系统插件目录"
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
fi
[ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ]
}
if ensure_buildx && docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
echo "[buildx] 自检通过(create 支持 --name)"
echo "BUILDX_INSTALLED=yes" >> "$GITHUB_ENV"
else
echo "[buildx] 自检未通过,镜像构建作业将自动回退 docker build(不阻塞部署)"
echo "BUILDX_INSTALLED=no" >> "$GITHUB_ENV"
fi
- name: 检出代码
run: |
@@ -669,7 +725,61 @@ jobs:
BRANCH="${GITHUB_REF_NAME:-main}"
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
- name: 初始化 Docker Buildx builder (为本作业重新创建)
- name: 确保 Docker Buildx 插件可用(本作业容器内独立安装)
run: |
set -euxo pipefail
# 每个 job 跑在独立容器里,meta-warmup 装的插件这里看不到,必须自己再确保一次。
# 症状回顾:`docker buildx create --name xxx` 报 unknown flag: --name(打出的是 docker
# 总帮助)→ buildx 子命令没落到真插件上。装完仍不可用则不报错,交给后面的 docker build 兜底。
# 判定同 meta-warmup:优先看插件文件是否在位,不把 `docker buildx version` 当唯一依据。
if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \
|| [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \
|| { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then
echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)"
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
exit 0
fi
echo "[buildx] 未发现可用插件,从官方 release 安装"
ARCH="$(uname -m)"
case "${ARCH}" in
x86_64|amd64) BUILDX_ARCH=amd64 ;;
aarch64|arm64) BUILDX_ARCH=arm64 ;;
*) echo "[buildx] 不支持的架构 ${ARCH}"; echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"; exit 0 ;;
esac
BUILDX_VER="v0.19.3"
BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}"
BUILDX_DIR="${HOME}/.docker/cli-plugins"
mkdir -p "${BUILDX_DIR}"
if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
"https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then
chmod +x "${BUILDX_DIR}/docker-buildx"
echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx"
else
rm -f "${BUILDX_DIR}/docker-buildx"
echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)"
fi
if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
fi
if [ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ]; then
if docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
echo "[buildx] 自检通过"
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
else
echo "[buildx] 插件在位但 create 仍不认识 --name,改用 docker build 兜底"
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
fi
else
echo "[buildx] 安装未成功,改用 docker build 兜底"
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
fi
- name: 初始化 Docker Buildx builder (为本作业重新创建,不可用则回退 docker build)
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
@@ -708,17 +818,35 @@ jobs:
done
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像"; exit 1; }
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" -u "${REGISTRY_USER}" --password-stdin
# buildx 插件不可用时(`docker buildx create --name` 报 unknown flag: --name,
# 说明子命令没落到真插件上),不再硬失败,转用普通 docker build 继续部署。
if [ "${BUILDX_USABLE:-no}" != "yes" ] \
|| ! docker buildx version >/dev/null 2>&1 \
|| ! docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
echo "[buildx] 本作业不可用,跳过 builder 初始化,将由 docker build 回退构建"
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
rm -f "${BKK_CFG}"
exit 0
fi
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
docker buildx create \
if docker buildx create \
--name "${BUILDER_NAME}" \
--driver docker-container \
--use \
--config "${BKK_CFG}" \
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
--driver-opt network=host \
--driver-opt "image=${BUILDKIT_IMAGE}"
--driver-opt "image=${BUILDKIT_IMAGE}" \
&& docker buildx inspect --bootstrap; then
echo "[buildx] builder ${BUILDER_NAME} 就绪,使用 buildx 构建(含分层缓存)"
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
else
echo "[buildx] builder 创建或启动失败,回退 docker build(不使用分层缓存)"
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
fi
rm -f "${BKK_CFG}"
docker buildx inspect --bootstrap
- name: 复用 meta-warmup 结果(与 Preflight 并发省 ~5min)
run: |
@@ -777,6 +905,13 @@ jobs:
echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)"
fi
# BUILDX_USABLE 由「初始化 Docker Buildx builder」步骤写入 GITHUB_ENV:
# yes → buildx(分层缓存 + --load 打进本地 daemon)
# no → 普通 docker build 兜底(无分层缓存;依赖 daemon.json 的 registry-mirrors
# 或 Dockerfile 里的 DOCKER_MIRROR build-arg 拉基础镜像)
# 注意:--provenance 只有 buildx 认,回退时必须去掉。
if [ "${BUILDX_USABLE:-yes}" = "yes" ]; then
echo "[build] 使用 docker buildx build"
docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
@@ -789,6 +924,20 @@ jobs:
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
--label "org.opencontainers.image.created=${OCS_CREATED}" \
.
else
echo "[build] buildx 不可用,回退 docker build(生效的 mirror 由 daemon.json 决定)"
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
docker build \
--file ./Dockerfile \
--platform linux/amd64 \
"${BUILD_ARGS[@]}" \
--tag "${IMG_FULL}" \
--tag "${IMG_LATEST}" \
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
--label "org.opencontainers.image.created=${OCS_CREATED}" \
.
fi
docker image inspect "${IMG_FULL}" >/dev/null
docker image inspect "${IMG_LATEST}" >/dev/null
-9
View File
@@ -21,15 +21,6 @@
color-scheme: light;
}
/* ===== 前台页面强制浅色 color-scheme(防止系统深色干扰)===== */
body[data-frontend="true"]:not(.admin-body),
body[data-frontend="true"]:not(.admin-body) input,
body[data-frontend="true"]:not(.admin-body) textarea,
body[data-frontend="true"]:not(.admin-body) select,
body[data-frontend="true"]:not(.admin-body) button {
color-scheme: light;
}
/* 保护表单元素外观,但不强锁 color-scheme = none(none 会导致控件 fallback 到浅色) */
input[type='text'],
input[type='email'],
+1 -1
View File
@@ -75,7 +75,7 @@
</div>
<h3 class="text-lg font-bold text-zinc-700">暂无API密钥</h3>
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
您还没有创建任何访问密钥。创建密钥后,您可以安全地将 VoiceHub 集成到第三方应用程序中。
您还没有创建任何访问密钥。创建密钥后,您可以安全地将宝一外校园点歌系统集成到第三方应用程序中。
</p>
<button
class="mt-8 flex items-center gap-2 px-6 py-3 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-2xl transition-all"
+1 -1
View File
@@ -185,7 +185,7 @@
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
仅支持宝一外校园点歌系统导出的 .json 格式
</p>
<input
ref="fileInput"
@@ -208,7 +208,7 @@
<h4 class="text-zinc-900 font-bold flex items-center gap-1.5 text-sm">
<Icon name="info" :size="14" /> 如何启动酷狗 sidecar?
</h4>
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与 VoiceHub 同一台机器即可)。</p>
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与宝一外校园点歌系统同一台机器即可)。</p>
<pre class="bg-zinc-900 text-zinc-100 text-[11px] rounded-xl p-3 overflow-x-auto font-mono"><code>git clone https://github.com/MakcRe/KuGouMusicApi
cd KuGouMusicApi
npm install
+1 -1
View File
@@ -344,7 +344,7 @@
<Bell :size="14" />
</div>
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>VoiceHub 系统</span
>宝一外校园点歌系统</span
>
</div>
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
@@ -284,7 +284,7 @@ const drawCanvas = () => {
}
ctx.font = '12px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Generated by VoiceHub', 200, height - 20)
ctx.fillText('Generated by 宝一外校园点歌系统', 200, height - 20)
}
const downloadCard = () => {
Binary file not shown.
+2 -2
View File
@@ -260,7 +260,7 @@
<!-- 页面头部 -->
<div class="page-header">
<div class="logo-section">
<img :src="logoUrl" alt="VoiceHub Logo" class="logo" >
<img :src="logoUrl" alt="宝一外校园点歌系统 Logo" class="logo" >
<!-- 竖线分割 -->
<div class="logo-divider" />
<!-- 学校logo -->
@@ -356,7 +356,7 @@
>备注:{{ settings.remark }}</span
>
</div>
<span class="footer-right">Generated by VoiceHub | 广播管理系统</span>
<span class="footer-right">Generated by 宝一外校园点歌系统 | 广播管理系统</span>
</div>
</div>
</div>
+2 -2
View File
@@ -14,7 +14,7 @@
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img
:src="logo"
alt="VoiceHub Logo"
alt="宝一外校园点歌系统 Logo"
class="w-8 h-8 object-contain"
width="32"
height="32"
@@ -24,7 +24,7 @@
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">VoiceHub</h1>
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">宝一外校园点歌系统</h1>
<p
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
+1 -1
View File
@@ -467,7 +467,7 @@ const saveConfig = async () => {
saving.value = true
const configToSave = {
...formData.value,
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
siteTitle: (formData.value.siteTitle || '').trim() || '宝一外校园点歌系统',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
siteVersion: (formData.value.siteVersion || '').trim() || null,
submissionGuidelines:
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="form-header">
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
<p v-else>登录您的VoiceHub账户</p>
<p v-else>登录宝一外校园点歌系统账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
@@ -146,7 +146,7 @@
<AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
<p class="help-text">不同宝一外校园点歌系统平台的账号不互通</p>
<div style="margin-top: 16px;">
<NuxtLink to="/register" class="register-link-btn">
还没有账号?去注册
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="login-form">
<div class="form-header">
<h2>注册账号</h2>
<p>创建您的VoiceHub账户</p>
<p>创建您的宝一外校园点歌系统账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
+6 -7
View File
@@ -96,9 +96,8 @@ onMounted(() => {
text-align: center;
padding: 20px 0;
margin-top: 30px;
border-top: 1px solid #E5E7EB;
border-top: 1px solid var(--border-primary);
width: 100%;
background-color: #ffffff;
}
.footer-info {
@@ -110,7 +109,7 @@ onMounted(() => {
}
.footer-item {
color: #374151;
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
@@ -118,7 +117,7 @@ onMounted(() => {
.footer-item:not(:last-child)::after {
content: ' | ';
margin: 0 10px;
color: #6B7280;
color: var(--text-quaternary);
}
.footer-item a {
@@ -128,7 +127,7 @@ onMounted(() => {
}
.footer-item a:hover {
color: #7C3AED;
color: var(--text-secondary);
}
.icp-link,
@@ -140,7 +139,7 @@ onMounted(() => {
.icp-link:hover,
.voicehub-link:hover {
color: #7C3AED;
color: var(--text-secondary);
}
.netlify-badge {
@@ -151,7 +150,7 @@ onMounted(() => {
.netlify-badge img {
height: 16px;
opacity: 0.9;
opacity: 0.8;
transition: opacity 0.2s ease;
}
-42
View File
@@ -1,42 +0,0 @@
<template>
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg" style="display:block; width:100%; height:auto; max-width:100%; max-width:320px; margin:0 auto;">
<defs>
<linearGradient id="petalGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#A855F7"/>
<stop offset="50%" stop-color="#EC4899"/>
<stop offset="100%" stop-color="#3B82F6"/>
</linearGradient>
<linearGradient id="micGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1f2937"/>
<stop offset="100%" stop-color="#111827"/>
</linearGradient>
</defs>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(0 200 190)"/>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(60 200 190)"/>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(120 200 190)"/>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(180 200 190)"/>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(240 200 190)"/>
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(300 200 190)"/>
<circle cx="310" cy="190" r="4" fill="url(#petalGrad)"/>
<circle cx="255" cy="285.26" r="4" fill="url(#petalGrad)"/>
<circle cx="145" cy="285.26" r="4" fill="url(#petalGrad)"/>
<circle cx="90" cy="190" r="4" fill="url(#petalGrad)"/>
<circle cx="145" cy="94.74" r="4" fill="url(#petalGrad)"/>
<circle cx="255" cy="94.74" r="4" fill="url(#petalGrad)"/>
<circle cx="200" cy="200" r="170" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
<circle cx="200" cy="200" r="185" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
<circle cx="200" cy="200" r="200" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
<circle cx="200" cy="190" r="46" fill="#ffffff" stroke="url(#petalGrad)" stroke-width="3"/>
<circle cx="200" cy="190" r="28" fill="url(#micGrad)"/>
<rect x="195" y="216" width="10" height="80" rx="5" fill="#1f2937"/>
<rect x="170" y="288" width="60" height="10" rx="5" fill="#111827"/>
<line x1="150" y1="304" x2="250" y2="304" stroke="#D1D5DB" stroke-width="2" stroke-linecap="round"/>
</svg>
</template>
<script setup lang="ts">
</script>
-40
View File
@@ -1,40 +0,0 @@
<template>
<nav
class="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-white border-t border-gray-200 px-4 py-2 pb-[max(8px,env(safe-area-inset-bottom))] backdrop-blur-sm shadow-[0_-2px_16px_rgba(0,0,0,0.04)]"
>
<div class="grid grid-cols-3 gap-2">
<button
type="button"
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
@click="$emit('navigate', 'request-section')"
>
<Music :size="20" />
<span class="text-[11px] font-semibold">点歌</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
@click="$emit('navigate', 'schedule-section')"
>
<CalendarDays :size="20" />
<span class="text-[11px] font-semibold">排期</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
@click="$emit('navigate', 'account')"
>
<User :size="20" />
<span class="text-[11px] font-semibold">我的</span>
</button>
</div>
</nav>
</template>
<script setup>
import { Music, CalendarDays, User } from '@lucide/vue'
defineEmits(['navigate'])
</script>
+2 -2
View File
@@ -6,7 +6,7 @@
<BookOpen class="w-5 h-5 text-primary" />
使用指南
</h3>
<p class="text-xs text-text-secondary mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
<p class="text-xs text-text-secondary mt-1">快速了解如何使用宝一外校园点歌系统</p>
</div>
<button
class="start-tour-btn"
@@ -92,7 +92,7 @@ const windowSize = ref({ width: 0, height: 0 })
const tourSteps = [
{
element: '.user-tutorial-container',
title: '欢迎来到 VoiceHub!',
title: '欢迎来到宝一外校园点歌系统!',
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
position: 'top'
},
+1 -1
View File
@@ -8,7 +8,7 @@
<div v-show="active" class="content">
<div class="year-bg">{{ data.year }}</div>
<div class="sub-title">年度点歌报告</div>
<h1 class="main-title">VoiceHub</h1>
<h1 class="main-title">宝一外校园点歌系统</h1>
</div>
</Transition>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="card-content">
<div class="header">
<div class="logo">VoiceHub</div>
<div class="logo">宝一外校园点歌系统</div>
<div class="year">{{ data.year }}</div>
</div>
+1 -3
View File
@@ -13,10 +13,8 @@ import { SpeedInsights } from '@vercel/speed-insights/nuxt'
const { initSiteConfig } = useSiteConfig()
// 初始化站点配置
onMounted(() => {
if (typeof document !== 'undefined' && document.body) {
document.body.setAttribute('data-frontend', 'true')
}
initSiteConfig()
})
</script>
BIN
View File
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">鍥剧墖姝屽崟</h2>
<p class="text-sm text-zinc-500">閫夋嫨鏃ユ湡鐢熸垚骞挎挱鎺掓湡鍗$墖骞跺垎浜?/p>
</div>
<div class="flex flex-col gap-8">
<!-- 璁剧疆闈㈡澘 -->
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 鍗$墖璁剧疆
</h3>
<div class="flex flex-col gap-5 items-end">
<!-- 鏃ユ湡閫夋嫨 -->
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">鎺掓湡鏃ユ湡</label>
<!-- 绫讳技鎺掓湡绠$悊鐨勬棩鏈熼€夋嫨鍣?-->
<AdminDatePicker
:modelValue="selectedDate"
@update:modelValue="selectedDate = $event"
@change="fetchSchedules"
/>
</div>
<button
:disabled="loading || schedules.length === 0"
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="showModal = true"
>
<ImageIcon class="w-4 h-4" />
{{ loading ? '鍔犺浇涓?..' : (schedules.length === 0 ? '璇ユ棩鏈熸棤鎺掓湡' : '鐢熸垚鍗$墖') }}
</button>
</div>
</div>
</div>
<!-- 棰勮鍖?-->
<div class="w-full flex flex-col gap-4">
<div v-if="schedules.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
<h3 class="text-lg font-bold text-zinc-100 mb-4">鎺掓湡棰勮 ({{ schedules.length }} 棣?</h3>
<div class="space-y-3">
<div v-for="(schedule, index) in schedules" :key="schedule.id" class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
<div class="flex-1 overflow-hidden">
<p class="text-sm text-zinc-200 font-medium truncate">{{ schedule.song?.title || schedule.title }}</p>
<p class="text-xs text-zinc-500 truncate">{{ schedule.song?.artist || schedule.artist }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
<p class="text-zinc-500">璇ユ棩鏈熸病鏈夋帓鏈熺殑姝屾洸</p>
</div>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showModal"
:songs="schedules"
:default-title="`${selectedDate} 鏍″洯骞挎挱姝屽崟`"
@close="showModal = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Layout, Image as ImageIcon, CalendarDays } from '@lucide/vue'
import AdminDatePicker from './AdminDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const selectedDate = ref(new Date().toISOString().split('T')[0])
const schedules = ref([])
const loading = ref(false)
const showModal = ref(false)
const fetchSchedules = async () => {
loading.value = true
try {
const response = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: {
date: selectedDate.value,
includeDrafts: 'true'
}
})
if (response.success && response.data && response.data.schedules) {
schedules.value = response.data.schedules
} else {
schedules.value = []
}
} catch (error) {
console.error('Failed to fetch schedules:', error)
if (window.$showNotification) {
window.$showNotification('鑾峰彇鎺掓湡澶辫触', 'error')
}
} finally {
loading.value = false
}
}
onMounted(() => {
fetchSchedules()
})
</script>
+3 -1
View File
@@ -155,7 +155,9 @@ export default defineEventHandler(async (event) => {
{
title: schedule.song.title,
artist: schedule.song.artist,
playDate: schedule.playDate
playDate: schedule.playDate,
// 微信「歌曲排期」订阅消息的排期序号字段要它
sequence: schedule.sequence
},
clientIP
)
@@ -61,7 +61,8 @@ export default defineEventHandler(async (event) => {
const notificationsToSend: Array<{
requesterId: number
songId: number
songInfo: { title: string; artist: string; playDate: Date }
// sequence 要给微信「歌曲排期」订阅消息的排期序号字段用
songInfo: { title: string; artist: string; playDate: Date; sequence?: number | null }
}> = []
// 开始事务
@@ -119,7 +120,8 @@ export default defineEventHandler(async (event) => {
songInfo: {
title: song.title,
artist: song.artist,
playDate: playDate
playDate: playDate,
sequence: item.sequence
}
})
+3 -1
View File
@@ -102,7 +102,9 @@ export default defineEventHandler(async (event) => {
{
title: draft.song.title,
artist: draft.song.artist,
playDate: draft.playDate
playDate: draft.playDate,
// 微信「歌曲排期」订阅消息的排期序号字段要它
sequence: draft.sequence
},
clientIP
)
+36 -6
View File
@@ -13,12 +13,15 @@ import {
* 1. access_token 能不能拿到(appid / secret 是否有效)
* 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null)
* 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码)
* 4. 微信返回的原文
* 4. 微信返回的原文(data 里能直接看到每个字段最终发了什么值)
*
* body(都可省):
* { userId?: number, kind?: string, openid?: string, templateId?: string, data?: object }
* { userId?: number, kind?: string, openid?: string, templateId?: string,
* vars?: object, data?: object }
* · 默认给自己发、用配置里的第一个模板
* · 想直接指定 openid / 模板 ID / data 也可以(用于排查)
* · vars 覆盖**变量**(songTitle / playDate / playTime / sequence / status / message…),
* 仍走 buildTemplateData → clampField,所以看到的是真发出去的值
* · data 直接当微信的 data 用(最后的兜底排查手段)
*/
export default defineEventHandler(async (event) => {
const user = event.context.user
@@ -41,7 +44,7 @@ export default defineEventHandler(async (event) => {
return {
ok: false,
reason: 'no-template',
hint: '服务器环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 没配置模板 ID(见 wechatSubscribeService.ts 头部注释)',
hint: '没有可用的模板 ID:内置默认模板见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES,也可用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 配置',
configuredKinds: kinds,
tokenOk: !!token,
openid: openid ? openid.slice(0, 6) + '****' : null
@@ -60,13 +63,40 @@ export default defineEventHandler(async (event) => {
}
}
/*
* 各通知类型的**真实例子**:拿这些值走一遍 buildTemplateData → clampField,
* 站长看到的 data 就是线上真会发出去的样子(比 '测试歌曲' 更能验字段名/长度)。
*/
const SAMPLE_VARS: Record<string, Record<string, unknown>> = {
SONG_SELECTED: {
songTitle: '测试歌曲《晴天》',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30',
sequence: '1',
status: '已排期',
message: '您投稿的歌曲《晴天》已被安排播放,播放日期:2026-02-14。'
},
SONG_REJECTED: {
songTitle: '测试歌曲《晴天》',
reason: '已有同名单曲,暂不重复收录',
message: '您投稿的歌曲《晴天》未通过审核。'
}
}
const data =
body?.data && typeof body.data === 'object'
? body.data
: buildTemplateData(conf || { id: templateId }, {
: buildTemplateData(
conf || { id: templateId },
Object.assign(
{
songTitle: '测试歌曲',
message: '这是一条来自宝一外校园点歌系统的测试通知'
})
},
SAMPLE_VARS[kind] || {},
body?.vars && typeof body.vars === 'object' ? body.vars : {}
)
)
const result = await sendRawSubscribe(openid, templateId, data)
+20 -2
View File
@@ -12,7 +12,7 @@ import {
} from '~/drizzle/schema'
import { and, eq, gte, inArray } from 'drizzle-orm'
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
import { sendWechatSubscribeToUser } from './wechatSubscribeService'
import { buildWechatTimeValue, sendWechatSubscribeToUser } from './wechatSubscribeService'
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
@@ -122,6 +122,8 @@ export async function createSongSelectedNotification(
title: string
artist: string
playDate: Date
/** 排期在该时段里的序号(微信「歌曲排期」模板的排期序号字段用) */
sequence?: number | null
},
ipAddress?: string
) {
@@ -207,7 +209,23 @@ export async function createSongSelectedNotification(
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', { songTitle: songInfo.title, message })
/*
* 微信模板字段比站内通知挑数据: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)
}
+44 -28
View File
@@ -1,6 +1,11 @@
import { db } from '~/drizzle/db'
import { notificationSettings, userIdentities } from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
// ⚠️ 服务端 utils 在仓库根的 server/ 下,Nuxt 4 里 `~` 指向 app/,必须用 `~~`
import { beijingNow, clampField } from '~~/server/utils/wechatSubscribeFields'
// 字段裁剪规则集中在 utils/wechatSubscribeFields.ts(纯函数,可单独测),这里只做转发
export { beijingNow, buildWechatTimeValue, clampField } from '~~/server/utils/wechatSubscribeFields'
/**
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
@@ -10,15 +15,22 @@ import { and, eq } from 'drizzle-orm'
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
* 4. 模板字段的类型和长度由模板决定:`thing` 最长 20 字、`time` 必须 `YYYY-MM-DD HH:mm`,
* 字段名与长度写错会被微信拒(47003),所以这里按模板配置逐个裁剪。
* 4. 模板字段的类型和长度由模板决定,**某一个字段超长会让整条消息被拒**(47003,报错里那个
* 字段名是微信挑的,不代表只有它有问题)。各类型的限制:
* · thing 20 字(本服务截 19 字 + 省略号)
* · phrase 5 个以内纯汉字(恰好 5 个在部分模板上会被拒,见 utils/wechatSubscribeFields.ts)
* · time 必须是 'YYYY-MM-DD HH:mm'
* · date 必须是 'YYYY-MM-DD'
* · character_string 32 字(只能数字、字母)
* · number/amount 32 字(只能数字)
* 所以这里按模板配置逐个裁剪;另外**空值也会 47003**,拿不到值的字段用占位文案兜住。
*
* 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道):
* WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用)
* WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON:
* {
* "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } },
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time" } }
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing2": "songTitle", "time1": "playTime" } }
* }
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
@@ -38,11 +50,38 @@ type TemplateConf = { id: string; fields?: Record<string, string> }
* 模板字段「驳回原因」= thing4 → reason
* ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003),
* 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。
*
* SONG_SELECTED = 歌曲被选中(管理员把歌排进排期 / 发布排期)。
* 站点上这条通知一直是发出去的,但之前**没有**内置微信模板,所以小程序那边收不到;
* 站长 2026-02 在公众平台选好了「歌曲排期」模板,这里补上默认映射:
* 模板字段「歌曲名称」= thing2 → songTitle
* 模板字段「播放日期」= date4 → playDate('YYYY-MM-DD',与站内通知里的日期一致)
* 模板字段「播放时间」= time1 → playTime('YYYY-MM-DD HH:mm',优先取排期时段的开始时间)
* 模板字段「排期序号」= character_string3 → sequence
* 模板字段「排期状态」= phrase5 → status(固定「已排期」,phrase 必须恰好 5 个汉字)
* ⚠️ 字段名(thing2/date4/…)必须和公众平台模板里的一模一样,否则微信 47003。
* 站长若选了别的字段名,用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 覆盖 fields 即可,
* 不用改代码(例:`{"SONG_SELECTED":{"id":"oS4Ev…","fields":{"thing1":"songTitle"}}}`)。
*
* 另外一个**必须让用户知道**的平台特性:模板 ID 变了 = 用户要重新授权。
* 老用户之前点的授权只对旧模板 ID 有效,新增模板后他们得再点一次「开启微信通知」,
* 否则这条消息会以 43101(无可用授权)静默跳过 —— 那是正常状态,不是 bug。
*/
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
SONG_REJECTED: {
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
fields: { thing6: 'songTitle', thing4: 'reason' }
},
SONG_SELECTED: {
// 站长提供的「歌曲排期」模板 ID
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
fields: {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
}
}
}
@@ -145,31 +184,8 @@ export async function getMiniAccessToken(force = false): Promise<string | null>
}
// ---------------- 取值 / 裁剪 ----------------
/** 模板字段的长度限制(微信侧的限制,超了直接 47003) */
function clampField(key: string, value: unknown): string {
const text = value === null || value === undefined ? '' : String(value)
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
if (/^thing/.test(key)) return text.length > 20 ? text.slice(0, 19) + '…' : text
return text.slice(0, 32)
}
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
export function beijingNow(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
const p = (n: number) => (n < 10 ? '0' + n : String(n))
return (
now.getUTCFullYear() +
'-' +
p(now.getUTCMonth() + 1) +
'-' +
p(now.getUTCDate()) +
' ' +
p(now.getUTCHours()) +
':' +
p(now.getUTCMinutes())
)
}
// clampField / beijingNow / buildWechatTimeValue 都在 ~/server/utils/wechatSubscribeFields.ts,
// 顶部已 import + re-export(那里的注释写清了每种字段类型的微信限制)。
/** 用模板配置 + 变量拼出微信要的 data */
export function buildTemplateData(
+103
View File
@@ -0,0 +1,103 @@
/**
* 微信**订阅消息**字段的取值规则(纯函数,不依赖 Nitro / 数据库,可直接用 node 跑验证)。
*
* 为什么单独放一个文件:微信的字段校验是**整条消息级别**的 —— 只要有一个字段超长、
* 格式不对或为空,整条消息发不出去,微信只回一个 `47003 argument invalid! data.xxx.value invalid`
* (而且报错里点名的字段不一定是真正有问题的那一个)。所以这些裁剪规则必须能单独测。
*
* 各类型限制(公众平台模板字段名后缀就是类型):
* thing 20 字
* phrase 5 个以内纯汉字(官方文档的写法;社区反馈**恰好 5 个汉字**在部分模板上会报
* `47003 data.phraseN.value invalid`,实测 4 个汉字正常)
* time 必须是 'YYYY-MM-DD HH:mm'
* date 必须是 'YYYY-MM-DD'
* character_string 32 字(只能数字、字母)
* number/amount 32 字(只能数字)
*/
/** 各类型字段的长度上限 */
export const FIELD_MAX_LEN: Record<string, number> = {
thing: 20,
phrase: 5,
time: 24,
date: 10,
character_string: 32,
number: 32,
amount: 32,
digit: 32,
letter: 32,
symbol: 5
}
/** 字段名 → 类型(thing1/character_string3 → thing/character_string) */
export function fieldType(key: string): string {
return String(key || '').replace(/[0-9]+$/, '')
}
/**
* 按字段类型裁剪成微信能接受的值。
* ⚠️ 返回空字符串仍然会被微信拒,调用方要保证变量有值(见 wechatSubscribeService 的占位逻辑)。
*/
export function clampField(key: string, value: unknown): string {
const raw = value === null || value === undefined ? '' : String(value)
// 微信不认换行,先压成一行(站内通知的 message 是多行拼的)
const text = raw.replace(/\s+/g, ' ').trim()
const type = fieldType(key)
if (type === 'thing') {
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
return Array.from(text).length > 20 ? Array.from(text).slice(0, 19).join('') + '…' : text
}
if (type === 'phrase') {
/*
* phrase = 5 个以内纯汉字。官方文档是「5个以内」,但社区反馈**恰好 5 个汉字**在部分模板上
* 会被判 47003 invalid(4 个正常,见 wxopen.club/topic/61daa41e947eea50368f28ec),所以
* 按最保守的写法统一补到 5 个字:短了补全角空格,长到 5 个字保持原样,超了截断。
* “已排期”只有 3 个字 —— 真发出去不是被拒就是显示得莫名其妙,这里补齐后不会超长。
*/
const chars = Array.from(text)
if (chars.length >= 5) return chars.slice(0, 5).join('')
let padded = text
while (Array.from(padded).length < 5) padded += ' '
return padded
}
const max = FIELD_MAX_LEN[type] || 32
return text.slice(0, max)
}
/** 北京时间(UTC+8)的 'YYYY-MM-DD' */
export function beijingDateString(date: Date): string {
const t = new Date(date.getTime() + 8 * 60 * 60 * 1000)
const p = (n: number) => (n < 10 ? '0' + n : String(n))
return t.getUTCFullYear() + '-' + p(t.getUTCMonth() + 1) + '-' + p(t.getUTCDate())
}
/**
* 把播放日期 + 排期时段揉成微信 time 型字段要求的 'YYYY-MM-DD HH:mm'。
* 支持 '12:30'、'12:30-13:00'、'12:30~13:00'、'12:30'(全角冒号),取**开始时间**。
* 解析不出来时退化成当天 00:00 —— time 型字段不能为空,否则整条消息 47003。
*/
export function buildWechatTimeValue(date: Date, timeRange?: string | null): string {
const day = beijingDateString(date)
const m = String(timeRange || '').match(/(\d{1,2})\s*[::]\s*(\d{2})/)
if (!m) return day + ' 00:00'
const hh = String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0')
return day + ' ' + hh + ':' + m[2]
}
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
export function beijingNow(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
const p = (n: number) => (n < 10 ? '0' + n : String(n))
return (
now.getUTCFullYear() +
'-' +
p(now.getUTCMonth() + 1) +
'-' +
p(now.getUTCDate()) +
' ' +
p(now.getUTCHours()) +
':' +
p(now.getUTCMinutes())
)
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 微信订阅消息字段裁剪规则的单元测试(`npm test` 跑)。
*
* 为什么值得单独测:微信对订阅消息的字段校验是**整条消息级别**的 —— 一个字段超长、
* 格式不对或为空,整条消息就被拒(47003),而且报错里点名的字段不一定是真凶。
* 这些规则一旦写错,线上表现就是「通知发不出去」,却看不出哪里错。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
beijingNow,
buildWechatTimeValue,
clampField
} from '../server/utils/wechatSubscribeFields.ts'
const len = (s) => Array.from(s).length
test('thing 型:20 字以内原样,超了截 19 字 + 省略号(正好 20)', () => {
assert.equal(clampField('thing2', '晴天'), '晴天')
const twenty = '一二三四五六七八九十一二三四五六七八九十'
assert.equal(clampField('thing2', twenty), twenty)
assert.equal(len(clampField('thing2', twenty)), 20)
const long = twenty + '崩坏的世界'
const clamped = clampField('thing2', long)
assert.equal(len(clamped), 20)
assert.ok(clamped.endsWith('…'))
assert.ok(long.startsWith(clamped.slice(0, 19)))
})
test('phrase 型:5 个以内纯汉字——“已排期”会被补成全角 5 字,不是原样发出去', () => {
const padded = clampField('phrase5', '已排期')
assert.equal(len(padded), 5)
assert.ok(padded.startsWith('已排期'))
// 刚好 5 个汉字原样保留
assert.equal(clampField('phrase5', '已排期播放'), '已排期播放')
// 超了截到 5 个
assert.equal(len(clampField('phrase5', '已排期播放中啦啦啦')), 5)
})
test('time 型:必须是 YYYY-MM-DD HH:mm 且不超过 24 字', () => {
const w = (v) => ({ value: clampField('time1', v) })
assert.deepEqual(w(buildWechatTimeValue(new Date('2026-02-14T00:00:00Z'), '12:30')), {
value: '2026-02-14 12:30'
})
assert.match(clampField('time1', '2026-02-14 12:30'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
assert.equal(len(clampField('time1', '2026-02-14 12:30')), 16)
})
test('buildWechatTimeValue:取时段开始时间,兼容各种写法,兜底当天 00:00', () => {
const day = new Date('2026-02-14T00:00:00Z')
assert.equal(buildWechatTimeValue(day, '12:30-13:00'), '2026-02-14 12:30')
assert.equal(buildWechatTimeValue(day, '12:30~13:00'), '2026-02-14 12:30')
assert.equal(buildWechatTimeValue(day, '12:30'), '2026-02-14 12:30')
assert.equal(buildWechatTimeValue(day, '9:05'), '2026-02-14 09:05')
assert.equal(buildWechatTimeValue(day, '13:00'), '2026-02-14 13:00')
// time 型字段不能为空,解析不出来也要给个合法值
assert.equal(buildWechatTimeValue(day, ''), '2026-02-14 00:00')
assert.equal(buildWechatTimeValue(day, null), '2026-02-14 00:00')
assert.equal(buildWechatTimeValue(day, '午间'), '2026-02-14 00:00')
})
test('buildWechatTimeValue:以北京时间取日期(UTC 16:00 已是次日)', () => {
assert.equal(buildWechatTimeValue(new Date('2026-02-13T16:00:00Z'), '12:30'), '2026-02-14 12:30')
assert.equal(buildWechatTimeValue(new Date('2026-02-13T15:59:00Z'), '12:30'), '2026-02-13 12:30')
})
test('character_string 型:序号原样发(不补零、不加字)', () => {
assert.equal(clampField('character_string3', 1), '1')
assert.equal(clampField('character_string3', '12'), '12')
assert.equal(clampField('character_string3', 0), '0')
})
test('date 型:YYYY-MM-DD 原样,脏值截到 10 字', () => {
assert.equal(clampField('date4', '2026-02-14'), '2026-02-14')
assert.equal(clampField('date4', '2026-02-14 12:30:00'), '2026-02-14')
})
test('空白与换行会被压成一行(微信不接受换行)', () => {
assert.equal(clampField('thing1', ' 晴天\n 周杰伦 '), '晴天 周杰伦')
assert.equal(clampField('thing1', null), '')
assert.equal(clampField('thing1', undefined), '')
})
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
// 与 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES.SONG_SELECTED 保持一致
const fields = {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
}
const vars = {
songTitle: '晴天',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30',
sequence: '1',
status: '已排期'
}
const out = {}
for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]])
assert.equal(out.thing2, '晴天')
assert.equal(out.date4, '2026-02-14')
assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
assert.equal(out.character_string3, '1')
assert.equal(len(out.phrase5), 5) // “已排期” 3 字 → 补齐到 5 字,避开 47003
// 不能有任何字段是空值:空值同样会被微信拒
for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空')
})
test('beijingNow 是合法的 time 型值,且与北京时间一致', () => {
assert.match(beijingNow(), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
// beijingNow() 内部按 UTC+8 取值,用同一算法反推对比
const t = new Date(Date.now() + 8 * 3600 * 1000)
const p = (n) => (n < 10 ? '0' + n : String(n))
assert.equal(
beijingNow(),
`${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`
)
})