Skip to content

feat: 接入飞书组织架构同步 + 外网部署参数 Web 化 - #110

Open
lwl3520 wants to merge 137 commits into
GavinTan:mainfrom
lwl3520:main
Open

feat: 接入飞书组织架构同步 + 外网部署参数 Web 化#110
lwl3520 wants to merge 137 commits into
GavinTan:mainfrom
lwl3520:main

Conversation

@lwl3520

@lwl3520 lwl3520 commented Jul 15, 2026

Copy link
Copy Markdown

概述

新增飞书(Lark)通讯录同步功能,可按 cron 周期自动拉取部门树与员工,本地开账号、签证书、邮件发送 .ovpn 附件 + 登录链接。同时把外网部署相关参数从纯环境变量收拢到 Web 系统管理菜单。

主要变更

飞书组织架构同步

  • 接入飞书 contact-v3 API(用官方 SDK larksuite/oapi-sdk-go/v3),DFS 遍历部门树 + 分页拉成员
  • 新员工:本地开账号(手机号后 6 位 + 4 位随机大小写字母作默认密码)→ easy-rsa 签证书 → 邮件发送 .ovpn 附件 + 登录链接
  • 离职:自动禁用本地账号(is_enable=false),保留证书和历史,方便复职复用
  • 部门 → VPN Group 映射(同名、父子关系保留)
  • 字段归属分离:飞书只覆盖它拥有的字段(name/email/phone/gid/is_enable),用显式 allowlist 保证绝不触碰管理员改的字段(MfaSecret / IpAddr / ExpireDate / OvpnConfig)
  • 飞书用户邮箱优先 enterprise_email(多数员工 personal email 为空、企业邮箱有值)
  • 复职支持:leaver→active 自动重新生成密码 + 强制改密 + 补发邮件
  • 后台账号管理加"发送邮件"按钮(手工补发/重置密码通知用)
  • 同步日志表:每次同步落盘一行(时间/类型/总数/新增/更新/禁用/错误/触发者),UI 可查
  • 同步策略:Web 菜单可配(启用开关、AppID/AppSecret、根部门 ID、同步 cron、离职自动禁用、默认分组、创建后发邮件)
  • 6 个纯函数单元测试通过(密码格式/回退/唯一性、手机规整、用户名派生)

外网部署参数 Web 化

  • 把以下参数从纯环境变量收拢到 Web 菜单管理(环境变量仅作首次初始默认值写入 config.json,之后以 Web 菜单为准):
    • 外显 IPopenvpn.ovpn_remote_addr,环境变量 OVPN_REMOTE_ADDR
    • 外显端口openvpn.ovpn_remote_port,环境变量 OVPN_REMOTE_PORT
    • 登录链接system.base.site_url,环境变量 OVPN_SITE_URL
    • 邮件带登录链接开关system.email.login_link_enabled,环境变量 OVPN_EMAIL_LOGIN_LINK,默认开)
    • 使用说明链接system.email.help_url,环境变量 OVPN_HELP_URL
  • genclient 改为从 config.json 读外显地址(不再依赖 shell 环境变量,配置收敛到 Web)
  • 邮件模板按登录链接开关条件渲染附件提示文案(关闭时附件提示不再提"点击上方链接登录后下载")

修复

  • 飞书同步创建用户时绑定同名 OvpnConfig(用户页面下载/登录校验的绑定键)
  • 老用户回填:仅当 OvpnConfig 为空时补同名配置,绝不覆盖管理员已设值
  • 容器 DNS 改公共(223.5.5.5 / 8.8.8.8),根治 Docker 内嵌 DNS 解析外部域名瞬时 server misbehaving(表现为 SMTP 发邮件偶发失败)

数据库变更

GORM AutoMigrate 自动处理,无破坏性:

  • user 表新增 phone / feishu_user_id(带索引)/ last_sync_at 字段
  • group 表新增 feishu_dept_id(带索引)/ feishu_parent_id(带索引)字段
  • 新增 feishu_sync_log 表(同步日志)
  • 新增 system.email.login_link_enabled / system.email.help_url 配置项
  • 新增 openvpn.ovpn_remote_addr / openvpn.ovpn_remote_port 配置项

