From 96eb32ad0321ee47e9eb3ff1fff6a6431cca16cc Mon Sep 17 00:00:00 2001 From: yml Date: Tue, 14 Apr 2026 13:13:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=91=E6=8D=A2=E6=B5=81?= =?UTF-8?q?=E7=A8=8B,=20=E4=BC=98=E5=8C=96=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 11.txt | 22 --- CLAUDE.md | 118 +++++++++++++ .../data/order-fulfillment-bindings.json | 6 +- apps/backend/scripts/start-dev-container.sh | 35 +++- .../services/claim/claim-session-service.js | 51 ++++-- .../claim/claim-session-service.test.js | 167 ++++++++++++++++++ .../src/services/session/session-redeem.js | 6 +- .../services/session/session-redeem.test.js | 11 ++ apps/backend/src/services/session/session.js | 33 +++- docker-compose.dev.yml | 2 +- docs/app信息.md | 4 +- docs/cloudflared_tunnel.txt | 59 +++++++ 12 files changed, 470 insertions(+), 44 deletions(-) delete mode 100644 11.txt create mode 100644 CLAUDE.md create mode 100644 apps/backend/src/services/claim/claim-session-service.test.js create mode 100644 docs/cloudflared_tunnel.txt diff --git a/11.txt b/11.txt deleted file mode 100644 index 842c0757..00000000 --- a/11.txt +++ /dev/null @@ -1,22 +0,0 @@ -https://cdk.linux.do/receive/069af7bc-7915-48b6-834b-cf52eb00b798 - - -cloudflared tunnel create mac-mini-tunnel - -yml@ymlMacBook-Air ~ % cloudflared tunnel create mac-mini-tunnel -Tunnel credentials written to /Users/yml/.cloudflared/562f9639-71f2-44e0-a5ef-b34bd4e215e5.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel. - -Created tunnel mac-mini-tunnel with id 562f9639-71f2-44e0-a5ef-b34bd4e215e5 -yml@ymlMacBook-Air ~ % - - -221329.cc.cd - -4. # 例子:把 web.yourdomain.com 指向刚才创建的隧道 -cloudflared tunnel route dns mac-mini-tunnel 221329.cc.cd - - -5. 启动隧道(临时/测试) -如果你只是临时跑一个服务(比如你 OrbStack 里的容器映射到了本地 80 端口): - -cloudflared tunnel run --url http://localhost:80 mac-mini-tunnel \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5864e6e1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +订单自动兑换系统 (Order Auto-Redemption System). 接收电商平台的 webhook 订单,通过 Playwright 浏览器自动化完成腾讯/QQ 等平台的兑换码兑换,生成用户领取链接。 + +## Architecture + +**Monorepo**: `apps/backend` + `apps/frontend` + `deploy/` + +### Backend (`apps/backend/`) +- **Stack**: Node.js (ESM), Express 5, PostgreSQL (pg), Playwright 1.42.1, Python OCR worker (ddddocr) +- **Entry**: `src/index.js` — Express app bootstrap with health checks and graceful shutdown +- **Layering**: `routes/` → `services/` → `repositories/` → `db/client.js` (pg Pool singleton) +- **Config**: `config/default.cjs` as base, `.env` overrides parsed in `src/config/runtime.js`. No dotenv library — custom env file parser. +- **DB migrations**: `src/db/migrations/` — SQL files run automatically on startup via `src/db/migrate.js` (tracks applied migrations in `schema_migrations` table) +- **Types**: Gradual TypeScript migration in progress. `tsconfig.json` with `allowJs: true, checkJs: false, strict: false` — JSDoc type annotations on selected files, types defined in `src/types/` +- **Test runner**: Node.js built-in `node --test` + +**Key service domains**: +- `services/session/` — Playwright browser session management (login, redeem, screenshots), the core automation engine +- `services/claim/` — Claim token generation and user-facing claim page API +- `services/admin/` — Admin panel CRUD (split into read/write helpers, auth, dashboard, etc.) +- `services/order/` — Webhook processing, order/inventory management, Agiso platform integration +- `services/platforms/` — Third-party platform adapters (currently Agiso for 闲鱼/Xianyu) +- `subservices/ocr-worker/` — Python (uv) microservice for CAPTCHA OCR, invoked via subprocess + +**Runtime data** (`data/` dir, mounted in Docker dev): logs, browser session artifacts, screenshots, `order-fulfillment-bindings.json` + +### Frontend (`apps/frontend/`) +- **Stack**: Vue 3, Vite 8, Element Plus, vue-router (hash mode), TypeScript +- **Routes**: `/tx/browser` (browser session control), `/claim/:token` (user claim page), `/admin/*` (admin panel with role-based guards) +- **Auto-imports**: `unplugin-auto-import` + `unplugin-vue-components` for Element Plus + +### Deployment (`deploy/`) +- Docker Compose: `docker-compose.yml` (production), `docker-compose.dev.yml` (hot-reload dev) +- Caddy reverse proxy: `/api/v1/*` → backend, `/*` → frontend +- Production Dockerfiles use Chinese mirrors (Tencent Cloud) for apt/npm/pip + +## Common Commands + +### Local Development (without Docker) +```bash +# Backend +cp .env.development.example .env +cd apps/backend +npm install +npm run dev # node --watch restart on src/ changes + +# Frontend +cd apps/frontend +npm install +npm run dev # Vite dev server + +# OCR worker (Python) +cd apps/backend/subservices/ocr-worker +uv sync +``` + +### Docker Development (recommended) +```bash +cp .env.development.example .env +docker compose -f docker-compose.dev.yml up -d --build +# Access: http://localhost, noVNC: http://localhost:6080/vnc.html +``` + +### Testing +```bash +# Run all backend tests +cd apps/backend && npm test + +# Run a single test file +cd apps/backend && node --test src/services/session/session-redeem.test.js + +# Run tests matching a pattern +cd apps/backend && node --test --test-name-pattern="redeem" +``` + +### Type Checking +```bash +cd apps/backend && npm run typecheck # tsc --noEmit (selected files) +cd apps/frontend && npm run typecheck # vue-tsc --noEmit +``` + +### Database +```bash +cd apps/backend && npm run db:migrate # Run migrations manually +cd apps/backend && npm run seed:dev-data # Seed dev data +cd apps/backend && npm run cleanup:dev-data # Clean dev data +``` + +### Browser (Playwright) +```bash +cd apps/backend && npm run browser:install # macOS +cd apps/backend && npm run browser:install:linux # Linux (with deps) +``` + +## API Routes + +All backend routes are prefixed with `/api/v1/`: +- `/api/v1/tencent/browser/*` — Browser session CRUD and control +- `/api/v1/webhooks/agiso/*` — Agiso platform webhook receiver +- `/api/v1/claim/*` — Claim token validation and redemption +- `/api/v1/admin/*` — Admin panel API (auth, orders, tasks, inventory, etc.) + +Health checks (no prefix): `/health`, `/health/live`, `/health/ready` + +## Important Conventions + +- Backend uses ESM throughout (`"type": "module"`) — always use `.js` extensions in imports +- Express route handlers use `buildSuccessPayload` / `sendRouteError` from `src/utils/http.js` for consistent response format +- Database queries go through `src/db/client.js` (`query()` / `withTransaction()`) — no raw pool usage in services +- Runtime config is accessed via `runtimeConfig` from `src/config/runtime.js` — never read `.env` directly outside that module +- Admin routes are split into separate files under `src/routes/admin/` with shared middleware in `shared.js` +- Frontend uses hash-based routing (`createWebHashHistory`) — claim links include `/#/claim/:token` +- Playwright version is pinned at `1.42.1` — do not upgrade without testing browser automation flows diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json index 1a7743e5..d3110406 100644 --- a/apps/backend/data/order-fulfillment-bindings.json +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -3,7 +3,7 @@ "provider": "agiso", "platform": "xianyu", "shopId": "2209880145223", - "skuCode": "sjz_hdl_test01", + "skuCode": "三角洲海底捞动作", "skuName": "海底捞第五人格皮肤", "profileKey": "tencent_claim_redeem", "enabled": true, @@ -22,7 +22,7 @@ "provider": "agiso", "platform": "xianyu", "shopId": "2209880145223", - "skuCode": "sjz_hdl_test01", + "skuCode": "三角洲海底捞动作", "skuName": "海底捞干员庆生动作1个", "profileKey": "tencent_claim_redeem", "enabled": true, @@ -41,7 +41,7 @@ "provider": "agiso", "platform": "xianyu", "shopId": "693760716", - "skuCode": "sjz_hdl_test01", + "skuCode": "三角洲海底捞动作", "skuName": "三角洲行动-自动领取", "profileKey": "tencent_claim_assisted", "enabled": true, diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh index 22439796..7787bda3 100644 --- a/apps/backend/scripts/start-dev-container.sh +++ b/apps/backend/scripts/start-dev-container.sh @@ -15,10 +15,43 @@ is_true() { start_browser_debug_services() { display="${TENCENT_BROWSER_DISPLAY:-:99}" - resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1440x1280x24}" + resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}" + display_number="${display#:}" + display_number="${display_number%%.*}" + display_socket="/tmp/.X11-unix/X${display_number}" + display_lock="/tmp/.X${display_number}-lock" + + if [ -f "$display_lock" ]; then + display_pid=$(cat "$display_lock" 2>/dev/null || true) + if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then + rm -f "$display_lock" "$display_socket" + fi + fi export DISPLAY="$display" Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 & + xvfb_pid=$! + + ready=0 + attempt=0 + while [ "$attempt" -lt 50 ]; do + if [ -S "$display_socket" ]; then + ready=1 + break + fi + + if ! kill -0 "$xvfb_pid" 2>/dev/null; then + break + fi + + attempt=$((attempt + 1)) + sleep 0.1 + done + + if [ "$ready" -ne 1 ]; then + echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2 + return + fi if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled" diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 887bdcac..d6b8d1cd 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -696,15 +696,39 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } } async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) { + return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) +} + +export async function redeemClaimTaskWithInventoryFallbackWithDeps( + context, + initialInventoryItem, + { + reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession, + redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession, + classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult, + markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed, + createTaskEvent: createRedeemTaskEvent = createTaskEvent, + invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem, + reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask, + nowIso: getNowIso = nowIso, + redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT, + } = {}, +) { const attempts = [] let currentInventoryItem = initialInventoryItem + let shouldReloadBeforeNextAttempt = false - for (let attemptIndex = 1; attemptIndex <= REDEEM_REPLACEMENT_LIMIT; attemptIndex += 1) { - const session = await redeemTencentBrowserSession(context.task.browser_session_id, { + for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) { + if (shouldReloadBeforeNextAttempt) { + await reloadRedeemSession(context.task.browser_session_id) + shouldReloadBeforeNextAttempt = false + } + + const session = await redeemSession(context.task.browser_session_id, { code: currentInventoryItem.display_value, }) const finalRedeem = session.redeem?.final?.redeem || null - const classification = classifyTencentRedeemResult(finalRedeem) + const classification = classifyRedeemResult(finalRedeem) const attemptSummary = { attempt: attemptIndex, inventoryItemId: currentInventoryItem.id, @@ -727,18 +751,18 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte } if (classification.outcome === 'code_used') { - const updatedAt = nowIso() - await markInventoryItemConsumed(currentInventoryItem.id, classification.message, updatedAt) - await createTaskEvent(context.task.id, 'claim_redeem_code_used', { + const updatedAt = getNowIso() + await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) + await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', { inventoryItemId: currentInventoryItem.id, codeMasked: attemptSummary.codeMasked, resultCode: attemptSummary.resultCode, resultMessage: classification.message, }, updatedAt) } else if (classification.outcome === 'code_invalid') { - const updatedAt = nowIso() - await invalidateReservedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) - await createTaskEvent(context.task.id, 'claim_redeem_code_invalid', { + const updatedAt = getNowIso() + await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt) + await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', { inventoryItemId: currentInventoryItem.id, codeMasked: attemptSummary.codeMasked, resultCode: attemptSummary.resultCode, @@ -755,7 +779,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte }) } - if (attemptIndex >= REDEEM_REPLACEMENT_LIMIT) { + if (attemptIndex >= redeemReplacementLimit) { throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', { errorCode: 'claim_redeem_replacement_limit_reached', taskStatus: 'retry_pending', @@ -766,7 +790,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte }) } - const replacement = await reserveInventoryForTask({ + const replacement = await reserveReplacementInventory({ skuCode: context.orderItem.sku_code, taskId: context.task.id, credentialType: currentInventoryItem.credential_type || 'tencent_code', @@ -788,8 +812,8 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte }) } - const replacedAt = nowIso() - await createTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { + const replacedAt = getNowIso() + await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { previousInventoryItemId: currentInventoryItem.id, previousCodeMasked: attemptSummary.codeMasked, previousOutcome: classification.outcome, @@ -798,6 +822,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), }, replacedAt) currentInventoryItem = replacement + shouldReloadBeforeNextAttempt = true } throw createRedeemTaskStateError('兑换失败,请稍后重试', { diff --git a/apps/backend/src/services/claim/claim-session-service.test.js b/apps/backend/src/services/claim/claim-session-service.test.js new file mode 100644 index 00000000..c83e25bc --- /dev/null +++ b/apps/backend/src/services/claim/claim-session-service.test.js @@ -0,0 +1,167 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js' + +test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => { + const events = [] + const invalidated = [] + const reserveCalls = [] + const reloadCalls = [] + + const context = { + task: { + id: 42, + browser_session_id: 'txbs-test', + }, + orderItem: { + sku_code: 'df-cdk', + }, + } + const initialInventoryItem = { + id: 1, + display_value: 'DJQFf7HgONCL4XD4EH', + credential_type: 'tencent_code', + } + const replacementInventoryItem = { + id: 2, + display_value: 'ABCD1234EFGH5678', + credential_type: 'tencent_code', + } + + let redeemCount = 0 + const result = await redeemClaimTaskWithInventoryFallbackWithDeps( + context, + initialInventoryItem, + { + reloadTencentBrowserSession: async (sessionId) => { + reloadCalls.push(sessionId) + return { sessionId, status: 'ready_to_redeem' } + }, + redeemTencentBrowserSession: async (sessionId, payload) => { + redeemCount += 1 + if (redeemCount === 1) { + assert.equal(sessionId, 'txbs-test') + assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH') + return { + redeem: { + final: { + redeem: { + iRet: -183, + sMsg: '该CDKEY不存在,请您确认后输入!', + }, + }, + }, + } + } + + assert.equal(payload.code, 'ABCD1234EFGH5678') + return { + redeem: { + final: { + redeem: { + iRet: 0, + sMsg: '领取成功', + }, + }, + }, + } + }, + invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => { + invalidated.push({ inventoryItemId, reason, updatedAt }) + return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt } + }, + reserveInventoryForTask: async (payload) => { + reserveCalls.push(payload) + return replacementInventoryItem + }, + createTaskEvent: async (taskId, eventType, payload, createdAt) => { + events.push({ taskId, eventType, payload, createdAt }) + }, + nowIso: (() => { + const values = [ + '2026-04-14T12:03:34.000Z', + '2026-04-14T12:03:35.000Z', + ] + let index = 0 + return () => values[index++] || values[values.length - 1] + })(), + }, + ) + + assert.equal(result.inventoryItem.id, 2) + assert.equal(result.classification.outcome, 'success') + assert.equal(result.attempts.length, 2) + assert.deepEqual( + result.attempts.map((attempt) => ({ + attempt: attempt.attempt, + inventoryItemId: attempt.inventoryItemId, + outcome: attempt.outcome, + resultCode: attempt.resultCode, + })), + [ + { + attempt: 1, + inventoryItemId: 1, + outcome: 'code_invalid', + resultCode: '-183', + }, + { + attempt: 2, + inventoryItemId: 2, + outcome: 'success', + resultCode: '0', + }, + ], + ) + assert.deepEqual(invalidated, [ + { + inventoryItemId: 1, + reason: '该CDKEY不存在,请您确认后输入!', + updatedAt: '2026-04-14T12:03:34.000Z', + }, + ]) + assert.deepEqual(reserveCalls, [ + { + skuCode: 'df-cdk', + taskId: 42, + credentialType: 'tencent_code', + roleKey: 'primary_code', + }, + ]) + assert.deepEqual(reloadCalls, ['txbs-test']) + assert.deepEqual( + events.map((event) => ({ + eventType: event.eventType, + taskId: event.taskId, + createdAt: event.createdAt, + payload: event.payload, + })), + [ + { + eventType: 'claim_redeem_code_invalid', + taskId: 42, + createdAt: '2026-04-14T12:03:34.000Z', + payload: { + inventoryItemId: 1, + codeMasked: 'DJQF****D4EH', + resultCode: '-183', + resultMessage: '该CDKEY不存在,请您确认后输入!', + }, + }, + { + eventType: 'claim_redeem_inventory_replaced', + taskId: 42, + createdAt: '2026-04-14T12:03:35.000Z', + payload: { + previousInventoryItemId: 1, + previousCodeMasked: 'DJQF****D4EH', + previousOutcome: 'code_invalid', + nextInventoryItemId: 2, + nextCodeMasked: 'ABCD****5678', + credentialType: 'tencent_code', + }, + }, + ], + ) +}) diff --git a/apps/backend/src/services/session/session-redeem.js b/apps/backend/src/services/session/session-redeem.js index a911d6dd..2e8fbe06 100644 --- a/apps/backend/src/services/session/session-redeem.js +++ b/apps/backend/src/services/session/session-redeem.js @@ -299,7 +299,11 @@ export function classifyTencentRedeemResult(result) { return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用') } - if (/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确/.test(normalized)) { + if ( + retCode === -165 || + retCode === -183 || + /兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized) + ) { return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确') } diff --git a/apps/backend/src/services/session/session-redeem.test.js b/apps/backend/src/services/session/session-redeem.test.js index 26535ef3..e22f4a89 100644 --- a/apps/backend/src/services/session/session-redeem.test.js +++ b/apps/backend/src/services/session/session-redeem.test.js @@ -45,6 +45,17 @@ test('classifyTencentRedeemResult marks invalid codes as replacement retries', ( assert.equal(result.retryWithReplacementCode, true) }) +test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => { + const result = classifyTencentRedeemResult({ + iRet: -183, + sMsg: '该CDKEY不存在,请您确认后输入!', + }) + + assert.equal(result.outcome, 'code_invalid') + assert.equal(result.success, false) + assert.equal(result.retryWithReplacementCode, true) +}) + test('classifyTencentRedeemResult recognizes successful gift popup', () => { const result = classifyTencentRedeemResult({ popup: { diff --git a/apps/backend/src/services/session/session.js b/apps/backend/src/services/session/session.js index ac6b18e6..8fc9eb31 100644 --- a/apps/backend/src/services/session/session.js +++ b/apps/backend/src/services/session/session.js @@ -41,7 +41,7 @@ const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() const DEFAULT_LOGIN_TYPE = 'qq' const SESSION_TTL_MS = 30 * 60 * 1000 -const DEFAULT_VIEWPORT = { width: 1440, height: 1280 } +const DEFAULT_VIEWPORT = resolveDefaultViewport() const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) const sessions = new Map() @@ -429,6 +429,37 @@ function buildChromiumLaunchOptions(launchOptions) { return options } +function resolveDefaultViewport() { + const fromVncResolution = parseViewportSpec(process.env.TENCENT_BROWSER_VNC_RESOLUTION) + if (fromVncResolution) { + return fromVncResolution + } + + return { width: 1600, height: 900 } +} + +function parseViewportSpec(rawValue) { + const text = String(rawValue || '').trim() + + if (!text) { + return null + } + + const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i) + if (!match) { + return null + } + + const width = Number(match[1]) + const height = Number(match[2]) + + if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) { + return null + } + + return { width, height } +} + function createSessionId() { let sessionId = '' diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 88290672..add9b1fd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -53,7 +53,7 @@ services: TENCENT_BROWSER_NOVNC_ENABLED: ${TENCENT_BROWSER_NOVNC_ENABLED:-true} TENCENT_BROWSER_NOVNC_PORT: ${TENCENT_BROWSER_NOVNC_PORT:-6080} TENCENT_BROWSER_VNC_PORT: ${TENCENT_BROWSER_VNC_PORT:-5900} - TENCENT_BROWSER_VNC_RESOLUTION: ${TENCENT_BROWSER_VNC_RESOLUTION:-1440x1280x24} + TENCENT_BROWSER_VNC_RESOLUTION: ${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} diff --git a/docs/app信息.md b/docs/app信息.md index 1e4471f4..71f67bc0 100644 --- a/docs/app信息.md +++ b/docs/app信息.md @@ -52,8 +52,8 @@ admin / dev-admin-123456 operator / dev-operator-123456 -DJQFf7Hg0VKY82JG76 -DJQFf7HgONCL4XD4EH +DJQFf7Hg0VKY82JG76 已使用 +DJQFf7HgONCL4XD4EH 错误的 未使用 DJPaMY6M0X1SFKAFQT diff --git a/docs/cloudflared_tunnel.txt b/docs/cloudflared_tunnel.txt new file mode 100644 index 00000000..3810e71b --- /dev/null +++ b/docs/cloudflared_tunnel.txt @@ -0,0 +1,59 @@ + + +cloudflared tunnel create mac-mini-tunnel + +yml@ymlMacBook-Air ~ % cloudflared tunnel create mac-mini-tunnel +Tunnel credentials written to /Users/yml/.cloudflared/562f9639-71f2-44e0-a5ef-b34bd4e215e5.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel. + +Created tunnel mac-mini-tunnel with id 562f9639-71f2-44e0-a5ef-b34bd4e215e5 +yml@ymlMacBook-Air ~ % + + +221329.cc.cd + +4. # 例子:把 web.yourdomain.com 指向刚才创建的隧道 +cloudflared tunnel route dns mac-mini-tunnel 221329.cc.cd + + +5. 启动隧道(临时/测试) +如果你只是临时跑一个服务(比如你 OrbStack 里的容器映射到了本地 80 端口): + +cloudflared tunnel run --url http://localhost:80 mac-mini-tunnel + +----- +如果两台电脑**不会同时运行**(例如一台是公司的 iMac,一台是家里的 MacBook),那么处理起来就简单得多了。 + +在这种情况下,你可以直接采用 **“共用同一个 Tunnel ID”** 的方案。这在 Cloudflare 的术语中叫做多副本(Replicas),虽然你不是为了负载均衡,但它能完美解决你的需求。 + +### 实现方案:共用 Tunnel +你只需要确保两台电脑拥有完全一致的“身份令牌”和“配置文件”。 + +1. **迁移证书与密钥:** + * 在第一台电脑(已配置好)上,找到这两个文件: + * `~/.cloudflared/cert.pem`(登录证书) + * `~/.cloudflared/.json`(隧道的身份密钥) + * 将它们拷贝到第二台电脑的 `~/.cloudflared/` 目录下。 + +2. **同步配置文件:** + * 将 `config.yml` 也拷贝过去。 + * **注意:** 确保两台电脑上运行的本地服务端口是一致的(比如都是 `localhost:8080`)。 + +3. **直接运行:** + * 在任意一台电脑上运行 `cloudflared tunnel run <隧道名>` 即可。 + +--- + +### 这样做的优势 +* **域名无缝切换:** 你不需要在 Cloudflare 后台修改任何 DNS 记录。只要其中一台电脑开启了隧道,`example.yourdomain.com` 就会立即指向那台电脑。 +* **无需重复配置:** 所有的路由规则(Ingress rules)都是一致的,维护起来非常省心。 + +### 注意事项 +* **如果真的“不小心”同时运行了:** + Cloudflare 会把它们当成一个隧道的两个节点。流量会以类似“轮询”的方式随机分配到两台电脑上。如果这时你的两台电脑跑的服务内容不一样,访问者可能会看到一会儿是 A 电脑的内容,一会儿是 B 电脑的内容。 +* **路径一致性:** + 在 `config.yml` 中,`credentials-file` 的路径如果写的是绝对路径(如 `/Users/jack/...`),请确保两台电脑的用户名一致;如果不一致,记得修改配置文件里的路径。 + +--- + +**总结:** +既然不同时运行,那就把第二台电脑伪装成第一台电脑的“克隆体”。只需拷贝那几个关键文件,你就能实现在家、在公司用同一个域名访问不同的机器了。 \ No newline at end of file