commit f538608ba4364ab0e1bcb31896b1d4f62f994285 Author: huxuejian Date: Thu Aug 6 11:23:33 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4f6d92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/logs/ +/.gocache/ +/.gotmp/ +*.exe diff --git a/README.md b/README.md new file mode 100644 index 0000000..df454f6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# readcard-go + +`card-read-service` 的 Go 版本,保持原有 HTTP 接口和响应格式,并增加业务失败、panic、DLL 崩溃与异常退出日志。 + +## 构建和运行 + +### 打包环境要求 + +执行打包操作的电脑必须已经安装 Go 开发环境,并且 `go` 命令已加入系统 `PATH`。可以在命令行中执行以下命令检查: + +```powershell +go version +``` + +能够正常输出 Go 版本信息即可,例如: + +```text +go version go1.26.5 windows/amd64 +``` + +不需要另外安装 32 位 Go。厂商 `CardReaderDLL.dll` 是 32 位 DLL,打包脚本会自动设置 `GOOS=windows`、`GOARCH=386` 和 `CGO_ENABLED=0`,使用常规的 64 位 Go 环境即可生成 32 位程序。 + +最终使用 `dist/readcard-go.exe` 的电脑不需要安装 Go 环境,但必须复制完整的 `dist` 目录,不能只复制 exe 文件。 + +### 执行打包 + +在 PowerShell 中执行: + +```powershell +.\build.ps1 -Version "1.0.0" +.\dist\readcard-go.exe +``` + +也可以直接双击项目根目录下的 `一键打包.bat`。脚本会自动调用 PowerShell 完成测试、构建和厂商依赖复制,成功后自动打开 `dist` 发布目录;打包版本可修改批处理文件顶部的 `BUILD_VERSION`。 + +在 macOS 中可使用 `一键打包.sh` 交叉编译 Windows 32 位产物: + +```bash +chmod +x ./一键打包.sh +./一键打包.sh +``` + +只检查打包环境、不执行构建: + +```bash +./一键打包.sh --check +``` + +macOS 脚本会检查 Go 版本和 `windows/386` 目标,编译 Windows 测试程序(不在 macOS 上运行),生成 `dist/readcard-go.exe`,并复制 `config.json` 和完整的厂商 DLL 目录。最终产物仍需在 Windows 真机上验证。 + +构建脚本会运行测试、生成 `dist/readcard-go.exe`,并把原项目的 `package/DWCardReaderDLL` 复制到发布目录。开发运行可执行: + +```powershell +.\run.ps1 +``` + +启动后: + +- 健康检查:`GET http://127.0.0.1:17880/health` +- 免 PIN 读卡:`POST http://127.0.0.1:17880/api/card/read-nopin` +- 嘉兴带 PIN 读卡:`POST http://127.0.0.1:17880/api/card/read` +- HMAC-SM3:`POST http://127.0.0.1:17880/api/hmac-sm3` + +免 PIN 读卡示例: + +```powershell +Invoke-RestMethod -Method Post -Uri http://127.0.0.1:17880/api/card/read-nopin ` + -ContentType "application/json" ` + -Body '{"reader_type":"ZJ_HZ_310000"}' +``` + +## 日志 + +日志默认写入程序目录下的 `logs`: + +- `service-组件-YYYY-MM-DD.log`:启动、配置、DLL 加载、HTTP 失败、读卡失败、panic 堆栈和自动重启记录。 +- `crash-YYYY-MM-DD.log`:工作进程写到标准错误的 Go runtime/native crash 信息。 + +日志达到 `log_max_file_mb` 后自动生成 `-001`、`-002` 分卷;日志目录超过 `log_max_total_mb` 时优先删除最旧分卷,同时仍按 `log_retention_days` 清理过期日志。默认单文件最大 20MB、目录总量最大 200MB、保留 30 天。 + +程序默认由守护进程启动工作进程。若 DLL 导致工作进程直接崩溃,守护进程会记录 PID、退出码、运行时长并自动重启;30 秒内连续崩溃达到 `max_rapid_restarts` 后停止,避免无限重启。日志不会记录身份证号、卡号、密钥或请求正文。 + +## 配置 + +`config.json`: + +```json +{ + "host": "127.0.0.1", + "port": 17880, + "dll_dir": "package/DWCardReaderDLL", + "cors_origin": "*", + "log_dir": "logs", + "log_retention_days": 30, + "log_max_file_mb": 20, + "log_max_total_mb": 200, + "restart_delay_ms": 1500, + "max_rapid_restarts": 5 +} +``` + +支持环境变量:`CARD_HOST`、`CARD_PORT`、`CARD_DLL_DIR`、`CARD_CORS_ORIGIN`、`CARD_LOG_DIR`、`CARD_LOG_MAX_FILE_MB`、`CARD_LOG_MAX_TOTAL_MB`。调试时设置 `CARD_NO_SUPERVISOR=1` 可直接运行工作进程。 + +接口行为和 `reader_type` 列表请参照原项目 `card-read-service/README.md`。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..97b3ef3 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,46 @@ +param( + [string]$Version = "dev", + [switch]$SkipVendorFiles +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = $PSScriptRoot +$DistDir = Join-Path $ProjectRoot "dist" +$VendorSource = Join-Path $ProjectRoot "..\card-read-service\package\DWCardReaderDLL" +$VendorTarget = Join-Path $DistDir "package\DWCardReaderDLL" + +New-Item -ItemType Directory -Force -Path $DistDir | Out-Null + +Push-Location $ProjectRoot +try { + go test -buildvcs=false ./... + if ($LASTEXITCODE -ne 0) { + throw "Host tests failed with exit code $LASTEXITCODE" + } + + $env:GOOS = "windows" + $env:GOARCH = "386" + $env:CGO_ENABLED = "0" + go test -buildvcs=false ./... + if ($LASTEXITCODE -ne 0) { + throw "Windows 386 tests failed with exit code $LASTEXITCODE" + } + + go build -buildvcs=false -trimpath -ldflags "-s -w -X main.version=$Version" -o (Join-Path $DistDir "readcard-go.exe") . + if ($LASTEXITCODE -ne 0) { + throw "Windows 386 build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +Copy-Item -LiteralPath (Join-Path $ProjectRoot "config.json") -Destination (Join-Path $DistDir "config.json") -Force +if (-not $SkipVendorFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $VendorSource "CardReaderDLL.dll"))) { + throw "Vendor DLL directory not found: $VendorSource" + } + New-Item -ItemType Directory -Force -Path $VendorTarget | Out-Null + Copy-Item -Path (Join-Path $VendorSource "*") -Destination $VendorTarget -Recurse -Force +} + +Write-Host "Build completed: $DistDir" diff --git a/config.go b/config.go new file mode 100644 index 0000000..fb16545 --- /dev/null +++ b/config.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Host string `json:"host"` + Port int `json:"port"` + DLLDir string `json:"dll_dir"` + CORSOrigin string `json:"cors_origin"` + LogDir string `json:"log_dir"` + LogRetentionDays int `json:"log_retention_days"` + LogMaxFileMB int `json:"log_max_file_mb"` + LogMaxTotalMB int `json:"log_max_total_mb"` + RestartDelayMS int `json:"restart_delay_ms"` + MaxRapidRestarts int `json:"max_rapid_restarts"` + AppRoot string `json:"-"` +} + +func defaultConfig(appRoot string) Config { + return Config{ + Host: "127.0.0.1", + Port: 17880, + DLLDir: "package/DWCardReaderDLL", + CORSOrigin: "*", + LogDir: "logs", + LogRetentionDays: 30, + LogMaxFileMB: 20, + LogMaxTotalMB: 200, + RestartDelayMS: 1500, + MaxRapidRestarts: 5, + AppRoot: appRoot, + } +} + +func resolveAppRoot() (string, error) { + if root := strings.TrimSpace(os.Getenv("CARD_APP_ROOT")); root != "" { + return filepath.Abs(root) + } + + cwd, cwdErr := os.Getwd() + if cwdErr == nil && fileExists(filepath.Join(cwd, "config.json")) { + return filepath.Abs(cwd) + } + + exe, exeErr := os.Executable() + if exeErr == nil { + return filepath.Abs(filepath.Dir(exe)) + } + if cwdErr != nil { + return "", fmt.Errorf("无法确定程序目录: cwd=%v, executable=%v", cwdErr, exeErr) + } + return filepath.Abs(cwd) +} + +func loadConfig() (Config, error) { + root, err := resolveAppRoot() + if err != nil { + return Config{}, err + } + return loadConfigAt(root) +} + +func loadConfigAt(root string) (Config, error) { + root, err := filepath.Abs(root) + if err != nil { + return Config{}, fmt.Errorf("解析程序目录失败: %w", err) + } + cfg := defaultConfig(root) + path := filepath.Join(root, "config.json") + if data, readErr := os.ReadFile(path); readErr == nil { + if err = json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("配置文件格式错误 %s: %w", path, err) + } + } else if !errors.Is(readErr, os.ErrNotExist) { + return Config{}, fmt.Errorf("读取配置文件失败 %s: %w", path, readErr) + } + cfg.AppRoot = root + + if value := strings.TrimSpace(os.Getenv("CARD_HOST")); value != "" { + cfg.Host = value + } + if value := strings.TrimSpace(os.Getenv("CARD_PORT")); value != "" { + cfg.Port, err = strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("CARD_PORT 不是有效端口: %q", value) + } + } + if value := strings.TrimSpace(os.Getenv("CARD_DLL_DIR")); value != "" { + cfg.DLLDir = value + } + if value := os.Getenv("CARD_CORS_ORIGIN"); value != "" { + cfg.CORSOrigin = value + } + if value := strings.TrimSpace(os.Getenv("CARD_LOG_DIR")); value != "" { + cfg.LogDir = value + } + if value := strings.TrimSpace(os.Getenv("CARD_LOG_MAX_FILE_MB")); value != "" { + cfg.LogMaxFileMB, err = strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("CARD_LOG_MAX_FILE_MB 不是有效整数: %q", value) + } + } + if value := strings.TrimSpace(os.Getenv("CARD_LOG_MAX_TOTAL_MB")); value != "" { + cfg.LogMaxTotalMB, err = strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("CARD_LOG_MAX_TOTAL_MB 不是有效整数: %q", value) + } + } + + if strings.TrimSpace(cfg.Host) == "" { + return Config{}, errors.New("host 不能为空") + } + if cfg.Port < 1 || cfg.Port > 65535 { + return Config{}, fmt.Errorf("port 超出范围: %d", cfg.Port) + } + if strings.TrimSpace(cfg.DLLDir) == "" { + return Config{}, errors.New("dll_dir 不能为空") + } + if strings.TrimSpace(cfg.CORSOrigin) == "" { + cfg.CORSOrigin = "*" + } + if strings.TrimSpace(cfg.LogDir) == "" { + cfg.LogDir = "logs" + } + if cfg.LogRetentionDays <= 0 { + cfg.LogRetentionDays = 30 + } + if cfg.LogMaxFileMB <= 0 { + cfg.LogMaxFileMB = 20 + } + if cfg.LogMaxTotalMB <= 0 { + cfg.LogMaxTotalMB = 200 + } + if cfg.LogMaxTotalMB < cfg.LogMaxFileMB*3 { + return Config{}, fmt.Errorf("log_max_total_mb 至少应为 log_max_file_mb 的 3 倍(当前 %d < %d)", cfg.LogMaxTotalMB, cfg.LogMaxFileMB*3) + } + if cfg.RestartDelayMS < 100 { + cfg.RestartDelayMS = 1500 + } + if cfg.MaxRapidRestarts <= 0 { + cfg.MaxRapidRestarts = 5 + } + if !filepath.IsAbs(cfg.LogDir) { + cfg.LogDir = filepath.Join(cfg.AppRoot, cfg.LogDir) + } + return cfg, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..cf3918f --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "host": "127.0.0.1", + "port": 17880, + "dll_dir": "package/DWCardReaderDLL", + "cors_origin": "*", + "log_dir": "logs", + "log_retention_days": 30, + "log_max_file_mb": 20, + "log_max_total_mb": 200, + "restart_delay_ms": 1500, + "max_rapid_restarts": 5 +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..f0b6fdc --- /dev/null +++ b/config_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigDefaults(t *testing.T) { + t.Setenv("CARD_HOST", "") + t.Setenv("CARD_PORT", "") + t.Setenv("CARD_DLL_DIR", "") + t.Setenv("CARD_CORS_ORIGIN", "") + t.Setenv("CARD_LOG_DIR", "") + t.Setenv("CARD_LOG_MAX_FILE_MB", "") + t.Setenv("CARD_LOG_MAX_TOTAL_MB", "") + cfg, err := loadConfigAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.Port != 17880 || !filepath.IsAbs(cfg.LogDir) { + t.Fatalf("unexpected config: %#v", cfg) + } +} + +func TestLoadConfigLogLimits(t *testing.T) { + t.Setenv("CARD_LOG_MAX_FILE_MB", "10") + t.Setenv("CARD_LOG_MAX_TOTAL_MB", "50") + cfg, err := loadConfigAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.LogMaxFileMB != 10 || cfg.LogMaxTotalMB != 50 { + t.Fatalf("unexpected log limits: %#v", cfg) + } +} + +func TestLoadConfigRejectsTooSmallTotalLogLimit(t *testing.T) { + t.Setenv("CARD_LOG_MAX_FILE_MB", "20") + t.Setenv("CARD_LOG_MAX_TOTAL_MB", "40") + if _, err := loadConfigAt(t.TempDir()); err == nil { + t.Fatal("expected log total limit error") + } +} + +func TestLoadConfigEnvironmentOverride(t *testing.T) { + t.Setenv("CARD_PORT", "18080") + t.Setenv("CARD_HOST", "0.0.0.0") + cfg, err := loadConfigAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.Port != 18080 || cfg.Host != "0.0.0.0" { + t.Fatalf("unexpected config: %#v", cfg) + } +} + +func TestLoadConfigRejectsInvalidJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadConfigAt(dir); err == nil { + t.Fatal("expected config error") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5e6766 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module readcard-go + +go 1.22 + diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..0b3266a --- /dev/null +++ b/logger.go @@ -0,0 +1,240 @@ +package main + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type dailyWriter struct { + dir string + prefix string + mu sync.Mutex + day string + part int + file *os.File + fileSize int64 + retention int + maxFileBytes int64 + maxTotalBytes int64 + lastCleanup time.Time + bytesSinceGC int64 +} + +func newDailyWriter(dir, prefix string, retention int, maxFileBytes, maxTotalBytes int64) (*dailyWriter, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + if err = os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + w := &dailyWriter{ + dir: dir, + prefix: prefix, + retention: retention, + maxFileBytes: maxFileBytes, + maxTotalBytes: maxTotalBytes, + } + if err = w.openForDayLocked(time.Now()); err != nil { + return nil, err + } + w.cleanupLocked(time.Now()) + return w, nil +} + +func (w *dailyWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + now := time.Now() + if w.file == nil || w.day != now.Format("2006-01-02") { + if err := w.openForDayLocked(now); err != nil { + return 0, err + } + w.cleanupLocked(now) + } + if w.maxFileBytes > 0 && w.fileSize > 0 && w.fileSize+int64(len(p)) > w.maxFileBytes { + if err := w.openNextPartLocked(now); err != nil { + return 0, err + } + w.cleanupLocked(now) + } + n, err := w.file.Write(p) + w.fileSize += int64(n) + w.bytesSinceGC += int64(n) + if err == nil && (w.bytesSinceGC >= 1<<20 || now.Sub(w.lastCleanup) >= time.Minute || (w.maxFileBytes > 0 && w.fileSize >= w.maxFileBytes)) { + w.cleanupLocked(now) + } + return n, err +} + +func (w *dailyWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +func (w *dailyWriter) openForDayLocked(now time.Time) error { + if w.file != nil { + _ = w.file.Close() + w.file = nil + } + w.day = now.Format("2006-01-02") + w.part = 0 + for { + path := w.partPathLocked() + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return w.openPathLocked(path, 0) + } + return err + } + if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes { + return w.openPathLocked(path, info.Size()) + } + w.part++ + } +} + +func (w *dailyWriter) openNextPartLocked(now time.Time) error { + if w.file != nil { + _ = w.file.Close() + w.file = nil + } + if w.day != now.Format("2006-01-02") { + return w.openForDayLocked(now) + } + w.part++ + for { + path := w.partPathLocked() + info, err := os.Stat(path) + if os.IsNotExist(err) { + return w.openPathLocked(path, 0) + } + if err != nil { + return err + } + if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes { + return w.openPathLocked(path, info.Size()) + } + w.part++ + } +} + +func (w *dailyWriter) partPathLocked() string { + if w.part == 0 { + return filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, w.day)) + } + return filepath.Join(w.dir, fmt.Sprintf("%s-%s-%03d.log", w.prefix, w.day, w.part)) +} + +func (w *dailyWriter) openPathLocked(path string, size int64) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + w.file = file + w.fileSize = size + return nil +} + +type logFileInfo struct { + path string + size int64 + modTime time.Time +} + +func (w *dailyWriter) cleanupLocked(now time.Time) { + w.lastCleanup = now + w.bytesSinceGC = 0 + entries, err := os.ReadDir(w.dir) + if err != nil { + return + } + cutoff := now.AddDate(0, 0, -w.retention) + files := make([]logFileInfo, 0, len(entries)) + var total int64 + currentPath := "" + if w.file != nil { + currentPath, _ = filepath.Abs(w.file.Name()) + } + for _, entry := range entries { + if entry.IsDir() || !isManagedLogName(entry.Name()) { + continue + } + path := filepath.Join(w.dir, entry.Name()) + info, statErr := entry.Info() + if statErr != nil { + continue + } + if w.retention > 0 && info.ModTime().Before(cutoff) && !samePath(path, currentPath) { + if os.Remove(path) == nil { + continue + } + } + files = append(files, logFileInfo{path: path, size: info.Size(), modTime: info.ModTime()}) + total += info.Size() + } + if w.maxTotalBytes <= 0 || total <= w.maxTotalBytes { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) }) + for _, file := range files { + if total <= w.maxTotalBytes { + break + } + if samePath(file.path, currentPath) { + continue + } + if os.Remove(file.path) == nil { + total -= file.size + } + } +} + +func isManagedLogName(name string) bool { + return strings.HasSuffix(name, ".log") && (strings.HasPrefix(name, "service-") || strings.HasPrefix(name, "crash-")) +} + +func samePath(left, right string) bool { + return right != "" && strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +} + +type appLogger struct { + *slog.Logger + writer *dailyWriter +} + +func newAppLogger(cfg Config, component string) (*appLogger, error) { + maxFileBytes := int64(cfg.LogMaxFileMB) * 1024 * 1024 + maxTotalBytes := int64(cfg.LogMaxTotalMB) * 1024 * 1024 + w, err := newDailyWriter(cfg.LogDir, "service-"+component, cfg.LogRetentionDays, maxFileBytes, maxTotalBytes) + if err != nil { + return nil, err + } + handler := slog.NewJSONHandler(io.MultiWriter(os.Stdout, w), &slog.HandlerOptions{Level: slog.LevelInfo}) + return &appLogger{Logger: slog.New(handler).With("component", component, "pid", os.Getpid()), writer: w}, nil +} + +func (l *appLogger) Close() error { + if l == nil || l.writer == nil { + return nil + } + return l.writer.Close() +} + +func discardLogger() *appLogger { + return &appLogger{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..38269f5 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDailyWriterRotatesBySize(t *testing.T) { + dir := t.TempDir() + w, err := newDailyWriter(dir, "service-test", 30, 64, 1024) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + if _, err = w.Write([]byte(strings.Repeat("a", 48))); err != nil { + t.Fatal(err) + } + if _, err = w.Write([]byte(strings.Repeat("b", 48))); err != nil { + t.Fatal(err) + } + files, err := filepath.Glob(filepath.Join(dir, "service-test-*.log")) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("expected 2 log parts, got %d: %v", len(files), files) + } +} + +func TestDailyWriterEnforcesTotalSize(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, "service-old-2020-01-01.log") + if err := os.WriteFile(oldPath, []byte(strings.Repeat("x", 90)), 0o644); err != nil { + t.Fatal(err) + } + oldTime := time.Now().AddDate(0, 0, -2) + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + w, err := newDailyWriter(dir, "service-test", 30, 64, 100) + if err != nil { + t.Fatal(err) + } + defer w.Close() + if _, err = w.Write([]byte(strings.Repeat("a", 64))); err != nil { + t.Fatal(err) + } + if _, err = w.Write([]byte(strings.Repeat("b", 64))); err != nil { + t.Fatal(err) + } + if _, err = os.Stat(oldPath); !os.IsNotExist(err) { + t.Fatalf("oldest log should be removed, stat error: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var total int64 + for _, entry := range entries { + info, infoErr := entry.Info() + if infoErr != nil { + t.Fatal(infoErr) + } + total += info.Size() + } + if total > 128 { // 当前打开分卷允许最多超出一个 64 字节分卷。 + t.Fatalf("log directory grew unexpectedly: %d bytes", total) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6c81e8b --- /dev/null +++ b/main.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime/debug" + "syscall" + "time" +) + +var version = "dev" + +func main() { + os.Exit(realMain()) +} + +func realMain() (exitCode int) { + defer func() { + if recovered := recover(); recovered != nil { + fmt.Fprintf(os.Stderr, "readcard-go panic: %v\n%s\n", recovered, debug.Stack()) + exitCode = 2 + } + }() + if isWorker() { + return runWorker() + } + return runSupervisor() +} + +func isWorker() bool { + if os.Getenv("CARD_NO_SUPERVISOR") == "1" { + return true + } + for _, arg := range os.Args[1:] { + if arg == "--worker" { + return true + } + } + return false +} + +func runWorker() int { + cfg, err := loadConfig() + if err != nil { + logBootstrapFailure("worker_config_failure", err) + fmt.Fprintln(os.Stderr, err) + return 1 + } + logger, err := newAppLogger(cfg, "worker") + if err != nil { + fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err) + return 1 + } + defer logger.Close() + defer func() { + if recovered := recover(); recovered != nil { + logger.Error("工作进程 panic", "event", "worker_panic", "panic", fmt.Sprint(recovered), "stack", string(debug.Stack())) + panic(recovered) + } + }() + + reader := newDLLReader(cfg) + status := reader.Status() + if status.Loaded { + logger.Info("读卡 DLL 初始化成功", "event", "dll_loaded", "dll_dir", status.DLLDir) + } else { + logger.Error("读卡 DLL 初始化失败", "event", "dll_load_failure", "dll_dir", status.DLLDir, "error", status.Error) + } + + address := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port)) + listener, err := net.Listen("tcp", address) + if err != nil { + logger.Error("HTTP 服务监听失败", "event", "listen_failure", "address", address, "error", err) + return 1 + } + server := &http.Server{ + Handler: newHandler(cfg, reader, logger), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, + } + logger.Info("读卡服务已启动", "event", "service_started", "version", version, "url", "http://"+address, "dll_dir", status.DLLDir, "arch", status.Arch) + + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + select { + case sig := <-signals: + logger.Info("收到停止信号", "event", "shutdown_signal", "signal", sig.String()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err = server.Shutdown(ctx); err != nil { + logger.Error("HTTP 服务优雅停止失败", "event", "shutdown_failure", "error", err) + return 1 + } + logger.Info("读卡服务已停止", "event", "service_stopped") + return 0 + case err = <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return 0 + } + logger.Error("HTTP 服务异常退出", "event", "server_failure", "error", err) + return 1 + } +} + +func runSupervisor() int { + cfg, err := loadConfig() + if err != nil { + logBootstrapFailure("supervisor_config_failure", err) + fmt.Fprintln(os.Stderr, err) + return 1 + } + logger, err := newAppLogger(cfg, "supervisor") + if err != nil { + fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err) + return 1 + } + defer logger.Close() + + exe, err := os.Executable() + if err != nil { + logger.Error("获取程序路径失败", "event", "supervisor_failure", "error", err) + return 1 + } + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + rapidRestarts := 0 + for { + started := time.Now() + cmd := exec.Command(exe, "--worker") + cmd.Dir = cfg.AppRoot + cmd.Env = append(os.Environ(), "CARD_APP_ROOT="+cfg.AppRoot) + cmd.Stdout = os.Stdout + crashWriter, crashErr := newDailyWriter( + cfg.LogDir, + "crash", + cfg.LogRetentionDays, + int64(cfg.LogMaxFileMB)*1024*1024, + int64(cfg.LogMaxTotalMB)*1024*1024, + ) + if crashErr != nil { + logger.Error("打开崩溃日志失败", "event", "crash_log_failure", "error", crashErr) + cmd.Stderr = os.Stderr + } else { + cmd.Stderr = io.MultiWriter(os.Stderr, crashWriter) + } + + if err = cmd.Start(); err != nil { + if crashWriter != nil { + _ = crashWriter.Close() + } + logger.Error("启动工作进程失败", "event", "worker_start_failure", "error", err) + return 1 + } + logger.Info("工作进程已启动", "event", "worker_started", "worker_pid", cmd.Process.Pid) + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + + select { + case sig := <-signals: + logger.Info("守护进程收到停止信号", "event", "supervisor_shutdown", "signal", sig.String(), "worker_pid", cmd.Process.Pid) + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-waitDone: + case <-time.After(10 * time.Second): + logger.Error("工作进程停止超时,强制结束", "event", "worker_kill", "worker_pid", cmd.Process.Pid) + _ = cmd.Process.Kill() + <-waitDone + } + if crashWriter != nil { + _ = crashWriter.Close() + } + return 0 + case waitErr := <-waitDone: + if crashWriter != nil { + _ = crashWriter.Close() + } + runFor := time.Since(started) + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + logger.Error("工作进程异常退出", "event", "worker_crash", "worker_pid", cmd.Process.Pid, "exit_code", exitCode, "runtime_ms", runFor.Milliseconds(), "error", waitErr) + if runFor < 30*time.Second { + rapidRestarts++ + } else { + rapidRestarts = 0 + } + if rapidRestarts >= cfg.MaxRapidRestarts { + logger.Error("工作进程短时间内连续崩溃,停止自动重启", "event", "restart_limit", "rapid_restarts", rapidRestarts) + return 1 + } + logger.Warn("即将自动重启工作进程", "event", "worker_restart", "delay_ms", cfg.RestartDelayMS, "rapid_restarts", rapidRestarts) + select { + case sig := <-signals: + logger.Info("重启等待期间收到停止信号", "event", "supervisor_shutdown", "signal", sig.String()) + return 0 + case <-time.After(time.Duration(cfg.RestartDelayMS) * time.Millisecond): + } + } + } +} + +func logBootstrapFailure(event string, failure error) { + root, err := resolveAppRoot() + if err != nil { + return + } + cfg := defaultConfig(root) + cfg.LogDir = filepath.Join(root, cfg.LogDir) + logger, err := newAppLogger(cfg, "bootstrap") + if err != nil { + return + } + logger.Error("程序启动失败", "event", event, "error", failure) + _ = logger.Close() +} diff --git a/reader.go b/reader.go new file mode 100644 index 0000000..c058bd5 --- /dev/null +++ b/reader.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "unsafe" +) + +const outMessageSize = 2048 + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procMultiByteToWide = kernel32.NewProc("MultiByteToWideChar") +) + +type ReaderStatus struct { + Loaded bool `json:"loaded"` + DLLDir string `json:"dllDir,omitempty"` + Arch string `json:"arch"` + Platform string `json:"platform"` + Error string `json:"error,omitempty"` +} + +type DLLResult struct { + Code int `json:"code"` + Success bool `json:"success"` + ReaderType string `json:"reader_type,omitempty"` + Raw string `json:"raw"` + Data any `json:"data"` +} + +type cardOperations interface { + Status() ReaderStatus + ReadCard(readerType string, noPIN bool) (DLLResult, error) + HMACSM3(key, secret, timestamp, requestBody string) (DLLResult, error) +} + +type DLLReader struct { + mu sync.Mutex + dll *syscall.LazyDLL + read *syscall.LazyProc + readNoPIN *syscall.LazyProc + hmac *syscall.LazyProc + dllDir string + loadErr error +} + +func newDLLReader(cfg Config) *DLLReader { + r := &DLLReader{} + r.init(cfg) + return r +} + +func (r *DLLReader) init(cfg Config) { + if runtime.GOOS != "windows" { + r.loadErr = errors.New("读卡 DLL 仅支持 Windows") + return + } + if runtime.GOARCH != "386" { + r.loadErr = fmt.Errorf("CardReaderDLL.dll 为 32 位,当前程序架构为 %s;请使用 build.ps1 构建 windows/386 版本", runtime.GOARCH) + return + } + + r.dllDir = resolveDLLDir(cfg) + dllPath := filepath.Join(r.dllDir, "CardReaderDLL.dll") + if !fileExists(dllPath) { + r.loadErr = fmt.Errorf("未找到 CardReaderDLL.dll: %s", dllPath) + return + } + + // 厂商 DLL 会按当前工作目录查找各地市的二级依赖库。 + if err := os.Chdir(r.dllDir); err != nil { + r.loadErr = fmt.Errorf("切换 DLL 工作目录失败: %w", err) + return + } + r.dll = syscall.NewLazyDLL(dllPath) + if err := r.dll.Load(); err != nil { + r.loadErr = fmt.Errorf("加载 CardReaderDLL.dll 失败: %w", err) + return + } + r.read = r.dll.NewProc("ZJ_ReadCardInfo") + r.readNoPIN = r.dll.NewProc("ZJ_ReadCardInfo_NoPin") + r.hmac = r.dll.NewProc("ZJ_Hmac_SM3") + for name, proc := range map[string]*syscall.LazyProc{ + "ZJ_ReadCardInfo": r.read, + "ZJ_ReadCardInfo_NoPin": r.readNoPIN, + "ZJ_Hmac_SM3": r.hmac, + } { + if err := proc.Find(); err != nil { + r.loadErr = fmt.Errorf("查找 DLL 函数 %s 失败: %w", name, err) + return + } + } +} + +func resolveDLLDir(cfg Config) string { + candidates := make([]string, 0, 3) + if filepath.IsAbs(cfg.DLLDir) { + candidates = append(candidates, cfg.DLLDir) + } else { + candidates = append(candidates, + filepath.Join(cfg.AppRoot, cfg.DLLDir), + filepath.Join(cfg.AppRoot, "..", "card-read-service", "package", "DWCardReaderDLL"), + ) + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, cfg.DLLDir)) + } + } + for _, candidate := range candidates { + candidate, _ = filepath.Abs(candidate) + if fileExists(filepath.Join(candidate, "CardReaderDLL.dll")) { + return filepath.Clean(candidate) + } + } + if len(candidates) == 0 { + return cfg.DLLDir + } + first, _ := filepath.Abs(candidates[0]) + return filepath.Clean(first) +} + +func (r *DLLReader) Status() ReaderStatus { + status := ReaderStatus{Loaded: r.loadErr == nil && r.dll != nil, DLLDir: r.dllDir, Arch: runtime.GOARCH, Platform: runtime.GOOS} + if r.loadErr != nil { + status.Error = r.loadErr.Error() + } + return status +} + +func (r *DLLReader) ReadCard(readerType string, noPIN bool) (DLLResult, error) { + proc := r.read + if noPIN { + proc = r.readNoPIN + } + return r.invoke(proc, readerType, readerType) +} + +func (r *DLLReader) HMACSM3(key, secret, timestamp, requestBody string) (DLLResult, error) { + return r.invoke(r.hmac, "", key, secret, timestamp, requestBody) +} + +func (r *DLLReader) invoke(proc *syscall.LazyProc, readerType string, args ...string) (DLLResult, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.loadErr != nil { + return DLLResult{}, r.loadErr + } + if proc == nil { + return DLLResult{}, errors.New("读卡 DLL 尚未初始化") + } + + ptrs := make([]*byte, len(args)) + callArgs := make([]uintptr, 0, len(args)+1) + for i, value := range args { + ptr, err := syscall.BytePtrFromString(value) + if err != nil { + return DLLResult{}, fmt.Errorf("DLL 入参包含 NUL 字符: %w", err) + } + ptrs[i] = ptr + callArgs = append(callArgs, uintptr(unsafe.Pointer(ptrs[i]))) + } + out := make([]byte, outMessageSize) + callArgs = append(callArgs, uintptr(unsafe.Pointer(&out[0]))) + + r1, _, _ := proc.Call(callArgs...) + runtime.KeepAlive(ptrs) + runtime.KeepAlive(out) + code := int(int32(r1)) + raw, decodeErr := decodeGBK(bytes.TrimRight(out, "\x00")) + if decodeErr != nil { + raw = strings.ToValidUTF8(string(bytes.TrimRight(out, "\x00")), "�") + } + raw = strings.TrimSpace(raw) + var data any + if raw != "" { + if jsonErr := json.Unmarshal([]byte(raw), &data); jsonErr != nil { + data = raw + } + } + return DLLResult{Code: code, Success: code == 0, ReaderType: readerType, Raw: raw, Data: data}, nil +} + +func decodeGBK(src []byte) (string, error) { + if len(src) == 0 { + return "", nil + } + const codePageGBK = 936 + size, _, callErr := procMultiByteToWide.Call( + codePageGBK, 0, uintptr(unsafe.Pointer(&src[0])), uintptr(len(src)), 0, 0, + ) + if size == 0 { + return "", fmt.Errorf("GBK 长度转换失败: %v", callErr) + } + wide := make([]uint16, int(size)) + written, _, callErr := procMultiByteToWide.Call( + codePageGBK, 0, uintptr(unsafe.Pointer(&src[0])), uintptr(len(src)), + uintptr(unsafe.Pointer(&wide[0])), size, + ) + if written == 0 { + return "", fmt.Errorf("GBK 转 UTF-16 失败: %v", callErr) + } + return syscall.UTF16ToString(wide[:int(written)]), nil +} diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..24caad6 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" +$env:GOOS = "windows" +$env:GOARCH = "386" +$env:CGO_ENABLED = "0" +Push-Location $PSScriptRoot +try { + go run . +} finally { + Pop-Location +} + diff --git a/server.go b/server.go new file mode 100644 index 0000000..f3e9bfd --- /dev/null +++ b/server.go @@ -0,0 +1,235 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime/debug" + "strings" + "sync/atomic" + "time" +) + +const jiaxingReaderType = "ZJ_SX_MT_TSW" + +type apiServer struct { + cfg Config + reader cardOperations + logger *appLogger + request atomic.Uint64 +} + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} + +func newHandler(cfg Config, reader cardOperations, logger *appLogger) http.Handler { + s := &apiServer{cfg: cfg, reader: reader, logger: logger} + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.health) + mux.HandleFunc("POST /api/card/read-nopin", s.readNoPIN) + mux.HandleFunc("POST /api/card/read", s.readWithPIN) + mux.HandleFunc("POST /api/hmac-sm3", s.hmacSM3) + mux.HandleFunc("/", s.notFound) + return s.middleware(mux) +} + +func (s *apiServer) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := s.cfg.CORSOrigin + if origin == "*" { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else if r.Header.Get("Origin") == origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + requestID := fmt.Sprintf("%d-%06d", time.Now().Unix(), s.request.Add(1)) + w.Header().Set("X-Request-ID", requestID) + wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK} + started := time.Now() + defer func() { + if recovered := recover(); recovered != nil { + s.logger.Error("HTTP 处理发生 panic", "event", "http_panic", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(recovered), "stack", string(debug.Stack())) + if !wrapped.wroteHeader { + writeJSON(wrapped, http.StatusInternalServerError, apiResponse{Code: 500, Message: "服务内部异常", Data: nil}) + } + } + if wrapped.status >= 400 { + s.logger.Warn("HTTP 请求失败", "event", "http_failure", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", wrapped.status, "duration_ms", time.Since(started).Milliseconds(), "remote", r.RemoteAddr) + } + }() + next.ServeHTTP(wrapped, r) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (w *statusWriter) WriteHeader(status int) { + if w.wroteHeader { + return + } + w.status = status + w.wroteHeader = true + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusWriter) Write(p []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(p) +} + +func (s *apiServer) health(w http.ResponseWriter, _ *http.Request) { + status := s.reader.Status() + data := map[string]any{ + "service": "card-read-service", + "loaded": status.Loaded, + "dllDir": status.DLLDir, + "arch": status.Arch, + "platform": status.Platform, + "error": nil, + "jiaxing_reader_type": jiaxingReaderType, + "dll_init_ok": status.Loaded, + } + if status.Error != "" { + data["error"] = status.Error + } + writeJSON(w, http.StatusOK, apiResponse{Code: 0, Message: "ok", Data: data}) +} + +func (s *apiServer) readNoPIN(w http.ResponseWriter, r *http.Request) { + var body struct { + ReaderType string `json:"reader_type"` + } + if !decodeBody(w, r, &body) { + return + } + readerType := strings.TrimSpace(body.ReaderType) + if readerType == "" { + writeJSON(w, http.StatusBadRequest, apiResponse{Code: 400, Message: "请传入 reader_type(地市读卡器标识,如 ZJ_HZ_310000)", Data: nil}) + return + } + result, err := s.reader.ReadCard(readerType, true) + if err != nil { + s.logger.Error("免 PIN 读卡异常", "event", "dll_call_error", "method", "ZJ_ReadCardInfo_NoPin", "reader_type", readerType, "error", err) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: err.Error(), Data: nil}) + return + } + if !result.Success { + message := resultMessage(result, "读卡失败") + s.logger.Error("免 PIN 读卡失败", "event", "card_read_failure", "method", "ZJ_ReadCardInfo_NoPin", "reader_type", readerType, "dll_code", result.Code, "message", message) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: message, Data: result}) + return + } + writeJSON(w, http.StatusOK, apiResponse{Code: 0, Message: "ok", Data: successfulData(result)}) +} + +func (s *apiServer) readWithPIN(w http.ResponseWriter, r *http.Request) { + var body struct { + ReaderType any `json:"reader_type"` + } + if !decodeBody(w, r, &body) { + return + } + if body.ReaderType != nil && strings.TrimSpace(fmt.Sprint(body.ReaderType)) != jiaxingReaderType { + writeJSON(w, http.StatusBadRequest, apiResponse{Code: 400, Message: "带 PIN 读卡仅支持嘉兴,reader_type 必须为 " + jiaxingReaderType, Data: nil}) + return + } + result, err := s.reader.ReadCard(jiaxingReaderType, false) + if err != nil { + s.logger.Error("带 PIN 读卡异常", "event", "dll_call_error", "method", "ZJ_ReadCardInfo", "reader_type", jiaxingReaderType, "error", err) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: err.Error(), Data: nil}) + return + } + if !result.Success { + message := resultMessage(result, "读卡失败") + s.logger.Error("带 PIN 读卡失败", "event", "card_read_failure", "method", "ZJ_ReadCardInfo", "reader_type", jiaxingReaderType, "dll_code", result.Code, "message", message) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: message, Data: result}) + return + } + writeJSON(w, http.StatusOK, apiResponse{Code: 0, Message: "ok", Data: successfulData(result)}) +} + +func (s *apiServer) hmacSM3(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if !decodeBody(w, r, &body) { + return + } + key, keyOK := body["key"] + secret, secretOK := body["secret"] + requestBody, bodyOK := body["request_body"] + if !keyOK || key == nil || !secretOK || secret == nil || !bodyOK || requestBody == nil { + writeJSON(w, http.StatusBadRequest, apiResponse{Code: 400, Message: "缺少 key / secret / request_body", Data: nil}) + return + } + timestamp := "0" + if value, ok := body["unix_timestamp"]; ok && value != nil { + timestamp = fmt.Sprint(value) + } + result, err := s.reader.HMACSM3(fmt.Sprint(key), fmt.Sprint(secret), timestamp, fmt.Sprint(requestBody)) + if err != nil { + s.logger.Error("HMAC-SM3 调用异常", "event", "dll_call_error", "method", "ZJ_Hmac_SM3", "error", err) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: err.Error(), Data: nil}) + return + } + if !result.Success { + message := resultMessage(result, "签名失败") + s.logger.Error("HMAC-SM3 失败", "event", "hmac_failure", "method", "ZJ_Hmac_SM3", "dll_code", result.Code, "message", message) + writeJSON(w, http.StatusInternalServerError, apiResponse{Code: 500, Message: message, Data: result}) + return + } + writeJSON(w, http.StatusOK, apiResponse{Code: 0, Message: "ok", Data: successfulData(result)}) +} + +func (s *apiServer) notFound(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusNotFound, apiResponse{Code: 404, Message: "Not Found", Data: nil}) +} + +func decodeBody(w http.ResponseWriter, r *http.Request, target any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(target); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{Code: 400, Message: "请求 JSON 格式错误: " + err.Error(), Data: nil}) + return false + } + return true +} + +func resultMessage(result DLLResult, fallback string) string { + if object, ok := result.Data.(map[string]any); ok { + if message, ok := object["Message"].(string); ok && strings.TrimSpace(message) != "" { + return message + } + } + if strings.TrimSpace(result.Raw) != "" { + return result.Raw + } + return fallback +} + +func successfulData(result DLLResult) any { + if result.Data != nil { + return result.Data + } + return result +} + +func writeJSON(w http.ResponseWriter, status int, response apiResponse) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(response) +} diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..8d4ee82 --- /dev/null +++ b/server_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type fakeReader struct { + status ReaderStatus + readResult DLLResult + readErr error + readPanic any + hmacResult DLLResult + hmacErr error + lastType string + lastNoPIN bool +} + +func (f *fakeReader) Status() ReaderStatus { return f.status } +func (f *fakeReader) ReadCard(readerType string, noPIN bool) (DLLResult, error) { + f.lastType, f.lastNoPIN = readerType, noPIN + if f.readPanic != nil { + panic(f.readPanic) + } + return f.readResult, f.readErr +} +func (f *fakeReader) HMACSM3(_, _, _, _ string) (DLLResult, error) { + return f.hmacResult, f.hmacErr +} + +func testHandler(reader *fakeReader) http.Handler { + cfg := defaultConfig(".") + return newHandler(cfg, reader, discardLogger()) +} + +func TestHealth(t *testing.T) { + reader := &fakeReader{status: ReaderStatus{Loaded: true, DLLDir: `C:\\dll`, Arch: "386", Platform: "windows"}} + recorder := httptest.NewRecorder() + testHandler(reader).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var response apiResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Code != 0 { + t.Fatalf("unexpected response: %#v", response) + } +} + +func TestReadNoPINRequiresReaderType(t *testing.T) { + recorder := httptest.NewRecorder() + testHandler(&fakeReader{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{}`))) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestReadNoPINSuccess(t *testing.T) { + reader := &fakeReader{readResult: DLLResult{Code: 0, Success: true, Data: map[string]any{"Success": true, "Name": "测试"}}} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{"reader_type":" ZJ_HZ_310000 "}`)) + testHandler(reader).ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || reader.lastType != "ZJ_HZ_310000" || !reader.lastNoPIN { + t.Fatalf("status=%d type=%q noPIN=%v body=%s", recorder.Code, reader.lastType, reader.lastNoPIN, recorder.Body.String()) + } +} + +func TestReadFailure(t *testing.T) { + reader := &fakeReader{readResult: DLLResult{Code: -1, Success: false, Raw: `{"Success":false,"Message":"未检测到卡"}`, Data: map[string]any{"Success": false, "Message": "未检测到卡"}}} + recorder := httptest.NewRecorder() + testHandler(reader).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{"reader_type":"ZJ_HZ_310000"}`))) + if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "未检测到卡") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestReadError(t *testing.T) { + reader := &fakeReader{readErr: errors.New("DLL unavailable")} + recorder := httptest.NewRecorder() + testHandler(reader).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{"reader_type":"ZJ_HZ_310000"}`))) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestReadPanicIsRecovered(t *testing.T) { + reader := &fakeReader{readPanic: "driver panic"} + recorder := httptest.NewRecorder() + testHandler(reader).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{"reader_type":"ZJ_HZ_310000"}`))) + if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "服务内部异常") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestWithPINRejectsOtherCity(t *testing.T) { + recorder := httptest.NewRecorder() + testHandler(&fakeReader{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read", strings.NewReader(`{"reader_type":"ZJ_HZ_310000"}`))) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestHMACSuccess(t *testing.T) { + reader := &fakeReader{hmacResult: DLLResult{Code: 0, Success: true, Data: "123:ABC"}} + recorder := httptest.NewRecorder() + testHandler(reader).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/hmac-sm3", strings.NewReader(`{"key":"k","secret":"1234","request_body":"{}"}`))) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "123:ABC") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestInvalidJSON(t *testing.T) { + recorder := httptest.NewRecorder() + testHandler(&fakeReader{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/card/read-nopin", strings.NewReader(`{`))) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/一键打包.bat b/一键打包.bat new file mode 100644 index 0000000..a9301ab --- /dev/null +++ b/一键打包.bat @@ -0,0 +1,134 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +set "PROJECT_DIR=%~dp0" +set "BUILD_VERSION=1.0.1" +set "MIN_GO_MAJOR=1" +set "MIN_GO_MINOR=22" +set "VENDOR_DLL=%PROJECT_DIR%..\card-read-service\package\DWCardReaderDLL\CardReaderDLL.dll" + +title readcard-go package +echo ======================================== +echo Checking readcard-go packaging environment... +echo Version: %BUILD_VERSION% +echo ======================================== +echo. + +where.exe powershell.exe >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Windows PowerShell was not found. + echo Install or enable Windows PowerShell and try again. + goto :environment_check_failed +) +echo [OK] PowerShell + +where.exe go.exe >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Go was not found in PATH. + echo Install Go %MIN_GO_MAJOR%.%MIN_GO_MINOR% or later from https://go.dev/dl/ + echo After installation, reopen this window and run the package script again. + goto :environment_check_failed +) + +set "GO_VERSION=" +for /f "delims=" %%V in ('go env GOVERSION 2^>nul') do set "GO_VERSION=%%V" +if not defined GO_VERSION ( + echo [ERROR] The Go installation could not report its version. + echo Reinstall Go and ensure its bin directory is included in PATH. + goto :environment_check_failed +) + +set "GO_SEMVER=!GO_VERSION:go=!" +set "GO_MAJOR=" +set "GO_MINOR=" +for /f "tokens=1,2 delims=." %%A in ("!GO_SEMVER!") do ( + set "GO_MAJOR=%%A" + set "GO_MINOR=%%B" +) +if not defined GO_MAJOR goto :invalid_go_version +if not defined GO_MINOR goto :invalid_go_version +set /a "GO_MAJOR_NUMBER=GO_MAJOR" >nul 2>&1 +if errorlevel 1 goto :invalid_go_version +set /a "GO_MINOR_NUMBER=GO_MINOR" >nul 2>&1 +if errorlevel 1 goto :invalid_go_version +if !GO_MAJOR_NUMBER! LSS %MIN_GO_MAJOR% goto :unsupported_go_version +if !GO_MAJOR_NUMBER! EQU %MIN_GO_MAJOR% if !GO_MINOR_NUMBER! LSS %MIN_GO_MINOR% goto :unsupported_go_version +echo [OK] Go: !GO_VERSION! + +set "HAS_WINDOWS_386=" +for /f "delims=" %%T in ('go tool dist list 2^>nul') do ( + if "%%T"=="windows/386" set "HAS_WINDOWS_386=1" +) +if not defined HAS_WINDOWS_386 ( + echo [ERROR] The installed Go toolchain does not support windows/386. + echo Install an official Go distribution and try again. + goto :environment_check_failed +) +echo [OK] Go target: windows/386 + +if not exist "%PROJECT_DIR%build.ps1" ( + echo [ERROR] Missing build script: %PROJECT_DIR%build.ps1 + goto :environment_check_failed +) +echo [OK] Build script + +if not exist "%PROJECT_DIR%config.json" ( + echo [ERROR] Missing configuration file: %PROJECT_DIR%config.json + goto :environment_check_failed +) +echo [OK] Configuration file + +if not exist "%VENDOR_DLL%" ( + echo [ERROR] Vendor DLL was not found: + echo %VENDOR_DLL% + echo Restore card-read-service\package\DWCardReaderDLL before packaging. + goto :environment_check_failed +) +echo [OK] Vendor DLL package + +if /i "%~1"=="--check" ( + echo. + echo Environment check passed. + exit /b 0 +) + +echo. +echo Environment check passed. Packaging readcard-go, please wait... +echo. + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_DIR%build.ps1" -Version "%BUILD_VERSION%" +set "BUILD_EXIT_CODE=%ERRORLEVEL%" + +echo. +if not "%BUILD_EXIT_CODE%"=="0" ( + echo ======================================== + echo Packaging failed. Exit code: %BUILD_EXIT_CODE% + echo See the error output above for details. + echo ======================================== + pause + exit /b %BUILD_EXIT_CODE% +) + +echo ======================================== +echo Packaging completed successfully. +echo Output directory: %PROJECT_DIR%dist +echo ======================================== +start "" "%PROJECT_DIR%dist" +pause +exit /b 0 + +:invalid_go_version +echo [ERROR] Unable to parse the installed Go version: !GO_VERSION! +echo Install Go %MIN_GO_MAJOR%.%MIN_GO_MINOR% or later from https://go.dev/dl/ +goto :environment_check_failed + +:unsupported_go_version +echo [ERROR] Go !GO_VERSION! is too old. +echo Install Go %MIN_GO_MAJOR%.%MIN_GO_MINOR% or later from https://go.dev/dl/ +goto :environment_check_failed + +:environment_check_failed +echo. +echo Packaging stopped because the environment check failed. +pause +exit /b 1 diff --git a/一键打包.sh b/一键打包.sh new file mode 100644 index 0000000..00b0de1 --- /dev/null +++ b/一键打包.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_VERSION="1.0.1" +MIN_GO_MAJOR=1 +MIN_GO_MINOR=22 +DIST_DIR="$PROJECT_DIR/dist" +VENDOR_SOURCE="$PROJECT_DIR/../card-read-service/package/DWCardReaderDLL" +VENDOR_DLL="$VENDOR_SOURCE/CardReaderDLL.dll" + +cd "$PROJECT_DIR" + +fail() { + printf '\n[ERROR] %s\n' "$1" >&2 + exit 1 +} + +printf '%s\n' "========================================" +printf '%s\n' "Checking readcard-go packaging environment..." +printf 'Version: %s\n' "$BUILD_VERSION" +printf '%s\n\n' "========================================" + +command -v go >/dev/null 2>&1 || fail \ + "Go was not found in PATH. Install Go $MIN_GO_MAJOR.$MIN_GO_MINOR or later from https://go.dev/dl/" + +GO_VERSION_RAW="$(go env GOVERSION 2>/dev/null || true)" +case "$GO_VERSION_RAW" in + go[0-9]*.[0-9]*) ;; + *) fail "Unable to parse the installed Go version: ${GO_VERSION_RAW:-unknown}" ;; +esac + +GO_VERSION="${GO_VERSION_RAW#go}" +GO_MAJOR="${GO_VERSION%%.*}" +GO_REMAINDER="${GO_VERSION#*.}" +GO_MINOR="${GO_REMAINDER%%.*}" +case "$GO_MAJOR:$GO_MINOR" in + *[!0-9:]*|:*|*:) fail "Unable to parse the installed Go version: $GO_VERSION_RAW" ;; +esac + +if [ "$GO_MAJOR" -lt "$MIN_GO_MAJOR" ] || \ + { [ "$GO_MAJOR" -eq "$MIN_GO_MAJOR" ] && [ "$GO_MINOR" -lt "$MIN_GO_MINOR" ]; }; then + fail "Go $GO_VERSION_RAW is too old. Install Go $MIN_GO_MAJOR.$MIN_GO_MINOR or later from https://go.dev/dl/" +fi +printf '[OK] Go: %s\n' "$GO_VERSION_RAW" + +if ! go tool dist list 2>/dev/null | grep -x "windows/386" >/dev/null; then + fail "The installed Go toolchain does not support windows/386." +fi +printf '%s\n' "[OK] Go target: windows/386" + +[ -f "$PROJECT_DIR/go.mod" ] || fail "Missing Go module file: $PROJECT_DIR/go.mod" +printf '%s\n' "[OK] Go module file" + +[ -f "$PROJECT_DIR/config.json" ] || fail "Missing configuration file: $PROJECT_DIR/config.json" +printf '%s\n' "[OK] Configuration file" + +[ -f "$VENDOR_DLL" ] || fail \ + "Vendor DLL was not found: $VENDOR_DLL. Restore card-read-service/package/DWCardReaderDLL before packaging." +printf '%s\n' "[OK] Vendor DLL package" + +if [ "${1:-}" = "--check" ]; then + printf '\n%s\n' "Environment check passed." + exit 0 +fi + +TMP_BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/readcard-go-build.XXXXXX")" +cleanup() { + rm -rf -- "$TMP_BUILD_DIR" +} +trap cleanup EXIT INT TERM + +printf '\n%s\n\n' "Environment check passed. Packaging readcard-go, please wait..." + +printf '%s\n' "Compiling Windows x86 tests..." +GOOS=windows GOARCH=386 CGO_ENABLED=0 \ + go test -c -buildvcs=false -o "$TMP_BUILD_DIR/readcard-go.test.exe" . + +printf '%s\n' "Building Windows x86 executable..." +GOOS=windows GOARCH=386 CGO_ENABLED=0 \ + go build \ + -buildvcs=false \ + -trimpath \ + -ldflags "-s -w -X main.version=$BUILD_VERSION" \ + -o "$TMP_BUILD_DIR/readcard-go.exe" \ + . + +mkdir -p "$DIST_DIR/package" +cp "$TMP_BUILD_DIR/readcard-go.exe" "$DIST_DIR/readcard-go.exe" +cp "$PROJECT_DIR/config.json" "$DIST_DIR/config.json" + +cp -R "$VENDOR_SOURCE" "$TMP_BUILD_DIR/DWCardReaderDLL" +VENDOR_TARGET="$DIST_DIR/package/DWCardReaderDLL" +case "$VENDOR_TARGET" in + "$DIST_DIR"/*) ;; + *) fail "Unexpected vendor target path: $VENDOR_TARGET" ;; +esac +rm -rf -- "$VENDOR_TARGET" +mv "$TMP_BUILD_DIR/DWCardReaderDLL" "$VENDOR_TARGET" + +printf '\n%s\n' "========================================" +printf '%s\n' "Packaging completed successfully." +printf 'Output directory: %s\n' "$DIST_DIR" +printf '%s\n' "========================================" + +if [ "$(uname -s)" = "Darwin" ] && command -v open >/dev/null 2>&1; then + open "$DIST_DIR" >/dev/null 2>&1 || true +fi