| @@ -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 | |||
| @@ -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(() => { | |||
| @@ -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, | |||
| @@ -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) | |||
| @@ -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 | |||