Files
dicom-iso/todo/go-print-relay.detail.md
2026-06-05 08:11:44 +07:00

12 KiB
Raw Permalink Blame History

Print / DICOM Relay — Implementation Detail

PHP Source: send_rimage_multiple.php

The original script has two distinct phases:

Phase 1 — DICOM Fetch (identical to mkiso_multiple.php):

  1. Parse comma-separated accession_number from GET
  2. DB lookup on HIS DB (rsabt201107):
    • pacs_result_series → MEDRECID, RegID (using first accession)
    • medrec → NamaPasien
  3. Create /tmp/dicomdir_<uniqid>/DICOMDIR
  4. Copy microdicom viewer files
  5. Nested loop: 14 modalities × N accessions → Java dcmqr C-MOVE

Phase 2 — Relay to CD Publisher (unique to this script): 6. dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR 7. No genisoimage, no download streaming 8. No cleanup (temp dir is leaked — Go version MUST fix this) 9. Bogus headers: Script sets Content-Type: application/octet-stream and Content-Disposition with patient-name filename, but never calls readfile(). The response body is empty (or dcmsend stdout).


Go Implementation

Service: internal/service/relay.go

package service

import (
    "context"
    "fmt"
    "log/slog"
    "mkiso-server/pkg/dicom"
)

type RelayService struct {
    dicomSvc      *DicomService
    patientRepo   *repo.PatientRepo   // <-- NEW: patient API client
    cdHost        string
    cdPort        int
    ourAETitle    string
    storescuBin   string
    microdicomSrc string
    logger        *slog.Logger
}

type RelayResult struct {
    AccessionsSent []string `json:"accessions_sent"`
    PatientName    string   `json:"patient_name"`     // from patient API
    Destination    string   `json:"destination"`
    FilesSent      int      `json:"files_sent"`
}

func (s *RelayService) RelayToCDPublisher(
    ctx context.Context,
    accessionNumbers []string,
) (*RelayResult, error) {
    // Step 1: Validate accession via patient API (replaces DB lookup)
    // Use first accession to get patient context
    patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
    if err != nil {
        return nil, fmt.Errorf("patient API lookup for %q: %w", accessionNumbers[0], err)
    }
    s.logger.Info("patient data retrieved for relay",
        "accession", accessionNumbers[0],
        "patient", patient.PatientName,
    )

    // Step 2: Create temp dir
    tempDir, err := os.MkdirTemp(cfg.TempDir, "dicomdir_")
    if err != nil {
        return nil, fmt.Errorf("create temp dir: %w", err)
    }
    defer os.RemoveAll(tempDir) // GUARANTEED cleanup (fixes PHP leak)

    dicomDir := filepath.Join(tempDir, "DICOMDIR")
    if err := os.MkdirAll(dicomDir, 0755); err != nil {
        return nil, fmt.Errorf("create DICOMDIR: %w", err)
    }

    // Copy microdicom viewer files
    if err := copyMicrodicom(s.microdicomSrc, tempDir); err != nil {
        return nil, fmt.Errorf("copy microdicom: %w", err)
    }

    // Fetch DICOM from PACS (same as download flow)
    filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
    if err != nil {
        return nil, fmt.Errorf("DICOM fetch failed for %v: %w", accessionNumbers, err)
    }
    s.logger.Info("DICOM fetched for relay",
        "accessions", accessionNumbers,
        "files", filesRetrieved,
        "dir", dicomDir,
    )

    // Relay to CD Publisher via storescu
    destination := fmt.Sprintf("%s:%d", s.cdHost, s.cdPort)
    exitCode, stdout, stderr, err := dicom.RunStoreSCU(
        ctx,
        s.storescuBin,
        s.ourAETitle,
        s.cdHost,
        s.cdPort,
        dicomDir,
    )
    if err != nil {
        return nil, fmt.Errorf("storescu failed: %w", err)
    }
    if exitCode != 0 {
        return nil, fmt.Errorf("storescu exit %d: %s", exitCode, stderr)
    }

    s.logger.Info("DICOM relayed to CD Publisher",
        "accessions", accessionNumbers,
        "destination", destination,
        "files", filesRetrieved,
    )

    return &RelayResult{
        AccessionsSent: accessionNumbers,
        PatientName:    patient.PatientName,
        Destination:    destination,
        FilesSent:      filesRetrieved,
    }, nil
}

DICOM Command: pkg/dicom/command.go — add RunStoreSCU

// RunStoreSCU sends DICOM files to a remote AE via C-STORE.
// Equivalent to: storescu -aet <ae> +sd +r <host> <port> <dir>
func RunStoreSCU(
    ctx context.Context,
    storescuBin, aeTitle, host string,
    port int,
    dir string,
) (exitCode int, stdout, stderr string, err error) {
    cmd := exec.CommandContext(ctx,
        storescuBin,
        "-aet", aeTitle,
        "+sd",    // scan directories
        "+r",     // recurse
        host,
        strconv.Itoa(port),
        dir,
    )

    var outBuf, errBuf bytes.Buffer
    cmd.Stdout = &outBuf
    cmd.Stderr = &errBuf

    err = cmd.Run()
    stdout = outBuf.String()
    stderr = errBuf.String()

    if err != nil {
        if exitErr, ok := err.(*exec.ExitError); ok {
            exitCode = exitErr.ExitCode()
        } else {
            exitCode = -1
        }
        return exitCode, stdout, stderr, err
    }

    return 0, stdout, stderr, nil
}

