- 新增 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:
@@ -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 * * *
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user