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

222
internal/service/dicom.go Normal file
View File

@@ -0,0 +1,222 @@
package service
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"mkiso-server/internal/config"
"mkiso-server/pkg/dicom"
)
// DicomService handles DICOM retrieval from PACS via storescp + movescu.
type DicomService struct {
cfg *config.Config
portAllocMgr *portManager
}
// NewDicomService creates a new DicomService.
func NewDicomService(cfg *config.Config) *DicomService {
return &DicomService{
cfg: cfg,
portAllocMgr: newPortManager(),
}
}
// FetchDICOM retrieves all DICOM files for a single accession number.
// It starts storescp, runs movescu, then stops storescp.
// Returns the number of files retrieved, or an error.
func (s *DicomService) FetchDICOM(ctx context.Context, accessionNumber, destDir string) (filesCount int, err error) {
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
if port == 0 {
return 0, fmt.Errorf("no available port for storescp")
}
defer release()
return s.fetchDICOMWithPort(ctx, accessionNumber, destDir, port)
}
// FetchDICOMMultiple retrieves DICOM files for multiple accession numbers.
// It starts storescp ONCE, runs movescu for each accession, then stops storescp.
// Returns the total number of files retrieved, or an error.
func (s *DicomService) FetchDICOMMultiple(ctx context.Context, accessionNumbers []string, destDir string) (totalFiles int, err error) {
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
if port == 0 {
return 0, fmt.Errorf("no available port for storescp")
}
defer release()
// Start storescp once
storescpCtx, cancelStorescp := context.WithCancel(ctx)
defer cancelStorescp()
resultCh, stop, err := dicom.StartStoresCP(
storescpCtx,
s.cfg.DCMTK.Storescp,
s.cfg.OurAE.AETitle,
port,
destDir,
)
if err != nil {
return 0, fmt.Errorf("start storescp: %w", err)
}
defer stop()
// Wait for storescp to be ready
if err := waitForStorescpReady(resultCh); err != nil {
return 0, err
}
// Run movescu for each accession
for _, acc := range accessionNumbers {
select {
case <-ctx.Done():
stop()
return totalFiles, ctx.Err()
default:
}
exitCode, _, stderr, err := dicom.RunMoveSCU(
ctx,
s.cfg.DCMTK.Movescu,
s.cfg.OurAE.AETitle,
s.cfg.PACS.AETitle,
s.cfg.PACS.Host,
s.cfg.PACS.Port,
port,
acc,
300, // 5 min timeout
)
if err != nil {
slog.Warn("movescu failed for accession",
"accession", acc,
"exit_code", exitCode,
"stderr", stderr,
"error", err,
)
// Continue with next accession — partial success is acceptable
continue
}
slog.Info("movescu completed",
"accession", acc,
"exit_code", exitCode,
)
}
// Count files retrieved
filesCount, err := countFiles(destDir)
if err != nil {
slog.Warn("count files failed", "dir", destDir, "error", err)
}
if filesCount == 0 {
return 0, fmt.Errorf("no DICOM data retrieved")
}
return filesCount, nil
}
// fetchDICOMWithPort uses a specific port for storescp + movescu.
func (s *DicomService) fetchDICOMWithPort(ctx context.Context, accessionNumber, destDir string, port int) (filesCount int, err error) {
storescpCtx, cancelStorescp := context.WithCancel(ctx)
defer cancelStorescp()
resultCh, stop, err := dicom.StartStoresCP(
storescpCtx,
s.cfg.DCMTK.Storescp,
s.cfg.OurAE.AETitle,
port,
destDir,
)
if err != nil {
return 0, fmt.Errorf("start storescp: %w", err)
}
defer stop()
// Wait for storescp to be ready
if err := waitForStorescpReady(resultCh); err != nil {
return 0, err
}
// Run movescu
exitCode, _, stderr, err := dicom.RunMoveSCU(
ctx,
s.cfg.DCMTK.Movescu,
s.cfg.OurAE.AETitle,
s.cfg.PACS.AETitle,
s.cfg.PACS.Host,
s.cfg.PACS.Port,
port,
accessionNumber,
300, // 5 min timeout
)
if err != nil {
return 0, fmt.Errorf("movescu failed (exit %d): %s (stderr: %s)", exitCode, err, stderr)
}
slog.Info("movescu completed",
"accession", accessionNumber,
"exit_code", exitCode,
)
// Count files
filesCount, err = countFiles(destDir)
if err != nil {
slog.Warn("count files failed", "dir", destDir, "error", err)
}
if filesCount == 0 {
return 0, fmt.Errorf("no DICOM data for accession %q", accessionNumber)
}
return filesCount, nil
}
// waitForStorescpReady waits for storescp to start or detects early failure.
func waitForStorescpReady(resultCh <-chan dicom.StorescpResult) error {
select {
case result := <-resultCh:
return fmt.Errorf("storescp exited prematurely (pid %d, err: %v, stderr: %s)",
result.PID, result.Err, result.Stderr)
case <-time.After(2 * time.Second):
return nil
}
}
// countFiles counts regular files in a directory (non-recursive).
func countFiles(dir string) (int, error) {
return dicom.CountFiles(dir)
}
// portManager manages a pool of allocated ports with mutex safety.
type portManager struct {
mu sync.Mutex
portsInUse map[int]bool
}
func newPortManager() *portManager {
return &portManager{
portsInUse: make(map[int]bool),
}
}
// allocate picks a free port from [base, base+size). Returns 0 if none available.
func (pm *portManager) allocate(base, size int) (int, func()) {
pm.mu.Lock()
defer pm.mu.Unlock()
for i := 0; i < size; i++ {
port := base + i
if !pm.portsInUse[port] {
pm.portsInUse[port] = true
return port, func() {
pm.mu.Lock()
delete(pm.portsInUse, port)
pm.mu.Unlock()
}
}
}
return 0, nil
}

