ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaScript全栈LIMS:样本流转、权限审计与并发控制

JavaScript全栈LIMS:样本流转、权限审计与并发控制

简介:面向实验室信息化开发与课程实践场景的LIMS源码包,适合具备JavaScript基础、希望理解Web端实验室管理系统架构的开发者与高校学生。内容围绕样本全流程跟踪、实验流程自动化、仪器数据整合、报告生成分发、权限控制、资源与库存调度及Dashboard指标展示等模块,可帮助梳理从业务建模到前后端实现的常见设计思路。技术层面以JavaScript为核心,前端可结合React或Angular,后端涉及Node.js、Java或.NET,并以MySQL、Oracle等关系型数据库承载数据。压缩包约52.49MB,文件总数暂显示为0、类型明细未提供,实际内容以源码工程为主,下载后需自行解压查看结构。已有1742人浏览学习,适合用作LIMS项目参考、二次开发起步或实验管理课程案例。

1. 从一张检测流转单说起:实验室信息管理系统到底在管什么

一个样本从收样窗口进来,到报告盖章发出去,中间要经过登记、分样、派发任务、录入数据、复核、审核、出报告六七道工序。纸质流转单加 Excel 的常见结局是:报告已经发出去,回头查某个原始数据是谁在哪天改的,翻遍表格也说不清。LIMS(实验室信息管理系统)要解决的正是这件事——把每个样本、每个检测项目、每一次数据变更都记录在一条时间线上,随时可以查。对实验室来说,它不是一套“更好用的 Excel”,而是数据可追溯性的基础设施。

这套系统的适用人群很明确:需要运行内部样本管理流程的中小型实验室、第三方检测机构,以及想找一个有真实业务复杂度的项目来练习 JavaScript 全栈的开发。它把样本流转、检测项目配置、结果录入、报告导出做成了首尾相连的流程,下面按技术栈、目录、核心链路、权限审计、踩坑和二次开发六块拆开讲。

2. 技术栈与目录结构:一套 JavaScript 全栈骨架怎么搭

2.1 技术选型:前后端同一种语言的取舍

这个项目选用的是一套统一的 JavaScript 技术栈:后端 Node.js + Express,ORM 用 Sequelize,数据库 MySQL 8,前端 Vue 3 + Vite + Element Plus,状态管理 Pinia,认证走 JWT,报告导出用 ExcelJS,日志用 winston。这套组合不算时髦,但对一个由两三个人维护的内部系统来说,它最大的价值是前后端同一种语言——要改一个字段,从数据库模型到接口到页面表单,一个人能一次性改完,不用在两套写法之间来回切换。

另一个考虑是实验室现场的技术能力。很多实验室的 IT 支持只有一两个人,他们更熟悉 JavaScript 生态,而不是 Java 那套重型框架。Express 的中间件模型足够表达业务分层,Sequelize 的迁移脚本能管住表结构变更,这两点对一个要跑三五年的内部系统来说,比性能指标重要得多。

这里给出仓库根目录的package.json里 scripts 部分,依赖清单里比较关键的有express、sequelize、mysql2、jsonwebtoken、exceljs、dayjs、winston,具体版本以仓库锁定文件为准:

{ "name": "lims", "private": true, "scripts": { "dev:server": "nodemon server/app.js", "dev:web": "vite --config web/vite.config.js", "build:web": "vite build --config web/vite.config.js", "start": "pm2 start ecosystem.config.js", "db:migrate": "sequelize-cli db:migrate", "db:seed": "sequelize-cli db:seed:all" } }

dev:server和dev:web分开跑,是因为前端用 Vite 做热更新、后端用 nodemon 重启,两边都放同一个进程里会互相拖累。db:migrate是表结构变更的唯一入口,不允许手工在数据库里加字段,否则换一台机器部署时表结构就对不上。start交给 PM2,生产环境不开 nodemon,避免文件变动触发无意义重启。

启动顺序有讲究:先执行db:migrate建表,再跑db:seed灌入基础字典(样本类型、检测项目、角色权限点),最后才起服务。少了字典数据,前端下拉框全是空的,会让人误以为接口挂了。

2.2 目录结构:server 与 web 两棵树

