ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Node.js + SQLite 实战:两天搭建轻量级企业考勤订饭系统

Node.js + SQLite 实战:两天搭建轻量级企业考勤订饭系统 先说个挺现实的场景公司日常里最琐碎但也最绕不开的两件事一个是考勤一个是订饭。月初对考勤表每天统计吃饭人数看起来都是小事真要做起来能把人逼疯。Excel传来传去微信群接龙刷屏月底对工时和餐费能对到怀疑人生。去年我用 Node.js 和 SQLite 从零搭了一套轻量级企业考勤订饭系统前后端全部自己搞定从设计表结构到部署上线花了两天时间。上线后行政同事每天中午不用再抱着表格挨个打电话催人我也借这个项目把全栈开发的整体链路完整走了一遍。这篇文章就是那次实战的完整复盘适合刚学完 Node.js 基础想做综合项目练手的朋友也适合需要在公司内网快速落地一套小工具的同学参考。这套系统的定位就是轻量级没有上 React/Vue没有用 MySQL/PostgreSQL核心依赖只有 Express 和 better-sqlite3前端是原生 HTML 加少量 JavaScript前后端同源部署。一台普通办公电脑甚至一台淘汰下来的旧笔记本就能把整套系统跑起来。下面我把技术选型、数据库设计、接口实现、常见坑位逐一拆开讲希望能让你少走一些弯路。1. 技术选型与整体设计思路1.1 为什么这个场景不用 MySQL而是 SQLite很多同学一听“企业级”三个字第一反应就是得配 MySQL、PostgreSQL不然不够“正规”。但仔细想想这个系统的实际使用场景一个中小型公司或者一个部门的内部工具同时在线人数撑死几十个人数据量一天最多几百条记录。这种量级上 MySQL纯粹是给自己加戏。SQLite 的核心优势就是它是一个单一文件数据库不需要单独安装数据库服务不需要配置端口、账号、权限程序直接读写这个文件。备份也极其简单——把这个 .db 文件复制一份就完成了备份恢复到另一台机器也只要把文件拷过去。运维成本几乎为零这对“轻量级企业内网工具”这个定位来说是压倒性的优势。很多人担心 SQLite 的并发能力实际上 SQLite 的写锁是库级锁同一时刻只允许一个写操作。但考勤打卡和订饭这种场景本来就不是高并发写入。早高峰九点前大家集中打卡假设五十个人挤在同一秒内打每个写操作是毫秒级完成串行执行也毫无压力。真正不适合 SQLite 的是那种需要大量并发写入、多实例同时访问的生产系统企业内网小工具完全够用。1.2 better-sqlite3 配上 ExpressNode.js 全栈的关键组合Node.js 生态里操作 SQLite 的库有十几个最主流的是 sqlite3 和 better-sqlite3。这两个我都用过sqlite3 是老牌库API 是异步回调风格用起来有点绕better-sqlite3 是同步 API性能更高写业务逻辑的时候像在写普通代码不用嵌套回调也不用 await 满天飞。better-sqlite3 的同步 API 在这个场景里反而更安全。考勤打卡这种操作如果用异步 API容易出现两个请求交错的边界问题。better-sqlite3 直接在同一个线程里串行执行 SQL前面一个写操作没跑完后面一个不会插进来业务逻辑天然就是线性的排查问题也简单。项目整体架构非常直白浏览器请求 Node.js 服务Express 处理路由better-sqlite3 读写 SQLite 文件。静态页面也由同一个 Express 服务托管前后端同源省去了配置 Nginx 反向代理、处理跨域 CORS 等一系列麻烦事。1.3 系统模块划分与页面流转动手前我把系统拆成了三个核心模块员工与部门管理、考勤打卡、订饭统计。这三个模块不是拍脑袋分的而是对应着企业里三个不同角色的真实诉求行政要管人、要统计考勤和餐费员工要打卡、要订饭管理员要看明细、看汇总。页面流转也顺着角色来设计。员工打开首页看到的是打卡页支持上下班打卡打卡后能看到今天的打卡时间和累计工时。旁边一个入口进订饭页选择今天吃什么。管理员进入管理后台可以维护部门员工信息按日期查看考勤明细按部门汇总订饭统计。整体设计思路就一句话用最小的技术栈覆盖最核心的业务闭环。不做用户注册不做复杂的权限体系不搞在线聊天把考勤和订饭这两件事做透这个系统就已经具备上线价值了。2. 数据库设计与核心字段解读2.1 三张核心表员工、考勤、订饭建表是整个项目的地基字段设计的好不好直接影响后面接口写起来顺不顺手。我先给出完整的建表语句再逐条拆解设计意图。-- 部门表 CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 员工表 CREATE TABLE IF NOT EXISTS employees ( id INTEGER PRIMARY KEY AUTOINCREMENT, dept_id INTEGER NOT NULL REFERENCES departments(id), name TEXT NOT NULL, badge_no TEXT UNIQUE NOT NULL, role INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 考勤表 CREATE TABLE IF NOT EXISTS attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER NOT NULL REFERENCES employees(id), work_date TEXT NOT NULL, check_in TEXT, check_out TEXT, source TEXT DEFAULT web, UNIQUE(employee_id, work_date) ); -- 订饭表 CREATE TABLE IF NOT EXISTS meal_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER NOT NULL REFERENCES employees(id), order_date TEXT NOT NULL, meal_type TEXT NOT NULL DEFAULT normal, created_at TEXT DEFAULT (datetime(now, localtime)), UNIQUE(employee_id, order_date) );department 和 employee 是基础数据互相之间用外键关联。考勤表 attendance 里最关键的设计是 UNIQUE(employee_id, work_date) 这个联合唯一约束它保证了同一个员工同一天只能有一条考勤记录重复打卡不会造成数据错乱。订饭表 meal_orders 同样用了 UNIQUE(employee_id, order_date)同一个人同一天不能重复订饭只能更新餐型或者退订。数据库层面的唯一约束是最后一道防线比前端按钮是否置灰要可靠得多。2.2 日期字段为什么用 YYYY-MM-DD 文本考勤表里我用 work_date 存日期订饭表里用 order_date 存日期两个字段的格式都统一是 YYYY-MM-DD 这种纯文本字符串没有用 datetime 带时分秒。原因很简单考勤和订饭的关注粒度本来就在“天”这个级别我需要的是按天去重、按月统计而不是精确到秒的时间戳。YYYY-MM-DD 格式还有一个优势文本排序就是时间顺序。SQLite 里直接执行WHERE work_date 2025-01-01 AND work_date 2025-02-01就能拿到一月份的考勤记录不用调用任何日期函数索引也能正常使用。订饭统计同理GROUP BY order_date一查每天多少人吃饭一目了然。时间相关字段直接在 Node.js 层用 dayjs 生成不依赖数据库的 datetime(now) 函数。这样做的好处是时间来源统一不会出现服务器时区和本地时区不一致导致的八小时偏差问题。2.3 踩坑实录SQLite 里没有“改列类型”这回事这个坑我印象很深当时想把 employees 表里的 badge_no 从 TEXT 改成 INTEGER因为行政那边突然说员工工号是纯数字的。我自然以为可以像 MySQL 一样执行ALTER TABLE employees MODIFY COLUMN badge_no INTEGER结果 SQLite 直接报错根本不存在这个语法。SQLite 的 ALTER TABLE 只支持重命名表和新增列不支持修改列类型、删除列、调整列顺序。遇到这种情况标准做法是“建新表、导数据、删旧表、改新表名”四步走-- 1. 创建新表字段类型按新需求来 CREATE TABLE employees_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, dept_id INTEGER NOT NULL REFERENCES departments(id), name TEXT NOT NULL, badge_no INTEGER UNIQUE NOT NULL, role INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 2. 把旧表数据复制过来 INSERT INTO employees_new (id, dept_id, name, badge_no, role, is_active, created_at) SELECT id, dept_id, name, badge_no, role, is_active, created_at FROM employees; -- 3. 删除旧表 DROP TABLE employees; -- 4. 新表改名为正式表 ALTER TABLE employees_new RENAME TO employees;这套操作本身不复杂但生产环境执行前必须先把 .db 文件备份。如果在正式库上直接跑万一中间哪一步写错数据可能就丢了。所以我在项目里加了一个约定凡是涉及表结构变更一律写一个单独的 Node.js 脚本执行不让手工在数据库客户端里敲避免误操作。另一个心得是字段类型在项目初期尽量定宽松一点。SQLite 本身就是弱类型数据库TEXT、INTEGER 之间的界限没有 MySQL 那么严格。拿不准的字段优先用 TEXT后面业务变化了再用上面的重建流程能少踩不少坑。3. 接口设计与全栈实现3.1 接口路由划分与数据流后端接口按 REST 风格划分核心接口遵循着“资源 动作”的规范。我列一下主要的接口方法路径说明POST/api/login登录校验员工号GET/api/me根据请求头获取当前员工信息POST/api/attendance/check打卡type 区分上班/下班GET/api/attendance/month按月查询考勤记录POST/api/meal/order订饭或修改餐型DELETE/api/meal/order取消订饭GET/api/meal/today查当天订饭状态GET/api/meal/summary按日期汇总订饭统计GET/api/admin/employees管理员查员工列表POST/api/admin/employees管理员新增员工数据流很简单前端把请求发给 ExpressExpress 解析路由调用 better-sqlite3 的预编译语句查询或写入 SQLite拿到结果后 JSON 返回给前端。所有接口共用同一个数据库连接better-sqlite3 允许多个预编译语句同时使用内部自己处理锁竞争。3.2 打卡接口的防重与更新逻辑打卡接口是考勤系统的核心设计难点在于同一天内员工可能打多次卡比如早上先按了一次“上班”发现没按上又按一次怎么保证不产生脏数据我直接用 SQLite 的 UPSERT 语法搞定app.post(/api/attendance/check, (req, res) { const { employeeId, type } req.body; const today dayjs().format(YYYY-MM-DD); const now dayjs().format(HH:mm:ss); const result db.prepare( INSERT INTO attendance (employee_id, work_date, check_in, check_out) VALUES (?, ?, ?, ?) ON CONFLICT(employee_id, work_date) DO UPDATE SET check_in CASE WHEN ? check_in THEN ? ELSE check_in END, check_out CASE WHEN ? check_out THEN ? ELSE check_out END ).run(employeeId, today, type check_in ? now : null, type check_out ? now : null, type, now, type, now); res.json({ ok: true, data: result }); });这个逻辑拆开看是这样的第一次打卡时表里没有当天记录走 INSERT 直接插入一行check_in 或 check_out 按 type 写入对应时间第二次打卡时 UNIQUE 约束触发冲突走 UPDATE 分支用 CASE 表达式只更新对应的时间字段另一个字段保持不动。为什么不用前端先查一遍再决定 insert 还是 update因为那两步操作之间有间隙两个请求同时进来的时候依然可能重复插入。把逻辑下推到数据库层面用唯一约束兜底不管前端怎么点按钮都不会出现一条员工同一天两条考勤的问题。3.3 订饭接口的截止时间与退订处理订饭比考勤多了一个业务规则截止时间。我定的是每天上午 11 点前可以订饭11 点后不能订也不能退。这个判断放在 Node.js 层做因为不是 SQL 能优雅表达的逻辑用 dayjs 取当前时间比较最直观app.post(/api/meal/order, (req, res) { const { employeeId, mealType } req.body; const now dayjs(); const today now.format(YYYY-MM-DD); if (now.hour() 11) { return res.status(400).json({ ok: false, message: 已过订饭截止时间 }); } const result db.prepare( INSERT INTO meal_orders (employee_id, order_date, meal_type) VALUES (?, ?, ?) ON CONFLICT(employee_id, order_date) DO UPDATE SET meal_type excluded.meal_type ).run(employeeId, today, mealType); res.json({ ok: true, data: result }); });excluded.meal_type是 UPSERT 语法里常用的写法代表“我这次要插入的值”冲突时直接用新值覆盖旧值这样员工可以随时改餐型不用先删再插。退订接口更简单一条 DELETE 语句加截止时间判断app.delete(/api/meal/order, (req, res) { const employeeId Number(req.headers[x-employee-id]); const today dayjs().format(YYYY-MM-DD); const result db.prepare( DELETE FROM meal_orders WHERE employee_id ? AND order_date ? ).run(employeeId, today); res.json({ ok: true, deleted: result.changes }); });daily summary 接口是行政最喜欢的功能一条 GROUP BY 搞定SELECT mo.order_date, d.name AS dept_name, mo.meal_type, COUNT(*) AS cnt FROM meal_orders mo JOIN employees e ON e.id mo.employee_id JOIN departments d ON d.id e.dept_id WHERE mo.order_date ? GROUP BY mo.order_date, d.name, mo.meal_type ORDER BY d.sort_order这段 SQL 把订单表和员工表、部门表关联起来一次查出当天每个部门订了哪种餐、各多少人行政直接把接口返回的数组渲染成表格就好。3.4 前端页面与权限保持的极简方案为了保持轻量级前端完全没上框架。页面就三个index.html 打卡页、meal.html 订饭页、admin.html 管理页公共样式写在一个 style.css 里公共逻辑写在一个 common.js 里用原生 fetch 调接口。登录方案我做了极简化处理。员工在打卡页输入工号后端校验存在后就把工号存进 localStorage后续每个请求都带上 X-Employee-Id 请求头。后端写了一个小中间件统一解析app.use(/api, (req, res, next) { const employeeId Number(req.headers[x-employee-id]); if (!employeeId) { return res.status(401).json({ ok: false, message: 未登录 }); } const emp db.prepare( SELECT * FROM employees WHERE id ? AND is_active 1 ).get(employeeId); if (!emp) { return res.status(401).json({ ok: false, message: 无效员工 }); } req.employee emp; next(); });管理员的权限判断更简单在管理接口里检查req.employee.role 1就行。说实话这套方案没有做密码校验放在公网上肯定不行但企业内网工具本身就是低信任成本环境追求的是快速可用。如果你要在非完全内网的环境部署建议至少加一层工号加密码的登录接口原理一样多一个字段而已。4. 实操过程中的关键工具与性能评估4.1 Node.js 版本选择与 Node 环境准备项目启动前先把 Node.js 环境装好。这里有一个很重要的原则正式项目用 LTS 版本别追最新的 Current 版本。LTS 全称是 Long Term Support官方承诺了长时间的安全更新和维护生产环境踩坑概率小很多。如果你需要在一台机器上同时维护多个 Node.js 项目建议装一个 nvm-windowsWindows 下或者 nvmMac/Linux 下。nvm 的好处是版本切换就像换通道一样快nvm install 22装好nvm use 22切过去某个项目需要老版本也不会打架我现在同时维护三四个项目来回切换一次没出过问题。装完之后用node -v和npm -v验证一下版本号。项目里我用的 Express 4.xbetter-sqlite3 最新版这两个包对 Node 版本要求都不高只要是 LTS 版本都没问题。4.2 用 DB Browser for SQLite 做日常维护开发阶段和上线之后我几乎每天都会用到 DB Browser for SQLite。这是一个跨平台的开源 SQLite 图形化管理工具Windows、macOS、Linux 都有安装包界面长得很像 Navicat但它是完全免费的。这个工具在我工作流里干三件事第一快速浏览表结构和数据检查考勤写入是否正常不用开终端敲 SELECT第二直接执行调试用的 SQL 语句比如算一下这个月总共欠了多少顿餐费第三导出 CSV 给行政做对账菜单栏里“导出表为 CSV”一点整个表的明细就能拉出来。日常排错时它还有一个大用处当代码报错但不确定数据到底长什么样时直接打开系统运行时用的 .db 文件切到“浏览数据”标签页看到什么就能对症下药。不过要注意程序正在运行的时候用这个工具打开数据库文件最好只做只读操作不要在里面改数据避免和业务代码的写入操作发生冲突。4.3 十万行数据查询速度实测之前看到有热搜词问“十万条数据SQLite 查询需要多久”当时我正好在优化考勤统计接口顺手做了一组测试。用脚本向 attendance 表里插入了十万条随机考勤记录覆盖过去两年的数据然后跑了几条典型查询。先说结论在带索引的情况下按员工加月份筛选考勤记录的时间在 20 毫秒到 50 毫秒之间全表做 COUNT(*) 统计大概是 100 到 200 毫秒无索引条件下按月查询慢一些但也没超过 500 毫秒。对企业考勤系统来说一个几十人的团队干十年也未必攒得下十万条考勤记录这个性能完全顶得住。SQLite 的索引设计也很直白我在 attendance 表上建了CREATE INDEX idx_attendance_date ON attendance(work_date)meal_orders 表上建了CREATE INDEX idx_meal_date ON meal_orders(order_date)。其实更重要的是联合唯一约束在生产环境已经自动建了索引所以 employee_id 加 work_date 的精确查询是很高效的。5. 常见问题与排查技巧实录5.1 版本安装报错Node.js v24.21.0 is not yet released这个报错信息我在热搜里看到过自己也踩过很多次坑。用 nvm 安装 Node.js 时如果想装的版本号还没有被收录进 nvm 的可下载列表就会报类似error installing 24.21.0: node.js v24.21.0 is not yet released or is not available的错误。出现这个问题的根源是 nvm 的版本列表和官网发布版本不同步一般是镜像源还没更新。解决办法分两步先执行nvm ls available查看当前可下载的版本列表再挑一个最新的 LTS 版本安装。千万别执着于安装报错信息里那个具体版本号说明它还没进列表等两天再装或者直接选一个列表里的版本就好。5.2 SQLite 锁死database is locked这个报错在项目刚上线时出现过一次当时好几个员工同时点打卡偶尔会有请求返回 500错误日志显示SQLITE_BUSY: database is locked。SQLite 在默认的 delete 日志模式下读操作和写操作之间会发生互相阻塞多个写请求并发时尤其明显。解决办法是在项目启动时开启 WAL 模式const db new Database(./data/company.db); db.pragma(journal_mode WAL); db.pragma(busy_timeout 5000);WAL 即 Write-Ahead Logging写操作先写到日志文件里再合并回主数据库读操作可以跟写操作同时进行不再互相锁死。busy_timeout 表示如果数据库被占用最多等 5 秒超过就超时报错。开启 WAL 之后数据库目录下会多出 .db-wal 和 .db-shm 两个文件这是正常的不是残留垃圾不要手动删它们。如果开了 WAL 之后偶尔还有锁冲突多半是某个事务执行时间太长或者有地方打开了事务忘记提交。排查方法是把所有写操作改成 short transaction打开事务后马上执行所有预编译语句执行完立刻 commit不给锁存活的窗口。5.3 时区偏差八小时与 Windows 换行符问题时区问题是我刚开始开发就遇到的在本地 Windows 上测试一切正常部署到一台装有 Rocky Linux 的内网服务器后考勤记录的时间差了八个小时。原因很简单数据库里的datetime(now,localtime)是拿服务器的本地时间Windows 开发机和 Linux 服务器时区配置不同SQLite 按各自时区生成了不同的时间。解决方案就是前面提过的所有时间字段全部在 Node.js 层计算用 dayjs 生成统一的 YYYY-MM-DD 和 HH:mm:ss 字符串数据库层完全不去生成时间。这样不管程序部署在哪台机器、什么时区行为完全一致。这里也提醒一点如果你从 MySQL 这种带复杂日期类型的库迁到 SQLite最容易出的就是这种时间类型转换问题建议统一用文本格式。Windows 换行符问题更像一个隐蔽事故用 Git 拉代码到 Linux 服务器时如果 .js 文件在 Windows 上保存为 CRLF 换行同一份代码在服务器上跑起来容易出现语法错误。解决方案是在项目根目录加一个.gitattributes文件内容写* textauto eollf保证提交到仓库的代码统一用 LF 换行这个习惯对任何跨平台 Node.js 项目都建议养成。5.4 上线后的运营小脚本迟到提醒与订饭汇总系统上线后我额外写了两个小脚本一个负责每天早上九点五分跑一遍查出当天还没打上班卡的人员名单另一个负责十一点统计订饭汇总。两者本质上都是 SQL 加邮件的组合。查未打卡的 SQL 长这样SELECT e.name AS emp_name, d.name AS dept_name FROM employees e JOIN departments d ON d.id e.dept_id LEFT JOIN attendance a ON a.employee_id e.id AND a.work_date ? WHERE e.is_active 1 AND a.id IS NULL这个查询的思路是从员工表出发左连接考勤表连接条件限制为当天日期如果考勤表里没有匹配到记录说明这个员工今天还没有打过卡。脚本用 node-cron 定时调度每天早上九点五分自动拉一遍结果把名单发到行政的邮箱和值班群省掉了一个个私聊催打卡的尴尬。订饭汇总就简单一些直接调前面写的 summary 接口把统计结果格式化成文本发出去。经过这两脚本加持行政同事几乎不用打开系统后台只要收邮件和群消息就够了。这个模式让系统从“被动查询工具”变成了“主动推送助手”实际上这才是企业内网工具最有价值的打开方式。我在实际运营中最大的体会是这个项目没有用到任何高深技术Express 加 better-sqlite3 加原生前端每一块都是简单纯粹的代码但它们组装起来之后确实把一个真实业务场景里的痛点解决了。很多时候我们容易陷入对技术先进性的追求反而忘了软件存在的意义是把日常琐事变得不再琐碎。最后再分享一个上线前容易忽略的细节数据库文件最好放在项目目录之外的一个独立路径比如/data/appdata/company.db避免部署升级时误删数据目录然后每天凌晨用文件复制的方式把 .db 文件备份到另一台机器对 SQLite 这种单文件数据库来说这就是最简单可靠的备份方案。如果后面有余力你还可以在现有基础上加一个导出 Excel 报表的功能行政的幸福感会再上一个台阶。
返回列表