forked from wangziqi/gongxue-base
chore: add PNVS provider configuration script
This commit is contained in:
@@ -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",
|
||||
|
||||
40
scripts/configure-aliyun-pnvs-provider-test.js
Normal file
40
scripts/configure-aliyun-pnvs-provider-test.js
Normal 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');
|
||||
189
scripts/configure-aliyun-pnvs-provider.js
Normal file
189
scripts/configure-aliyun-pnvs-provider.js
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user