瀏覽代碼

fix(vp): show target printer's serial on proxy-mode VP card

  The runtime services (SSDP, MQTT bind identity, cert subject) already
  advertise the target printer's serial via target_printer_serial or
  self.serial in proxy mode, but the API response that drives the VP
  settings card always returned the self-generated suffix-based serial.
  The card therefore displayed a serial that didn't match what slicers
  see, breaking the "one identity per VP" mental model.

  _vp_to_dict now resolves vp.target_printer_id -> Printer.serial_number
  when mode == VP_MODE_PROXY and substitutes the result into the response
  serial field. Archive / queue / review keep the self-generated serial
  (those modes never speak the target's identity). Orphaned target falls
  back to self-generated so the card still renders.
maziggy 3 月之前
父節點
當前提交
7b4c5b3c1f
共有 3 個文件被更改,包括 102 次插入6 次删除
  1. 3 0
      CHANGELOG.md
  2. 19 6
      backend/app/api/routes/virtual_printers.py
  3. 80 0
      backend/tests/integration/test_virtual_printer_api.py

文件差異過大導致無法顯示
+ 3 - 0
CHANGELOG.md


+ 19 - 6
backend/app/api/routes/virtual_printers.py

@@ -74,13 +74,26 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
-def _vp_to_dict(vp, status: dict | None = None) -> dict:
-    """Convert VirtualPrinter model to response dict."""
+async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
+    """Convert VirtualPrinter model to response dict.
+
+    In proxy mode the surfaced serial is the target printer's actual serial
+    (what the bridge advertises over SSDP / what slicers see), not the
+    self-generated suffix. Archive / queue / review keep the self-generated
+    serial since those modes never speak the target's identity.
+    """
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VP_MODE_PROXY
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
 
     model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
     serial = _get_serial_for_model(model_code, vp.serial_suffix)
+    if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
+        result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
+        target_serial = result.scalar_one_or_none()
+        if target_serial:
+            serial = target_serial
 
     return {
         "id": vp.id,
@@ -118,7 +131,7 @@ async def list_virtual_printers(
     for vp in vps:
         instance = virtual_printer_manager.get_instance(vp.id)
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
-        printers.append(_vp_to_dict(vp, status))
+        printers.append(await _vp_to_dict(vp, db, status))
 
     return {
         "printers": printers,
@@ -247,7 +260,7 @@ async def create_virtual_printer(
         except Exception as e:
             logger.error("Failed to start virtual printer after create: %s", e)
 
-    return _vp_to_dict(vp)
+    return await _vp_to_dict(vp, db)
 
 
 @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
@@ -331,7 +344,7 @@ async def get_virtual_printer(
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
 
-    return _vp_to_dict(vp, status)
+    return await _vp_to_dict(vp, db, status)
 
 
 @router.put("/{vp_id}")
@@ -506,7 +519,7 @@ async def update_virtual_printer(
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
 
-    return _vp_to_dict(vp, status)
+    return await _vp_to_dict(vp, db, status)
 
 
 @router.delete("/{vp_id}")

+ 80 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -599,3 +599,83 @@ class TestVirtualPrinterAccessCodeInheritance:
 
         vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
         assert vp.access_code == "REALCODE"
+
+
+class TestVirtualPrinterSerialSurface:
+    """Proxy-mode VPs must surface the target printer's serial in API responses.
+
+    The bridge advertises the target's serial over SSDP and forwards the
+    target's identity to the slicer; the VP-settings card should show the
+    same serial so the user sees one consistent identity per VP. Archive /
+    queue / review VPs keep the self-generated suffix-based serial since
+    those modes never speak the target's identity.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
+        target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "ProxyVP",
+                "mode": "proxy",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+        assert create_resp.json()["serial"] == "00M09A123456789"
+
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
+        assert get_resp.status_code == 200
+        assert get_resp.json()["serial"] == "00M09A123456789"
+
+        list_resp = await async_client.get("/api/v1/virtual-printers")
+        assert list_resp.status_code == 200
+        listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
+        assert listed["serial"] == "00M09A123456789"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
+        # Even with a target printer set (#1429 access-code inheritance flow),
+        # archive / queue / review VPs are NOT bridging the target's identity
+        # to the slicer — they synthesise their own. Self-generated serial.
+        target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        assert create_resp.json()["serial"] != "00M09A123456789"
+        # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
+        # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
+        assert len(create_resp.json()["serial"]) >= 8
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
+        self, async_client: AsyncClient, db_session
+    ):
+        # Defensive fallback: a proxy VP whose target_printer_id points at a
+        # row that no longer exists (printer deleted mid-config, manual SQL
+        # tweak, race) must not 500 — it returns the self-generated serial
+        # so the card still renders and the user can fix the target.
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
+        db_session.add(vp)
+        await db_session.commit()
+
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
+        assert get_resp.status_code == 200
+        body = get_resp.json()
+        assert body["serial"]  # non-empty
+        assert len(body["serial"]) >= 8

部分文件因文件數量過多而無法顯示