| @@ -84,29 +84,66 @@ export function useApi() { | |||||
| return { timeStr, nonce } | 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 = {}) { | 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 = { | const fetchOptions = { | ||||
| method, | method, | ||||
| headers: reqHeaders, | headers: reqHeaders, | ||||
| onResponseError({ response }) { | |||||
| } | |||||
| if (import.meta.client) { | |||||
| fetchOptions.onResponseError = ({ response }) => { | |||||
| console.error('[useApi] HTTP Error', response.status, url) | console.error('[useApi] HTTP Error', response.status, url) | ||||
| }, | |||||
| } | |||||
| } | } | ||||
| if (method === 'GET') { | if (method === 'GET') { | ||||
| @@ -168,7 +208,6 @@ export function useApi() { | |||||
| if (allParams[k] === '') delete allParams[k] | if (allParams[k] === '') delete allParams[k] | ||||
| }) | }) | ||||
| const queryStr = new URLSearchParams(allParams).toString() | const queryStr = new URLSearchParams(allParams).toString() | ||||
| fetchOptions.query = allParams | |||||
| // GET 请求通过 server proxy 时,参数在 query 里 | // GET 请求通过 server proxy 时,参数在 query 里 | ||||
| const finalUrl = url + (queryStr ? '?' + queryStr : '') | const finalUrl = url + (queryStr ? '?' + queryStr : '') | ||||
| const result = import.meta.server | const result = import.meta.server | ||||
| @@ -116,9 +116,25 @@ export async function useArticleDetail(colId) { | |||||
| const detail = computed(() => data.value?.detail || {}) | const detail = computed(() => data.value?.detail || {}) | ||||
| const canonical = computed(() => `https://tool.aionline.cc${articleUrl(detail.value)}`) | 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(() => { | 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 host = process.env.HOST || '0.0.0.0' | ||||
| const port = process.env.PORT || '6888' | const port = process.env.PORT || '6888' | ||||
| const apiBase = process.env.NUXT_PUBLIC_API_BASE || 'https://api.aionline.cc' | 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 = { | const env = { | ||||
| NODE_ENV: 'production', | NODE_ENV: 'production', | ||||
| HOST: host, | HOST: host, | ||||
| @@ -37,7 +37,8 @@ function resolveApiBase() { | |||||
| } | } | ||||
| function resolveServerApiBase() { | 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 | let url | ||||
| try { | try { | ||||
| url = new URL(value) | url = new URL(value) | ||||
| @@ -9,7 +9,7 @@ set -Eeuo pipefail | |||||
| APP_NAME="${APP_NAME:-pc_nuxt}" | APP_NAME="${APP_NAME:-pc_nuxt}" | ||||
| BRANCH="${BRANCH:-}" | BRANCH="${BRANCH:-}" | ||||
| NUXT_PUBLIC_API_BASE="${NUXT_PUBLIC_API_BASE:-https://api.aionline.cc}" | 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)" | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||
| export NUXT_PUBLIC_API_BASE | export NUXT_PUBLIC_API_BASE | ||||
| @@ -72,6 +72,60 @@ if (!['http:', 'https:'].includes(url.protocol)) { | |||||
| NODE | 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() { | validate_tls_security() { | ||||
| if [ "${NODE_TLS_REJECT_UNAUTHORIZED:-}" = "0" ]; then | if [ "${NODE_TLS_REJECT_UNAUTHORIZED:-}" = "0" ]; then | ||||
| log "Refusing deployment: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate verification." | log "Refusing deployment: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate verification." | ||||
| @@ -139,6 +193,8 @@ main() { | |||||
| log "API: $NUXT_PUBLIC_API_BASE" | log "API: $NUXT_PUBLIC_API_BASE" | ||||
| log "Server API: $NUXT_API_SERVER_BASE" | log "Server API: $NUXT_API_SERVER_BASE" | ||||
| verify_server_api | |||||
| sync_latest_code | sync_latest_code | ||||
| install_dependencies | install_dependencies | ||||
| build_app | build_app | ||||