1. 项目背景与核心需求
去年接手公司行政部门的数字化改造需求时,发现现有考勤和订餐系统存在几个痛点:传统打卡机数据难以统计,微信群接龙订餐混乱易错,行政人员每天要花2小时手工整理数据。作为技术负责人,我决定用最精简的技术栈开发一套轻量级解决方案。
这个系统需要同时满足三个核心需求:
- 员工端:网页打卡+订餐一键完成
- 管理端:实时查看考勤统计与订餐汇总
- 行政端:自动生成日报表并同步食堂
选择Node.js+SQLite方案主要基于以下考量:
- 零部署成本:行政部只有一台老旧Windows电脑
- 开发效率:全JavaScript技术栈单人可快速迭代
- 数据安全:所有数据存储在本地无需担心云服务合规问题
2. 技术架构设计
2.1 整体技术栈选型
前端采用经典组合:
- EJS模板引擎(避免构建工具复杂度)
- Bootstrap 5.2(响应式布局开箱即用)
- Vanilla JS(减少依赖项)
后端服务层:
- Express 4.x(路由中间件)
- Sequelize ORM(数据库操作)
- Node-schedule(定时任务)
数据存储:
- SQLite3(单文件数据库)
- CSV导出模块(兼容行政现有Excel流程)
2.2 数据库设计要点
考虑到考勤和订餐的强关联性,采用单数据库多表设计:
sql复制CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
department TEXT,
card_id TEXT UNIQUE
);
CREATE TABLE checkins (
id INTEGER PRIMARY KEY,
employee_id INTEGER REFERENCES employees(id),
checkin_time DATETIME DEFAULT CURRENT_TIMESTAMP,
location TEXT
);
CREATE TABLE meals (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
employee_id INTEGER REFERENCES employees(id),
option TEXT CHECK(option IN ('A','B','C'))
);
特别设计了联合索引提升查询效率:
sql复制CREATE INDEX idx_checkin_daily
ON checkins(employee_id, date(checkin_time));
CREATE INDEX idx_meal_orders
ON meals(date, department);
3. 核心功能实现细节
3.1 考勤打卡模块
采用地理围栏+设备指纹双验证:
javascript复制// 前台获取地理位置
navigator.geolocation.getCurrentPosition(pos => {
const {latitude, longitude} = pos.coords;
if(!isInOfficeRange(latitude, longitude)) {
alert('请在公司范围内打卡');
return;
}
// 生成设备指纹
const fingerprint = generateFingerprint();
submitCheckin(fingerprint);
});
后台验证逻辑包含防作弊机制:
javascript复制async function verifyCheckin(fingerprint) {
// 同设备15分钟内禁止重复打卡
const lastCheckin = await Checkin.findOne({
where: {
fingerprint,
checkin_time: {
[Op.gt]: new Date(Date.now() - 15*60*1000)
}
}
});
if(lastCheckin) throw new Error('打卡过于频繁');
// 工作日验证
const today = new Date();
if([0,6].includes(today.getDay())) {
await Holiday.findOrCreate({ date: today });
}
}
3.2 订餐系统实现
采用乐观锁解决订餐并发问题:
javascript复制router.post('/order', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const meal = await Meal.findOne({
where: { date: req.body.date },
transaction
});
if(meal.locked) {
throw new Error('今日订餐已截止');
}
// 检查是否已订餐
const existing = await Meal.findOne({
where: {
date: req.body.date,
employee_id: req.user.id
},
transaction
});
if(existing) {
await existing.update({ option: req.body.option }, { transaction });
} else {
await Meal.create({
date: req.body.date,
employee_id: req.user.id,
option: req.body.option
}, { transaction });
}
await transaction.commit();
res.sendStatus(200);
} catch (err) {
await transaction.rollback();
res.status(400).json({ error: err.message });
}
});
4. 性能优化实践
4.1 SQLite调优配置
在app启动时添加以下优化配置:
javascript复制const db = new Sequelize({
dialect: 'sqlite',
storage: 'db.sqlite',
logging: false,
pool: {
max: 5,
min: 0,
idle: 10000
},
retry: {
max: 3
}
});
// 性能优化设置
await db.query('PRAGMA journal_mode = WAL;');
await db.query('PRAGMA synchronous = NORMAL;');
await db.query('PRAGMA cache_size = -10000;'); // 10MB缓存
4.2 报表生成优化
采用流式CSV导出避免内存溢出:
javascript复制function generateDailyReport(date) {
const filename = `report_${date}.csv`;
const writable = fs.createWriteStream(filename);
writable.write('部门,姓名,打卡时间,订餐选项\n');
return new Promise((resolve, reject) => {
sequelize.query(`
SELECT e.department, e.name,
strftime('%H:%M', c.checkin_time) as time,
m.option
FROM employees e
LEFT JOIN checkins c ON e.id = c.employee_id
LEFT JOIN meals m ON e.id = m.employee_id
WHERE date(c.checkin_time) = ?
ORDER BY e.department, e.name
`, {
replacements: [date],
type: QueryTypes.SELECT,
stream: true
}).then(stream => {
stream.on('data', row => {
writable.write(`${row.department},${row.name},${row.time},${row.option}\n`);
});
stream.on('end', () => {
writable.end();
resolve(filename);
});
stream.on('error', reject);
});
});
}
5. 部署与运维方案
5.1 零配置启动方案
创建一键启动批处理脚本:
bat复制@echo off
set NODE_ENV=production
start /B node server.js
start http://localhost:3000
exit
配合Windows任务计划实现开机自启:
- 创建基本任务
- 触发器选择"计算机启动时"
- 操作选择"启动程序"
- 程序路径指向批处理脚本
5.2 数据备份策略
采用增量备份方案:
javascript复制const backup = require('backup-db')('sqlite3', {
db: 'db.sqlite',
backup: 'backups',
frequency: '1d',
keep: 7
});
// 每天凌晨3点执行完整备份
schedule.scheduleJob('0 3 * * *', () => {
backup.backup(err => {
if(err) console.error('备份失败:', err);
else console.log('备份完成:', new Date());
});
});
6. 踩坑经验实录
6.1 SQLite并发写入问题
初期直接使用默认配置时,高峰期出现"database is locked"错误。解决方案:
- 启用WAL模式提升并发性
- 所有写操作添加事务
- 设置合理的busy_timeout(实测500ms最佳)
javascript复制// 在Sequelize初始化时添加
dialectOptions: {
timeout: 500
}
6.2 时区处理陷阱
发现跨日考勤统计异常,原因是:
- SQLite的datetime字段默认UTC时间
- 服务器时区配置为东八区
最终解决方案:
javascript复制// 所有时间查询使用本地时区转换
sequelize.query(`SELECT * FROM checkins
WHERE strftime('%Y-%m-%d', checkin_time, 'localtime') = ?`, {
replacements: [date]
});
6.3 内存泄漏排查
连续运行两周后出现内存暴涨,经排查发现:
- Express文件上传中间件未释放临时文件
- Sequelize连接未正确关闭
修复方案:
javascript复制// 添加上传清理中间件
app.use((req, res, next) => {
res.on('finish', () => {
if(req.file) fs.unlink(req.file.path, noop);
if(req.files) {
req.files.forEach(file => fs.unlink(file.path, noop));
}
});
next();
});
// 添加进程退出处理
process.on('SIGINT', async () => {
await sequelize.close();
process.exit(0);
});
7. 扩展功能实现
7.1 微信消息通知
集成企业微信API实现订餐提醒:
javascript复制const axios = require('axios');
async function sendWechatAlert(userId, message) {
const token = await getWechatToken();
return axios.post(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, {
touser: userId,
msgtype: "text",
agentid: WECHAT_AGENT_ID,
text: {
content: message
},
safe: 0
});
}
// 每日10:30发送订餐提醒
schedule.scheduleJob('30 10 * * *', async () => {
const users = await Employee.findAll();
for(const user of users) {
if(user.wechat_id) {
await sendWechatAlert(user.wechat_id,
`今日订餐截止11:30,请及时提交:${BASE_URL}/order`);
}
}
});
7.2 可视化报表
使用Chart.js生成部门考勤统计图:
javascript复制router.get('/stats', async (req, res) => {
const data = await sequelize.query(`
SELECT department,
COUNT(DISTINCT e.id) as total,
SUM(CASE WHEN date(c.checkin_time) = date('now')
THEN 1 ELSE 0 END) as checked
FROM employees e
LEFT JOIN checkins c ON e.id = c.employee_id
GROUP BY department
`, { type: QueryTypes.SELECT });
res.render('stats', {
departments: data.map(d => d.department),
totals: data.map(d => d.total),
checks: data.map(d => d.checked)
});
});
前端EJS模板:
html复制<canvas id="chart" width="800" height="400"></canvas>
<script>
new Chart(document.getElementById('chart'), {
type: 'bar',
data: {
labels: <%- JSON.stringify(departments) %>,
datasets: [
{
label: '应到人数',
data: <%- JSON.stringify(totals) %>,
backgroundColor: 'rgba(54, 162, 235, 0.5)'
},
{
label: '实到人数',
data: <%- JSON.stringify(checks) %>,
backgroundColor: 'rgba(75, 192, 192, 0.5)'
}
]
}
});
</script>
8. 安全防护措施
8.1 防SQL注入方案
- 所有查询使用参数化查询
- 动态表名采用白名单校验:
javascript复制const validTables = ['employees', 'checkins', 'meals'];
function validateTableName(table) {
if(!validTables.includes(table)) {
throw new Error('Invalid table name');
}
return table;
}
8.2 敏感数据保护
员工身份证号等字段采用AES加密:
javascript复制const crypto = require('crypto');
const CIPHER_KEY = process.env.CIPHER_KEY;
function encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc',
Buffer.from(CIPHER_KEY), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
function decrypt(text) {
const [iv, content] = text.split(':');
const decipher = crypto.createDecipheriv('aes-256-cbc',
Buffer.from(CIPHER_KEY), Buffer.from(iv, 'hex'));
let decrypted = decipher.update(Buffer.from(content, 'hex'));
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
9. 项目成果与反思
上线三个月后的关键指标:
- 考勤统计耗时从120分钟→3分钟
- 订餐错误率从15%→0.2%
- 系统稳定性99.8%(单次最长连续运行87天)
值得改进的方面:
- 初期低估了SQLite的并发需求,应提前做好压力测试
- 前端交互可以引入更轻量的框架如Preact
- 报表导出功能应该增加PDF格式支持
这套系统虽然技术简单,但精准解决了行政部门的痛点。最大的体会是:合适的才是最好的,在资源受限的场景下,轻量级技术栈反而能带来更高的投入产出比。后续计划将配置模块抽离为JSON文件,实现更灵活的策略调整。