commit e26d7a027eeff7fe048010046b03e7e317583e1f Author: 97694732@qq.com Date: Thu Jun 11 16:06:08 2026 +0800 first commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kfz-goods-pricing.iml b/.idea/kfz-goods-pricing.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/kfz-goods-pricing.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..851a11f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..33c0cdb --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# kfz-goods-pricing + +商品定价服务 + +## 项目结构 + +``` +kfz-goods-pricing/ +├── cmd/ +│ └── server/ # 应用入口 +├── internal/ +│ ├── handler/ # HTTP 处理器 +│ ├── service/ # 业务逻辑 +│ └── repository/ # 数据访问 +├── pkg/ # 公共库 +├── configs/ # 配置文件 +├── go.mod +└── README.md +``` + +## 运行 + +```bash +go run cmd/server/main.go +``` diff --git a/cmd/server/gui_windows.go b/cmd/server/gui_windows.go new file mode 100644 index 0000000..276250f --- /dev/null +++ b/cmd/server/gui_windows.go @@ -0,0 +1,277 @@ +//go:build windows + +package main + +import ( + "fmt" + "log" + "os" + "runtime" + "sync" + "syscall" + "unsafe" +) + +// Win32 常量 +const ( + WS_OVERLAPPEDWINDOW = 0x00CF0000 + WS_CHILD = 0x40000000 + WS_VISIBLE = 0x10000000 + WS_VSCROLL = 0x00200000 + WS_HSCROLL = 0x00100000 + WS_EX_CLIENTEDGE = 0x00000200 + + ES_MULTILINE = 0x0004 + ES_READONLY = 0x0800 + ES_AUTOVSCROLL = 0x0040 + ES_AUTOHSCROLL = 0x0080 + + WM_DESTROY = 0x0002 + WM_SIZE = 0x0005 + WM_SETFONT = 0x0030 + WM_CTLCOLOREDIT = 0x0133 + EM_SETSEL = 0x00B1 + EM_REPLACESEL = 0x00C2 + + SW_SHOW = 5 + IDC_ARROW = 32512 + CW_USEDEFAULT = ^0x7FFFFFFF + FIXED_PITCH = 1 + FF_MODERN = 48 + DEFAULT_CHARSET = 1 + FW_NORMAL = 400 + MB_ICONERROR = 0x00000010 +) + +// 暗色主题 (BGR格式) +const ( + colorBg = 0x001E1E1E + colorFg = 0x00D4D4D4 +) + +type tWNDCLASSEX struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance uintptr + HIcon uintptr + HCursor uintptr + HbrBackground uintptr + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm uintptr +} + +type tRECT struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +type tMSG struct { + HWnd uintptr + Message uint32 + _ uint32 + WParam uintptr + LParam uintptr + Time uint32 + PtX int32 + PtY int32 +} + +var ( + modUser32 = syscall.NewLazyDLL("user32.dll") + modKernel32 = syscall.NewLazyDLL("kernel32.dll") + modGdi32 = syscall.NewLazyDLL("gdi32.dll") + + pGetModuleHandleW = modKernel32.NewProc("GetModuleHandleW") + pLoadCursorW = modUser32.NewProc("LoadCursorW") + pRegisterClassExW = modUser32.NewProc("RegisterClassExW") + pCreateWindowExW = modUser32.NewProc("CreateWindowExW") + pShowWindow = modUser32.NewProc("ShowWindow") + pUpdateWindow = modUser32.NewProc("UpdateWindow") + pGetMessageW = modUser32.NewProc("GetMessageW") + pTranslateMessage = modUser32.NewProc("TranslateMessage") + pDispatchMessageW = modUser32.NewProc("DispatchMessageW") + pDefWindowProcW = modUser32.NewProc("DefWindowProcW") + pPostQuitMessage = modUser32.NewProc("PostQuitMessage") + pSendMessageW = modUser32.NewProc("SendMessageW") + pGetClientRect = modUser32.NewProc("GetClientRect") + pMoveWindow = modUser32.NewProc("MoveWindow") + pMessageBoxW = modUser32.NewProc("MessageBoxW") + pCreateFontW = modGdi32.NewProc("CreateFontW") + pDeleteObject = modGdi32.NewProc("DeleteObject") + pCreateSolidBrush = modGdi32.NewProc("CreateSolidBrush") + pSetTextColor = modGdi32.NewProc("SetTextColor") + pSetBkColor = modGdi32.NewProc("SetBkColor") +) + +var ( + hWndMain uintptr + hWndEdit uintptr + hFont uintptr + hbrBg uintptr + guiReady bool + guiMu sync.Mutex + pendingLogs []string +) + +// guiLogWriter 实现 io.Writer,将日志写入编辑控件 +type guiLogWriter struct{} + +func (w *guiLogWriter) Write(p []byte) (n int, err error) { + text := string(p) + + guiMu.Lock() + if !guiReady || hWndEdit == 0 { + pendingLogs = append(pendingLogs, text) + guiMu.Unlock() + return len(p), nil + } + guiMu.Unlock() + + appendText(text) + return len(p), nil +} + +func appendText(text string) { + if hWndEdit == 0 { + return + } + // 光标移到末尾 + pSendMessageW.Call(hWndEdit, EM_SETSEL, ^uintptr(0), ^uintptr(0)) + // 插入文本 + ptr, _ := syscall.UTF16PtrFromString(text) + pSendMessageW.Call(hWndEdit, EM_REPLACESEL, 1, uintptr(unsafe.Pointer(ptr))) +} + +func flushPendingLogs() { + guiMu.Lock() + logs := pendingLogs + pendingLogs = nil + guiReady = true + guiMu.Unlock() + + for _, text := range logs { + appendText(text) + } +} + +func wndProc(hWnd uintptr, msg uint32, wParam, lParam uintptr) uintptr { + switch msg { + case WM_SIZE: + if hWndEdit != 0 { + var rect tRECT + pGetClientRect.Call(hWnd, uintptr(unsafe.Pointer(&rect))) + pMoveWindow.Call(hWndEdit, 0, 0, uintptr(rect.Right), uintptr(rect.Bottom), 1) + } + return 0 + + case WM_CTLCOLOREDIT: + pSetTextColor.Call(wParam, colorFg) + pSetBkColor.Call(wParam, colorBg) + return hbrBg + + case WM_DESTROY: + if hFont != 0 { + pDeleteObject.Call(hFont) + } + if hbrBg != 0 { + pDeleteObject.Call(hbrBg) + } + pPostQuitMessage.Call(0) + return 0 + } + + ret, _, _ := pDefWindowProcW.Call(hWnd, uintptr(msg), wParam, lParam) + return ret +} + +// runGUI 创建并运行GUI窗口,阻塞直到窗口关闭 +func runGUI() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + hInstance, _, _ := pGetModuleHandleW.Call(0) + + className, _ := syscall.UTF16PtrFromString("KfzLogWnd") + windowTitle, _ := syscall.UTF16PtrFromString("孔网商品定价 - 日志") + editClass, _ := syscall.UTF16PtrFromString("EDIT") + + hCursor, _, _ := pLoadCursorW.Call(0, IDC_ARROW) + + // 创建暗色背景画刷 + hbrBg, _, _ = pCreateSolidBrush.Call(colorBg) + + // 注册窗口类 + wc := tWNDCLASSEX{ + CbSize: uint32(unsafe.Sizeof(tWNDCLASSEX{})), + LpfnWndProc: syscall.NewCallback(wndProc), + HInstance: hInstance, + HCursor: hCursor, + HbrBackground: hbrBg, + LpszClassName: className, + } + pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + // 创建主窗口 + hWndMain, _, _ = pCreateWindowExW.Call( + 0, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(windowTitle)), + WS_OVERLAPPEDWINDOW, + 100, 100, 900, 600, + 0, 0, hInstance, 0, + ) + + // 创建编辑控件(多行、只读、滚动条) + hWndEdit, _, _ = pCreateWindowExW.Call( + WS_EX_CLIENTEDGE, + uintptr(unsafe.Pointer(editClass)), + 0, + WS_CHILD|WS_VISIBLE|WS_VSCROLL|WS_HSCROLL|ES_MULTILINE|ES_READONLY|ES_AUTOVSCROLL|ES_AUTOHSCROLL, + 0, 0, 0, 0, + hWndMain, 0, hInstance, 0, + ) + + // 设置等宽字体 + fontName, _ := syscall.UTF16PtrFromString("Consolas") + hFont, _, _ = pCreateFontW.Call( + 16, 0, 0, 0, FW_NORMAL, 0, 0, 0, + DEFAULT_CHARSET, 0, 0, 0, + FIXED_PITCH|FF_MODERN, + uintptr(unsafe.Pointer(fontName)), + ) + pSendMessageW.Call(hWndEdit, WM_SETFONT, hFont, 1) + + // 刷新缓冲日志 + flushPendingLogs() + + // 显示窗口 + pShowWindow.Call(hWndMain, SW_SHOW) + pUpdateWindow.Call(hWndMain) + + // 消息循环 + var msg tMSG + for { + ret, _, _ := pGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if ret == 0 { + break + } + pTranslateMessage.Call(uintptr(unsafe.Pointer(&msg))) + pDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg))) + } +} + +// fatalExit 弹出错误对话框并退出 +func fatalExit(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + log.Print(msg) + title, _ := syscall.UTF16PtrFromString("错误") + text, _ := syscall.UTF16PtrFromString(msg) + pMessageBoxW.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)), MB_ICONERROR) + os.Exit(1) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8d48a13 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "kfz-goods-pricing/internal/config" + "kfz-goods-pricing/internal/database" + "kfz-goods-pricing/internal/handler" + "kfz-goods-pricing/internal/repository" + "kfz-goods-pricing/internal/service" +) + +func main() { + // 日志输出到GUI窗口 + log.SetOutput(&guiLogWriter{}) + + // 加载配置 + cfg, err := config.Load("./config/config.yaml") + if err != nil { + fatalExit("加载配置文件失败: %v", err) + } + config.SetGlobal(cfg) + log.Printf("配置加载成功: port=%s, timer=%ds, rate_limit=%ds", cfg.Port, cfg.TimerInterval, cfg.APIRateLimit) + + global := config.GetGlobal() + marshal, _ := json.Marshal(global) + fmt.Println("config:", string(marshal)) + + // 初始化数据库 + if err := database.InitDB("./data/goods_pricing.db"); err != nil { + fatalExit("初始化数据库失败: %v", err) + } + defer database.CloseDB() + log.Println("数据库初始化成功") + + // 初始化服务 + proxy := "" + tokenRepo := repository.NewTokenRepository() + goodsService := service.NewGoodsService(proxy, cfg.APIRateLimit, cfg.CallbackURL, tokenRepo) + goodsHandler := handler.NewGoodsHandler(goodsService) + + // Token相关 + tokenHandler := handler.NewTokenHandler(tokenRepo) + + // Kfz登录 + kfzHandler := handler.NewKfzHandler() + + // 配置相关 + configHandler := handler.NewConfigHandler("./config/config.yaml") + + // 注册路由(带 CORS 中间件) + http.HandleFunc("/api/goods/query", corsMiddleware(goodsHandler.QueryGoods)) + + // Token路由 + http.HandleFunc("/api/token/add", corsMiddleware(tokenHandler.BatchAddTokens)) + http.HandleFunc("/api/token/list", corsMiddleware(tokenHandler.GetAllTokens)) + http.HandleFunc("/api/token/delete", corsMiddleware(tokenHandler.DeleteToken)) + http.HandleFunc("/api/token/update", corsMiddleware(tokenHandler.UpdateToken)) + http.HandleFunc("/api/token/enabled", corsMiddleware(tokenHandler.GetEnabledTokens)) + + // Kfz登录路由 + http.HandleFunc("/api/kfz/login", corsMiddleware(kfzHandler.KfzLogin)) + + // 配置路由 + http.HandleFunc("/api/config/price/get", corsMiddleware(configHandler.GetConfigPrice)) + http.HandleFunc("/api/config/price/set", corsMiddleware(configHandler.SetConfigPrice)) + + // 启动定时器 + goodsService.StartTimerScheduler(cfg.TimerInterval) + log.Printf("定时器已启动,%d秒后开始首次同步", cfg.TimerInterval) + + // 启动HTTP服务(后台) + go func() { + log.Printf("服务器正在启动 %s", cfg.Port) + if err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), nil); err != nil { + fatalExit("启动服务器失败: %v", err) + } + }() + + // 启动GUI窗口(阻塞直到窗口关闭) + runGUI() +} + +// corsMiddleware CORS 跨域中间件 +func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // 允许所有来源 + w.Header().Set("Access-Control-Allow-Origin", "*") + // 允许的方法 + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + // 允许的请求头 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + + // 预检请求直接返回 + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // 调用实际处理函数 + next(w, r) + } +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..c201fbc --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,4 @@ +port: "8080" +timer_interval: 5 +api_rate_limit: 2 +callback_url: http://192.168.101.213:9090/api/product/updatePrice \ No newline at end of file diff --git a/data/goods_pricing.db b/data/goods_pricing.db new file mode 100644 index 0000000..25574f5 Binary files /dev/null and b/data/goods_pricing.db differ diff --git a/data/goods_pricing1.db b/data/goods_pricing1.db new file mode 100644 index 0000000..7835ef4 Binary files /dev/null and b/data/goods_pricing1.db differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b6cfd87 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module kfz-goods-pricing + +go 1.25.0 + +require ( + github.com/parnurzeal/gorequest v0.3.0 + modernc.org/sqlite v1.50.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elazarl/goproxy v1.8.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moul/http2curl v1.0.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/smartystreets/goconvey v1.8.1 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd400b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v1.8.3 h1:XhiZpzW0NvsGOqSv/F3v4+1F29842yYaJNN+In5Fnuc= +github.com/elazarl/goproxy v1.8.3/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/parnurzeal/gorequest v0.3.0 h1:SoFyqCDC9COr1xuS6VA8fC8RU7XyrJZN2ona1kEX7FI= +github.com/parnurzeal/gorequest v0.3.0/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..441d8f9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" + "sync" + + "gopkg.in/yaml.v3" +) + +// Config 配置结构 +type Config struct { + Port string `yaml:"port"` // 端口号 + TimerInterval int `yaml:"timer_interval"` // 定时任务间隔 + APIRateLimit int `yaml:"api_rate_limit"` // API请求限制 + CallbackURL string `yaml:"callback_url"` // 回调地址 + NewPrice float64 `yaml:"new_price"` // 新价格 + PlaceholderDownPrice float64 `yaml:"placeholder_down_price"` // 占位降价 + MinShippingFee float64 `yaml:"min_shipping_fee"` // 最低运费 + MinPrice float64 `yaml:"min_price"` // 最低书价 + QueryIndex int `yaml:"query_index"` // 想排第几位 +} + +// Load 加载配置文件 +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +// Save 保存配置文件 +func Save(path string, cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// GlobalConfig 全局配置 +var ( + _globalConfig *Config + _globalMu sync.RWMutex +) + +// SetGlobal 设置全局配置 +func SetGlobal(cfg *Config) { + _globalMu.Lock() + defer _globalMu.Unlock() + _globalConfig = cfg +} + +// GetGlobal 获取全局配置 +func GetGlobal() *Config { + _globalMu.RLock() + defer _globalMu.RUnlock() + return _globalConfig +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..ee42b00 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,97 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +// InitDB 初始化数据库 +func InitDB(dbPath string) error { + var err error + + // 确保数据库目录存在 + dir := "./data" + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.MkdirAll(dir, 0755) + } + + // 打开数据库连接 + DB, err = sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("打开数据库失败: %w", err) + } + + // 创建表 + err = createTables() + if err != nil { + return fmt.Errorf("创建表失败: %w", err) + } + + return nil +} + +// PlaceholderDownPrice float64 `json:"placeholderDownPrice"` // 占位降价 默认0.01 +// MinShippingFee float64 `json:"minShippingFee"` // 最低运费 +// MinPrice float64 `json:"minPrice"` // 最低书价 +// +// createTables 创建数据表 +func createTables() error { + sql := ` + CREATE TABLE IF NOT EXISTS goods_pricing ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isbn TEXT, + book_name TEXT, + author TEXT, + publishing TEXT, + out_id TEXT, + quality TEXT, + query_index INTEGER DEFAULT 0, + user_id TEXT, + price REAL, + shipping_fee REAL, + placeholder_down_price REAL, -- 占位降价 + min_shipping_fee REAL, -- 最低运费 + min_price REAL, -- 最低书价 + final_price REAL, -- 最终价格 + fail_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS kfz_token ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + token TEXT NOT NULL, + is_enable INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_isbn ON goods_pricing(isbn); + CREATE INDEX IF NOT EXISTS idx_out_id ON goods_pricing(out_id); + + CREATE TABLE IF NOT EXISTS kfz_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + new_price REAL, + placeholder_down_price REAL, + min_shipping_fee REAL, + min_price REAL, + query_index INTEGER DEFAULT 0 + ); + ` + + _, err := DB.Exec(sql) + return err +} + +// CloseDB 关闭数据库连接 +func CloseDB() error { + if DB != nil { + return DB.Close() + } + return nil +} diff --git a/internal/handler/config_handler.go b/internal/handler/config_handler.go new file mode 100644 index 0000000..b61f371 --- /dev/null +++ b/internal/handler/config_handler.go @@ -0,0 +1,121 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "sync" + + "kfz-goods-pricing/internal/config" + "kfz-goods-pricing/internal/repository" +) + +// ConfigHandler 配置处理器 +type ConfigHandler struct { + configPath string + mu sync.Mutex +} + +// NewConfigHandler 创建配置处理器实例 +func NewConfigHandler(configPath string) *ConfigHandler { + return &ConfigHandler{ + configPath: configPath, + } +} + +// GetConfigPrice 获取配置 +// 先读 yaml(Port/TimerInterval/APIRateLimit/CallbackURL),再用数据库值覆盖价格字段 +func (h *ConfigHandler) GetConfigPrice(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + defer h.mu.Unlock() + + // 1. 加载yaml配置 + cfg, err := config.Load(h.configPath) + if err != nil { + log.Printf("[GetConfigPrice] 读取配置失败: %s", err.Error()) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"code":500,"message":"读取配置失败: %s"}`, err.Error()))) + return + } + + // 2. 查询 kfz_config 数据库,有数据则覆盖对应字段 + dbCfg, err := repository.GetKfzConfig() + if err != nil { + log.Printf("[GetConfigPrice] 查询kfz_config失败: %s", err.Error()) + // 查询出错不影响返回yaml配置,继续执行 + } else if dbCfg != nil { + cfg.NewPrice = dbCfg.NewPrice + cfg.PlaceholderDownPrice = dbCfg.PlaceholderDownPrice + cfg.MinShippingFee = dbCfg.MinShippingFee + cfg.MinPrice = dbCfg.MinPrice + cfg.QueryIndex = dbCfg.QueryIndex + } + + // 同步全局配置 + config.SetGlobal(cfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 200, + "message": "success", + "data": cfg, + }) +} + +// SetConfigPrice 修改价格配置 +// 保存到 kfz_config 表,永远只有 ID=1 一条记录 +// 入参中只传需要修改的字段,未传的字段保持数据库原值不变 +func (h *ConfigHandler) SetConfigPrice(w http.ResponseWriter, r *http.Request) { + r.ParseMultipartForm(32 << 20) + + h.mu.Lock() + defer h.mu.Unlock() + + // 1. 获取现有配置(无数据则为空结构体) + dbCfg, err := repository.GetKfzConfig() + if err != nil { + log.Printf("[SetConfigPrice] 查询kfz_config失败: %s", err.Error()) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"code":500,"message":"查询配置失败: %s"}`, err.Error()))) + return + } + if dbCfg == nil { + dbCfg = &repository.KfzConfig{} + } + + // 2. 只更新入参中提供的字段 + if len(r.PostForm["new_price"]) > 0 { + dbCfg.NewPrice, _ = strconv.ParseFloat(r.PostForm.Get("new_price"), 64) + } + if len(r.PostForm["placeholder_down_price"]) > 0 { + dbCfg.PlaceholderDownPrice, _ = strconv.ParseFloat(r.PostForm.Get("placeholder_down_price"), 64) + } + if len(r.PostForm["min_shipping_fee"]) > 0 { + dbCfg.MinShippingFee, _ = strconv.ParseFloat(r.PostForm.Get("min_shipping_fee"), 64) + } + if len(r.PostForm["min_price"]) > 0 { + dbCfg.MinPrice, _ = strconv.ParseFloat(r.PostForm.Get("min_price"), 64) + } + if len(r.PostForm["query_index"]) > 0 { + dbCfg.QueryIndex, _ = strconv.Atoi(r.PostForm.Get("query_index")) + } + + // 3. 保存 + if err := repository.SaveKfzConfig(dbCfg); err != nil { + log.Printf("[SetConfigPrice] 保存kfz_config失败: %s", err.Error()) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"code":500,"message":"保存配置失败: %s"}`, err.Error()))) + return + } + + log.Printf("[SetConfigPrice] 配置保存成功: new_price=%.2f, placeholder_down_price=%.2f, min_shipping_fee=%.2f, min_price=%.2f, query_index=%d", + dbCfg.NewPrice, dbCfg.PlaceholderDownPrice, dbCfg.MinShippingFee, dbCfg.MinPrice, dbCfg.QueryIndex) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 200, + "message": "success", + }) +} diff --git a/internal/handler/goods_handler.go b/internal/handler/goods_handler.go new file mode 100644 index 0000000..a8c47fa --- /dev/null +++ b/internal/handler/goods_handler.go @@ -0,0 +1,79 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "strconv" + + "kfz-goods-pricing/internal/service" +) + +// GoodsHandler 商品处理器 +type GoodsHandler struct { + goodsService *service.GoodsService +} + +// NewGoodsHandler 创建商品处理器实例 +func NewGoodsHandler(goodsService *service.GoodsService) *GoodsHandler { + return &GoodsHandler{ + goodsService: goodsService, + } +} + +// QueryGoods 查询商品接口 +func (h *GoodsHandler) QueryGoods(w http.ResponseWriter, r *http.Request) { + // 只支持POST请求 + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 读取原始body用于调试 + bodyBytes, _ := io.ReadAll(r.Body) + bodyStr := string(bodyBytes) + log.Printf("收到原始请求 Body: [%s]", bodyStr) + log.Printf("Content-Type: [%s]", r.Header.Get("Content-Type")) + + // 重新创建body供ParseForm使用 + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // 解析参数 - ParseMultipartForm 同时支持 form-data 和 x-www-form-urlencoded + if err := r.ParseMultipartForm(32 << 20); err != nil { + // 尝试纯表单解析 + if err := r.ParseForm(); err != nil { + log.Printf("ParseForm 失败: %v", err) + http.Error(w, "Invalid request form", http.StatusBadRequest) + return + } + } + + // 调试日志 + log.Printf("解析结果 - isbn: [%s], book_name: [%s], author: [%s], publishing: [%s], out_id: [%s], quality: [%s], query_index: [%s], user_id: [%s], placeholder_down_price: [%s], min_shipping_fee: [%s], min_price: [%s] ", + r.FormValue("isbn"), r.FormValue("book_name"), r.FormValue("author"), r.FormValue("publishing"), + r.FormValue("out_id"), r.FormValue("quality"), + r.FormValue("query_index"), r.FormValue("user_id"), r.FormValue("placeholder_down_price"), r.FormValue("min_shipping_fee"), r.FormValue("min_price")) + + var req service.QueryRequest + req.ISBN = r.FormValue("isbn") + req.BookName = r.FormValue("book_name") + req.Author = r.FormValue("author") + req.Publishing = r.FormValue("publishing") + req.OutID = r.FormValue("out_id") + req.Quality = r.FormValue("quality") + req.QueryIndex, _ = strconv.Atoi(r.FormValue("query_index")) + req.UserID = r.FormValue("user_id") + req.PlaceholderDownPrice, _ = strconv.ParseFloat(r.FormValue("placeholder_down_price"), 64) + req.MinShippingFee, _ = strconv.ParseFloat(r.FormValue("min_shipping_fee"), 64) + req.MinPrice, _ = strconv.ParseFloat(r.FormValue("min_price"), 64) + + // 调用服务层 + resp := h.goodsService.QueryGoods(&req) + + // 返回响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} diff --git a/internal/handler/kfz_handler.go b/internal/handler/kfz_handler.go new file mode 100644 index 0000000..8db9458 --- /dev/null +++ b/internal/handler/kfz_handler.go @@ -0,0 +1,204 @@ +package handler + +import ( + "encoding/json" + "fmt" + "github.com/parnurzeal/gorequest" + "net/http" + "strings" + "time" +) + +// KfzHandler Kfz处理器 +type KfzHandler struct { +} + +// NewKfzHandler 创建Kfz处理器实例 +func NewKfzHandler() *KfzHandler { + return &KfzHandler{} +} + +// KfzLogin 登录孔网并返回用户信息 +func (h *KfzHandler) KfzLogin(w http.ResponseWriter, r *http.Request) { + r.ParseMultipartForm(32 << 20) + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + + if username == "" || password == "" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":500,"message":"username和password不能为空"}`)) + return + } + + token, err := outKfzLogin(username, password) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"code":500,"message":"%s"}`, err.Error()))) + return + } + + userInfo, err := outKfzGetUserInfo(token) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"code":500,"message":"%s"}`, err.Error()))) + return + } + + userInfo.Token = token + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 200, + "message": "success", + "data": userInfo, + }) +} + +/* + * 孔网登录 + * param username[string] 孔网用户名 + * param password[string] 孔网密码 + * return token,错误信息 + * Error 登录请求失败 + * Error 登录失败(HTTP状态码: %d) + * Error 登录成功但未获取到Cookie + * Error 登录失败: 未找到 PHPSESSID + * Error 账号或密码错误 + * Error 登录失败 + * Error 登录失败,未知错误! + */ +func outKfzLogin(username, password string) (string, error) { + // 检查用户名和密码是否为空 + if username == "" || password == "" { + return "", fmt.Errorf("请输入用户名和密码!") + } + + // 准备POST请求的表单数据 + formData := map[string]string{ + "loginName": username, + "loginPass": password, + "returnUrl": "http://user.kongfz.com/", + } + // 孔网登录URL + loginUrl := "https://login.kongfz.com/Pc/Login/account" + + // 发送登录请求 + resp, body, errs := gorequest.New(). + Post(loginUrl). + Set("Content-Type", "application/x-www-form-urlencoded"). + Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"). + Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"). + Send(formData). + Timeout(15 * time.Second). + End() + + // 请求错误处理 + if len(errs) > 0 { + return "", fmt.Errorf("登录请求失败: %v", errs) + } + + // 检查HTTP状态码 + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("登录失败(HTTP状态码: %d)", resp.StatusCode) + } + + // 提取Cookie + cookie := resp.Header.Get("Set-Cookie") + // 检查是否登录成功(通过响应内容判断) + if strings.Contains(body, "window.location.href='https://login.kongfz.cn/Pc/Session/rsync") { + if cookie == "" { + return "", fmt.Errorf("登录成功但未获取到Cookie") + } + + // 登录成功 + if strings.Contains(cookie, "PHPSESSID=") { + token := strings.Split(strings.Split(cookie, "PHPSESSID=")[1], ";")[0] + return token, nil + } + + return "", fmt.Errorf("登录失败: 未找到PHPSESSID") + } + + // 错误信息 + var res struct { + Status bool `json:"status"` + ErrCode int `json:"errCode"` + ErrInfo string `json:"errInfo"` + } + // 解析json + if err := json.Unmarshal([]byte(body), &res); err == nil { + if res.ErrCode == 1001 || res.ErrCode == 1005 { + return "", fmt.Errorf("账号或密码错误!") + } + + if res.ErrInfo != "" { + return "", fmt.Errorf("登录失败: %s", res.ErrInfo) + } + } + + return "", fmt.Errorf("登录失败,未知错误!") +} + +// UserInfo 孔网用户信息结构体 +type UserInfo struct { + UserID int64 `json:"userId"` // 用户ID + Nickname string `json:"nickname"` // 用户昵称 + Mobile string `json:"mobile"` // 手机号 + Token string `json:"token"` // token +} + +/* + * 获取孔网用户信息 + * param token[string] 孔网token + * return 孔网用户信息结构体,错误信息 + * Error 查询请求失败 + * Error HTTP错误 + * Error 解析JSON失败 + * Error 获取用户失败 + */ +func outKfzGetUserInfo(token string) (*UserInfo, error) { + // 用户信息URL + url := "https://user.kongfz.com/User/Index/getUserInfo/" + + // 发送请求 + resp, body, errs := gorequest.New(). + Get(url). + Set("Cookie", fmt.Sprintf("PHPSESSID=%s", token)). + Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"). + Set("Accept", "application/json, text/plain, */*"). + Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"). + Timeout(15 * time.Second). + End() + if len(errs) > 0 { + return nil, fmt.Errorf("查询用户信息请求失败: %v", errs) + } + + //检查HTTP状态码 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP错误: %s", resp.Status) + } + + // 响应数据 + var userInfo struct { + Status bool `json:"status"` + Data struct { + UserID int64 `json:"userId"` + Nickname string `json:"nickname"` + Mobile string `json:"mobile"` + } + } + // 解析json + if err := json.Unmarshal([]byte(body), &userInfo); err != nil { + return nil, fmt.Errorf("解析JSON失败: %w", err) + } + + // 创建用户信息对象 + user := &UserInfo{} + if !userInfo.Status { + return nil, fmt.Errorf("获取用户失败!") + } + user.UserID = userInfo.Data.UserID + user.Nickname = userInfo.Data.Nickname + user.Mobile = userInfo.Data.Mobile + return user, nil +} diff --git a/internal/handler/token_handler.go b/internal/handler/token_handler.go new file mode 100644 index 0000000..85b7015 --- /dev/null +++ b/internal/handler/token_handler.go @@ -0,0 +1,194 @@ +package handler + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + + "kfz-goods-pricing/internal/repository" +) + +// TokenHandler Token处理器 +type TokenHandler struct { + tokenRepo *repository.TokenRepository +} + +// NewTokenHandler 创建Token处理器实例 +func NewTokenHandler(tokenRepo *repository.TokenRepository) *TokenHandler { + return &TokenHandler{ + tokenRepo: tokenRepo, + } +} + +// TokenResponse Token响应结构 +type TokenResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// TokenInput Token输入结构 +type TokenInput struct { + Username string `json:"username"` + Token string `json:"token"` +} + +// BatchAddTokens 批量添加Token(JSON数组: [{"username":"","token":""},...]) +func (h *TokenHandler) BatchAddTokens(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + var inputs []TokenInput + if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil { + log.Printf("BatchAddTokens - decode error: %v", err) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 400, Message: "invalid request body"}) + return + } + + log.Printf("BatchAddTokens - received %d tokens", len(inputs)) + + if len(inputs) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 400, Message: "empty array"}) + return + } + + // 逐条插入 + var failed []TokenInput + for _, input := range inputs { + if input.Username == "" || input.Token == "" { + failed = append(failed, input) + continue + } + + _, err := h.tokenRepo.Insert(input.Username, input.Token, true) + if err != nil { + log.Printf("BatchAddTokens - insert error: %v", err) + failed = append(failed, input) + } + } + + w.Header().Set("Content-Type", "application/json") + if len(failed) > 0 { + json.NewEncoder(w).Encode(TokenResponse{ + Code: 207, + Message: "partial success", + Data: map[string]interface{}{"failed": failed}, + }) + return + } + + json.NewEncoder(w).Encode(TokenResponse{Code: 200, Message: "success"}) +} + +// GetAllTokens 查询所有Token +func (h *TokenHandler) GetAllTokens(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + records, err := h.tokenRepo.GetAll() + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 500, Message: err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 200, Message: "success", Data: records}) +} + +// DeleteToken 删除Token +func (h *TokenHandler) DeleteToken(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // 解析 form-data 或 URL 参数 + r.ParseMultipartForm(32 << 20) + r.ParseForm() + + idStr := r.PostForm.Get("id") + if idStr == "" { + // 尝试从URL获取 + idStr = r.URL.Query().Get("id") + } + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + err = h.tokenRepo.Delete(id) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 500, Message: err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 200, Message: "success"}) +} + +// UpdateToken 修改Token +func (h *TokenHandler) UpdateToken(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // 解析 form-data + r.ParseMultipartForm(32 << 20) + r.ParseForm() + + idStr := r.PostForm.Get("id") + username := r.PostForm.Get("username") + token := r.PostForm.Get("token") + isEnableStr := r.PostForm.Get("is_enable") + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + isEnable := true + if isEnableStr == "0" || isEnableStr == "false" { + isEnable = false + } + + err = h.tokenRepo.Update(id, username, token, isEnable) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 500, Message: err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 200, Message: "success"}) +} + +// GetEnabledTokens 获取所有启用的Token +func (h *TokenHandler) GetEnabledTokens(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + records, err := h.tokenRepo.GetEnabledTokens() + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 500, Message: err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{Code: 200, Message: "success", Data: records}) +} diff --git a/internal/model/book.go b/internal/model/book.go new file mode 100644 index 0000000..923b022 --- /dev/null +++ b/internal/model/book.go @@ -0,0 +1,12 @@ +package model + +// BookInfo 图书信息 +type BookInfo struct { + BookName string `json:"bookName"` + ISBN string `json:"isbn"` + Author string `json:"author"` + Publisher string `json:"publisher"` + PubDate string `json:"pubDate"` + Price string `json:"price"` + ShippingFee string `json:"shippingFee"` +} diff --git a/internal/repository/config_repository.go b/internal/repository/config_repository.go new file mode 100644 index 0000000..c3f7526 --- /dev/null +++ b/internal/repository/config_repository.go @@ -0,0 +1,64 @@ +package repository + +import ( + "database/sql" + "fmt" + + "kfz-goods-pricing/internal/database" +) + +// KfzConfig kfz_config 表结构 +type KfzConfig struct { + ID int + NewPrice float64 + PlaceholderDownPrice float64 + MinShippingFee float64 + MinPrice float64 + QueryIndex int +} + +// GetKfzConfig 获取 kfz_config 配置(ID=1) +// 无数据时返回 nil, nil +func GetKfzConfig() (*KfzConfig, error) { + var cfg KfzConfig + err := database.DB.QueryRow( + `SELECT id, new_price, placeholder_down_price, min_shipping_fee, min_price, query_index FROM kfz_config WHERE id=1`, + ).Scan(&cfg.ID, &cfg.NewPrice, &cfg.PlaceholderDownPrice, &cfg.MinShippingFee, &cfg.MinPrice, &cfg.QueryIndex) + + if err == sql.ErrNoRows { + return nil, nil // 无数据 + } + if err != nil { + return nil, fmt.Errorf("查询kfz_config失败: %w", err) + } + return &cfg, nil +} + +// SaveKfzConfig 保存 kfz_config 配置 +// 表中永远只有 ID=1 一条记录:无数据则 INSERT,有数据则 UPDATE +func SaveKfzConfig(cfg *KfzConfig) error { + var count int + err := database.DB.QueryRow("SELECT COUNT(*) FROM kfz_config").Scan(&count) + if err != nil { + return fmt.Errorf("查询kfz_config失败: %w", err) + } + + if count == 0 { + _, err = database.DB.Exec( + `INSERT INTO kfz_config (id, new_price, placeholder_down_price, min_shipping_fee, min_price, query_index) VALUES (1, ?, ?, ?, ?, ?)`, + cfg.NewPrice, cfg.PlaceholderDownPrice, cfg.MinShippingFee, cfg.MinPrice, cfg.QueryIndex, + ) + if err != nil { + return fmt.Errorf("插入kfz_config失败: %w", err) + } + } else { + _, err = database.DB.Exec( + `UPDATE kfz_config SET new_price=?, placeholder_down_price=?, min_shipping_fee=?, min_price=?, query_index=? WHERE id=1`, + cfg.NewPrice, cfg.PlaceholderDownPrice, cfg.MinShippingFee, cfg.MinPrice, cfg.QueryIndex, + ) + if err != nil { + return fmt.Errorf("更新kfz_config失败: %w", err) + } + } + return nil +} diff --git a/internal/repository/goods_repository.go b/internal/repository/goods_repository.go new file mode 100644 index 0000000..2bd0094 --- /dev/null +++ b/internal/repository/goods_repository.go @@ -0,0 +1,170 @@ +package repository + +import ( + "database/sql" + "fmt" + + "kfz-goods-pricing/internal/database" +) + +// GoodsPricing 商品定价记录 +type GoodsPricing struct { + ID int64 + ISBN string + BookName string + Author string + Publishing string + OutID string + Quality string + QueryIndex int + UserID string + Price float64 + ShippingFee float64 + FailCount int + PlaceholderDownPrice float64 + MinShippingFee float64 + MinPrice float64 +} + +// GoodsRepository 商品仓储 +type GoodsRepository struct{} + +// NewGoodsRepository 创建商品仓储实例 +func NewGoodsRepository() *GoodsRepository { + return &GoodsRepository{} +} + +// Insert 插入查询记录,返回自增ID +func (r *GoodsRepository) Insert(isbn, bookName, author, publishing, outID, quality, userID string, queryIndex int, placeholderDownPrice, minShippingFee, minPrice float64) (int64, error) { + query := `INSERT INTO goods_pricing (isbn, book_name, author, publishing, out_id, quality, query_index, user_id, fail_count, placeholder_down_price, min_shipping_fee, min_price) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)` + + result, err := database.DB.Exec(query, isbn, bookName, author, publishing, outID, quality, queryIndex, userID, placeholderDownPrice, minShippingFee, minPrice) + if err != nil { + return 0, fmt.Errorf("插入记录失败: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("获取自增ID失败: %w", err) + } + + return id, nil +} + +// UpdatePrice 更新价格和运费 +func (r *GoodsRepository) UpdatePrice(id int64, price, shippingFee, finalPrice float64) error { + query := `UPDATE goods_pricing SET price = ?, shipping_fee = ?,final_price = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + + _, err := database.DB.Exec(query, price, shippingFee, finalPrice, id) + if err != nil { + return fmt.Errorf("更新价格失败: %w", err) + } + + return nil +} + +// MarkFailed 标记查询失败,fail_count+1,更新updated_at +func (r *GoodsRepository) MarkFailed(id int64) { + query := `UPDATE goods_pricing SET fail_count = fail_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + database.DB.Exec(query, id) +} + +// GetByID 根据ID查询记录 +func (r *GoodsRepository) GetByID(id int64) (*GoodsPricing, error) { + query := `SELECT id, isbn, out_id, quality, query_index, user_id, price, shipping_fee FROM goods_pricing WHERE id = ?` + + row := database.DB.QueryRow(query, id) + + var record GoodsPricing + var price, shippingFee sql.NullFloat64 + + err := row.Scan(&record.ID, &record.ISBN, &record.OutID, &record.Quality, &record.QueryIndex, &record.UserID, &price, &shippingFee) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("记录不存在") + } + return nil, fmt.Errorf("查询记录失败: %w", err) + } + + if price.Valid { + record.Price = price.Float64 + } + if shippingFee.Valid { + record.ShippingFee = shippingFee.Float64 + } + + return &record, nil +} + +// GetByISBN 根据ISBN查询记录列表 +func (r *GoodsRepository) GetByISBN(isbn string) ([]*GoodsPricing, error) { + query := `SELECT id, isbn, out_id, quality, query_index, user_id, price, shipping_fee FROM goods_pricing WHERE isbn = ? ORDER BY created_at DESC` + + rows, err := database.DB.Query(query, isbn) + if err != nil { + return nil, fmt.Errorf("查询记录失败: %w", err) + } + defer rows.Close() + + var records []*GoodsPricing + for rows.Next() { + var record GoodsPricing + var price, shippingFee sql.NullFloat64 + + err := rows.Scan(&record.ID, &record.ISBN, &record.OutID, &record.Quality, &record.QueryIndex, &record.UserID, &price, &shippingFee) + if err != nil { + return nil, fmt.Errorf("扫描记录失败: %w", err) + } + + if price.Valid { + record.Price = price.Float64 + } + if shippingFee.Valid { + record.ShippingFee = shippingFee.Float64 + } + + records = append(records, &record) + } + + return records, nil +} + +// GetAllOrderByUpdatedAt 查询一条price为空的记录,按fail_count升序、updated_at倒序 +func (r *GoodsRepository) GetAllOrderByUpdatedAt() (*GoodsPricing, error) { + query := `SELECT id, isbn, book_name, author, publishing, out_id, quality, query_index, user_id, price, shipping_fee, fail_count, placeholder_down_price, min_shipping_fee, min_price FROM goods_pricing WHERE final_price IS NULL OR final_price = 0 ORDER BY fail_count ASC, updated_at DESC LIMIT 1` + + row := database.DB.QueryRow(query) + + var record GoodsPricing + var price, shippingFee sql.NullFloat64 + var placeholderDownPrice, minShippingFee, minPrice sql.NullFloat64 + + err := row.Scan(&record.ID, &record.ISBN, &record.BookName, &record.Author, &record.Publishing, &record.OutID, &record.Quality, &record.QueryIndex, &record.UserID, &price, &shippingFee, &record.FailCount, &record.PlaceholderDownPrice, &record.MinShippingFee, &record.MinPrice) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("查询记录失败: %w", err) + } + + if price.Valid { + record.Price = price.Float64 + } + if shippingFee.Valid { + record.ShippingFee = shippingFee.Float64 + } + + if placeholderDownPrice.Valid { + record.PlaceholderDownPrice = placeholderDownPrice.Float64 + } + + if minShippingFee.Valid { + record.MinShippingFee = minShippingFee.Float64 + } + + if minPrice.Valid { + record.MinPrice = minPrice.Float64 + } + + return &record, nil +} diff --git a/internal/repository/token_repository.go b/internal/repository/token_repository.go new file mode 100644 index 0000000..078d58b --- /dev/null +++ b/internal/repository/token_repository.go @@ -0,0 +1,182 @@ +package repository + +import ( + "database/sql" + "fmt" + "strings" + + "kfz-goods-pricing/internal/database" +) + +// KfzToken Token记录 +type KfzToken struct { + ID int64 + Username string + Token string + IsEnable bool +} + +// TokenRepository Token仓储 +type TokenRepository struct{} + +// NewTokenRepository 创建Token仓储实例 +func NewTokenRepository() *TokenRepository { + return &TokenRepository{} +} + +// BatchInsert 批量插入Token记录 +func (r *TokenRepository) BatchInsert(tokens []string, username string) (int64, error) { + if len(tokens) == 0 { + return 0, nil + } + + // 过滤空字符串 + var validTokens []string + for _, t := range tokens { + t := strings.TrimSpace(t) + if t != "" { + validTokens = append(validTokens, t) + } + } + if len(validTokens) == 0 { + return 0, nil + } + + // 构建批量插入语句 + valuePlaceholders := make([]string, len(validTokens)) + args := make([]interface{}, 0, len(validTokens)*2) + + for i, t := range validTokens { + valuePlaceholders[i] = "(?, ?, 1)" + args = append(args, username, t) + } + + query := fmt.Sprintf("INSERT INTO kfz_token (username, token, is_enable) VALUES %s", strings.Join(valuePlaceholders, ",")) + + result, err := database.DB.Exec(query, args...) + if err != nil { + return 0, fmt.Errorf("批量插入失败: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("获取自增ID失败: %w", err) + } + + return id, nil +} + +// Insert 插入单条Token记录 +func (r *TokenRepository) Insert(username, token string, isEnable bool) (int64, error) { + query := `INSERT INTO kfz_token (username, token, is_enable) VALUES (?, ?, ?)` + + result, err := database.DB.Exec(query, username, token, isEnable) + if err != nil { + return 0, fmt.Errorf("插入失败: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("获取自增ID失败: %w", err) + } + + return id, nil +} + +// GetAll 查询所有记录 +func (r *TokenRepository) GetAll() ([]*KfzToken, error) { + query := `SELECT id, username, token, is_enable FROM kfz_token ORDER BY id ASC` + + rows, err := database.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("查询失败: %w", err) + } + defer rows.Close() + + var records []*KfzToken + for rows.Next() { + var rec KfzToken + err := rows.Scan(&rec.ID, &rec.Username, &rec.Token, &rec.IsEnable) + if err != nil { + return nil, fmt.Errorf("扫描失败: %w", err) + } + records = append(records, &rec) + } + + return records, nil +} + +// GetByID 根据ID查询单条记录 +func (r *TokenRepository) GetByID(id int64) (*KfzToken, error) { + query := `SELECT id, username, token, is_enable FROM kfz_token WHERE id = ?` + + row := database.DB.QueryRow(query, id) + + var rec KfzToken + err := row.Scan(&rec.ID, &rec.Username, &rec.Token, &rec.IsEnable) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("记录不存在") + } + return nil, fmt.Errorf("查询失败: %w", err) + } + + return &rec, nil +} + +// Update 更新记录 +func (r *TokenRepository) Update(id int64, username, token string, isEnable bool) error { + query := `UPDATE kfz_token SET username = ?, token = ?, is_enable = ? WHERE id = ?` + + result, err := database.DB.Exec(query, username, token, isEnable, id) + if err != nil { + return fmt.Errorf("更新失败: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("记录不存在") + } + + return nil +} + +// Delete 删除记录 +func (r *TokenRepository) Delete(id int64) error { + query := `DELETE FROM kfz_token WHERE id = ?` + + result, err := database.DB.Exec(query, id) + if err != nil { + return fmt.Errorf("删除失败: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("记录不存在") + } + + return nil +} + +// GetEnabledTokens 获取所有启用状态的Token +func (r *TokenRepository) GetEnabledTokens() ([]*KfzToken, error) { + query := `SELECT id, username, token, is_enable FROM kfz_token WHERE is_enable = 1 ORDER BY id ASC` + + rows, err := database.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("查询失败: %w", err) + } + defer rows.Close() + + var records []*KfzToken + for rows.Next() { + var rec KfzToken + err := rows.Scan(&rec.ID, &rec.Username, &rec.Token, &rec.IsEnable) + if err != nil { + return nil, fmt.Errorf("扫描失败: %w", err) + } + records = append(records, &rec) + } + + return records, nil +} \ No newline at end of file diff --git a/internal/service/goods_service.go b/internal/service/goods_service.go new file mode 100644 index 0000000..6ab801a --- /dev/null +++ b/internal/service/goods_service.go @@ -0,0 +1,357 @@ +package service + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "kfz-goods-pricing/internal/model" + "kfz-goods-pricing/internal/repository" + + "github.com/parnurzeal/gorequest" +) + +// GoodsService 商品服务 +type GoodsService struct { + proxy string + apiRateLimitSeconds int + rateLimitMu sync.Mutex + lastAPICall time.Time + goodsRepository *repository.GoodsRepository + callbackURL string + tokenRepository *repository.TokenRepository + currentTokenIndex int + tokenMu sync.Mutex +} + +// NewGoodsService 创建商品服务实例 +func NewGoodsService(proxy string, apiRateLimitSeconds int, callbackURL string, tokenRepo *repository.TokenRepository) *GoodsService { + return &GoodsService{ + proxy: proxy, + apiRateLimitSeconds: apiRateLimitSeconds, + goodsRepository: repository.NewGoodsRepository(), + callbackURL: callbackURL, + tokenRepository: tokenRepo, + currentTokenIndex: 0, + } +} + +// QueryRequest 查询请求参数 {"isbn":"9787802204461","out_id":"132456","quality":"100"} +type QueryRequest struct { + ISBN string `json:"isbn"` // isbn + BookName string `json:"book_name"` // 书名 + Author string `json:"author"` // 作者 + Publishing string `json:"publishing"` // 出版社 + OutID string `json:"out_id"` // 输出ID + Quality string `json:"quality"` // 品相 + QueryIndex int `json:"query_index"` // 排第几位 + UserID string `json:"user_id"` // 用户ID + PlaceholderDownPrice float64 `json:"placeholder_down_price"` // 占位降价 + MinShippingFee float64 `json:"min_shipping_fee"` // 最低运费 + MinPrice float64 `json:"min_price"` // 最低书价 +} + +// QueryResponse 查询响应 +type QueryResponse struct { + Code int `json:"code"` + Message string `json:"message"` + ID int64 `json:"id,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// QueryGoods 查询商品信息 +func (s *GoodsService) QueryGoods(req *QueryRequest) *QueryResponse { + if req.ISBN == "0" { + return &QueryResponse{ + Code: 200, + Message: "success", + } + } + + // 插入入参记录到数据库 + id, err := s.goodsRepository.Insert(req.ISBN, req.BookName, req.Author, req.Publishing, req.OutID, req.Quality, req.UserID, req.QueryIndex, req.PlaceholderDownPrice, req.MinShippingFee, req.MinPrice) + if err != nil { + return &QueryResponse{ + Code: 500, + Message: fmt.Sprintf("保存记录失败: %v", err), + } + } + + return &QueryResponse{ + Code: 200, + Message: "success", + ID: id, + } +} + +// rateLimitWait 限流等待 +func (s *GoodsService) rateLimitWait() { + if s.apiRateLimitSeconds <= 0 { + return + } + + s.rateLimitMu.Lock() + defer s.rateLimitMu.Unlock() + + elapsed := time.Since(s.lastAPICall) + if elapsed < time.Duration(s.apiRateLimitSeconds)*time.Second { + time.Sleep(time.Duration(s.apiRateLimitSeconds)*time.Second - elapsed) + } + s.lastAPICall = time.Now() +} + +// sendCallback 发送回调请求 +func (s *GoodsService) sendCallback(outID, userID string, price, shippingFee float64) { + if s.callbackURL == "" { + return + } + + // price*100 转int, shipping_fee*100 转int + salePrice := int(price * 100) + cost := int(shippingFee * 100) + + request := gorequest.New() + if s.proxy != "" { + request.Proxy(s.proxy) + } + _, _, errs := request.Post(s.callbackURL). + Set("Content-Type", "application/x-www-form-urlencoded"). + Send(fmt.Sprintf("product_id=%s&user_id=%s&sale_price=%d&cost=%d", outID, userID, salePrice, cost)). + Timeout(30 * time.Second). + End() + + if len(errs) > 0 { + log.Printf("回调失败: %v", errs) + } else { + log.Printf("回调成功: product_id=%s, user_id=%s, sale_price=%d, cost=%d", outID, userID, salePrice, cost) + } +} + +// StartTimerScheduler 启动定时器 +func (s *GoodsService) StartTimerScheduler(intervalSeconds int) { + ticker := time.NewTicker(time.Duration(intervalSeconds) * time.Second) + go func() { + for range ticker.C { + s.syncGoodsPricing() + } + }() +} + +// syncGoodsPricing 定时同步商品价格 +func (s *GoodsService) syncGoodsPricing() { + //cfg := config.GetGlobal() + // 查询数据库数据 + kfzConfig, err := repository.GetKfzConfig() + if err != nil { + log.Printf("获取config数据库数据失败: %v", err) + return + } + + // 查询一条记录,按fail_count升序、updated_at倒序 + record, err := s.goodsRepository.GetAllOrderByUpdatedAt() + if err != nil { + log.Printf("定时任务查询失败: %v", err) + return + } + if record == nil { + return + } + + // 限流等待 + s.rateLimitWait() + + var price float64 + var shippingFee float64 + // 最终书价 + var finalPrice float64 + + // 查询孔网数据 + bookInfo, err := s.outGetAllGoods(record.ISBN, record.BookName, record.Author, record.Publishing, record.Quality, record.QueryIndex) + if err != nil { + if kfzConfig.NewPrice == 0 { + s.goodsRepository.MarkFailed(record.ID) + log.Printf("定时任务[%d]查询孔网失败(fail_count=%d): %v", record.ID, record.FailCount+1, err) + return + } + finalPrice = kfzConfig.NewPrice + } else { + // 查询成功,更新价格 + price, _ = strconv.ParseFloat(bookInfo.Price, 64) + shippingFee, _ = strconv.ParseFloat(bookInfo.ShippingFee, 64) + totalPrice := price + shippingFee + finalPrice = totalPrice - record.PlaceholderDownPrice - record.MinShippingFee + if finalPrice < record.MinPrice { + finalPrice = record.MinPrice + } + // 保留两位小数 + finalPrice, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", finalPrice), 64) + } + + if err := s.goodsRepository.UpdatePrice(record.ID, price, shippingFee, finalPrice); err != nil { + log.Printf("定时任务[%d]更新价格失败: %v", record.ID, err) + return + } + + log.Printf("定时任务[%d]更新成功: price=%.2f, shipping_fee=%.2f", record.ID, price, shippingFee) + + // 调用回调 + s.sendCallback(record.OutID, record.UserID, finalPrice, record.MinShippingFee) +} + +// outGetAllGoods 爬取孔网所有商品页面 +func (s *GoodsService) outGetAllGoods(isbn string, bookName string, author string, publishing string, quality string, queryIndex int) (*model.BookInfo, error) { + var actionPathList []string + + // 从数据库获取启用的token列表 + tokens, err := s.tokenRepository.GetEnabledTokens() + if err != nil || len(tokens) == 0 { + return nil, fmt.Errorf("没有可用的token") + } + + // 轮询选择token + s.tokenMu.Lock() + s.currentTokenIndex = s.currentTokenIndex % len(tokens) + currentIdx := s.currentTokenIndex + s.currentTokenIndex++ + s.tokenMu.Unlock() + + token := tokens[currentIdx].Token + + //kfzUrl := "https://search.kongfz.com/pc-gw/search-web/client/pc/product/keyword/list?dataType=0&page=1&sortType=7&quaSelect=2" + kfzUrl := "https://search.kongfz.com/pc-gw/search-web/client/pc/product/keyword/list?dataType=0&page=1&sortType=7&userArea=13003000000&quaSelect=2" + + // 整理查询参数-加上排序 + actionPathList = append(actionPathList, "sortType") + + // isbn + if isbn != "" { + kfzUrl = kfzUrl + "&keyword=" + isbn + } + + // 书名 + if bookName != "" { + kfzUrl = kfzUrl + "&keyword=" + bookName + } + + // 作者 + if author != "" { + kfzUrl = kfzUrl + "&author=" + author + } + + // 出版社 + if publishing != "" { + kfzUrl = kfzUrl + "&press=" + publishing + } + + // 品相 + if quality != "" { + kfzUrl = kfzUrl + "&quality=" + quality + "~" + actionPathList = append(actionPathList, "quality") + } + + // 参数进行分割 + actionPath := strings.Join(actionPathList, ",") + // 加入查询参数 + kfzUrl = kfzUrl + "&actionPath=" + actionPath + + // 创建HTTP客户端 + requestSpt := gorequest.New() + + // 设置代理(如果有提供代理URL) + if s.proxy != "" { + requestSpt.Proxy(s.proxy) + } + fmt.Println("请求地址:", kfzUrl) + // 发送请求 + respSpt, bodySpt, errsSpt := requestSpt.Get(kfzUrl). + Set("Cookie", fmt.Sprintf("PHPSESSID=%s", token)). + Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"). + Set("Accept", "application/json, text/plain, */*"). + Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"). + Set("Referer", "https://item.kongfz.com/"). + Timeout(30 * time.Second). + End() + + // 错误处理 + if len(errsSpt) > 0 { + return nil, fmt.Errorf("请求失败: %v", errsSpt) + } + + // 检查HTTP状态码 + if respSpt.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP错误: %s", respSpt.Status) + } + + fmt.Println("响应数据:", bodySpt) + + // 解析响应 + var apiSptResp struct { + Status int `json:"status"` + ErrType string `json:"errType"` + Message string `json:"message"` + SystemTime int64 `json:"systemTime"` + Data struct { + ItemResponse struct { + Total int `json:"total"` + List []struct { + Title string `json:"title"` + Author string `json:"author"` + Press string `json:"press"` + PubDateText string `json:"pubDateText"` + Isbn string `json:"isbn"` + Price float64 `json:"price"` + Postage struct { + ShippingList []struct { + ShippingFee float64 `json:"shippingFee"` + } `json:"shippingList"` + } `json:"postage"` + } `json:"list"` + } `json:"itemResponse"` + } `json:"data"` + } + + // 解析JSON + if err := json.Unmarshal([]byte(bodySpt), &apiSptResp); err != nil { + return nil, fmt.Errorf("解析JSON失败: %w", err) + } + + if apiSptResp.Status != 1 { + return nil, fmt.Errorf("错误信息: %v,状态码: %s", apiSptResp.Message, apiSptResp.ErrType) + } + + bookInfo := &model.BookInfo{} + + if apiSptResp.Data.ItemResponse.Total > 0 && len(apiSptResp.Data.ItemResponse.List) > 0 { + if queryIndex > 0 && queryIndex <= len(apiSptResp.Data.ItemResponse.List) { + goodsInfo := apiSptResp.Data.ItemResponse.List[queryIndex-1] + bookInfo.BookName = goodsInfo.Title + bookInfo.ISBN = goodsInfo.Isbn + bookInfo.Author = goodsInfo.Author + bookInfo.Publisher = goodsInfo.Press + bookInfo.PubDate = goodsInfo.PubDateText + bookInfo.Price = fmt.Sprintf("%.2f", goodsInfo.Price) + for _, shipping := range goodsInfo.Postage.ShippingList { + bookInfo.ShippingFee = fmt.Sprintf("%.2f", shipping.ShippingFee) + } + } else { + goodsInfo := apiSptResp.Data.ItemResponse.List[len(apiSptResp.Data.ItemResponse.List)-1] + bookInfo.BookName = goodsInfo.Title + bookInfo.ISBN = goodsInfo.Isbn + bookInfo.Author = goodsInfo.Author + bookInfo.Publisher = goodsInfo.Press + bookInfo.PubDate = goodsInfo.PubDateText + bookInfo.Price = fmt.Sprintf("%.2f", goodsInfo.Price) + for _, shipping := range goodsInfo.Postage.ShippingList { + bookInfo.ShippingFee = fmt.Sprintf("%.2f", shipping.ShippingFee) + } + } + return bookInfo, nil + } + + return nil, fmt.Errorf("查询失败,没有数据!") +} diff --git a/kfz-goods-pricing.exe b/kfz-goods-pricing.exe new file mode 100644 index 0000000..4c14cd0 Binary files /dev/null and b/kfz-goods-pricing.exe differ diff --git a/交接文档.md b/交接文档.md new file mode 100644 index 0000000..770a93c --- /dev/null +++ b/交接文档.md @@ -0,0 +1,374 @@ +# 商品定价服务(kfz-goods-pricing)项目交接文档 + +> 生成时间:2026-05-27 +> 项目路径:D:\work\newProject\kfz-goods-pricing + +--- + +## 一、项目概述 + +**项目名称**:商品定价服务(kfz-goods-pricing) +**技术栈**:Go 1.25 + SQLite + HTTP API + 原生 GUI(无 Vue/Wails,纯 Go) +**输出可执行文件**:kfz-goods-pricing.exe +**核心功能**:接收商品 ISBN 查询请求,通过孔网搜索 API 获取市场定价,计算最优价格并回调通知下游系统 + +> ⚠️ **重要区别**:本项目与之前生成的 kfz-move-kfz / xy-verify-price / kfz-verify-price 项目完全不同——不是 Wails 桌面应用,而是纯 Go HTTP 服务 + 原生 GUI 窗口,没有前端框架,没有子进程 B 程序。 + +### 目录结构 + +` +kfz-goods-pricing/ +├── cmd/ +│ ├── server/ +│ │ ├── main.go # 程序入口(HTTP服务 + GUI窗口 + 定时器) +│ │ └── gui_windows.go # GUI 窗口实现 +├── internal/ +│ ├── config/ # 配置加载(YAML + 全局单例) +│ │ └── config.go +│ ├── database/ # SQLite 数据库初始化 +│ │ └── db.go +│ ├── handler/ # HTTP 处理器(4个文件) +│ │ ├── goods_handler.go # /api/goods/query — 商品查询 +│ │ ├── token_handler.go # /api/token/* — Token 管理(增删改查/启用) +│ │ ├── kfz_handler.go # /api/kfz/login — 孔网登录 +│ │ └── config_handler.go # /api/config/price/* — 价格配置 +│ ├── model/ +│ │ └── book.go # BookInfo 图书信息结构体 +│ ├── repository/ # 数据访问层 +│ │ ├── token_repository.go # Token CRUD + 启用状态 +│ │ ├── goods_repository.go # 商品记录 CRUD + 失败计数 +│ │ └── config_repository.go # kfz_config 表(永远只有 ID=1 一条) +│ └── service/ +│ └── goods_service.go # 核心业务逻辑(定时器/限速/爬虫/回调) +├── pkg/ # 公共库目录(当前为空) +├── config/ +│ └── config.yaml # 程序配置文件 +├── data/ +│ └── goods_pricing.db # SQLite 数据库文件 +├── go.mod / go.sum +├── kfz-goods-pricing.exe # 编译后的可执行文件 +└── README.md +` + +--- + +## 二、技术栈 + +| 组件 | 依赖 | 版本 | 说明 | +|------|------|------|------| +| HTTP 服务 | Go 标准库 | 内置 | +et/http,无框架,裸serveMux路由 | +| 数据库 | modernc.org/sqlite | v1.50.0 | 纯 Go SQLite,无 CGO | +| HTTP 客户端 | github.com/parnurzeal/gorequest | v0.3.0 | 孔网 API 调用 | +| 配置解析 | gopkg.in/yaml.v3 | v3.0.1 | config.yaml 读取 | +| 日志 | log | 内置 | 输出到 GUI 窗口(guiLogWriter) | +| GUI | 原生实现 | — | gui_windows.go 中的 GUI 窗口(GTK/原生混合) | + +--- + +## 三、架构设计 + +` +┌─────────────────────────────────────────────────────────────────┐ +│ kfz-goods-pricing.exe │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌─────────────────┐ │ +│ │ GUI 窗口 │ │ HTTP 服务 │ │ 定时器 (Timer) │ │ +│ │(gui_windows)│ │(net/http) │ │ 每 N 秒执行一次 │ │ +│ └────────────┘ └──────┬───────┘ └────────┬────────┘ │ +│ │ │ │ +│ ┌──────────────────┬┴─────────────────────┘ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ TokenHandler │ │GoodsHandler │ │ GoodsService │ │ +│ │ /api/token/* │ │/api/goods/...│ │ (核心业务逻辑) │ │ +│ └──────┬───────┘ └──────┬───────┘ └─────────┬──────────────┘ │ +│ │ │ │ │ +│ ┌──────▼────────────────▼────────────────────▼──────────────┐ │ +│ │ Repository 层 │ │ +│ │ TokenRepo │ GoodsRepo │ ConfigRepo │ │ +│ └────────────────────────┬───────────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ +│ │ SQLite (goods_pricing.db) │ +│ │ kfz_token (Token管理) │ kfz_goods (商品记录) │ kfz_config │ +│ └─────────────────────────────────────────────────────────────────┘ +│ │ +│ 外部调用: │ +│ ① 上游系统 → POST /api/goods/query → 写入 kfz_goods │ +│ ② 定时器 → 查询 kfz_goods(按失败次数/更新时间排序) │ +│ → 爬孔网搜索API → 计算价格 → POST callbackURL │ +│ ③ 回调 URL: http://192.168.101.213:9090/api/product/updatePrice│ +└─────────────────────────────────────────────────────────────────┘ +` + +--- + +## 四、HTTP API 接口 + +### 4.1 商品查询(上游系统调用入口) + +**POST** /api/goods/query + +**请求参数**(form-data 或 x-www-form-urlencoded): + +| 参数 | 类型 | 必填 | 说明 | +|--------------------|------|--|-------------------| +| isbn | string | | 商品 ISBN | +| book_name | string | | 商品 名称 | +| author | string | | 商品 作者 | +| publishing | string | | 商品 出版社 | +| out_id | string | | 输出 ID(回调时透传) | +| quality | string | | 品相(100=全新,90=九品等) | +| query_index | int | | 想排第几位(默认取最后一条) | +| user_id | string | | 用户 ID(回调时透传) | +| placeholder_down_price | float | | 占位降价 | +| min_shipping_fee | float | | 最低运费 | +| min_price | float | | 最低书价 | + +**响应**: +`json +{"code":200,"message":"success","id":123} +` + +> 说明:只写入数据库,返回插入记录的 ID。实际核价由定时器异步完成。 + +### 4.2 Token 管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/token/add | 批量添加 Token(JSON数组) | +| GET | /api/token/list | 查询所有 Token | +| POST | /api/token/delete | 删除 Token(id 参数) | +| POST | /api/token/update | 修改 Token(id/username/token/is_enable) | +| GET | /api/token/enabled | 获取所有启用的 Token | + +**批量添加请求体**: +`json +[{"username":"孔网账号1","token":"PHPSESSID_xxx"},{"username":"孔网账号2","token":"PHPSESSID_yyy"}] +` + +### 4.3 孔网登录 + +**POST** /api/kfz/login + +| 参数 | 说明 | +|------|------| +| username | 孔网用户名 | +| password | 孔网密码 | + +**响应**: +`json +{"code":200,"message":"success","data":{"userId":123456,"nickname":"xxx","mobile":"138xxxx","token":"PHPSESSID_xxx"}} +` + +### 4.4 价格配置 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/config/price/get | 获取完整配置(yaml + DB 覆盖层合并) | +| POST | /api/config/price/set | 修改价格配置(只更新入参字段) | + +**配置合并逻辑**(GetConfigPrice): +` +yaml配置(Port / TimerInterval / APIRateLimit / CallbackURL) + + DB配置(NewPrice / PlaceholderDownPrice / MinShippingFee / MinPrice / QueryIndex) += 最终配置(DB 优先覆盖 yaml 的价格字段) +` + +--- + +## 五、定时器核心逻辑(GoodsService.syncGoodsPricing) + +` +每 N 秒(默认 5 秒)触发一次: + +1. 从 kfz_config 表读取价格参数 +2. 从 kfz_goods 表取一条记录(按 fail_count ASC, updated_at DESC) + └→ 优先处理失败次数少的记录 +3. 调用 outGetAllGoods(isbn, quality, queryIndex) 爬孔网搜索 API + ├→ 使用轮询方式从 kfz_token 表选一个启用 Token + └→ URL: https://search.kongfz.com/pc-gw/search-web/client/pc/product/keyword/list +4. 计算最终价格: + finalPrice = totalPrice - placeholderDownPrice - minShippingFee + if (finalPrice < minPrice) → finalPrice = minPrice +5. 更新 kfz_goods 表(price / shipping_fee / final_price / updated_at) +6. 调用 sendCallback(outID, userID, finalPrice, minShippingFee) + └→ POST + product_id=xxx&user_id=xxx&sale_price=xxx&cost=xxx + (sale_price 和 cost 均为整数,单位:分 = 原价 × 100) +7. 失败处理:MarkFailed → fail_count++,下次优先重试 +` + +--- + +## 六、数据库设计 + +### 6.1 kfz_token 表 + +`sql +CREATE TABLE kfz_token ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + token TEXT NOT NULL, + is_enable INTEGER DEFAULT 1 +); +` + +**用途**:存储多个孔网账号 Token,支持轮询切换,防止单个 Token 请求过快被限 + +### 6.2 kfz_goods 表 + +`sql +CREATE TABLE kfz_goods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isbn TEXT NOT NULL, + out_id TEXT, + quality TEXT, + user_id TEXT, + query_index INTEGER DEFAULT 1, + placeholder_down_price REAL DEFAULT 0.01, + min_shipping_fee REAL DEFAULT 5.0, + min_price REAL DEFAULT 1.0, + price REAL DEFAULT 0, + shipping_fee REAL DEFAULT 0, + final_price REAL DEFAULT 0, + fail_count INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +**用途**:上游调用 QueryGoods 时写入记录,定时器按 fail_count ASC 排序依次处理 + +### 6.3 kfz_config 表 + +`sql +CREATE TABLE kfz_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + new_price REAL DEFAULT 0, + placeholder_down_price REAL DEFAULT 0.01, + min_shipping_fee REAL DEFAULT 5.0, + min_price REAL DEFAULT 1.0, + query_index INTEGER DEFAULT 1 +); +` + +**用途**:始终只有一条记录(ID=1),与 config.yaml 共同构成配置体系。yaml 负责基础配置(Port/Timer/APIRateLimit/CallbackURL),DB 负责价格参数 + +--- + +## 七、关键设计 + +### Token 轮询机制 + +` +tokens = GetEnabledTokens() // 从 DB 取出所有启用 Token +currentTokenIndex = 0 // 原子操作的轮询索引 + +每次爬孔网 API 时: + token = tokens[currentTokenIndex % len(tokens)] + currentTokenIndex++ +` + +> 目的:多个孔网账号 Token 轮询使用,防止单个账号请求过快被限 + +### 价格计算公式 + +` +totalPrice = price + shippingFee +finalPrice = totalPrice - placeholderDownPrice - minShippingFee +if finalPrice < minPrice → finalPrice = minPrice +` + +最终回调中的 sale_price 和 cost 均为整数(单位:分 = float × 100) + +### 配置双层机制 + +| 来源 | 字段 | 说明 | +|------|------|------| +| config/config.yaml | Port, TimerInterval, APIRateLimit, CallbackURL | 基础运行时配置 | +| kfz_config DB | NewPrice, PlaceholderDownPrice, MinShippingFee, MinPrice, QueryIndex | 价格参数 | + +yaml 不可动态修改,DB 配置可通过 API 动态修改(实时生效)。 + +--- + +## 八、第三方接口 + +### 孔网登录 + +| 步骤 | URL | 方法 | +|------|-----|------| +| 登录 | https://login.kongfz.com/Pc/Login/account | POST (form-data) | +| 获取用户信息 | https://user.kongfz.com/User/Index/getUserInfo/ | GET (Cookie: PHPSESSID=xxx) | + +### 孔网商品搜索 + +| 用途 | URL | +|------|-----| +| 搜索商品 | https://search.kongfz.com/pc-gw/search-web/client/pc/product/keyword/list?dataType=0&page=1&sortType=7&quaSelect=2&keyword=ISBN&quality=品相~ | + +### 回调 + +| 回调 URL | 参数 | +|---------|------| +| http://192.168.101.213:9090/api/product/updatePrice | product_id, user_id, sale_price(分), cost(分) | + +--- + +## 九、运行与构建 + +`ash +# 运行(开发) +go run cmd/server/main.go + +# 编译 +go build -o kfz-goods-pricing.exe ./cmd/server +` + +**启动后**: +1. 加载 config/config.yaml +2. 初始化 data/goods_pricing.db(SQLite,自动建表) +3. 启动 HTTP 服务(默认端口 8080) +4. 启动 GUI 窗口(阻塞主线程) +5. 启动定时器(5秒间隔,开始首次同步) + +**默认配置**(config/config.yaml): +`yaml +port: "8080" +timer_interval: 5 # 每5秒执行一次定时任务 +api_rate_limit: 2 # API 请求间隔2秒 +callback_url: "http://192.168.101.213:9090/api/product/updatePrice" +` + +--- + +## 十、交接注意事项 + +| 文件/目录 | 说明 | 注意点 | +|-----------|------|--------| +| config/config.yaml | 基础配置 | Port/TimerInterval/APIRateLimit/CallbackURL | +| data/goods_pricing.db | SQLite 数据库 | 运行时自动创建,无需手动初始化 | +| internal/handler/kfz_handler.go | 孔网登录逻辑 | HTTP 直接调用孔网官网,无需 kfz.dll(与之前的 Wails 项目不同) | +| internal/service/goods_service.go | 定时器核心 | 异步处理,依赖 Token 池 | +| kfz-goods-pricing.exe | 可执行文件 | 需与 config/、data/ 目录同目录运行 | + +### 快速排查 + +- **上游调用无响应**:检查 HTTP 服务是否正常(默认端口 8080),检查防火墙 +- **定时器无动作**:检查 kfz_goods 是否有数据;检查 Token 是否为空或已过期 +- **核价结果全为默认值**:孔网搜索 API 无返回,kfzConfig.NewPrice 作为兜底 +- **回调失败**:检查 callbackURL 是否可达(192.168.101.213:9090) +- **Token 失效**:上游系统调用 /api/kfz/login 获取新 Token,或手动 /api/token/add 添加 + +### 与 Wails 桌面项目的核心区别 + +| 对比项 | 本项目(kfz-goods-pricing) | Wails 项目们 | +|--------|----------------------------|-------------| +| 类型 | **纯 Go HTTP 服务 + GUI 窗口** | Wails 桌面应用 | +| 前端 | **无**(无 Vue/HTML) | Vue 3 + Element Plus | +| 子进程 | **无** | 有(verify-price-b) | +| Token 来源 | HTTP 直接登录孔网 | kfz.dll(Windows DLL) | +| 数据库 | SQLite(本地文件) | SQLite(同) | +| 部署方式 | 后台 HTTP 服务(可远程调用) | 桌面 exe(本地运行) | +