/** One-shot, audited owner backfill. The upstream report database is SELECT-only. */ import { createHash, randomUUID } from 'node:crypto' import { readFile, writeFile, lstat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { selectArtPlannerOwner } = require('../lib/art-workflow-owner.cjs') export const ART_CODE = 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838' export const SYNC_ID = 'ding-workflow-hourly-v4-product-id' const own = (value, key) => !!value && typeof value === 'object' && Object.hasOwn(value, key) const canonical = value => value === null || typeof value !== 'object' ? JSON.stringify(value) : Array.isArray(value) ? `[${value.map(canonical).join(',')}]` : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` export const digest = value => createHash('sha256').update(canonical(value)).digest('hex') export function buildOwnerPatches(snapshots, sourceRows) { const sources = new Map() for (const row of sourceRows) { const rows = sources.get(row.processInstanceId) || [] rows.push(row); sources.set(row.processInstanceId, rows) } return snapshots.filter(row => !own(row.formFields, 'plannerOwner')).map(row => { if (row.formFields !== null && (typeof row.formFields !== 'object' || Array.isArray(row.formFields))) throw new Error('INVALID_FORM_FIELDS') const source = sources.get(row.processInstanceId) if (!source) throw new Error('SOURCE_WORKFLOW_NOT_FOUND') const owner = selectArtPlannerOwner(source) const after = { ...(row.formFields || {}), plannerOwner: owner } return { workflowId: row.processInstanceId, businessId: row.businessId, before: row.formFields, beforeSqlNull: row.formFieldsSqlNull === true, beforeHash: digest(row.formFields), owner, afterHash: digest(after) } }) } /** Idempotent, all-or-nothing CAS; a concurrent refresh requires a new audit. */ export async function applyOwnerPatches(db, patches, leaseOwner, restore = false) { return db.$transaction(async tx => { const leases = await tx.$queryRawUnsafe('SELECT lease_owner, lease_expires_at FROM ding_workflow_sync_states WHERE id=$1 FOR UPDATE', SYNC_ID) if (leases.length !== 1 || leases[0].lease_owner !== leaseOwner || new Date(leases[0].lease_expires_at).getTime() <= Date.now()) throw new Error('SYNC_LEASE_LOST') let changed = 0, skipped = 0 for (const patch of patches) { const row = await tx.dingWorkflowSnapshot.findUnique({ where: { processInstanceId: patch.workflowId }, select: { formFields: true, processCode: true } }) if (!row || row.processCode !== ART_CODE) throw new Error('SNAPSHOT_TARGET_CHANGED') if (restore) { if (digest(row.formFields) === patch.beforeHash) { skipped++; continue } if (digest(row.formFields) !== patch.afterHash) throw new Error('RESTORE_CONFLICT_REVIEW_MANUALLY') } else { // A completed hourly refresh is authoritative; never overwrite its owner. if (own(row.formFields, 'plannerOwner')) { skipped++; continue } if (digest(row.formFields) !== patch.beforeHash) throw new Error('SNAPSHOT_CHANGED_REAUDIT') } const nullState = row.formFields === null ? await tx.$queryRawUnsafe('SELECT form_fields IS NULL AS "sqlNull" FROM ding_workflow_snapshots WHERE process_instance_id=$1', patch.workflowId) : [] const previous = row.formFields === null && nullState[0]?.sqlNull ? null : JSON.stringify(row.formFields) const next = restore ? patch.before : { ...(row.formFields || {}), plannerOwner: patch.owner } const count = await tx.$executeRawUnsafe(`UPDATE ding_workflow_snapshots SET form_fields=$1::jsonb, updated_at=NOW() WHERE process_instance_id=$2 AND process_code=$3 AND form_fields IS NOT DISTINCT FROM $4::jsonb`, restore && patch.beforeSqlNull ? null : JSON.stringify(next), patch.workflowId, ART_CODE, previous) if (count !== 1) throw new Error('SNAPSHOT_CHANGED_REAUDIT') changed++ } return { changed, skipped } }, { timeout: 120000 }) } function table(schema, name) { if (![schema, name].every(value => /^[A-Za-z_][A-Za-z0-9_]*$/.test(value))) throw new Error('INVALID_SOURCE_TABLE') return `[${schema}].[${name}]` } async function readSourceRows(ids) { if (!ids.length) return [] const sql = require('mssql') const env = process.env if (!['REPORT_DB_SERVER', 'REPORT_DB_DATABASE', 'REPORT_DB_USER', 'REPORT_DB_PASSWORD'].every(key => env[key])) throw new Error('REPORT_DATABASE_NOT_CONFIGURED') const pool = new sql.ConnectionPool({ server: env.REPORT_DB_SERVER, database: env.REPORT_DB_DATABASE, user: env.REPORT_DB_USER, password: env.REPORT_DB_PASSWORD, port: Number(env.REPORT_DB_PORT || 1433), options: { encrypt: env.REPORT_DB_ENCRYPT === 'true', trustServerCertificate: env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false' }, connectionTimeout: 20000, requestTimeout: 120000, pool: { max: 1, min: 0 } }) const workflow = table(env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt', env.REPORT_DB_WORKFLOW_TABLE || 'ding_Workflow') const scheme = table(env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt', env.REPORT_DB_WORKFLOW_SCHEME_TABLE || 'ding_WorkflowScheme') try { await pool.connect() const result = await pool.request().input('ids', sql.NVarChar(sql.MAX), JSON.stringify(ids)).input('code', sql.NVarChar, ART_CODE).query(` SELECT CONVERT(nvarchar(255),w.processInstanceId) AS processInstanceId,CONVERT(nvarchar(64),s.id) AS sourceRowId,s.name, CONVERT(nvarchar(max),s.[value]) AS value,CONVERT(nvarchar(max),s.extValue) AS extValue FROM ${workflow} w INNER JOIN OPENJSON(@ids) ids ON CONVERT(nvarchar(255),w.processInstanceId)=CONVERT(nvarchar(255),ids.[value]) LEFT JOIN ${scheme} s ON s.processInstanceId=w.processInstanceId AND s.name=N'策划案负责人' WHERE w.processCode=@code`) return result.recordset } finally { await pool.close() } } async function assertTarget(db, writing) { const url = new URL(process.env.DATABASE_URL || '') if (process.env.NODE_ENV !== 'production') { if (!['localhost', '127.0.0.1'].includes(url.hostname) || url.port !== '55439' || !/^\/salary_isolated_/.test(url.pathname)) throw new Error('ISOLATED_DATABASE_REQUIRED') return { commit: 'local', database: { host: url.hostname, port: url.port, name: url.pathname.slice(1) } } } const { requireExactCommit, assertExpectedDatabaseIdentity, assertBackupReceipt } = await import('./safe-production-migrate.mjs') const commit = requireExactCommit(process.env.EXPECTED_COMMIT_SHA, 'EXPECTED_COMMIT_SHA') const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') if ((await readFile(path.join(root, 'DEPLOYMENT_COMMIT'), 'utf8')).trim() !== commit) throw new Error('IMAGE_COMMIT_MISMATCH') const identity = await db.$queryRawUnsafe('SELECT current_database() AS database,current_user AS "user",inet_server_addr()::text AS server_address,inet_server_port()::text AS server_port') const database = assertExpectedDatabaseIdentity(url, identity[0]) if (writing) { if (process.env.ALLOW_ART_OWNER_BACKFILL !== 'PATCH_MISSING_PLANNER_OWNER') throw new Error('EXPLICIT_WRITE_FLAG_REQUIRED') await assertBackupReceipt({ expectedCommit: commit, database }) } return { commit, database } } async function privateWrite(file, value) { const parent = await lstat(path.dirname(file)) if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o077) !== 0) throw new Error('PRIVATE_OUTPUT_DIRECTORY_REQUIRED') await writeFile(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) } async function main(args) { const modes = args.filter(arg => ['--audit', '--apply', '--restore'].includes(arg)) if (modes.length !== 1 || args.some(arg => !['--audit', '--apply', '--restore'].includes(arg) && !/^--(?:output|manifest)=\/.+/.test(arg))) throw new Error('USE_AUDIT_APPLY_OR_RESTORE_WITH_ABSOLUTE_PATH') const mode = modes[0], output = args.find(arg => arg.startsWith('--output='))?.slice(9), manifestFile = args.find(arg => arg.startsWith('--manifest='))?.slice(11) const { PrismaClient } = require('@prisma/client') const db = new PrismaClient({ log: [] }) let leaseOwner try { const target = await assertTarget(db, mode !== '--audit') if (mode === '--audit') { if (!output || manifestFile) throw new Error('AUDIT_OUTPUT_REQUIRED') const snapshots = await db.$queryRawUnsafe('SELECT process_instance_id AS "processInstanceId", business_id AS "businessId", form_fields AS "formFields", form_fields IS NULL AS "formFieldsSqlNull" FROM ding_workflow_snapshots WHERE process_code=$1 ORDER BY process_instance_id', ART_CODE) const missing = snapshots.filter(row => !own(row.formFields, 'plannerOwner')) const patches = buildOwnerPatches(missing, await readSourceRows(missing.map(row => row.processInstanceId))) const people = await db.performanceSurveyEmployeeProfile.findMany({ select: { dingtalkUserId: true, realName: true, flowerName: true, displayName: true } }) const unresolved = patches.filter(patch => { if (patch.owner === null) return false let contacts; try { contacts = JSON.parse(patch.owner) } catch { /* name-only source */ } if (Array.isArray(contacts)) return contacts.length !== 1 || !people.some(person => person.dingtalkUserId === contacts[0].emplId) return people.filter(person => [person.realName, person.flowerName, person.displayName].includes(patch.owner)).length !== 1 }).map(patch => patch.workflowId) const manifest = { schemaVersion: 1, target, generatedAt: new Date().toISOString(), total: snapshots.length, unresolved, patches } await privateWrite(output, manifest) console.log(JSON.stringify({ mode, total: snapshots.length, missing: patches.length, explicitEmpty: patches.filter(patch => patch.owner === null).length, unresolved: unresolved.length, manifestSha256: createHash('sha256').update(await readFile(output)).digest('hex') })) return } if (!manifestFile || output) throw new Error('APPLY_MANIFEST_REQUIRED') const stat = await lstat(manifestFile) if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw new Error('PRIVATE_MANIFEST_REQUIRED') const bytes = await readFile(manifestFile) if (createHash('sha256').update(bytes).digest('hex') !== process.env.EXPECTED_OWNER_MANIFEST_SHA256) throw new Error('MANIFEST_HASH_MISMATCH') const manifest = JSON.parse(bytes.toString('utf8')) if (manifest.schemaVersion !== 1 || digest(manifest.target) !== digest(target) || !Array.isArray(manifest.patches)) throw new Error('MANIFEST_TARGET_MISMATCH') for (const patch of manifest.patches) { if (own(patch.before, 'plannerOwner') || digest(patch.before) !== patch.beforeHash || digest({ ...(patch.before || {}), plannerOwner: patch.owner }) !== patch.afterHash) throw new Error('INVALID_PATCH_MANIFEST') } leaseOwner = `planner-owner-backfill:${process.pid}:${randomUUID()}` await db.dingWorkflowSyncState.upsert({ where: { id: SYNC_ID }, create: { id: SYNC_ID }, update: {} }) const claimed = await db.dingWorkflowSyncState.updateMany({ where: { id: SYNC_ID, OR: [{ leaseExpiresAt: null }, { leaseExpiresAt: { lt: new Date() } }] }, data: { leaseOwner, leaseExpiresAt: new Date(Date.now() + 20 * 60 * 1000) } }) if (claimed.count !== 1) throw new Error('SYNC_BUSY_RETRY_AFTER_COMPLETION') if (mode === '--apply') { const source = await readSourceRows(manifest.patches.map(patch => patch.workflowId)) const refreshed = buildOwnerPatches(manifest.patches.map(patch => ({ processInstanceId: patch.workflowId, businessId: patch.businessId, formFields: patch.before })), source) if (refreshed.some((patch, i) => patch.afterHash !== manifest.patches[i].afterHash)) throw new Error('SOURCE_CHANGED_REAUDIT') } const result = await applyOwnerPatches(db, manifest.patches, leaseOwner, mode === '--restore') await privateWrite(`${manifestFile}.${mode.slice(2)}.${randomUUID()}.json`, { ...result, manifestSha256: process.env.EXPECTED_OWNER_MANIFEST_SHA256, completedAt: new Date().toISOString() }) console.log(JSON.stringify({ mode, ...result })) } finally { if (leaseOwner) await db.dingWorkflowSyncState.updateMany({ where: { id: SYNC_ID, leaseOwner }, data: { leaseOwner: null, leaseExpiresAt: null } }).catch(() => {}) await db.$disconnect() } } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { main(process.argv.slice(2)).catch(error => { // Bound data, SQL, credentials and personal records must not enter stdout. console.error(`负责人维护未完成:${/^[A-Z_]+$/.test(error.message) ? error.message : 'VERIFY_DATABASE_SOURCE_BACKUP_AND_PRIVATE_AUDIT'}`) process.exitCode = 1 }) }