package server import ( "context" "encoding/json" "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) // 以下接口需要登录。 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/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) } // handleLogin 处理登录。 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}) } // handleLogout 处理登出。 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 := true ldapErr := "" if err := s.deps.LDAP.Ping(); err != nil { ldapOK = false ldapErr = err.Error() } feishuOK := true feishuErr := "" if s.deps.FeishuTest != nil { if err := s.deps.FeishuTest(ctx); err != nil { feishuOK = false feishuErr = err.Error() } } accounts, err := s.deps.Store.AllAccounts() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } groups, err := s.deps.Store.AllGroups() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } active, inactive := 0, 0 for _, a := range accounts { if a.State == "inactive" { inactive++ } else { active++ } } 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(), "inactiveOu": cfg.InactiveOUDN(), }, "feishu": map[string]any{ "ok": feishuOK, "error": feishuErr, "appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList, }, "counts": map[string]any{ "activeUsers": active, "inactiveUsers": inactive, "groups": len(groups), }, "lastRun": lastRun, "sync": map[string]any{ "enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule, "defaultDryRun": cfg.Sync.DefaultDryRun, }, }) } // handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。 func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { accounts, err := s.deps.Store.AllAccounts() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } // 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项, // 这类问题在界面上一眼可见比事后翻日志有用得多。 drift := map[string]string{} if conn, err := s.deps.LDAP.Dial(); err == nil { defer conn.Close() active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN()) inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN()) if errA == nil && errI == nil { for _, a := range accounts { switch a.State { case "inactive": if _, ok := inactive[a.UID]; !ok { drift[a.UID] = "台账标记离职,但 LDAP 中不存在" } default: if _, ok := active[a.UID]; !ok { if _, inInactive := inactive[a.UID]; inInactive { drift[a.UID] = "台账标记在职,实际位于离职 OU" } else { drift[a.UID] = "台账存在,但 LDAP 中不存在" } } } } } } type row struct { UID string `json:"uid"` Name string `json:"name"` Email string `json:"email"` State string `json:"state"` DN string `json:"dn"` DeptIDs string `json:"deptIds"` LastSync string `json:"lastSyncedAt"` Drift string `json:"drift,omitempty"` } rows := make([]row, 0, len(accounts)) for _, a := range accounts { rows = append(rows, row{ UID: a.UID, Name: a.Name, Email: a.Email, State: a.State, DN: a.DN, DeptIDs: a.DeptIDs, LastSync: a.LastSyncedAt.Format(time.RFC3339), Drift: drift[a.UID], }) } writeJSON(w, http.StatusOK, map[string]any{"users": rows}) } // handleGroups 返回受管分组清单。 func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { groups, err := s.deps.Store.AllGroups() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) } // 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) } // 默认 dry-run:忘记传参数时宁可什么都不做。 dryRun := true if req.DryRun != nil { dryRun = *req.DryRun } // 同一时刻只允许一次同步。 select { case s.syncMu <- struct{}{}: defer func() { <-s.syncMu }() default: writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"}) return } 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, }) } // 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}) } // 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:] }