System.Text.Json.JsonExceptionJsonSerializerOptions to support cycles

System.Text.Json.JsonException:“A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Data.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.Id.”

System.Text.Json.JsonException:“A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Data.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.UserGroups.Group.Id.”

对象图中存在 循环引用(Object Cycle),而当前的 JSON 序列化配置 没有启用循环引用处理机制

看一下model  // UserGroup 配置
 modelBuilder.Entity<UserGroup>(entity =>
 {
     entity.ToTable("UserGroup");
     entity.HasKey(e => e.Id);
     entity.Property(e => e.Id).HasColumnName("Id");
     entity.Property(e => e.UserId).HasColumnName("UserId");
     entity.Property(e => e.GroupId).HasColumnName("GroupId");
     entity.Property(e => e.JoinTime).HasColumnName("JoinTime").HasDefaultValueSql("CURRENT_TIMESTAMP");
     entity.Property(e => e.Role).HasColumnName("Role").HasDefaultValue(UserRole.Member);

     entity.HasOne(e => e.User);
           //.WithMany(u => u.UserGroups)
           //.HasForeignKey(e => e.UserId)
           //.OnDelete(DeleteBehavior.Cascade);

     entity.HasOne(e => e.Group)
           .WithMany(g => g.UserGroups)
           .HasForeignKey(e => e.GroupId)
           .OnDelete(DeleteBehavior.Cascade);

     entity.HasIndex(e => new { e.UserId, e.GroupId }).IsUnique();
 });
, 
            // SysUser 配置
            modelBuilder.Entity<SysUser>(entity =>
            {
                entity.ToTable("SysUser");
                entity.HasKey(e => e.Id);
                entity.Property(e => e.Id).HasColumnName("Id");
                entity.Property(e => e.UserName).HasColumnName("UserName").HasMaxLength(50).IsRequired();
                entity.Property(e => e.Password).HasColumnName("Password").HasMaxLength(100).IsRequired();
                entity.Property(e => e.Email).HasColumnName("Email").HasMaxLength(100);
                entity.Property(e => e.CreateTime).HasColumnName("CreateTime").HasDefaultValueSql("CURRENT_TIMESTAMP");
                entity.Property(e => e.IsActive).HasColumnName("IsActive").HasDefaultValue(true);

                entity.HasIndex(e => e.UserName).IsUnique();
            });

            // TodoGroup 配置
            modelBuilder.Entity<TodoGroup>(entity =>
            {
                entity.ToTable("TodoGroup");
                entity.HasKey(e => e.Id);
                entity.Property(e => e.Id).HasColumnName("Id");
                entity.Property(e => e.GroupName).HasColumnName("GroupName").HasMaxLength(100).IsRequired();
                entity.Property(e => e.Description).HasColumnName("Description").HasMaxLength(500);
                entity.Property(e => e.CreatorId).HasColumnName("CreatorId");
                entity.Property(e => e.CreateTime).HasColumnName("CreateTime").HasDefaultValueSql("CURRENT_TIMESTAMP");
                entity.Property(e => e.IsActive).HasColumnName("IsActive").HasDefaultValue(true);

                entity.HasOne(e => e.Creator)
                      .WithMany()
                      .HasForeignKey(e => e.CreatorId)
                      .OnDelete(DeleteBehavior.Restrict);
            }); 还有方法  public async Task<ApiResponse<TodoGroup>> CreateGroupAsync(int creatorId, string groupName, string? description = null)
  {
      try
      {
          var group = new TodoGroup
          {
              GroupName = groupName,
              Description = description,
              CreatorId = creatorId,
              CreateTime = DateTime.Now,
              IsActive = true
          };

          _context.TodoGroups.Add(group);
          await _context.SaveChangesAsync();

          // 创建者自动加入组并设为管理员
          var userGroup = new UserGroup
          {
              UserId = creatorId,
              GroupId = group.Id,
              Role = UserRole.Admin,
              JoinTime = DateTime.Now
          };

          _context.UserGroups.Add(userGroup);
          await _context.SaveChangesAsync();

          return ApiResponse<TodoGroup>.SuccessResult(group, "组创建成功");
      }
      catch (Exception ex)
      {
          return ApiResponse<TodoGroup>.ErrorResult($"创建组失败: {ex.Message}", 500);
      }
  }

感谢你提供完整的 DbContext 配置和 CreateGroupAsync 方法。现在我们可以 精准定位并解决 你遇到的 System.Text.Json.JsonException: A possible object cycle was detected 问题。


🔍 问题根源分析

你遇到的异常路径是:

Path: $.Data.UserGroups.Group.UserGroups.Group.UserGroups...

结合你的模型配置,我们可以推断出:

✅ 模型关系如下:

  1. SysUserUserGroupTodoGroup

    • UserGroup

      • UserIdSysUser.Id
      • GroupIdTodoGroup.Id
      • Group 导航属性 → 指向 TodoGroup
      • User 导航属性 → 指向 SysUser
    • TodoGroup

      • UserGroups 导航属性 → ICollection<UserGroup>(一个组有多个成员)
      • Creator → 指向 SysUser(创建者)
    • SysUser

      • 缺少 UserGroups 导航属性!(关键点)

❌ 循环引用路径(导致异常)