Handler: internal/handler/print.go

func PrintISO(relaySvc *service.RelayService) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        acc := r.URL.Query().Get("accession_number")
        if acc == "" {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_number"})
            return
        }

        result, err := relaySvc.RelayToCDPublisher(r.Context(), []string{acc})
        if err != nil {
            slog.Error("print ISO failed", "accession", acc, "error", err)
            status := http.StatusBadGateway
            if strings.Contains(err.Error(), "not found") {
                status = http.StatusNotFound
            }
            writeJSON(w, status, map[string]string{
                "error":       "DICOM relay failed",
                "destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port),
                "detail":      err.Error(),
            })
            return
        }

        writeJSON(w, http.StatusOK, map[string]interface{}{
            "status":          "ok",
            "accessions_sent": result.AccessionsSent,
            "patient_name":    result.PatientName,
            "destination":     result.Destination,
            "files_sent":      result.FilesSent,
        })
    }
}

func PrintISOMultiple(relaySvc *service.RelayService) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        raw := r.URL.Query().Get("accession_numbers")
        if raw == "" {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_numbers"})
            return
        }

        accs := strings.Split(raw, ",")
        for i, a := range accs {
            accs[i] = strings.TrimSpace(a)
            if accs[i] == "" {
                writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty accession_number in list"})
                return
            }
        }

        result, err := relaySvc.RelayToCDPublisher(r.Context(), accs)
        if err != nil {
            slog.Error("print ISO multiple failed", "accessions", accs, "error", err)
            writeJSON(w, http.StatusBadGateway, map[string]string{
                "error":       "DICOM relay failed",
                "destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port),
                "detail":      err.Error(),
            })
            return
        }

        writeJSON(w, http.StatusOK, map[string]interface{}{
            "status":          "ok",
            "accessions_sent": result.AccessionsSent,
            "destination":     result.Destination,
            "files_sent":      result.FilesSent,
        })
    }
}

Config Additions

# config.yaml — new cd_publisher block
cd_publisher:
  host: "172.16.0.120"
  port: 104

Config Struct: internal/config/config.go

type Config struct {
    // ... existing fields ...
    CDPublisher CDPublisherConfig `yaml:"cd_publisher"`
}

type CDPublisherConfig struct {
    Host string `yaml:"host"`
    Port int    `yaml:"port"`
}

Key Differences from Download Flow

Aspect Download Multiple Print/Relay
Output ISO file streamed to browser DICOM sent to CD Publisher
Response Content-Type: application/octet-stream Content-Type: application/json
genisoimage Required Not used
storescu Not used Required (to CD Publisher)
Patient API For filename For validation + response context
Filename {name}-{accs}.iso N/A (JSON response)
Cleanup After streaming completes After relay completes

storescu Command

Replaces dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR:

/data/dcmtk-bin/storescu \
  -aet CDRECORD \
  +sd \        # scan directories  
  +r \         # recurse
  172.16.0.120 104 \
  /tmp/dicomdir_xxx/DICOMDIR
Flag Purpose
-aet CDRECORD Our AE title (calling)
+sd Scan subdirectories for DICOM files
+r Recurse into subdirectories
172.16.0.120 104 CD Publisher host:port
/tmp/dicomdir_xxx/DICOMDIR Directory containing DICOM files

Error Cases

Scenario Response
Invalid accession (patient API 404) 404 {"error":"accession_number not found"}
Patient API unavailable 502 {"error":"patient API unavailable"}
CD Publisher unreachable 502 {"error":"CD Publisher unreachable","destination":"..."}
CD Publisher rejects association 502 {"error":"DICOM relay failed","detail":"association rejected"}
DICOM fetch failed (PACS down) 502 {"error":"PACS unreachable"}
No DICOM files fetched 502 {"error":"no DICOM data for accession_number"}
Partial relay (some files failed) storescu may return 0 but log warnings — Go should parse stderr

Integration with HIS Frontend

The pacs_downloadiso module JS calls:

// printiso() in index.php
window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+AccessionNumber);

// fn_printisomultiple() in index.php
window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+an);

The Go API returns JSON, not an HTML page. The HIS JS uses window.open() — it will open a new tab/window showing the JSON.

Recommendation: The nginx proxy should handle this. Or the HIS module could be updated to use AJAX instead of window.open() for print. For now, opening a JSON tab is acceptable (shows success/failure to the operator).

Better: if the window.open approach is kept, the Go handler could return a simple HTML page instead of raw JSON for the print endpoint (check Accept header or add a ?format=html query param).


Verification

# Test single accession relay
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/iso/print?accession_number=MR.2024.001"

# Expected: {"status":"ok","accessions_sent":["MR.2024.001"],"patient_name":"JOHN DOE","destination":"172.16.0.120:104","files_sent":42}

# Test multiple accession relay
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/iso/print-multiple?accession_numbers=MR.001,CT.002"

# Test error: invalid accession (patient API returns 404)
# Expected: 404 {"error":"accession_number not found"}

# Test error: CD Publisher offline
# Expected: 502 {"error":"CD Publisher unreachable"}

# Test error: patient API unavailable
# Expected: 502 {"error":"patient API unavailable"}

# Test storescu directly (dev workstation)
storescu -aet CDRECORD +sd +r 172.16.0.120 104 /tmp/test_dicom/DICOMDIR