chore: add PNVS provider configuration script

This commit is contained in:
Codex
2026-07-03 23:16:58 +08:00
parent 7eb5ceca62
commit f8f83168b4
4 changed files with 253 additions and 1 deletions

View File

@@ -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",

View File

@@ -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');

View File

@@ -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=<real-access-key-id>
ALIYUN_ACCESS_KEY_SECRET=<real-access-key-secret>
ALIYUN_PNVS_SIGN_NAME=<sms-sign-name>
ALIYUN_PNVS_TEMPLATE_CODE=<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 };

View File

@@ -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