文章目录

我们的人事数据链路里,最"原始"的一环反而在最外面:三家劳务公司每天各报一份入职/面试名单,方式是微信里传来传去 Excel。文件版本靠聊天记录追、手机号身份证号在群里裸奔、HR 每天还要把三份表手工合并。公司内部的群晖共享盘按纪律只读,不适合让外部公司直接写;宜搭是内部审批体系,给外部账号开不了口子。这篇记录我们怎么用自建的 Nextcloud + OnlyOffice,把"在线填表"这件事做成一个可控的浏览器入口。

在线填表系统登录页
▲ 填表入口:自托管在线表格的登录页,三家公司用 HR 分发的账号登录,打开即填,无需安装任何客户端

一、需求与方案取舍

先把需求写清楚:

选型时试过、也否掉了几条路:

方案结论原因
继续微信传 Excel维持现状版本混乱、敏感字段在聊天记录里流转,无法审计
公有云在线文档否名单含身份证/手机号,数据出域不可接受
NocoDB 等自建低代码表否评估后不适合:外部公司要的是"填 Excel"的直觉操作,且迁移成本高于收益
自建 Nextcloud + OnlyOffice采用文件在自己服务器,浏览器打开即原生 Excel 编辑体验,权限可控到文件级

二、部署形态:一套 Compose,两个入口

整套系统就两个容器:nextcloud 负责账号、共享与文件,onlyoffice/documentserver 负责在浏览器里渲染 Excel。对外只放两个 HTTPS 端口——一个是登录与文件浏览,一个是编辑器资源。TLS 全部在服务器侧 nginx 终止,证书复用现有域名自动续期,不额外维护一套。

1
公司账号登录 Nextcloud,文件列表里只挂载 HR 共享给它的那一份名单
/login
↓
2
点击文件即在浏览器内打开 OnlyOffice 编辑器,单元格操作与本地 Excel 一致
.xlsx 在线编辑
↓
3
保存回写 Nextcloud;服务器侧每分钟轮询文件指纹,变化即推送钉钉群通知 HR
内容级指纹

OnlyOffice 连接器有一条容易踩的配置纪律:三个地址各司其职,不能混用——浏览器访问编辑器的地址、Nextcloud 服务端访问编辑器的地址、编辑器服务端回读文件的地址,分别对应"公网入口 / 内网直连 / 内网直连"。用公网地址做服务端互访会绕远路,用内网地址做浏览器入口则外部用户直接白屏。另外,公网入口的相互访问还要依赖路由器的端口回流(hairpin),要在上线前实测,不能想当然。

编排文件里最值得贴出来的两处,一是版本钉死,二是那个"覆盖镜像自带配置"的挂载——它们都是踩过坑之后加的:

# docker-compose.yml(节选;密钥走环境文件,不写进编排)
services:
  app:
    image: nextcloud:34.0.4        # 版本钉死, 升级只跟同大版本安全补丁
    volumes:
      - ./data:/var/www/html
      # 覆盖镜像自带的 remoteip 配置: 否则可信代理判定失效, 公网跳转全变 http
      - ./apache-remoteip.conf:/etc/apache2/conf-available/remoteip.conf:ro
    environment:
      POSTGRES_HOST: db
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: "cloud.example.com"
      TRUSTED_PROXIES: "172.16.0.0/12"   # 容器网段, 而不是公网 IP
  onlyoffice:
    image: onlyoffice/documentserver:9.4.0
    # 启动补丁: 幂等修复模块缺基类依赖导致的"关闭后重开空白"
    entrypoint: ["/bin/bash", "-c", "python3 /opt/ds-patches/fix-deps.py && exec /app/ds/run-document-server.sh"]
    environment:
      JWT_ENABLED: "true"
      JWT_SECRET: ${OO_JWT_SECRET}

三、账号与共享模型:一个文件一家公司

权限模型刻意做得极简:HR 是三个名单文件的所有者,把每个文件单独共享给对应公司的账号(读 + 改)。公司账号登录后,文件列表里只有自己那一份,看不到别人的,也看不到 HR 的其他文件。

这里有个反直觉的点:共享粒度是"每个文件一份独立共享",而不是"一个文件夹共享出去"。这样三家公司的隔离不需要靠目录权限推导,而是天然成立;HR 调整某一家时,也不会误动另外两家。

四、把"能改"收窄成"只能填"

外部账号只给"编辑",还是太宽了——登录进 Nextcloud 之后,删除、改名、上传、新建、把文件再次分享出去,这些入口默认都在。我们对公司侧账号做了两层收紧:

💡 设计原则:权限收紧要做在服务端,UI 隐藏只负责"让人找不到入口",不负责安全。我们最后是双保险:界面上看不见,接口层再拦一道。

这类拦截逻辑我们做成了一个自建小应用,部署与升级有自己的纪律:改完必须执行一次平台的升级命令让应用注册生效,否则改动只停留在文件层——这在自托管的套件里是常见坑,任何"改了不生效"的怪现象,第一步先查它有没有真正 reload。

拦截本身是两层。第一层在 WebDAV 入口,判断逻辑只有一句:目标已存在就是"改",放行;目标不存在就是"建",403:

// DAV 层: 名单内账号的 "新建文件 / 建目录 / 复制出新文件" 直接拒
private function denyIfNew(string $path, string $message): void
{
    $uid = $this->restrictedUid();                    // 不在名单 -> 放行
    if ($uid === null || !str_starts_with($path, 'files/' . $uid . '/')) {
        return;
    }
    if ($this->server->tree->nodeExists($path)) {   // 目标已存在 -> 是"改", 放行
        return;
    }
    throw new Forbidden($message);                 // 目标不存在 -> 403
}

