diff --git a/README.md b/README.md index 3ff9d607..ff50b735 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ docker compose -f docker-compose.dev.yml up -d --build - 浏览器会话产物 - 截图和证明文件 +当 `TENCENT_BROWSER_HEADLESS=false` 时,开发版后端容器会自动启动 `Xvfb + x11vnc + noVNC`。 +默认可直接在本机访问: + +- `http://127.0.0.1:6080/vnc.html` + +这样即使整个项目都跑在 Docker 里,也可以直接查看后端 Playwright 浏览器画面。 + PostgreSQL 数据则保存在 Docker volume 里: - `postgres_dev_data` diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh new file mode 100644 index 00000000..22439796 --- /dev/null +++ b/apps/backend/scripts/start-dev-container.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +is_true() { + normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]') + case "$normalized" in + 1|true|yes|on) + return 0 + ;; + *) + return 1 + ;; + esac +} + +start_browser_debug_services() { + display="${TENCENT_BROWSER_DISPLAY:-:99}" + resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1440x1280x24}" + + export DISPLAY="$display" + Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 & + + if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then + echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled" + return + fi + + vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}" + novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}" + + x11vnc \ + -display "$display" \ + -rfbport "$vnc_port" \ + -forever \ + -shared \ + -nopw \ + -listen 0.0.0.0 \ + -xkb >/tmp/x11vnc.log 2>&1 & + + if command -v novnc_proxy >/dev/null 2>&1; then + novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & + elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then + /usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & + elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then + websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & + else + echo "[backend-dev] noVNC requested but novnc/websockify not found" + return + fi + + echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html" +} + +npm install --no-fund --no-audit +python3 -m pip install --no-cache-dir --break-system-packages -e /app/subservices/ocr-worker + +if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then + start_browser_debug_services +fi + +exec npm run dev diff --git a/apps/backend/src/repositories/inventory-repo.js b/apps/backend/src/repositories/inventory-repo.js index 3af47e3c..5410c89f 100644 --- a/apps/backend/src/repositories/inventory-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -193,6 +193,54 @@ export async function listInventoryItems({ } } +export async function listInventorySkuSuggestions({ + credentialType = '', + keyword = '', + limit = 50, +} = {}) { + const filters = [] + const params = [] + + if (credentialType) { + params.push(credentialType) + filters.push(`credential_type = $${params.length}`) + } + + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`sku_code ILIKE $${params.length}`) + } + + const normalizedLimit = Number.isFinite(Number(limit)) + ? Math.max(1, Math.min(Number(limit), 100)) + : 50 + + params.push(normalizedLimit) + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const result = await query( + ` + SELECT + sku_code, + credential_type, + COUNT(*)::int AS total_count, + COUNT(*) FILTER (WHERE status = 'available')::int AS available_count, + MAX(updated_at) AS latest_updated_at + FROM inventory_items + ${whereClause} + GROUP BY sku_code, credential_type + ORDER BY + COUNT(*) FILTER (WHERE status = 'available') DESC, + COUNT(*) DESC, + MAX(updated_at) DESC, + sku_code ASC + LIMIT $${params.length} + `, + params, + ) + + return result.rows +} + export async function createInventoryItems(rows) { let created = 0 diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js index 86369e83..6c8fd7e2 100644 --- a/apps/backend/src/routes/admin/inventory.js +++ b/apps/backend/src/routes/admin/inventory.js @@ -3,6 +3,7 @@ import { Router } from 'express' import { createAdminInventoryItem, getAdminInventoryItems, + getAdminInventorySkuSuggestions, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem, @@ -22,6 +23,15 @@ router.get('/inventory', createJsonHandler( }, )) +router.get('/inventory/sku-suggestions', createJsonHandler( + (req) => getAdminInventorySkuSuggestions(req.query), + { + successMessage: 'ok', + errorMessage: '读取内部 SKU 建议失败', + scope: '[admin/inventory/sku-suggestions]', + }, +)) + router.post('/inventory', createJsonHandler( (req) => createAdminInventoryItem(req.body), { diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index dea76718..d2f63b16 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -21,6 +21,7 @@ import { getInventoryItemById, invalidateInventoryItem, listInventoryItems, + listInventorySkuSuggestions, markInventoryItemDelivered, releaseReservedInventoryItem, } from '../../repositories/inventory-repo.js' @@ -155,6 +156,7 @@ export async function getAdminOrderDetail(orderId) { itemSummary, rawPayload: safeParseJson(order.raw_payload_json), bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), + agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), }, items: items.map((item) => ({ orderItemId: item.id, @@ -424,6 +426,30 @@ export async function createAdminInventoryItem(payload = {}) { } } +export async function getAdminInventorySkuSuggestions(query = {}) { + const credentialType = String(query.credentialType || '').trim() + const keyword = String(query.keyword || '').trim() + const requestedLimit = Number(query.limit) + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.min(100, Math.floor(requestedLimit)) + : 50 + const items = await listInventorySkuSuggestions({ + credentialType, + keyword, + limit, + }) + + return { + items: items.map((item) => ({ + skuCode: String(item.sku_code || '').trim(), + credentialType: String(item.credential_type || '').trim() || 'tencent_code', + totalCount: Number(item.total_count || 0), + availableCount: Number(item.available_count || 0), + latestUpdatedAt: item.latest_updated_at || null, + })), + } +} + export async function importAdminInventoryItems(payload = {}) { const rows = normalizeInventoryImportRows(payload) @@ -1286,10 +1312,12 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) { const binding = buildTaskBindingState(task) + const taskContext = parseTaskContext(task) return { taskId: task.id, taskNo: task.task_no, + deliveryStatus: task.delivery_status || '', status: task.task_status, systemBindingStatus: binding.systemBindingStatus, userBindingStatus: binding.userBindingStatus, @@ -1301,6 +1329,7 @@ function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummar lastError: task.last_error, retryCount: getTaskRetryCount(task), bindingSummary, + agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), createdAt: task.created_at, updatedAt: task.updated_at, } @@ -1606,6 +1635,7 @@ async function getRequiredInventoryItem(inventoryItemId) { function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) { const binding = buildTaskBindingState(task) + const taskContext = parseTaskContext(task) return { taskId: task.id, @@ -1629,6 +1659,7 @@ function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSumma redeemedAt: task.redeemed_at, retryCount: getTaskRetryCount(task), bindingSummary, + agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, @@ -1892,6 +1923,61 @@ function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) { } } +function buildOrderAgisoAutoDeliverySummary(order, tasks = []) { + if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { + return null + } + + const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] + const totalTaskCount = normalizedTasks.length + const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length + const latest = normalizedTasks.reduce((best, task) => { + const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) + + if (!autoDelivery) { + return best + } + + const candidate = { + ...autoDelivery, + sourceTaskId: Number(task.id || 0) || null, + sourceTaskNo: String(task.task_no || '').trim(), + } + const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) + const bestTime = Date.parse(String(best?.updatedAt || '')) + + if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { + return candidate + } + + return best + }, null) + + if (latest) { + return { + ...latest, + totalTaskCount, + deliveredTaskCount, + } + } + + return { + status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', + trigger: '', + reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', + platformOrderId: String(order?.platform_order_id || '').trim(), + responseStatus: 0, + errorMessage: '', + requestId: '', + aldsType: null, + updatedAt: null, + sourceTaskId: null, + sourceTaskNo: '', + totalTaskCount, + deliveredTaskCount, + } +} + function buildTaskBindingState(task) { const normalizedStatus = String(task?.task_status || '').trim() @@ -2018,6 +2104,24 @@ function mapManualDispatchContext(value, viewerContext = createAdminViewerContex } } +function mapAgisoAutoDeliveryContext(value) { + if (!value || typeof value !== 'object') { + return null + } + + return { + status: String(value.status || '').trim(), + trigger: String(value.trigger || '').trim(), + reason: String(value.reason || '').trim(), + platformOrderId: String(value.platformOrderId || '').trim(), + responseStatus: Number(value.responseStatus || 0), + errorMessage: String(value.errorMessage || '').trim(), + requestId: String(value.requestId || '').trim(), + aldsType: Number(value.aldsType || 0) || null, + updatedAt: value.updatedAt || null, + } +} + function mapRedeemResolutionContext(value) { if (!value || typeof value !== 'object') { return null diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index 976633d5..817000b7 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -12,11 +12,11 @@ function resolveTone(status: string) { return 'success' } - if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) { + if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding', 'pending', 'waiting'].includes(normalized)) { return 'primary' } - if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed'].includes(normalized)) { + if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed', 'skipped'].includes(normalized)) { return 'warning' } @@ -65,6 +65,9 @@ function resolveLabel(status: string) { revoked: '已撤销', disabled: '已停用', success: '成功', + skipped: '已跳过', + pending: '待触发', + waiting: '等待中', processing: '处理中', system_bound: '系统已绑定', pending_binding: '待系统绑定', diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index be1b0ab6..fbaac6fb 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -6,6 +6,7 @@ import type { AdminFulfillmentLookupResult, AdminFulfillmentBindingConfigItem, AdminInventoryItemListItem, + AdminInventorySkuSuggestion, AdminMessageDeliveryListItem, AdminObservedProductItem, AdminTaskActionResponse, @@ -173,6 +174,10 @@ export function fetchAdminInventoryItems(params?: Record) { return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params) } +export function fetchAdminInventorySkuSuggestions(params?: Record) { + return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/inventory/sku-suggestions', params) +} + export function fetchAdminMessageDeliveries(params?: Record) { return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>( '/api/v1/admin/message-deliveries', diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index c3623baa..cfb5277a 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -160,6 +160,25 @@ export interface AdminOrderBindingSummary { userBindingStatus: string } +export interface AdminAgisoAutoDeliveryStatus { + status: string + trigger: string + reason: string + platformOrderId: string + responseStatus: number + errorMessage: string + requestId: string + aldsType: number | null + updatedAt: string | null +} + +export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus { + totalTaskCount: number + deliveredTaskCount: number + sourceTaskId: number | null + sourceTaskNo: string +} + export interface AdminOrderListItem { orderId: number provider: string @@ -221,6 +240,7 @@ export interface AdminTaskListItem { inventoryCredentialType: string claimToken: string screenshotPath: string + agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export interface AdminTaskOperations { @@ -274,6 +294,7 @@ export interface AdminOrderDetail { itemSummary: string rawPayload: Record bindingSummary: AdminOrderBindingSummary + agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null } items: Array<{ orderItemId: number @@ -284,23 +305,7 @@ export interface AdminOrderDetail { deliveryMode: string spec: Record }> - tasks: Array<{ - taskId: number - taskNo: string - status: string - systemBindingStatus: string - userBindingStatus: string - loginType: string - browserSessionId: string - claimedAt: string | null - roleConfirmedAt: string | null - redeemedAt: string | null - lastError: string - retryCount: number - bindingSummary: AdminTaskBindingSummary - createdAt: string - updatedAt: string - }> + tasks: AdminTaskListItem[] webhookEvents: Array<{ eventId: number provider: string @@ -320,6 +325,10 @@ export interface AdminTaskDetail { task: AdminTaskListItem order: null | { orderId: number + provider: string + platform: string + shopId: string + shopName: string platformOrderId: string payStatus: string orderStatus: string @@ -409,6 +418,14 @@ export interface AdminTaskDetail { operations: AdminTaskOperations } +export interface AdminInventorySkuSuggestion { + skuCode: string + credentialType: string + totalCount: number + availableCount: number + latestUpdatedAt: string | null +} + export interface AdminInventoryItemListItem { inventoryItemId: number skuCode: string diff --git a/apps/frontend/src/views/admin/AdminInventoryView.vue b/apps/frontend/src/views/admin/AdminInventoryView.vue index 2197cbf6..e70f1eb1 100644 --- a/apps/frontend/src/views/admin/AdminInventoryView.vue +++ b/apps/frontend/src/views/admin/AdminInventoryView.vue @@ -1,5 +1,5 @@