资讯动态

公文签收系统源码解析:从登录鉴权到签收留痕的完整实现

发布时间:2026/10/24 6:54:19 来源:尧图企业网站定制
简介这份资源是一套面向信息化办公场景的公文签收系统源码适合需要了解文件流转、审批与归档流程实现方式的开发者、行政信息化人员及课程设计学习者参考。系统围绕公文上传管理、工作流程定义、自动流转提醒、签收审批、状态追踪、版本控制与权限管理等环节展开可帮助读者理解一套轻量级公文处理平台的整体结构与业务逻辑。压缩包共57个文件约196KB以asp动态页面为主辅以jpg、gif图片素材、inc公共包含文件、css样式表、mdb数据库及说明文档覆盖前后台页面、用户与部门管理、消息收发等模块目录组织相对完整。目前已有220人浏览学习适合作为小型办公系统二次开发或功能拆解的参考样本。1. 公文签收系统一个被低估的刚需场景单位里管收文的人最怕什么不是文件多而是签收记录对不上。纸质登记本上签了字回头查不到是谁在几点几分领走的微信群里通知一声“文件已发”三天后问起来没人认账。公文签收系统解决的就是这个链路问题——把“谁、什么时候、签收了哪份文件”变成一条可查、可追溯的数据记录。这个项目适合两类人一类是单位内部需要做收文登记信息化的行政或技术人员另一类是拿它当练手项目、想搞懂一套完整增删改查加权限控制怎么落地的开发者。技术栈上它走的是最稳的路子后端管数据、前端管交互、数据库管留痕没有花哨的中间件部署成本低二次开发门槛也低。下面从目录结构开始把这份源码拆开讲清楚。2. 项目结构与技术选型为什么选这套组合2.1 整体分层与目录约定拿到源码先看目录这是判断一个项目能不能改的第一直觉。公文签收系统这类管理型项目常见做法是前后端分离但部署在一起目录大致长这样document-sign-system/ ├── server/ # 后端服务 │ ├── app.js # 入口挂路由和中间件 │ ├── routes/ # 路由层按业务拆分 │ │ ├── auth.js # 登录鉴权 │ │ ├── document.js # 公文增删改查 │ │ └── sign.js # 签收记录 │ ├── models/ # 数据模型 │ ├── middleware/ # 鉴权、日志、错误处理 │ └── config/ # 数据库连接、环境变量 ├── web/ # 前端页面 │ ├── src/ │ │ ├── views/ # 页面登录、公文列表、签收详情 │ │ ├── api/ # 请求封装 │ │ └── router/ # 前端路由 │ └── package.json ├── sql/ # 建表脚本 │ └── init.sql └── README.md这个结构不复杂但分层清晰routes只负责收参数和返回结果models管数据读写middleware做统一拦截。改功能的时候基本不用满项目找代码按业务名进对应文件就行。我一般拿到新项目会先看app.js里挂了哪些中间件这决定了请求进来之后会经过几道关卡也决定了后面排查问题时该在哪一层打断点。2.2 技术栈选择背后的取舍后端用 Node.js Express数据库用 MySQL前端用 Vue 加一套现成的管理后台组件库。这套组合在中小型管理系统里很常见原因很实际Express 上手快路由和中间件模型直观一个文件就能把接口跑起来MySQL 的DATETIME类型天然适合记录签收时间事务也能保证“公文状态变更”和“签收记录写入”要么都成功要么都失败前端组件库省掉了表格、分页、弹窗这些重复劳动。有人会问为什么不用更重的框架。公文签收系统的业务复杂度不高核心就是几张表的关联查询加权限判断用重框架反而增加部署和调试成本。这里的关键取舍是把复杂度留给数据一致性把简单留给部署。数据库只装一个 MySQL服务端npm install之后node app.js就能起前端npm run build出静态文件交给后端托管一台普通服务器足够。2.3 数据库表设计签收留痕的核心整个系统的可信度建立在表结构上。核心三张表用户表、公文表、签收记录表。签收记录表是重点它不能只存一个“已签收”状态而要存“谁在什么时间签的”。-- 公文表一份公文一条记录 CREATE TABLE document ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, -- 公文标题 doc_no VARCHAR(50) NOT NULL, -- 发文字号如“某字〔2024〕1号” content TEXT, -- 正文内容 status TINYINT DEFAULT 0, -- 0待签收 1已签收 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 签收记录表一次签收一条独立于公文状态 CREATE TABLE sign_record ( id INT PRIMARY KEY AUTO_INCREMENT, document_id INT NOT NULL, -- 关联公文 user_id INT NOT NULL, -- 签收人 signed_at DATETIME DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(255), -- 签收备注 INDEX idx_doc (document_id), INDEX idx_user (user_id) );这里有个设计细节值得说公文表里的status只是冗余的展示字段真正的签收凭据在sign_record表里。为什么不全靠status因为一旦有人误操作把状态改回去签收痕迹就丢了。独立记录表相当于给每次签收留了一份不可覆盖的底账查询时用LEFT JOIN把公文和签收人拼起来。索引加在document_id和user_id上是因为列表页按公文查签收人、个人中心按用户查签收历史这两个方向都是高频查询。3. 核心模块拆解从登录到签收的完整链路3.1 登录鉴权Token 怎么发怎么验签收系统第一道关是身份。没有可靠的身份签收记录就没有意义。项目里走的是经典的 Token 方案登录成功签发一个带过期时间的 Token后续请求放在请求头里带回来服务端中间件统一校验。// routes/auth.js —— 登录接口 const jwt require(jsonwebtoken); const SECRET process.env.JWT_SECRET || change-me-in-prod; router.post(/login, async (req, res) { const { username, password } req.body; const user await UserModel.findByUsername(username); // 密码比对库里存的是哈希不存明文 if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ code: 401, msg: 账号或密码错误 }); } // 签发 Token2 小时过期载荷只放必要信息 const token jwt.sign( { uid: user.id, role: user.role }, SECRET, { expiresIn: 2h } ); res.json({ code: 0, data: { token, username: user.username } }); });这段代码有三个参数要盯住SECRET必须从环境变量读写死在代码里等于把钥匙挂在门上expiresIn设成 2 小时是管理系统的常见折中太短频繁掉线太长泄露风险大载荷里只放uid和role不放密码、手机号这类敏感字段因为 Token 是 Base64 编码不是加密前端能解开看。校验侧放在中间件里所有需要登录的接口统一挂上// middleware/auth.js module.exports function auth(req, res, next) { const header req.headers.authorization || ; const token header.replace(Bearer , ); if (!token) return res.status(401).json({ code: 401, msg: 未登录 }); try { req.user jwt.verify(token, SECRET); // 解出 uid 和 role next(); } catch (e) { // 过期或伪造都会走到这里 return res.status(401).json({ code: 401, msg: 登录已失效 }); } };jwt.verify同时做了签名校验和过期校验抛异常就说明 Token 不可信。这里容易翻车的地方是前端把 Token 存哪存localStorage简单但容易被脚本读取存httpOnlyCookie 更安全但跨域配置麻烦。管理系统内网部署常见做法是存localStorage加短过期时间够用。3.2 公文列表与签收接口状态流转怎么写公文列表要展示“待签收”和“已签收”两类签收动作要保证幂等——同一个人重复点签收不能产生两条记录。先看列表查询// routes/document.js —— 公文列表带签收人信息 router.get(/list, auth, async (req, res) { const { status, page 1, size 10 } req.query; const offset (page - 1) * size; // 用 LEFT JOIN 把签收人和签收时间带出来 const sql SELECT d.id, d.title, d.doc_no, d.status, d.created_at, u.username AS signer, s.signed_at FROM document d LEFT JOIN sign_record s ON s.document_id d.id LEFT JOIN user u ON u.id s.user_id ${status ! undefined ? WHERE d.status ? : } ORDER BY d.created_at DESC LIMIT ? OFFSET ? ; const params status ! undefined ? [status, Number(size), offset] : [Number(size), offset]; const rows await db.query(sql, params); res.json({ code: 0, data: rows }); });LEFT JOIN而不是INNER JOIN是关键待签收的公文还没有签收记录用内连接会直接把它们过滤掉列表就只剩已签收的了。分页参数size和offset必须转成数字MySQL 的LIMIT不接受字符串占位符这是新手常踩的坑。签收接口的核心是“先查后写”加事务// routes/sign.js —— 签收动作 router.post(/:docId, auth, async (req, res) { const docId req.params.docId; const uid req.user.uid; const conn await db.getConnection(); try { await conn.beginTransaction(); // 1. 查公文是否存在且未签收加行锁防并发 const [docs] await conn.query( SELECT id, status FROM document WHERE id ? FOR UPDATE, [docId] ); if (!docs.length) throw new Error(公文不存在); if (docs[0].status 1) throw new Error(该公文已被签收); // 2. 写签收记录 await conn.query( INSERT INTO sign_record (document_id, user_id, remark) VALUES (?, ?, ?), [docId, uid, req.body.remark || ] ); // 3. 更新公文状态 await conn.query(UPDATE document SET status 1 WHERE id ?, [docId]); await conn.commit(); res.json({ code: 0, msg: 签收成功 }); } catch (e) { await conn.rollback(); res.status(400).json({ code: 400, msg: e.message }); } finally { conn.release(); } });FOR UPDATE是这段代码的灵魂。两个人同时点签收第一个事务拿到行锁第二个会阻塞等待等第一个提交后它再查发现status已经是 1直接报“已被签收”。没有这个锁两个请求可能都查到status0然后各插一条记录签收表就出现重复。事务保证“插记录”和“改状态”要么都成要么都不成不会出现记录写了状态没改的中间态。finally里conn.release()必须写连接池连接不释放跑一阵子服务就卡死了。3.3 前端请求封装与签收交互前端不用每个页面都手写fetch统一封装一层把 Token 注入和错误提示收口// web/src/api/request.js import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截自动带 Token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) config.headers.Authorization Bearer ${token}; return config; }); // 响应拦截统一处理 401 和业务错误 request.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token); window.location.href /login; // 登录失效回登录页 } return Promise.reject(err); } ); export default request;拦截器把两件重复的事收掉了每个请求自动带 Token每个 401 自动跳登录。页面里签收按钮只关心业务结果// 签收按钮点击 async function handleSign(docId) { try { await request.post(/sign/${docId}, { remark: 已阅 }); ElMessage.success(签收成功); loadList(); // 刷新列表 } catch (e) { ElMessage.error(e.response?.data?.msg || 签收失败); } }e.response?.data?.msg用了可选链因为网络错误时response可能不存在直接取data.msg会再抛一个异常把真正的错误信息盖掉。这种细节在联调阶段特别省时间。4. 跑起来与避坑部署时最容易翻车的几处4.1 本地启动的完整步骤先把数据库建好再起后端最后起前端顺序不能乱因为前端代理指向后端端口。# 1. 建库建表 mysql -u root -p -e CREATE DATABASE doc_sign DEFAULT CHARSET utf8mb4; mysql -u root -p doc_sign sql/init.sql # 2. 配置后端环境变量新建 server/.env # DB_HOST127.0.0.1 # DB_USERroot # DB_PASS你的密码 # DB_NAMEdoc_sign # JWT_SECRET换成一串随机字符 # 3. 起后端 cd server npm install node app.js # 4. 起前端 cd web npm install npm run devinit.sql里一般会带一条初始管理员账号密码是哈希后的值登录后第一件事就是改密码。前端vite.config.js或vue.config.js里的代理要指向后端实际端口默认 3000改过端口这里也要同步改否则接口全 404。4.2 常见问题排查五个真实踩坑记录现象一登录成功但刷新页面就退出。原因通常是 Token 存了但没在页面初始化时读回来或者路由守卫里判断逻辑写反了。解决在应用入口检查localStorage.getItem(token)有值就恢复登录态同时用 Token 调一次“获取当前用户”接口验证是否过期过期就清掉。现象二签收接口偶发“该公文已被签收”但明明没人签过。原因是并发下两个请求都进了事务其中一个先提交另一个查到的status已经是 1。这其实是正确行为但如果业务上允许多人会签就要把判断条件从“公文状态”改成“该用户是否已签”查sign_record里有没有(document_id, user_id)的组合并给这两列加唯一索引兜底。现象三列表分页第二页数据重复或缺失。排序字段用了created_at同一秒创建的公文顺序不稳定翻页时数据库每次返回的顺序可能不同。解决排序改成ORDER BY created_at DESC, id DESC加id做第二排序键保证顺序唯一。现象四中文标题存进去变成问号。建库时没指定字符集或者连接配置里没设charset。解决建库用utf8mb4数据库连接池配置加charset: utf8mb4两边都要对只改一边不够。现象五部署到服务器后前端页面白屏接口 502。多半是前端build出来的静态文件路径和后端托管目录对不上或者后端进程没起。解决先curl一下后端健康接口确认服务活着再看后端静态托管配置的目录是不是web/distNginx 反代的话检查proxy_pass端口。提示JWT_SECRET上线前必须换掉默认值且不要提交到代码仓库。签收记录属于业务凭证数据库定期备份比什么优化都重要。5. 二次开发从单点签收到流转闭环的改造思路这套源码跑通之后最自然的改造方向是把“签收”扩展成“流转”。现在的模型是一份公文签一次就结束实际业务里往往是“收文→拟办→批办→承办→归档”多节点流转。改造的核心不是加页面而是把sign_record升级成通用的流转记录表加一个action字段区分动作类型再加step字段标记当前节点。-- 流转记录表改造一条记录代表一次节点操作 ALTER TABLE sign_record ADD COLUMN action VARCHAR(20) DEFAULT sign; ALTER TABLE sign_record ADD COLUMN step TINYINT DEFAULT 1; -- action 取值sign签收 / assign拟办 / approve批办 / archive归档对应的接口从“签收”抽象成“推进节点”每次推进前校验当前节点和用户角色是否匹配// 节点推进的通用校验逻辑 const FLOW { 1: { next: 2, role: clerk }, // 签收后进入拟办需办事员角色 2: { next: 3, role: leader }, // 拟办后进入批办需领导角色 3: { next: 4, role: clerk } // 批办后进入归档 }; async function advance(docId, uid, role, action) { const doc await getDocForUpdate(docId); // 行锁查当前节点 const rule FLOW[doc.step]; if (!rule || rule.role ! role) throw new Error(当前节点无权操作); await insertRecord(docId, uid, action, rule.next); await updateDocStep(docId, rule.next); }这样改的好处是新增节点只改FLOW配置和角色表不用动接口结构。验证方法也简单用两个不同角色的账号交替操作同一份公文看节点是否按预期推进、越权操作是否被拦、每一步的signed_at和操作人是否都留了痕。另一个高频改造点是导出。收文台账经常要导成 Excel 交上去常见做法是在后端加一个导出接口用exceljs把列表查询结果写成文件流返回前端用Blob接收触发下载。注意导出要走和列表一样的权限过滤别让普通用户导出了全部公文。我踩过最疼的一次坑是给流转表加字段时直接在生产库上ALTER TABLE表不大但正好赶上有人签收锁表几十秒前端一片超时。从那以后我每次改表结构都强制走一遍“备份→低峰期执行→先在测试库跑一遍完整流程”的流程再急也不省这一步。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