diff --git a/README.md b/README.md index df454f6..a347e46 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # readcard-go +## GUI 读卡界面 + +双击打包后的 `readcard-go.exe` 会启动桌面读卡界面和后台 HTTP 服务。点击“读卡”后,成功时显示二维码,内容格式为: + +```text +encodeURIComponent(Name)|IDNum|CardNum|CardIDCode +``` + +失败时错误原因显示在二维码下方。GUI 默认使用杭州免 PIN 读卡器类型,可在 `config.json` 中修改: + +```json +"gui_reader_type": "ZJ_HZ_310000" +``` + +Windows Common Controls v6 清单已通过 `rsrc_windows_386.syso` 嵌入 EXE,用于避免 GUI 启动时出现 `TTM_ADDTOOL failed`。`readcard-go.exe.manifest` 是资源的源文件;如修改该文件,需要重新生成对应的 `.syso` 资源。 + `card-read-service` 的 Go 版本,保持原有 HTTP 接口和响应格式,并增加业务失败、panic、DLL 崩溃与异常退出日志。 ## 构建和运行 diff --git a/build.ps1 b/build.ps1 index 97b3ef3..4ca801a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -8,6 +8,11 @@ $ProjectRoot = $PSScriptRoot $DistDir = Join-Path $ProjectRoot "dist" $VendorSource = Join-Path $ProjectRoot "..\card-read-service\package\DWCardReaderDLL" $VendorTarget = Join-Path $DistDir "package\DWCardReaderDLL" +$ResourceFile = Join-Path $ProjectRoot "rsrc_windows_386.syso" + +if (-not (Test-Path -LiteralPath $ResourceFile)) { + throw "Missing embedded Windows resource: $ResourceFile" +} New-Item -ItemType Directory -Force -Path $DistDir | Out-Null @@ -26,7 +31,7 @@ try { 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") . + go build -buildvcs=false -trimpath -ldflags "-s -w -H windowsgui -X main.version=$Version" -o (Join-Path $DistDir "readcard-go.exe") . if ($LASTEXITCODE -ne 0) { throw "Windows 386 build failed with exit code $LASTEXITCODE" } diff --git a/config.go b/config.go index fb16545..c035545 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,7 @@ type Config struct { LogMaxTotalMB int `json:"log_max_total_mb"` RestartDelayMS int `json:"restart_delay_ms"` MaxRapidRestarts int `json:"max_rapid_restarts"` + GUIReaderType string `json:"gui_reader_type"` AppRoot string `json:"-"` } @@ -36,6 +37,7 @@ func defaultConfig(appRoot string) Config { LogMaxTotalMB: 200, RestartDelayMS: 1500, MaxRapidRestarts: 5, + GUIReaderType: "ZJ_HZ_310000", AppRoot: appRoot, } } @@ -96,6 +98,9 @@ func loadConfigAt(root string) (Config, error) { if value := strings.TrimSpace(os.Getenv("CARD_DLL_DIR")); value != "" { cfg.DLLDir = value } + if value := strings.TrimSpace(os.Getenv("CARD_GUI_READER_TYPE")); value != "" { + cfg.GUIReaderType = value + } if value := os.Getenv("CARD_CORS_ORIGIN"); value != "" { cfg.CORSOrigin = value } @@ -124,6 +129,9 @@ func loadConfigAt(root string) (Config, error) { if strings.TrimSpace(cfg.DLLDir) == "" { return Config{}, errors.New("dll_dir 不能为空") } + if strings.TrimSpace(cfg.GUIReaderType) == "" { + return Config{}, errors.New("gui_reader_type 不能为空") + } if strings.TrimSpace(cfg.CORSOrigin) == "" { cfg.CORSOrigin = "*" } diff --git a/config.json b/config.json index cf3918f..c73762f 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,7 @@ "host": "127.0.0.1", "port": 17880, "dll_dir": "package/DWCardReaderDLL", + "gui_reader_type": "ZJ_HZ_310000", "cors_origin": "*", "log_dir": "logs", "log_retention_days": 30, diff --git a/config_test.go b/config_test.go index f0b6fdc..6f33338 100644 --- a/config_test.go +++ b/config_test.go @@ -18,7 +18,7 @@ func TestLoadConfigDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.Port != 17880 || !filepath.IsAbs(cfg.LogDir) { + if cfg.Port != 17880 || cfg.GUIReaderType != "ZJ_HZ_310000" || !filepath.IsAbs(cfg.LogDir) { t.Fatalf("unexpected config: %#v", cfg) } } diff --git a/go.mod b/go.mod index f5e6766..2c44538 100644 --- a/go.mod +++ b/go.mod @@ -2,3 +2,13 @@ module readcard-go go 1.22 +require ( + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e +) + +require ( + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + golang.org/x/sys v0.20.0 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..51dff83 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..3af168d --- /dev/null +++ b/gui.go @@ -0,0 +1,225 @@ +//go:build windows + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/lxn/walk" + . "github.com/lxn/walk/declarative" + "github.com/lxn/win" + qrcode "github.com/skip2/go-qrcode" +) + +type guiAPIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +type guiCardData struct { + Name string `json:"Name"` + IDNum string `json:"IDNum"` + CardNum string `json:"CardNum"` + CardIDCode string `json:"CardIDCode"` +} + +func runGUIProcess() int { + cfg, err := loadConfig() + if err != nil { + walk.MsgBox(nil, "读卡程序", err.Error(), walk.MsgBoxIconError) + return 1 + } + if err = runGUI(cfg); err != nil { + walk.MsgBox(nil, "读卡程序", err.Error(), walk.MsgBoxIconError) + return 1 + } + return 0 +} + +func runGUI(cfg Config) error { + var window *walk.MainWindow + var qrView *walk.ImageView + var message *walk.Label + var readButton *walk.PushButton + var currentBitmap *walk.Bitmap + + setMessage := func(text string, isError bool) { + message.SetText(text) + if isError { + message.SetTextColor(walk.RGB(196, 43, 28)) + } else { + message.SetTextColor(walk.RGB(90, 98, 108)) + } + } + + readCard := func() { + readButton.SetEnabled(false) + readButton.SetText("正在读卡…") + _ = qrView.SetImage(nil) + if currentBitmap != nil { + currentBitmap.Dispose() + currentBitmap = nil + } + setMessage("请保持卡片位置不动", false) + go func() { + card, failure := requestCard(cfg) + var qrImage image.Image + if failure == nil { + content := buildQRContent(card) + if strings.Trim(content, "|") == "" { + failure = fmt.Errorf("读卡成功,但未获取到有效卡片信息") + } else { + code, qrErr := qrcode.New(content, qrcode.Medium) + if qrErr != nil { + failure = fmt.Errorf("生成二维码失败:%w", qrErr) + } else { + qrImage = code.Image(300) + } + } + } + + window.Synchronize(func() { + defer func() { + readButton.SetEnabled(true) + readButton.SetText("读卡") + }() + if failure != nil { + setMessage(failure.Error(), true) + return + } + bitmap, bitmapErr := walk.NewBitmapFromImage(qrImage) + if bitmapErr != nil { + setMessage("显示二维码失败:"+bitmapErr.Error(), true) + return + } + if err := qrView.SetImage(bitmap); err != nil { + bitmap.Dispose() + setMessage("显示二维码失败:"+err.Error(), true) + return + } + if currentBitmap != nil { + currentBitmap.Dispose() + } + currentBitmap = bitmap + setMessage("读卡成功,请扫描二维码", false) + }) + }() + } + + windowDef := MainWindow{ + AssignTo: &window, + Title: "社保卡读卡", + Size: Size{520, 590}, + MinSize: Size{520, 590}, + MaxSize: Size{520, 590}, + Background: SolidColorBrush{Color: walk.RGB(247, 249, 252)}, + Layout: VBox{MarginsZero: true, SpacingZero: true}, + Children: []Widget{ + Composite{ + Layout: VBox{Margins: Margins{24, 22, 24, 12}, Spacing: 12}, + Children: []Widget{ + Label{Text: "社保卡读卡", Font: Font{Family: "Microsoft YaHei UI", PointSize: 16, Bold: true}, TextColor: walk.RGB(30, 38, 50)}, + ImageView{AssignTo: &qrView, MinSize: Size{320, 320}, Mode: ImageViewModeCenter, Background: SolidColorBrush{Color: walk.RGB(255, 255, 255)}}, + Label{AssignTo: &message, Text: "请放置社保卡,然后点击读卡", Font: Font{Family: "Microsoft YaHei UI", PointSize: 10}, Alignment: AlignHCenterVCenter, TextAlignment: AlignCenter, MinSize: Size{0, 52}, TextColor: walk.RGB(90, 98, 108)}, + }, + }, + Composite{ + Background: SolidColorBrush{Color: walk.RGB(255, 255, 255)}, + Layout: HBox{Margins: Margins{24, 18, 24, 18}}, + Children: []Widget{ + HSpacer{}, + PushButton{AssignTo: &readButton, Text: "读卡", MinSize: Size{150, 44}, Font: Font{Family: "Microsoft YaHei UI", PointSize: 11, Bold: true}, OnClicked: readCard}, + HSpacer{}, + }, + }, + }, + } + if err := windowDef.Create(); err != nil { + return err + } + // Keep only the requested minimize and close buttons in the title bar. + style := win.GetWindowLong(window.Handle(), win.GWL_STYLE) + win.SetWindowLong(window.Handle(), win.GWL_STYLE, style&^win.WS_MAXIMIZEBOX) + defer func() { + if currentBitmap != nil { + currentBitmap.Dispose() + } + window.Dispose() + }() + window.Run() + return nil +} + +func requestCard(cfg Config) (guiCardData, error) { + body, _ := json.Marshal(map[string]string{"reader_type": cfg.GUIReaderType}) + client := &http.Client{Timeout: 2 * time.Minute} + host := cfg.Host + if host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + url := fmt.Sprintf("http://%s:%d/api/card/read-nopin", host, cfg.Port) + var lastErr error + for attempt := 0; attempt < 8; attempt++ { + response, err := client.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + lastErr = err + time.Sleep(250 * time.Millisecond) + continue + } + defer response.Body.Close() + var payload guiAPIResponse + if err = json.NewDecoder(response.Body).Decode(&payload); err != nil { + return guiCardData{}, fmt.Errorf("读卡服务返回格式错误:%w", err) + } + if response.StatusCode != http.StatusOK || payload.Code != 0 { + if strings.TrimSpace(payload.Message) == "" { + payload.Message = fmt.Sprintf("读卡失败(状态码 %d)", response.StatusCode) + } + return guiCardData{}, fmt.Errorf("%s", payload.Message) + } + var card guiCardData + if err = json.Unmarshal(payload.Data, &card); err != nil { + return guiCardData{}, fmt.Errorf("无法解析卡片信息:%w", err) + } + return card, nil + } + return guiCardData{}, fmt.Errorf("无法连接读卡服务:%v", lastErr) +} + +func buildQRContent(card guiCardData) string { + return encodeURIComponent(card.Name) + "|" + card.IDNum + "|" + card.CardNum + "|" + card.CardIDCode +} + +// encodeURIComponent matches JavaScript's encodeURIComponent for UTF-8 text. +func encodeURIComponent(value string) string { + const hex = "0123456789ABCDEF" + var result strings.Builder + result.Grow(len(value)) + for len(value) > 0 { + r, size := utf8.DecodeRuneInString(value) + if r == utf8.RuneError && size == 1 { + r = '\uFFFD' + } + encoded := make([]byte, utf8.RuneLen(r)) + utf8.EncodeRune(encoded, r) + for _, b := range encoded { + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || strings.ContainsRune("-_.!~*'()", rune(b)) { + result.WriteByte(b) + } else { + result.WriteByte('%') + result.WriteByte(hex[b>>4]) + result.WriteByte(hex[b&15]) + } + } + value = value[size:] + } + return result.String() +} diff --git a/gui_test.go b/gui_test.go new file mode 100644 index 0000000..9c2549a --- /dev/null +++ b/gui_test.go @@ -0,0 +1,21 @@ +//go:build windows + +package main + +import "testing" + +func TestBuildQRContent(t *testing.T) { + got := buildQRContent(guiCardData{Name: "张 三", IDNum: "3301", CardNum: "C-1", CardIDCode: "ID"}) + want := "%E5%BC%A0%20%E4%B8%89|3301|C-1|ID" + if got != want { + t.Fatalf("buildQRContent() = %q, want %q", got, want) + } +} + +func TestEncodeURIComponentCompatibleCharacters(t *testing.T) { + const input = "AZaz09-_.!~*'() /" + const want = "AZaz09-_.!~*'()%20%2F" + if got := encodeURIComponent(input); got != want { + t.Fatalf("encodeURIComponent() = %q, want %q", got, want) + } +} diff --git a/main.go b/main.go index 6c81e8b..881c011 100644 --- a/main.go +++ b/main.go @@ -29,24 +29,31 @@ func realMain() (exitCode int) { exitCode = 2 } }() + if hasArg("--gui") { + return runGUIProcess() + } if isWorker() { return runWorker() } return runSupervisor() } -func isWorker() bool { - if os.Getenv("CARD_NO_SUPERVISOR") == "1" { - return true - } +func hasArg(want string) bool { for _, arg := range os.Args[1:] { - if arg == "--worker" { + if arg == want { return true } } return false } +func isWorker() bool { + if os.Getenv("CARD_NO_SUPERVISOR") == "1" { + return true + } + return hasArg("--worker") +} + func runWorker() int { cfg, err := loadConfig() if err != nil { @@ -172,6 +179,22 @@ func runSupervisor() int { waitDone := make(chan error, 1) go func() { waitDone <- cmd.Wait() }() + gui := exec.Command(exe, "--gui") + gui.Dir = cfg.AppRoot + gui.Env = append(os.Environ(), "CARD_APP_ROOT="+cfg.AppRoot) + if err = gui.Start(); err != nil { + logger.Error("启动 GUI 失败", "event", "gui_start_failure", "error", err) + _ = cmd.Process.Kill() + <-waitDone + if crashWriter != nil { + _ = crashWriter.Close() + } + return 1 + } + logger.Info("GUI 已启动", "event", "gui_started", "gui_pid", gui.Process.Pid) + guiDone := make(chan error, 1) + go func() { guiDone <- gui.Wait() }() + select { case sig := <-signals: logger.Info("守护进程收到停止信号", "event", "supervisor_shutdown", "signal", sig.String(), "worker_pid", cmd.Process.Pid) @@ -186,8 +209,25 @@ func runSupervisor() int { if crashWriter != nil { _ = crashWriter.Close() } + _ = gui.Process.Kill() + <-guiDone + return 0 + case guiErr := <-guiDone: + logger.Info("GUI 已关闭", "event", "gui_stopped", "error", guiErr) + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-waitDone: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-waitDone + } + if crashWriter != nil { + _ = crashWriter.Close() + } return 0 case waitErr := <-waitDone: + _ = gui.Process.Kill() + <-guiDone if crashWriter != nil { _ = crashWriter.Close() } diff --git a/readcard-go.exe.manifest b/readcard-go.exe.manifest new file mode 100644 index 0000000..f8724a7 --- /dev/null +++ b/readcard-go.exe.manifest @@ -0,0 +1,26 @@ + + + + Social security card reader + + + + + + + + PerMonitorV2, PerMonitor + true + + + diff --git a/rsrc_windows_386.syso b/rsrc_windows_386.syso new file mode 100644 index 0000000..8a827e5 Binary files /dev/null and b/rsrc_windows_386.syso differ diff --git a/一键打包.bat b/一键打包.bat index a9301ab..ff26458 100644 --- a/一键打包.bat +++ b/一键打包.bat @@ -78,6 +78,18 @@ if not exist "%PROJECT_DIR%config.json" ( ) echo [OK] Configuration file +if not exist "%PROJECT_DIR%readcard-go.exe.manifest" ( + echo [ERROR] Missing Windows application manifest: %PROJECT_DIR%readcard-go.exe.manifest + goto :environment_check_failed +) +echo [OK] Windows application manifest + +if not exist "%PROJECT_DIR%rsrc_windows_386.syso" ( + echo [ERROR] Missing embedded Windows resource: %PROJECT_DIR%rsrc_windows_386.syso + goto :environment_check_failed +) +echo [OK] Embedded Windows resource + if not exist "%VENDOR_DLL%" ( echo [ERROR] Vendor DLL was not found: echo %VENDOR_DLL% diff --git a/一键打包.sh b/一键打包.sh index 00b0de1..8792099 100644 --- a/一键打包.sh +++ b/一键打包.sh @@ -56,6 +56,14 @@ 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 "$PROJECT_DIR/readcard-go.exe.manifest" ] || fail \ + "Missing Windows application manifest: $PROJECT_DIR/readcard-go.exe.manifest" +printf '%s\n' "[OK] Windows application manifest" + +[ -f "$PROJECT_DIR/rsrc_windows_386.syso" ] || fail \ + "Missing embedded Windows resource: $PROJECT_DIR/rsrc_windows_386.syso" +printf '%s\n' "[OK] Embedded Windows resource" + [ -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" @@ -82,7 +90,7 @@ GOOS=windows GOARCH=386 CGO_ENABLED=0 \ go build \ -buildvcs=false \ -trimpath \ - -ldflags "-s -w -X main.version=$BUILD_VERSION" \ + -ldflags "-s -w -H windowsgui -X main.version=$BUILD_VERSION" \ -o "$TMP_BUILD_DIR/readcard-go.exe" \ .