前后端放在同一个仓库里,但运行时分属两个进程,目录上严格隔开。下面是主干结构,删掉了零碎的配置文件:

lims/ ├── server/ # 后端进程,独立于前端运行 │ ├── app.js # 入口:中间件装配 + 路由挂载 │ ├── config/ │ │ ├── db.js # Sequelize 连接池与时区配置 │ │ └── index.js # 环境变量收敛点,禁止散落读 process.env │ ├── models/ # 一张表一个文件 │ │ ├── user.js │ │ ├── sample.js │ │ ├── testItem.js │ │ ├── testResult.js │ │ └── auditLog.js │ ├── routes/ # 只做路由绑定,不写业务 │ ├── controllers/ # 参数校验 + 调 service + 组织响应 │ ├── services/ # 业务规则集中在这层,可被单测覆盖 │ ├── middlewares/ │ │ ├── auth.js # JWT 解析 + 权限点校验 │ │ ├── audit.js # 写操作自动落审计日志 │ │ └── error.js # 统一异常出口 │ ├── utils/ │ │ ├── sampleNo.js # 样本编号生成器 │ │ └── excel.js # 报告导出 │ └── migrations/ # 表结构变更脚本 ├── web/ # 前端 Vue 3 │ ├── src/ │ │ ├── api/ # 按后端模块拆的请求函数 │ │ ├── views/ │ │ │ ├── sample/ # 登记、列表、流转详情 │ │ │ ├── result/ # 结果录入、复核 │ │ │ └── report/ # 报告生成与下载 │ │ ├── router/ │ │ ├── store/ # Pinia:用户信息、字典缓存 │ │ └── components/ │ └── vite.config.js ├── ecosystem.config.js # PM2 进程配置 └── README.md

分层的关键约束是:controllers不直接碰 Sequelize 模型,所有业务判断都下推到services。这样做的收益在写测试时体现得最明显——services里的函数接收普通参数、返回普通对象,不依赖req和res,用 Node 自带的测试运行器就能跑。常见做法是让 controller 只做三件事:取参数、调 service、把结果包装成统一的{ code, msg, data }结构。异常全部抛出去,交给middlewares/error.js统一转 HTTP 状态码,避免每个接口都写一遍 try-catch。

utils/sampleNo.js和utils/excel.js单独拆出来的原因不同。前者是因为编号规则经常按客户要求调整(前缀、日期格式、流水位数),放在一个文件里改起来不会漏;后者是因为 ExcelJS 的用法比较分散,集中在一个文件里方便统一加样式和合并单元格。

2.3 数据模型:四张主表支撑一条流转线

整个系统围绕四张表转:sample(样本主表)、sample_item(样本与检测项目的关联)、test_result(检测结果)、audit_log(审计日志)。其中sample和test_result是核心,下面给出建表的核心字段:

CREATE TABLE sample ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, sample_no VARCHAR(32) NOT NULL UNIQUE COMMENT '对外展示的样本编号', client_name VARCHAR(128) NOT NULL COMMENT '委托方名称', sample_type VARCHAR(16) NOT NULL COMMENT '水样/土样/气样等', status TINYINT NOT NULL DEFAULT 10 COMMENT '10待接收 20已接收 30检测中 40待复核 50已审核 60已出报告', received_at DATETIME NULL COMMENT '实际接收时间', created_by BIGINT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

sample_no上加唯一索引不是为了查询快,而是并发场景下的最后一道保险——应用层的编号生成器一旦在极端情况下没有生效,数据库会直接拒绝插入重复编号,不会默默地产生两条编号一样的样本记录。status用TINYINT而不是字符串,原因是流转判断要做范围比较和索引,数字比字符串省空间也省心。received_at单独存而不是复用created_at,因为登记时间和实际接收时间在业务上不是一回事:样本可能先被登记到系统,隔一天才由快递送到。

test_result表的设计里有一个细节值得说:结果值同时存两列,value_num(数值)和value_text(文本),而不是塞一列VARCHAR。原因是数值型结果要做超标判断、均值统计、检出限比较,存成字符串就得在 SQL 里做类型转换,索引直接失效。文本型结果(比如“无色”“无味”这类描述性结论)走value_text,两类结果在报告里合并展示。

3. 样本全流程链路:从登记到出报告的核心模块拆解

