feat: base go mkiso

This commit is contained in:
2026-06-05 08:11:44 +07:00
commit 983667a76a
63 changed files with 5322 additions and 0 deletions

View 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
```