first commit

This commit is contained in:
huxuejian 2026-08-06 11:23:33 +08:00
commit f538608ba4
16 changed files with 1769 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/dist/
/logs/
/.gocache/
/.gotmp/
*.exe

104
README.md Normal file
View File

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

46
build.ps1 Normal file
View File

@ -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"

160
config.go Normal file
View File

@ -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()
}

12
config.json Normal file
View File

@ -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
}

66
config_test.go Normal file
View File

@ -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")
}
}

4
go.mod Normal file
View File

@ -0,0 +1,4 @@
module readcard-go
go 1.22

240
logger.go Normal file
View File

@ -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))}
}

75
logger_test.go Normal file
View File

@ -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)
}
}

233
main.go Normal file
View File

@ -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()
}

211
reader.go Normal file
View File

@ -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")), "<22>")
}
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
}

11
run.ps1 Normal file
View File

@ -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
}

235
server.go Normal file
View File

@ -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)
}

124
server_test.go Normal file
View File

@ -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())
}
}

134
一键打包.bat Normal file
View File

@ -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

109
一键打包.sh Normal file
View File

@ -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