本来以为开发一套人事管理系统两个月就足够了。没想要真正用起来,天天一堆的问题需要优化。改造起来也异常的费劲,主要是每次修改都需要兼容历史数据,心惊胆战的。好在今天遇到的问题,不是大规模修改组织架构这样的变态需求,而是一个相对独立的功能需求,改起来比较简单。
现存的问题
目前的人事管理系统,包含一个入职员工自己填写个人基本资料的功能,以及 HR 编辑员工信息的功能。
出现了一个严重的 BUG,复现流程:
- HR 进入员工信息编辑页面,开始编辑员工信息
- 然后,入职员工扫码进入员工端编辑页面,开始编辑个人基本资料
- 入职员工提交了个人基本资料,并保存成功
- HR 继续编辑员工信息,并提交保存
这时,HR 提交的内容会覆盖掉员工提交的内容,导致员工填写的个人基本资料丢失了 😓 估计入职的大兄弟已经泪流满面,还得重新填写一遍几十个字段的个人信息。。。
需求
其实最好是可以同时编辑,这样比较节省时间,员工在填写个人资料的时候,HR 可以同步编辑岗位信息。
方案
这个可以实现,但是得新增一个功能,就是增加一个独立的岗位信息编辑功能,或者叫非基本信息编辑功能(可能有更好的名字,再想想)。
点击这个按钮之后,可以编辑员工信息中除了基本信息的部分,即,跟员工端编辑的内容完全隔离开。这样就能同时操作了。
如果要保证用户体验,还是要加上一个状态提醒,即,员工在扫二维码编辑基本信息时,在 HR 端提示只能使用岗位信息编辑功能,不能使用原有的全量信息编辑功能。
我还写了一个演示 Demo 页面,来展示这个功能的实现细节,并且把 js 代码也放在了前端的页面里,方便前端同事了解如何实现。

员工端
在进入员工端编辑页面后,浏览器端的 js 每 30 秒钟向服务器发送一次 POST 请求,表示正在编辑基本信息。
后端会提供一个接口,接收员工的编辑状态,并把这个状态存储在内存中(或者 Redis 中)。这个状态可以设置一个过期时间,例如 60 秒,如果超过 60 秒没有收到更新,就认为员工已经不在编辑了。前端调用接口时,提供员工共享编辑接口使用的那个 token,后端会从 token 中解析出员工 ID,来标识哪个员工正在编辑。
HR 端
通过 SSE(Server-Sent Events)接收服务器端推送来的员工编辑状态。
浏览器对同一个域名下的 SSE 连接数有限制(通常是 6 个),所以采用全局的方式来管理 SSE 连接,避免每个员工都建立一个 SSE 连接导致资源浪费。
服务器端会返回一个正在编辑个人资料的员工 ID 列表。
HR 端在收到这个列表后,更新界面上对应员工的编辑状态提示。包括:
- 员工列表页,现在正在编辑的提示图标,或文字
- 员工详情页,如果员工正在编辑,提示只能编辑岗位信息,不能编辑基本信息。或者把全部信息保存的按钮自动禁用掉,只保留保存岗位信息的按钮。
SSE 的优点:
- 自动重连:如果后端服务重启或网络波动,浏览器会自动发起重连,你不需要写任何心跳重连逻辑(WebSocket 必须手动写)。
- 轻量级:SSE 基于普通的 HTTP 协议,不需要像 WebSocket 那样处理协议升级(Upgrade)。
- 单向通信:需求只是“员工操作 -> 告知 HR”,这是典型的单向推送。
服务器端的实现
用 golang 来实现这个需求的服务端异常简单,既可以通过共享内存来存储员工的编辑状态,也可以方便的调用 Gin 框架的 SSE 功能来推送消息给前端。
如果用 Python 来搞,估计服务器部署就得折腾半天。
这里不使用 Redis,而直接使用 golang 的 go-cache 来存储员工的编辑状态。因为这个状态不需要持久化,也不需要跨服务器共享,所以 go-cache 就足够了。
import (
"time"
"github.com/patrickmn/go-cache"
)
// 创建一个缓存,默认 5 分钟过期,每 10 分钟清理一次过期项
var EditLock = cache.New(5*time.Minute, 10*time.Minute)
// 设置编辑锁
func LockUser(userID string) {
// 设置该用户正在编辑,有效期 60 秒
EditLock.Set(userID, true, 60*time.Second)
}
// 检查是否在编辑
func IsUserLocked(userID string) bool {
_, found := EditLock.Get(userID)
return found
}
// 返回正在编辑的用户列表
func GetLockedUsers() []string {
items := EditLock.Items()
lockedUsers := make([]string, 0, len(items))
for userID := range items {
lockedUsers = append(lockedUsers, userID)
}
return lockedUsers
}
golang Gin 端 SSE 的实现示例:
func StreamHandler(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
// 模拟持续推送,每 2 秒钟检查一次编辑状态并推送给前端
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 检查内存/Redis 中的编辑状态
isEditing := CheckStatus(c.Query("id"))
c.SSEvent("message", map[string]interface{}{
"is_editing": isEditing,
})
c.Writer.Flush() // 必须手动 Flush 才能实时发送
case <-c.Request.Context().Done():
// HR 关闭了页面,后端停止推送
return
}
}
}
Nginx 配置
如果 Golang Gin 后端部署在 Nginx 反向代理后面,需要确保 Nginx 配置支持 SSE 的长连接。可以在 Nginx 配置中添加以下内容, 否则会出现线上接口收不到任何推送消息的情况:
# 专门为 SSE 接口做的特殊配置
location /api/v1/employee/status-stream {
proxy_pass http://golang_backend;
# 核心:必须关闭缓冲,否则消息会被攒在 Nginx 缓存里不发给前端
proxy_buffering off;
proxy_cache off;
# 核心:设置长连接超时(根据业务需求,比如 1 小时或 24 小时)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 核心:处理 HTTP/1.1 协议及 Connection 头部
# Nginx 默认会把 Connection 改为 close,这会导致 SSE 无法持续
proxy_http_version 1.1;
proxy_set_header Connection "";
# 修正:告诉浏览器不要缓存这个流
proxy_set_header Cache-Control "no-cache";
# 实时转发,不缓冲
chunked_transfer_encoding off;
}
SSE 对比 WebSocket
我这是第一次使用 SSE 来给前端推送消息,之前都是用 WebSocket 的。
我感觉 SSE 用起来非常方便,省去了很多 WebSocket 需要处理的细节,尤其是自动重连和协议升级方面。对于这种单向推送的场景,SSE 真的是非常合适的选择。
怪不得现在大模型的网页端,都是用 SSE 来实时推送 AI 生成的内容的,完美契合了单向通信的需求。
关于作者 🌱
我是来自山东烟台的一名开发者,有感兴趣的话题,或者软件开发需求,欢迎加微信 zhongwei 聊聊,或者关注我的个人公众号“大象工具”, 查看更多联系方式