diff --git a/Fabric.Backend.Guild/src/database.config.ts b/Fabric.Backend.Guild/src/database.config.ts index 499c9c8..966c31e 100644 --- a/Fabric.Backend.Guild/src/database.config.ts +++ b/Fabric.Backend.Guild/src/database.config.ts @@ -7,6 +7,7 @@ import { DmParticipant } from './entities/dm-participant.entity'; import { GuildRole } from './entities/guild-role.entity'; import { GuildMember } from './entities/guild-member.entity'; import { GuildMemberRole } from './entities/guild-member-role.entity'; +import { IdempotencyRecord } from './entities/idempotency-record.entity'; export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ type: 'mysql', @@ -24,6 +25,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ GuildRole, GuildMember, GuildMemberRole, + IdempotencyRecord, ], synchronize: (process.env.DB_SYNC ?? 'true') === 'true', logging: (process.env.DB_LOGGING ?? 'false') === 'true', diff --git a/Fabric.Backend.Guild/src/entities/idempotency-record.entity.ts b/Fabric.Backend.Guild/src/entities/idempotency-record.entity.ts new file mode 100644 index 0000000..a5d6936 --- /dev/null +++ b/Fabric.Backend.Guild/src/entities/idempotency-record.entity.ts @@ -0,0 +1,26 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('idempotency_records') +@Index(['scope', 'idempotencyKey'], { unique: true }) +export class IdempotencyRecord { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 64 }) + scope!: string; + + @Column({ type: 'varchar', length: 128 }) + idempotencyKey!: string; + + @Column({ type: 'json' }) + responseBody!: Record; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts index 0e7cf50..07a94c3 100644 --- a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts +++ b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Headers, NotFoundException, Param, Patch, @@ -14,6 +15,7 @@ import { DataSource, Repository } from 'typeorm'; import { CreateMessageDto } from './dto.create-message.dto'; import { Channel } from '../entities/channel.entity'; import { Message } from '../entities/message.entity'; +import { IdempotencyRecord } from '../entities/idempotency-record.entity'; const EDIT_WINDOW_MS = 15 * 60 * 1000; const DEFAULT_PAGE_LIMIT = 50; @@ -27,8 +29,33 @@ export class MessagingController { private readonly channelRepo: Repository, @InjectRepository(Message) private readonly messageRepo: Repository, + @InjectRepository(IdempotencyRecord) + private readonly idemRepo: Repository, ) {} + private async getIdempotentResponse( + scope: string, + idempotencyKey?: string, + ): Promise | null> { + if (!idempotencyKey) return null; + const row = await this.idemRepo.findOne({ where: { scope, idempotencyKey } }); + return row?.responseBody ?? null; + } + + private async saveIdempotentResponse( + scope: string, + idempotencyKey: string | undefined, + responseBody: Record, + ): Promise { + if (!idempotencyKey) return; + const row = this.idemRepo.create({ + scope, + idempotencyKey, + responseBody, + }); + await this.idemRepo.save(row); + } + private toView(m: Message) { return { messageId: m.messageId, @@ -46,7 +73,15 @@ export class MessagingController { } @Post() - async create(@Param('id') channelId: string, @Body() body: CreateMessageDto) { + async create( + @Param('id') channelId: string, + @Body() body: CreateMessageDto, + @Headers('idempotency-key') idempotencyKey?: string, + ) { + const scope = `POST:/channels/${channelId}/messages`; + const existed = await this.getIdempotentResponse(scope, idempotencyKey); + if (existed) return existed; + const message = await this.dataSource.transaction(async (manager) => { const channel = await manager.findOne(Channel, { where: { id: channelId }, @@ -78,7 +113,9 @@ export class MessagingController { return manager.save(Message, row); }); - return this.toView(message); + const responseBody = this.toView(message) as Record; + await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + return responseBody; } @Patch(':messageId') @@ -86,7 +123,12 @@ export class MessagingController { @Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }, + @Headers('idempotency-key') idempotencyKey?: string, ) { + const scope = `PATCH:/channels/${channelId}/messages/${messageId}`; + const existed = await this.getIdempotentResponse(scope, idempotencyKey); + if (existed) return existed; + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; @@ -99,11 +141,21 @@ export class MessagingController { item.content = body.content ?? item.content; item.editedAt = new Date(); const saved = await this.messageRepo.save(item); - return this.toView(saved); + const responseBody = this.toView(saved) as Record; + await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + return responseBody; } @Delete(':messageId') - async remove(@Param('id') channelId: string, @Param('messageId') messageId: string) { + async remove( + @Param('id') channelId: string, + @Param('messageId') messageId: string, + @Headers('idempotency-key') idempotencyKey?: string, + ) { + const scope = `DELETE:/channels/${channelId}/messages/${messageId}`; + const existed = await this.getIdempotentResponse(scope, idempotencyKey); + if (existed) return existed; + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; @@ -114,7 +166,13 @@ export class MessagingController { item.attachments = []; await this.messageRepo.save(item); - return { status: 'deleted', mode: 'soft', messageId }; + const responseBody = { + status: 'deleted', + mode: 'soft', + messageId, + } as Record; + await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + return responseBody; } @Get() diff --git a/Fabric.Backend.Guild/src/messaging/messaging.module.ts b/Fabric.Backend.Guild/src/messaging/messaging.module.ts index 1ed52ac..3cba2a6 100644 --- a/Fabric.Backend.Guild/src/messaging/messaging.module.ts +++ b/Fabric.Backend.Guild/src/messaging/messaging.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { MessagingController } from './messaging.controller'; import { Channel } from '../entities/channel.entity'; import { Message } from '../entities/message.entity'; +import { IdempotencyRecord } from '../entities/idempotency-record.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Channel, Message])], + imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord])], controllers: [MessagingController], }) export class MessagingModule {} diff --git a/docs/TODO-backend-center-guild.md b/docs/TODO-backend-center-guild.md index 4fe0b4d..97eb763 100644 --- a/docs/TODO-backend-center-guild.md +++ b/docs/TODO-backend-center-guild.md @@ -53,7 +53,7 @@ ### 2.3 一致性与回补 - [x] 回补接口:`seq_from/seq_to` - [x] 断片检测辅助响应字段(next_expected_seq 等) -- [ ] 幂等键支持(写接口) +- [x] 幂等键支持(写接口) ### 2.4 实时通信(MVP 后半) - [ ] WebSocket 网关接入