first commit
This commit is contained in:
commit
f538608ba4
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/dist/
|
||||||
|
/logs/
|
||||||
|
/.gocache/
|
||||||
|
/.gotmp/
|
||||||
|
*.exe
|
||||||
104
README.md
Normal file
104
README.md
Normal 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
46
build.ps1
Normal 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
160
config.go
Normal 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
12
config.json
Normal 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
66
config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
240
logger.go
Normal file
240
logger.go
Normal 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
75
logger_test.go
Normal 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
233
main.go
Normal 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
211
reader.go
Normal 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
11
run.ps1
Normal 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
235
server.go
Normal 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
124
server_test.go
Normal 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
134
一键打包.bat
Normal 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
109
一键打包.sh
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user