feat(settings): 运行配置中心——.env 业务参数统一到后台页面维护
CI / backend (push) Canceled after 0s

- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退
- 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径
- 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库
- 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志
- 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心
- 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认)
- .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
This commit is contained in:
Logan.Li committed 2026-11-01 06:17:58 +08:00
1 parent b0b611eee2
commit 83a3630404
17 files changed
+1204 -33

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import request from './request'
// 运行配置中心 API(.env 里的业务参数统一到页面维护) // Logan.Li
// 读取全部配置(机密项只返回掩码 + 指纹)
export function getRuntimeConfig() {
return request({ url: '/settings/runtime', method: 'get' })
}
// 批量保存:values 为 { 配置键: 值 },空字符串表示清除并回退 .env
export function updateRuntimeConfig(values) {
return request({ url: '/settings/runtime', method: 'put', data: { values } })
}
// 恢复默认:删除页面值,回退 .env / 注册表默认
export function resetRuntimeConfig(keys) {
return request({ url: '/settings/runtime/reset', method: 'post', data: { keys } })
}
+3
View File
@@ -70,6 +70,9 @@
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>系统设置
</el-menu-item>
<el-menu-item index="/settings/runtime">
<el-icon><SetUp /></el-icon>运行配置
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/integration">
+6
View File
@@ -89,6 +89,12 @@ const routes = [
component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置', icon: 'Setting' },
},
{
path: 'settings/runtime',
name: 'RuntimeConfig',
component: () => import('@/views/settings/Runtime.vue'),
meta: { title: '运行配置', icon: 'SetUp' },
},
],
},
{
+301
View File
@@ -0,0 +1,301 @@
<template>
<div class="runtime-page">
<!-- 说明与统计 -->
<el-card shadow="never" class="intro-card">
<template #header>
<div class="card-header">
<span class="title">运行配置</span>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
</template>
<el-alert type="info" :closable="false" show-icon>
<template #title>
<span class="alert-title">.env 只保留启动必需项,其余业务参数在本页维护</span>
</template>
<div class="alert-body">
读取优先级:<b>本页配置</b> → <b>.env</b> → <b>默认值</b>;「恢复默认」即删除本页值回退 .env。
登录令牌密钥、加密主密钥、离线签名种子等<b>引导密钥</b>必须留在 .env(数据库不可用前就要用到,
且入库等于把钥匙锁进被加密的箱子),本页仅只读展示指纹供核对。
</div>
</el-alert>
<div class="summary">
<div class="sum-item"><b>{{ stats.editable }}</b><span>可在页面配置</span></div>
<div class="sum-item"><b>{{ stats.envOnly }}</b><span>仅限 .env</span></div>
<div class="sum-item"><b>{{ stats.fromPage }}</b><span>当前取自本页</span></div>
<div class="sum-item"><b>{{ stats.restart }}</b><span>改动需重启</span></div>
</div>
</el-card>
<!-- 分组配置 -->
<el-card v-for="group in groups" :key="group.name" shadow="never" class="group-card">
<template #header>
<div class="card-header">
<span class="title">{{ group.name }}</span>
<el-tag v-if="groupEnvOnly(group)" size="small" type="info">仅 .env</el-tag>
<el-tag v-else size="small">{{ group.items.length }} 项</el-tag>
</div>
</template>
<el-table :data="group.items" size="small" class="cfg-table">
<el-table-column label="配置项" min-width="330">
<template #default="{ row }">
<div class="item-label">
{{ row.label }}
<el-tag v-if="row.danger" size="small" type="danger">高风险</el-tag>
</div>
<code class="item-key">{{ row.key }}</code>
<div class="item-desc">{{ row.description }}</div>
</template>
</el-table-column>
<el-table-column label="当前值" min-width="240">
<template #default="{ row }">
<span v-if="!row.configured" class="muted">未配置</span>
<el-tag v-else-if="row.kind === 'bool'" size="small" :type="row.value === '1' ? 'danger' : 'info'">
{{ row.value === '1' ? '已开启' : '已关闭' }}
</el-tag>
<code v-else class="val">{{ row.masked }}</code>
<div v-if="row.kind === 'secret' && row.fingerprint" class="hint">
指纹 {{ row.fingerprint }}
</div>
</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">
<el-tag size="small" :type="sourceMeta(row.source).type">
{{ sourceMeta(row.source).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="生效方式" width="110">
<template #default="{ row }">
<span :class="{ muted: !row.requires_restart }">
{{ row.requires_restart ? '需重启' : '立即生效' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="right">
<template #default="{ row }">
<el-tooltip
v-if="!row.editable"
:content="row.read_only_reason"
placement="top"
>
<span class="lock-hint"><el-icon><Lock /></el-icon>只读</span>
</el-tooltip>
<template v-else>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="row.source === 'page'"
link
type="warning"
@click="resetOne(row)"
>恢复默认</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="dialog.visible" :title="`编辑:${dialog.row?.label || ''}`" width="560px">
<template v-if="dialog.row">
<code class="item-key dialog-key">{{ dialog.row.key }}</code>
<p class="item-desc dialog-desc">{{ dialog.row.description }}</p>
<el-form label-width="96px" @submit.prevent>
<el-form-item v-if="dialog.row.kind === 'bool'" label="开关">
<el-switch
v-model="dialog.boolValue"
inline-prompt
active-text="开"
inactive-text="关"
/>
<div v-if="dialog.row.danger" class="hint danger">
高风险:开启后未验签的回调也会被放行,生产模式下保存会被拒绝。
</div>
</el-form-item>
<el-form-item v-else-if="dialog.row.kind === 'list'" label="取值">
<el-input
v-model="dialog.textValue"
type="textarea"
:rows="3"
:placeholder="dialog.row.placeholder"
/>
<div class="hint">多个值用英文逗号分隔</div>
</el-form-item>
<el-form-item v-else :label="dialog.row.kind === 'secret' ? '新值' : '取值'">
<el-input
v-model="dialog.textValue"
:type="dialog.row.kind === 'secret' ? 'password' : 'text'"
:show-password="dialog.row.kind === 'secret'"
:placeholder="dialog.row.kind === 'secret' ? '留空表示清除(回退 .env / 未配置)' : dialog.row.placeholder"
/>
<div v-if="dialog.row.kind === 'secret'" class="hint">
当前指纹 {{ dialog.row.fingerprint || '—' }},保存后不可查看明文
</div>
</el-form-item>
</el-form>
<el-alert
v-if="dialog.row.requires_restart"
type="warning"
:closable="false"
show-icon
title="该项改动需重启容器后生效"
/>
</template>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Lock } from '@element-plus/icons-vue'
import { getRuntimeConfig, updateRuntimeConfig, resetRuntimeConfig } from '@/api/runtime'
const loading = ref(false)
const saving = ref(false)
const groups = ref([])
const bootstrapKeys = ref([])
const dialog = reactive({
visible: false,
row: null,
textValue: '',
boolValue: false,
})
const sourceMap = {
page: { label: '本页配置', type: 'success' },
env: { label: '.env', type: 'info' },
default: { label: '默认值', type: 'info' },
missing: { label: '未配置', type: 'warning' },
}
function sourceMeta(source) {
return sourceMap[source] || { label: source || '—', type: 'info' }
}
function groupEnvOnly(group) {
return group.items.length > 0 && group.items.every((it) => !it.editable)
}
const stats = computed(() => {
const all = groups.value.flatMap((g) => g.items)
return {
editable: all.filter((it) => it.editable).length,
envOnly: all.filter((it) => !it.editable).length,
fromPage: all.filter((it) => it.source === 'page').length,
restart: all.filter((it) => it.requires_restart).length,
}
})
async function load() {
loading.value = true
try {
const res = await getRuntimeConfig()
groups.value = res.data?.groups || []
bootstrapKeys.value = res.data?.bootstrap_keys || []
} finally {
loading.value = false
}
}
function openEdit(row) {
dialog.row = row
dialog.textValue = row.kind === 'secret' ? '' : row.value || ''
dialog.boolValue = row.value === '1'
dialog.visible = true
}
async function save() {
const row = dialog.row
if (!row) return
let value
if (row.kind === 'bool') {
value = dialog.boolValue ? '1' : '0'
if (row.danger && dialog.boolValue) {
try {
await ElMessageBox.confirm(
'开启后任何未验签的支付回调都会被当作已付款处理,仅限本地调试。确认继续?',
'高风险操作',
{ type: 'warning', confirmButtonText: '确认开启', cancelButtonText: '取消' },
)
} catch {
return
}
}
} else {
value = (dialog.textValue || '').trim()
}
saving.value = true
try {
const res = await updateRuntimeConfig({ [row.key]: value })
applyOverview(res.data)
dialog.visible = false
ElMessage.success('已保存')
} finally {
saving.value = false
}
}
async function resetOne(row) {
try {
await ElMessageBox.confirm(
`将删除本页保存的「${row.label}」,回退到 .env 或默认值。确认恢复?`,
'恢复默认',
{ type: 'warning', confirmButtonText: '恢复', cancelButtonText: '取消' },
)
} catch {
return
}
const res = await resetRuntimeConfig([row.key])
applyOverview(res.data)
ElMessage.success('已恢复 .env / 默认值')
}
function applyOverview(data) {
if (!data) return
groups.value = data.groups || []
bootstrapKeys.value = data.bootstrap_keys || []
}
onMounted(load)
</script>
<style scoped>
.runtime-page { display: flex; flex-direction: column; gap: 16px; }
.card-header { display: flex; align-items: center; gap: 8px; }
.card-header .title { font-size: 15px; font-weight: 600; color: #303133; }
.card-header > :last-child { margin-left: auto; }
.alert-title { font-weight: 600; }
.alert-body { font-size: 12px; line-height: 1.7; color: #606266; }
.summary { display: flex; gap: 32px; margin-top: 16px; flex-wrap: wrap; }
.sum-item { display: flex; flex-direction: column; }
.sum-item b { font-size: 20px; color: #303133; }
.sum-item span { font-size: 12px; color: #909399; }
.item-label { font-size: 13px; font-weight: 600; color: #303133; display: flex; align-items: center; gap: 6px; }
.item-key { font-size: 11px; color: #909399; font-family: ui-monospace, Menlo, Consolas, monospace; }
.item-desc { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 2px; }
.dialog-desc { margin: 4px 0 16px; }
.dialog-key { display: block; margin-top: 4px; }
.val { font-size: 12px; color: #303133; word-break: break-all; }
.muted { color: #c0c4cc; font-size: 12px; }
.hint { font-size: 11px; color: #909399; line-height: 1.6; margin-top: 2px; }
.hint.danger { color: #e6a23c; }
.lock-hint { font-size: 12px; color: #c0c4cc; display: inline-flex; align-items: center; gap: 4px; }
.cfg-table :deep(.el-table__cell) { vertical-align: top; }
</style>