3.1 状态机:样本状态用枚举而不是字符串

样本的状态流转是这套系统中最容易出问题的地方。早期版本用字符串常量,'检测中'、'检测中 '(多了一个空格)、'检测'三种写法的记录在数据库里同时存在,前端按状态筛选出来的数量永远对不上。改成数字枚举加显式流转表之后,非法跳转会在代码层直接被拦下:

// server/services/sampleState.js // 前后端共用同一份状态定义,前端字典也从这个接口拉 const STATUS = { PENDING: 10, // 待接收 RECEIVED: 20, // 已接收 TESTING: 30, // 检测中 REVIEWING: 40, // 待复核 APPROVED: 50, // 已审核 REPORTED: 60, // 已出报告 }; // 只允许表里列出的流转,跳级和回退都必须显式声明 const TRANSITIONS = { [STATUS.PENDING]: [STATUS.RECEIVED], [STATUS.RECEIVED]: [STATUS.TESTING], [STATUS.TESTING]: [STATUS.REVIEWING], [STATUS.REVIEWING]: [STATUS.APPROVED, STATUS.TESTING], // 复核不通过打回检测 [STATUS.APPROVED]: [STATUS.REPORTED], [STATUS.REPORTED]: [], }; function assertTransition(from, to) { const allowed = TRANSITIONS[from] || []; if (!allowed.includes(to)) { const err = new Error(`非法状态流转: ${from} -> ${to}`); err.status = 409; throw err; } } module.exports = { STATUS, assertTransition };

这段代码里有两个决定值得说明。第一,回退只有一处:待复核可以打回检测中,其他环节不允许退回去。这是业务规则,不是技术限制——报告一旦审核通过,再退回去就等于承认已发出的报告有问题,那要走另一条流程(作废重出),不能简单地把状态改回去。第二,TRANSITIONS用对象字面量而不用数组,是为了 O(1) 查找,样本量上来之后每次流转都遍历一遍数组没有必要。

err.status = 409这个写法是这套代码里统一约定:service 层抛出的错误对象带一个status属性,middlewares/error.js读这个属性决定 HTTP 状态码,读不到就按 500 处理。好处是业务代码不用引入任何 HTTP 框架的类型,测试时直接断言err.status就行。

3.2 流转接口:事务加行锁,防止两个人同时点

状态机的校验函数只是规则,真正容易出问题的是并发。想象一个场景:检测员录完数据点了“提交复核”,与此同时另一个检测员在列表页批量操作里也点了同一批样本的“提交”。两个请求几乎同时到达,都读到了status = 30,都通过了assertTransition校验,然后各自执行 update,最终只有一条生效,另一条静默丢失。更糟的情况是两条都写成功,流转记录表里多出一条时间戳相同的日志,事后追溯时完全看不懂。

解决办法是把“读状态、校验、写状态”三步包进一个事务,并且在读的时候加行锁:

// server/services/sampleService.js const { sequelize } = require('../config/db'); const { Sample, TestResult } = require('../models'); const { STATUS, assertTransition } = require('./sampleState'); async function changeStatus(sampleId, toStatus, operatorId, remark) { return sequelize.transaction(async (t) => { // LOCK.UPDATE 相当于 SELECT ... FOR UPDATE,后到的请求会阻塞等待 const sample = await Sample.findByPk(sampleId, { lock: t.LOCK.UPDATE, transaction: t, }); if (!sample) { const err = new Error('样本不存在'); err.status = 404; throw err; } assertTransition(sample.status, toStatus); // 提交复核前必须至少有一条检测结果,否则复核人打开页面是空的 if (toStatus === STATUS.REVIEWING) { const count = await TestResult.count({ where: { sample_id: sampleId }, transaction: t, }); if (count === 0) { const err = new Error('没有检测结果,不能提交复核'); err.status = 409; throw err; } } await sample.update( { status: toStatus, updated_by: operatorId }, { transaction: t } ); return sample; }); } module.exports = { changeStatus };

