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

368 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```