From f8f83168b4ce6e6880818441ce7eb0f0885aab20 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 23:16:58 +0800 Subject: [PATCH] chore: add PNVS provider configuration script --- package.json | 3 +- .../configure-aliyun-pnvs-provider-test.js | 40 ++++ scripts/configure-aliyun-pnvs-provider.js | 189 ++++++++++++++++++ scripts/deploy/README.md | 22 ++ 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 scripts/configure-aliyun-pnvs-provider-test.js create mode 100644 scripts/configure-aliyun-pnvs-provider.js diff --git a/package.json b/package.json index 236cb4f9..85e86bd7 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,9 @@ "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 --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 --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", "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/configure-aliyun-pnvs-provider-test.js b/scripts/configure-aliyun-pnvs-provider-test.js new file mode 100644 index 00000000..e36b54b3 --- /dev/null +++ b/scripts/configure-aliyun-pnvs-provider-test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { buildConfig, configureAliyunPnvsProvider, publicConfig } from './configure-aliyun-pnvs-provider.js'; + +const env = { + DATABASE_URL: 'postgresql://user:pass@127.0.0.1:54322/postgres', + PNVS_TENANT_ID: '00000000-0000-0000-0000-000000000001', + ALIYUN_ACCESS_KEY_ID: 'access-key-id', + ALIYUN_ACCESS_KEY_SECRET: 'access-key-secret', + ALIYUN_PNVS_SIGN_NAME: '短信签名', + ALIYUN_PNVS_TEMPLATE_CODE: 'SMS_123456789', +}; + +const config = buildConfig(env); +assert.equal(config.tenantId, env.PNVS_TENANT_ID); +assert.equal(config.templateParam.code, '##code##'); +assert.equal(config.templateParam.min, '5'); + +const driftConfig = buildConfig({ + ...env, + ALIYUN_PNVS_TEMPLATE_PARAM: '{"min":"5"}', +}); +assert.equal(driftConfig.templateParam.code, '##code##', 'script should preserve PNVS code placeholder'); + +const cfgPublic = publicConfig(config); +assert.equal(cfgPublic.secretRef, 'app_private.tenant_secrets:sms:aliyun-pnvs'); +assert.equal(cfgPublic.endpoint, 'https://dypnsapi.aliyuncs.com'); +assert.equal(JSON.stringify(cfgPublic).includes('access-key-secret'), false, 'public config must not include secrets'); + +const dryRun = await configureAliyunPnvsProvider(config, { dryRun: true }); +assert.equal(dryRun.provider, 'aliyun-pnvs'); +assert.equal(dryRun.secretScope, 'sms'); +assert.equal(dryRun.secretKey, 'aliyun-pnvs'); +assert.equal(JSON.stringify(dryRun).includes('access-key-secret'), false, 'dry-run output must not include secret plaintext'); + +assert.throws( + () => buildConfig({ ...env, ALIYUN_ACCESS_KEY_SECRET: '' }), + /Missing required PNVS config env/, +); + +console.log('[PASS] configure Aliyun PNVS provider script'); diff --git a/scripts/configure-aliyun-pnvs-provider.js b/scripts/configure-aliyun-pnvs-provider.js new file mode 100644 index 00000000..1b157275 --- /dev/null +++ b/scripts/configure-aliyun-pnvs-provider.js @@ -0,0 +1,189 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; + +const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001'; + +function envString(env, key, fallback = '') { + return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; +} + +function envNumberString(env, key, fallback) { + const value = envString(env, key, fallback); + return /^\d+$/.test(value) ? value : fallback; +} + +function envJsonObject(env, key, fallback) { + const raw = envString(env, key); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${key} must be a JSON object`); + } + return parsed; +} + +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 accessKeyId = envString(env, 'ALIYUN_ACCESS_KEY_ID'); + const accessKeySecret = envString(env, 'ALIYUN_ACCESS_KEY_SECRET'); + const signName = envString(env, 'ALIYUN_PNVS_SIGN_NAME', envString(env, 'ALIYUN_SIGN_NAME')); + const templateCode = envString(env, 'ALIYUN_PNVS_TEMPLATE_CODE', envString(env, 'ALIYUN_TEMPLATE_CODE')); + + const missing = []; + if (!databaseUrl) missing.push('DATABASE_URL'); + if (!tenantId) missing.push('PNVS_TENANT_ID or TENANT_ID'); + if (!accessKeyId) missing.push('ALIYUN_ACCESS_KEY_ID'); + if (!accessKeySecret) missing.push('ALIYUN_ACCESS_KEY_SECRET'); + if (!signName) missing.push('ALIYUN_PNVS_SIGN_NAME or ALIYUN_SIGN_NAME'); + if (!templateCode) missing.push('ALIYUN_PNVS_TEMPLATE_CODE or ALIYUN_TEMPLATE_CODE'); + if (missing.length > 0) throw new Error(`Missing required PNVS config env: ${missing.join(', ')}`); + + const validTime = envNumberString(env, 'ALIYUN_PNVS_VALID_TIME', '300'); + const interval = envNumberString(env, 'ALIYUN_PNVS_INTERVAL', '60'); + const templateParam = envJsonObject(env, 'ALIYUN_PNVS_TEMPLATE_PARAM', { + code: '##code##', + min: String(Math.max(1, Math.ceil(Number(validTime) / 60))), + }); + if (!Object.values(templateParam).some(value => String(value) === '##code##')) { + templateParam.code = '##code##'; + } + + return { + databaseUrl, + tenantId, + accessKeyId, + accessKeySecret, + signName, + templateCode, + displayName: envString(env, 'ALIYUN_PNVS_DISPLAY_NAME', '阿里云短信认证'), + endpoint: envString(env, 'ALIYUN_PNVS_ENDPOINT', 'https://dypnsapi.aliyuncs.com'), + regionId: envString(env, 'ALIYUN_PNVS_REGION_ID', 'cn-hangzhou'), + countryCode: envString(env, 'ALIYUN_PNVS_COUNTRY_CODE', '86'), + codeType: envNumberString(env, 'ALIYUN_PNVS_CODE_TYPE', '1'), + codeLength: envNumberString(env, 'ALIYUN_PNVS_CODE_LENGTH', '6'), + validTime, + interval, + duplicatePolicy: envNumberString(env, 'ALIYUN_PNVS_DUPLICATE_POLICY', '1'), + schemeName: envString(env, 'ALIYUN_PNVS_SCHEME_NAME'), + templateParam, + }; +} + +function publicConfig(config) { + return { + signName: config.signName, + templateCode: config.templateCode, + endpoint: config.endpoint, + regionId: config.regionId, + countryCode: config.countryCode, + templateParam: config.templateParam, + codeType: config.codeType, + codeLength: config.codeLength, + validTime: config.validTime, + interval: config.interval, + duplicatePolicy: config.duplicatePolicy, + ...(config.schemeName ? { schemeName: config.schemeName } : {}), + secretRef: 'app_private.tenant_secrets:sms:aliyun-pnvs', + }; +} + +async function configureAliyunPnvsProvider(inputConfig, options = {}) { + const config = inputConfig?.databaseUrl ? inputConfig : buildConfig(options.env || process.env); + const configPublic = publicConfig(config); + if (options.dryRun) { + return { + tenantId: config.tenantId, + provider: 'aliyun-pnvs', + configPublic, + secretScope: 'sms', + secretKey: 'aliyun-pnvs', + }; + } + + const pool = new pg.Pool({ connectionString: config.databaseUrl, max: 1 }); + try { + await pool.query('begin'); + await pool.query( + ` + insert into app_private.tenant_secrets ( + tenant_id, secret_scope, secret_key, provider, secret_json, last_rotated_at + ) + values ($1::uuid, 'sms', 'aliyun-pnvs', 'aliyun-pnvs', $2::jsonb, now()) + on conflict (tenant_id, secret_scope, secret_key) + do update set provider = excluded.provider, + secret_json = excluded.secret_json, + last_rotated_at = now(), + updated_at = now() + `, + [ + config.tenantId, + JSON.stringify({ + accessKeyId: config.accessKeyId, + accessKeySecret: config.accessKeySecret, + }), + ], + ); + const providerResult = await pool.query( + ` + insert into public.tenant_auth_providers ( + tenant_id, provider, status, display_name, config_public + ) + values ($1::uuid, 'aliyun-pnvs', 'active', $2, $3::jsonb) + on conflict (tenant_id, provider) + do update set status = excluded.status, + display_name = excluded.display_name, + config_public = excluded.config_public, + updated_at = now() + returning tenant_id as "tenantId", provider, status, config_public as "configPublic" + `, + [config.tenantId, config.displayName, JSON.stringify(configPublic)], + ); + await pool.query('commit'); + return providerResult.rows[0]; + } catch (error) { + await pool.query('rollback').catch(() => undefined); + throw error; + } finally { + await pool.end(); + } +} + +async function main() { + try { + const dryRun = process.argv.includes('--dry-run'); + const result = await configureAliyunPnvsProvider(null, { dryRun }); + console.log(JSON.stringify({ + ok: true, + dryRun, + tenantId: result.tenantId, + provider: result.provider, + status: result.status || 'active', + secretRef: result.configPublic?.secretRef || 'app_private.tenant_secrets:sms:aliyun-pnvs', + }, null, 2)); + } catch (error) { + console.error(error.message); + console.error(` +Required example: + DATABASE_URL=postgresql://tiku_app:***@127.0.0.1:54322/postgres + PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 + ALIYUN_ACCESS_KEY_ID= + ALIYUN_ACCESS_KEY_SECRET= + ALIYUN_PNVS_SIGN_NAME= + ALIYUN_PNVS_TEMPLATE_CODE= + +Optional: + ALIYUN_PNVS_TEMPLATE_PARAM='{"code":"##code##","min":"5"}' + ALIYUN_PNVS_VALID_TIME=300 + ALIYUN_PNVS_INTERVAL=60 +`); + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { + await main(); +} + +export { buildConfig, configureAliyunPnvsProvider, publicConfig }; diff --git a/scripts/deploy/README.md b/scripts/deploy/README.md index 0b3a7cfe..4b4930b9 100644 --- a/scripts/deploy/README.md +++ b/scripts/deploy/README.md @@ -156,6 +156,28 @@ Taro H5 构建存在 webpack asset size warning,这是前端包体优化事项 - 线上 H5 公开配置文件在 `/srv/tiku-saas/www/*/runtime-config.json`,密钥只允许放 `supabasePublishableKey` 这类公开 key。 - 短信登录推荐生产使用 `AUTH_SMS_PROVIDER=aliyun-pnvs`。阿里云 AccessKey/Secret 只写入 `app_private.tenant_secrets(secret_scope='sms', secret_key='aliyun-pnvs')`,不要写进 `/etc/tiku-saas/api.env` 或 H5 `runtime-config.json`。 +配置 PNVS provider 推荐用仓库脚本写入数据库,避免手写 SQL 时把密钥打进命令历史。生产环境建议临时关闭 shell history,再用 `read -s` 输入 AccessKeySecret: + +```bash +cd /opt/tiku-saas/repo +set -a +source /etc/tiku-saas/api.env +set +a +set +o history +read -r -p 'Aliyun AccessKeyId: ' ALIYUN_ACCESS_KEY_ID +read -r -s -p 'Aliyun AccessKeySecret: ' ALIYUN_ACCESS_KEY_SECRET; echo +read -r -p 'PNVS SignName: ' ALIYUN_PNVS_SIGN_NAME +read -r -p 'PNVS TemplateCode: ' ALIYUN_PNVS_TEMPLATE_CODE +PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 \ +ALIYUN_ACCESS_KEY_ID="$ALIYUN_ACCESS_KEY_ID" \ +ALIYUN_ACCESS_KEY_SECRET="$ALIYUN_ACCESS_KEY_SECRET" \ +ALIYUN_PNVS_SIGN_NAME="$ALIYUN_PNVS_SIGN_NAME" \ +ALIYUN_PNVS_TEMPLATE_CODE="$ALIYUN_PNVS_TEMPLATE_CODE" \ +npm run configure:aliyun-pnvs +unset ALIYUN_ACCESS_KEY_ID ALIYUN_ACCESS_KEY_SECRET ALIYUN_PNVS_SIGN_NAME ALIYUN_PNVS_TEMPLATE_CODE +set -o history +``` + 接管服务器时先做只读检查: ```bash