feat: add platform dunning notifications

This commit is contained in:
Codex
2026-06-30 07:28:32 +08:00
parent e66623158d
commit 51f8cbdec8
26 changed files with 1782 additions and 24 deletions

View File

@@ -1602,6 +1602,107 @@ async function testPlatformTenantOperationsAndAudit() {
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
});
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
const platformReminder = reminders.items?.find(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue');
assert.ok(platformReminder?.id, 'platform overdue reminder should expose reminder id');
const dunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'integration_platform_dunning',
name: '集成测试平台催缴通知',
provider: 'generic',
webhookUrl: 'https://ops.example.test/platform-dunning',
secret: 'integration-platform-dunning-notification-secret',
reminderTypes: ['overdue'],
reminderChannels: ['internal'],
minReminderLevel: 1,
tenantIds: [tenantId],
timeoutSec: 5,
metadata: { owner: 'finance' },
},
});
assert.equal(dunningChannel.item?.channelCode, 'integration_platform_dunning', 'platform admin should upsert dunning notification channel');
assert.equal(dunningChannel.item?.secretRef, 'app_private.platform_secrets:webhook:platform_dunning_integration_platform_dunning', 'dunning channel should expose only platform secretRef');
assert.equal(dunningChannel.item?.webhook?.host, 'ops.example.test', 'dunning channel response should expose safe webhook host');
assert.equal(dunningChannel.item?.webhookUrl, undefined, 'dunning channel response must not expose raw webhook URL');
assert.ok(!JSON.stringify(dunningChannel).includes('integration-platform-dunning-notification-secret'), 'dunning channel response must not leak webhook secret');
const dunningChannels = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true, provider: 'generic', limit: 20 },
});
assert.ok(
dunningChannels.items?.some(item => item.channelCode === 'integration_platform_dunning'),
'platform admin should list dunning notification channels',
);
assert.ok(!JSON.stringify(dunningChannels).includes('integration-platform-dunning-notification-secret'), 'dunning channel list must not leak webhook secret');
const invalidDunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'bad_dunning_channel',
name: 'bad dunning channel',
provider: 'generic',
webhookUrl: 'ftp://ops.example.test/hook',
},
expectStatus: 400,
});
assert.equal(invalidDunningChannel.code, 'INVALID_WEBHOOK_URL', 'dunning notification channel should reject unsafe webhook URL');
const dunningEventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformDunningNotificationEventId = '';
try {
const channelRow = await dunningEventPool.query(
"select id from public.platform_dunning_notification_channels where channel_code = 'integration_platform_dunning' limit 1",
);
const insertedEvent = await dunningEventPool.query(
`
insert into public.platform_dunning_notification_events (
channel_id, reminder_id, invoice_id, tenant_id, provider, status, attempts,
last_http_code, request_payload, last_response_summary
)
values (
$1, $2, $3, $4, 'generic', 'sent', 1,
200,
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
'{"ok":true}'
)
on conflict (channel_id, reminder_id)
do update set status = excluded.status,
attempts = excluded.attempts,
request_payload = excluded.request_payload,
updated_at = now()
returning id
`,
[channelRow.rows[0].id, platformReminder.id, ids.platformOverdueInvoice, tenantId],
);
platformDunningNotificationEventId = insertedEvent.rows[0].id;
} finally {
await dunningEventPool.end();
}
const dunningEvents = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { reminderId: platformReminder.id, status: 'sent', limit: 20 },
});
assert.ok(
dunningEvents.items?.some(item => item.id === platformDunningNotificationEventId),
'platform admin should list dunning notification events',
);
const listedDunningEvent = dunningEvents.items?.find(item => item.id === platformDunningNotificationEventId);
assert.equal(listedDunningEvent?.requestPayload?.body?.token, '[REDACTED]', 'dunning event list should redact token-like payload details');
assert.equal(listedDunningEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'dunning event list should redact nested password-like payload details');
assert.ok(!JSON.stringify(dunningEvents).includes('must-not-leak'), 'dunning event list must not leak sensitive details');
const overdueAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
@@ -1668,6 +1769,20 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
const studentDunningNotificationChannelDenied = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification channels');
const studentDunningNotificationEventDenied = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification events');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,

View File

