Files
X-Financial/web/vite.config.js
DESKTOP-72TV0V4\caoxiaozhu ae63766c91 Add vue-router, login/setup flow and backend logging
Refactor frontend to route-based navigation with vue-router, add
system setup and login pages with API integration. Add structured
logging, access-log middleware and startup lifecycle to FastAPI
backend.
2026-05-06 22:23:42 +08:00

698 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const envFile = path.join(rootDir, '.env')
const envExampleFile = path.join(rootDir, '.env.example')
const adminSecretDir = path.join(rootDir, 'server', '.secrets')
const adminSecretFile = path.join(adminSecretDir, 'admin.json')
const adminScryptOptions = { N: 16384, r: 8, p: 1 }
const adminScryptKeyLength = 64
function ensureEnvFile() {
if (fs.existsSync(envFile)) {
return
}
if (fs.existsSync(envExampleFile)) {
fs.copyFileSync(envExampleFile, envFile)
return
}
fs.writeFileSync(envFile, '', 'utf8')
}
function ensureAdminSecretDir() {
fs.mkdirSync(adminSecretDir, { recursive: true })
}
function parseEnv(text) {
const result = {}
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex === -1) {
continue
}
const key = trimmed.slice(0, separatorIndex).trim()
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
function readEnvState() {
ensureEnvFile()
return parseEnv(fs.readFileSync(envFile, 'utf8'))
}
function readAdminSecret() {
if (!fs.existsSync(adminSecretFile)) {
return null
}
try {
const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8'))
if (
payload &&
payload.algorithm === 'scrypt' &&
typeof payload.username === 'string' &&
typeof payload.salt === 'string' &&
typeof payload.derived_key === 'string'
) {
return payload
}
} catch {
return null
}
return null
}
function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) {
return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options)
}
function persistAdminCredentials(payload) {
ensureAdminSecretDir()
const existing = readAdminSecret()
const salt = randomBytes(16).toString('hex')
const now = new Date().toISOString()
const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt)
const record = {
version: 1,
algorithm: 'scrypt',
username: String(payload.admin_username || '').trim(),
salt,
derived_key: derivedKey.toString('hex'),
key_length: adminScryptKeyLength,
...adminScryptOptions,
created_at: existing?.created_at || now,
updated_at: now
}
fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
}
function verifyAdminCredentials(username, password) {
const record = readAdminSecret()
if (!record) {
throw new Error('管理员账号尚未初始化,请先完成初始化配置。')
}
if (record.username !== String(username || '').trim()) {
return false
}
const derivedKey = hashAdminPassword(
String(password || ''),
record.salt,
Number(record.key_length || adminScryptKeyLength),
{
N: Number(record.N || adminScryptOptions.N),
r: Number(record.r || adminScryptOptions.r),
p: Number(record.p || adminScryptOptions.p)
}
)
const storedKey = Buffer.from(record.derived_key, 'hex')
if (storedKey.length !== derivedKey.length) {
return false
}
return timingSafeEqual(storedKey, derivedKey)
}
function normalizeLoopbackHost(host) {
const normalized = String(host || '').trim().toLowerCase()
if (normalized === 'localhost' || normalized === '::1') {
return '127.0.0.1'
}
if (normalized === '::') {
return '0.0.0.0'
}
return normalized
}
function resolveClientHost(host) {
const normalizedHost = normalizeLoopbackHost(host)
if (!normalizedHost || normalizedHost === '0.0.0.0') {
return '127.0.0.1'
}
return String(host || '').trim()
}
function hostsConflict(left, right) {
const normalizedLeft = normalizeLoopbackHost(left)
const normalizedRight = normalizeLoopbackHost(right)
if (!normalizedLeft || !normalizedRight) {
return false
}
if (normalizedLeft === normalizedRight) {
return true
}
return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0'
}
function serializeEnvValue(value) {
const stringValue = value == null ? '' : String(value)
if (stringValue === '') {
return ''
}
if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) {
return stringValue
}
return `'${stringValue.replace(/'/gu, `'\\''`)}'`
}
function updateEnvFile(updates) {
ensureEnvFile()
let content = fs.readFileSync(envFile, 'utf8')
const existingLines = content ? content.split(/\r?\n/u) : []
const remainingKeys = new Set(Object.keys(updates))
const nextLines = existingLines.map((line) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
return line
}
const separatorIndex = line.indexOf('=')
if (separatorIndex === -1) {
return line
}
const key = line.slice(0, separatorIndex).trim()
if (!remainingKeys.has(key)) {
return line
}
remainingKeys.delete(key)
return `${key}=${serializeEnvValue(updates[key])}`
})
for (const key of remainingKeys) {
nextLines.push(`${key}=${serializeEnvValue(updates[key])}`)
}
content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n`
fs.writeFileSync(envFile, content, 'utf8')
}
function buildDatabaseUrl(payload) {
const username = encodeURIComponent(payload.postgres_user)
const password = encodeURIComponent(payload.postgres_password)
return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}`
}
function buildCorsOrigins(payload) {
const webHost = String(payload.web_host || '').trim()
const webPort = String(payload.web_port || '').trim()
const origins = new Set()
const normalizedHost = normalizeLoopbackHost(webHost)
if (normalizedHost === '0.0.0.0') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
origins.add(`http://0.0.0.0:${webPort}`)
} else {
origins.add(`http://${webHost}:${webPort}`)
if (normalizedHost === '127.0.0.1') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
}
}
return JSON.stringify([...origins])
}
function buildApiBaseUrl(payload, currentEnv) {
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
const host = resolveClientHost(payload.server_host)
const port = String(payload.server_port || '').trim()
return `http://${host}:${port}${apiPrefix}`
}
function buildClientEnvUpdates(payload, apiBaseUrl) {
return {
VITE_SETUP_COMPLETED: 'true',
VITE_COMPANY_NAME: String(payload.company_name || '').trim(),
VITE_COMPANY_CODE: String(payload.company_code || '').trim(),
VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(),
VITE_WEB_HOST: String(payload.web_host || '').trim(),
VITE_WEB_PORT: String(payload.web_port || '').trim(),
VITE_SERVER_HOST: String(payload.server_host || '').trim(),
VITE_SERVER_PORT: String(payload.server_port || '').trim(),
VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(),
VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(),
VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(),
VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(),
VITE_REDIS_URL: String(payload.redis_url || '').trim(),
VITE_API_BASE_URL: apiBaseUrl
}
}
function normalizeState(env) {
return {
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true',
company: {
name: env.COMPANY_NAME || '',
code: env.COMPANY_CODE || '',
admin_email: env.ADMIN_EMAIL || ''
},
admin: {
configured: Boolean(readAdminSecret())
},
web: {
host: env.WEB_HOST || '127.0.0.1',
port: Number(env.WEB_PORT || 5173)
},
server: {
host: env.SERVER_HOST || '127.0.0.1',
port: Number(env.SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || 5432),
name: env.POSTGRES_DB || 'x_financial',
username: env.POSTGRES_USER || 'postgres',
password_configured: Boolean(env.POSTGRES_PASSWORD)
},
redis: {
enabled: Boolean(env.REDIS_URL),
url: env.REDIS_URL || ''
}
}
}
async function readJsonBody(req) {
const chunks = []
for await (const chunk of req) {
chunks.push(chunk)
}
const raw = Buffer.concat(chunks).toString('utf8')
return raw ? JSON.parse(raw) : {}
}
function sendJson(res, statusCode, payload) {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify(payload))
}
function validateRuntimePayload(payload) {
const fields = [
['web_host', 'Web Host'],
['server_host', 'Server Host']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const portFields = [
['web_port', 'Web Port'],
['server_port', 'Server Port']
]
for (const [field, label] of portFields) {
const value = Number(payload[field])
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return `${label} 必须在 1 到 65535 之间。`
}
}
return ''
}
function validateDatabasePayload(payload) {
const fields = [
['postgres_host', 'PostgreSQL Host'],
['postgres_db', '数据库名称'],
['postgres_user', '数据库用户']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const port = Number(payload.postgres_port)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return 'PostgreSQL Port 必须在 1 到 65535 之间。'
}
if (!String(payload.postgres_password || '').length) {
return '请填写数据库密码。'
}
return ''
}
function validateIdentityPayload(payload) {
const companyName = String(payload.company_name || '').trim()
const adminEmail = String(payload.admin_email || '').trim()
const adminUsername = String(payload.admin_username || '').trim()
const adminPassword = String(payload.admin_password || '')
const adminPasswordConfirm = String(payload.admin_password_confirm || '')
if (companyName.length < 2) {
return '企业名称至少 2 个字符。'
}
if (!adminEmail) {
return '请填写管理员邮箱。'
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) {
return '管理员邮箱格式不正确。'
}
if (adminUsername.length < 4) {
return '管理员账号至少 4 位。'
}
if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) {
return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。'
}
if (adminPassword.length < 5) {
return '管理员密码当前至少 5 位。'
}
if (adminPassword !== adminPasswordConfirm) {
return '两次输入的管理员密码不一致。'
}
return ''
}
function validateSetupPayload(payload) {
return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload)
}
function canReuseCurrentWebPort(payload, currentEnv) {
return (
Number(payload.web_port) === Number(currentEnv.WEB_PORT || 5173) &&
hostsConflict(String(payload.web_host || '').trim(), currentEnv.WEB_HOST || '127.0.0.1')
)
}
async function assertPortAvailable(host, port) {
await new Promise((resolve, reject) => {
const tester = net.createServer()
tester.once('error', (error) => {
tester.close()
reject(error)
})
tester.once('listening', () => {
tester.close(() => resolve())
})
tester.listen(port, host)
})
}
async function testRuntimePorts(payload, currentEnv) {
const webPort = Number(payload.web_port)
const serverPort = Number(payload.server_port)
const webHost = String(payload.web_host || '').trim()
const serverHost = String(payload.server_host || '').trim()
if (webPort === serverPort && hostsConflict(webHost, serverHost)) {
throw new Error('Web 与 Server 不能使用同一个主机与端口组合。')
}
if (!canReuseCurrentWebPort(payload, currentEnv)) {
try {
await assertPortAvailable(webHost, webPort)
} catch {
throw new Error(`Web 端口 ${webHost}:${webPort} 已被占用。`)
}
}
try {
await assertPortAvailable(serverHost, serverPort)
} catch {
throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`)
}
}
async function loadPgClient() {
try {
const module = await import('pg')
return module.Client
} catch {
throw new Error('缺少 Node 侧 PostgreSQL 驱动 pgweb/node_modules/pg。请先执行 bash start.sh或进入 web 目录执行 npm install。')
}
}
async function testDatabaseConnection(payload) {
const Client = await loadPgClient()
const client = new Client({
host: String(payload.postgres_host || '').trim(),
port: Number(payload.postgres_port),
database: String(payload.postgres_db || '').trim(),
user: String(payload.postgres_user || '').trim(),
password: String(payload.postgres_password || ''),
connectionTimeoutMillis: 5000
})
try {
await client.connect()
await client.query('SELECT 1')
} finally {
await client.end().catch(() => {})
}
}
function localSetupPlugin() {
return {
name: 'local-setup-api',
configureServer(server) {
server.middlewares.use('/__setup/auth/login', async (req, res) => {
try {
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const username = String(payload.username || '').trim()
const password = String(payload.password || '')
if (!username || !password) {
sendJson(res, 400, { detail: '请输入管理员账号和密码。' })
return
}
const passed = verifyAdminCredentials(username, password)
if (!passed) {
sendJson(res, 401, { detail: '管理员账号或密码错误。' })
return
}
sendJson(res, 200, {
ok: true,
detail: '登录成功。',
user: {
username
}
})
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '管理员登录校验失败。'
})
}
})
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateRuntimePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload, readEnvState())
sendJson(res, 200, { ok: true, detail: '端口占用检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '端口占用检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '运行端口检测服务异常。'
})
}
})
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateDatabasePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testDatabaseConnection(payload)
sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '数据库连接检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '数据库检测服务异常。'
})
}
})
server.middlewares.use('/__setup/bootstrap', async (req, res) => {
try {
if (req.method === 'GET') {
sendJson(res, 200, normalizeState(readEnvState()))
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateSetupPayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload, readEnvState())
await testDatabaseConnection(payload)
} catch (error) {
sendJson(res, 400, {
detail: error instanceof Error ? error.message : '初始化校验失败。'
})
return
}
persistAdminCredentials(payload)
const currentEnv = readEnvState()
const apiBaseUrl = buildApiBaseUrl(payload, currentEnv)
updateEnvFile({
SETUP_COMPLETED: 'true',
COMPANY_NAME: String(payload.company_name || '').trim(),
COMPANY_CODE: String(payload.company_code || '').trim(),
ADMIN_EMAIL: String(payload.admin_email || '').trim(),
WEB_HOST: String(payload.web_host || '').trim(),
WEB_PORT: String(payload.web_port || '').trim(),
SERVER_HOST: String(payload.server_host || '').trim(),
SERVER_PORT: String(payload.server_port || '').trim(),
POSTGRES_HOST: String(payload.postgres_host || '').trim(),
POSTGRES_PORT: String(payload.postgres_port || '').trim(),
POSTGRES_DB: String(payload.postgres_db || '').trim(),
POSTGRES_USER: String(payload.postgres_user || '').trim(),
POSTGRES_PASSWORD: String(payload.postgres_password || ''),
DATABASE_URL: buildDatabaseUrl(payload),
REDIS_URL: String(payload.redis_url || '').trim(),
CORS_ORIGINS: buildCorsOrigins(payload),
VITE_API_BASE_URL: apiBaseUrl,
...buildClientEnvUpdates(payload, apiBaseUrl)
})
sendJson(res, 201, normalizeState(readEnvState()))
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '初始化写入失败。'
})
}
})
}
}
}
export default defineConfig({
envDir: '..',
plugins: [vue(), localSetupPlugin()]
})