12 KiB
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):
- Parse comma-separated
accession_numberfrom GET - DB lookup on HIS DB (
rsabt201107):pacs_result_series→ MEDRECID, RegID (using first accession)medrec→ NamaPasien
- Create
/tmp/dicomdir_<uniqid>/DICOMDIR - Copy microdicom viewer files
- 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