接入自建后端 + Gitea CI/CD
部署到群晖 / deploy (push) Failing after 6m22s

- 新增 server/:Node + Express + SQLite + node-cron 实现登录、纪念日 CRUD 和定时订阅消息推送
- 新增 .gitea/workflows/deploy.yml:推送即触发群晖 Docker 部署,监听 15002
- utils/api.js:自动按 envVersion 切换本地/线上 BASE_URL
- app.js 与 add-anniversary.js 移除 wx.cloud 调用,改走自建后端
- cloudfunctions/ 暂保留以便回滚
- 一并提交此前未入库的首页 / 设置页 / 日历 / 万年历等改造

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuming
2026-06-01 15:44:09 +08:00
parent 6747ade9c4
commit 3965e542fc
49 changed files with 5616 additions and 670 deletions
+18
View File
@@ -0,0 +1,18 @@
# 微信小程序凭证(在微信公众平台 → 开发管理 → 开发设置 中获取)
WX_APPID=
WX_APPSECRET=
# 订阅消息模板 ID(保持和云函数 sendReminder 中的 TEMPLATE_ID 一致)
WX_TEMPLATE_ID=6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw
# 小程序版本:开发版 developer / 体验版 trial / 正式版 formal
WX_MINIPROGRAM_STATE=formal
# 服务监听端口
PORT=3000
# SQLite 数据库文件路径(容器内路径,挂载到宿主机 data/ 目录)
DB_PATH=/app/data/birthday.db
# 定时任务 cron 表达式(默认每天 9:00)
REMINDER_CRON=0 9 * * *
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
.env
*.log
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-bookworm-slim
# better-sqlite3 是原生模块,需要编译工具
RUN apt-get update && apt-get install -y \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先单独拷 package*.json,利用 docker 层缓存
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "src/index.js"]
+18
View File
@@ -0,0 +1,18 @@
services:
birthday-server:
build:
context: .
args:
HTTP_PROXY: http://host.docker.internal:29758
HTTPS_PROXY: http://host.docker.internal:29758
NO_PROXY: localhost,127.0.0.1
container_name: birthday-server
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./data:/app/data
environment:
- TZ=Asia/Shanghai
+20
View File
@@ -0,0 +1,20 @@
{
"name": "birthday-reminder-server",
"version": "1.0.0",
"description": "生日提醒小程序自建后端",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"axios": "^1.7.7",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"node-cron": "^3.0.3"
},
"engines": {
"node": ">=18"
}
}
+51
View File
@@ -0,0 +1,51 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'birthday.db')
// 确保数据目录存在
const dir = path.dirname(dbPath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
// 纪念日表:兼容原云数据库 anniversaries 集合字段
db.exec(`
CREATE TABLE IF NOT EXISTS anniversaries (
id TEXT PRIMARY KEY,
openid TEXT NOT NULL,
personId TEXT,
personName TEXT,
type TEXT,
customTypeName TEXT,
isLunar INTEGER DEFAULT 0,
solarYear INTEGER,
solarMonth INTEGER,
solarDay INTEGER,
importance TEXT,
remindEnabled INTEGER DEFAULT 0,
remindDays INTEGER DEFAULT 0,
remark TEXT,
createTime INTEGER,
updateTime INTEGER
);
CREATE INDEX IF NOT EXISTS idx_anniv_openid ON anniversaries(openid);
CREATE INDEX IF NOT EXISTS idx_anniv_remind ON anniversaries(remindEnabled);
CREATE TABLE IF NOT EXISTS remind_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anniversaryId TEXT,
personName TEXT,
typeName TEXT,
daysUntil INTEGER,
sendDate INTEGER,
status TEXT,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId);
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate);
`)
module.exports = db
+145
View File
@@ -0,0 +1,145 @@
require('dotenv').config()
const express = require('express')
const db = require('./db')
const wx = require('./wx')
const reminder = require('./reminder')
const app = express()
app.use(express.json({ limit: '1mb' }))
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ ok: true, time: Date.now() })
})
// 登录:前端传 code,返回 openid
app.post('/api/login', async (req, res) => {
try {
const { code } = req.body
if (!code) return res.json({ success: false, error: '缺少 code' })
const result = await wx.code2session(code)
res.json({ success: true, openid: result.openid, unionid: result.unionid })
} catch (err) {
console.error('登录失败:', err.message)
res.json({ success: false, error: err.message })
}
})
// 纪念日操作(兼容原云函数 syncAnniversary 的 action 协议)
app.post('/api/anniversary', (req, res) => {
try {
const openid = req.headers['x-openid'] || req.body.openid
if (!openid) return res.json({ success: false, error: '缺少 openid' })
const { action, data } = req.body
switch (action) {
case 'add': return res.json(addAnniversary(openid, data))
case 'update': return res.json(updateAnniversary(openid, data))
case 'delete': return res.json(deleteAnniversary(openid, data.id))
case 'sync': return res.json(syncAnniversaries(openid, data))
case 'get': return res.json(getAnniversaries(openid))
default: return res.json({ success: false, error: '未知操作' })
}
} catch (err) {
console.error('纪念日操作失败:', err.message)
res.json({ success: false, error: err.message })
}
})
// ---- 纪念日 CRUD ----
const FIELDS = [
'id', 'openid', 'personId', 'personName', 'type', 'customTypeName',
'isLunar', 'solarYear', 'solarMonth', 'solarDay', 'importance',
'remindEnabled', 'remindDays', 'remark', 'createTime', 'updateTime'
]
function normalize(row) {
if (!row) return row
return {
...row,
isLunar: !!row.isLunar,
remindEnabled: !!row.remindEnabled
}
}
function addAnniversary(openid, anniv) {
const now = Date.now()
const id = anniv.id || ('id_' + now + '_' + Math.random().toString(36).slice(2, 11))
const row = {
id, openid,
personId: anniv.personId || null,
personName: anniv.personName || '',
type: anniv.type || 'other',
customTypeName: anniv.customTypeName || null,
isLunar: anniv.isLunar ? 1 : 0,
solarYear: anniv.solarYear ?? null,
solarMonth: anniv.solarMonth,
solarDay: anniv.solarDay,
importance: anniv.importance || 'normal',
remindEnabled: anniv.remindEnabled ? 1 : 0,
remindDays: anniv.remindDays ?? 0,
remark: anniv.remark || null,
createTime: anniv.createTime || now,
updateTime: now
}
const placeholders = FIELDS.map(f => '@' + f).join(', ')
db.prepare(`INSERT OR REPLACE INTO anniversaries (${FIELDS.join(',')}) VALUES (${placeholders})`).run(row)
return { success: true, id }
}
function updateAnniversary(openid, anniv) {
const existing = db.prepare('SELECT * FROM anniversaries WHERE id = ? AND openid = ?').get(anniv.id, openid)
if (!existing) return { success: false, error: '纪念日不存在' }
const merged = {
...existing,
...anniv,
openid,
isLunar: ('isLunar' in anniv ? (anniv.isLunar ? 1 : 0) : existing.isLunar),
remindEnabled: ('remindEnabled' in anniv ? (anniv.remindEnabled ? 1 : 0) : existing.remindEnabled),
updateTime: Date.now()
}
const sets = FIELDS.filter(f => f !== 'id' && f !== 'openid' && f !== 'createTime')
.map(f => `${f} = @${f}`).join(', ')
db.prepare(`UPDATE anniversaries SET ${sets} WHERE id = @id AND openid = @openid`).run(merged)
return { success: true }
}
function deleteAnniversary(openid, id) {
const info = db.prepare('DELETE FROM anniversaries WHERE id = ? AND openid = ?').run(id, openid)
return { success: info.changes > 0 }
}
function syncAnniversaries(openid, list) {
const tx = db.transaction((items) => {
db.prepare('DELETE FROM anniversaries WHERE openid = ?').run(openid)
for (const item of items) addAnniversary(openid, item)
})
tx(Array.isArray(list) ? list : [])
return { success: true, count: Array.isArray(list) ? list.length : 0 }
}
function getAnniversaries(openid) {
const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? ORDER BY createTime DESC').all(openid)
return { success: true, data: rows.map(normalize) }
}
// 手动触发提醒任务(便于调试,无需等到定点)
app.post('/api/reminder/run', async (req, res) => {
try {
const result = await reminder.runOnce()
res.json({ success: true, ...result })
} catch (err) {
res.json({ success: false, error: err.message })
}
})
// ---- 启动 ----
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`生日提醒后端已启动,监听 ${PORT}`)
reminder.start()
})
+139
View File
@@ -0,0 +1,139 @@
const cron = require('node-cron')
const db = require('./db')
const wx = require('./wx')
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
const TYPE_NAMES = {
birthday: '公历生日',
lunar_birthday: '农历生日',
wedding: '结婚纪念日',
engagement: '订婚纪念日',
other: '其他纪念日'
}
function getTypeName(type, customName) {
if (type === 'other' && customName) return customName
return TYPE_NAMES[type] || '纪念日'
}
function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
// 算出"距离今年(或明年)这个纪念日还有多少天"
function getThisYearDate(anniv) {
const today = new Date()
today.setHours(0, 0, 0, 0)
let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0)
if (target < today) {
target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0)
}
return target
}
function daysBetween(target) {
const today = new Date()
today.setHours(0, 0, 0, 0)
return Math.round((target - today) / 86400000)
}
// 检查今天是否已经给这条纪念日发过提醒
function alreadySentToday(anniversaryId) {
const start = new Date()
start.setHours(0, 0, 0, 0)
const row = db.prepare(
'SELECT COUNT(*) AS n FROM remind_logs WHERE anniversaryId = ? AND sendDate >= ? AND status = ?'
).get(anniversaryId, start.getTime(), 'success')
return row.n > 0
}
const insertLog = db.prepare(`
INSERT INTO remind_logs (anniversaryId, personName, typeName, daysUntil, sendDate, status, error)
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
`)
async function runOnce() {
console.log('[reminder] 开始扫描纪念日...')
const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all()
console.log(`[reminder] 启用提醒的纪念日 ${list.length}`)
let ok = 0
let fail = 0
for (const anniv of list) {
try {
const target = getThisYearDate(anniv)
const daysUntil = daysBetween(target)
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0))
if (!shouldRemind) continue
if (alreadySentToday(anniv.id)) {
console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`)
continue
}
const typeName = getTypeName(anniv.type, anniv.customTypeName)
await wx.sendSubscribeMessage({
touser: anniv.openid,
page: 'pages/index/index',
templateId: TEMPLATE_ID,
miniprogramState: MINIPROGRAM_STATE,
data: {
name1: { value: anniv.personName },
thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}` },
thing6: { value: formatDate(target) },
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
}
})
insertLog.run({
anniversaryId: anniv.id,
personName: anniv.personName,
typeName,
daysUntil,
sendDate: Date.now(),
status: 'success',
error: null
})
ok++
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`)
} catch (err) {
fail++
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message)
insertLog.run({
anniversaryId: anniv.id,
personName: anniv.personName,
typeName: null,
daysUntil: null,
sendDate: Date.now(),
status: 'failed',
error: err.message
})
}
}
console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`)
return { total: list.length, ok, fail }
}
function start() {
const expr = process.env.REMINDER_CRON || '0 9 * * *'
if (!cron.validate(expr)) {
console.error(`[reminder] 无效的 cron 表达式: ${expr},定时任务未启动`)
return
}
cron.schedule(expr, runOnce, { timezone: 'Asia/Shanghai' })
console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`)
}
module.exports = { start, runOnce }
+57
View File
@@ -0,0 +1,57 @@
const axios = require('axios')
const APPID = process.env.WX_APPID
const APPSECRET = process.env.WX_APPSECRET
// access_token 内存缓存(微信全局唯一,2 小时有效)
let _token = { value: null, expireAt: 0 }
async function getAccessToken() {
const now = Date.now()
// 提前 5 分钟刷新,避免临界过期
if (_token.value && _token.expireAt - now > 5 * 60 * 1000) {
return _token.value
}
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`
const { data } = await axios.get(url, { timeout: 8000 })
if (!data.access_token) {
throw new Error(`获取 access_token 失败: ${JSON.stringify(data)}`)
}
_token = {
value: data.access_token,
expireAt: now + (data.expires_in - 300) * 1000
}
return _token.value
}
// code2session:拿 openid(小程序登录)
async function code2session(code) {
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${APPSECRET}&js_code=${code}&grant_type=authorization_code`
const { data } = await axios.get(url, { timeout: 8000 })
if (data.errcode) {
throw new Error(`code2session 失败: ${JSON.stringify(data)}`)
}
return { openid: data.openid, unionid: data.unionid || null, session_key: data.session_key }
}
// 发送订阅消息
async function sendSubscribeMessage({ touser, page, data, templateId, miniprogramState = 'formal' }) {
const token = await getAccessToken()
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`
const body = {
touser,
template_id: templateId,
page,
miniprogram_state: miniprogramState,
lang: 'zh_CN',
data
}
const { data: res } = await axios.post(url, body, { timeout: 8000 })
if (res.errcode !== 0) {
throw new Error(`发送订阅消息失败: ${JSON.stringify(res)}`)
}
return res
}
module.exports = { getAccessToken, code2session, sendSubscribeMessage }