优化数据库初始化失败处理
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-30 19:40:57 +08:00
parent 29cfebd620
commit 0b10489728
7 changed files with 175 additions and 32 deletions

View File

@ -89,6 +89,8 @@ const lang = {
"db_name_required": "请输入数据库名",
"db_init_success": "数据库连接成功,数据库和数据表已初始化",
"db_init_failed": "数据库初始化失败,请检查连接信息和数据库权限",
"db_init_retry_tip": "连接失败不会跳转登录页,请修正信息后重新点击下一步。",
"db_request_failed": "数据库初始化请求失败,请检查 Smail 服务状态后重试",
"pg_user_ph": "请输入 PostgreSQL 用户名",
"pg_password_ph": "请输入 PostgreSQL 密码",
"pg_dsn": "PostgreSQL DSN",

View File

@ -116,6 +116,17 @@
line-height: 1.7;
}
.database-error {
margin: 0 0 18px;
}
.database-retry-tip {
margin: -4px 0 8px 120px;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.database-inline-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;

View File

@ -52,6 +52,16 @@ export function usePage() {
const fullscreenLoading = ref(false)
const databaseLoading = ref(false)
const databaseError = ref("")
const getDatabaseErrorMessage = (error) => {
const message = typeof error?.message === "string" ? error.message.trim() : ""
if (message && !/^network error$/i.test(message) && !/^timeout of \d+ms exceeded$/i.test(message) && !/^request failed with status code \d+$/i.test(message)) {
return message
}
return lang.db_request_failed
}
const dnsChecking = ref(false)
@ -109,22 +119,19 @@ export function usePage() {
})
}
const getPassword = () => {
http.post("/api/v1/setup", {"action": "get", "step": "password"}).then((res) => {
if (res.errorNo !== 0) {
ElMessage.error(res.errorMsg)
} else {
adminSettings.hadSeted = res.data !== ""
if (adminSettings.hadSeted) {
adminSettings.account = res.data
adminSettings.password = "*******"
adminSettings.password2 = "*******"
}
}
})
}
const getPassword = () => http.post("/api/v1/setup", {"action": "get", "step": "password"}).then((res) => {
if (res.errorNo !== 0) {
throw new Error(res.errorMsg || lang.db_init_failed)
}
adminSettings.hadSeted = res.data != ""
if (adminSettings.hadSeted) {
adminSettings.account = res.data
adminSettings.password = "*******"
adminSettings.password2 = "*******"
}
})
const getDbConfig = () => {
http.post("/api/v1/setup", {"action": "get", "step": "database"}).then((res) => {
if (res.errorNo !== 0) {
@ -155,6 +162,11 @@ export function usePage() {
}
const setDbConfig = () => {
if (databaseLoading.value) {
return
}
databaseError.value = ""
if (dbSettings.type === "sqlite" && !dbSettings.dsn) {
dbSettings.dsn = "./config/smail.db";
}
@ -173,6 +185,10 @@ export function usePage() {
ElMessage.error(lang.db_port_required)
return
}
if (!/^\d+$/.test(dbSettings.port.trim()) || Number(dbSettings.port) < 1 || Number(dbSettings.port) > 65535) {
ElMessage.error("数据库端口必须是 1 到 65535 之间的数字")
return
}
if (!dbSettings.name.trim()) {
ElMessage.error(lang.db_name_required)
return
@ -198,20 +214,27 @@ export function usePage() {
http.post("/api/v1/setup", payload).then((res) => {
if (res.errorNo !== 0) {
ElMessage.error(res.errorMsg || lang.db_init_failed)
databaseError.value = res.errorMsg || lang.db_init_failed
ElMessage.error(databaseError.value)
return
}
ElMessage.success(lang.db_init_success)
active.value++;
getPassword();
}).catch(() => {
ElMessage.error(lang.db_init_failed)
getPassword().then(() => {
ElMessage.success(lang.db_init_success)
active.value++
}).catch((error) => {
databaseError.value = getDatabaseErrorMessage(error)
ElMessage.error(databaseError.value)
})
}).catch((error) => {
databaseError.value = getDatabaseErrorMessage(error)
ElMessage.error(databaseError.value)
}).finally(() => {
databaseLoading.value = false
})
}
const handleDbTypeChange = (type) => {
databaseError.value = ""
dbSettings.dsn = ""
dbSettings.password = ""
if (type === "mysql" || type === "postgres") {
@ -259,6 +282,10 @@ export function usePage() {
}
}
const clearDatabaseError = () => {
databaseError.value = ""
}
const formatDNSRecordTTL = (ttl) => {
if (ttl >= 3600 && ttl % 3600 === 0) {
return `${ttl / 3600}${lang.dns_ttl_hour}`
@ -413,6 +440,7 @@ export function usePage() {
adminSettings,
dbSettings,
databaseLoading,
databaseError,
domainSettings,
sslSettings,
active,
@ -428,6 +456,7 @@ export function usePage() {
getDomainConfig,
setDbConfig,
handleDbTypeChange,
clearDatabaseError,
getDNSConfig,
formatDNSRecordTTL,
copyDNSValue,

View File

@ -33,40 +33,66 @@
</el-select>
</el-form-item>
<el-alert
v-if="databaseError"
:title="databaseError"
type="error"
show-icon
:closable="false"
class="database-error"
/>
<div v-if="dbSettings.type === 'mysql' || dbSettings.type === 'postgres'" class="mysql-fields">
<div class="database-hint">{{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }}</div>
<div class="database-hint">
{{ dbSettings.type === 'mysql' ? lang.mysql_create_desc : lang.postgres_create_desc }}
</div>
<el-form-item :label="lang.db_user">
<el-input v-model="dbSettings.user" :placeholder="dbSettings.type === 'mysql' ? lang.db_user_ph : lang.pg_user_ph"
<el-input v-model="dbSettings.user" @input="clearDatabaseError"
:disabled="databaseLoading"
:placeholder="dbSettings.type === 'mysql' ? lang.db_user_ph : lang.pg_user_ph"
autocomplete="username"></el-input>
</el-form-item>
<div class="database-inline-fields">
<el-form-item :label="lang.db_host" class="database-host-field">
<el-input v-model="dbSettings.host" :placeholder="lang.db_host_ph"></el-input>
<el-input v-model="dbSettings.host" @input="clearDatabaseError"
:disabled="databaseLoading"
:placeholder="lang.db_host_ph"></el-input>
</el-form-item>
<el-form-item :label="lang.db_port" class="database-port-field">
<el-input v-model="dbSettings.port" :placeholder="lang.db_port_ph"
<el-input v-model="dbSettings.port" @input="clearDatabaseError"
:disabled="databaseLoading"
:placeholder="lang.db_port_ph"
inputmode="numeric"></el-input>
</el-form-item>
</div>
<el-form-item :label="lang.db_name">
<el-input v-model="dbSettings.name" :placeholder="lang.db_name_ph"></el-input>
<el-input v-model="dbSettings.name" @input="clearDatabaseError"
:disabled="databaseLoading"
:placeholder="lang.db_name_ph"></el-input>
</el-form-item>
<el-form-item :label="lang.db_password">
<el-input v-model="dbSettings.password" type="password"
<el-input v-model="dbSettings.password" @input="clearDatabaseError"
:disabled="databaseLoading"
type="password"
:placeholder="dbSettings.type === 'mysql' ? lang.db_password_ph : lang.pg_password_ph" autocomplete="new-password"
show-password></el-input>
<div class="database-field-tip">{{ lang.db_password_desc }}</div>
</el-form-item>
</div>
<div class="database-retry-tip">{{ lang.db_init_retry_tip }}</div>
<el-form-item :label="lang.sqlite_db_path" v-if="dbSettings.type === 'sqlite'">
<el-input v-model="dbSettings.dsn" placeholder="请输入 SQLite 数据文件路径"></el-input>
<el-input v-model="dbSettings.dsn" @input="clearDatabaseError"
:disabled="databaseLoading"
placeholder="请输入 SQLite 数据文件路径"></el-input>
</el-form-item>
</el-form>
</div>
@ -308,7 +334,8 @@
<el-button v-if="active > 0" id="previous" @click="previous">
{{ lang.previous }}
</el-button>
<el-button :loading="databaseLoading" :element-loading-text="waitDesc"
<el-button :loading="databaseLoading" :disabled="databaseLoading"
:element-loading-text="waitDesc"
v-loading.fullscreen.lock="fullscreenLoading" id="next"
@click="next">
{{ dnsChecking ? lang.ssl_verify : lang.next }}
@ -326,6 +353,7 @@ const {
lang,
dbSettings,
databaseLoading,
databaseError,
adminSettings,
domainSettings,
addDomain,
@ -338,6 +366,7 @@ const {
next,
previous,
handleDbTypeChange,
clearDatabaseError,
formatDNSRecordTTL,
copyDNSValue,
fullscreenLoading

View File

@ -46,7 +46,8 @@ type sslResponse struct {
}
func writeSetupError(ctx *context.Context, w http.ResponseWriter, err error) {
log.WithContext(ctx).WithError(err).Error("setup request failed")
// 保留底层错误原因,便于定位数据库驱动、网络和权限问题;响应仍只返回安全的业务提示。
log.WithContext(ctx).WithError(err).Errorf("setup request failed: %v", err)
message := setup.DatabaseSetupErrorMessage(err)
if message == "" {
message = "\u521d\u59cb\u5316\u64cd\u4f5c\u5931\u8d25"

View File

@ -212,6 +212,10 @@ func SetDatabaseSettingsWithOptions(ctx *context.Context, settings DatabaseSetti
return errors.New("dbtype error")
}
// 兼容未携带模式字段的旧调用:只要提交了结构化数据库字段,就按字段模式处理。
if strings.TrimSpace(settings.Mode) == "" && (dbType == config.DBTypeMySQL || dbType == config.DBTypePostgres) {
settings.Mode = "fields"
}
dbDSN := strings.TrimSpace(settings.DSN)
if settings.Mode == "fields" {
switch dbType {
@ -278,11 +282,17 @@ func buildPostgresDSN(settings DatabaseSettings, previousDSN string) (string, er
if user == "" {
return "", errors.New("PostgreSQL 用户不能为空")
}
if !validDatabaseField(user, 128) {
return "", errors.New("PostgreSQL 用户格式无效")
}
host := strings.TrimSpace(settings.Host)
if host == "" {
host = "127.0.0.1"
}
host = normalizeDatabaseHost(host)
if !validDatabaseHost(host) {
return "", errors.New("PostgreSQL 主机地址格式无效")
}
portText := strings.TrimSpace(settings.Port)
if portText == "" {
portText = "5432"
@ -392,11 +402,17 @@ func buildMySQLDSN(settings DatabaseSettings, previousDSN string) (string, error
if user == "" {
return "", errors.New("MySQL 用户不能为空")
}
if !validDatabaseField(user, 128) {
return "", errors.New("MySQL 用户格式无效")
}
host := strings.TrimSpace(settings.Host)
if host == "" {
host = "127.0.0.1"
}
host = normalizeMySQLHost(host)
if !validDatabaseHost(host) {
return "", errors.New("MySQL 主机地址格式无效")
}
portText := strings.TrimSpace(settings.Port)
if portText == "" {
portText = "3306"
@ -424,7 +440,8 @@ func buildMySQLDSN(settings DatabaseSettings, previousDSN string) (string, error
dsnConfig.Addr = net.JoinHostPort(host, strconv.Itoa(port))
dsnConfig.DBName = databaseName
dsnConfig.ParseTime = true
dsnConfig.Loc = time.Local
// 使用跨平台稳定的时区,避免 Windows 交叉编译后的 CST 在 Linux 上无法解析。
dsnConfig.Loc = time.UTC
dsnConfig.Params = map[string]string{"charset": "utf8mb4"}
return dsnConfig.FormatDSN(), nil
}
@ -460,6 +477,44 @@ func normalizeMySQLHost(host string) string {
return host
}
func validDatabaseField(value string, maxLength int) bool {
if value == "" || len(value) > maxLength {
return false
}
for _, char := range value {
if char == '\r' || char == '\n' || char == '\t' || char == ' ' {
return false
}
}
return true
}
func validDatabaseHost(host string) bool {
if host == "" || len(host) > 253 {
return false
}
if strings.ContainsAny(host, "\r\n\t /\\?@#") {
return false
}
if net.ParseIP(host) != nil {
return true
}
if strings.Contains(host, ":") {
return false
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
for _, char := range label {
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' {
return false
}
}
}
return true
}
func validMySQLIdentifier(value string) bool {
if len(value) == 0 || len(value) > 64 {
return false

View File

@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/lib/pq"
@ -75,6 +76,9 @@ func TestBuildMySQLDSNUsesStructuredFields(t *testing.T) {
if parsed.DBName != "smail_1" || !parsed.ParseTime {
t.Fatalf("unexpected database options: name=%q parseTime=%v", parsed.DBName, parsed.ParseTime)
}
if parsed.Loc != time.UTC {
t.Fatalf("unexpected database location: %v", parsed.Loc)
}
if !strings.Contains(dsn, "charset=utf8mb4") {
t.Fatalf("charset option is missing from DSN: %q", dsn)
}
@ -210,6 +214,7 @@ func TestBuildMySQLDSNRejectsInvalidInput(t *testing.T) {
{User: "root", Port: "0", Name: "smail"},
{User: "root", Port: "65536", Name: "smail"},
{User: "root", Port: "3306", Name: "smail-test"},
{User: "root", Host: "mysql server", Port: "3306", Name: "smail"},
{Port: "3306", Name: "smail"},
}
for _, settings := range tests {
@ -219,6 +224,17 @@ func TestBuildMySQLDSNRejectsInvalidInput(t *testing.T) {
}
}
func TestBuildMySQLDSNAcceptsDatabaseServiceHost(t *testing.T) {
if _, err := buildMySQLDSN(DatabaseSettings{
User: "root",
Host: "mysql",
Port: "3306",
Name: "smail",
}, ""); err != nil {
t.Fatalf("database service host was rejected: %v", err)
}
}
func TestEnsureMySQLDatabaseSkipsCreationWhenDatabaseExists(t *testing.T) {
target := &fakeSetupDB{}
openCalls := 0