feat: 完成云同步、语音控制与多平台扩展基础架构搭建
本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
using Hua.Todo.Application.Repositories;
|
||||
using Hua.Todo.Application.Services.CloudSync.Models;
|
||||
using Hua.Todo.Application.Services.CloudSync.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Hua.Todo.Core.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Hua.Todo.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。
|
||||
/// </summary>
|
||||
public class CloudTaskSyncServiceSqliteTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly TodoDbContext _dbContext;
|
||||
private readonly CloudTaskSyncService _service;
|
||||
private readonly Guid _testUserId;
|
||||
|
||||
public CloudTaskSyncServiceSqliteTests()
|
||||
{
|
||||
_testUserId = Guid.NewGuid();
|
||||
|
||||
// 使用共享缓存的 SQLite 内存数据库
|
||||
_connection = new SqliteConnection("Data Source=CloudTaskSyncTests;Mode=Memory;Cache=Shared");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dbContext = new TodoDbContext(options);
|
||||
_dbContext.Database.EnsureCreated();
|
||||
|
||||
// 创建测试用户(满足 UserId 外键约束)
|
||||
_dbContext.Users.Add(new UserEntity
|
||||
{
|
||||
Id = _testUserId,
|
||||
UserName = "test_user",
|
||||
PasswordHash = "hash",
|
||||
PasswordSalt = "salt",
|
||||
Role = "User"
|
||||
});
|
||||
_dbContext.SaveChanges();
|
||||
|
||||
_service = new CloudTaskSyncService(_dbContext, NullLogger<CloudTaskSyncService>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dbContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复现用户报告的 Bug:同一批次中同步父子任务(均有已知 Id,lastModificationTime 为 null)。
|
||||
/// 期望:首次同步成功创建两个任务。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_ParentChildWithNullTime_FirstSync_Succeeds()
|
||||
{
|
||||
var parentId = Guid.NewGuid();
|
||||
var childId = Guid.NewGuid();
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = childId,
|
||||
Title = "子任务",
|
||||
Priority = TaskPriority.Medium,
|
||||
Code = "2",
|
||||
ParentTaskId = parentId,
|
||||
LastModificationTime = null
|
||||
},
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId,
|
||||
Title = "父任务",
|
||||
Priority = TaskPriority.High,
|
||||
Code = "1",
|
||||
ParentTaskId = null,
|
||||
LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, response.Tasks.Count);
|
||||
var parent = response.Tasks.First(t => t.Title == "父任务");
|
||||
Assert.Null(parent.ParentTaskId);
|
||||
var child = response.Tasks.First(t => t.Title == "子任务");
|
||||
Assert.Equal(parentId, child.ParentTaskId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复现 Bug 场景:首次同步成功后再用相同数据重新同步(模拟客户端重试)。
|
||||
/// lastModificationTime 为 null 时 LWW 应跳过更新,不应抛出 UNIQUE 约束。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_ParentChildWithNullTime_Resync_NoUniqueViolation()
|
||||
{
|
||||
var parentId = Guid.NewGuid();
|
||||
var childId = Guid.NewGuid();
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
|
||||
Code = "2", ParentTaskId = parentId, LastModificationTime = null
|
||||
},
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
|
||||
Code = "1", ParentTaskId = null, LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 首次同步
|
||||
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, firstResponse.Tasks.Count);
|
||||
|
||||
// 重新同步(同一 DbContext,模拟客户端重试)
|
||||
var resyncResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, resyncResponse.Tasks.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试多次重试同步不引发 UNIQUE 约束。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_ParentChildWithNullTime_MultipleResyncs_NoUniqueViolation()
|
||||
{
|
||||
var parentId = Guid.NewGuid();
|
||||
var childId = Guid.NewGuid();
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
|
||||
Code = "2", ParentTaskId = parentId, LastModificationTime = null
|
||||
},
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
|
||||
Code = "1", ParentTaskId = null, LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, response.Tasks.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试已有 DbContext 跟踪实体的场景下同步不会引发 UNIQUE 冲突。
|
||||
/// 模拟场景:任务已通过其他 API 创建并仍被跟踪,然后通过 CloudSync 同步同一数据。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_TaskExistsThenSynced_NoUniqueViolation()
|
||||
{
|
||||
var taskId = Guid.NewGuid();
|
||||
var existingTask = new TaskEntity
|
||||
{
|
||||
Id = taskId,
|
||||
UserId = _testUserId,
|
||||
Title = "已有任务",
|
||||
Priority = TaskPriority.Medium,
|
||||
CreationTime = DateTime.UtcNow,
|
||||
CreatorId = _testUserId,
|
||||
LastModificationTime = DateTime.UtcNow,
|
||||
LastModifierId = _testUserId
|
||||
};
|
||||
_dbContext.Tasks.Add(existingTask);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var syncRequest = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "已有任务",
|
||||
LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _service.SyncAsync(_testUserId, syncRequest, CancellationToken.None);
|
||||
Assert.Single(response.Tasks);
|
||||
Assert.Equal("已有任务", response.Tasks.First().Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复现用户 Bug 的核心场景:使用独立 DbContext 模拟跨请求重试。
|
||||
/// Context A 创建任务并提交,Context B 再次同步相同数据时不应触发 UNIQUE 约束。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_CrossContextResync_NoUniqueViolation()
|
||||
{
|
||||
var parentId = Guid.NewGuid();
|
||||
var childId = Guid.NewGuid();
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
|
||||
Code = "2", ParentTaskId = parentId, LastModificationTime = null
|
||||
},
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
|
||||
Code = "1", ParentTaskId = null, LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Context A:首次同步
|
||||
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, firstResponse.Tasks.Count);
|
||||
|
||||
// Context B:使用全新 DbContext 重新同步(模拟另一个请求)
|
||||
var options = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
using var dbContextB = new TodoDbContext(options);
|
||||
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
|
||||
|
||||
var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, resyncResponse.Tasks.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用报错场景中的精确 UUID 复现 Bug:
|
||||
/// 父子任务在同一批次中同步,lastModificationTime 均为 null。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_ExactBugReportUuids_NoUniqueViolation()
|
||||
{
|
||||
// 使用报错日志中的精确 UUID
|
||||
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
|
||||
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = childId,
|
||||
Title = "测试",
|
||||
Priority = (TaskPriority)1,
|
||||
IsCompleted = false,
|
||||
Code = "2",
|
||||
ParentTaskId = parentId,
|
||||
LastModificationTime = null
|
||||
},
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId,
|
||||
Title = "测试",
|
||||
Priority = (TaskPriority)1,
|
||||
IsCompleted = false,
|
||||
Code = "1",
|
||||
ParentTaskId = null,
|
||||
LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 首次同步
|
||||
var response1 = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, response1.Tasks.Count);
|
||||
|
||||
// 使用全新 DbContext 重新同步(模拟跨请求重试)
|
||||
var options = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
using var dbContextB = new TodoDbContext(options);
|
||||
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
|
||||
|
||||
// 重试同步 - 不应抛出 UNIQUE 约束
|
||||
var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Equal(2, response2.Tasks.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试同一次同步请求中,父任务已存在于 DB 但 lastModificationTime 为 null 的重同步场景。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_ExistingTaskWithNullTime_NoUniqueViolation()
|
||||
{
|
||||
// 使用精确 UUID
|
||||
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
|
||||
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
|
||||
|
||||
// 先创建一个已存在的父任务(模拟前置同步已完成)
|
||||
var existingParent = new TaskEntity
|
||||
{
|
||||
Id = parentId,
|
||||
UserId = _testUserId,
|
||||
Title = "测试",
|
||||
Priority = (TaskPriority)1,
|
||||
IsCompleted = false,
|
||||
Code = "1",
|
||||
ParentTaskId = null,
|
||||
CreationTime = DateTime.UtcNow,
|
||||
CreatorId = _testUserId,
|
||||
LastModificationTime = DateTime.UtcNow,
|
||||
LastModifierId = _testUserId
|
||||
};
|
||||
_dbContext.Tasks.Add(existingParent);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 同步请求(lastModificationTime 为 null,应被 LWW 跳过)
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId,
|
||||
Title = "测试",
|
||||
Priority = (TaskPriority)1,
|
||||
IsCompleted = false,
|
||||
Code = "1",
|
||||
ParentTaskId = null,
|
||||
LastModificationTime = null // 🔑 关键:null 时间戳
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 不应抛出 UNIQUE 约束
|
||||
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Single(response.Tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证跨 UserId 同步相同 Id 不再触发 UNIQUE 约束。
|
||||
/// 任务已存在于 DB(通过其他 UserId 创建),用不同 UserId 同步相同 Id,
|
||||
/// 应被 ProcessUpsertAsync 的 DB 级别二次查重发现并安全跳过。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SyncAsync_TaskExistsWithDifferentUserId_HandlesGracefully()
|
||||
{
|
||||
// 创建另一个用户的 DbContext
|
||||
var otherUserId = Guid.NewGuid();
|
||||
_dbContext.Users.Add(new UserEntity
|
||||
{
|
||||
Id = otherUserId,
|
||||
UserName = "other_user",
|
||||
PasswordHash = "hash",
|
||||
PasswordSalt = "salt",
|
||||
Role = "User"
|
||||
});
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 使用报错 UUID 创建任务,但属于 otherUserId
|
||||
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
|
||||
_dbContext.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = parentId,
|
||||
UserId = otherUserId,
|
||||
Title = "other user task",
|
||||
Priority = (TaskPriority)1,
|
||||
CreationTime = DateTime.UtcNow,
|
||||
CreatorId = otherUserId,
|
||||
LastModificationTime = DateTime.UtcNow,
|
||||
LastModifierId = otherUserId
|
||||
});
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 使用独立 DbContext 同步相同 Id(模拟跨请求)
|
||||
var options = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
using var dbContextB = new TodoDbContext(options);
|
||||
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
|
||||
|
||||
var request = new SyncRequest
|
||||
{
|
||||
Upserts = new List<CloudTaskUpsert>
|
||||
{
|
||||
new CloudTaskUpsert
|
||||
{
|
||||
Id = parentId,
|
||||
Title = "测试",
|
||||
Priority = (TaskPriority)1,
|
||||
IsCompleted = false,
|
||||
Code = "1",
|
||||
ParentTaskId = null,
|
||||
LastModificationTime = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 应正常完成(不抛出 DbUpdateException),DB 级别二次查重发现已存在实体并跳过
|
||||
// 注意:response.Tasks 为空,因为该任务属于 otherUserId,不属于 _testUserId
|
||||
var response = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
|
||||
Assert.Empty(response.Tasks);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user