diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 354c3808..e7fcb94c 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -106,6 +106,11 @@ npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-pe PNVS 短信登录上线前要用真实手机号跑一次远程 smoke。脚本不会读取或输出密钥;它只调用公网 API,发送验证码后在终端输入收到的短信验证码,再确认 `/api/auth/me` 可用: ```bash +set -a +source /etc/tiku-saas/api.env +set +a +PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs + SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com \ SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 \ SMS_SMOKE_PHONE=13800138000 \ diff --git a/package.json b/package.json index 85e86bd7..49408655 100644 --- a/package.json +++ b/package.json @@ -58,9 +58,10 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node scripts/configure-aliyun-pnvs-provider-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/remote-sms-login-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node scripts/configure-aliyun-pnvs-provider-test.js && node scripts/diagnose-aliyun-pnvs-provider-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/remote-sms-login-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "configure:aliyun-pnvs": "node scripts/configure-aliyun-pnvs-provider.js", + "diagnose:aliyun-pnvs": "node scripts/diagnose-aliyun-pnvs-provider.js", "smoke:sms-login:remote": "node scripts/remote-sms-login-smoke.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", diff --git a/scripts/deploy/README.md b/scripts/deploy/README.md index 6a7c5ac6..d385d57c 100644 --- a/scripts/deploy/README.md +++ b/scripts/deploy/README.md @@ -149,7 +149,7 @@ Taro H5 构建存在 webpack asset size warning,这是前端包体优化事项 2026-07-03 最新接管状态: -- Gitea `main` 已包含 PNVS 短信认证、后台登录修复、PNVS provider 配置脚本和旧题库视觉对齐版本,最新提交应至少是 `f8f8316`。 +- Gitea `main` 已包含 PNVS 短信认证、后台登录修复、PNVS provider 配置/诊断脚本和旧题库视觉对齐版本,最新提交应至少是 `4fb4125`。 - 服务器仓库仍在 `/opt/tiku-saas/repo`,归属用户应为 `deploy:deploy`。 - 生产 API 已能启动,`https://api.tjszsb.com/api/tenant/resolve?host=app.tjszsb.com` 已返回 `master` 租户。 - Supabase self-hosted 运行在 `/opt/tiku-saas/supabase-project`,Kong 通过 Nginx 暴露到 `https://supabase.tjszsb.com`。 @@ -178,6 +178,16 @@ unset ALIYUN_ACCESS_KEY_ID ALIYUN_ACCESS_KEY_SECRET ALIYUN_PNVS_SIGN_NAME ALIYUN set -o history ``` +配置后先跑只读诊断,确认 env、`tenant_auth_providers` 和 `tenant_secrets` 对齐;输出只包含 AccessKey 长度和脱敏前后缀,不会打印密钥明文: + +```bash +cd /opt/tiku-saas/repo +set -a +source /etc/tiku-saas/api.env +set +a +PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs +``` + 接管服务器时先做只读检查: ```bash diff --git a/scripts/diagnose-aliyun-pnvs-provider-test.js b/scripts/diagnose-aliyun-pnvs-provider-test.js new file mode 100644 index 00000000..67c4498c --- /dev/null +++ b/scripts/diagnose-aliyun-pnvs-provider-test.js @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { + diagnoseAliyunPnvsProvider, + parseSecretRef, + summarizeSecret, +} from './diagnose-aliyun-pnvs-provider.js'; + +const tenantId = '00000000-0000-0000-0000-000000000001'; + +function queryFixture({ providerRows = [], secretRows = [] }) { + return async (sql, params) => { + if (sql.includes('from public.tenant_auth_providers')) { + assert.equal(params[0], tenantId); + return { rows: providerRows }; + } + if (sql.includes('from app_private.tenant_secrets')) { + assert.equal(params[0], tenantId); + return { + rows: secretRows.filter(row => row.scope === params[1] && row.key === params[2]), + }; + } + throw new Error(`Unexpected SQL: ${sql}`); + }; +} + +const goodProviderRows = [ + { + tenantId, + provider: 'aliyun-pnvs', + status: 'active', + displayName: '阿里云短信认证', + updatedAt: '2026-07-03T00:00:00.000Z', + configPublic: { + signName: '升本刷题通', + templateCode: 'SMS_123456789', + endpoint: 'https://dypnsapi.aliyuncs.com', + regionId: 'cn-hangzhou', + countryCode: '86', + codeType: '1', + codeLength: '6', + validTime: '300', + interval: '60', + templateParam: { code: '##code##', min: '5' }, + secretRef: 'app_private.tenant_secrets:sms:aliyun-pnvs', + }, + }, +]; +const goodSecretRows = [ + { + scope: 'sms', + key: 'aliyun-pnvs', + provider: 'aliyun-pnvs', + accessKeyId: 'LTAI5tExampleId', + accessKeySecret: 'very-long-secret-value-that-must-not-leak', + lastRotatedAt: '2026-07-03T00:00:00.000Z', + }, +]; + +const ok = await diagnoseAliyunPnvsProvider( + { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' }, + { query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) }, +); + +assert.equal(ok.ok, true); +assert.equal(ok.provider.found, true); +assert.equal(ok.provider.publicConfig.templateParamHasCodePlaceholder, true); +assert.equal(ok.secret.found, true); +assert.equal(ok.secret.accessKeySecretLength, goodSecretRows[0].accessKeySecret.length); +assert.ok(!JSON.stringify(ok).includes(goodSecretRows[0].accessKeySecret), 'diagnostic output must not include AccessKeySecret plaintext'); +assert.ok(!JSON.stringify(ok).includes(goodSecretRows[0].accessKeyId), 'diagnostic output should mask AccessKeyId plaintext'); +assert.match(ok.secret.accessKeyIdMasked, /^LTAI\.\.\./); + +const missingSecret = await diagnoseAliyunPnvsProvider( + { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' }, + { query: queryFixture({ providerRows: goodProviderRows, secretRows: [] }) }, +); +assert.equal(missingSecret.ok, false); +assert.ok(missingSecret.checks.some(item => item.id === 'db.tenant_secret' && item.status === 'blocker')); + +const wrongEnvProvider = await diagnoseAliyunPnvsProvider( + { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun' }, + { query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) }, +); +assert.equal(wrongEnvProvider.ok, false); +assert.ok(wrongEnvProvider.checks.some(item => item.id === 'env.auth_sms_provider' && item.status === 'blocker')); + +const invalidSecretRef = parseSecretRef('plain-secret'); +assert.equal(invalidSecretRef.valid, false); +assert.equal(invalidSecretRef.scope, 'sms'); +assert.equal(invalidSecretRef.key, 'aliyun-pnvs'); + +const secretSummary = summarizeSecret({ + provider: 'aliyun-pnvs', + accessKeyId: ' LTAI5tExampleId', + accessKeySecret: 'secret-value ', +}); +assert.equal(secretSummary.accessKeyIdHasEdgeWhitespace, true); +assert.equal(secretSummary.accessKeySecretHasEdgeWhitespace, true); +assert.ok(!JSON.stringify(secretSummary).includes('secret-value ')); + +console.log('[PASS] diagnose Aliyun PNVS provider script'); diff --git a/scripts/diagnose-aliyun-pnvs-provider.js b/scripts/diagnose-aliyun-pnvs-provider.js new file mode 100644 index 00000000..ef2e1665 --- /dev/null +++ b/scripts/diagnose-aliyun-pnvs-provider.js @@ -0,0 +1,275 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; + +const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001'; +const PNVS_ALIASES = new Set(['aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth']); + +function envString(env, key, fallback = '') { + return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; +} + +function normalizeProvider(value) { + return String(value || '').trim().toLowerCase().replace(/_/g, '-'); +} + +function isPnvsProvider(value) { + return PNVS_ALIASES.has(String(value || '').trim().toLowerCase()) || PNVS_ALIASES.has(normalizeProvider(value)); +} + +function parseSecretRef(value) { + const raw = String(value || '').trim(); + const parts = raw.split(':'); + if (parts.length !== 3 || parts[0] !== 'app_private.tenant_secrets') { + return { raw, scope: 'sms', key: 'aliyun-pnvs', valid: !raw }; + } + return { raw, scope: parts[1] || 'sms', key: parts[2] || 'aliyun-pnvs', valid: true }; +} + +function templateParamHasCodePlaceholder(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return Object.values(value).some(item => String(item) === '##code##'); +} + +function mask(value) { + const text = String(value || ''); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}...${text.slice(-2)}`; + return `${text.slice(0, 4)}...${text.slice(-4)}`; +} + +function summarizePublicConfig(configPublic = {}) { + const secretRef = parseSecretRef(configPublic.secretRef); + return { + signName: typeof configPublic.signName === 'string' ? configPublic.signName : '', + templateCode: typeof configPublic.templateCode === 'string' ? configPublic.templateCode : '', + endpoint: typeof configPublic.endpoint === 'string' ? configPublic.endpoint : '', + regionId: typeof configPublic.regionId === 'string' ? configPublic.regionId : '', + countryCode: typeof configPublic.countryCode === 'string' ? configPublic.countryCode : '', + codeType: typeof configPublic.codeType === 'string' ? configPublic.codeType : '', + codeLength: typeof configPublic.codeLength === 'string' ? configPublic.codeLength : '', + validTime: typeof configPublic.validTime === 'string' ? configPublic.validTime : '', + interval: typeof configPublic.interval === 'string' ? configPublic.interval : '', + templateParamHasCodePlaceholder: templateParamHasCodePlaceholder(configPublic.templateParam), + secretRef: secretRef.raw || 'app_private.tenant_secrets:sms:aliyun-pnvs', + secretRefValid: secretRef.valid, + secretScope: secretRef.scope, + secretKey: secretRef.key, + }; +} + +function summarizeSecret(row) { + if (!row) return { found: false }; + const accessKeyId = String(row.accessKeyId || ''); + const accessKeySecret = String(row.accessKeySecret || ''); + return { + found: true, + provider: row.provider || '', + accessKeyIdLength: accessKeyId.length, + accessKeyIdMasked: mask(accessKeyId), + accessKeySecretLength: accessKeySecret.length, + accessKeyIdHasEdgeWhitespace: /^\s|\s$/.test(accessKeyId), + accessKeySecretHasEdgeWhitespace: /^\s|\s$/.test(accessKeySecret), + lastRotatedAt: row.lastRotatedAt || null, + }; +} + +function buildConfig(env = process.env) { + const databaseUrl = envString(env, 'DATABASE_URL'); + const tenantId = envString(env, 'PNVS_TENANT_ID', envString(env, 'TENANT_ID', DEFAULT_TENANT_ID)); + const authSmsProvider = envString(env, 'AUTH_SMS_PROVIDER'); + if (!databaseUrl) throw new Error('Missing required env: DATABASE_URL'); + if (!tenantId) throw new Error('Missing required env: PNVS_TENANT_ID or TENANT_ID'); + return { databaseUrl, tenantId, authSmsProvider }; +} + +async function queryProviderRows(query, tenantId) { + const result = await query( + ` + select tenant_id as "tenantId", + provider, + status, + display_name as "displayName", + config_public as "configPublic", + updated_at as "updatedAt" + from public.tenant_auth_providers + where tenant_id = $1::uuid + and lower(replace(provider, '_', '-')) in ('aliyun-pnvs', 'aliyun-pnvs-sms', 'aliyun-sms-auth') + order by case status when 'active' then 0 when 'testing' then 1 else 2 end, + updated_at desc nulls last + `, + [tenantId], + ); + return result.rows || []; +} + +async function querySecretRow(query, tenantId, secretScope, secretKey) { + const result = await query( + ` + select provider, + secret_json->>'accessKeyId' as "accessKeyId", + secret_json->>'accessKeySecret' as "accessKeySecret", + last_rotated_at as "lastRotatedAt" + from app_private.tenant_secrets + where tenant_id = $1::uuid + and secret_scope = $2 + and secret_key = $3 + limit 1 + `, + [tenantId, secretScope, secretKey], + ); + return result.rows?.[0] || null; +} + +function buildChecks({ config, providerRow, publicConfig, secret }) { + const checks = []; + const add = (status, id, message, details = {}) => checks.push({ status, id, message, details }); + if (isPnvsProvider(config.authSmsProvider)) { + add('pass', 'env.auth_sms_provider', 'AUTH_SMS_PROVIDER is set to aliyun-pnvs-compatible provider', { + provider: config.authSmsProvider, + }); + } else { + add('blocker', 'env.auth_sms_provider', 'AUTH_SMS_PROVIDER should be aliyun-pnvs for PNVS SMS authentication', { + provider: config.authSmsProvider || '(unset)', + }); + } + + if (!providerRow) { + add('blocker', 'db.auth_provider', 'No aliyun-pnvs tenant_auth_providers row exists for tenant'); + return checks; + } + if (providerRow.status === 'active' || providerRow.status === 'testing') { + add('pass', 'db.auth_provider.status', 'PNVS tenant auth provider is active/testing', { + provider: providerRow.provider, + status: providerRow.status, + }); + } else { + add('blocker', 'db.auth_provider.status', 'PNVS tenant auth provider should be active or testing', { + provider: providerRow.provider, + status: providerRow.status, + }); + } + + for (const key of ['signName', 'templateCode']) { + if (publicConfig[key]) { + add('pass', `db.auth_provider.${key}`, `PNVS public config has ${key}`); + } else { + add('blocker', `db.auth_provider.${key}`, `PNVS public config is missing ${key}`); + } + } + if (publicConfig.endpoint && !/^https:\/\/[^/]*aliyuncs\.com\b/i.test(publicConfig.endpoint)) { + add('blocker', 'db.auth_provider.endpoint', 'PNVS endpoint should be an aliyuncs.com HTTPS endpoint', { + endpoint: publicConfig.endpoint, + }); + } else { + add('pass', 'db.auth_provider.endpoint', 'PNVS endpoint is aliyuncs.com HTTPS or default-compatible', { + endpoint: publicConfig.endpoint || '(default)', + }); + } + if (publicConfig.templateParamHasCodePlaceholder) { + add('pass', 'db.auth_provider.template_param', 'PNVS templateParam preserves ##code## placeholder'); + } else { + add('warn', 'db.auth_provider.template_param', 'PNVS templateParam lacks ##code##; backend can add it, but configure script should preserve it'); + } + if (publicConfig.secretRefValid) { + add('pass', 'db.auth_provider.secret_ref', 'PNVS secretRef is valid', { + secretRef: publicConfig.secretRef, + }); + } else { + add('blocker', 'db.auth_provider.secret_ref', 'PNVS secretRef is invalid', { + secretRef: publicConfig.secretRef, + }); + } + if (!secret.found) { + add('blocker', 'db.tenant_secret', 'PNVS tenant secret row is missing', { + secretScope: publicConfig.secretScope, + secretKey: publicConfig.secretKey, + }); + } else { + add('pass', 'db.tenant_secret', 'PNVS tenant secret row exists', { + secretScope: publicConfig.secretScope, + secretKey: publicConfig.secretKey, + }); + if (secret.accessKeyIdLength <= 0) add('blocker', 'db.tenant_secret.access_key_id', 'PNVS AccessKeyId is empty'); + if (secret.accessKeySecretLength <= 0) add('blocker', 'db.tenant_secret.access_key_secret', 'PNVS AccessKeySecret is empty'); + if (secret.accessKeyIdHasEdgeWhitespace || secret.accessKeySecretHasEdgeWhitespace) { + add('blocker', 'db.tenant_secret.whitespace', 'PNVS AccessKey fields contain leading/trailing whitespace'); + } + } + return checks; +} + +async function diagnoseAliyunPnvsProvider(inputConfig, options = {}) { + const config = inputConfig?.databaseUrl ? inputConfig : buildConfig(options.env || process.env); + const query = options.query; + if (!query) throw new Error('diagnoseAliyunPnvsProvider requires a query function'); + const rows = await queryProviderRows(query, config.tenantId); + const providerRow = rows.find(row => row.status === 'active') || rows.find(row => row.status === 'testing') || rows[0] || null; + const publicConfig = providerRow ? summarizePublicConfig(providerRow.configPublic || {}) : summarizePublicConfig(); + const secret = providerRow + ? summarizeSecret(await querySecretRow(query, config.tenantId, publicConfig.secretScope, publicConfig.secretKey)) + : { found: false }; + const checks = buildChecks({ config, providerRow, publicConfig, secret }); + return { + ok: checks.every(item => item.status !== 'blocker'), + tenantId: config.tenantId, + env: { + authSmsProvider: config.authSmsProvider || '', + providerMatchesPnvs: isPnvsProvider(config.authSmsProvider), + }, + provider: providerRow + ? { + found: true, + provider: providerRow.provider, + status: providerRow.status, + displayName: providerRow.displayName || '', + updatedAt: providerRow.updatedAt || null, + publicConfig, + } + : { found: false, publicConfig }, + secret, + checks, + }; +} + +async function diagnoseWithPool(config) { + const pool = new pg.Pool({ connectionString: config.databaseUrl, max: 1 }); + try { + return await diagnoseAliyunPnvsProvider(config, { query: (text, params) => pool.query(text, params) }); + } finally { + await pool.end(); + } +} + +async function main() { + try { + const config = buildConfig(); + const result = await diagnoseWithPool(config); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + } catch (error) { + console.error(error.message); + console.error(` +Usage: + set -a + source /etc/tiku-saas/api.env + set +a + PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs +`); + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { + await main(); +} + +export { + buildChecks, + buildConfig, + diagnoseAliyunPnvsProvider, + isPnvsProvider, + parseSecretRef, + summarizePublicConfig, + summarizeSecret, +};