diff --git a/openspec/changes/add-g5-independent-write/specs/db/spec.md b/openspec/changes/add-g5-independent-write/specs/db/spec.md index 6ecdc62..c010006 100644 --- a/openspec/changes/add-g5-independent-write/specs/db/spec.md +++ b/openspec/changes/add-g5-independent-write/specs/db/spec.md @@ -60,4 +60,15 @@ #### Scenario: G5 room_status 使用 hotel_id 与 room_id 冲突键 - **WHEN** 系统写入 `room_status.room_status_moment_g5` - **THEN** 应使用 `(hotel_id, room_id)` 作为 `ON CONFLICT` 的冲突键 -- **AND** 应将 `device_id` 作为普通可更新列写入 \ No newline at end of file +- **AND** 应将 `device_id` 作为普通可更新列写入 + +#### Scenario: G5 room_status 写入时强制 online_status 为 1 +- **WHEN** 系统向 `room_status.room_status_moment_g5` 插入新行或因冲突执行更新 +- **THEN** 应将 `online_status` 写为 `1` +- **AND** 该规则仅作用于 G5 room_status 路径 + +#### Scenario: G5 room_status 每包数据都触发更新时间刷新 +- **WHEN** 系统收到任意一包属于同一 `(hotel_id, room_id)` 的 G5 room_status 数据 +- **THEN** 即使业务字段与现存记录完全一致,也应执行一次 `ON CONFLICT DO UPDATE` +- **AND** 不应在 G5 room_status 的冲突更新路径上使用“字段无变化则跳过 UPDATE”的 WHERE 条件 +- **AND** 应依赖数据库触发器自动刷新 `ts_ms` \ No newline at end of file diff --git a/src/db/databaseManager.js b/src/db/databaseManager.js index bbb6944..448ec73 100644 --- a/src/db/databaseManager.js +++ b/src/db/databaseManager.js @@ -785,7 +785,7 @@ class DatabaseManager { } _buildRoomStatusUpsertQuery(events, target) { - const { tableName, conflictColumns, includeGuid, tableRef } = target; + const { tableName, conflictColumns, includeGuid, tableRef, forceOnlineStatusOnWrite, forceUpdateOnConflict } = target; const columns = this._getRoomStatusBaseColumns(); const uniqueEventsMap = new Map(); @@ -804,29 +804,43 @@ class DatabaseManager { return { sql: null, values: [], uniqueEvents: [] }; } - const allColumns = includeGuid ? [...columns, 'guid'] : [...columns]; + const allColumns = forceOnlineStatusOnWrite ? [...columns, 'online_status'] : [...columns]; + if (includeGuid) { + allColumns.push('guid'); + } const updateColumns = columns.filter((column) => !['ts_ms', ...conflictColumns].includes(column)); - const updateSet = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', '); + const updateAssignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`); + if (forceOnlineStatusOnWrite) { + updateAssignments.push('online_status = 1'); + } + const updateSet = updateAssignments.join(', '); const whereConditions = updateColumns.map((column) => `${tableRef}.${column} IS DISTINCT FROM EXCLUDED.${column}`).join(' OR '); const values = []; const placeholders = uniqueEvents.map((event, eventIndex) => { const rowValues = this._roomStatusToRowValues(event); + if (forceOnlineStatusOnWrite) { + rowValues.push(1); + } values.push(...rowValues); const start = eventIndex * rowValues.length; const params = rowValues.map((_, valueIndex) => `$${start + valueIndex + 1}`).join(', '); return includeGuid ? `(${params}, gen_random_uuid())` : `(${params})`; }).join(', '); + const whereClause = forceUpdateOnConflict + ? '' + : ` + WHERE + ${tableRef}.ts_ms <= EXCLUDED.ts_ms + AND (${whereConditions})`; + const sql = ` INSERT INTO ${tableName} (${allColumns.join(', ')}) VALUES ${placeholders} ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET - ${updateSet} - WHERE - ${tableRef}.ts_ms <= EXCLUDED.ts_ms - AND (${whereConditions}) + ${updateSet}${whereClause} `; return { sql, values, uniqueEvents }; @@ -876,6 +890,8 @@ class DatabaseManager { includeGuid: true, autoCreatePartitions: true, tableRef: this.config.roomStatusTable ?? 'room_status.room_status_moment', + forceOnlineStatusOnWrite: false, + forceUpdateOnConflict: false, logPrefix: 'upsertRoomStatus', }); } @@ -887,6 +903,8 @@ class DatabaseManager { includeGuid: false, autoCreatePartitions: false, tableRef: this.config.roomStatusTable ?? 'room_status.room_status_moment_g5', + forceOnlineStatusOnWrite: true, + forceUpdateOnConflict: true, logPrefix: 'upsertRoomStatusG5', }); } diff --git a/test/dualWrite.test.js b/test/dualWrite.test.js index e79f12b..898a477 100644 --- a/test/dualWrite.test.js +++ b/test/dualWrite.test.js @@ -589,12 +589,47 @@ describe('DatabaseManager: room_status upsert SQL', () => { includeGuid: false, autoCreatePartitions: false, tableRef: 'room_status.room_status_moment_g5', + forceOnlineStatusOnWrite: true, + forceUpdateOnConflict: true, logPrefix: 'upsertRoomStatusG5', }); assert.match(built.sql, /ON CONFLICT \(hotel_id, room_id\)/); assert.doesNotMatch(built.sql, /ts_ms = EXCLUDED\.ts_ms/); assert.equal(/guid/.test(built.sql), false); }); + + it('forces online_status to 1 for g5 room_status insert and update', () => { + const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x', roomStatusTable: 'room_status.room_status_moment_g5' }); + const built = dm._buildRoomStatusUpsertQuery([buildBasePayload()], { + tableName: 'room_status.room_status_moment_g5', + conflictColumns: ['hotel_id', 'room_id'], + includeGuid: false, + autoCreatePartitions: false, + tableRef: 'room_status.room_status_moment_g5', + forceOnlineStatusOnWrite: true, + forceUpdateOnConflict: true, + logPrefix: 'upsertRoomStatusG5', + }); + assert.match(built.sql, /INSERT INTO room_status\.room_status_moment_g5 \(.*online_status\)/s); + assert.match(built.sql, /online_status = 1/); + assert.equal(built.values[built.values.length - 1], 1); + }); + + it('always updates g5 room_status on conflict even when business fields are unchanged', () => { + const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x', roomStatusTable: 'room_status.room_status_moment_g5' }); + const built = dm._buildRoomStatusUpsertQuery([buildBasePayload()], { + tableName: 'room_status.room_status_moment_g5', + conflictColumns: ['hotel_id', 'room_id'], + includeGuid: false, + autoCreatePartitions: false, + tableRef: 'room_status.room_status_moment_g5', + forceOnlineStatusOnWrite: true, + forceUpdateOnConflict: true, + logPrefix: 'upsertRoomStatusG5', + }); + assert.doesNotMatch(built.sql, /room_status\.room_status_moment_g5\.ts_ms <= EXCLUDED\.ts_ms/); + assert.doesNotMatch(built.sql, /IS DISTINCT FROM EXCLUDED/); + }); }); describe('DatabaseManager: insertHeartbeatEventsDual', () => {