368 lines
12 KiB
Markdown
368 lines
12 KiB
Markdown
# 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`
|
||
|
||
```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`
|
||
|
||
```go
|
||
// 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`
|
||
|
||
```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
|
||
|
||
```yaml
|
||
# config.yaml — new cd_publisher block
|
||
cd_publisher:
|
||
host: "172.16.0.120"
|
||
port: 104
|
||
```
|
||
|
||
### Config Struct: `internal/config/config.go`
|
||
|
||
```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`:
|
||
|
||
```bash
|
||
/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:
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
```bash
|
||
# 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
|
||
```
|