feat(guild-messaging): add idempotency-key support for write endpoints
This commit is contained in:
@@ -7,6 +7,7 @@ import { DmParticipant } from './entities/dm-participant.entity';
|
|||||||
import { GuildRole } from './entities/guild-role.entity';
|
import { GuildRole } from './entities/guild-role.entity';
|
||||||
import { GuildMember } from './entities/guild-member.entity';
|
import { GuildMember } from './entities/guild-member.entity';
|
||||||
import { GuildMemberRole } from './entities/guild-member-role.entity';
|
import { GuildMemberRole } from './entities/guild-member-role.entity';
|
||||||
|
import { IdempotencyRecord } from './entities/idempotency-record.entity';
|
||||||
|
|
||||||
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -24,6 +25,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
GuildRole,
|
GuildRole,
|
||||||
GuildMember,
|
GuildMember,
|
||||||
GuildMemberRole,
|
GuildMemberRole,
|
||||||
|
IdempotencyRecord,
|
||||||
],
|
],
|
||||||
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -14,6 +15,7 @@ import { DataSource, Repository } from 'typeorm';
|
|||||||
import { CreateMessageDto } from './dto.create-message.dto';
|
import { CreateMessageDto } from './dto.create-message.dto';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity';
|
||||||
import { Message } from '../entities/message.entity';
|
import { Message } from '../entities/message.entity';
|
||||||
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
|
|
||||||
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const DEFAULT_PAGE_LIMIT = 50;
|
const DEFAULT_PAGE_LIMIT = 50;
|
||||||
@@ -27,8 +29,33 @@ export class MessagingController {
|
|||||||
private readonly channelRepo: Repository<Channel>,
|
private readonly channelRepo: Repository<Channel>,
|
||||||
@InjectRepository(Message)
|
@InjectRepository(Message)
|
||||||
private readonly messageRepo: Repository<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) {
|
private toView(m: Message) {
|
||||||
return {
|
return {
|
||||||
messageId: m.messageId,
|
messageId: m.messageId,
|
||||||
@@ -46,7 +73,15 @@ export class MessagingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@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 message = await this.dataSource.transaction(async (manager) => {
|
||||||
const channel = await manager.findOne(Channel, {
|
const channel = await manager.findOne(Channel, {
|
||||||
where: { id: channelId },
|
where: { id: channelId },
|
||||||
@@ -78,7 +113,9 @@ export class MessagingController {
|
|||||||
return manager.save(Message, row);
|
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')
|
@Patch(':messageId')
|
||||||
@@ -86,7 +123,12 @@ export class MessagingController {
|
|||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Param('messageId') messageId: string,
|
@Param('messageId') messageId: string,
|
||||||
@Body() body: { content?: 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 } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
|
||||||
@@ -99,11 +141,21 @@ export class MessagingController {
|
|||||||
item.content = body.content ?? item.content;
|
item.content = body.content ?? item.content;
|
||||||
item.editedAt = new Date();
|
item.editedAt = new Date();
|
||||||
const saved = await this.messageRepo.save(item);
|
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')
|
@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 } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
|
||||||
@@ -114,7 +166,13 @@ export class MessagingController {
|
|||||||
item.attachments = [];
|
item.attachments = [];
|
||||||
await this.messageRepo.save(item);
|
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()
|
@Get()
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { MessagingController } from './messaging.controller';
|
import { MessagingController } from './messaging.controller';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity';
|
||||||
import { Message } from '../entities/message.entity';
|
import { Message } from '../entities/message.entity';
|
||||||
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Channel, Message])],
|
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord])],
|
||||||
controllers: [MessagingController],
|
controllers: [MessagingController],
|
||||||
})
|
})
|
||||||
export class MessagingModule {}
|
export class MessagingModule {}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
### 2.3 一致性与回补
|
### 2.3 一致性与回补
|
||||||
- [x] 回补接口:`seq_from/seq_to`
|
- [x] 回补接口:`seq_from/seq_to`
|
||||||
- [x] 断片检测辅助响应字段(next_expected_seq 等)
|
- [x] 断片检测辅助响应字段(next_expected_seq 等)
|
||||||
- [ ] 幂等键支持(写接口)
|
- [x] 幂等键支持(写接口)
|
||||||
|
|
||||||
### 2.4 实时通信(MVP 后半)
|
### 2.4 实时通信(MVP 后半)
|
||||||
- [ ] WebSocket 网关接入
|
- [ ] WebSocket 网关接入
|
||||||
|
|||||||
Reference in New Issue
Block a user