diff --git a/app/composables/useApi.js b/app/composables/useApi.js index c2f0ead..ef9c2d6 100644 --- a/app/composables/useApi.js +++ b/app/composables/useApi.js @@ -84,29 +84,66 @@ export function useApi() { return { timeStr, nonce } } + /** + * SSR JSON 请求使用 Node 原生 http/https。 + * + * 线上运行环境可能与 Nuxt 构建环境的 Node 小版本不同,Node 23 下曾出现 + * `Headers is not a constructor`,导致服务端渲染无法取到文章数据。这里不经过 + * fetch/ofetch/Headers,避免运行时兼容层影响 SEO 页面。 + */ async function fetchJson(url, options = {}) { - const response = await fetch(url, options) - const text = await response.text() - let result = null - - if (text) { - try { - result = JSON.parse(text) - } catch { - const err = new Error(text || `请求失败 (${response.status})`) - err.code = response.status - throw err - } + const target = new URL(url) + const transport = target.protocol === 'https:' + ? await import('node:https') + : await import('node:http') + const body = typeof options.body === 'string' ? options.body : '' + const headers = { ...(options.headers || {}) } + if (body && !Object.keys(headers).some(key => key.toLowerCase() === 'content-length')) { + headers['Content-Length'] = Buffer.byteLength(body) } - if (!response.ok) { - const err = new Error(result?.msg || result?.message || `请求失败 (${response.status})`) - err.code = result?.code || response.status - err.response = result - throw err - } + return await new Promise((resolve, reject) => { + const req = transport.request(target, { + method: options.method || 'GET', + headers, + }, (response) => { + const chunks = [] + response.on('data', chunk => chunks.push(Buffer.from(chunk))) + response.on('end', () => { + const status = Number(response.statusCode || 0) + const text = Buffer.concat(chunks).toString('utf8') + let result = null + + if (text) { + try { + result = JSON.parse(text) + } catch { + const err = new Error(text || `请求失败 (${status})`) + err.code = status + reject(err) + return + } + } + + if (status < 200 || status >= 300) { + const err = new Error(result?.msg || result?.message || `请求失败 (${status})`) + err.code = result?.code || status + err.response = result + reject(err) + return + } + + resolve(result || {}) + }) + }) - return result || {} + req.setTimeout(15000, () => { + req.destroy(new Error(`请求超时: ${target.origin}`)) + }) + req.on('error', reject) + if (body) req.write(body) + req.end() + }) } /** @@ -155,9 +192,12 @@ export function useApi() { const fetchOptions = { method, headers: reqHeaders, - onResponseError({ response }) { + } + + if (import.meta.client) { + fetchOptions.onResponseError = ({ response }) => { console.error('[useApi] HTTP Error', response.status, url) - }, + } } if (method === 'GET') { @@ -168,7 +208,6 @@ export function useApi() { if (allParams[k] === '') delete allParams[k] }) const queryStr = new URLSearchParams(allParams).toString() - fetchOptions.query = allParams // GET 请求通过 server proxy 时,参数在 query 里 const finalUrl = url + (queryStr ? '?' + queryStr : '') const result = import.meta.server diff --git a/app/composables/useArticle.js b/app/composables/useArticle.js index b38ab3b..d77ece7 100644 --- a/app/composables/useArticle.js +++ b/app/composables/useArticle.js @@ -116,9 +116,25 @@ export async function useArticleDetail(colId) { const detail = computed(() => data.value?.detail || {}) const canonical = computed(() => `https://tool.aionline.cc${articleUrl(detail.value)}`) - // 文章不存在 → 404,避免无效页面被搜索引擎收录 - if (error.value || !data.value?.detail?.id) { - throw createError({ statusCode: 404, statusMessage: '文章不存在', fatal: true }) + if (error.value) { + console.error('[article:ssr] load detail failed', { + id, + colId, + message: error.value.message, + code: error.value.code || error.value.statusCode, + cause: error.value.cause?.message, + }) + throw createError({ + statusCode: 502, + message: '文章加载失败,请稍后重试', + fatal: true, + cause: error.value, + }) + } + + // 仅接口成功但确实没有数据时返回 404,避免把网络异常误判为文章不存在。 + if (!data.value?.detail?.id) { + throw createError({ statusCode: 404, message: '文章不存在', fatal: true }) } await nuxtApp.runWithContext(() => { diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index a346e34..b01d51f 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -2,7 +2,7 @@ const appName = process.env.APP_NAME || 'pc_nuxt' const host = process.env.HOST || '0.0.0.0' const port = process.env.PORT || '6888' const apiBase = process.env.NUXT_PUBLIC_API_BASE || 'https://api.aionline.cc' -const apiServerBase = process.env.NUXT_API_SERVER_BASE || apiBase +const apiServerBase = process.env.NUXT_API_SERVER_BASE || 'http://127.0.0.1:16888' const env = { NODE_ENV: 'production', HOST: host, diff --git a/nuxt.config.js b/nuxt.config.js index 7983330..6127340 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -37,7 +37,8 @@ function resolveApiBase() { } function resolveServerApiBase() { - const value = String(process.env.NUXT_API_SERVER_BASE || apiBase).trim().replace(/\/+$/, '') + const defaultServerApiBase = isProduction ? 'http://127.0.0.1:16888' : apiBase + const value = String(process.env.NUXT_API_SERVER_BASE || defaultServerApiBase).trim().replace(/\/+$/, '') let url try { url = new URL(value) diff --git a/restart.sh b/restart.sh index f951a61..f187afc 100644 --- a/restart.sh +++ b/restart.sh @@ -9,7 +9,7 @@ set -Eeuo pipefail APP_NAME="${APP_NAME:-pc_nuxt}" BRANCH="${BRANCH:-}" NUXT_PUBLIC_API_BASE="${NUXT_PUBLIC_API_BASE:-https://api.aionline.cc}" -NUXT_API_SERVER_BASE="${NUXT_API_SERVER_BASE:-$NUXT_PUBLIC_API_BASE}" +NUXT_API_SERVER_BASE="${NUXT_API_SERVER_BASE:-http://127.0.0.1:16888}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export NUXT_PUBLIC_API_BASE @@ -72,6 +72,60 @@ if (!['http:', 'https:'].includes(url.protocol)) { NODE } +verify_server_api() { + log "Verify SSR API connectivity" + node - "$NUXT_API_SERVER_BASE" <<'NODE' +const http = require('node:http') +const https = require('node:https') +const base = process.argv[2] +const target = new URL('/article/getlist', `${base.replace(/\/+$/, '')}/`) +const body = new URLSearchParams({ + mac: 'pc-nuxt-deploy-check', + base_timestamp: String(Math.floor(Date.now() / 1000)), + client: '1', + client_ios: '0', + version: '1.0.0', + version_code: '1', + page_no: '1', + page_size: '1' +}).toString() +const transport = target.protocol === 'https:' ? https : http +const request = transport.request(target, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'Content-Length': Buffer.byteLength(body) + }, + timeout: 10000 +}, (response) => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + let result + try { + result = JSON.parse(text) + } catch { + console.error(`[pc_nuxt] SSR API returned invalid JSON (${response.statusCode}): ${text.slice(0, 200)}`) + process.exit(1) + } + if (response.statusCode < 200 || response.statusCode >= 300 || result.code !== 0) { + console.error(`[pc_nuxt] SSR API check failed (${response.statusCode}): ${result.msg || text.slice(0, 200)}`) + process.exit(1) + } + console.log(`[pc_nuxt] SSR API OK: ${base}`) + }) +}) +request.on('timeout', () => request.destroy(new Error('timeout'))) +request.on('error', (error) => { + console.error(`[pc_nuxt] SSR API unavailable: ${base} (${error.message})`) + process.exit(1) +}) +request.write(body) +request.end() +NODE +} + validate_tls_security() { if [ "${NODE_TLS_REJECT_UNAUTHORIZED:-}" = "0" ]; then log "Refusing deployment: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate verification." @@ -139,6 +193,8 @@ main() { log "API: $NUXT_PUBLIC_API_BASE" log "Server API: $NUXT_API_SERVER_BASE" + verify_server_api + sync_latest_code install_dependencies build_app