这个项目其实是被逼出来的。当时某公司一直用微信群接龙订饭、用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 locked | SQLite锁竞争 | 开启WAL模式,设置busy_timeout,错峰执行备份 |
| 导出CSV中文乱码 | 编码格式不对 | 添加UTF-8 BOM,字段用引号包裹 |
| 员工重复提交形成多条订单 | 前端防重失效 | 后端加唯一约束,事务内先查后插 |
| 月度汇总计算很慢 | 没有合理利用索引 | 为employee_no和work_date创建联合索引 |
这里想特别强调索引的重要性。考勤表在 employee_no + work_date 上建了唯一索引,这个索引不只是用来去重,也直接服务了月度统计的查询加速。数据量不大时感觉不到区别,但等到数据攒了半年,没有索引和有序索引的查询性能差距会肉眼可见。
6.4 几点运维经验与实际心得
这几个月跑下来,我个人的体会是:轻量级系统最怕的不是功能不够,而是没人维护。这套系统真正稳定运行的关键,靠的不是高深技术,而是三个朴素习惯:第一,任何一次代码变更都先备份数据库;第二,每周扫一眼日志确认没有异常报错;第三,所有定时任务统一收敛在一个文件里管理,避免散落各处。
另外一个很实用的小技巧是,在系统里埋一个隐藏的管理员接口,直接查询当天所有关键指标的概况。这样排查问题时不需要登录数据库去查SQL,而是先在页面上看个大概方向,再决定要不要深挖。对于小型系统,这个简易排查入口比搭建完整的监控大屏划算得多。
随便一提,SQLite 在这种场景下是真的比想象中可靠。项目上线后我很少关注数据库本身有没有问题,真正操心的几乎都是业务规则上的边界情况。
这套系统后续如果要继续扩展,最值得加的功能大概有两块:一是对接企业微信或钉钉做消息通知,员工到点没打卡或订饭没成功时能自动提醒;二是引入一个"排班管理"模块,让考勤的迟到早退判定可以按不同岗位的班次分别计算。这两块功能都建立在一个稳定的基础上,改起来应该不会伤筋动骨。如果有朋友实践了同样的方案,或者有其他轻量级企业工具的想法,随时可以聊。