fix: 统一订阅更新冲突语义

This commit is contained in:
sunlei 2026-07-24 06:28:34 +08:00
parent 986615ac32
commit 761c49ed3c
2 changed files with 52 additions and 17 deletions

View File

@ -144,23 +144,28 @@ export class QqbotMessageSubscriptionService {
input: MessageSubscriptionInput, input: MessageSubscriptionInput,
): Promise<MessageSubscriptionView> { ): Promise<MessageSubscriptionView> {
const normalized = await this.normalizeInput(input); const normalized = await this.normalizeInput(input);
const saved = await this.subscriptionRepository.manager.transaction( try {
/** Holds both target and prospective active-key rows through the update save. */ const saved = await this.subscriptionRepository.manager.transaction(
async (manager) => { /** Holds both target and prospective active-key rows through the update save. */
const repository = manager.getRepository(QqbotMessageSubscription); async (manager) => {
const current = await this.findActiveForWrite(repository, id); const repository = manager.getRepository(QqbotMessageSubscription);
const conflict = await repository.findOne({ const current = await this.findActiveForWrite(repository, id);
lock: { mode: 'pessimistic_write' }, const conflict = await repository.findOne({
where: { activeKey: normalized.activeKey, isDeleted: false }, lock: { mode: 'pessimistic_write' },
}); where: { activeKey: normalized.activeKey, isDeleted: false },
if (conflict && conflict.id !== current.id) { });
this.throwNaturalKeyConflict(); if (conflict && conflict.id !== current.id) {
} this.throwNaturalKeyConflict();
Object.assign(current, normalized); }
return repository.save(current); Object.assign(current, normalized);
}, return repository.save(current);
); },
return this.toView(saved); );
return this.toView(saved);
} catch (error) {
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
throw error;
}
} }
/** /**

View File

@ -389,6 +389,36 @@ describe('QqbotMessageSubscriptionService', () => {
); );
}); });
it('maps duplicate-key update races to Vben HTTP 409 and propagates other save failures', async () => {
const duplicateKeyRace = setup([subscription()]);
duplicateKeyRace.subscriptionRepository.save.mockRejectedValueOnce({
code: 'ER_DUP_ENTRY',
errno: 1062,
});
await expect(
duplicateKeyRace.service.update('100', {
enabled: true,
name: '更新竞态',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).rejects.toMatchObject({ status: HttpStatus.CONFLICT });
const infrastructureFailure = new Error('database unavailable');
const persistenceFailure = setup([subscription()]);
persistenceFailure.subscriptionRepository.save.mockRejectedValueOnce(
infrastructureFailure,
);
await expect(
persistenceFailure.service.update('100', {
enabled: true,
name: '更新故障',
sourceConfig: CONFIG,
sourceKey: SOURCE_KEY,
}),
).rejects.toBe(infrastructureFailure);
});
it('pages only undeleted records with real filters and returns detached, real-time source status', async () => { it('pages only undeleted records with real filters and returns detached, real-time source status', async () => {
const source = adapter({ const source = adapter({
invalidReasonCode: 'ddns_mapping_mismatch', invalidReasonCode: 'ddns_mapping_mismatch',