当你查询 TodoGroup 并包含 UserGroupsUserGroups.Group 时,会形成以下链条:

TodoGroup
  └── UserGroups → UserGroup
        └── Group → TodoGroup
              └── UserGroups → UserGroup
                    └── Group → TodoGroup
                          ...(无限嵌套)

虽然 EF Core 不会无限加载,但如果你在返回 API 响应时 未断开循环,序列化器会尝试遍历这个结构,导致:

深度超过 32 层 → 抛出 object cycle 异常


✅ 解决方案(推荐组合拳)

✅ 方案一:【立即修复】启用 ReferenceHandler.Preserve

Program.cs(或 Startup.cs)中配置 JSON 选项:

// Program.cs (.NET 6+)
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;
        options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
    });

// 如果用了 SignalR
builder.Services.AddSignalR()
    .AddJsonProtocol(options =>
    {
        options.PayloadSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;
    });

✅ 立即可解决循环引用问题
⚠️ JSON 会包含 $id, $ref 元数据(客户端需支持)


✅ 方案二:【最佳实践】使用 DTO 返回数据(推荐)

不要直接返回实体(TodoGroup),而是返回一个 扁平化的 DTO

1. 创建 DTO 类

public class TodoGroupDto
{
    public int Id { get; set; }
    public string GroupName { get; set; } = string.Empty;
    public string? Description { get; set; }
    public int CreatorId { get; set; }
    public DateTime CreateTime { get; set; }
    public bool IsActive { get; set; }

    // ✅ 返回成员信息,但不包含 Group 导航属性
    public List<UserGroupDto> Members { get; set; } = new();
}

public class UserGroupDto
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string UserName { get; set; } = string.Empty; // 来自 SysUser
    public UserRole Role { get; set; }
    public DateTime JoinTime { get; set; }
}

2. 修改 CreateGroupAsync 返回 DTO

public async Task<ApiResponse<TodoGroupDto>> CreateGroupAsync(int creatorId, string groupName, string? description = null)
{
    try
    {
        var creator = await _context.SysUsers
            .FirstOrDefaultAsync(u => u.Id == creatorId);
        if (creator == null)
            return ApiResponse<TodoGroupDto>.ErrorResult("用户不存在", 404);

        var group = new TodoGroup
        {
            GroupName = groupName,
            Description = description,
            CreatorId = creatorId,
            CreateTime = DateTime.Now,
            IsActive = true
        };

        _context.TodoGroups.Add(group);
        await _context.SaveChangesAsync();

        // 创建者加入组
        var userGroup = new UserGroup
        {
            UserId = creatorId,
            GroupId = group.Id,
            Role = UserRole.Admin,
            JoinTime = DateTime.Now
        };

        _context.UserGroups.Add(userGroup);
        await _context.SaveChangesAsync();

        // ✅ 映射为 DTO 返回(避免循环)
        var dto = new TodoGroupDto
        {
            Id = group.Id,
            GroupName = group.GroupName,
            Description = group.Description,
            CreatorId = group.CreatorId,
            CreateTime = group.CreateTime,
            IsActive = group.IsActive,
            Members = new List<UserGroupDto>
            {
                new UserGroupDto
                {
                    Id = userGroup.Id,
                    UserId = creatorId,
                    UserName = creator.UserName,
                    Role = userGroup.Role,
                    JoinTime = userGroup.JoinTime
                }
            }
        };

        return ApiResponse<TodoGroupDto>.SuccessResult(dto, "组创建成功");
    }
    catch (Exception ex)
    {
        return ApiResponse<TodoGroupDto>.ErrorResult($"创建组失败: {ex.Message}", 500);
    }
}

✅ 方案三:修复 SysUser 实体(可选但推荐)

你目前的 SysUser 缺少 UserGroups 导航属性,建议补上:

public class SysUser
{
    public int Id { get; set; }
    public string UserName { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public string? Email { get; set; }
    public DateTime CreateTime { get; set; }
    public bool IsActive { get; set; }

    // ✅ 添加导航属性
    public ICollection<UserGroup> UserGroups { get; set; } = new List<UserGroup>();
}

并在 DbContext 中启用关系:

modelBuilder.Entity<UserGroup>()
    .HasOne(ug => ug.User)
    .WithMany(u => u.UserGroups) // 启用双向导航
    .HasForeignKey(ug => ug.UserId)
    .OnDelete(DeleteBehavior.Cascade);

⚠️ 但返回 SysUser 时仍需 DTO 或 [JsonIgnore],否则也会循环。


✅ 最终建议(生产环境)

场景 推荐方案
API 返回数据 ✅ 使用 DTO(如 TodoGroupDto
SignalR 实时消息 ✅ DTO + ReferenceHandler.Preserve(备用)
快速调试 ✅ 临时启用 Preserve
长期维护 ✅ DTO + 明确的数据结构

✅ 总结

你遇到的循环引用是由于:

TodoGroup ←→ UserGroup ←→ Group 形成闭环,序列化时无限嵌套。

🔧 立即修复:

options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;

🏆 长期方案:

使用 DTO 返回数据,不直接暴露实体

这样既避免了循环引用,又提升了 API 安全性和性能。

如果你需要,我可以帮你生成完整的 DTO 映射(包括 AutoMapper 配置)。