lock: t.LOCK.UPDATE是这段代码的核心。第一个请求进来时锁住这一行,第二个请求会一直等到事务提交才继续,此时它读到的是已经更新过的status,assertTransition会直接抛错,返回 409。这里不能图省事用乐观锁版本号,因为样本流转的操作频率不高,冲突概率低,加行锁的成本可以接受,而乐观锁需要在 update 的 where 条件里带版本号、再判断影响行数,出错时的排查成本更高。

“提交复核前必须有结果”这个校验放在 service 而不是 controller,是为了让定时任务、数据导入这类非 HTTP 入口也能复用同一套规则。常见翻车是只在页面上做了按钮置灰,接口层没拦,用 Postman 直接调一次就能把空样本推到复核队列里。

3.3 登记接口:一个 POST 进来之后的完整链路

登记是整套系统里字段最多的一个接口,一个请求要写两张表。下面是最小可用版本:

// server/services/sampleService.js(续) const { Sample, SampleItem } = require('../models'); const { nextSampleNo } = require('../utils/sampleNo'); const { STATUS } = require('./sampleState'); async function register(payload, operatorId) { const { clientName, sampleType, items } = payload; // 至少一个检测项目,否则这个样本没有存在的意义 if (!Array.isArray(items) || items.length === 0) { const err = new Error('至少选择一个检测项目'); err.status = 400; throw err; } return sequelize.transaction(async (t) => { const sampleNo = await nextSampleNo(sampleType, t); const sample = await Sample.create( { sample_no: sampleNo, client_name: clientName, sample_type: sampleType, status: STATUS.PENDING, created_by: operatorId, }, { transaction: t } ); // 样本与检测项目是 1:N,把「这个样要测什么」在登记时固化 await SampleItem.bulkCreate( items.map((it) => ({ sample_id: sample.id, item_code: it.code, item_name: it.name, method: it.method, })), { transaction: t } ); return sample; }); }

参数含义需要逐一交代清楚:clientName是委托方名称,用于报告抬头,必填;sampleType决定编号前缀,取值来自字典表,不在字典里的值会被前端的下拉框限制住;items是检测项目数组,每一项的code对应test_item表的项目编码,method是本次实际使用的检测方法(同一个项目可能因为样品基质不同换方法,所以方法挂在样本-项目关联上而不是项目主表上)。

bulkCreate而不是循环create,是因为一次登记可能带十几个检测项目,循环写会产生十几次往返。bulkCreate在 Sequelize 里会拼成一条多值 INSERT,事务内执行,失败整体回滚,不会出现“样本建了但项目只写进去一半”的脏数据。

编号生成单独写一个函数,原因是它最容易成为并发瓶颈:

// server/utils/sampleNo.js const dayjs = require('dayjs'); async function nextSampleNo(sampleType, t) { const prefix = `S${sampleType}${dayjs().format('YYYYMMDD')}`; // 独立序列表:没有记录就插一条 seq=1,已有记录就自增 await t.sequelize.query( `INSERT INTO sample_seq (prefix, seq) VALUES (?, 1) ON DUPLICATE KEY UPDATE seq = seq + 1`, { replacements: [prefix], transaction: t } ); // 同一事务内能读到刚自增后的值 const [rows] = await t.sequelize.query( `SELECT seq FROM sample_seq WHERE prefix = ?`, { replacements: [prefix], transaction: t } ); return `${prefix}${String(rows[0].seq).padStart(4, '0')}`; } module.exports = { nextSampleNo };

ON DUPLICATE KEY UPDATE这一句是 MySQL 的方言,作用是把“不存在则插入、存在则自增”压成一条语句,减少一次往返和一次竞态窗口。padStart(4, '0')保证流水号固定四位,这样按编号排序和按时间排序的结果一致,打印时也整齐。前缀里带日期,意味着每天零点序号重新从 1 开始,符合实验室的编号习惯。

4. 权限、审计与双人复核:合规要求怎么落进代码

4.1 RBAC:权限点而不是角色硬编码

权限这块最容易写成if (user.role === 'admin'),然后随着角色变多,判断散落在几十个接口里。这套系统用的是权限点模型:角色是权限点的集合,接口挂的是权限点,两者解耦。权限点命名约定是模块:动作,例如sample:create、result:review、report:export:

