fix(notifications): изолировать уведомления по space и чинить отправку в Telegram
- «Истекает оплата» строится только по VPS текущего space - ключ notification_state разделён по space: второй space больше не падает на PRIMARY KEY и не получает дубль каждый тик - таймаут 10 с для Telegram и webhook - текст от хостеров экранируется для parse_mode=HTML - сообщения длиннее 4096 символов обрезаются по строке - повторное падение того же VPS снова уведомляет - ошибки по отдельным чатам пишутся в журнал при частичной доставке - удалён неиспользуемый sendWebhook/notifyWebhook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80af888cdb
commit
f38694da63
10 files changed
+167
-56
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '../index.js'
|
||||
import { getCurrentSpaceId } from '../space-context.js'
|
||||
import { getCurrentSpaceId, MAIN_SPACE_ID } from '../space-context.js'
|
||||
|
||||
export type NotificationChannel = 'telegram' | 'webhook'
|
||||
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
|
||||
@@ -42,6 +42,14 @@ function toLogDto(row: typeof schema.notificationLog.$inferSelect): Notification
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* notification_state.key is the table's sole PRIMARY KEY, so two spaces with the same event key collided
|
||||
* (insert failed after sending → re-sent every tick). Non-main spaces get a prefixed key; main keeps old rows.
|
||||
*/
|
||||
function stateKeyForSpace(key: string, spaceId: string): string {
|
||||
return spaceId === MAIN_SPACE_ID ? key : `${spaceId}::${key}`
|
||||
}
|
||||
|
||||
export const notificationRepository = {
|
||||
listRecent(limit = 50): NotificationLogRow[] {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
@@ -79,8 +87,9 @@ export const notificationRepository = {
|
||||
.run()
|
||||
},
|
||||
|
||||
getState(key: string) {
|
||||
getState(rawKey: string) {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const key = stateKeyForSpace(rawKey, spaceId)
|
||||
return getDb()
|
||||
.select()
|
||||
.from(schema.notificationState)
|
||||
@@ -93,10 +102,11 @@ export const notificationRepository = {
|
||||
.get()
|
||||
},
|
||||
|
||||
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
|
||||
upsertState(rawKey: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
|
||||
const db = getDb()
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const existing = this.getState(key)
|
||||
const existing = this.getState(rawKey)
|
||||
const key = stateKeyForSpace(rawKey, spaceId)
|
||||
const values = {
|
||||
key,
|
||||
spaceId,
|
||||
|
||||
@@ -65,3 +65,18 @@ describe('ensureRuntimeSchema (legacy DB without providers.apiType)', () => {
|
||||
expect(sqlite.prepare(`SELECT apiCredentials FROM provider_accounts`).get()).toEqual({ apiCredentials: 'u:p' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRuntimeSchema (notification_state keys)', () => {
|
||||
afterEach(() => resetRuntimeMigrate())
|
||||
|
||||
it('prefixes plain keys of non-main spaces so main can insert its own', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
resetRuntimeMigrate()
|
||||
ensureRuntimeSchema(sqlite)
|
||||
sqlite.exec(`INSERT INTO notification_state (key, spaceId) VALUES ('payment_expiry', 'space-x'), ('low_balance', 'space-main')`)
|
||||
resetRuntimeMigrate()
|
||||
ensureRuntimeSchema(sqlite)
|
||||
const keys = (sqlite.prepare(`SELECT key FROM notification_state ORDER BY key`).all() as { key: string }[]).map((r) => r.key)
|
||||
expect(keys).toEqual(['low_balance', 'space-x::payment_expiry'])
|
||||
})
|
||||
})
|
||||
@@ -556,5 +556,10 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||
rebuildVpsDomainsBindingUnique(sqlite)
|
||||
ensureMainSpace(sqlite)
|
||||
backfillSpaceIds(sqlite)
|
||||
// Non-main spaces' notification_state rows written before key prefixing would block main's inserts.
|
||||
sqlite.exec(
|
||||
`UPDATE notification_state SET key = spaceId || '::' || key
|
||||
WHERE spaceId != '${MAIN_SPACE_ID}' AND instr(key, '::') = 0`,
|
||||
)
|
||||
migrated = true
|
||||
}
|
||||
Reference in new issue
Block a user