feat(guild-messaging): add idempotency-key support for write endpoints

This commit is contained in:
nav
2026-05-12 10:40:00 +00:00
parent 2ec50f3234
commit 670762aa7a
5 changed files with 94 additions and 7 deletions

View File

@@ -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',

View File

@@ -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<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -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<Channel>,
@InjectRepository(Message)
private readonly messageRepo: Repository<Message>,
@InjectRepository(IdempotencyRecord)
private readonly idemRepo: Repository<IdempotencyRecord>,
) {}
private async getIdempotentResponse(
scope: string,
idempotencyKey?: string,
): Promise<Record<string, unknown> | 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<string, unknown>,
): Promise<void> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
return responseBody;
}
@Get()

View File

@@ -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 {}

View File

@@ -53,7 +53,7 @@
### 2.3 一致性与回补
- [x] 回补接口:`seq_from/seq_to`
- [x] 断片检测辅助响应字段next_expected_seq 等)
- [ ] 幂等键支持(写接口)
- [x] 幂等键支持(写接口)
### 2.4 实时通信MVP 后半)
- [ ] WebSocket 网关接入