// server/middlewares/auth.js const jwt = require('jsonwebtoken'); function requirePermission(...points) { return async (req, res, next) => { const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (!token) return res.status(401).json({ code: 401, msg: '未登录' }); let payload; try { payload = jwt.verify(token, process.env.JWT_SECRET); } catch (e) { return res.status(401).json({ code: 401, msg: '登录已过期' }); } // 登录时一次性查出权限点塞进 token,避免每个请求再查一次库 req.user = payload; const granted = payload.perms || []; const ok = points.every( (p) => granted.includes(p) || granted.includes('*') ); if (!ok) return res.status(403).json({ code: 403, msg: '无权限' }); next(); }; } module.exports = { requirePermission };

points.every意味着多个权限点是“与”的关系,适合“既要有样本编辑权、又要有报告导出权”这种组合。granted.includes('*')给超级管理员留了一个通配口,但只建议在初始化脚本里给一个账号配置,不开放给界面。把权限放进 token 的代价是权限变更后必须重新登录才生效,这在内部系统里可以接受;如果对权限实时性要求高,就得改成每个请求查一次 Redis 缓存,那是另一个量级的复杂度。

路由上的挂法是这样的,一眼能看出接口要什么权限:

// server/routes/sample.js router.post('/', requirePermission('sample:create'), audit('sample', 'create'), ctrl.create); router.patch('/:id/status', requirePermission('sample:flow'), audit('sample', 'flow'), ctrl.changeStatus); router.post('/:id/report', requirePermission('report:export'), audit('report', 'export'), ctrl.exportReport);

4.2 审计日志中间件:谁在什么时候改了什么

审计表的设计只保留必要字段,重点是“能还原动作”,而不是“存下整个请求”。下面这个中间件挂在路由上,等响应结束再写日志:

// server/middlewares/audit.js const { AuditLog } = require('../models'); function audit(module, action) { return async (req, res, next) => { // 拦截 res.json,等业务处理完拿到结果再落日志 const originJson = res.json.bind(res); res.json = async (body) => { try { await AuditLog.create({ module, action, user_id: req.user && req.user.id, target_id: req.params.id || (body && body.data && body.data.id) || null, // 只截前 2000 字符:整包 body 可能带附件 base64,会把日志表撑爆 payload: JSON.stringify(req.body).slice(0, 2000), ip: req.ip, result: body && body.code === 0 ? 'success' : 'fail', }); } catch (e) { // 审计写失败不能影响业务返回,只打错误日志 console.error('audit log failed', e); } return originJson(body); }; next(); }; } module.exports = { audit };

拦截res.json而不是在业务流程里手动调用埋点函数,好处是不会漏。新增一个接口时只要挂上中间件,日志自动就有了。风险是如果某个 handler 直接res.send或res.end,这层拦截会失效,所以项目里约定所有接口统一走res.json,由middlewares/error.js兜底转换。

payload截断到 2000 字符是一个血泪经验。早期版本不截断,某个带附件的接口把 base64 内容整个写进了日志表,一周之内表体积涨到几个 G,查询直接超时。截断之后查询恢复,代价是超长参数还原不完整,但对于追溯“谁改了什么字段”这个主要目的来说够用。

4.3 双人复核:同一份结果两个人的签名

检测行业的基本合规要求是录入和复核不能是同一个人。这条规则必须在服务端强制,页面上把按钮藏起来没有意义:

// server/services/resultService.js async function review(resultId, reviewerId, pass, comment) { return sequelize.transaction(async (t) => { const result = await TestResult.findByPk(resultId, { lock: t.LOCK.UPDATE, transaction: t, }); if (!result) { throw Object.assign(new Error('结果不存在'), { status: 404 }); } // 录入人不能复核自己录的数据,这是合规底线,不做成可配置项 if (result.entered_by === reviewerId) { throw Object.assign(new Error('不能复核本人录入的数据'), { status: 403 }); } // 已经被复核过的结果不允许二次复核,避免覆盖前一个人的意见 if (result.reviewed_by) { throw Object.assign(new Error('该结果已被复核'), { status: 409 }); } await result.update( { review_status: pass ? 2 : 3, // 2 通过,3 退回 reviewed_by: reviewerId, reviewed_at: new Date(), review_comment: comment, }, { transaction: t } ); return result; }); }

