fix: align backend APIs and upload flow
This commit is contained in:
@@ -11,6 +11,15 @@ namespace MessageService.Infrastructure.Configs
|
||||
builder.ToTable("conversations");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.HasIndex(x => x.UserId);
|
||||
builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted });
|
||||
builder.Property<string>("ActiveConversationKey")
|
||||
.HasMaxLength(96)
|
||||
.HasComputedColumnSql(
|
||||
"CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END",
|
||||
stored: true);
|
||||
builder.HasIndex("ActiveConversationKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_conversations_ActiveConversationKey");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace MessageService.Infrastructure.Configs
|
||||
builder.ToTable("messages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.HasIndex(x => new { x.StreamKey, x.SequenceId });
|
||||
builder.HasIndex(x => new { x.StreamKey, x.MsgType, x.State, x.SequenceId });
|
||||
builder.ComplexProperty(x => x.Content, c =>
|
||||
{
|
||||
// 1. Fallback 是简单字符串,直接映射
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace MessageService.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(MessageDbContext))]
|
||||
[Migration("20260909000100_ApiAlignmentFixes")]
|
||||
public partial class ApiAlignmentFixes : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted",
|
||||
table: "conversations",
|
||||
columns: new[] { "UserId", "ChatType", "TargetId", "IsDeleted" });
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted",
|
||||
table: "conversations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace MessageService.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(MessageDbContext))]
|
||||
[Migration("20260911000100_ConversationUniqueness")]
|
||||
public partial class ConversationUniqueness : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
CREATE TEMPORARY TABLE `conversation_dedup` AS
|
||||
SELECT
|
||||
`Id`,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY `UserId`, `ChatType`, `TargetId`
|
||||
ORDER BY COALESCE(`ModificationTime`, `CreationTime`) DESC, `Id` DESC
|
||||
) AS `row_num`,
|
||||
MAX(`UnreadCount`) OVER (
|
||||
PARTITION BY `UserId`, `ChatType`, `TargetId`
|
||||
) AS `max_unread_count`,
|
||||
MAX(`LastReadSequenceId`) OVER (
|
||||
PARTITION BY `UserId`, `ChatType`, `TargetId`
|
||||
) AS `max_last_read_sequence_id`
|
||||
FROM `conversations`
|
||||
WHERE `IsDeleted` = 0;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE `conversations` AS `conversation`
|
||||
INNER JOIN `conversation_dedup` AS `dedup` ON `conversation`.`Id` = `dedup`.`Id`
|
||||
SET
|
||||
`conversation`.`UnreadCount` = CASE
|
||||
WHEN `dedup`.`row_num` = 1 THEN `dedup`.`max_unread_count`
|
||||
ELSE `conversation`.`UnreadCount`
|
||||
END,
|
||||
`conversation`.`LastReadSequenceId` = CASE
|
||||
WHEN `dedup`.`row_num` = 1 THEN `dedup`.`max_last_read_sequence_id`
|
||||
ELSE `conversation`.`LastReadSequenceId`
|
||||
END,
|
||||
`conversation`.`IsDeleted` = CASE
|
||||
WHEN `dedup`.`row_num` = 1 THEN `conversation`.`IsDeleted`
|
||||
ELSE 1
|
||||
END,
|
||||
`conversation`.`Deletion` = CASE
|
||||
WHEN `dedup`.`row_num` = 1 THEN `conversation`.`Deletion`
|
||||
ELSE CURRENT_TIMESTAMP(6)
|
||||
END;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("DROP TEMPORARY TABLE `conversation_dedup`;");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ActiveConversationKey",
|
||||
table: "conversations",
|
||||
type: "varchar(96)",
|
||||
maxLength: 96,
|
||||
nullable: true,
|
||||
computedColumnSql: "CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END",
|
||||
stored: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_conversations_ActiveConversationKey",
|
||||
table: "conversations",
|
||||
column: "ActiveConversationKey",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "UX_conversations_ActiveConversationKey",
|
||||
table: "conversations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ActiveConversationKey",
|
||||
table: "conversations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace MessageService.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(MessageDbContext))]
|
||||
[Migration("20260913000100_ConversationActivityAndMessageSearch")]
|
||||
public partial class ConversationActivityAndMessageSearch : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "LastMessageTime",
|
||||
table: "conversations",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE `conversations` AS `conversation`
|
||||
SET `conversation`.`LastMessageTime` = COALESCE(
|
||||
(
|
||||
SELECT MAX(`message`.`CreationTime`)
|
||||
FROM `messages` AS `message`
|
||||
WHERE `message`.`StreamKey` = `conversation`.`StreamKey`
|
||||
AND `message`.`IsDeleted` = 0
|
||||
),
|
||||
`conversation`.`CreationTime`
|
||||
);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_messages_StreamKey_MsgType_State_SequenceId",
|
||||
table: "messages",
|
||||
columns: new[] { "StreamKey", "MsgType", "State", "SequenceId" });
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_messages_StreamKey_MsgType_State_SequenceId",
|
||||
table: "messages");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastMessageTime",
|
||||
table: "conversations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ namespace MessageService.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("MessageService.Domain.Entities.Conversation", b =>
|
||||
{
|
||||
b.Property<string>("ActiveConversationKey")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("varchar(96)")
|
||||
.HasComputedColumnSql("CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END", true);
|
||||
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
@@ -45,6 +51,9 @@ namespace MessageService.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageTime")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long?>("LastReadSequenceId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -74,8 +83,14 @@ namespace MessageService.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ActiveConversationKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_conversations_ActiveConversationKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted");
|
||||
|
||||
b.ToTable("conversations", (string)null);
|
||||
});
|
||||
|
||||
@@ -176,6 +191,8 @@ namespace MessageService.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("StreamKey", "SequenceId");
|
||||
|
||||
b.HasIndex("StreamKey", "MsgType", "State", "SequenceId");
|
||||
|
||||
b.ToTable("messages", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MessageService.Domain.Entities;
|
||||
using MessageService.Domain.IReposities;
|
||||
using MessageService.Domain.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MessageService.Infrastructure.Reposities
|
||||
@@ -18,31 +19,54 @@ namespace MessageService.Infrastructure.Reposities
|
||||
db.Conversations.Add(conversation);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId)
|
||||
public async Task<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Conversations.Where(x => x.UserId == userId)
|
||||
.AsNoTracking()
|
||||
.Select(s => s.StreamKey)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Conversation?> FindByIdAsync(Guid id)
|
||||
public async Task<Conversation?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Conversations.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return await db.Conversations.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey)
|
||||
public async Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Conversations.Where(x => x.StreamKey == streamKey).ToListAsync();
|
||||
return await db.Conversations.Where(x => x.StreamKey == streamKey).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId)
|
||||
public async Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Conversations.Where(x => x.TargetId == targetId).ToListAsync();
|
||||
return await db.Conversations.Where(x => x.TargetId == targetId).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Conversation>> FindByUserIdAsync(Guid userId)
|
||||
public async Task<IReadOnlyList<ConversationSummary>> ListByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Conversations.Where(x => x.UserId == userId).ToListAsync();
|
||||
return await db.Conversations
|
||||
.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.LastMessageTime ?? x.CreationTime)
|
||||
.Select(x => new ConversationSummary(
|
||||
x.Id,
|
||||
x.UserId,
|
||||
x.TargetId,
|
||||
x.TargetAvatar,
|
||||
x.TargetName,
|
||||
x.LastReadSequenceId,
|
||||
x.UnreadCount,
|
||||
x.ChatType,
|
||||
x.LastMessage,
|
||||
x.LastMessageTime ?? x.CreationTime))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Conversation?> FindActiveAsync(Guid userId, Guid targetId, Domain.Enums.ChatType chatType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return db.Conversations.FirstOrDefaultAsync(
|
||||
x => x.UserId == userId && x.TargetId == targetId && x.ChatType == chatType,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,38 +18,68 @@ namespace MessageService.Infrastructure.Reposities
|
||||
db.Messages.Add(message);
|
||||
}
|
||||
|
||||
public async Task<Message?> FindByIdAsync(Guid id)
|
||||
public async Task<Message?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await db.Messages.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return await db.Messages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit)
|
||||
public async Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = db.Messages.Where(x => x.StreamKey == streamKey);
|
||||
List<Message> messages = [];
|
||||
List<Message> fetched;
|
||||
if (direction == 0) // Before: 找比锚点小的,按倒序排
|
||||
{
|
||||
if (cusor.HasValue)
|
||||
query = query.Where(m => m.SequenceId < cusor.Value);
|
||||
|
||||
var list = await query
|
||||
fetched = await query
|
||||
.OrderByDescending(m => m.SequenceId) // 最新消息在最前
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
messages = [.. list.OrderBy(s => s.SequenceId)];
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (cusor is null)
|
||||
return (messages, false);
|
||||
return (Array.Empty<Message>(), false);
|
||||
|
||||
messages = await query.OrderBy(o => o.SequenceId)
|
||||
fetched = await query
|
||||
.Where(m => m.SequenceId > cusor.Value)
|
||||
.OrderBy(o => o.SequenceId)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return (messages, messages.Count > limit);
|
||||
var hasMore = fetched.Count > limit;
|
||||
var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList();
|
||||
return (messages, hasMore);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Message> messages, bool hasMore)> SearchAsync(
|
||||
string streamKey,
|
||||
string keyword,
|
||||
long? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = db.Messages
|
||||
.AsNoTracking()
|
||||
.Where(message =>
|
||||
message.StreamKey == streamKey &&
|
||||
message.MsgType == Domain.Enums.MessageType.Text &&
|
||||
message.State == Domain.Enums.MessageState.Sent &&
|
||||
message.Content.Fallback.Contains(keyword));
|
||||
|
||||
if (cursor.HasValue)
|
||||
{
|
||||
query = query.Where(message => message.SequenceId < cursor.Value);
|
||||
}
|
||||
|
||||
var fetched = await query
|
||||
.OrderByDescending(message => message.SequenceId)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (fetched.Take(limit).ToList(), fetched.Count > limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user