先说个结论:第二个项目能顺利上线,不是因为我技术变强了,而是我终于承认自己不是全能的。项目代号就叫 project-2,前面那个挂掉的是 project-1。这个故事本身没什么稀罕,但它帮我总结出了一套小团队做内部工具很实用的打法:先跑通、再做对、最后优化。如果你手上也正要做一个没人敢接的内部小系统,或者被一个"好像很简单但始终推不动"的需求卡住了,这篇内容应该能帮上忙。
1. 为什么叫 project-2:project-1 挂掉的三条死因
1.1 我不是从零开始,是从废墟里爬出来的
project-1 是一个进销存系统。当时某部门反馈说现有表格管理混乱、库存对不上、出货单容易漏填,听起来需求清晰,于是提了一个覆盖采购、销售、库存、财务对账、报表、权限管理的大方案。我当时想的是"既然都做了,就一次做到位",结果做了三个月还没上线,需求方越来越没耐心,最后项目烂尾。
复盘下来有三条死因很典型。
第一是过度设计。一个不到二十个人用的内部系统,我上了微服务拆分、消息队列、独立的权限中心,光是环境搭建和部署就耗掉了一半时间。第二是需求没有边界。谁都能往需求池里加东西,业务方今天想到一个报表,明天觉得导出 Excel 不好用,全都要加,项目自然失控。第三是交付太晚。第一个可用版本拖了三个多月,需求方已经自己找了一个第三方表格工具救急,我们做的东西还没机会接受真实数据检验。
1.2 痛定思痛后定的三条立项原则
project-2 立项的时候,我在文档第一页写下三条规矩,后面所有决策都以它们为判断标准:
- 两周内能演示一个可用版本,否则说明范围太大。
- 能用单机解决的不上集群,能不引入中间件就不引入。
- 每一批功能上线前,必须有真实业务用户参与验收。
这三条原则看起来很保守,甚至有点"写代码不够高级",但正是它们让 project-2 活了下来。我后来意识到,做内部工具最大的敌人不是技术复杂,而是需求方已经对"项目什么时候能用"失去了信任。先把一个真正能用的最小闭环跑起来,比什么都重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 需求还原:把"方便一点、别漏单"翻译成具体规则
2.1 业务现场:共享 Excel 和四个人的小群
这次的项目叫"工单流转小助手",服务的部门负责对接客户问题,每天通过邮件、聊天群、电话收到大量请求。她们当时的做法是:把每一条客户诉求手工登记在一张共享 Excel 表里,然后在聊天群里 @ 某个人去处理。看起来没什么问题,实际上一周能漏掉三四条,因为"群里喊了但没人认领""A 以为 B 在处理,B 以为 A 在处理"的情况每天都在发生。
我花了两天坐下来看她们的真实操作流程,没有急着写需求文档。观察到的关键信息是:一张 Excel 表里的列超过二十列,但真正被填的只有标题、联系人、描述、负责人、状态。字段多不是好事,是负担。第二,状态只有"未处理/处理中/已完成",没有"已驳回""超时"这些概念,所以一旦方法不对,只能靠私聊恢复记忆。
2.2 把用户原话翻译成功能清单
业务方主管的原话是:"我们想要一个地方,能把客户问题记下来,能分给人,能知道到底处理完没有,最好超时会提醒。"这些话已经很接近需求了,但还是需要翻译成可开发的功能点。
我最终拆出三件事:
- 创建工单:录入客户名称、联系方式、问题描述、优先级,提交后进入待处理池。
- 分配与流转:分派员可以把工单指定给某人,也可以由处理人自己领取;处理完成后必须填写结果。
- 超时提醒:不同优先级有不同的处理时限,超时后系统在首页提醒分派员,避免单子卡住。
原话里有一句"能导出报表"被我砍掉了。理由是:第一版先把流转闭环做好,报表可以让她们继续用 Excel 从列表里筛选,等数据量真实跑起来之后再考虑专门报表页。事实证明,砍掉报表让开发周期缩短了至少三分之一。
2.3 一个容易被忽略的关键决策:谁都能建单,但不是谁都能改状态
权限设计上我做了最简单的角色区分:分派员可以创建、分派、驳回、查看所有工单;处理人可以领取、处理、提交完成;管理员额外能维护用户名单。没有做按钮级权限,没有做部门隔离,因为三十个人以下的内部场景,做细粒度权限纯粹是增加使用成本和开发成本。
我还定了一个交互原则:状态只能通过明确按钮改变,不允许直接编辑状态字段。这条规则让我后面建的日志表派上了大用场,谁在什么时间做了什么操作,全部可追溯。这也是 project-1 留给我的教训:以前系统里谁都能改数据,出了错根本查不到责任人。
3. 技术选型:这一次我主动选择"无聊"
3.1 后端只用 Express + SQLite,为什么够用
project-2 的技术栈说出去可能不太有"面子":后端是 Node.js + Express,数据库直接用 SQLite,前端是 Vue 3 + Vite + Element Plus,部署在一台内网 Linux 服务器上,用 pm2 托起来。
选这个组合的理由很务实:预期并发很低,一天最多两三百次读写;数据量很小,跑一年工单记录也就几万条;不需要独立数据库服务,SQLite 的备份就是复制一份文件。真的,这个项目的数据库文件在上线三个月后也只有 20 多 MB,实在没有上 MySQL 的必要。如果你预期每天几万次请求、多人同时频繁写入,SQLite 需要谨慎评估,但在我这个场景里,它的单文件特性反而带来了巨大的运维便利。
为了便于对比,我把 project-1 和 project-2 的选型差别列了一下:
| 决策项 | project-1(失败) | project-2(本次) |
|---|---|---|
| 架构 | 微服务拆分 | 单进程应用 |
| 数据库 | MySQL + Redis | SQLite |
| 前后端 | 前后端分离 + 复杂权限 | 前后端分离,角色极简 |
| 消息通知 | 消息队列异步通知 | 定时任务扫描 + 站内提醒 |
| 部署目标 | Docker 集群 | 单台内网服务器 + pm2 |
| 首个可用版本 | 三个月没上线 | 两周内出内测版 |
3.2 前端选型盯住一点:业务人员学得快不快
前端用 Vue 3 而不是 React,基本是团队习惯问题。真正花心思的在于组件库,Element Plus 的表单、表格、抽屉、标签页刚好覆盖了管理后台类需求。我见过很多内部小项目为了展示技术天天折腾交互特效,结果业务用户看到花哨界面反而不知道怎么操作。这个系统核心页面就是三个:填单页、列表页、详情抽屉,越朴素越好。
我记得当时最明智的一个决定是:把状态标签做成颜色鲜明的 Tag。待处理是橙色、处理中是蓝色、已完成是绿色、已驳回是灰色。这个很小的视觉设计,让业务人员打开列表扫一眼就能定位异常单,比任何培训都有效。
3.3 部署与备份的土办法
部署方案简单到有些古早:代码通过 git 推到服务器,手动执行 npm install 和 build,pm2 启动进程。数据库每天凌晨用 crontab 做一次复制备份,保留最近三十天。没有 CI/CD 流水线,没有容器化,因为两三个人用不上的东西都不值得投入。
备份脚本核心就三行:
bash复制#!/bin/bash
BACKUP_DIR=/data/backup/ticket
SQLITE_FILE=/data/app/ticket/data/ticket.db
DATE=$(date +%Y%m%d_%H%M%S)
cp "$SQLITE_FILE" "$BACKUP_DIR/ticket_$DATE.db"
要恢复时,把备份文件复制回去再重启服务就行。我见过太多小项目把运维复杂化了,实际上内部工具最重要的就是出问题时能在十分钟内恢复数据,这一点 SQLite 的单文件模型天然占优。
4. 核心实现:两张业务表加一个状态机
4.1 数据模型:刻意少设计字段
数据库我是故意做得小气的,一共三张表:users、tickets、ticket_logs。没有扩展表,没有多类型分类,因为每多一张表,就需要多写一遍维护逻辑。
建表语句大概是这样的:
sql复制CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'handler'
);
CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
customer TEXT,
phone TEXT,
description TEXT,
priority INTEGER NOT NULL DEFAULT 2,
status TEXT NOT NULL DEFAULT 'pending',
assignee_id INTEGER,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
result TEXT
);
CREATE TABLE ticket_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
operator_id INTEGER NOT NULL,
action TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
字段少的好处是表单简单,用户填写的页面只有六七个输入项。很多人做内部系统喜欢把字段设计得很全,客户性别、客户地址、所属行业全都要,实际上没人填,最终变成一堆空值。我这次先做最需要的字段,如果业务方后面明确说必须加地址,再加也来得及。
4.2 状态机:所有流转都走一个函数
工单的状态我在接口层写死成一个状态机对象,前端只能调用明确的动作接口,不允许直接传 status 字段乱改。核心定义是这个:
javascript复制const ALLOWED_TRANSITIONS = {
pending: ['processing', 'rejected'],
processing: ['done', 'pending'],
rejected: ['pending'],
done: []
};
也就是说,待处理只能转为处理中或已驳回;处理中只能转为已完成或重新打开;已驳回只能重新变等待处理;已完成是终态。这个约束让整个系统的逻辑清晰了很多。以前 project-1 是每个接口各自判断状态,改一个逻辑要动十处,还经常出现漏改的情况。
每一次状态变化都会往 ticket_logs 插入一条记录:
javascript复制async function logAction(ticketId, operatorId, action, detail) {
await db.run(
`INSERT INTO ticket_logs (ticket_id, operator_id, action, detail, created_at)
VALUES (?, ?, ?, ?, ?)`,
[ticketId, operatorId, action, detail, dayjs().format('YYYY-MM-DD HH:mm:ss')]
);
}
日志表上线后帮了我大忙。业务方质疑"某张单怎么突然变成处理中了",我拉出日志一看,是某位同事在错误的时间点了领取。不扯皮,定位快。
4.3 超时扫描:一个定时任务撑起提醒功能
超时提醒是业务方最想要的功能,实现起来其实很简单。我在项目里加了一个定时扫描任务,每分钟跑一次,逻辑是找出所有超过时限还没完成的工单,然后在首页生成提醒消息。
时限按优先级区分:优先级 1 的紧急工单必须在 60 分钟内开始处理;优先级 2 必须在 8 小时内完成;优先级 3 允许 24 小时。这里有个细节是"开始处理"和"完成"是两套指标:紧急单只要 60 分钟内被领取并标记为处理中,就算有响应,不必非得完成。
扫描脚本大概是:
javascript复制const stuckTickets = await db.all(`
SELECT * FROM tickets
WHERE status = 'pending'
AND priority = 1
AND datetime(created_at) <= datetime('now', '-60 minutes')
`);
找到超时工单后做的事情不是自动换人,而是提醒分派员重新分配或催办。这个设计我专门跟业务主管确认过:系统只负责暴露问题,决策仍由人来判断。不选自动重新分配,是为了避免系统乱指派造成更大的混乱。
4.4 并发领取的坑:两个同事同时抢同一张单
开发过程中被测试小姑娘提了一个很真实的问题:"如果两个人同时点击领取同一个工单,会怎样?"我一开始写的是先查再改的逻辑,也就是先 SELECT 看 assignment 是否为空,再 UPDATE 写入当前用户。在低并发下基本没问题,但理论上两个请求同时读到空值,最终两个人都以为自己是处理人。
后来改成了单条 UPDATE 原子判断:
sql复制UPDATE tickets
SET assignee_id = ?, status = 'processing', started_at = ?
WHERE id = ? AND assignee_id IS NULL AND status = 'pending';
这段 SQL 的巧妙之处在于把"领取条件"直接放进 UPDATE 的 WHERE 里,执行完通过检查受影响行数判断是否成功:
javascript复制if (result.changes === 0) {
throw new Error('这张工单刚被别人领取了');
}
看起来很小一个点,但这种细节才是内部系统真正靠得住的根基。不要相信"用户一般不会同时操作"这种话,工作日早上十点的确会同时操作。
5. 前端交互:让业务人员"不用教就会"
5.1 页面清单刻意控制在四个以内
整个系统我只做了四个页面:首页统计、工单列表、创建工单、详情抽屉。没有单独的管理页,用户管理直接做在菜单里作为隐藏页。少做页面不是偷懒,而是减少使用者的认知负担。内部工具最怕做出来业务人员不会用,最后又回到表格和聊天群。
首页就是三个数字卡片:待处理数量、处理中超时数量、今日新增。业务主管每天上班先看首页,有红色数字就点进去处理。这比我写什么周报邮件都直接。工单列表页则承载了主要操作:按状态筛选、按优先级排序、行内按钮领取、点击查看详情。
5.2 按钮按当前状态动态出现,不给用户犯错机会
详情抽屉里会展示工单的全部字段和处理日志,然后在底部根据当前状态显示不同按钮。待处理时只显示"领取";处理中显示"提交完成"和"重新打开";已驳回显示"重新领取"。没有可编辑的文本字段来直接改状态,所有状态变化必须通过按钮触发。
我还在前端做了二次确认提示,尤其是"提交完成"需要填写处理结果,否则会弹出提示。这个看起来繁琐,实际上大大减少了"空结果已完成"的脏数据。后来统计了一下,上线一个月内没有一条提交完成却空着 results 的记录。
5.3 优先级设计被吐槽后的修正
最初优先级我用下拉框让用户选"高/中/低",结果客服那边几乎全部选高,因为大家都希望自己的客户请求先被处理。上线一周后超时提醒完全失灵,因为所有紧急单都超时了。我找业务主管商量后改了一个更稳妥的默认:新建工单默认优先级 2(普通),如果要选紧急,系统会弹一个提示要求填写紧急理由,并且只有分派员可以修改优先级。
这个修改遭到了一点抱怨,但坚持下来是对的。超时提醒的意义在于把真正重要的单挑出来,如果所有单都紧急,那等于没有紧急。后来有个经典场景:某客户投诉被标记为紧急,分派员看到理由写"客户在门店闹",立刻优先处理。系统帮忙识别了真实冲突,这才是提醒该有的价值。
6. 上线那段时间:文档里查不到的实战坑
6.1 时间字段差点全线乱掉
第一个坑是时间显示的时区问题。开发时我在本机 Windows 上测试,一切正常;部署到 Linux 服务器后,工单列表的时间比实际慢了 8 个小时。原因很典型:SQLite 的 datetime('now') 返回的是 UTC 时间,而浏览器展示时又是按用户本地时区换算的,中间夹了两个转换层。
处理方案是统一规范:所有时间字段在应用层生成,统一使用 dayjs 格式化成"YYYY-MM-DD HH:mm:ss"的本地时间字符串,再写入数据库。前端拿到字符串后不做额外转换,直接展示。这样看起来不是最佳实践,但对内部小工具来说,一致性比时区理论的优雅更重要。如果未来系统扩大了,再考虑全面切换 ISO 8601 格式也不迟。
6.2 历史 Excel 导入乱码:GBK 的锅
上线前需要把共享 Excel 里的历史工单导进系统。Excel 导出的 CSV 在 Windows 下默认是 GBK 编码,我直接用 Node.js fs 读取后中文全是乱码。第一反应是想让业务方存成 UTF-8 再发过来,但她们哪有这个耐心。
后来用 iconv-lite 做了编码转换:
javascript复制const iconv = require('iconv-lite');
const buffer = fs.readFileSync('tickets.csv');
const content = iconv.decode(buffer, 'gbk');
代码改动三行,问题就解决了。这提醒我:内部系统永远要预期业务方给你的文件是各种环境导出的,编码问题不是小事。后续我还加了一个判断逻辑,如果前三个字节是 UTF-8 BOM(EF BB BF)就按 UTF-8 解码,否则回退 GBK。
6.3 简易监控:看日志比做告警平台实在
我没有上任何监控系统,只做了两个东西:每一条操作日志都会写到数据库的 ticket_logs 表,应用运行日志按天切分保留三十天。出现问题第一件事是查这两类日志。有一次用户反馈"列表页打开变慢了",我看了一眼日志发现是某位同事一小时内在列表页发起了上千次请求,原因是她在浏览器里反复点刷新按钮,后来加了一个简单的前端节流,问题就没了。
对于小项目,监控的价值不是报表多漂亮,而是能快速回答"发生了什么"。日志表的意义比任何定时探活都大。
6.4 上线一个月后的复盘:哪些决定被验证是对的
用了一个多月,项目基本稳定。回看当初的决策,有几件事被验证了是对的。第一,砍掉报表功能非常正确:业务方确实需要看数据,但她们用列表页的筛选器自己过滤后导出 Excel,完全够用。第二,保持角色权限简单是对的:整个部门只有三个人需要分派权限,处理人开账号就能用,没人抱怨过权限不够。第三,SQLite 在真实负载下完全撑得住,最热的时候同时在线十几个人,读写毫无压力。
6.5 最后分享一个我现在仍然在用的习惯
每次上线前,我会把自己当成最不懂技术的人,把所有按钮都点一遍。这个项目上线前我通过这种方式发现了七个状态显示问题,比如某张已驳回的工单还在"处理中"的默认筛选里出现。这些 bug 不严重,但会直接影响业务方对系统的信任。
做 project-2 让我明白,多数内部项目失败,不是技术选型不够前沿,而是没有及时交付一个可靠的小闭环。如果你也在做这样的项目,先别急着铺开大架构,把一个核心流程跑通让用户用起来,比什么都强。