这里用reviewed_by是否存在来判断“已复核”,而不是看review_status的值,原因是退回(3)和未复核(0)在业务含义上不同:退回的结果需要录入人修改后重新提交,reviewed_by保留着能查到是谁退的。reviewed_at用new Date()而不是数据库的NOW(),是为了让时间和应用服务器一致,避免数据库服务器和应用服务器时钟不同步导致日志顺序错乱。

复核不通过时,样本整体状态会从待复核打回检测中,这个联动在sampleService.changeStatus里完成,review函数只负责自己那张表。职责分开之后,测试可以对两个函数分别覆盖,不用构造一整条链路。

5. 常见问题排查:本地能跑、实验室跑不起来的五类坑

5.1 环境与依赖:两个来回折腾最久的坑

坑一:开发机上的node_modules直接拷到服务器,启动报原生模块找不到。

现象:本地一切正常,把整个目录打包上传到 Linux 服务器,执行npm run dev:server报Cannot find module '../build/Release/xxx.node'。

原因:bcrypt这类密码哈希库是原生模块,安装时会按当前操作系统和 CPU 架构编译出.node二进制文件。Windows 上编译出来的产物在 Linux 上完全不能用,反过来也一样。

解决:目标机上删掉node_modules重新npm install。如果生产服务器没有编译工具链(缺 python、make、g++),要么预先装好,要么换成纯 JS 实现的bcryptjs——性能略低但零编译依赖,对实验室这种几十人并发的场景完全够用。我一般会在 README 里直接写明“不要跨平台拷贝 node_modules”,省得后来的人再踩一次。

坑二:报告上的日期总是差 8 小时。

现象:页面登记时间显示正确,导出的 Excel 里时间少了 8 小时,或者数据库里存的和页面显示的差 8 小时。

原因:mysql2驱动默认按本地时区解析DATETIME字段,而 MySQL 服务端的time_zone可能是 UTC(容器镜像常见默认值)。两边各按自己的理解解释同一个字符串,差 8 小时就出现了。

解决:在 Sequelize 的连接配置里显式写死时区,不依赖任何默认值:

// server/config/db.js const { Sequelize } = require('sequelize'); const sequelize = new Sequelize( process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASS, { host: process.env.DB_HOST, dialect: 'mysql', timezone: '+08:00', // 写入和读取都用东八区,避免隐式转换 dialectOptions: { dateStrings: true, typeCast: true }, pool: { max: 10, min: 0, idle: 10000 }, logging: false, // 生产环境必须关,见 5.3 } );

dateStrings: true让驱动把日期原样返回字符串,不做 JSDate转换,配合dayjs格式化,前后端拿到的时间字符串完全一致。pool.max设成 10 是按实验室并发规模估的,设太大反而会因为连接数超过 MySQL 上限导致整个服务连不上库。

5.2 并发与一致性:两个会导致数据出错的坑

坑三:两个复核人同时点“通过”,结果被覆盖。

现象:复核列表里两条记录,两个复核人几乎同时点击,最后只有一个人的意见留下,另一个人的操作既没有报错也没有生效。

原因:review函数早期版本是“先查再改”,查询和更新之间没有锁,两个请求都查到了reviewed_by为空,都认为可以复核,后写的覆盖先写的。

解决:一是事务加lock: t.LOCK.UPDATE,二是更新语句里加条件,用影响行数判断:

