diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.test.ts b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.test.ts new file mode 100644 index 00000000..4fa5ff47 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.test.ts @@ -0,0 +1,76 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonError = jest.fn() +const mockJsonSuccess = jest.fn() +const mockProcessDue = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/services/jushuitan-fixed-virtual-inventory.service', () => ({ + jushuitanFixedVirtualInventoryService: { + processDue: (...args: unknown[]) => mockProcessDue(...args), + }, +})) + +const { POST } = require('./route') + +function createRequest(input: { secret?: string } = {}) { + return { + headers: { + get: (name: string) => + name === 'x-jst-virtual-inventory-secret' ? input.secret ?? null : null, + }, + } +} + +describe('POST fixed Jushuitan virtual inventory push', () => { + beforeEach(() => { + jest.clearAllMocks() + process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET = 'cron-secret' + mockHandleRequest.mockImplementation( + async (_request: unknown, handler: (ctx: unknown) => unknown) => + handler({ requestId: 'request-1', user: null }), + ) + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + code, + message, + status, + requestId, + })) + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + data, + status, + requestId, + })) + mockProcessDue.mockResolvedValue({ processed: 1, pushed: 1, failed: 0 }) + }) + + afterAll(() => { + delete process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET + }) + + it('rejects a request without the cron secret', async () => { + const response = await POST(createRequest()) + + expect(response.status).toBe(401) + expect(mockProcessDue).not.toHaveBeenCalled() + }) + + it('runs the daily batch for the authenticated 20:00 request', async () => { + const response = await POST(createRequest({ secret: 'cron-secret' })) + + expect(mockProcessDue).toHaveBeenCalledWith( + expect.objectContaining({ source: 'SYSTEM' }), + ) + expect(response.data).toEqual({ + processed: 1, + pushed: 1, + failed: 0, + }) + }) +}) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.ts new file mode 100644 index 00000000..facdf831 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import type { AuthUser } from '@/server/core/types' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { jushuitanFixedVirtualInventoryService } from '@/server/services/jushuitan-fixed-virtual-inventory.service' + +function getCronUser(request: NextRequest): AuthUser | null { + const cronSecret = process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET?.trim() + if (!cronSecret) return null + + const headerSecret = + request.headers.get('x-jst-virtual-inventory-secret')?.trim() ?? '' + if (headerSecret !== cronSecret) return null + + return { + id: 'jst-fixed-virtual-inventory-cron', + email: 'jst-fixed-virtual-inventory-cron@local', + name: '聚水潭固定虚拟库存定时任务', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + businessRoles: [], + } +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const user = ctx.user ?? getCronUser(request) + if (!user) { + return jsonError( + ErrorCode.AUTH_INVALID_TOKEN, + '请先登录或配置固定虚拟库存定时任务密钥', + 401, + ctx.requestId, + ) + } + + const result = await jushuitanFixedVirtualInventoryService.processDue({ + actor: user, + source: ctx.user ? 'USER' : 'SYSTEM', + actorId: ctx.user?.id, + requestId: ctx.requestId, + now: new Date(), + }) + + return jsonSuccess(result, 200, ctx.requestId) + }, { requireAuth: false }) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts index 4bb6c84f..12b9a43f 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts @@ -13,7 +13,6 @@ import { applyVirtualInventoryPushResultToRows, applyVirtualInventoryRunFailureToRows, } from '@/server/services/jushuitan-virtual-inventory-queue.service' -import { jushuitanFixedVirtualInventoryService } from '@/server/services/jushuitan-fixed-virtual-inventory.service' const DUE_PRODUCT_PAGE_SIZE = 20 const MAX_DUE_PRODUCTS_PER_RUN = 100 @@ -425,16 +424,6 @@ export async function POST(request: NextRequest) { if (dueProductIds.length < requestedPageSize) break } - const fixedVirtualInventory = ctx.user - ? { processed: 0, pushed: 0, failed: 0 } - : await jushuitanFixedVirtualInventoryService.processDue({ - actor: user, - source: logSource, - actorId, - requestId: ctx.requestId, - now, - }) - return jsonSuccess( { processedProductCount: processedProducts.length, @@ -446,7 +435,6 @@ export async function POST(request: NextRequest) { 0, ), processedProducts, - fixedVirtualInventory, }, 200, ctx.requestId, diff --git a/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.test.ts b/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.test.ts index a6605ba8..a9535740 100644 --- a/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.test.ts +++ b/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.test.ts @@ -66,7 +66,7 @@ describe("Jushuitan fixed virtual inventory service", () => { it("builds the fixed warehouse and quantity payload from a barcode", () => { expect(buildFixedVirtualInventoryPayload("BARCODE-1")).toEqual({ wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID, - items: [ + sku_and_qty: [ { sku_id: "BARCODE-1", qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY }, ], }); @@ -132,6 +132,36 @@ describe("Jushuitan fixed virtual inventory service", () => { ); }); + it("retries a code-zero response without an external request id", async () => { + const repository = createRepository([createTask()]); + const post = jest.fn(async () => ({ + path: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH, + code: 0, + message: "ok", + raw: { code: 0, data: {} }, + })); + jest.spyOn(operationLogService, "record").mockResolvedValue(undefined); + const service = new JushuitanFixedVirtualInventoryService(repository, post); + const now = new Date("2026-08-10T00:00:00.000Z"); + + const result = await service.processDue({ + actor, + source: "SYSTEM", + requestId: "local-request-1", + now, + }); + + expect(result).toEqual({ processed: 1, pushed: 0, failed: 1 }); + expect(repository.markSucceeded).not.toHaveBeenCalled(); + expect(repository.scheduleRetry).toHaveBeenCalledWith( + expect.objectContaining({ + id: "task-1", + attemptedAt: now, + error: "Jushuitan fixed virtual inventory response is missing request_id", + }), + ); + }); + it("schedules every failed task exactly two hours later", async () => { const repository = createRepository([createTask()]); const post = jest.fn(async () => { @@ -164,4 +194,5 @@ describe("Jushuitan fixed virtual inventory service", () => { expect.objectContaining({ status: OperationLogStatus.FAILED }), ); }); + }); diff --git a/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.ts b/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.ts index 4a7a4790..89d1ab0d 100644 --- a/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.ts +++ b/furniture-3d-system/src/server/services/jushuitan-fixed-virtual-inventory.service.ts @@ -20,6 +20,8 @@ export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH = export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID = 15994660; export const JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY = 100000; export const FIXED_VIRTUAL_INVENTORY_RETRY_DELAY_MS = 2 * 60 * 60 * 1000; +const FIXED_VIRTUAL_INVENTORY_BATCH_SIZE = 100; +const MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN = 5000; type FixedVirtualInventoryProduct = { id: string; @@ -68,7 +70,7 @@ function normalizeError(error: unknown) { function buildFixedVirtualInventoryPayload(barcode: string) { return { wms_co_id: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_WMS_CO_ID, - items: [ + sku_and_qty: [ { sku_id: barcode, qty: JUSHUITAN_FIXED_VIRTUAL_INVENTORY_QTY, @@ -105,16 +107,26 @@ class JushuitanFixedVirtualInventoryService { async processDue(input: FixedVirtualInventoryRunInput) { const now = input.now ?? new Date(); - const tasks = await this.repository.findDue(now); const result = { processed: 0, pushed: 0, failed: 0 }; - for (const task of tasks) { - result.processed += 1; - if (await this.processOne(task, input)) { - result.pushed += 1; - } else { - result.failed += 1; + while (result.processed < MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN) { + const take = Math.min( + FIXED_VIRTUAL_INVENTORY_BATCH_SIZE, + MAX_FIXED_VIRTUAL_INVENTORY_TASKS_PER_RUN - result.processed, + ); + const tasks = await this.repository.findDue(now, take); + if (tasks.length === 0) break; + + for (const task of tasks) { + result.processed += 1; + if (await this.processOne(task, input)) { + result.pushed += 1; + } else { + result.failed += 1; + } } + + if (tasks.length < take) break; } return result; @@ -141,7 +153,12 @@ class JushuitanFixedVirtualInventoryService { JUSHUITAN_FIXED_VIRTUAL_INVENTORY_PATH, payload, ); - const requestId = readRequestId(response.raw) || input.requestId; + const requestId = readRequestId(response.raw); + if (!requestId) { + throw new Error( + "Jushuitan fixed virtual inventory response is missing request_id", + ); + } await this.repository.markSucceeded({ id: task.id, attemptedAt, @@ -157,7 +174,7 @@ class JushuitanFixedVirtualInventoryService { productSpu: task.productSpu, skuId: task.skuItemId, skuCode: task.skuCode, - message: "Jushuitan fixed virtual inventory push succeeded", + message: "Jushuitan fixed virtual inventory request accepted", requestId, details: { flow: "fixed-wms-virtual-inventory", diff --git a/scripts/deploy-yydd-prod-from-git.ps1 b/scripts/deploy-yydd-prod-from-git.ps1 index 13fe9a33..32f31779 100644 --- a/scripts/deploy-yydd-prod-from-git.ps1 +++ b/scripts/deploy-yydd-prod-from-git.ps1 @@ -387,19 +387,27 @@ start_pm2_release() { install_virtual_inventory_push_cron() { local cron_file local cron_line + local fixed_cron_line local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock" + local fixed_lock_file="/tmp/furniture-3d-system-fixed-virtual-inventory-push.lock" local endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-virtual-inventory-push" + local fixed_endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push" local log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log" + local fixed_log_file="$SHARED/logs/jushuitan-fixed-virtual-inventory-push.log" mkdir -p "$SHARED/logs" cron_file="$(mktemp)" (crontab -l 2>/dev/null || true) | sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' | - sed '\|furniture-3d-system-virtual-inventory-push|d' > "$cron_file" + sed '\|furniture-3d-system-virtual-inventory-push|d' | + sed '\|/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push|d' | + sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' > "$cron_file" cron_line="* * * * * /usr/bin/flock -n \"$lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 55 --request POST \"$endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$log_file\" 2>&1 # furniture-3d-system-virtual-inventory-push" + fixed_cron_line="0 20 * * * /usr/bin/flock -n \"$fixed_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 1800 --request POST \"$fixed_endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$fixed_log_file\" 2>&1 # furniture-3d-system-fixed-virtual-inventory-push" printf '%s\n' "$cron_line" >> "$cron_file" + printf '%s\n' "$fixed_cron_line" >> "$cron_file" crontab "$cron_file" rm -f -- "$cron_file" }