feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+529 -4
View File
@@ -2,8 +2,11 @@ package api
import (
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
@@ -12,10 +15,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service/ikev2"
"vpn-platform/backend/internal/service/l2tp"
"vpn-platform/backend/internal/service/openvpn"
"vpn-platform/backend/internal/store"
)
@@ -80,12 +87,26 @@ func ldapSettings() (*model.LDAPConfig, error) {
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
url := cfg.URL
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
url = "ldap://" + url
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
url = "ldaps://" + url
}
conn, err := ldap.DialURL(url)
if err != nil {
return nil, err
}
if cfg.StartTLS && strings.HasPrefix(url, "ldap://") {
host := strings.TrimPrefix(url, "ldap://")
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
conn.Close()
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
}
} else if strings.HasPrefix(url, "ldap://") {
conn.Close()
return nil, fmt.Errorf("明文 ldap:// 存在口令泄露风险,请改用 ldaps:// 或开启 StartTLS")
}
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
conn.Close()
@@ -130,16 +151,48 @@ func verifyMFA(user *model.User, code string) error {
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return fmt.Errorf("需要有效的 MFA 验证码")
}
secret, err := store.DecryptSecret(device.Secret)
if err != nil {
return fmt.Errorf("MFA 配置无效")
}
if totp.Validate(strings.TrimSpace(code), secret) {
if totp.Validate(code, secret) {
return nil
}
// 备用码一次性消费(丢失验证器时恢复)
if consumeAdminBackupCode(&device, code) {
store.Audit(user.Username, "mfa_backup_used", user.Username, "使用备用码登录")
return nil
}
return fmt.Errorf("需要有效的 MFA 验证码")
}
func consumeAdminBackupCode(device *model.MFADevice, code string) bool {
plain, err := store.DecryptSecret(device.BackupCodes)
if err != nil || plain == "" {
return false
}
var codes []string
if json.Unmarshal([]byte(plain), &codes) != nil {
return false
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), " ", ""))
for i, bc := range codes {
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(bc)), []byte(normalized)) == 1 {
codes = append(codes[:i], codes[i+1:]...)
raw, _ := json.Marshal(codes)
if enc, err := store.EncryptSecret(string(raw)); err == nil {
store.DB.Model(device).Update("backup_codes", enc)
}
return true
}
}
return false
}
func (s *Server) mfaStatus(c *gin.Context) {
var device model.MFADevice
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
@@ -268,6 +321,7 @@ func (s *Server) updateUser(c *gin.Context) {
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
user.MustChangePassword = true // 管理员重置后强制改密
}
store.DB.Save(&user)
c.JSON(200, user)
@@ -300,6 +354,7 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
BaseDN string `json:"base_dn"`
UserFilter string `json:"user_filter"`
UsernameAttribute string `json:"username_attribute"`
StartTLS bool `json:"starttls"`
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
@@ -308,7 +363,8 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
}
var cfg model.LDAPConfig
store.DB.First(&cfg)
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.Enabled
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.StartTLS, cfg.Enabled =
req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.StartTLS, req.Enabled
if req.BindPassword != "" {
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
}
@@ -361,17 +417,32 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
return
}
t, err := store.GetTunnel(req.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"})
return
}
switch t.Protocol {
case "openvpn", "l2tp", "ikev2":
default:
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
return
}
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true}
if req.Enabled != nil {
user.Enabled = *req.Enabled
}
if user.Source == "local" {
if len(req.Password) < 8 {
c.JSON(400, gin.H{"error": "本地拨号用户密码必填"})
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
// L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。
if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc
}
} else if user.LDAPDN == "" {
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
return
@@ -380,6 +451,7 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
return
}
_ = refreshDialAuth(t)
c.JSON(200, user)
}
func (s *Server) updateVPNUser(c *gin.Context) {
@@ -399,17 +471,470 @@ func (s *Server) updateVPNUser(c *gin.Context) {
}
user.DisplayName, user.Email = req.DisplayName, req.Email
if req.Enabled != nil {
if !*req.Enabled && user.Enabled {
revokeOpenVPNCert(&user)
}
user.Enabled = *req.Enabled
}
if req.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc
}
}
store.DB.Save(&user)
if t, err := store.GetTunnel(user.TunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, user)
}
func (s *Server) deleteVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error == nil {
revokeOpenVPNCert(&user)
tunnelID := user.TunnelID
store.DB.Delete(&model.VPNUser{}, id)
if t, err := store.GetTunnel(tunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, gin.H{"ok": true})
return
}
store.DB.Delete(&model.VPNUser{}, id)
c.JSON(200, gin.H{"ok": true})
}
func revokeOpenVPNCert(user *model.VPNUser) {
if user.Certificate == "" {
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
return
}
if err := openvpn.New().RevokeClientCertificate(t, user); err != nil {
store.Audit("system", "revoke_cert_failed", user.Username, err.Error())
}
}
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
func (s *Server) vpnUserMFASetup(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
return
}
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "OPSX-OpenVPN",
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
pendingMFAMu.Lock()
// 用 user_id 偏移避免与管理端 MFA 冲突:拨号用户用高位 key 空间
pendingMFAs[vpnUserPendingKey(user.ID)] = pendingMFA{
secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute),
}
pendingMFAMu.Unlock()
c.JSON(200, gin.H{
"otpauth_url": key.URL(),
"secret": key.Secret(),
"period": 30,
"digits": 6,
"algorithm": "SHA1",
"encoding": "base32",
"expires_in": 900,
"hint": "兼容主流 Authenticator 应用:扫描二维码或手动输入 base32 密钥",
})
}
func vpnUserPendingKey(vpnUserID uint) uint {
// 管理端 MFA 用真实 user_id;拨号 MFA pending 用高位偏移隔离。
return 1_000_000_000 + vpnUserID
}
func (s *Server) vpnUserMFAEnable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
var req struct {
Code string `json:"code"`
}
if c.ShouldBindJSON(&req) != nil || req.Code == "" {
c.JSON(400, gin.H{"error": "请输入验证码"})
return
}
pendingMFAMu.Lock()
pending, ok := pendingMFAs[vpnUserPendingKey(user.ID)]
delete(pendingMFAs, vpnUserPendingKey(user.ID))
pendingMFAMu.Unlock()
if !ok || time.Now().After(pending.expires) || !totp.Validate(strings.TrimSpace(req.Code), pending.secret) {
c.JSON(400, gin.H{"error": "验证码无效或设置已过期,请重新扫描二维码"})
return
}
secret, err := store.EncryptSecret(pending.secret)
if err != nil {
c.JSON(500, gin.H{"error": "加密 MFA 密钥失败"})
return
}
raw, _ := json.Marshal(pending.backups)
backups, err := store.EncryptSecret(string(raw))
if err != nil {
c.JSON(500, gin.H{"error": "加密备用码失败"})
return
}
user.MFASecret = secret
user.MFABackupCodes = backups
user.MFAEnabled = true
if err := store.DB.Save(&user).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "enable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups, "mfa_enabled": true})
}
func (s *Server) vpnUserMFADisable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
store.DB.Model(&user).Updates(map[string]interface{}{
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
"mfa_last_counter": 0, "mfa_last_used_at": nil,
})
store.Audit(c.GetString("username"), "disable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_enabled": false})
}
// vpnUserMFAStatus OTP Manager:查看拨号用户 MFA 状态。
func (s *Server) vpnUserMFAStatus(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
locked := user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil)
c.JSON(200, gin.H{
"mfa_enabled": user.MFAEnabled,
"mfa_has_pin": user.MFAHasPin,
"mfa_fail_count": user.MFAFailCount,
"mfa_locked": locked,
"mfa_locked_until": user.MFALockedUntil,
"mfa_last_used_at": user.MFALastUsedAt,
"compatible": "TOTP 标准(兼容 Google/Microsoft Authenticator,sha1:base32:6:30s)",
})
}
// vpnUserMFAPin 设置或清除静态 PIN(连接时第二因子 = PIN+OTP)。
func (s *Server) vpnUserMFAPin(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if !user.MFAEnabled {
c.JSON(400, gin.H{"error": "请先启用 MFA 再设置 PIN"})
return
}
var req struct {
Pin string `json:"pin"` // 空字符串表示清除
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "set_vpn_user_mfa_pin", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_has_pin": user.MFAHasPin})
}
// vpnUserMFABackupCodes 重新生成备用码。
func (s *Server) vpnUserMFABackupCodes(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
codes, err := openvpn.RegenerateBackupCodes(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "regen_vpn_user_mfa_backup", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": codes})
}
// vpnUserMFAUnlock 清除 MFA 锁定(失败次数过多后)。
func (s *Server) vpnUserMFAUnlock(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if err := openvpn.UnlockUserMFA(&user); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "unlock_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true})
}
// vpnUserOTPSecretsLine 导出 TOTP 密钥行(标准 otp-secrets 格式,便于迁移与备份)。
func (s *Server) vpnUserOTPSecretsLine(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
line, err := openvpn.GenerateOTPSecretsLine(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"line": line,
"format": "user server type:hash:encoding:key:pin:udid client",
"note": "标准 otp-secrets 格式密钥行;密钥敏感,请妥善保管,切勿泄露",
})
}
// refreshDialAuth 在拨号用户变更后重写 L2TP/IKEv2 认证文件。
func refreshDialAuth(t *model.Tunnel) error {
if t == nil {
return nil
}
switch t.Protocol {
case "l2tp":
return l2tp.New().RefreshAuth(t)
case "ikev2":
return ikev2.New().RefreshAuth(t)
}
return nil
}
// ---- 用户批量导入 / LDAP 导入 ----
// listLDAPUsers 浏览 LDAP 目录中的用户列表,供导入选择。复用 ldapSettings + ldapConnect。
func (s *Server) listLDAPUsers(c *gin.Context) {
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用或配置不完整:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
// 搜索 BaseDN 下所有匹配用户过滤器的条目
attrs := []string{"dn", cfg.UsernameAttribute, "cn", "mail"}
result, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.UserFilter, attrs, nil,
))
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 搜索失败:" + err.Error()})
return
}
// 查询本地已有的 LDAP 用户名,标记已导入
var existingNames []string
store.DB.Model(&model.User{}).Where("source = ?", "ldap").Pluck("username", &existingNames)
existingMap := map[string]bool{}
for _, n := range existingNames {
existingMap[n] = true
}
type ldapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
Imported bool `json:"imported"`
}
users := make([]ldapUser, 0, len(result.Entries))
for _, entry := range result.Entries {
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
continue
}
users = append(users, ldapUser{
DN: entry.DN,
UID: uid,
CN: entry.GetAttributeValue("cn"),
Mail: entry.GetAttributeValue("mail"),
Imported: existingMap[uid],
})
}
c.JSON(200, users)
}
// batchCreateUsers 批量创建本地用户。
func (s *Server) batchCreateUsers(c *gin.Context) {
var req struct {
Users []struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Role string `json:"role"`
} `json:"users"`
DefaultRole string `json:"default_role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if req.DefaultRole == "" {
req.DefaultRole = "viewer"
}
type failInfo struct {
Username string `json:"username"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, u := range req.Users {
if u.Username == "" {
failed++
fails = append(fails, failInfo{Error: "用户名为空"})
continue
}
// 检查是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&count)
if count > 0 {
skipped++
continue
}
pwd := u.Password
if len(pwd) < 8 {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: "密码至少 8 位"})
continue
}
role := u.Role
if role == "" {
role = req.DefaultRole
}
if role != "admin" && role != "operator" && role != "viewer" {
role = "viewer"
}
hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
user := model.User{
Username: u.Username, Password: string(hash), Email: u.Email,
Role: role, Source: "local", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "batch_create_user", u.Username, "")
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.Users),
})
}
// importLDAPUsers 从 LDAP 导入选中的用户到本地 DB(source=ldap)。
func (s *Server) importLDAPUsers(c *gin.Context) {
var req struct {
DNs []string `json:"dns"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
type failInfo struct {
DN string `json:"dn"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, dn := range req.DNs {
// 按 DN 查询单个条目
result, err := conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{cfg.UsernameAttribute, "cn", "mail"}, nil,
))
if err != nil || len(result.Entries) == 0 {
failed++
fails = append(fails, failInfo{DN: dn, Error: "查询失败"})
continue
}
entry := result.Entries[0]
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
failed++
fails = append(fails, failInfo{DN: dn, Error: "无 uid 属性"})
continue
}
// 检查本地是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", uid).Count(&count)
if count > 0 {
skipped++
continue
}
user := model.User{
Username: uid, Email: entry.GetAttributeValue("mail"),
Role: "viewer", Source: "ldap", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{DN: dn, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "import_ldap_user", uid, dn)
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.DNs),
})
}