const [affected] = await TestResult.update( { review_status: 2, reviewed_by: reviewerId, reviewed_at: new Date() }, { where: { id: resultId, reviewed_by: null }, // 只有还没人复核才更新 transaction: t, } ); if (affected === 0) { throw Object.assign(new Error('该结果已被他人复核'), { status: 409 }); }

两道防线是有意重复的:行锁挡住绝大多数并发,where条件兜住锁失效的极端情况(比如事务隔离级别被改过)。数据一致性这种事,多一层校验的成本远低于事后追账。

坑四:样本编号出现重复。

现象:某天集中登记了上百个样本,编号里出现了两个一样的,数据库唯一索引直接报错,接口 500。

原因:编号生成用的是SELECT MAX(sample_no) + 1,两个请求同时执行,读到同一个最大值,各自加一,得到同一个结果。

解决:改成独立序列表sample_seq,用INSERT ... ON DUPLICATE KEY UPDATE自增(见 3.3 的nextSampleNo)。序列表按“前缀 + 序号”存储,每天一个前缀,天然按天重置。即使应用层算错,sample_no上的唯一索引也会拦住重复插入,接口返回明确错误,而不是写入脏数据。

5.3 部署上线:一条上线当天才发现的坑

坑五:生产环境日志把磁盘写满。

现象:系统上线第三天,服务器磁盘告警,日志文件涨到十几个 G,服务无法写入新文件直接挂掉。

原因:Sequelize 的logging选项默认是console.log,也就是每条 SQL 都打一遍。开发时这个输出很有用,能看到 ORM 生成的 SQL 长什么样;生产环境加上winston的文件传输之后,每条 SQL 变成一行日志文件,一天几十万行。

解决:连接配置里显式写logging: false,需要排查问题时通过环境变量临时打开,或者用logging: (sql) => logger.debug(sql)只往 debug 级别写。上线前把这一条写进部署清单,比事后清理磁盘省事得多。

6. 二次开发:给检测项目加一种新结果类型

6.1 改动面清单:从一个字段开始往外扩

最常见的二次开发需求是“我们这里有一类检测项目,结果不是数值,是从几个选项里选一个”。比如水的嗅味等级、颜色的描述性判定。这类需求看着小,其实要动四个地方,漏一个就会出现“后端能存、前端不显示”或者“前端能填、导出是空的”。

第一步是数据模型。test_item表里加一个value_type字段,用ENUM限定取值,再加一个options存枚举候选值:

// server/models/testItem.js module.exports = (sequelize, DataTypes) => { const TestItem = sequelize.define('TestItem', { code: { type: DataTypes.STRING(32), unique: true, allowNull: false }, name: { type: DataTypes.STRING(64), allowNull: false }, // value_type 决定前端用什么控件渲染、后端用什么规则校验 value_type: { type: DataTypes.ENUM('number', 'text', 'enum'), allowNull: false, defaultValue: 'number', }, unit: DataTypes.STRING(16), lod: DataTypes.DECIMAL(10, 6), // 检出限,仅数值型有意义 options: DataTypes.JSON, // value_type=enum 时的候选值数组 method: DataTypes.STRING(128), }, { tableName: 'test_item' }); return TestItem; };

第二步是后端校验。不同value_type走不同分支,校验函数独立出来,方便单测:

// server/services/resultService.js function validateValue(item, raw) { if (item.value_type === 'number') { const n = Number(raw); if (!Number.isFinite(n)) { throw Object.assign(new Error(`${item.name} 必须是数值`), { status: 400 }); } if (n < 0) { throw Object.assign(new Error(`${item.name} 不能为负数`), { status: 400 }); } return n; } if (item.value_type === 'enum') { const opts = item.options || []; if (!opts.includes(raw)) { throw Object.assign( new Error(`${item.name} 取值不在允许范围内`), { status: 400 } ); } return raw; } // text 类型统一按字符串处理,截断防止超长内容写爆字段 return String(raw).slice(0, 255); }

Number.isFinite而不是isNaN,是因为isNaN('')返回 false(空字符串会被转成 0),而空结果在检测场景里应该被拒绝,不能默认成 0。这个细节在数值型项目上尤其重要——把“未检出”存成 0,报告就错了。

第三步是前端表单。views/result/ResultForm.vue里按value_type动态渲染控件:number用el-input-number并绑定unit后缀,text用el-input,enum用el-select并把options作为候选项。第四步是导出,utils/excel.js里对enum类型的结果加一列“判定”,把原始选项值映射成报告上要展示的文字。

6.2 改完之后怎么验证:一条能重复跑的回归脚本

改完代码点页面确认一遍是不够的,尤其是状态机和校验这类逻辑,手点很容易只覆盖到正常路径。我的做法是维护一个 shell 脚本,跑一遍主链路,任何一次改动之后先跑脚本再看页面:

#!/usr/bin/env bash # 改完状态机、校验规则或数据模型后,先跑这条主链路 set -e BASE=http://127.0.0.1:3000/api TOKEN=$(curl -s -X POST $BASE/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"demo <p> <a href="https://download.csdn.net/download/weixin_42169674/15224392" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
返回列表