第二层在存储层,因为 DAV 守卫拦不到服务端的 Node API(编辑器"另存为"就走那条路)。思路相同,但挂在文件系统上,所有写路径都会经过:

// 存储层写保护: 只允许改写既有文件; files/ 下目标不存在即拒绝
public function file_put_contents(string $path, mixed $data): int|float|false
{
    if (str_starts_with($path, 'files/') && !$this->file_exists($path)) {
        throw new ForbiddenException('该账号不允许新建或上传文件', false);
    }
    return parent::file_put_contents($path, $data);
}
💡 代码里的边界:存储层的作用范围刻意只限 files/——files_versions/、回收站与编辑器的版本历史不受影响,编辑保存也不受影响;一句话,能改不能建。

五、模板加固:让 Excel 自己防误操作

光限制账号还不够,Excel 本身是"最容易改坏"的载体。模板层面做了三件事:

  1. 结构锁定:工作表保护开启,表头单元格锁定,数据列整列解锁——外部只输数据,动不了标题行与列结构;
  2. 格式统一:日期列预设 yyyy-mm-dd 格式并加宽列宽,避免"2026/9/1、2026.9.1、9月1日"三种写法混进来;
  3. 阅读友好:冻结表头,数据再多也不用滚回去看列名。
⚠️ 老格式陷阱:在线编辑不认旧版 .xls。历史模板是 xls 的,必须转成 xlsx 并重建共享;转换时要拿转换前后的数据逐格校验(我们实测零差异),别让"转换"变成静默的数据事故。

六、从"文件变了"到"钉钉群提醒"

HR 不需要天天刷新页面看有没有人填。服务器侧有一个每分钟一轮的监控脚本:轮询三个名单文件的元信息,变化且稳定 60 秒后(避免编辑中的半截状态触发),推一条消息到 HR 的钉钉群,附上公网链接,内外网都能点开。

这里踩过一个有意思的坑:"打开文件看了两眼"也会改 etag——OnlyOffice 建立会话后元数据就变了。第一版用 etag 判定,结果 HR 被大量"假更新"轰炸。修法是换成内容级指纹(sha256):只有单元格里的内容真的变了才通知;顺手把"每次变化重置稳定计时""单文件失败不中断整轮"等边角逻辑补齐。通知脚本的测试纪律也定死了:调试一律用干跑模式,绝不拿真实群当测试环境。

七、三个深坑

坑 1:公网登录页跳转全变 http,登录表单还被浏览器拦

最隐蔽 内网一切正常,一挂到公网就怪:页面跳转地址全变成 http://,浏览器把登录表单当混合内容拦掉。排查方向看似是 nginx 的转发协议头没传对,实际根因在镜像自带的 Apache 配置:mod_remoteip 把 PHP 眼里的"客户端 IP"改成了真实访客 IP,破坏了 Nextcloud 对"可信代理"的判定,代理声明的协议头随即被无视。修复方式是用挂载覆盖掉镜像里的 remoteip 配置,让可信代理链恢复;重建容器后要专门核对这一项,它是"重建即复发"的坑。

坑 2:编辑器关闭后立刻重开是空白,刷新才恢复

同一会话里必现:关掉编辑器再点开,白板一块,刷新一下又好了。查下来是编辑器版本本身的一批模块缺失基类依赖(加载顺序边角问题)。既然等官方修复遥遥无期,就在容器启动时打补丁:幂等地给缺失依赖的模块补齐基类引用,再配合官方脚本轮换一次静态资源 URL,让浏览器的旧缓存自然失效。容器重建时补丁自动重打——这也是自托管的价值:问题在栈里,就能在栈里修。

坑 3:想回滚文件内容,别用 WebDAV 的版本接口

发现名单被改坏、想"一键恢复历史版本",最直觉的做法是调 WebDAV 的版本 MOVE 接口。实测在这个大版本上,它会先把当前文件删进回收站再恢复——一旦中途失败,共享关系直接断掉,外部账号的清单入口消失。正确路径是两条:用编辑器自带的版本历史界面回滚,或者删掉旧共享、对当前文件 ID 重建共享。这类"接口语义与直觉不符"的坑,回滚演练时务必提前验证。

八、日常运维

九、踩坑清单(可直接复用)

  1. 外部协作先定数据边界:文件停在自己服务器,是这类需求的第一约束;
  2. 共享粒度=单文件:一家一个文件一份共享,隔离天然成立,治理也简单;
  3. 权限双保险:UI 隐藏负责体验,服务端拦截负责安全,编辑器"另存为"也是写路径,要一并封;
  4. 模板先加固再发放:保护工作表 + 锁定表头 + 日期格式,能省掉一半的数据清洗;
  5. 通知用内容指纹而非 etag:打开浏览也会改元信息,只有内容哈希才是真更新;
  6. 深色经验:mod_remoteip 破坏可信代理——公网协议头失效先查它,且容器重建会复发;
  7. 编辑器白屏先怀疑版本:启动补丁 + 资源缓存轮换可以根治"关闭后重开空白";
  8. 回滚别信 WebDAV 版本 MOVE:用编辑器版本历史,或删旧共享重建;
  9. 上线前实测端口回流(hairpin):内外网同域不同端口这条路,路由器不过关就是白干。
✅ 现状:三家公司已全部切换到浏览器在线填写,HR 从"催表 + 合表"里解放出来;文件更新一分钟内推送提醒,后续自动进入原有的人事同步链路。系统独立于内部审批体系运行,外部只拿到"一个文件的一次填写权"——这是我认为外部协作最小化的合理形态。

评 论