diff --git a/Fabric.Backend.Guild/src/entities/message.entity.ts b/Fabric.Backend.Guild/src/entities/message.entity.ts index 83bfebe..65eaa4a 100644 --- a/Fabric.Backend.Guild/src/entities/message.entity.ts +++ b/Fabric.Backend.Guild/src/entities/message.entity.ts @@ -9,6 +9,10 @@ export class Message { @PrimaryGeneratedColumn('uuid') id!: string; + @Index() + @Column({ type: 'varchar', length: 80, unique: true }) + messageId!: string; + @Index() @Column({ type: 'char', length: 36, nullable: true }) channelId!: string | null; @@ -26,6 +30,24 @@ export class Message { @Column({ type: 'text' }) content!: string; + @Column({ type: 'varchar', length: 80, nullable: true }) + replyToMessageId!: string | null; + + @Column({ type: 'json', nullable: true }) + mentions!: string[] | null; + + @Column({ type: 'json', nullable: true }) + attachments!: Array<{ url: string; name?: string; mimeType?: string }> | null; + + @Column({ type: 'datetime', nullable: true }) + editedAt!: Date | null; + + @Column({ type: 'datetime', nullable: true }) + deletedAt!: Date | null; + + @Column({ type: 'boolean', default: false }) + isDeleted!: boolean; + @CreateDateColumn() @Index() createdAt!: Date; diff --git a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts index cb5bb1e..7db165a 100644 --- a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts +++ b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts @@ -1,19 +1,19 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; import { CreateMessageDto } from './dto.create-message.dto'; - -type Message = { - messageId: string; - seq: number; - content: string; - authorUserId: string; - replyToMessageId: string | null; - mentions: string[]; - attachments: Array<{ url: string; name?: string; mimeType?: string }>; - createdAt: string; - editedAt: string | null; - deletedAt: string | null; - isDeleted: boolean; -}; +import { Channel } from '../entities/channel.entity'; +import { Message } from '../entities/message.entity'; const EDIT_WINDOW_MS = 15 * 60 * 1000; const DEFAULT_PAGE_LIMIT = 50; @@ -21,39 +21,73 @@ const MAX_PAGE_LIMIT = 200; @Controller('channels/:id/messages') export class MessagingController { - private seqByChannel = new Map(); - private messagesByChannel = new Map(); + constructor( + private readonly dataSource: DataSource, + @InjectRepository(Channel) + private readonly channelRepo: Repository, + @InjectRepository(Message) + private readonly messageRepo: Repository, + ) {} + + private toView(m: Message) { + return { + messageId: m.messageId, + seq: m.seq, + content: m.content, + authorUserId: m.authorUserId, + replyToMessageId: m.replyToMessageId, + mentions: m.mentions ?? [], + attachments: m.attachments ?? [], + createdAt: m.createdAt.toISOString(), + editedAt: m.editedAt ? m.editedAt.toISOString() : null, + deletedAt: m.deletedAt ? m.deletedAt.toISOString() : null, + isDeleted: m.isDeleted, + }; + } @Post() - create(@Param('id') channelId: string, @Body() body: CreateMessageDto) { - const next = (this.seqByChannel.get(channelId) ?? 0) + 1; - this.seqByChannel.set(channelId, next); + async create(@Param('id') channelId: string, @Body() body: CreateMessageDto) { + const message = await this.dataSource.transaction(async (manager) => { + const channel = await manager.findOne(Channel, { + where: { id: channelId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!channel) { + throw new NotFoundException('channel not found'); + } - const message: Message = { - messageId: body.clientMessageId ?? `m-${channelId}-${next}`, - seq: next, - content: body.content, - authorUserId: body.authorUserId ?? 'anonymous', - replyToMessageId: body.replyToMessageId ?? null, - mentions: body.mentions ?? [], - attachments: body.attachments ?? [], - createdAt: new Date().toISOString(), - editedAt: null, - deletedAt: null, - isDeleted: false, - }; + const nextSeq = channel.lastSeq + 1; + channel.lastSeq = nextSeq; + await manager.save(Channel, channel); - const arr = this.messagesByChannel.get(channelId) ?? []; - arr.push(message); - this.messagesByChannel.set(channelId, arr); + const messageId = body.clientMessageId ?? `m-${channelId}-${nextSeq}`; + const row = manager.create(Message, { + messageId, + channelId, + conversationId: null, + authorUserId: body.authorUserId ?? 'anonymous', + seq: nextSeq, + content: body.content, + replyToMessageId: body.replyToMessageId ?? null, + mentions: body.mentions ?? [], + attachments: body.attachments ?? [], + editedAt: null, + deletedAt: null, + isDeleted: false, + }); + return manager.save(Message, row); + }); - return message; + return this.toView(message); } @Patch(':messageId') - edit(@Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }) { - const arr = this.messagesByChannel.get(channelId) ?? []; - const item = arr.find((m) => m.messageId === messageId); + async edit( + @Param('id') channelId: string, + @Param('messageId') messageId: string, + @Body() body: { content?: string }, + ) { + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; const now = Date.now(); @@ -63,27 +97,28 @@ export class MessagingController { } item.content = body.content ?? item.content; - item.editedAt = new Date().toISOString(); - return item; + item.editedAt = new Date(); + const saved = await this.messageRepo.save(item); + return this.toView(saved); } @Delete(':messageId') - remove(@Param('id') channelId: string, @Param('messageId') messageId: string) { - const arr = this.messagesByChannel.get(channelId) ?? []; - const item = arr.find((m) => m.messageId === messageId); + async remove(@Param('id') channelId: string, @Param('messageId') messageId: string) { + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; item.isDeleted = true; - item.deletedAt = new Date().toISOString(); + item.deletedAt = new Date(); item.content = '[deleted]'; item.mentions = []; item.attachments = []; + await this.messageRepo.save(item); return { status: 'deleted', mode: 'soft', messageId }; } @Get() - listBySeq( + async listBySeq( @Param('id') channelId: string, @Query('seq_from') seqFrom?: string, @Query('seq_to') seqTo?: string, @@ -96,9 +131,17 @@ export class MessagingController { Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, MAX_PAGE_LIMIT) : DEFAULT_PAGE_LIMIT; - const arr = this.messagesByChannel.get(channelId) ?? []; - const filtered = arr.filter((m) => m.seq >= from && m.seq <= to); - const items = filtered.slice(0, safeLimit); + + const qb = this.messageRepo + .createQueryBuilder('m') + .where('m.channelId = :channelId', { channelId }) + .andWhere('m.seq >= :from', { from }) + .andWhere('m.seq <= :to', { to }) + .orderBy('m.seq', 'ASC'); + + const total = await qb.getCount(); + const rows = await qb.limit(safeLimit).getMany(); + const items = rows.map((m) => this.toView(m)); return { items, @@ -107,7 +150,7 @@ export class MessagingController { seqTo: to, limit: safeLimit, returned: items.length, - hasMore: filtered.length > items.length, + hasMore: total > items.length, }, }; } diff --git a/Fabric.Backend.Guild/src/messaging/messaging.module.ts b/Fabric.Backend.Guild/src/messaging/messaging.module.ts index 9133ca9..1ed52ac 100644 --- a/Fabric.Backend.Guild/src/messaging/messaging.module.ts +++ b/Fabric.Backend.Guild/src/messaging/messaging.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { MessagingController } from './messaging.controller'; +import { Channel } from '../entities/channel.entity'; +import { Message } from '../entities/message.entity'; @Module({ + imports: [TypeOrmModule.forFeature([Channel, Message])], controllers: [MessagingController], }) export class MessagingModule {} diff --git a/docs/TODO-backend-center-guild.md b/docs/TODO-backend-center-guild.md index 44f3931..6214dcf 100644 --- a/docs/TODO-backend-center-guild.md +++ b/docs/TODO-backend-center-guild.md @@ -48,7 +48,7 @@ - [x] 编辑消息(可编辑窗口策略先简化) - [x] 删除消息(软删 vs 硬删,先定策略) - [x] `GET messages` 分页(seq 区间 + limit) -- [ ] seq 分配改为 DB 原子方案(避免并发冲突) +- [x] seq 分配改为 DB 原子方案(避免并发冲突) ### 2.3 一致性与回补 - [ ] 回补接口:`seq_from/seq_to`