简介:这份资源是一套面向信息化办公场景的公文签收系统源码,适合需要了解文件流转、审批与归档流程实现方式的开发者、行政信息化人员及课程设计学习者参考。系统围绕公文上传管理、工作流程定义、自动流转提醒、签收审批、状态追踪、版本控制与权限管理等环节展开,可帮助读者理解一套轻量级公文处理平台的整体结构与业务逻辑。压缩包共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: '账号或密码错误' }); } // 签发 Token,2 小时过期,载荷只放必要信息 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,直接报“已被签收”。没有这个锁,两个请求可能都查到status=0,然后各插一条记录,签收表就出现重复。事务保证“插记录”和“改状态”要么都成要么都不成,不会出现记录写了状态没改的中间态。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_HOST=127.0.0.1 # DB_USER=root # DB_PASS=你的密码 # DB_NAME=doc_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/dist,Nginx 反代的话检查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,表不大但正好赶上有人签收,锁表几十秒,前端一片超时。从那以后我每次改表结构都强制走一遍“备份→低峰期执行→先在测试库跑一遍完整流程”的流程,再急也不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取