资讯详情

资讯详情

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

这个项目其实是被逼出来的。当时某公司一直用微信群接龙订饭、用Excel表格记考勤行政同事每天光是统计午饭订单就要花掉将近一小时月底那几天更是要对着打卡记录加班核对迟到早退。我实在看不过去就趁着几个周末的空档用 Node.js 和 SQLite 从零搭了一套轻量级的企业考勤订饭系统。这套系统做下来前后端加起来不到两千行代码部署在一台最普通的服务器上却实实在在把行政每天半小时起步的统计工作压缩到了十分钟以内。如果你也是中小团队的开发者、或者被行政工作折磨到想自己动手的IT负责人这篇文章应该对你很有参考价值。我会从需求分析到数据库设计、从接口实现到部署排坑把整个复盘完整记录下来中间穿插大量实际踩过的坑和当时的取舍思路希望能让你少走一点弯路。1. 项目缘起为什么我决定自己造轮子1.1 痛点场景还原在动手写代码之前先说说这套系统要解决什么实际问题。某公司的日常考勤采用的是指纹打卡机数据导出来是一堆文本文件订饭则是行政每天早上在微信群发一条今天大家吃什么的接龙消息员工按格式回复行政下午统一整理后发给食堂。这套流程看起来没什么大毛病但实际用起来痛点非常明显一是订饭统计极其痛苦。群里几十条接龙消息有人回复得晚有人回复的格式五花八门还有人前一天忘了订、当天早上才临时在群里喊今天还有饭吗。行政需要把这些信息一条条复制到Excel里再手动统计每个档口的数量一旦有人临时取消或增加整张表又要重算一遍。二是考勤月底核对极费时间。指纹机导出的记录是每秒钟一条的流水需要按照员工工号和日期做去重、排序、比对上下班时间再人工判断迟到和早退。公司人虽然不多但加上请假、外勤、中途临时出入等各种情况月底对账还是要花掉两三个工作日。三是数据没有任何行为约束。Excel表格里想填什么就填什么今天记成9点、明天记成8点59分全靠填表人自觉。更麻烦的是历史数据没有唯一性约束同一个人同一天的考勤可能出现多行根本分不清哪一条才是有效的。当时也考虑过直接买一套现成的OA办公系统但一套带考勤和订餐模块的系统少说也要大几千块而且通常是按年收费的SaaS。以这家公司的体量来看性价比实在一般何况SaaS系统里的订饭功能往往只是顺带做的很难贴合每天中午统计几个档口数量这种具体需求。自研这套系统的核心判断依据其实很简单需求足够清晰、业务逻辑足够封闭、使用人数足够少。当这三个条件都满足时自己从头写一套轻量级方案往往比采购现成系统更省时间也更顺手。1.2 MVP需求边界先砍掉哪些功能任何项目一上来最容易犯的毛病就是想得太全。刚开始我也列了一大堆功能排班管理、加班申请、调休审批、菜单管理、食堂档口对接、微信通知、移动端App……列完之后冷静下来把这些需求按照是不是每天都要用和没有它是不是会出大乱子两个维度筛了一遍最后砍得只剩三个核心模块用户登录、考勤打卡、订饭统计。当中特别需要说明的取舍是这样的菜单管理没有做成动态维护而是允许行政直接在后台编辑当天默认菜单和档口选项请假审批没有做成流程引擎而是把请假退化成用户提交申请、行政后台直接改状态消息通知暂时不做因为公司本来就有工作群系统只需要在页面上给出明确反馈就够了。这套先砍需求的做法在后续开发中帮了大忙。很多系统之所以烂尾不是因为功能不够多而是因为无关功能分散了精力。MVP阶段只要保证核心链路是通的后面的扩展完全可以在真实反馈的基础上再做。1.3 为什么选原生方案而不是低代码平台这里有个绕不开的话题市面上有现成的低代码平台拖拖拽拽也能做一个订饭统计工具为什么非要用Node.js手写低代码平台确实能解决一部分表单收集场景但考勤订饭系统有两个地方它很难处理一是考勤打卡涉及复杂的业务规则例如一天多次打卡、跨天逻辑、迟到宽容时间、请假抵扣这类规则在低代码平台的表单流程模型里表达起来非常别扭二是订饭系统的截止时间状态流转既需要准确到秒级的判断又需要在截止后禁止任何变更低代码平台往往对这些底层约束支持得不够细。相比之下用传统编程语言从零实现所有规则都可以写进代码里逻辑清晰、行为可控也方便后续接手维护。对开发者来说这种方式确实更加可控。2. 技术选型Node.js SQLite 的组合是怎么确定的2.1 为什么后端选 Node.js 而不是 Java 或 PHP后端语言的选择主要看两个因素团队技术栈和维护成本。如果团队里没人会Java强行上一套Spring Boot后面连小需求都找不到人改。当时的情况是团队对JavaScript最熟前后端还能共用一套语言所以Node.js是顺理成章的选择。Node.js在这个场景下还有一个隐形优势异步非阻塞I/O对于这种大量小请求、每个请求逻辑都不重的系统特别对症。考勤打卡和订饭操作本质上就是几十个人在同一时间段内做简单的读写操作不会有计算密集型任务Node.js的模型应对这种负载绰绰有余。框架层面我选的是 Express。虽然现在不少人推荐更现代的 Fastify 或 NestJS但Express作为最经典的Node框架文档多、资料全、生态成熟对于一个轻量级内部系统来说足够稳定。NestJS虽然维护性更好但它的装饰器和依赖注入体系对这个体量的项目来说略显厚重。2.2 SQLite 在小规模企业场景下的适用边界很多人一听到SQLite就觉得这不是手机上的嵌入式数据库吗怎么能用在正经系统里。这种印象需要纠正一下。SQLite确实不是为高并发写入设计的但它对中小规模企业应用的支持其实非常可靠。单个数据库文件、零配置、支持ACID事务对于这家公司几十号人的并发量它的承载能力完全够用。我用一个具体数字来说明这套系统上线后的峰值请求出现在中午12点前大概有三十多人会集中在半小时内打卡和订饭换算下来每秒最多也就两三个请求。在SQLite的使用边界内这个负载连热身都算不上。即便打开WAL模式提升并发读写能力这也是锦上添花实际上普通模式也足够支撑这个量级。选择SQLite还有一个隐藏价值备份极其简单。备份MySQL可能要涉及mysqldump、binlog之类一大堆概念而SQLite的备份就是复制一个文件。配合定时任务把数据库文件复制到备份目录整个备份方案不到十行脚本就能落地。注意SQLite适合单机、小并发、数据量可控的场景。如果预期未来会有多个应用服务器同时读写同一个数据库文件或者日写入量会到几万条那就应该尽早切换到PostgreSQL这类真正的服务型数据库。2.3 核心依赖清单真正用到的Node模块比想象中少。运行时用的是较新版本的Node.js内置了测试支持的模块后端框架用Express数据库驱动选择了 better-sqlite3登录态用 JWT 做无状态认证定时任务用 node-cron 处理每天凌晨自动重置订饭状态这类需求。选 better-sqlite3 而不是 node-sqlite3 是个值得强调的决定。better-sqlite3 是同步API但它的事务性能和执行效率反而更高因为它是直接调用SQLite的C接口不需要像node-sqlite3那样经过异步线程池调度。对于业务逻辑简单但要求事务原子性的场景同步API写起来明显更省心。3. 数据库设计三张核心表与日期字段的坑3.1 核心表结构设计与SQL参考数据库设计是整个系统最基础的部分表结构定得好后面的接口逻辑能省一半力气。这套系统最终只用了三张核心业务表用户表、考勤表、订饭订单表。以下是建表语句的核心参考-- 用户表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_no TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT employee, -- employee / admin department TEXT NOT NULL DEFAULT , created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); -- 考勤表 CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_no TEXT NOT NULL, work_date TEXT NOT NULL, -- 本地日期格式 YYYY-MM-DD check_in_time TEXT, -- 上班打卡时间 HH:MM:SS check_out_time TEXT, -- 下班打卡时间 HH:MM:SS status TEXT NOT NULL DEFAULT normal, -- normal / late / early / leave / absent created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), UNIQUE(employee_no, work_date) ); -- 订饭订单表 CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_no TEXT NOT NULL, order_date TEXT NOT NULL, -- 订的是哪一天的午饭 YYYY-MM-DD menu_item TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT active, -- active / cancelled created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), UNIQUE(employee_no, order_date) );用户表里的 employee_no 是整个业务的主键维度它比自增id更稳定——员工换名字不会变工号而且考勤表直接存员工号做统计时不需要频繁JOIN用户表。工号这种天然的业务主键在这里比自增主键更合适。考勤表的关键设计是 work_date 字段和 UNIQUE(employee_no, work_date) 约束。这条唯一约束直接解决了Excel表格时代最大的痛点同一人同一天的考勤记录只能有一行想插重复数据数据库直接拒绝。订饭表同样加了 UNIQUE(employee_no, order_date) 约束语义是一个员工一天只能订一次饭。需要调整时就先取消原订单再重新下单而不是在原有记录上改来改去这样能保留完整的操作追溯。3.2 日期处理的大坑为什么不用时间戳使用 SQLite 的人经常会踩的一个坑是日期存储格式。刚开始我也想过直接用 Unix 时间戳存今天这种业务日期后来发现这完全是给自己挖坑。今天对于业务来说是一个本地时区的概念而时间戳是UTC的绝对时刻。如果你在晚上23点半晚高峰部署了代码一个不小心存进去的日期可能已经变成了明天凌晨跑步的同事打卡也可能被算成前一天。为了避免这类时区边界问题我用了一个非常土但非常稳的方案应用层统一生成本地时区的 YYYY-MM-DD 字符串直接存入SQLite所有业务判断都以这个字符串为准。具体做法是在Node.js里定义一个函数专门用来生成业务日期。自己拼日期可能踩夏令时和时区偏移的坑更稳妥的是用第三方库 dayjs一行代码搞定简单可靠。这套系统里的所有今天昨天订饭截止时间判断全部基于这个本地日期字符串不直接依赖服务器时区和时间戳运算。注意排查这类日期问题时经常会出现明明时间戳是对的页面上就是差一天的现象。遇到这种情况优先检查生成日期字符串的那个函数而不是怀疑数据库。3.3 订饭截止时间与状态流转设计订饭系统的核心规则是当日饭当日订前一天下午五点半截止过了点系统直接锁定谁也改不了。这句话落到代码里就是状态流转的判断。订单状态只有 active 和 cancelled 两种不搞待确认已接单已完成这种复杂状态。因为对于内部订饭这个场景员工关心的是我订没订上行政关心的是今天哪个档口多少人中间环节的状态对谁都没有实际意义。截止时间的计算被封装成一个纯函数传入当前时间返回截止时间点然后与当前时间比较。这样做的好处是测试的时候可以直接传不同的时间进来验证逻辑不用真的去等17:30到来。// 判断订饭是否已截止的核心逻辑 const dayjs require(dayjs); const CUTOFF_TIME 17:30:00; function isOrderDeadlinePassed(dateStr, now new Date()) { const deadline new Date(${dateStr}T${CUTOFF_TIME}); return now.getTime() deadline.getTime(); }有一点需要注意每个人理解5点半截止是不一样的员工可能觉得5点29分还能改5点30分就不能改了。这里采用了严格的大于判断过了5点30分整的那一刻就算截止避免模糊地带。4. 后端接口设计与核心业务逻辑实现4.1 RESTful接口清单后端接口设计遵循最朴素的REST风格不搞复杂的资源嵌套。下面列出的是全部核心接口总共不到十个方法路径功能权限POST/api/auth/login登录获取JWT公开GET/api/me获取当前用户信息登录用户POST/api/attendance/check打卡上班/下班登录用户GET/api/attendance查询我的考勤记录登录用户GET/api/attendance/summary考勤汇总管理员管理员POST/api/orders提交订饭订单登录用户DELETE/api/orders取消今日订单登录用户GET/api/orders/today今日订单统计管理员管理员GET/api/orders/my查询我的订饭记录登录用户权限控制用最简洁的中间件实现解析JWT把员工号和角色塞进请求对象管理员接口再判断一次 role 字段。对于这种内部系统不需要引入完整的RBAC权限体系一个中间件加一个角色判断足够了过度设计在这里是负担。4.2 打卡逻辑一日多次打卡与状态判定打卡接口是整个系统里业务规则最密集的地方。表面上就是按一下按钮实际上要处理的情况相当多。我的实现逻辑是这样的先根据当前日期查出今天的考勤记录如果没有说明今天是第一次打卡此时不管几点都记成 check_in_time状态在第一次打卡时就初步判定。如果同一天已经有记录操作就是打下班卡把 check_out_time 更新掉。迟到判定是另一个容易出错的地方。公司规定的上班时间是9:00我加了一个15分钟的宽容期也就是9:15之前打卡不记迟到。这里有个初始方案没有得到充分重视的细节第一次打卡不能简单判断9:15之后就算迟到因为有可能员工确实迟到但9:10打卡的是当天第一次打卡也可能员工9:05到公司后先去忙别的9:30才想起打卡。这两种情况数据库上看都是9:30的 check_in_time但一个应该算迟到另一个不应该。我最终采用的方案是把所有迟到判定都集中在月底汇总时做以第一次打卡时间为准。如果第一次打卡时间在9:15之后标记为迟到如果有请假记录则标记为请假而不是迟到。这样做虽然不能处理人到公司了但忘了打卡的情况但那个问题通过当天补卡申请功能兜底由管理员手工调整状态逻辑就闭环了。4.3 订饭逻辑截止前的下单与取消订饭接口的实现看起来很简单但事务处理是核心。一次订饭操作涉及到检查是否已过截止时间、检查是否已经订过、写入订单记录、更新统计缓存这几个步骤必须在一个事务里完成。如果分开执行极端场景下可能出现两个人同时判断我没订过然后同时插入成功——虽然SQLite的真实场景几乎不会触发但代码上还是要防住。事务参考实现如下const insertOrder db.transaction((employee_no, order_date, menu_item) { // 1. 校验截至时间 if (isOrderDeadlinePassed(order_date)) { throw new Error(订饭已截止); } // 2. 校验是否重复 const exist db.prepare( SELECT id FROM orders WHERE employee_no ? AND order_date ? AND status active ).get(employee_no, order_date); if (exist) { throw new Error(今天已经订过饭); } // 3. 插入订单 db.prepare( INSERT INTO orders (employee_no, order_date, menu_item) VALUES (?, ?, ?) ).run(employee_no, order_date, menu_item); });用 db.transaction 包住同步操作是 better-sqlite3 最推荐的写法。它保证要么全部成功要么全部回滚中间任何一步抛错都不会留下脏数据。取消订单的语义是把 status 字段从 active 改成 cancelled而不是物理删除。这样做的好处是行政统计时可以明确区分今天订了又取消和根本没订避免对不上账的时候说不清楚。4.4 幂等性设计和防重处理内部系统虽然不对外提供API但幂等性依然值得关注。用户可能双击提交按钮、前端可能因为网络问题重发请求这些场景都会导致同一条数据被插入两次。前端防重是关键的第一道防线。提交按钮在点击后立即进入 loading 状态并禁用这是成本最低的办法。后端的 UNIQUE 约束是第二道防线哪怕前端没拦住数据库也会直接报错配合事务回滚避免脏数据。这里有个经验教训值得分享刚开始只做了前端防重以为足够了结果有一次测试人员在网络慢的页面连点了三下提交三条重复订单还是进了数据库。从那次之后所有写操作接口我都坚持在后端加唯一约束或先查后插的逻辑两层保障都不可少。5. 前端界面与交互设计要点5.1 选型原生HTML加轻量框架比重型框架更合适前端部分一开始就确定了不用重框架原因是系统使用场景非常固定员工每天打开两次页面一次早上打卡一次中午订饭行政每天下午统计订单。这样一个使用频率低、页面数量少的系统引入 React 或 Vue 全家桶只会增加构建复杂度。最终采用的是原生 HTML、CSS、JavaScript 加一个轻量级的UI库来减少样式工作量。能力上坦白说原生JS写起来确实啰嗦但好处是零构建、零依赖、打开即用部署时直接把静态文件扔给后端服务就好。如果能接受一个小的构建成本也可以用Vue或React但为了减少项目体积我选择了最轻量的路线。5.2 员工端打卡与订饭的交互细节员工端只有三个页面登录页、今日首页打卡和订饭入口、我的记录页。设计原则是一屏一操作首页放一个大的打卡按钮点击后立刻显示打卡成功或失败订饭部分显示今天的菜单选项员工选择档口后提交页面上直接显示已订XX档口米饭×1。这里有几个交互细节比较重要。一是打卡按钮要根据当天的打卡状态变换文案未打卡显示上班打卡已打上班卡显示下班打卡两个都打了显示今日考勤已完成。二是订饭提交成功后按钮立刻变成取消订饭让用户知道操作已经生效。三是订饭截止后整个订饭区域直接隐藏或置灰避免用户反复尝试提交后收到失败提示。移动端适配直接使用响应式布局员工在手机上打开页面时按钮做大、间距变大因为绝大多数人都是拿手机操作的。5.3 行政端统计视图与汇总导出行政端的核心诉求是少做Excel和一眼看明白。所以统计页面做了三件事默认显示今天的订单按档口分组统计提供日期选择器可以查过去任何一天的统计所有统计表格上方放一个导出CSV按钮点一下就能拿到完整的原始明细。导出CSV这个功能看起来不起眼实际上帮了大忙。行政每个月要把考勤汇总发给相关负责人审核有CSV导出之后就不用自己手敲了。这里有个技术细节生成CSV时要注意Excel兼容性字段里如果有英文逗号或换行符需要包引号中文编码建议用UTF-8 BOM格式否则用Excel打开时会显示乱码这个坑我试过几次才搞定。6. 部署实战与排坑记录6.1 轻量部署单机部署与数据备份整套系统最终部署在一台普通Linux服务器上用 PM2 管理Node进程。服务器不需要高性能因为一个Node进程加一个SQLite文件资源占用非常低。部署的具体步骤如下将项目代码同步到服务器目录执行 npm install --production 安装生产依赖。使用 PM2 启动应用设置开机自启并配置日志轮转读取。把SQLite数据库文件放到一个独立数据目录确保代码更新时不会误覆盖。配置定时备份任务每天凌晨复制一份数据库文件到一个带日期的备份目录保留最近30份。# PM2 启动示例 pm2 start app.js --name attendance-meal-system pm2 save pm2 startup # 数据库备份脚本 0 2 * * * cp /data/attendance.db /data/backup/attendance_$(date \%Y\%m\%d).db有两个细节需要注意一是SQLite的数据库文件在写入过程中直接复制可能会损坏稳妥的做法是使用 SQLite 的在线备份命令或者先把数据库文件正常关闭再复制。在低峰时段直接复制实际风险也很低但正式环境建议用 sqlite3 的备份指令这个要更安全。二是更新代码前一定先备份数据库因为某些表结构变更可能不可逆有备份在手心里不慌。6.2 并发写入与SQLite锁冲突的排查部署后遇到过唯一一次线上事故跟SQLite的锁机制有关。某天中午大量员工在12:00到12:05之间同时打卡订饭结果部分请求返回了 database is locked 错误。排查后定位到两个原因一是当时的数据库连接使用默认配置没有开启 WAL 模式。WAL模式下读操作不阻塞写操作、写操作之间也可以并发大大降低锁冲突概率。二是某次定时任务在中午执行了备份流程备份期间与业务写入抢占了数据库锁。解决办法也很简单开启WAL模式、定时备份任务错峰到凌晨执行并在better-sqlite3连接中设置合理的 busy_timeout 超时。这次排障最大的收获是学会了用 SQLite 自带的监控指令查看锁状态出现问题先确认是不是锁竞争最稳妥。const db require(better-sqlite3)(data/app.db); db.pragma(journal_mode WAL); db.pragma(busy_timeout 5000);6.3 常见问题速查表把开发调试和上线运维过程中比较常遇到的问题整理成一张表方便排查问题现象定位思路解决方案打卡后日期变成前一天/后一天时区拼接错误统一用本地日期字符串本地时区基于系统时区生成订饭截止后仍能下单校验函数用了服务器UTC时间截止判断必须基于同一业务日期字符串大量请求时偶发 database is lockedSQLite锁竞争开启WAL模式设置busy_timeout错峰执行备份导出CSV中文乱码编码格式不对添加UTF-8 BOM字段用引号包裹员工重复提交形成多条订单前端防重失效后端加唯一约束事务内先查后插月度汇总计算很慢没有合理利用索引为employee_no和work_date创建联合索引这里想特别强调索引的重要性。考勤表在 employee_no work_date 上建了唯一索引这个索引不只是用来去重也直接服务了月度统计的查询加速。数据量不大时感觉不到区别但等到数据攒了半年没有索引和有序索引的查询性能差距会肉眼可见。6.4 几点运维经验与实际心得这几个月跑下来我个人的体会是轻量级系统最怕的不是功能不够而是没人维护。这套系统真正稳定运行的关键靠的不是高深技术而是三个朴素习惯第一任何一次代码变更都先备份数据库第二每周扫一眼日志确认没有异常报错第三所有定时任务统一收敛在一个文件里管理避免散落各处。另外一个很实用的小技巧是在系统里埋一个隐藏的管理员接口直接查询当天所有关键指标的概况。这样排查问题时不需要登录数据库去查SQL而是先在页面上看个大概方向再决定要不要深挖。对于小型系统这个简易排查入口比搭建完整的监控大屏划算得多。随便一提SQLite 在这种场景下是真的比想象中可靠。项目上线后我很少关注数据库本身有没有问题真正操心的几乎都是业务规则上的边界情况。这套系统后续如果要继续扩展最值得加的功能大概有两块一是对接企业微信或钉钉做消息通知员工到点没打卡或订饭没成功时能自动提醒二是引入一个排班管理模块让考勤的迟到早退判定可以按不同岗位的班次分别计算。这两块功能都建立在一个稳定的基础上改起来应该不会伤筋动骨。如果有朋友实践了同样的方案或者有其他轻量级企业工具的想法随时可以聊。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →