// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。 // // 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点 // **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置 // confirmed_leave。 package server import ( "context" "encoding/json" "fmt" "html" "log/slog" "net/http" "strings" "time" "idsync/internal/config" "idsync/internal/ldaplib" "idsync/internal/store" "idsync/internal/syncer" "idsync/internal/webui" ) // Deps 是服务器依赖。 type Deps struct { Cfg *config.Config Auth *Auth Store *store.Store Runner *syncer.Runner LDAP *ldaplib.Client // FeishuTest 检查飞书连通性 FeishuTest func(ctx context.Context) error Log *slog.Logger } // Server 装配路由。 type Server struct { deps *Deps mux *http.ServeMux // syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。 syncMu chan struct{} } // New 构造服务器。 func New(d *Deps) *Server { s := &Server{ deps: d, mux: http.NewServeMux(), syncMu: make(chan struct{}, 1), } s.routes() return s } // Handler 返回可挂载到 http.Server 的处理器。 func (s *Server) Handler() http.Handler { return s.mux } func (s *Server) routes() { // 静态资源与登录页不需要认证。 s.mux.HandleFunc("/", s.handleIndex) s.mux.HandleFunc("/static/", webui.AssetHandler()) s.mux.HandleFunc("/api/login", s.handleLogin) s.mux.HandleFunc("/api/logout", s.handleLogout) // 离职确认端点:token 即凭证,不经 Auth 中间件。 s.mux.HandleFunc(config.ResignConfirmRoute, s.handleResignConfirm) // 以下接口需要登录。 s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus)) s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers)) s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups)) s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan)) s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync)) s.mux.HandleFunc("/api/snapshot", s.deps.Auth.Middleware(s.handleSnapshot)) s.mux.HandleFunc("/api/resign/pending", s.deps.Auth.Middleware(s.handleResignPending)) s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns)) } // handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"}) return } webui.IndexHandler()(w, r) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) return } if err := s.deps.Auth.Login(w, r); err != nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.deps.Auth.Logout(w) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // handleStatus 返回概览信息。 func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() cfg := s.deps.Cfg ldapOK, ldapErr := true, "" if err := s.deps.LDAP.Ping(); err != nil { ldapOK, ldapErr = false, err.Error() } feishuOK, feishuErr := true, "" if s.deps.FeishuTest != nil { if err := s.deps.FeishuTest(ctx); err != nil { feishuOK, feishuErr = false, err.Error() } } users, err := s.deps.Store.ActiveUsers() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } depts, err := s.deps.Store.ActiveDepartments() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } skipped, _ := s.deps.Store.SkippedUsers() pending, _ := s.deps.Store.PendingConfirm() lastRun, err := s.deps.Store.LastRun() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{ "ldap": map[string]any{ "ok": ldapOK, "error": ldapErr, "url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(), }, "feishu": map[string]any{ "ok": feishuOK, "error": feishuErr, "appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList, }, "lldap": map[string]any{ "baseUrl": cfg.Lldap.BaseURL, }, "counts": map[string]any{ "activeUsers": len(users), "departments": len(depts), "pendingConfirm": len(pending), "skipped": len(skipped), }, "lastRun": lastRun, "schedule": map[string]any{ "enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot, "sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun, }, }) } // handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。 func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { all, err := s.deps.Store.AllMirrorUsers() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } type row struct { UID string `json:"uid"` Name string `json:"name"` Email string `json:"email"` State string `json:"state"` Skipped bool `json:"skipped"` SkipReason string `json:"skipReason,omitempty"` DeptIDs []string `json:"deptIds"` LastSync string `json:"lastSyncedAt"` } rows := make([]row, 0, len(all)) for _, u := range all { deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID) rows = append(rows, row{ UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State, Skipped: u.Skipped, SkipReason: u.SkipReason, DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt), }) } writeJSON(w, http.StatusOK, map[string]any{"users": rows}) } // handleGroups 返回部门镜像。 func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { depts, err := s.deps.Store.AllDepartments() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"groups": depts}) } // handlePlan 计算并返回 dry-run 物化计划,不产生任何写入。 func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() plan, err := s.deps.Runner.Preview(ctx) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, plan) } // handleSync 执行物化。默认 dry-run,只有显式传 dryRun=false 才真正写入。 func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) return } var req struct { DryRun *bool `json:"dryRun"` Force bool `json:"force"` } if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } dryRun := true if req.DryRun != nil { dryRun = *req.DryRun } if err := s.lockSync(); err != nil { writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) return } defer s.unlockSync() ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual") if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan}) return } writeJSON(w, http.StatusOK, map[string]any{"dryRun": dryRun, "plan": plan, "result": res}) } // handleSnapshot 触发一轮快照。 func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) return } if err := s.lockSync(); err != nil { writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) return } defer s.unlockSync() ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() res, err := s.deps.Runner.RunSnapshot(ctx) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, res) } // handleResignPending 返回待人工确认的离职候选(等价于卡片按钮,可在界面操作)。 func (s *Server) handleResignPending(w http.ResponseWriter, r *http.Request) { list, err := s.deps.Store.PendingConfirm() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } type row struct { UID string `json:"uid"` Name string `json:"name"` Email string `json:"email"` LastSync string `json:"lastSyncedAt"` } rows := make([]row, 0, len(list)) for _, u := range list { rows = append(rows, row{UID: u.UID, Name: u.DisplayName, Email: u.Email, LastSync: timeOrZero(u.LastSyncedAt)}) } writeJSON(w, http.StatusOK, map[string]any{"pending": rows}) } // handleResignConfirm 处理一次性离职确认:校验 token 并通过后置 confirmed_leave。 // // token 即凭证,因此本项目路由不经 Auth 中间件。成功后渲染极简确认页,不回显 // 用户敏感信息;无效/过期/已用分别给出对应提示。 func (s *Server) handleResignConfirm(w http.ResponseWriter, r *http.Request) { uid := r.URL.Query().Get("uid") token := r.URL.Query().Get("token") rec, err := s.deps.Store.ConsumeToken(token) if err != nil { s.renderConfirmPage(w, "确认链接无效", "该确认链接暂不可用,可能是已失效、已使用或已过期。请向管理员重新发起离职确认。") return } // token 与请求里携带的 uid 必须一致,防串用。 if rec.UID != uid { s.renderConfirmPage(w, "确认链接无效", "该确认链接与请求的用户不一致,已拒绝。") return } if err := s.deps.Store.ConfirmLeave(uid); err != nil { s.log().Warn("确认离职失败", "uid", uid, "err", err) s.renderConfirmPage(w, "操作失败", "处理确认时出错,请稍后重试或联系管理员。") return } s.log().Info("用户已人工确认离职", "uid", uid) s.renderConfirmPage(w, "已确认离职", "该员工已标记为离职,将在下一轮同步中从目录移除。") } // renderConfirmPage 渲染极简确认页,不展示任何用户敏感信息。 func (s *Server) renderConfirmPage(w http.ResponseWriter, title, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = fmt.Fprintf(w, `
%s
`, html.EscapeString(title), html.EscapeString(title), html.EscapeString(body)) } // handleRuns 返回物化历史。 func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { runs, err := s.deps.Store.RecentRuns(100) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) } func (s *Server) lockSync() error { select { case s.syncMu <- struct{}{}: return nil default: return fmt.Errorf("已有快照或物化任务在执行") } } func (s *Server) unlockSync() { <-s.syncMu } func (s *Server) log() *slog.Logger { if s.deps.Log != nil { return s.deps.Log } return slog.Default() } // writeJSON 输出 JSON 响应。 func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(body) } // maskSecret 只保留标识的前后几位。 func maskSecret(s string) string { if len(s) <= 8 { if len(s) == 0 { return "未配置" } return "****" } return s[:4] + "****" + s[len(s)-4:] } func timeOrZero(t time.Time) string { if t.IsZero() { return "" } return t.Format(time.RFC3339) }