first commit
This commit is contained in:
commit
e26d7a027e
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
11
.idea/go.imports.xml
generated
Normal 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
9
.idea/kfz-goods-pricing.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
25
README.md
Normal 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
277
cmd/server/gui_windows.go
Normal 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
106
cmd/server/main.go
Normal 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
4
config/config.yaml
Normal 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
BIN
data/goods_pricing.db
Normal file
Binary file not shown.
BIN
data/goods_pricing1.db
Normal file
BIN
data/goods_pricing1.db
Normal file
Binary file not shown.
26
go.mod
Normal file
26
go.mod
Normal 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
74
go.sum
Normal 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
66
internal/config/config.go
Normal 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
97
internal/database/db.go
Normal 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
|
||||
}
|
||||
121
internal/handler/config_handler.go
Normal file
121
internal/handler/config_handler.go
Normal 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 获取配置
|
||||
// 先读 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",
|
||||
})
|
||||
}
|
||||
79
internal/handler/goods_handler.go
Normal file
79
internal/handler/goods_handler.go
Normal 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)
|
||||
}
|
||||
204
internal/handler/kfz_handler.go
Normal file
204
internal/handler/kfz_handler.go
Normal 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
|
||||
}
|
||||
194
internal/handler/token_handler.go
Normal file
194
internal/handler/token_handler.go
Normal 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 批量添加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})
|
||||
}
|
||||
12
internal/model/book.go
Normal file
12
internal/model/book.go
Normal 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"`
|
||||
}
|
||||
64
internal/repository/config_repository.go
Normal file
64
internal/repository/config_repository.go
Normal 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
|
||||
}
|
||||
170
internal/repository/goods_repository.go
Normal file
170
internal/repository/goods_repository.go
Normal 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
|
||||
}
|
||||
182
internal/repository/token_repository.go
Normal file
182
internal/repository/token_repository.go
Normal 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
|
||||
}
|
||||
357
internal/service/goods_service.go
Normal file
357
internal/service/goods_service.go
Normal 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
BIN
kfz-goods-pricing.exe
Normal file
Binary file not shown.
374
交接文档.md
Normal file
374
交接文档.md
Normal 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 | 批量添加 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(本地运行) |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user