diff --git a/backend/internal/identityprovider/directory_sync.go b/backend/internal/identityprovider/directory_sync.go index 62d5812..d1f7070 100644 --- a/backend/internal/identityprovider/directory_sync.go +++ b/backend/internal/identityprovider/directory_sync.go @@ -26,8 +26,10 @@ var directoryRunMu sync.Mutex var directoryRunning bool type directorySnapshot struct { - departments map[string]model.FeishuDepartment - members map[string]model.FeishuDirectoryMember + departments map[string]model.FeishuDepartment + members map[string]model.FeishuDirectoryMember + visibleDepartments int + visibleUsers int } func (p *Provider) DirectorySyncStatus(c *gin.Context) { @@ -90,6 +92,10 @@ func (p *Provider) DirectorySyncConfigure(c *gin.Context) { } directoryRunMu.Lock() defer directoryRunMu.Unlock() + if directoryRunning { + c.JSON(409, gin.H{"error": "请等待当前同步完成后再修改开关"}) + return + } status := syncStatus(cfg, base) status.Enabled = *input.Enabled if err := store.DB.Save(&status).Error; err != nil { @@ -167,8 +173,13 @@ func performDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error if err != nil { return failedSync(status, err) } + currentCfg, currentBase, err := enabled() + if err != nil || currentBase != base || currentCfg.AppID != cfg.AppID || currentCfg.TenantKey != cfg.TenantKey { + return failedSync(status, errors.New("飞书应用配置已变化,请重新同步")) + } now = time.Now() status.LastSuccess, status.Departments, status.Members = &now, len(snapshot.departments), len(snapshot.members) + status.VisibleDepartments, status.VisibleUsers = snapshot.visibleDepartments, snapshot.visibleUsers status.LastError = "" err = store.DB.Transaction(func(tx *gorm.DB) error { for _, dept := range snapshot.departments { @@ -183,7 +194,10 @@ func performDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error return err } } - return tx.Save(&status).Error + return tx.Model(&model.FeishuDirectorySync{}).Where("id = ? AND issuer = ? AND app_id = ?", 1, base, cfg.AppID).Updates(map[string]any{ + "last_success": now, "last_error": "", "departments": status.Departments, "members": status.Members, + "visible_departments": status.VisibleDepartments, "visible_users": status.VisibleUsers, + }).Error }) if err != nil { return failedSync(status, errors.New("写入通讯录失败")) @@ -193,7 +207,7 @@ func performDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error func failedSync(status model.FeishuDirectorySync, err error) (model.FeishuDirectorySync, error) { status.LastError = err.Error() - _ = store.DB.Save(&status).Error + _ = store.DB.Model(&model.FeishuDirectorySync{}).Where("id = ? AND issuer = ? AND app_id = ?", 1, status.Issuer, status.AppID).Update("last_error", status.LastError).Error return status, err } @@ -259,6 +273,7 @@ func readDirectory(ctx context.Context, token string) (directorySnapshot, error) if len(scope.DepartmentIDs) == 0 && len(scope.UserIDs) == 0 { return snapshot, errors.New("飞书应用通讯录可见范围为空;请配置成员和部门可见范围") } + snapshot.visibleDepartments, snapshot.visibleUsers = len(scope.DepartmentIDs), len(scope.UserIDs) queue, seen := append([]string{}, scope.DepartmentIDs...), map[string]bool{} for len(queue) > 0 { id := queue[0] diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 76527d2..89f6c1e 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -41,15 +41,17 @@ type IdentityProfile struct { // FeishuDirectorySync tracks a single application's import schedule and outcome. type FeishuDirectorySync struct { - ID uint `gorm:"primaryKey"` - Enabled bool `json:"enabled"` - Issuer string `gorm:"size:256"` - AppID string `gorm:"size:128"` - LastAttempt *time.Time `json:"last_attempt"` - LastSuccess *time.Time `json:"last_success"` - LastError string `gorm:"size:512" json:"last_error"` - Departments int `json:"departments"` - Members int `json:"members"` + ID uint `gorm:"primaryKey"` + Enabled bool `json:"enabled"` + Issuer string `gorm:"size:256"` + AppID string `gorm:"size:128"` + LastAttempt *time.Time `json:"last_attempt"` + LastSuccess *time.Time `json:"last_success"` + LastError string `gorm:"size:512" json:"last_error"` + Departments int `json:"departments"` + Members int `json:"members"` + VisibleDepartments int `json:"visible_departments"` + VisibleUsers int `json:"visible_users"` } type FeishuDepartment struct { diff --git a/docs/specs/SDD-Feishu-directory-sync.md b/docs/specs/SDD-Feishu-directory-sync.md index ef0fdd4..b899a8f 100644 --- a/docs/specs/SDD-Feishu-directory-sync.md +++ b/docs/specs/SDD-Feishu-directory-sync.md @@ -4,7 +4,7 @@ |---|---| | 版本 / 变更级别 | 0.1 / S3(通讯录权限、个人信息持久化与服务发布) | | 设计状态 | 已形成实施方案 | -| 实现状态 | 已实现,待远程验证 | +| 实现状态 | 已部署;当前飞书可见范围仅含一名成员,部门尚不可见 | | 负责人 / 日期 | Codex / 2026-10-23 | | 关联需求 | 飞书组织成员导入,启用后每 30 分钟同步 | | 批准状态 | 用户要求开发;飞书应用权限由管理员自行授予 | @@ -35,6 +35,7 @@ - 新增 `GET /api/system/identity/directory-sync`、`PUT /api/system/identity/directory-sync`(enabled)、`POST /api/system/identity/directory-sync/run`。仅 admin + HTTPS;不返回凭据。 - 新增同步状态、部门、成员表,使用 `(issuer, app_id, id)` 区分应用;保留缺席成员,仅更新本次见到的数据和 `last_seen_at`。 +- 最近同步状态还记录飞书 `/scopes` 返回的可见部门入口数和单独成员数;这不是企业通讯录总人数。 - 不修改原有登录、专线及 VPNUser API。Fly书权限以实际 API 报错为准:部门与成员列表通常要求部门和用户的应用身份读取权限及足够的通讯录可见范围,页面提供具体引导。 ## 5. 风险与验收 @@ -48,10 +49,12 @@ | AC | 对应需求 | 前置/操作 | 预期 | 验证方式 | 结果 | |---|---|---|---|---|---| -| AC-01 | FR-01 | 启用自动同步并重启 | 下次到期继续运行,关闭后停止 | 代码检查/人工验证 | 待远程验证 | +| AC-01 | FR-01 | 启用自动同步并重启 | 下次到期继续运行,关闭后停止 | 代码检查/人工验证 | 未启用;待权限配置完善后验证 | | AC-02 | FR-02 | 模拟部门分页与成员重复 | 完整遍历、按 open_id 去重 | `go test ./backend/internal/identityprovider -run TestReadDirectory` | 通过 | | AC-03 | FR-03 | 中间页错误 | 不接受不完整快照,不给新准入 | `TestReadDirectoryFailsClosedOnIncompletePage`;Go 事务代码检查 | 通过 | -| AC-04 | FR-04 | 打开身份页 | 可查看状态及成员摘要 | `npm run build`;页面待人工检查 | 构建通过,页面待验证 | +| AC-04 | FR-04 | 打开身份页 | 可查看状态及成员摘要 | `npm run build`、远程 API 手动试读 | API 通过:1 名成员、0 个部门;页面待人工检查 | + +2026-10-23 首次测试服务器部署后,手动同步成功,自动开关仍关闭。实际仅 1 位可见成员、0 个部门,无法据此认为企业只有 1 位员工;等待飞书开放平台配置部门权限与应用通讯录可见范围后复测全量组织。后续界面增补了可见范围计数与部门缺失提示,待再次部署复测。 ## 6. 发布与恢复 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 27e09d2..91e70f9 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -794,7 +794,7 @@ "commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled" }, "feishuSync": { - "title": "Feishu directory", "hint": "Import manually or every 30 minutes when enabled. Imported members do not receive VPN access or tunnel grants. Names are shown exactly as returned by Feishu.", "permissions": "Grant app identity access to department and user lists, publish the app, and set its directory visibility to the intended departments and members.", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "name": "Name", "departments": "Department IDs", "firstMembers": "Showing the first 500 members", "saved": "Sync settings saved", "started": "Sync started; watch progress below", "done": "Directory sync complete", "failed": "Directory sync failed" + "title": "Feishu directory", "hint": "Import manually or every 30 minutes when enabled. Imported members do not receive VPN access or tunnel grants. Names are shown exactly as returned by Feishu.", "permissions": "Grant app identity access to department and user lists, publish the app, and set its directory visibility to the intended departments and members.", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "visibility": "App-visible scope: {departments} department roots · {users} individually visible users (not the enterprise total)", "noDepartments": "Only individually visible members were returned. Grant department read access and expand the app's directory visibility in Feishu, publish, then sync again.", "name": "Name", "departments": "Department IDs", "firstMembers": "Showing the first 500 members", "saved": "Sync settings saved", "started": "Sync started; watch progress below", "done": "Directory sync complete", "failed": "Directory sync failed" }, "feishuDirectory": { "title": "Feishu directory lookup", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index fc2c727..5467b79 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -794,7 +794,7 @@ "commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启" }, "feishuSync": { - "title": "飞书组织与成员", "hint": "手动导入,或开启后每 30 分钟同步一次。导入不会自动开通 VPN 或授予专线权限;仅显示飞书返回的姓名。", "permissions": "需应用身份读取部门及直属成员(如 contact:department.base:readonly、contact:user.department:readonly);发布应用,并设置通讯录可见范围。具体权限以飞书接口报错为准。", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败" + "title": "飞书组织与成员", "hint": "手动导入,或开启后每 30 分钟同步一次。导入不会自动开通 VPN 或授予专线权限;仅显示飞书返回的姓名。", "permissions": "需应用身份读取部门及直属成员(如 contact:department.base:readonly、contact:user.department:readonly);发布应用,并设置通讯录可见范围。具体权限以飞书接口报错为准。", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "visibility": "飞书应用当前可见范围:部门入口 {departments} · 单独成员 {users}(与企业通讯录总人数不同)", "noDepartments": "当前只读到单独授权的成员,没有可见部门。请在飞书配置应用的部门读取权限和通讯录可见范围,发布后重新同步。", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败" }, "feishuDirectory": { "title": "飞书姓名查询", diff --git a/frontend/src/views/settings/components/IdentitySettings.vue b/frontend/src/views/settings/components/IdentitySettings.vue index 947f77d..9da1935 100644 --- a/frontend/src/views/settings/components/IdentitySettings.vue +++ b/frontend/src/views/settings/components/IdentitySettings.vue @@ -66,6 +66,8 @@ {{ $t('tls.refresh') }}

{{ $t('feishuSync.lastSuccess') }}:{{ syncStatus.last_success ? new Date(syncStatus.last_success).toLocaleString() : '—' }} · {{ $t('feishuSync.counts', { departments: syncStatus.departments || 0, members: syncStatus.members || 0 }) }}

+

{{ $t('feishuSync.visibility', { departments: syncStatus.visible_departments || 0, users: syncStatus.visible_users || 0 }) }}

+