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