改为可视化界面

This commit is contained in:
huxuejian 2026-08-12 13:40:06 +08:00
parent f538608ba4
commit 8fd7ec0d15
14 changed files with 391 additions and 8 deletions

View File

@ -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 崩溃与异常退出日志。
## 构建和运行

View File

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

View File

@ -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 = "*"
}

View File

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

View File

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

10
go.mod
View File

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

11
go.sum Normal file
View File

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

225
gui.go Normal file
View File

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

21
gui_test.go Normal file
View File

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

50
main.go
View File

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

26
readcard-go.exe.manifest Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="readcard-go"
version="1.0.0.0"
processorArchitecture="*" />
<description>Social security card reader</description>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*" />
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
</assembly>

BIN
rsrc_windows_386.syso Normal file

Binary file not shown.

View File

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

View File

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