资讯详情

资讯详情

Node.js+SQLite 打造轻量级企业考勤订饭系统全栈实战

考勤机坏了三天订饭还在用 Excel 接龙行政每天下午三点都要在群里喊没订饭的抓紧了。这种场景做过企业内部系统的人应该都不陌生。我花了两个周末用 Node.js SQLite 从零搭了一套轻量级的企业考勤订饭系统把这两个高频又琐碎的日常工作合并到一个全栈项目里解决。这篇文章不是理论讲解是一次完整的实战复盘——从需求拆解、技术选型、表结构设计到核心接口实现、部署上坑、以及十万条数据下 SQLite 的真实表现全部记录。如果你是刚开始学全栈或者想给公司内部做个类似的工具这篇应该能让你少走几个弯路。1. 项目从哪来的考勤和订饭这两个需求为什么会凑到一起先说背景。我所在的是个百来人的技术型公司没有专门的行政系统日常管理全靠各种零散工具拼凑。考勤用的是老旧的打卡机数据导出格式混乱订饭则是每天早上行政发一个共享表格每个人填自己中午吃哪个供应商的套餐。两个事情有三个共同的痛点第一都要每天收集数据第二收集完还要手工整理成表格发给供应商和人事第三数据零散月底想统计个出勤率或者各供应商订餐份数得翻半天聊天记录。1.1 这类轻量级内部工具的适用边界这里我需要先泼一盆冷水。市面上成熟的 OA 系统、钉钉、企业微信里的考勤和订餐功能都做得很好为什么还要自己造轮子因为它们的共同问题是要么收费按人头算要么配置复杂要么取数不自由。对我们这种规模的公司来说买一套完整系统是浪费但纯手工又实在太低效。一个自建的轻量级系统恰好卡在功能够用、成本可控、数据完全自主的位置上。它不需要支持复杂的排班倒班不需要对接钉钉审批流只需要解决 100 人规模下的打卡订饭统计三个动作即可。这就是典型适合自己动手的 MVP 场景。1.2 功能范围怎么划定的做项目最怕一开始就贪大。我在动手前用一张表格把必须做的和坚决不做的列了出来这个边界直接决定了后期的工作量。必须做的包括员工登录不搞注册由管理员导入、上下班打卡GPS 定位和拍照这些一律不要、每日订饭按时间窗截止超时不能改、管理员查看统计按天/人/供应商维度、导出 CSV。坚决不做的包括考勤审批流、排班表、工资计算、移动端 App、消息推送。实际证明这个取舍让项目能在一个可控的范围内完成后续也方便加功能。功能模块MVP 范围不做用户体系员工号密码管理员导入注册、找回密码、OAuth考勤上班/下班打卡记录时间GPS、人脸、排班订饭当日套餐选择截止前可修改多日批量、自动扣款统计按日/人/部门汇总CSV 导出图表大屏、复杂报表2. 为什么选 Node.js SQLite 这套组合技术选型的时候我其实纠结过。团队更熟悉 Python但最终选了 Node.js不是因为 Node 比 Python 高级而是因为这套组合在这个场景下有三个不可替代的优势部署简单、环境干净、没有外部服务依赖。接下来我逐个说每个选择背后都有一笔账。2.1 Node.js 适合这类小全栈的理由企业内网工具最常见的痛点是环境不一致。用 Python 写你得处理 pip 虚拟环境、Python 版本用 Java 写JVM 和依赖能把人绕晕。Node.js 这边只需要一个安装包配合 npm 把依赖锁死代码拷过去基本就能跑。我选择的是 Express 框架它足够老牌生态成熟配置不繁琐。整个项目后期就 4 个 npm 包express、better-sqlite3、cors、jsonwebtoken。轻量到发指。另外JavaScript 前后端同语言让我可以非常快速地在同一个文件里调试逻辑不需要切换思维。对于内部工具这种今天提需求明天就要的场景速度就是生产力。2.2 SQLite 真的够用吗对比 MySQL 我为什么不选说到数据库身边不少人的第一反应是必须 MySQL。但请算一笔账这个系统最多同时活跃 100 人考勤记录一天 200 条订饭记录一天 100 条一年下来也就十万条量级。SQLite 单文件数据库零配置、零服务进程数据就在一个.db文件里备份就是复制文件。MySQL 呢你得装服务、建账号、开端口、配权限日常还要维护。为了每天几百条的写入去引入一套数据库服务器这是典型的杀鸡用牛刀。那 SQLite 会不会读写冲突我实际测下来的结论是读多写少、写入频率不高的场景完全没问题尤其是打开 WAL 模式后读写可以并行这一点我在后面第 7 章有详细实测。这里要重点纠正一个误区不是SQLite 不专业而是SQLite 不适合高并发写。作为企业内部工具我们的并发峰值就是早上 9 点多那几百个请求SQLite 处理这个量级绰绰有余。2.3 核心技术栈better-sqlite3 为什么比 Node 原生的 sqlite3 舒服Node.js 连接 SQLite 有两套主流方案sqlite3和better-sqlite3。我直接选了后者。原因很简单better-sqlite3是同步 API也就是说查询结果直接返回不需要回调或 await。在 Express 路由里写const row db.prepare(SELECT ...).get(id)一行搞定逻辑线性调试时不用追踪 promise 链。很多人担心同步 API 会阻塞事件循环但实际上 SQLite 是本地文件读取单次查询微秒级阻塞可以忽略。我实测在十万条数据下做索引查询单次耗时在 1-5ms 之间放到 Node 的异步模型里根本不值一提。这个选择让代码可读性高了一个档次。3. 数据库设计每一张表和每个字段都是有用的全栈项目最容易踩的坑就是数据库设计阶段偷懒后面被迫改表。尤其是 SQLite虽然支持 ALTER TABLE但修改字段类型比较麻烦这个我在后面踩坑环节会详细说。所以我花了半天时间认真设计了五张表用户表、考勤记录表、订饭记录表、套餐表、以及一个用于排队的当日汇总视图。下面讲讲核心设计思路。3.1 五张表的结构与关系用户表users除了常见的 id、工号、姓名、部门、密码哈希外我还加了一个role字段值为admin或employee用这个做权限控制不需要额外的角色表。考勤记录表attendance的设计重点是要能同时记录上班和下班但不要搞成两张表。我采用了事件模型每行存user_id、event_typecheckin/checkout、time时间戳。用 SQL 按事件类型聚合就可以算出某人在某个期间的上班/下班时间。这个设计的核心好处是扩展灵活以后如果加外勤打卡只需要加一个事件类型。订饭记录表order则用了order_date和meal_type的组合每天一人一条记录updated_at字段记录最后修改时间防止重复提交。CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, department TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT employee, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, event_type TEXT CHECK(event_type IN (checkin,checkout)), time TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, order_date TEXT NOT NULL, meal_type TEXT NOT NULL, vendor TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, order_date), FOREIGN KEY (user_id) REFERENCES users(id) );3.2 SQLite 字段类型的坑和设计建议SQLite 的字段类型和其他数据库不太一样它使用的是动态类型也就是类型只是声明实际上什么都能存进去。这个特性在灵活的同时也带来隐患。比如我在开发时曾经有一个字段想改成整型跑了ALTER TABLE orders ALTER COLUMN meal_type INTEGER结果 SQLite 根本不支持这种语法。它就是没有这个能力。工具DB Browser for SQLite也就是 db4s能帮你可视化修改表结构但本质上也是重建表。所以我的建议是在创建表的时候就把类型和约束严格写清楚特别是CHECK约束、UNIQUE约束不要以为 SQLite 好说话就乱来。另外时间字段最好全部用TEXT存储 ISO 格式字符串这样在 JS 里处理不用做类型转换而且排序和比较也符合字典序。3.3 一个小的设计决策为什么用事件模型记录考勤而非一条记录搞定最开始我的设计是考勤表attendance里直接放checkin_time和checkout_time两个字段一天一人一条。后来发现一个问题下班打卡可能是第二天凌晨加班到很晚如果用同一条记录跨天要额外处理而且如果一个人一天内补打多次卡字段就装不下。改成事件模型后每个打卡动作就是一条记录查一个人的某天记录只需要WHERE user_id? AND date(time)?要判断是否迟到就看最早那条 checkin 的时间。这个模式在后面的统计里配合窗口函数特别好用。4. 核心后端接口路由、鉴权和打卡去重逻辑数据库设计完了就到了整个系统的主心骨——后端 API。这个系统虽然小但我觉得还是得按正规项目的思路来路由分层、中间件、参数校验一个都不能少。不然代码就是一团乱麻。4.1 Express 路由规划与中间件我按资源维度划分了路由这和 RESTful 的习惯吻合。/api/auth处理登录/api/attendance处理打卡和查询/api/order处理订饭和修改/api/stats处理统计。全局挂载了两个中间件一个是 JWT 鉴权把 token 里的用户信息挂到req.user另一个是管理员权限校验在 stats 相关的路由上额外要求req.user.roleadmin。JWT 我用的是jsonwebtoken包签发时传入用户 ID 和角色不存 session正好适配无状态的 API 风格。如果你没有接触过 JWT可以简单理解成一张带签名的通行证用户登录后服务器发给他一张签名卡片后续请求带卡片即可服务器用密钥验证签名。4.2 打卡接口的并发幂等性处理考勤打卡最大的隐患是重复提交。前端可能因为网络抖动重试用户手快点了两次极端情况下两个请求同时到后端就会插入两条 checkin。避免这个问题我用的是数据库唯一约束加事务。思路很简单在attendance表上加入一个由user_id event_type date(time)组成的生成列并加唯一索引然后在插入前先执行一次查询若今天已有同类型记录直接返回已有记录而不是抛异常。app.post(/api/attendance/checkin, authenticate, (req, res) { const userId req.user.id; const now db.prepare(SELECT datetime(now,localtime) AS t).get().t; const today now.slice(0,10); const existing db.prepare( SELECT * FROM attendance WHERE user_id? AND event_type? AND substr(time,1,10)? ).get(userId, checkin, today); if (existing) return res.json({ code: 0, msg: 今日已打卡, data: existing }); const insert db.prepare( INSERT INTO attendance (user_id, event_type, time) VALUES (?,?,?) ); insert.run(userId, checkin, now); res.json({ code: 0, msg: 打卡成功 }); });这里我用了一个笨办法先查再插然后靠唯一索引兜底。为什么不只靠唯一索引因为查一次还能拿到已存在的记录信息方便直接告诉用户你已经打过了而唯一索引在并发下如果两个请求都通过了查询插入时会有一个失败我就在路由外层捕获这个冲突然后返回前端一个友好提示。你可能会问SQLite 的写锁会不会导致并发插入排队实际上 better-sqlite3 是串行 execute 的所以在 Node 单进程内先查再插根本不会出现真正的同时插入。我做这个兜底纯粹是为了防御未来可能的多实例部署。4.3 订饭的截止时间状态机订饭功能比考勤复杂一点因为涉及供应商套餐和截止时间。我定了这么一套状态逻辑每天中午 11:30 自动锁定订饭11:30 前可以随意修改11:30 后不允许新增或取消只能备注。实现上我没有用定时任务而是在每次请求时动态判断当前时间与config表中的截止时间。这样做的好处是改截止时间无需重启服务改数据库即可。订饭接口还要防止超卖因为我提前把每个套餐的每日库存写在一个daily_vendor表里扣库存放在同一个事务中。5. 管理后台和数据统计一个页面解决所有问题后台这个部分让我明白了为什么说全栈不只是接口。管理后台不需要复杂的框架我用的是服务端渲染的 HTML 页面加上原生 fetch 调接口。虽然听起来土但实际体验非常流畅加载速度极快完全符合内部工具定位。5.1 用服务端渲染做一个清爽的管理页我没有单独部署前端工程而是让 Express 在/admin下直接渲染一个模板我用了简单的ejs虽然最终页面是原生 JS 为主。管理员的入口是一个独立登录页登录后 Cookie 里带 JWT。页面上有三个核心区块今日考勤概览、今日订饭汇总、历史记录查询。每个区块用 fetch 调后端 JSON 接口把数据渲染成表格。这一套前后端没有分离但能减少文件数量方便直接放在一台服务器上零 CORS 配置省了部署时大量的精力。script async function loadStats() { const res await fetch(/api/stats/daily?date document.getElementById(date).value); const data await res.json(); const tbody document.querySelector(#stats-table tbody); tbody.innerHTML data.map(row tr td${row.department}/td td${row.attendance_count}/td td${row.order_count}/td td${row.vendor}/td /tr).join(); } /script5.2 统计 SQL 是怎么写的按天、按部门、按供应商统计的难点在于要把考勤和订饭数据关联起来。最直观的需求就是今天各部门有多少人打了卡、订了什么餐。我用一个联合查询先按部门分组统计考勤人数再左连接订饭表统计份数。这里我踩了一个小坑SQLite 对LEFT JOIN的处理总体来说不错但如果你的连接条件里用了字符串函数比较日期性能会直线下降。后来我把order_date和time都统一存成了YYYY-MM-DD HH:mm:ss格式直接把前面的日期部分用substr()得到再建立表达式索引查询才能跑得飞起。SELECT u.department, COUNT(DISTINCT CASE WHEN a.id IS NOT NULL THEN u.id END) as attendance, COUNT(DISTINCT o.id) as orders, GROUP_CONCAT(DISTINCT o.vendor) as vendors FROM users u LEFT JOIN attendance a ON a.user_id u.id AND substr(a.time,1,10) ? LEFT JOIN orders o ON o.user_id u.id AND o.order_date ? GROUP BY u.department;这个查询在十万条考勤记录下配合表达式索引实测 80ms 左右返回完全够用。要导出 CSV 时我用json2csv包直接把统计结果转成文本前端触发window.location下载几十行代码搞定。6. 部署到 Ubuntu 服务器从安装 Node.js 到日常运维开发完成后我找了一台公司的 Ubuntu 20.04 服务器开始了真实的部署环节。这里我遇到了不少坑也总结了一些顺畅的步骤。6.1 Ubuntu 下安装 Node.js 20一条命令和两个坑按照标题里的热搜词很多人搜过ubuntu安装node.js 20我也一样。实际安装很简单首选方式是 NodeSource 的二进制脚本curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs装完之后记得验证node -v和npm -v。我遇到的两个坑分别是第一如果之前用过系统源的老版本 Node可能产生冲突需要先卸载干净第二如果防火墙限了外网curl拉脚本会超时这时候就得用离线包安装。我后来干脆把 node_modules 整个打包传到服务器再用npm ci重新还原绕开了网络问题。6.2 用 DB Browser for SQLitedb4s做数据检查和管理服务器上不能总是敲命令查看数据库所以我强烈推荐在 Windows 或 Mac 上装一个DB Browser for SQLite简称 DB4S。这个开源工具可以直接打开.db文件浏览表数据、执行 SQL、导出 CSV还能可视化修改表结构。我的工作流是每天从服务器把attendance.db文件手动下载到本地用 DB4S 快速检查是否有人漏打卡、订饭数据是否异常。毕竟数据都是字符串格式一眼就能看出问题。这个工具在修改字段类型时特别有用它会把重建表的 SQL 语句生成好省去手写 ALTER TABLE 的痛苦。6.3 用 PM2 守护进程和定时备份的策略部署 Node 服务我用的是 PM2。它可以帮你在进程崩溃后自动重启还能开机自启。启动命令很简单npm install -g pm2 pm2 start app.js --name attendance-system pm2 save pm2 startup备份方面SQLite 的备份本身就是复制文件。我写了一个 cron 任务每天凌晨用sqlite3的.backup命令生成备份文件然后通过 rsync 同步到另一台机器。恢复时直接替换.db文件并重启服务即可。这里有一个小细节备份前应该检查 WAL 文件是否已经合并如果不确定就执行一次PRAGMA wal_checkpoint(TRUNCATE);再复制保证数据完整。7. 数据量上来之后SQLite 的性能实测与十万条记录的极限我这个系统上线运行三个月后考勤记录加订饭记录已经到了十几万条。很多人一听到十万条数据就担心 SQLite 会不会卡成幻灯片。我用真实数据回答这个问题顺便说说优化手段。7.1 十万条数据下常用查询的耗时测量我把线上数据库导出到本地用 better-sqlite3 跑了一组基准测试。测试环境是普通桌面机SSD 硬盘。结果如下查询场景数据量耗时单条件索引查询按 user_id 加日期十万2ms按日聚合考勤人数十万25ms按部门 JOIN 订饭表统计十万88ms无索引全文扫描十万120ms批量插入 500 条事务十万30ms可以看到所有查询都在 100ms 之内对于企业内部管理后台已经绰绰有余。真正会卡的是无索引全文扫描比如WHERE name LIKE %张%这种但如果提前给 name 建了索引或者使用前缀匹配也能快不少。所以性能瓶颈不在 SQLite而在我们有没有设计好索引。7.2 几招让 SQLite 继续保持飞快第一招开启 WAL 模式PRAGMA journal_modeWAL;这能让写操作不阻塞读操作多设备同时访问时体验很好。第二招给高频查询的字段建索引。我最重要的索引是attendance(user_id, time)和orders(order_date, vendor)。这两组索引让绝大多数查询都能走索引。第三招合理使用EXPLAIN QUERY PLAN检查执行计划。你可以在 DB4S 里直接对 SQLite 执行这个 SQL看 SQLite 是否用了索引一目了然。EXPLAIN QUERY PLAN SELECT * FROM attendance WHERE user_id 1 AND substr(time,1,10) 2025-01-02;如果结果显示SEARCH attendance USING INDEX ...说明走了索引OK。如果显示SCAN那就要考虑调整查询条件或建立相应索引。7.3 什么时候该迁到 MySQL我的判断标准虽然我对 SQLite 情有独钟但也要尊重它适用的极限。我的判断标准有三个第一如果同时写入并发超过每秒数百次明显是瓶颈第二如果数据量超过 500 万行且需要大量复杂 JOIN 聚合第三如果团队需要更细的账号权限、主从复制这些运维能力。到了这些时候迁到 MySQL 或 PostgreSQL 是合理的但那个迁移也不是简单的 dump/import要注意字段类型和 SQL 方言差异。当前这个考勤订饭系统我认为五年内都不必担心。8. 写代码之外的几个大坑问题排查链路分享最后这部分我把开发过程中最典型的三个问题以及排查思路完整写出来。因为我想说的重点不是怎么解决了而是遇到这种问题大脑应该怎么走一套排查流程。8.1 坑一SQLite 修改字段类型为什么不支持我在开发中途想把orders表里的meal_type从TEXT改成INTEGER来存储套餐编号。执行ALTER TABLE orders ALTER COLUMN meal_type INTEGER时报错。查了文档才知道 SQLite 原生不支持ALTER COLUMN。正确的做法有三种一是新建一个新表把旧表数据迁过去然后删旧表并重命名二是如果只是要改变字段类型但不变更约束可以临时修改 sqlite_master 里的 SQL这种比较 hack不建议三是像我一样放弃 ALTER在后面加一个新字段meal_id老字段留着不用。我的建议是如果你知道自己要改字段类型用 DB4S 的Edit Table功能它会自动生成重建表脚本比手写要安全。8.2 坑二JWT 过期导致的诡异登录失效上线后有人反映上午还正常下午突然登录失败。排查思路如下先看客户端请求是否把 token 带上了再看服务端报错信息。后来在日志里发现了JwtExpired异常。因为jsonwebtoken默认的过期间隔是 1 小时。我一开始签发 token 的时候没设过期时间导致请求时验证的是默认 1 小时。修法也很简单签发时显式设置expiresIn: 12h同时清除浏览器缓存重新登录即可。这个问题的教训是任何涉及时间的配置都要显式声明不要依赖默认值。8.3 坑三订饭截止时间服务器时区不对有一次行政说临时推迟截止时间到 12:30我在数据库里改了配置但前端 11:45 就已经不能点餐了。排查后发现Node.js 里new Date()拿到的是 UTC 时间而 SQLite 里的datetime(now)默认也是 UTC。我的所有时间都在生成时就存成了datetime(now,localtime)但接口里判断截止时间时我一时偷懒用了new Date().toISOString()去和数据库里的本地时间字符串比较UTC 和本地差了 8 小时自然会导致判断提前。后来我统一封装了一个getLocalTime()函数强制所有时间相关取数和比较都用同一个来源。这种时区不一致的问题在部署到跨时区服务器时尤其容易踩。写在最后这套小系统的价值与总结项目上线已经三个月每天稳定服务 80 多个员工早高峰打卡响应时间在十几毫秒内餐饭统计再也没有错过一个人。对我来说这个项目最大的价值不是代码本身而是让我完整走了一遍需求分析 - 技术选型 - 编码 - 部署 - 运维优化的链路并且用最小的成本验证了一个重要的原则不是所有系统都需要重框架、重数据库、复杂架构。在小规模场景下Node.js SQLite 的组合完全可以撑起一个实用、可靠、可维护的企业内部工具。如果你也要做类似的系统记住我踩过的这些坑数据库字段当初设计一定要想清楚时间统一用本地时间存取事务保护好关键写操作部署前把 Node 环境和 PM2 都配好。希望这份复盘能让你少走一点弯路也期待看到你自己的全栈实战作品。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →