diff --git a/.env.example b/.env.example index 525dd68..67fdc15 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,10 @@ APP_PORT=47880 VITE_REALTIME_MODE=websocket VITE_REALTIME_WS_URL=/ws VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api -VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6 -WORDCLOUD_API_UPSTREAM=http://114.55.99.6:8000 +VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0 +WORDCLOUD_API_UPSTREAM=http://192.168.31.213:8000 +ORDERS_ADMIN_PASSWORD=zhihui2024 +ORDERS_TOKEN_SECRET=wordcloud-orders-demo HARBOR_REGISTRY=192.168.31.213:10081 HARBOR_PROJECT=wordcloud IMAGE_TAG=latest diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 3592e7b..efe501b 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -8,11 +8,11 @@ COPY . . ARG VITE_REALTIME_MODE=websocket ARG VITE_REALTIME_WS_URL=/ws ARG VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api -ARG VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6 +ARG VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0 ENV VITE_REALTIME_MODE=$VITE_REALTIME_MODE ENV VITE_REALTIME_WS_URL=$VITE_REALTIME_WS_URL ENV VITE_WORDCLOUD_API_BASE_URL=$VITE_WORDCLOUD_API_BASE_URL -ENV VITE_WORDCLOUD_DEFAULT_JOB_ID=$VITE_WORDCLOUD_DEFAULT_JOB_ID +ENV VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=$VITE_WORDCLOUD_DEFAULT_PRODUCT_ID RUN npm run build FROM nginx:1.27-alpine diff --git a/README.md b/README.md index 93951a0..33825c1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ npm run lint - `src/components/`:词云、搜索 dock、QR panel、状态组件。 - `src/data/clouds/`:离线 `demo` 词云数据。 - `src/lib/wordcloudApi.ts`:WordCloud API 适配器;运行时读取位置数据与 SVG。 -- `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程任务词云。 +- `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程产品词云。 - `src/hooks/`:focus lifecycle、queue 和 realtime integration。 - `src/realtime/`:BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。 - `src/styles/global.css`:从既有设计提取的全局 tokens。 @@ -63,14 +63,14 @@ http://:47880/control/demo 如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。 -除 `demo` 外,路由参数即 WordCloud `jobId`。例如: +除 `demo` 外,路由参数即 WordCloud `productId`。例如: ```text -/cloud/bd1f240adcb4458f857c40ac427122c6 -/screen/bd1f240adcb4458f857c40ac427122c6 -/control/bd1f240adcb4458f857c40ac427122c6 +/cloud/prod_362d8ecaced241e5ab81dddfc880d6b0 +/screen/prod_362d8ecaced241e5ab81dddfc880d6b0 +/control/prod_362d8ecaced241e5ab81dddfc880d6b0 ``` -`/cloud/imported`、`/screen/imported` 与 `/control/imported` 是由 `VITE_WORDCLOUD_DEFAULT_JOB_ID` 配置的默认任务别名。应用默认从同源 `/wordcloud-api` 读取位置与 SVG,Nginx 再将其转发到 `WORDCLOUD_API_UPSTREAM`,默认值为 `http://192.168.31.213:8000`。这样浏览器不需要跨域读取 SVG,且多域名部署时仍保留访问者实际使用的域名。 +`/cloud/imported`、`/screen/imported` 与 `/control/imported` 是由 `VITE_WORDCLOUD_DEFAULT_PRODUCT_ID` 配置的默认产品别名。应用先从同源 `/wordcloud-product-api` 解析 Product ID 对应的最新词云归档,再从 `/wordcloud-api` 读取位置数据与 SVG。这样浏览器不需要读取产品管理鉴权信息,多域名部署时仍保留访问者实际使用的域名。一个 Product 下存在多个词云时,当前版本先取最新归档中的第一个词云。 本地 `npm run dev` 也会将 `/wordcloud-api` 转发到同一个后端。只有在已具备可靠 CORS 的场景,才需要把 `VITE_WORDCLOUD_API_BASE_URL` 改为完整的远端 URL。 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index d108b73..49c234c 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -45,6 +45,15 @@ server { proxy_set_header X-Forwarded-Port $server_port; } + location ^~ /wordcloud-product-api/ { + proxy_pass http://realtime:8787/; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ^~ /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=31536000, immutable"; diff --git a/docker-compose.yml b/docker-compose.yml index f2bbf10..70279ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: VITE_REALTIME_MODE: ${VITE_REALTIME_MODE:-websocket} VITE_REALTIME_WS_URL: ${VITE_REALTIME_WS_URL:-/ws} VITE_WORDCLOUD_API_BASE_URL: ${VITE_WORDCLOUD_API_BASE_URL:-/wordcloud-api} - VITE_WORDCLOUD_DEFAULT_JOB_ID: ${VITE_WORDCLOUD_DEFAULT_JOB_ID:-bd1f240adcb4458f857c40ac427122c6} + VITE_WORDCLOUD_DEFAULT_PRODUCT_ID: ${VITE_WORDCLOUD_DEFAULT_PRODUCT_ID:-prod_362d8ecaced241e5ab81dddfc880d6b0} ports: - "${APP_PORT:-47880}:80" depends_on: @@ -25,6 +25,9 @@ services: dockerfile: Dockerfile environment: REALTIME_PORT: 8787 + WORDCLOUD_API_UPSTREAM: ${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000} + ORDERS_ADMIN_PASSWORD: ${ORDERS_ADMIN_PASSWORD:-zhihui2024} + ORDERS_TOKEN_SECRET: ${ORDERS_TOKEN_SECRET:-wordcloud-orders-demo} expose: - "8787" restart: unless-stopped diff --git a/server/realtime-server.mjs b/server/realtime-server.mjs index 8b7ae23..ec65f8a 100644 --- a/server/realtime-server.mjs +++ b/server/realtime-server.mjs @@ -1,7 +1,30 @@ import { createServer } from 'node:http'; +import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import { WebSocketServer } from 'ws'; +function selectLatestSourceJobId(product) { + const versions = (product?.versions ?? []) + .filter((version) => Array.isArray(version?.wordcloud_archives)) + .sort((left, right) => { + const leftTime = Date.parse(left?.created_at ?? ''); + const rightTime = Date.parse(right?.created_at ?? ''); + return (Number.isFinite(rightTime) ? rightTime : 0) + - (Number.isFinite(leftTime) ? leftTime : 0); + }); + + for (const version of versions) { + const archive = version.wordcloud_archives.find((item) => ( + typeof item?.source_job_id === 'string' && item.source_job_id.length > 0 + )); + if (archive) { + return archive.source_job_id; + } + } + + return null; +} + function isFocusEvent(value) { return ( typeof value === 'object' && @@ -18,7 +41,22 @@ function isFocusEvent(value) { ); } -export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || 8787)) { +export function createRealtimeServer( + port = Number(process.env.REALTIME_PORT || 8787), + options = {}, +) { + const upstreamUrl = options.upstreamUrl + ?? process.env.WORDCLOUD_API_UPSTREAM + ?? 'http://192.168.31.213:8000'; + const adminPassword = options.adminPassword + ?? process.env.ORDERS_ADMIN_PASSWORD + ?? 'zhihui2024'; + const tokenSecret = options.tokenSecret + ?? process.env.ORDERS_TOKEN_SECRET + ?? 'wordcloud-orders-demo'; + const fetchImpl = options.fetchImpl ?? fetch; + const upstream = upstreamUrl.replace(/\/+$/, ''); + const server = createServer((request, response) => { if (request.url === '/healthz') { response.writeHead(200, { 'content-type': 'application/json' }); @@ -26,6 +64,47 @@ export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || return; } + const sourceJobMatch = request.url.match(/^\/api\/products\/([^/]+)\/source-job$/); + if (sourceJobMatch && request.method === 'GET') { + const productId = decodeURIComponent(sourceJobMatch[1]); + const sendError = (status, message) => { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ detail: message })); + }; + + fetchImpl(`${upstream}/api/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: adminPassword }), + }) + .then(async (loginResponse) => { + if (!loginResponse.ok) { + return sendError(502, '词云产品服务认证失败'); + } + const { token } = await loginResponse.json(); + return fetchImpl(`${upstream}/api/products/${encodeURIComponent(productId)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + }) + .then(async (productResponse) => { + if (!productResponse.ok) { + return sendError( + productResponse.status === 404 ? 404 : 502, + productResponse.status === 404 ? 'product not found' : '词云产品服务不可用', + ); + } + const sourceJobId = selectLatestSourceJobId(await productResponse.json()); + if (!sourceJobId) { + return sendError(404, 'product has no wordcloud archive'); + } + + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ product_id: productId, source_job_id: sourceJobId })); + }) + .catch(() => sendError(502, '词云产品服务不可用')); + return; + } + response.writeHead(404, { 'content-type': 'text/plain' }); response.end('Not found'); }); diff --git a/server/realtime-server.test.mjs b/server/realtime-server.test.mjs index e7635b4..4e38b33 100644 --- a/server/realtime-server.test.mjs +++ b/server/realtime-server.test.mjs @@ -3,6 +3,68 @@ import assert from 'node:assert/strict'; import { createRealtimeServer } from './realtime-server.mjs'; import WebSocket from 'ws'; +const productDetail = { + product_id: 'prod_abc123', + versions: [ + { + version_id: 'ver_old', + created_at: '2026-09-13T08:00:00Z', + wordcloud_archives: [ + { source_job_id: 'job_old', created_at: '2026-09-13T08:00:00Z' }, + ], + }, + { + version_id: 'ver_new', + created_at: '2026-09-13T09:00:00Z', + wordcloud_archives: [ + { source_job_id: 'job_new', created_at: '2026-09-13T09:00:00Z' }, + ], + }, + ], +}; + +test('resolves a product id to the latest source job id', async () => { + const requests = []; + const fetchImpl = async (input, init) => { + requests.push({ input: String(input), init }); + if (input === 'http://wordcloud.test/api/login') { + return new Response(JSON.stringify({ token: 'product-token' }), { status: 200 }); + } + if (input === 'http://wordcloud.test/api/products/prod_abc123') { + return new Response(JSON.stringify(productDetail), { status: 200 }); + } + return new Response('not found', { status: 404 }); + }; + + const server = createRealtimeServer(0, { + upstreamUrl: 'http://wordcloud.test', + adminPassword: 'test-password', + tokenSecret: 'test-secret', + fetchImpl, + }); + await new Promise((resolve) => server.listen(resolve)); + const { port } = server.address(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/products/prod_abc123/source-job`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + product_id: 'prod_abc123', + source_job_id: 'job_new', + }); + assert.deepEqual(requests.map((request) => request.input), [ + 'http://wordcloud.test/api/login', + 'http://wordcloud.test/api/products/prod_abc123', + ]); + + const loginBody = JSON.parse(requests[0].init.body); + assert.equal(loginBody.password, 'test-password'); + assert.equal(requests[1].init.headers.Authorization, 'Bearer product-token'); + } finally { + server.close(); + } +}); + test('broadcasts focus events to other connected clients', async () => { const server = createRealtimeServer(0); await new Promise((resolve) => server.listen(resolve)); diff --git a/src/AppRoutes.test.tsx b/src/AppRoutes.test.tsx index 482f14c..bf89b4c 100644 --- a/src/AppRoutes.test.tsx +++ b/src/AppRoutes.test.tsx @@ -4,7 +4,8 @@ import { MemoryRouter } from 'react-router-dom'; import { afterEach, vi } from 'vitest'; import { AppRoutes } from './AppRoutes'; -const remoteJobId = 'bd1f240adcb4458f857c40ac427122c6'; +const remoteProductId = 'prod_abc123'; +const remoteJobId = 'job_abc123'; const remoteLocationResult = { job_id: remoteJobId, query: '', @@ -49,6 +50,12 @@ function mockRemoteCloudApi() { return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); + if (url === `/wordcloud-product-api/api/products/${remoteProductId}/source-job`) { + return new Response(JSON.stringify({ product_id: remoteProductId, source_job_id: remoteJobId }), { + status: 200, + }); + } + if (url.endsWith(`/api/jobs/${remoteJobId}`)) { return new Response('job not found', { status: 404 }); } @@ -150,7 +157,7 @@ describe('application routes', () => { const user = userEvent.setup(); const personal = render( - + , ); @@ -159,6 +166,9 @@ describe('application routes', () => { expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2); }); expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + expect(fetchMock).toHaveBeenCalledWith( + `/wordcloud-product-api/api/products/${remoteProductId}/source-job`, + ); expect(fetchMock).toHaveBeenCalledWith( `/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`, ); @@ -174,7 +184,7 @@ describe('application routes', () => { personal.unmount(); const screenView = render( - + , ); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 2b16763..2faf2d1 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,28 +7,28 @@ export function AppRoutes() { return ( } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> ); } function PersonalRoute() { - const { cloudId = '' } = useParams(); + const { productId = '' } = useParams(); - return ; + return ; } function PublicRoute() { - const { cloudId = '' } = useParams(); + const { productId = '' } = useParams(); - return ; + return ; } function ControllerRoute() { - const { cloudId = '' } = useParams(); + const { productId = '' } = useParams(); - return ; + return ; } diff --git a/src/components/QRPanel.tsx b/src/components/QRPanel.tsx index 1b95363..76a6c45 100644 --- a/src/components/QRPanel.tsx +++ b/src/components/QRPanel.tsx @@ -3,15 +3,15 @@ import { toDataURL } from 'qrcode'; import styles from './QRPanel.module.css'; interface QRPanelProps { - cloudId: string; + productId: string; } -export function QRPanel({ cloudId }: QRPanelProps) { +export function QRPanel({ productId }: QRPanelProps) { const [qrDataUrl, setQRDataUrl] = useState(null); useEffect(() => { let cancelled = false; - const url = new URL(`/control/${encodeURIComponent(cloudId)}`, window.location.origin).href; + const url = new URL(`/control/${encodeURIComponent(productId)}`, window.location.origin).href; toDataURL(url, { errorCorrectionLevel: 'M', @@ -38,7 +38,7 @@ export function QRPanel({ cloudId }: QRPanelProps) { return () => { cancelled = true; }; - }, [cloudId]); + }, [productId]); return (