默认行为兼容

  • 飞书同步默认关闭system.feishu.feishu_enabled=false),需在 Web 菜单填 AppID/AppSecret 后手动启用
  • 邮件登录链接默认开启,使用说明链接默认(不显示)
  • 外显 IP/端口默认(自动探测本机出网 IP 和配置端口,不改变默认部署行为)
  • 现有用户不受影响(飞书同步只处理有 feishu_user_id 的账号)

本地构建/测试

# 构建(含 easy-rsa 本地预下载)
docker buildx build -f build/Dockerfile.local -t openvpn-web:local .

# 单测
cd src/openvpn-web && OVPN_DATA="$(mktemp -d)" GOPROXY="https://goproxy.cn,direct" GOSUMDB=off go test .

附:build/Dockerfile.local(多阶段本地编译)、docker-compose.local.yml(一键启动)、scripts/feishu-smoke/(飞书 API 连通性独立诊断工具)。

已知事项 / 待办

  • 飞书 event 订阅(实时同步)未实现,目前是定时全量幂等拉取
  • 跨租户(multi-tenant)不支持
  • 同步日志未做自动清理(可参考 history_max_daysfeishu_log_max_days
  • 飞书 SSO 登录未做,目前飞书仅用于同步,本地账号密码登录不变

验证清单(review 时可参考)

  • 配置飞书 AppID/AppSecret → 测试连接 → 立即同步,账号管理出现飞书用户
  • data/clients/<user>.ovpn 已生成且 remote 行指向正确地址
  • 收到含 .ovpn 附件 + 登录链接的邮件
  • 飞书后台移除一员工,等下次同步,本地 IsEnable=false.ovpn 仍存在
  • 飞书后台重加该员工,等下次同步,本地 IsEnable=true、新密码、新邮件
  • 改 IP/MFA/密码后再次同步,这三个字段不被飞书覆盖
  • openvpn 段填外显 IP/端口,再生成客户端,.ovpnremote 行 = 该地址

GavinTan and others added 30 commits May 22, 2026 10:12
- User 新增 Phone / FeishuUserID / LastSyncAt 三个字段
- Group 新增 FeishuDeptID / FeishuParentID 两个字段
- sendEmail 改 variadic 签名,支持邮件附件(向后兼容)
- 新增 EmailAttachment 类型
- 新增 FeishuSyncLog 模型(同步日志表)
- 新增 FeishuClient + tenant_access_token 缓存 + 限流 + 指数退避重试
- 新增飞书 API 封装(部门树、用户列表、用户详情)

TODO: 当前是自定义 HTTP 客户端实现,下次提交改为 larksuite/oapi-sdk-go/v3 官方 SDK。
larksuite/oapi-sdk-go/v3 v3.9.9 与 golang.org/x/time v0.15.0 已作为间接依赖加入。
用 larksuite/oapi-sdk-go/v3 替代自定义 HTTP 客户端,去掉手写的 token 缓存/限流/重试,由 SDK 内置处理。

- FeishuClient 薄封装 lark.Client,暴露 WalkDepartments / ListUsersInDept / GetUser / TestConnection
- 保留 FeishuDept/FeishuUser 值类型(指针解引用),让同步引擎不接触 SDK 细节
- 移除 golang.org/x/time 依赖,larksuite SDK 升为直接依赖
RunSync 编排:拉部门树 → 映射 VPN 组 → 拉成员去重 → 逐用户 reconcile → 离职检测。

- FeishuSyncer.RunSync:全局互斥防重叠,每次同步落盘一行 FeishuSyncLog
- reconcileGroups:飞书部门拓扑序映射为 VPN Group(父先于子),根部门成员归默认组
- reconcileUser:新增/更新分离;字段归属用显式 allowlist,绝不触碰管理员字段(MfaSecret/IpAddr/ExpireDate 等)
- 复职检测:leaver→active 时重新生成密码、强制改密、补发邮件
- detectLeavers:仅改 is_enable,保留证书与历史
- ResendWelcome:供 admin UI 发送邮件按钮复用
- 密码生成:mobile 后 6 位 + 4 位 crypto/rand 大小写字母
- ensureClientCert/sendWelcomeEmail:复用 genclient shell 与 email.html,邮件附 .ovpn
- config.go: 新增 SysFeishuConfig 结构、viper 默认值、9 个包级运行时变量
- loadConfig 中 AES 解密 app_secret 到明文变量(与 email.password 同容忍策略)
- OnConfigChange 回调增加 restartFeishuCron,改 cron 表达式或开关无需重启
- main.go: cron 提为包级全局 cronScheduler,新增 restartFeishuCron(nil 容错)
- AutoMigrate 增加 FeishuSyncLog
- 新增 currentFeishuConfig() 组装运行时配置供 handler 复用
- main.go: 新增 4 个 admin 路由(同步/测试/日志/重发邮件)
- /settings POST 增加 feishu_app_secret AES 加密分支
- feishu.go: ResendWelcome/sendWelcomeEmail 重构为包级函数,重发邮件不依赖飞书启用
- index.html: 系统设置 tab 新增飞书同步配置段 + 同步日志表
- settings.js: 字段 load/bind、测试连接、立即同步、日志渲染
- user.js: 账号管理表'更多'菜单加'发送邮件'项及 handler
- email.html: 新增 HasAttachment 附件提示块
- main.go: 两处 email 模板调用点补 HasAttachment 字段(向后兼容)
覆盖密码生成格式/回退/唯一性、手机号规整、数字剥离、用户名派生优先级。6 个用例全通过。

注意:package main 测试会触发 init()→initConfig() 写 config.json,
运行时需 OVPN_DATA 指向临时目录(config.json 已在 .gitignore)。
实测发现多数员工 personal email 字段为空、enterprise_email 有值。
- FeishuUser 增加 EnterpriseEmail,SDK 解析两处补齐
- 新增 primaryEmail():个人邮箱优先否则企业邮箱
- deriveUsername 优先级 email→企业邮箱→手机后6→user_id→open_id
- reconcileUser 创建/更新/复职三处 Email 字段与发邮件均用 primaryEmail
- smoke test 验证字段结构通过;单元测试同步更新
- build/Dockerfile.local: 多阶段构建,golang 编译 openvpn-web + alpine 运行;easy-rsa 用宿主机预下载包;sed 修 CRLF
- docker-compose.local.yml: 一键 up --build
- .gitignore: 忽略 easy-rsa 二进制包
NAT/端口映射等外网暴露场景,让生成的客户端 .ovpn 指向真实外网可达地址。

genclient 的 remote 行优先级:显式参数 > 环境变量 OVPN_REMOTE_ADDR/OVPN_REMOTE_PORT > 自动探测本机出网IP/config端口。

实测验证:设 OVPN_REMOTE_ADDR=203.0.113.10 OVPN_REMOTE_PORT=11194,
生成的 ovpn remote 行 = '203.0.113.10 11194';传显式参数则覆盖环境变量。
问题2: 账户没和同名 ovpn 绑定
- reconcileUser 创建用户时设 OvpnConfig = username + '.ovpn'
- 老用户回填:更新路径仅当 OvpnConfig 为空时补同名配置(绝不覆盖管理员已设值)

问题1: 重置密码发邮件偶发失败
- 根因是 Docker 内嵌 DNS(127.0.0.11) 解析 smtp.feishu.cn 瞬时 'server misbehaving'
- docker-compose.local.yml 加 dns: [223.5.5.5, 8.8.8.8] 用公共 DNS 根治
把 OVPN_REMOTE_ADDR/PORT/EMAIL_LOGIN_LINK/HELP_URL/SITE_URL 等部署参数从纯环境变量收拢到 Web 系统管理菜单,环境变量仅作首次初始默认值写入 config.json(之后 Web 菜单为准)。

config.go:
- SysEmailConfig 新增 LoginLinkEnabled / HelpURL
- OvpnConfig 新增 OvpnRemoteAddr / OvpnRemotePort(取代 shell 直接读环境变量)
- viper.SetDefault 读 OVPN_SITE_URL / OVPN_EMAIL_LOGIN_LINK / OVPN_HELP_URL / OVPN_REMOTE_ADDR / OVPN_REMOTE_PORT 作为初始值
- 加 envOr / envOrBool helper

docker-entrypoint.sh genclient:
- 改为从 config.json 读 ovpn_remote_addr/port(不再读环境变量,配置收敛到 Web)

email.html:
- 登录链接段包 if LoginLinkEnabled
- 加 {{ if .HelpUrl }} 使用说明链接段
- 附件提示文案根据 LoginLinkEnabled 条件渲染(关闭时不提点击上方链接登录后下载)

调用点(feishu.go / main.go)三处邮件渲染均传 LoginLinkEnabled + HelpUrl。

Web 菜单:
- 邮件配置段:邮件带登录链接 checkbox + 使用说明链接 text
- openvpn 段:外显IP + 外显端口(NAT 场景)
- settings.js:4 个新字段的 load + blur/change handler
- 4 个新提示符补齐问号 path
data/ 含 PKI、客户端配置、ovpn.db、config.json 等运行时数据,gitignored。
Windows 工作区下 build/openvpn-auth 被 git autocrlf 转成 CRLF,而
Dockerfile.local 的 sed 只处理了 entrypoint 和 supervisord,漏掉了
openvpn-auth。镜像内该认证脚本 shebang 变为 #!/bin/bash\r 无法执行,
OpenVPN auth-user-pass-verify 静默失败:所有 VPN 账号登录失败,web 登录正常。

- Dockerfile.local: sed 补上 /usr/lib/openvpn/plugins/openvpn-auth
- 新增 .gitattributes 强制脚本类文件保持 LF,防止复发
根部门 ID、同步计划、默认分组 ID 三处 tooltip 图标原本只画了外圈
<path>,缺少内部问号形状的 <path>,渲染出来是一个空圈、看不到问号。
补全后三处均与同模板中其他 bi-question-circle 保持双 path 结构。
main.go 大量 time.Now().Format(...) 直接使用 time.Time 自带 Location,
未调 .Local(),因此最终输出取决于宿主机 /etc/localtime。云上默认 UTC
的宿主机,Web 上证书过期时间、飞书同步日志、CSV 导出等所有时间戳都
不是东八区。

修复:
- main.go init() 最前面用 time.LoadLocation("Asia/Shanghai") 覆盖
  time.Local,进程内所有 time.Now() 自动按东八区输出。
  必须放在 initConfig/loadConfig 之前;package-level var(如 logger
  prefix)初始化在 init 之前完成,那一刻取到的字符串字面量是 UTC 的一
  次性前缀,对后续运行无影响。
- docker-compose*.yml 添加 TZ=Asia/Shanghai 环境变量,让容器内非 Go
  进程(openvpn/easyrsa/supervisord)的日志时间戳也是东八区。双保险。
上一版只改了 main.go init() 调用 time.LoadLocation("Asia/Shanghai"),
但 Dockerfile.local 的 apk add 列表里没装 tzdata。Alpine 镜像里 musl
libc 自带的 zoneinfo 只有 UTC 等基础项,没有 Asia/Shanghai,
time.LoadLocation 报错、time.Local 退回 UTC,所有时间仍然慢 8 小时。

修复:
- Dockerfile.local 的 apk add 加上 tzdata,镜像里就有
  /usr/share/zoneinfo/Asia/Shanghai,LoadLocation 成功。
- main.go init() 在 LoadLocation 失败时不再静默跳过,而是往 stderr 打
  WARNING 日志,提示检查 Dockerfile 是否装了 tzdata,避免再次踩坑。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants