diff --git a/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts b/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts index 64ace60..9d93adc 100644 --- a/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts +++ b/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts @@ -144,23 +144,28 @@ export class QqbotMessageSubscriptionService { input: MessageSubscriptionInput, ): Promise { const normalized = await this.normalizeInput(input); - const saved = await this.subscriptionRepository.manager.transaction( - /** Holds both target and prospective active-key rows through the update save. */ - async (manager) => { - const repository = manager.getRepository(QqbotMessageSubscription); - const current = await this.findActiveForWrite(repository, id); - const conflict = await repository.findOne({ - lock: { mode: 'pessimistic_write' }, - where: { activeKey: normalized.activeKey, isDeleted: false }, - }); - if (conflict && conflict.id !== current.id) { - this.throwNaturalKeyConflict(); - } - Object.assign(current, normalized); - return repository.save(current); - }, - ); - return this.toView(saved); + try { + const saved = await this.subscriptionRepository.manager.transaction( + /** Holds both target and prospective active-key rows through the update save. */ + async (manager) => { + const repository = manager.getRepository(QqbotMessageSubscription); + const current = await this.findActiveForWrite(repository, id); + const conflict = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { activeKey: normalized.activeKey, isDeleted: false }, + }); + if (conflict && conflict.id !== current.id) { + this.throwNaturalKeyConflict(); + } + Object.assign(current, normalized); + return repository.save(current); + }, + ); + return this.toView(saved); + } catch (error) { + if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict(); + throw error; + } } /** diff --git a/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts b/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts index ca5fa6d..234d623 100644 --- a/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts @@ -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 () => { const source = adapter({ invalidReasonCode: 'ddns_mapping_mismatch',