新增: - installer/lock.go: lock 文件检测/创建/删除(原子写入);ErrLockExists sentinel - installer/bootstrap.go: Detect() 启动期判定 ModeSetup/ModeNormal, 损坏自愈(自动备份 db.corrupt-<ts> + 删 lock + 回退安装模式) - middleware/installguard.go: 安装模式白名单(/setup /api/setup /health /metrics), 中间件顺序: SecurityHeaders → InstallGuard → RateLimit → ...(避免限流计数污染) - controller/setup.go: GET /api/setup/status + /api/setup/prefill + POST /api/setup; token 校验用常量时间比较防计时攻击;安装后端点永久 404 - router/setup.html: Vue3 + axios CDN,4 字段表单 + 动态音色行, 调用 /api/setup/prefill 自动从旧 env 预填(API key 永不预填) 改造: - main.go: 启动期 store.Open → installer.Detect → 注入 setup 控制器 - controller/tts.go: 安装模式双保险(/v1/audio/speech 即便漏过中间件也 503) - controller/health.go (via dto): 加 installed + mode 字段,部署探针可识别 - router/router.go: 挂载 /setup + /api/setup/*,根路径安装模式跳 /setup - setting/config.go: InitSetupToken 读 TTS_ADMIN_KEY 或随机生成 32 字符 hex (公网部署建议设 env;空时启动日志一次性打印) 删除: - router/dashboard.go: 内容并入 router/router.go .gitignore: tts.db / tts.db-* / installed.lock 加入(运行时产物) 验收(端到端跑通,见 scripts/ 已删除): 1. 删 lock → /v1/audio/speech 503 + /setup 200 HTML 2. POST /api/setup 错 token → 401;对 token → 200 + 写 lock + 写 db 3. 重复 POST → 404(端点永久关闭) 4. /health: 安装期 installed=false,装后 installed=true 5. 损坏 db header: 自动备份 tts.db.corrupt-<ts> + 删 lock + 回安装模式 测试: - installer: 6 个 test, 覆盖 lock 生命周期 + Detect 三态 + 损坏自愈 - middleware: 3 个 test, 覆盖白名单/denylist/自定义 - store(M0): 仍 27/27 绿, 70.7% 覆盖率 二进制大小: 9.77 → 15.87 MB (+6MB,大部分是 setup.html embed + 新包)
239 lines
12 KiB
HTML
239 lines
12 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>火山 TTS · 安装引导</title>
|
||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #0b0f1a;
|
||
--bg-card: #131826;
|
||
--bg-elev: #1a2138;
|
||
--border: #232b42;
|
||
--text: #e4e7ee;
|
||
--text-dim: #8a92a6;
|
||
--text-mute: #5a6275;
|
||
--accent: #00d4ff;
|
||
--accent-2: #7b2ff7;
|
||
--success: #22c55e;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: var(--bg); color: var(--text);
|
||
min-height: 100vh; padding: 32px 24px; font-size: 14px;
|
||
background-image:
|
||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
|
||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
|
||
}
|
||
#app { max-width: 760px; margin: 0 auto; }
|
||
|
||
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
|
||
.logo {
|
||
width: 48px; height: 48px; border-radius: 12px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 20px; color: #fff; box-shadow: 0 4px 20px rgba(0,212,255,0.3);
|
||
}
|
||
.header h1 { font-size: 22px; font-weight: 600; }
|
||
.header .sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||
|
||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
|
||
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||
.card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 16px; }
|
||
|
||
.field { margin-bottom: 14px; }
|
||
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
|
||
.field label .req { color: var(--danger); margin-left: 4px; }
|
||
.field input, .field select {
|
||
width: 100%; background: var(--bg-elev); border: 1px solid var(--border);
|
||
color: var(--text); padding: 10px 12px; border-radius: 8px; font-size: 13px;
|
||
font-family: inherit; transition: border-color 0.2s;
|
||
}
|
||
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
|
||
.field .hint { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
|
||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
|
||
.voice-row { display: grid; grid-template-columns: 1fr 1fr 1.4fr 1.2fr 32px; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||
.voice-row input { font-size: 12px; padding: 8px 10px; }
|
||
.voice-row .rm { background: transparent; border: 1px solid var(--border); color: var(--danger); cursor: pointer; padding: 6px; border-radius: 6px; font-size: 14px; }
|
||
.voice-row .rm:hover { border-color: var(--danger); }
|
||
|
||
.btn {
|
||
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
|
||
padding: 10px 18px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||
transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
|
||
font-family: inherit;
|
||
}
|
||
.btn:hover { border-color: var(--accent); }
|
||
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
|
||
.btn-primary:hover { background: #33ddff; }
|
||
.btn-primary:disabled { background: var(--text-mute); border-color: var(--text-mute); cursor: not-allowed; color: var(--bg); }
|
||
.btn-add { background: transparent; border: 1px dashed var(--border); color: var(--text-dim); width: 100%; justify-content: center; padding: 10px; }
|
||
.btn-add:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; }
|
||
.err { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||
.ok { color: var(--success); font-size: 12px; margin-top: 8px; }
|
||
|
||
.banner { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 8px; padding: 10px 14px; color: var(--warning); font-size: 12px; margin-bottom: 16px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="header">
|
||
<div class="logo">TTS</div>
|
||
<div>
|
||
<h1>火山 TTS 聚合平台 · 首次安装</h1>
|
||
<div class="sub">填写以下信息完成初始化。token 来自启动日志或环境变量 <code>TTS_ADMIN_KEY</code>。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">火山引擎 TTS 凭证</div>
|
||
<div class="card-desc">从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。</div>
|
||
<div class="field">
|
||
<label>API Key <span class="req">*</span></label>
|
||
<input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off">
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field">
|
||
<label>默认资源 ID <span class="req">*</span></label>
|
||
<input v-model="form.default_resource_id" placeholder="volc.megatts.default">
|
||
<div class="hint">音色所属的计费资源 ID;volc.megatts.icl 用于复刻音色。</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>默认音色 <span class="req">*</span></label>
|
||
<input v-model="form.default_speaker" placeholder="zh_female_qingxin">
|
||
<div class="hint">未传 voice 字段时使用。</div>
|
||
</div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field">
|
||
<label>默认输出格式</label>
|
||
<select v-model="form.default_format">
|
||
<option value="mp3">mp3</option>
|
||
<option value="wav">wav</option>
|
||
<option value="opus">opus (ogg_opus)</option>
|
||
<option value="pcm">pcm</option>
|
||
<option value="aac">aac</option>
|
||
<option value="flac">flac</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>采样率 (Hz)</label>
|
||
<input type="number" v-model.number="form.sample_rate" placeholder="24000">
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>初始化 Token <span class="req">*</span></label>
|
||
<input type="password" v-model="form.token" placeholder="启动日志里打印的一次性 token,或 TTS_ADMIN_KEY" autocomplete="off">
|
||
<div class="hint">公网部署建议设置 TTS_ADMIN_KEY;否则每次启动会重新生成。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
|
||
<div class="card-desc">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div>
|
||
<div v-for="(v, i) in form.voices" :key="i" class="voice-row">
|
||
<input v-model="v.name" placeholder="对外名 qian">
|
||
<input v-model="v.speaker" placeholder="S_xxx">
|
||
<input v-model="v.resource_id" placeholder="volc.megatts.icl">
|
||
<input v-model="v.model" placeholder="seed-tts-2.0-standard">
|
||
<button class="rm" @click="removeVoice(i)" :disabled="form.voices.length <= 1" title="删除">×</button>
|
||
</div>
|
||
<button class="btn btn-add" @click="addVoice">+ 添加音色</button>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<div style="color: var(--text-mute); font-size: 12px;">
|
||
提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。
|
||
</div>
|
||
<button class="btn btn-primary" @click="submit" :disabled="submitting">
|
||
{{ submitting ? '安装中...' : '完成安装' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, onMounted } = Vue;
|
||
createApp({
|
||
setup() {
|
||
const form = ref({
|
||
api_key: '',
|
||
default_resource_id: 'volc.megatts.default',
|
||
default_speaker: '',
|
||
default_format: 'mp3',
|
||
sample_rate: 24000,
|
||
token: '',
|
||
voices: [{ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' }],
|
||
});
|
||
const errorMsg = ref('');
|
||
const submitting = ref(false);
|
||
|
||
const addVoice = () => {
|
||
form.value.voices.push({ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' });
|
||
};
|
||
const removeVoice = (i) => {
|
||
if (form.value.voices.length > 1) form.value.voices.splice(i, 1);
|
||
};
|
||
|
||
onMounted(async () => {
|
||
// 预填旧 env 变量(只填非空字段;api_key 永不预填,需手动输入)
|
||
try {
|
||
const r = await axios.get('/api/setup/prefill');
|
||
const s = r.data.settings || {};
|
||
if (s.default_resource_id) form.value.default_resource_id = s.default_resource_id;
|
||
if (s.default_speaker) form.value.default_speaker = s.default_speaker;
|
||
if (s.default_format) form.value.default_format = s.default_format;
|
||
if (s.sample_rate) form.value.sample_rate = parseInt(s.sample_rate, 10) || 24000;
|
||
} catch (e) {
|
||
console.log('prefill skipped:', e.message);
|
||
}
|
||
});
|
||
|
||
const submit = async () => {
|
||
errorMsg.value = '';
|
||
if (!form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) {
|
||
errorMsg.value = '请填写所有必填项(API Key / 资源 ID / 默认音色 / Token)';
|
||
return;
|
||
}
|
||
if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) {
|
||
errorMsg.value = '音色列表不完整(每条都需要 name + speaker + resource_id)';
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
try {
|
||
const r = await axios.post('/api/setup', {
|
||
token: form.value.token,
|
||
settings: {
|
||
api_key: form.value.api_key,
|
||
default_resource_id: form.value.default_resource_id,
|
||
default_speaker: form.value.default_speaker,
|
||
default_format: form.value.default_format,
|
||
sample_rate: String(form.value.sample_rate || 24000),
|
||
},
|
||
voices: form.value.voices,
|
||
});
|
||
// 成功:跳 /admin(M2 才会有,先跳 /dashboard)
|
||
window.location.href = r.data.redirect || '/dashboard';
|
||
} catch (e) {
|
||
const msg = e.response?.data?.error?.message || e.message;
|
||
errorMsg.value = `安装失败: ${msg}`;
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
return { form, errorMsg, submitting, addVoice, removeVoice, submit };
|
||
},
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|