From 40063d82b8473972c9e7164eb45d18eff9e571ef Mon Sep 17 00:00:00 2001 From: quant Date: Fri, 27 Feb 2026 18:03:55 +0800 Subject: [PATCH] admin --- .dockerignore | 7 ++ .env | 1 - .env.example | 1 + .gitignore | 5 + Dockerfile | 8 +- README.md | 11 ++- package.json | 1 + server/index.mjs | 232 ++++++++++++++++++++++++++++++++++++++++++++ src/services/api.ts | 34 ++++--- 9 files changed, 279 insertions(+), 21 deletions(-) create mode 100644 .dockerignore delete mode 100644 .env create mode 100644 .env.example create mode 100644 server/index.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36e30f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +node_modules +dist +.env +.env.* +README.md +*.log diff --git a/.env b/.env deleted file mode 100644 index 48ed77b..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -VITE_API_KEY=sk-657968d48d0249099f3809f796f80a4f diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f32d0f9 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_KEY=your_dashscope_api_key_here diff --git a/.gitignore b/.gitignore index a547bf3..4d18a66 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environment variables (never commit secrets) +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile index 2990785..fbc2766 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,9 @@ RUN npm ci COPY . . RUN npm run build -# Use a simple Node static server instead of nginx -RUN npm install -g serve - EXPOSE 80 -# Serve the built SPA on port 80, with history fallback so /admin 等前端路由都可直接访问 -CMD ["serve", "-s", "dist", "-l", "80"] +# Serve SPA + proxy streaming API on port 80 +ENV PORT=80 +CMD ["node", "server/index.mjs"] diff --git a/README.md b/README.md index da5e1d5..0cf872d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Use AI (DashScope Qwen) to generate authentic WeChat Moments copy instantly. ```env VITE_API_KEY=your_dashscope_api_key_here ``` + + For Docker / production deployment, prefer using server-side env: + ```env + DASHSCOPE_API_KEY=your_dashscope_api_key_here + ``` 4. **Start Development Server** ```bash @@ -65,13 +70,13 @@ Use AI (DashScope Qwen) to generate authentic WeChat Moments copy instantly. ```bash docker build -t wx-pyq . -docker run -p 80:80 wx-pyq +docker run -p 80:80 -e DASHSCOPE_API_KEY=your_dashscope_api_key_here wx-pyq ``` ## Note / 注意事项 -- This is a frontend-only demo. The API Key is exposed in the browser network requests. For production, please use a backend proxy. -- 本项目为纯前端演示。API Key 会暴露在浏览器请求中。生产环境请务必使用后端代理。 +- For dev, it can call DashScope directly (API Key is exposed in the browser). For production/Docker, the app uses a same-origin server proxy (`/api/chat/completions`) so streaming is more reliable and the key is not bundled into frontend assets. +- 开发环境可直连 DashScope(API Key 会暴露在浏览器请求中)。生产环境/Docker 默认走同源后端代理(`/api/chat/completions`),流式更稳定且不会把 Key 打进前端静态资源。 ## License diff --git a/package.json b/package.json index f1d2572..8bed185 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "start": "node server/index.mjs", "lint": "eslint .", "preview": "vite preview", "test": "jest", diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..b6db1ba --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,232 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const PORT = Number(process.env.PORT || 80); +const DIST_DIR = path.resolve(process.cwd(), 'dist'); + +const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'text/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.ico': + return 'image/x-icon'; + case '.json': + return 'application/json; charset=utf-8'; + case '.map': + return 'application/json; charset=utf-8'; + case '.woff2': + return 'font/woff2'; + case '.txt': + return 'text/plain; charset=utf-8'; + default: + return 'application/octet-stream'; + } +} + +function sendJson(res, statusCode, body) { + const payload = JSON.stringify(body); + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(payload); +} + +async function readJsonBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + if (!raw) return null; + return JSON.parse(raw); +} + +function applyApiCorsHeaders(req, res) { + const origin = req.headers.origin; + if (!origin) return; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); +} + +async function handleChatCompletions(req, res) { + applyApiCorsHeaders(req, res); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'Method Not Allowed' }); + return; + } + + const apiKey = + process.env.DASHSCOPE_API_KEY || + process.env.VITE_API_KEY || + process.env.DASHSCOPE_KEY; + + if (!apiKey) { + sendJson(res, 500, { error: 'Missing DASHSCOPE_API_KEY in server environment' }); + return; + } + + let body; + try { + body = await readJsonBody(req); + } catch (e) { + sendJson(res, 400, { error: 'Invalid JSON request body' }); + return; + } + + const upstreamUrl = `${DASHSCOPE_BASE_URL}/chat/completions`; + const abortController = new AbortController(); + + req.on('close', () => { + abortController.abort(); + }); + + let upstream; + try { + upstream = await fetch(upstreamUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify(body ?? {}), + signal: abortController.signal, + }); + } catch (e) { + sendJson(res, 502, { error: 'Upstream request failed' }); + return; + } + + const isStream = Boolean(body && body.stream); + + res.statusCode = upstream.status; + res.setHeader( + 'Content-Type', + upstream.headers.get('content-type') || + (isStream ? 'text/event-stream; charset=utf-8' : 'application/json; charset=utf-8') + ); + + // Help prevent buffering in common reverse proxies. + res.setHeader('Cache-Control', isStream ? 'no-cache, no-transform' : 'no-store'); + res.setHeader('X-Accel-Buffering', 'no'); + + // For streaming: flush headers ASAP so the browser can start rendering. + if (isStream) { + res.setHeader('Connection', 'keep-alive'); + } + if (typeof res.flushHeaders === 'function') res.flushHeaders(); + + if (!upstream.body) { + const text = await upstream.text().catch(() => ''); + res.end(text); + return; + } + + try { + const reader = upstream.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) res.write(Buffer.from(value)); + } + } catch (e) { + // If client disconnected, ignore; otherwise just end response. + } finally { + res.end(); + } +} + +function serveStatic(req, res, url) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.statusCode = 405; + res.end('Method Not Allowed'); + return; + } + + const pathname = decodeURIComponent(url.pathname); + + // Prevent path traversal. + const safePath = path.normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, ''); + const fsPath = path.join(DIST_DIR, safePath); + + let resolved = fsPath; + + try { + if (pathname.endsWith('/')) { + resolved = path.join(fsPath, 'index.html'); + } else if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + resolved = path.join(resolved, 'index.html'); + } + } catch { + // ignore + } + + try { + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + // SPA history fallback. + resolved = path.join(DIST_DIR, 'index.html'); + } + } catch { + resolved = path.join(DIST_DIR, 'index.html'); + } + + const isAsset = pathname.startsWith('/assets/'); + const contentType = getContentType(resolved); + + res.statusCode = 200; + res.setHeader('Content-Type', contentType); + res.setHeader( + 'Cache-Control', + isAsset ? 'public, max-age=31536000, immutable' : 'no-cache' + ); + + if (req.method === 'HEAD') { + res.end(); + return; + } + + fs.createReadStream(resolved).pipe(res); +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + if (url.pathname === '/api/chat/completions') { + await handleChatCompletions(req, res); + return; + } + + serveStatic(req, res, url); + } catch (e) { + res.statusCode = 500; + res.end('Internal Server Error'); + } +}); + +server.listen(PORT, () => { + console.log(`Server listening on :${PORT}`); +}); + diff --git a/src/services/api.ts b/src/services/api.ts index 06bb098..efb4d74 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -4,17 +4,18 @@ import type { InternalAxiosRequestConfig } from 'axios'; // DashScope API Configuration const API_KEY = import.meta.env.VITE_API_KEY; const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +const USE_SERVER_PROXY = import.meta.env.PROD; -if (!API_KEY) { +if (!USE_SERVER_PROXY && !API_KEY) { console.warn('DashScope API Key is missing in .env file!'); } // Create Axios instance for DashScope export const dashScopeApi = axios.create({ - baseURL: DASHSCOPE_BASE_URL, + baseURL: USE_SERVER_PROXY ? '' : DASHSCOPE_BASE_URL, headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${API_KEY}`, + ...(USE_SERVER_PROXY ? {} : { 'Authorization': `Bearer ${API_KEY}` }), }, timeout: 60000, // 60s timeout for AI generation }); @@ -27,7 +28,7 @@ const INITIAL_RETRY_DELAY = 1000; dashScopeApi.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Ensure API Key is present - if (!config.headers.Authorization && API_KEY) { + if (!USE_SERVER_PROXY && !config.headers.Authorization && API_KEY) { config.headers.Authorization = `Bearer ${API_KEY}`; } return config; @@ -75,7 +76,8 @@ dashScopeApi.interceptors.response.use( */ export const generateCopy = async (model: string, messages: { role: string; content: string }[]) => { try { - const response = await dashScopeApi.post('/chat/completions', { + const endpoint = USE_SERVER_PROXY ? '/api/chat/completions' : '/chat/completions'; + const response = await dashScopeApi.post(endpoint, { model, messages, // stream: true, // TODO: Implement streaming if needed @@ -103,12 +105,19 @@ export const generateCopyStream = async ( onFinish: () => void ) => { try { - const response = await fetch(`${DASHSCOPE_BASE_URL}/chat/completions`, { + const url = USE_SERVER_PROXY ? '/api/chat/completions' : `${DASHSCOPE_BASE_URL}/chat/completions`; + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }; + if (!USE_SERVER_PROXY) { + headers['Authorization'] = `Bearer ${API_KEY}`; + } + + const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${API_KEY}`, - }, + headers, + cache: 'no-store', body: JSON.stringify({ model, messages, @@ -144,9 +153,10 @@ export const generateCopyStream = async ( const trimmedLine = line.trim(); if (!trimmedLine || trimmedLine === 'data: [DONE]') continue; - if (trimmedLine.startsWith('data: ')) { + if (trimmedLine.startsWith('data:')) { try { - const jsonStr = trimmedLine.replace('data: ', ''); + const jsonStr = trimmedLine.slice('data:'.length).trimStart(); + if (jsonStr === '[DONE]') continue; const json = JSON.parse(jsonStr); const content = json.choices?.[0]?.delta?.content || ''; if (content) {