first commit

This commit is contained in:
97694732@qq.com 2026-06-11 16:06:08 +08:00
commit e26d7a027e
25 changed files with 2468 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

11
.idea/go.imports.xml generated Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

9
.idea/kfz-goods-pricing.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kfz-goods-pricing.iml" filepath="$PROJECT_DIR$/.idea/kfz-goods-pricing.iml" />
</modules>
</component>
</project>

25
README.md Normal file
View File

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

277
cmd/server/gui_windows.go Normal file
View File

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

106
cmd/server/main.go Normal file
View File

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

4
config/config.yaml Normal file
View File

@ -0,0 +1,4 @@
port: "8080"
timer_interval: 5
api_rate_limit: 2
callback_url: http://192.168.101.213:9090/api/product/updatePrice

BIN
data/goods_pricing.db Normal file

Binary file not shown.

BIN
data/goods_pricing1.db Normal file

Binary file not shown.

26
go.mod Normal file
View File

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

74
go.sum Normal file
View File

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

66
internal/config/config.go Normal file
View File

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

97
internal/database/db.go Normal file
View File

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

View File

@ -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 获取配置
// 先读 yamlPort/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",
})
}

View File

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

View File

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

View File

@ -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 批量添加TokenJSON数组: [{"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})
}

12
internal/model/book.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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("查询失败,没有数据!")
}

BIN
kfz-goods-pricing.exe Normal file

Binary file not shown.

374
交接文档.md Normal file
View File

@ -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 | 批量添加 TokenJSON数组 |
| GET | /api/token/list | 查询所有 Token |
| POST | /api/token/delete | 删除 Tokenid 参数) |
| POST | /api/token/update | 修改 Tokenid/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/CallbackURLDB 负责价格参数
---
## 七、关键设计
### 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.dbSQLite自动建表
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.dllWindows DLL |
| 数据库 | SQLite本地文件 | SQLite |
| 部署方式 | 后台 HTTP 服务(可远程调用) | 桌面 exe本地运行 |