优化兑换流程, 优化浏览器分辨率
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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('兑换失败,请稍后重试', {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
@@ -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 || '兑换码错误,请确认兑换码信息是否准确')
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ admin / dev-admin-123456
|
||||
operator / dev-operator-123456
|
||||
|
||||
|
||||
DJQFf7Hg0VKY82JG76
|
||||
DJQFf7HgONCL4XD4EH
|
||||
DJQFf7Hg0VKY82JG76 已使用
|
||||
DJQFf7HgONCL4XD4EH 错误的
|
||||
|
||||
未使用
|
||||
DJPaMY6M0X1SFKAFQT
|
||||
|
||||
@@ -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/<Tunnel-ID>.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/...`),请确保两台电脑的用户名一致;如果不一致,记得修改配置文件里的路径。
|
||||
|
||||
---
|
||||
|
||||
**总结:**
|
||||
既然不同时运行,那就把第二台电脑伪装成第一台电脑的“克隆体”。只需拷贝那几个关键文件,你就能实现在家、在公司用同一个域名访问不同的机器了。
|
||||
Reference in New Issue
Block a user