Compare commits

...

3 Commits

Author SHA1 Message Date
ceyhandagdas51272 cbb0968256 fix: 端口占用时自动选择可用端口,不再闪退
CI / Go (api) (pull_request) Failing after 1s
CI / Python (ingestion) (pull_request) Failing after 1s
CI / Migrations (postgres) (pull_request) Failing after 1s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 04:17:55 +00:00
ceyhandagdas51272 5a641705dd fix: Windows 闪退 — 增加日志文件输出、错误时保持窗口
CI / Go (api) (pull_request) Failing after 2s
CI / Python (ingestion) (pull_request) Failing after 0s
CI / Migrations (postgres) (pull_request) Failing after 2s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 03:23:07 +00:00
ceyhandagdas51272 21c895a008 bypos-collector: 移除硬编码 sdogid、添加重试、补全 CSV 导出、增加测试
CI / Go (api) (pull_request) Failing after 1s
CI / Python (ingestion) (pull_request) Failing after 0s
CI / Migrations (postgres) (pull_request) Failing after 2s
改进内容:
- sdogid 不再硬编码,优先从 $BYPOS_SDOGID 环境变量读取,其次 -sdogid 参数,
  也可在 Web 控制台输入框填写;三者都未设时启动警告、采集报错
- 网络错误自动重试(最多 3 次,指数退避 500ms/1s/2s)
- Web 控制台采集参数区增加 sdogid 输入框
- CSV 导出补全 retmsg 和 source 两列
- 新增 Go 单元测试(ean13Check、md5hex、sanitizeBarcodes、resolveSdogID 等)
- README 更新配置说明

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 03:10:09 +00:00
5 changed files with 217 additions and 23 deletions
+4 -3
View File
@@ -58,9 +58,10 @@ GET http://zc.bypos.net/byGoodsService/byMessage.asmx/GetGoodsinfo
## 配置
- `sdogid`:中心库账号 id(本项目所属云店账号的授权 id)。默认值见
`collect.go``defaultSdogID`,也可用 `-sdogid` 参数或控制台覆盖。
**这是账号级凭证**——若本仓库对外公开,建议改为从环境变量/外部配置读取。
- `sdogid`中心库账号 id本项目所属云店账号的授权 id)。
优先从环境变量 `BYPOS_SDOGID` 读取,其次是命令行 `-sdogid` 参数
也可以在 Web 控制台的输入框中填写。**三者都未设时启动会警告、开始采集时会报错。**
- 网络错误自动重试(最多 3 次,指数退避 500ms/1s/2s)。
## 导入 goods/天工库
+42 -8
View File
@@ -20,12 +20,21 @@ import (
)
// ---- upstream config ----
// sdogid is the 云店 account license id observed in the live request. It is
// configurable so the tool is not tied to a single account.
const defaultSdogID = "137966"
const endpoint = "http://zc.bypos.net/byGoodsService/byMessage.asmx/GetGoodsinfo"
// maxRetries is the number of retry attempts for transient network errors.
const maxRetries = 3
// resolveSdogID reads the account id from $BYPOS_SDOGID, falling back to the
// explicit argument (CLI flag or Web UI input). Returns empty if neither set.
func resolveSdogID(explicit string) string {
if v := os.Getenv("BYPOS_SDOGID"); v != "" {
return v
}
return explicit
}
var stringTagRe = regexp.MustCompile(`(?s)<string[^>]*>(.*)</string>`)
// Product is the normalized record we persist (one JSON object per line).
@@ -87,8 +96,8 @@ func ean13Check(body string) (string, bool) {
return body + strconv.Itoa(chk), true
}
// lookup queries the upstream central library for one barcode.
func (c *Collector) lookup(ctx context.Context, barcode string) (*Product, error) {
// lookupOnce performs a single HTTP request to the upstream central library.
func (c *Collector) lookupOnce(ctx context.Context, barcode string) (*Product, error) {
tsMs := strconv.FormatInt(time.Now().Unix()*1000, 10) // always ends in 000
sparm1 := md5hex(c.sdogID)
sparm2 := md5hex(barcode + tsMs)
@@ -147,6 +156,30 @@ func (c *Collector) lookup(ctx context.Context, barcode string) (*Product, error
return p, nil
}
// lookup queries the upstream with up to maxRetries retries on transient errors.
func (c *Collector) lookup(ctx context.Context, barcode string) (*Product, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
p, err := c.lookupOnce(ctx, barcode)
if err == nil {
return p, nil
}
lastErr = err
if attempt < maxRetries {
backoff := time.Duration(1<<uint(attempt)) * 500 * time.Millisecond
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return nil, lastErr
}
// ---- job / collector state ----
type Stats struct {
@@ -187,9 +220,7 @@ type Collector struct {
}
func NewCollector(sdogID string) *Collector {
if sdogID == "" {
sdogID = defaultSdogID
}
sdogID = resolveSdogID(sdogID)
c := &Collector{
client: &http.Client{Timeout: 25 * time.Second},
sdogID: sdogID,
@@ -308,6 +339,9 @@ func (c *Collector) Start(req JobReq) error {
if req.SdogID != "" {
c.sdogID = req.SdogID
}
if c.sdogID == "" {
return fmt.Errorf("未指定 sdogid,请通过控制台输入框、环境变量 $BYPOS_SDOGID 或 -sdogid 参数配置")
}
// Build the list of barcodes to query.
var barcodes []string
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"os"
"testing"
)
func TestEan13Check_valid(t *testing.T) {
tests := []struct {
body string
want string
}{
{"692045990501", "6920459905012"},
{"690100000000", "6901000000004"},
{"690100000099", "6901000000998"},
}
for _, tt := range tests {
got, ok := ean13Check(tt.body)
if !ok {
t.Errorf("ean13Check(%q) returned not ok", tt.body)
continue
}
if got != tt.want {
t.Errorf("ean13Check(%q) = %q, want %q", tt.body, got, tt.want)
}
}
}
func TestEan13Check_invalid(t *testing.T) {
cases := []string{"", "12345", "12345678901", "1234567890123", "69010000a000"}
for _, body := range cases {
_, ok := ean13Check(body)
if ok {
t.Errorf("ean13Check(%q) should return not ok", body)
}
}
}
func TestMd5hex(t *testing.T) {
got := md5hex("137966")
if len(got) != 32 {
t.Errorf("md5hex should return 32-char hex, got len=%d", len(got))
}
if got != md5hex("137966") {
t.Error("md5hex should be deterministic")
}
if got == md5hex("other") {
t.Error("md5hex should differ for different inputs")
}
}
func TestSanitizeBarcodes(t *testing.T) {
got := sanitizeBarcodes("6920459905012\n6901028941068 123")
want := []string{"6920459905012", "6901028941068", "123"}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSanitizeBarcodes_empty(t *testing.T) {
got := sanitizeBarcodes(" \n\t ")
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
}
func TestResolveSdogID_envOverride(t *testing.T) {
os.Setenv("BYPOS_SDOGID", "999999")
defer os.Unsetenv("BYPOS_SDOGID")
got := resolveSdogID("111111")
if got != "999999" {
t.Errorf("expected env var override, got %q", got)
}
}
func TestResolveSdogID_fallback(t *testing.T) {
os.Unsetenv("BYPOS_SDOGID")
got := resolveSdogID("111111")
if got != "111111" {
t.Errorf("expected fallback to explicit, got %q", got)
}
}
func TestResolveSdogID_empty(t *testing.T) {
os.Unsetenv("BYPOS_SDOGID")
got := resolveSdogID("")
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestNewCollector_sdogFromEnv(t *testing.T) {
os.Setenv("BYPOS_SDOGID", "888888")
defer os.Unsetenv("BYPOS_SDOGID")
c := NewCollector("")
if c.sdogID != "888888" {
t.Errorf("expected sdogID from env, got %q", c.sdogID)
}
}
func TestStartRejectsEmptySdogID(t *testing.T) {
os.Unsetenv("BYPOS_SDOGID")
c := NewCollector("")
err := c.Start(JobReq{Mode: "list", List: "6920459905012"})
if err == nil {
t.Fatal("expected error for empty sdogid")
}
}
+53 -12
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"embed"
"encoding/csv"
"encoding/json"
@@ -20,18 +21,48 @@ import (
//go:embed web/*
var webFS embed.FS
var collector = NewCollector("")
var collector *Collector
// waitExit keeps the console window open on Windows so the user can read errors.
func waitExit() {
if runtime.GOOS == "windows" {
fmt.Println("\n按回车键退出...")
bufio.NewReader(os.Stdin).ReadBytes('\n')
}
}
func main() {
addr := flag.String("addr", "127.0.0.1:8765", "本地监听地址")
noOpen := flag.Bool("no-open", false, "不自动打开浏览器")
sdog := flag.String("sdogid", "", "中心库账号 id(默认使用内置值)")
flag.Parse()
if *sdog != "" {
collector.sdogID = *sdog
// Log to file so crashes are diagnosable even if console closes.
lf, lfErr := os.OpenFile("bypos-collector.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if lfErr == nil {
log.SetOutput(io.MultiWriter(os.Stderr, lf))
defer lf.Close()
}
sub, _ := fs.Sub(webFS, "web")
defer func() {
if r := recover(); r != nil {
log.Printf("程序崩溃: %v", r)
waitExit()
}
}()
log.Println("bypos-collector 启动中...")
addr := flag.String("addr", "127.0.0.1:8765", "本地监听地址")
noOpen := flag.Bool("no-open", false, "不自动打开浏览器")
sdog := flag.String("sdogid", "", "中心库账号 id(优先读 $BYPOS_SDOGID 环境变量)")
flag.Parse()
collector = NewCollector(*sdog)
if collector.sdogID == "" {
log.Println("警告: 未配置 sdogid,请通过 $BYPOS_SDOGID 环境变量、-sdogid 参数或控制台输入框指定")
}
sub, err := fs.Sub(webFS, "web")
if err != nil {
log.Printf("错误: 无法加载内嵌 web 资源: %v", err)
waitExit()
return
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(sub)))
mux.HandleFunc("/api/start", handleStart)
@@ -42,7 +73,13 @@ func main() {
ln, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatalf("无法监听 %s: %v", *addr, err)
log.Printf("端口 %s 被占用,自动选择可用端口...", *addr)
ln, err = net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Printf("错误: 无法监听: %v", err)
waitExit()
return
}
}
realAddr := ln.Addr().String()
urlStr := "http://" + realAddr + "/"
@@ -51,10 +88,14 @@ func main() {
fmt.Println(" 控制台: " + urlStr)
fmt.Println(" 关闭本窗口即停止程序")
fmt.Println("==============================================")
log.Printf("监听地址: %s", realAddr)
if !*noOpen {
go openBrowser(urlStr)
}
log.Fatal(http.Serve(ln, mux))
if err := http.Serve(ln, mux); err != nil {
log.Printf("HTTP 服务异常退出: %v", err)
waitExit()
}
}
func openBrowser(url string) {
@@ -143,14 +184,14 @@ func handleExportCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", "attachment; filename=products.csv")
w.Write([]byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM so Excel reads Chinese correctly
cw := csv.NewWriter(w)
cw.Write([]string{"barcode", "name", "spec", "unit", "area", "manufacturer", "license", "in_price", "sell_price", "status", "fetched_at"})
cw.Write([]string{"barcode", "name", "spec", "unit", "area", "manufacturer", "license", "in_price", "sell_price", "status", "retmsg", "fetched_at", "source"})
dec := json.NewDecoder(f)
for {
var p Product
if err := dec.Decode(&p); err != nil {
break
}
cw.Write([]string{p.Barcode, p.Name, p.Spec, p.Unit, p.Area, p.Manufacturer, p.License, p.InPrice, p.SellPrice, p.Status, p.FetchedAt})
cw.Write([]string{p.Barcode, p.Name, p.Spec, p.Unit, p.Area, p.Manufacturer, p.License, p.InPrice, p.SellPrice, p.Status, p.RetMsg, p.FetchedAt, p.Source})
}
cw.Flush()
}
+5
View File
@@ -79,6 +79,10 @@
<div class="card">
<h3>② 采集参数</h3>
<div class="row">
<div>
<label>中心库账号 sdogid</label>
<input type="text" id="sdogid" placeholder="留空则使用环境变量或启动参数"/>
</div>
<div>
<label>并发数</label>
<input type="number" id="concurrency" value="3" min="1" max="20"/>
@@ -165,6 +169,7 @@ async function start(){
start_body: document.getElementById('start').value.trim(),
end_body: document.getElementById('end').value.trim(),
list: document.getElementById('list').value,
sdog_id: document.getElementById('sdogid').value.trim(),
concurrency: parseInt(document.getElementById('concurrency').value)||3,
delay_ms: parseInt(document.getElementById('delay').value)||0,
log_miss: document.getElementById('logmiss').checked,