feat: base go mkiso
This commit is contained in:
367
todo/go-print-relay.detail.md
Normal file
367
todo/go-print-relay.detail.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user