185
internal/service/iso.go Normal file
View File

@@ -0,0 +1,185 @@
package service
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"mkiso-server/internal/config"
"mkiso-server/internal/isobuilder"
"mkiso-server/internal/repo"
"mkiso-server/pkg/dicom"
)
// ISOService handles ISO creation from DICOM data.
type ISOService struct {
cfg *config.Config
dicomSvc *DicomService
patientRepo *repo.PatientRepo
}
// NewISOService creates a new ISOService.
func NewISOService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *ISOService {
return &ISOService{
cfg: cfg,
dicomSvc: dicomSvc,
patientRepo: patientRepo,
}
}
// ISOItem is the result of ISO generation.
type ISOItem struct {
Path string // Full path to the generated ISO file
Cleanup func() // Call to remove temp files
Filename string // Suggested download filename
}
// GenerateISO creates an ISO for a single accession number.
func (s *ISOService) GenerateISO(ctx context.Context, accessionNumber string) (*ISOItem, error) {
// Create temp working directory
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
cleanup := func() { os.RemoveAll(tempDir) }
dicomDir := filepath.Join(tempDir, "DICOMDIR")
if err := os.MkdirAll(dicomDir, 0755); err != nil {
cleanup()
return nil, fmt.Errorf("create DICOMDIR: %w", err)
}
// Copy microdicom viewer files
if err := s.copyMicrodicom(tempDir); err != nil {
cleanup()
return nil, fmt.Errorf("copy microdicom: %w", err)
}
// Fetch DICOM from PACS
_, err = s.dicomSvc.FetchDICOM(ctx, accessionNumber, dicomDir)
if err != nil {
cleanup()
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
}
// Generate ISO
isoName := sanitizeFilename(accessionNumber) + ".iso"
isoPath := filepath.Join(s.cfg.ISO.TempDir, isoName)
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
cleanup()
os.Remove(isoPath)
return nil, fmt.Errorf("ISO creation failed: %w", err)
}
slog.Info("ISO created",
"path", isoPath,
"accession", accessionNumber,
)
return &ISOItem{
Path: isoPath,
Cleanup: func() { cleanup(); os.Remove(isoPath) },
Filename: isoName,
}, nil
}
// GenerateISOMultiple creates an ISO for multiple accession numbers.
// It tries to get the patient name from the patient API for a descriptive filename.
// If the patient API is unavailable, it falls back to the accession list as filename.
func (s *ISOService) GenerateISOMultiple(ctx context.Context, accessionNumbers []string) (*ISOItem, error) {
// Create temp working directory
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
cleanup := func() { os.RemoveAll(tempDir) }
dicomDir := filepath.Join(tempDir, "DICOMDIR")
if err := os.MkdirAll(dicomDir, 0755); err != nil {
cleanup()
return nil, fmt.Errorf("create DICOMDIR: %w", err)
}
// Copy microdicom viewer files
if err := s.copyMicrodicom(tempDir); err != nil {
cleanup()
return nil, fmt.Errorf("copy microdicom: %w", err)
}
// Fetch DICOM from PACS
_, err = s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
if err != nil {
cleanup()
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
}
// Determine filename: try patient API, fall back to accession list
filename := buildMultiFilename(accessionNumbers)
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
if err != nil {
slog.Warn("patient API lookup failed, using accession-based filename",
"accession", accessionNumbers[0],
"error", err,
)
} else if patient != nil && patient.PatientName != "" {
name := sanitizeFilename(patient.PatientName)
accPart := sanitizeFilename(accessionList(accessionNumbers))
filename = fmt.Sprintf("%s-%s.iso", name, accPart)
slog.Info("using patient name for ISO filename",
"patient", patient.PatientName,
"filename", filename,
)
}
isoPath := filepath.Join(s.cfg.ISO.TempDir, filename)
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
cleanup()
os.Remove(isoPath)
return nil, fmt.Errorf("ISO creation failed: %w", err)
}
slog.Info("ISO created (multiple)",
"path", isoPath,
"accessions", accessionNumbers,
)
return &ISOItem{
Path: isoPath,
Cleanup: func() { cleanup(); os.Remove(isoPath) },
Filename: filename,
}, nil
}
// copyMicrodicom copies the microdicom viewer files into the temp directory.
func (s *ISOService) copyMicrodicom(destDir string) error {
if !dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
slog.Warn("microdicom path does not exist, skipping copy",
"path", s.cfg.ISO.MicrodicomPath,
)
return nil
}
return dicom.CopyDir(s.cfg.ISO.MicrodicomPath, destDir)
}
// sanitizeFilename removes characters not safe for filenames.
func sanitizeFilename(s string) string {
reg := regexp.MustCompile(`[^a-zA-Z0-9\-\.]+`)
return strings.TrimRight(reg.ReplaceAllString(strings.ToUpper(s), ""), ".-")
}
// accessionList joins accession numbers with "-".
func accessionList(accs []string) string {
return strings.Join(accs, "-")
}
// buildMultiFilename creates a default filename from accession numbers.
func buildMultiFilename(accs []string) string {
return sanitizeFilename(accessionList(accs)) + ".iso"
}

