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