@@ -0,0 +1,265 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const ids = {
tenant: '00000000-0000-0000-0000-00000000db01',
invoice: '00000000-0000-0000-0000-00000000db02',
reminder: '00000000-0000-0000-0000-00000000db03',
};
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function startWebhookServer() {
const port = await getFreePort();
const requests = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
requests.push({
url: req.url,
headers: req.headers,
body: raw ? JSON.parse(raw) : {},
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${port}/platform-dunning`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
}
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning-notifications'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE: '20',
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS: '5000',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /platform-dunning-notifications batch enqueued=\d+/, 'worker output should include dunning notification summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.platform_dunning_notification_events where tenant_id = $1', [ids.tenant]);
await pool.query("delete from public.platform_dunning_notification_channels where channel_code = 'worker_platform_dunning_test'");
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_dunning_test'");
await pool.query('delete from public.audit_logs where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_payments where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoices where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
}
async function seed(pool, webhookUrl) {
await pool.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, 'platform-dunning-notification-worker', '平台催缴通知租户', '平台催缴通知有限公司', 'active', 'saas', 'past_due', '{"source":"platform-dunning-notification-worker-test"}'::jsonb)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
invoice_title, invoice_type, metadata
)
values (
$1, '平台催缴通知有限公司', 'TAX-DB-TEST', '财务负责人',
'13800006666', 'finance-dunning@example.test',
'平台催缴通知有限公司', 'normal_vat',
'{"apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
)
on conflict (tenant_id)
do update set contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.tenant],
);
await pool.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, total_cents, paid_cents, balance_cents,
due_date, issued_at, note, metadata
)
values (
$1, $2, 'DNWORKER202606300001', 'service_fee', 'overdue', 'CNY',
880000, 880000, 0, 880000,
current_date - interval '9 days', now(), 'platform dunning notification worker invoice',
'{"source":"platform-dunning-notification-worker-test"}'::jsonb
)
`,
[ids.invoice, ids.tenant],
);
await pool.query(
`
insert into public.tenant_invoice_reminders (
id, tenant_id, invoice_id, reminder_type, channel, status,
reminder_date, reminder_level, due_date, balance_cents_snapshot,
message, metadata
)
values (
$1, $2, $3, 'overdue', 'internal', 'pending',
current_date, 2, current_date - interval '9 days', 880000,
'请尽快跟进服务费催缴',
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
)
`,
[ids.reminder, ids.tenant, ids.invoice],
);
await pool.query(
`
insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ('webhook', 'worker_platform_dunning_test', 'platform-dunning-notification-secret', 'generic', now())
on conflict (secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
);
await pool.query(
`
insert into public.platform_dunning_notification_channels (
channel_code, name, enabled, provider, webhook_url, secret_ref,
reminder_types, reminder_channels, min_reminder_level, tenant_ids, timeout_sec
)
values (
'worker_platform_dunning_test', 'Worker 平台催缴通知', true, 'generic',
$1, 'app_private.platform_secrets:webhook:worker_platform_dunning_test',
array['overdue']::text[], array['internal']::text[], 1, array[$2::uuid], 5
)
on conflict (channel_code)
do update set enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
reminder_types = excluded.reminder_types,
reminder_channels = excluded.reminder_channels,
min_reminder_level = excluded.min_reminder_level,
tenant_ids = excluded.tenant_ids,
timeout_sec = excluded.timeout_sec,
updated_at = now()
`,
[webhookUrl, ids.tenant],
);
}
async function main() {
const webhook = await startWebhookServer();
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await seed(pool, webhook.url);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /sent=1/, 'worker should send one platform dunning notification');
assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once');
assert.equal(webhook.requests[0].body.event, 'platform.invoice.dunning_reminder', 'generic notification should use dunning event name');
assert.equal(webhook.requests[0].body.reminder.id, ids.reminder, 'webhook body should include reminder id');
assert.equal(webhook.requests[0].body.invoice.id, ids.invoice, 'webhook body should include invoice id');
assert.equal(webhook.requests[0].body.tenant.id, ids.tenant, 'webhook body should include tenant id');
assert.equal(webhook.requests[0].body.billingContact.phoneMasked, '138****6666', 'webhook body should mask phone');
assert.equal(webhook.requests[0].body.billingContact.emailMasked, 'fi***@example.test', 'webhook body should mask email');
assert.equal(webhook.requests[0].body.reminder.metadata.apiKey, '[REDACTED]', 'webhook body should redact token-like keys');
assert.equal(webhook.requests[0].body.reminder.metadata.nested.password, '[REDACTED]', 'webhook body should redact nested password');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('must-not-leak'), 'webhook request must not leak sensitive values');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('platform-dunning-notification-secret'), 'webhook request must not leak signing secret');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('13800006666'), 'webhook request must not leak raw phone');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('finance-dunning@example.test'), 'webhook request must not leak raw email');
const events = await pool.query(
`
select status, attempts, last_http_code, sent_at, request_payload
from public.platform_dunning_notification_events
where reminder_id = $1
limit 1
`,
[ids.reminder],
);
assert.equal(events.rowCount, 1, 'worker should create one dunning notification event');
assert.equal(events.rows[0].status, 'sent', 'dunning notification event should be sent');
assert.equal(events.rows[0].attempts, 1, 'dunning notification event should record one attempt');
assert.equal(events.rows[0].last_http_code, 200, 'dunning notification event should record HTTP 200');
assert.ok(events.rows[0].sent_at, 'dunning notification event should record sent_at');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('must-not-leak'), 'stored request payload should be redacted');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('platform-dunning-notification-secret'), 'stored request payload should not leak secret');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('13800006666'), 'stored request payload should not leak raw phone');
const reminder = await pool.query(
`
select status, sent_at, metadata
from public.tenant_invoice_reminders
where id = $1
`,
[ids.reminder],
);
assert.equal(reminder.rows[0]?.status, 'sent', 'worker should mark reminder sent after successful external notification');
assert.ok(reminder.rows[0]?.sent_at, 'worker should record reminder sent_at');
assert.equal(reminder.rows[0]?.metadata?.externalNotification?.channelCode, 'worker_platform_dunning_test', 'worker should record external notification metadata');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent dunning notification');
assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent dunning notification');
console.log('Platform dunning notification worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
await webhook.close();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -21,6 +21,7 @@ const safeBaseEnv = {
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef',
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
};
const safeApiEnv = {
@@ -90,6 +91,17 @@ assert.match(
'worker config should name unsafe platform audit notification localhost mode',
);
const unsafeWorkerPlatformDunningNotification = runImport(workerConfigUrl, {
...safeBaseEnv,
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
});
assert.notEqual(unsafeWorkerPlatformDunningNotification.status, 0, 'production worker config should reject platform dunning notification localhost mode');
assert.match(
unsafeWorkerPlatformDunningNotification.output,
/WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true/,
'worker config should name unsafe platform dunning notification localhost mode',
);
const safeWorker = runImport(workerConfigUrl, safeBaseEnv);
assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`);

View File

@@ -80,6 +80,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_CRM_BATCH_SIZE=20
WORKER_COMMERCE_BATCH_SIZE=20
WORKER_ASSET_BATCH_SIZE=50
@@ -117,6 +118,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
`);
assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail');
@@ -157,4 +159,37 @@ assert.ok(
'readiness should block platform audit notification localhost mode in production',
);
const unsafePlatformDunningNotificationLocalhost = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true
`);
assert.notEqual(unsafePlatformDunningNotificationLocalhost.status, 0, 'platform dunning notification localhost readiness should fail');
assert.ok(
unsafePlatformDunningNotificationLocalhost.payload.checks?.some(item => item.id === 'env.worker_platform_dunning_notification_insecure_localhost' && item.status === 'blocker'),
'readiness should block platform dunning notification localhost mode in production',
);
console.log('[PASS] production readiness check script');

View File

@@ -333,6 +333,12 @@ function validateEnv() {
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
}
if (envBool('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_platform_dunning_notification_insecure_localhost', 'WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
} else {
pass('env.worker_platform_dunning_notification_insecure_localhost', 'Platform dunning notification worker insecure localhost webhook mode is disabled');
}
const requiredPositiveNumbers = [
'WORKER_CRM_BATCH_SIZE',
'WORKER_COMMERCE_BATCH_SIZE',
@@ -470,6 +476,51 @@ async function validateDatabase() {
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
}
const unsafePlatformDunningNotificationRows = await pool.query(`
select id, channel_code, provider, webhook_url
from public.platform_dunning_notification_channels
where enabled = true
and (
webhook_url !~* '^https://'
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
)
`);
if (unsafePlatformDunningNotificationRows.rowCount > 0) {
block('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks must use production HTTPS URLs', {
count: unsafePlatformDunningNotificationRows.rowCount,
samples: unsafePlatformDunningNotificationRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks use production HTTPS URLs');
}
const missingPlatformDunningNotificationSecretRows = await pool.query(`
select c.id, c.channel_code, c.provider, c.secret_ref
from public.platform_dunning_notification_channels c
left join app_private.platform_secrets s
on s.secret_scope = split_part(c.secret_ref, ':', 2)
and s.secret_key = split_part(c.secret_ref, ':', 3)
where c.enabled = true
and c.provider in ('dingtalk', 'feishu')
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
`);
if (missingPlatformDunningNotificationSecretRows.rowCount > 0) {
block('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels require app_private.platform_secrets rows', {
count: missingPlatformDunningNotificationSecretRows.rowCount,
samples: missingPlatformDunningNotificationSecretRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels have private secret rows');
}
const unverifiedDomainRows = await pool.query(`
select count(*)::int as count
from public.tenant_domains