120
internal/service/relay.go Normal file
View File

@@ -0,0 +1,120 @@
package service
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"mkiso-server/internal/config"
"mkiso-server/internal/repo"
"mkiso-server/pkg/dicom"
)
// RelayService handles DICOM relay (print) to the CD Publisher.
type RelayService struct {
cfg *config.Config
dicomSvc *DicomService
patientRepo *repo.PatientRepo
}
// NewRelayService creates a new RelayService.
func NewRelayService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *RelayService {
return &RelayService{
cfg: cfg,
dicomSvc: dicomSvc,
patientRepo: patientRepo,
}
}
// Destination returns the CD Publisher address as "host:port".
func (s *RelayService) Destination() string {
return fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
}
// RelayResult describes the outcome of a DICOM relay operation.
type RelayResult struct {
AccessionsSent []string `json:"accessions_sent"`
PatientName string `json:"patient_name"`
Destination string `json:"destination"`
FilesSent int `json:"files_sent"`
}
// RelayToCDPublisher fetches DICOM studies from PACS and forwards them
// to the CD Publisher via storescu (C-STORE).
func (s *RelayService) RelayToCDPublisher(ctx context.Context, accessionNumbers []string) (*RelayResult, error) {
// Step 1: Try to get patient data from API (graceful if unavailable)
var patientName string
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
if err != nil {
slog.Warn("patient API lookup failed for relay",
"accession", accessionNumbers[0],
"error", err,
)
} else if patient != nil {
patientName = patient.PatientName
slog.Info("patient data retrieved for relay",
"accession", accessionNumbers[0],
"patient", patientName,
)
}
// Step 2: Create temp dir
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tempDir) // guaranteed cleanup
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 (optional — CD Publisher doesn't need it,
// but matches PHP behavior of copying before fetch)
if dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
if err := dicom.CopyDir(s.cfg.ISO.MicrodicomPath, tempDir); err != nil {
slog.Warn("copy microdicom for relay failed", "error", err)
}
}
// Step 3: Fetch DICOM from PACS
filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
if err != nil {
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
}
slog.Info("DICOM fetched for relay",
"accessions", accessionNumbers,
"files", filesRetrieved,
)
// Step 4: Relay to CD Publisher via storescu
destination := fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
exitCode, stdout, stderr, err := dicom.RunStoreSCU(
ctx,
s.cfg.DCMTK.Storescu,
s.cfg.OurAE.AETitle,
s.cfg.CDPublisher.Host,
s.cfg.CDPublisher.Port,
dicomDir,
)
if err != nil {
return nil, fmt.Errorf("storescu relay failed (exit %d): %w (stderr: %s)", exitCode, err, stderr)
}
_ = stdout
slog.Info("DICOM relayed to CD Publisher",
"accessions", accessionNumbers,
"destination", destination,
"files", filesRetrieved,
)
return &RelayResult{
AccessionsSent: accessionNumbers,
PatientName: patientName,
Destination: destination,
FilesSent: filesRetrieved,
}, nil
}