feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经 进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。 按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面 手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。 shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有 这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空, 把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在 DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。 增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而 last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。 宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at, 否则下次跳过这段区间,漏的单永远补不回来。 日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间: 抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是 "2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时, 订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天, 当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation—— 那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。 金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder 在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。 迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec), v1-v4 逐字未动,CheckSchema 覆盖新表新列。 会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do() 一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。 测试全部用 httptest 假服务端,不打真实站点。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// OnlineThreshold 是判定客户端"在线"的时间窗:
|
||||
@@ -78,3 +81,102 @@ func isTempBuild(exe string) bool {
|
||||
// go run 的产物路径里通常带 go-build 字样
|
||||
return strings.Contains(filepath.ToSlash(exe), "/go-build")
|
||||
}
|
||||
|
||||
// ---------- 顺运宝配置(config.yaml) ----------
|
||||
|
||||
// SybConfig 是 config.yaml 里 `syb:` 一节,见 admin/config.example.yaml
|
||||
// 和 docs/admin/08-顺运宝接口.md §8。
|
||||
type SybConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PageSize int `yaml:"page_size"`
|
||||
MaxMatches int `yaml:"max_matches"`
|
||||
SyncFrom string `yaml:"sync_from"`
|
||||
}
|
||||
|
||||
// String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法
|
||||
// 不小心把明文密码带进日志——日志可能被贴进工单排查问题。
|
||||
//
|
||||
// `[必须]` 这是 admin/AGENTS.md「日志、页面、导出里不得出现 token、密码、Cookie」
|
||||
// 的从源头防护,不依赖每个调用方都记得手动打码。
|
||||
func (c SybConfig) String() string {
|
||||
pw := "(空)"
|
||||
if c.Password != "" {
|
||||
pw = "****"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s}",
|
||||
c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom)
|
||||
}
|
||||
|
||||
// Config 是 config.yaml 的顶层结构。目前只有顺运宝一节,
|
||||
// 后续如果要给别的模块加配置,在这里加新的字段即可。
|
||||
type Config struct {
|
||||
Syb SybConfig `yaml:"syb"`
|
||||
}
|
||||
|
||||
// configFileName 是 config.yaml 相对 exe(或 go run 时相对工作目录)的文件名。
|
||||
// 和 admin/config.example.yaml 同一目录,方便操作员按提示复制。
|
||||
const configFileName = "config.yaml"
|
||||
|
||||
// ConfigPath 返回 config.yaml 应该在的路径(不保证文件存在)。
|
||||
//
|
||||
// 路径规则和 DataDir 一致(exe 旁边;go run 时是当前工作目录),
|
||||
// 因为 config.example.yaml 的说明就是"复制到 admin/ 目录",
|
||||
// 和 data/ 同级。
|
||||
func ConfigPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if isTempBuild(exe) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = wd
|
||||
}
|
||||
return filepath.Join(dir, configFileName), nil
|
||||
}
|
||||
|
||||
// Load 读取并解析 config.yaml。
|
||||
//
|
||||
// `[必须]` 文件不存在时给出「复制 config.example.yaml」的明确提示,
|
||||
// 不是一句冷冰冰的「读取失败」——初级程序员第一次跑起来大概率会踩到这个。
|
||||
func Load() (*Config, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, missingConfigError(path)
|
||||
}
|
||||
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return parseConfig(raw, path)
|
||||
}
|
||||
|
||||
// missingConfigError 是缺文件时的提示,抽成函数是为了配置测试
|
||||
// (config_test.go)能直接复用同一句提示,不用真的绕开 os.Executable。
|
||||
func missingConfigError(path string) error {
|
||||
return fmt.Errorf(
|
||||
"没有找到配置文件 %s。\n"+
|
||||
"请复制 config.example.yaml 为 config.yaml,并填入顺运宝账号密码:\n"+
|
||||
" Windows: copy admin\\config.example.yaml admin\\config.yaml\n"+
|
||||
" Linux: cp admin/config.example.yaml admin/config.yaml",
|
||||
path)
|
||||
}
|
||||
|
||||
// parseConfig 解析 YAML 内容,抽成函数同样是为了让测试不依赖 os.Executable。
|
||||
func parseConfig(raw []byte, path string) (*Config, error) {
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadFrom 是测试专用的小工具:把一段 YAML 文本写到临时目录里的
|
||||
// config.yaml,绕开 ConfigPath()(它依赖 os.Executable,测试环境里
|
||||
// 不可控),直接测 Load 里"读文件 + 解析"这段逻辑。
|
||||
func loadFrom(t *testing.T, yamlText string) (*Config, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, configFileName)
|
||||
if yamlText != "" {
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
|
||||
t.Fatalf("写测试配置文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, missingConfigError(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return parseConfig(raw, path)
|
||||
}
|
||||
|
||||
func TestLoad_缺文件时提示复制模板(t *testing.T) {
|
||||
// 注意:不要在错误信息断言里用测试名本身会出现的中文片段做 Contains 判断——
|
||||
// t.TempDir() 生成的目录名会把测试函数名拼进路径,路径又被拼进错误信息,
|
||||
// 断言字符串一旦和测试名撞了就会产生误报,这里刻意避开这个坑。
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, configFileName)
|
||||
err := func() error {
|
||||
_, e := os.ReadFile(missingPath)
|
||||
if os.IsNotExist(e) {
|
||||
return missingConfigError(missingPath)
|
||||
}
|
||||
return e
|
||||
}()
|
||||
if err == nil {
|
||||
t.Fatal("缺文件时应该返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "config.example.yaml") {
|
||||
t.Errorf("错误信息应该提示复制 config.example.yaml,实际: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "复制") {
|
||||
t.Errorf("错误信息应该给出「复制模板文件」这个具体操作,不是一句笼统的报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_纯数字密码加引号能读出字符串(t *testing.T) {
|
||||
cfg, err := loadFrom(t, `
|
||||
syb:
|
||||
base_url: https://www.shunyunbaoerp.com
|
||||
username: tester
|
||||
password: "0012345"
|
||||
page_size: 20
|
||||
max_matches: 500
|
||||
sync_from: "2026-07-01"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if cfg.Syb.Password != "0012345" {
|
||||
t.Errorf("密码应该原样保留字符串(含前导 0),实际 %q", cfg.Syb.Password)
|
||||
}
|
||||
if cfg.Syb.Username != "tester" {
|
||||
t.Errorf("username 解析错误: %q", cfg.Syb.Username)
|
||||
}
|
||||
if cfg.Syb.PageSize != 20 || cfg.Syb.MaxMatches != 500 {
|
||||
t.Errorf("数字字段解析错误: page_size=%d max_matches=%d", cfg.Syb.PageSize, cfg.Syb.MaxMatches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_纯数字密码不加引号仍能读出字符串(t *testing.T) {
|
||||
// go-yaml 对 "password: 0012345"(不加引号)这种写法的行为要在这里锁定:
|
||||
// 如果被解析成整数再转字符串,前导 0 会丢,密码就错了。
|
||||
// 这里不强制要求这种写法本身合法,只要求:如果它没有报错,
|
||||
// 结果不能悄悄丢字符。
|
||||
cfg, err := loadFrom(t, `
|
||||
syb:
|
||||
base_url: https://www.shunyunbaoerp.com
|
||||
username: tester
|
||||
password: 12345
|
||||
`)
|
||||
if err != nil {
|
||||
// 反序列化到 string 字段直接报错也是可以接受的行为
|
||||
// (文档 08 §8 描述的正是这种失败模式),不是本测试要断言的重点。
|
||||
t.Skipf("不加引号的纯数字密码解析失败(符合文档描述的已知坑): %v", err)
|
||||
}
|
||||
if cfg.Syb.Password != "12345" {
|
||||
t.Errorf("密码字符串不应该丢字符,实际 %q", cfg.Syb.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybConfig_String不泄露密码(t *testing.T) {
|
||||
cfg := SybConfig{
|
||||
BaseURL: "https://www.shunyunbaoerp.com",
|
||||
Username: "tester",
|
||||
Password: "super-secret-password",
|
||||
}
|
||||
s := cfg.String()
|
||||
if strings.Contains(s, "super-secret-password") {
|
||||
t.Fatalf("SybConfig.String() 不能包含明文密码,实际: %s", s)
|
||||
}
|
||||
if strings.Contains(s, "tester") == false {
|
||||
t.Errorf("String() 应该保留用户名等非敏感信息方便排查: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_配置文件损坏时报明确错误(t *testing.T) {
|
||||
_, err := loadFrom(t, "syb: [this is not a valid mapping")
|
||||
if err == nil {
|
||||
t.Fatal("格式错误的 YAML 应该返回错误")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,6 +13,7 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
modernc.org/sqlite v1.38.0
|
||||
)
|
||||
@@ -28,7 +29,6 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
|
||||
+205
-13
@@ -1,43 +1,235 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/service"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
// ---------- 2. 顺运宝数据 ----------
|
||||
|
||||
// SybList 渲染货运单列表页。
|
||||
// 「匹配状态」是算出来的(sku_mappings 里有没有记录),不是存的字段。
|
||||
// 「匹配状态」是算出来的(shopee_sku_id 是否非空),不是存的字段,
|
||||
// 本工单(#46)不做规格匹配,这里只如实显示"有没有"。
|
||||
func (h *Handler) SybList(c *gin.Context) {
|
||||
keyword := c.Query("order_no")
|
||||
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
|
||||
}
|
||||
|
||||
// TODO(骨架): 查货运单,并左联 sku_mappings 得出匹配状态
|
||||
var rows []gin.H
|
||||
// renderSybList 是 SybList、SybSync、SybLoginAndSync 跳转回来共用的渲染逻辑。
|
||||
func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) {
|
||||
pageNum := service.ParsePage(pageRaw)
|
||||
result, err := service.ListSybOrdersView(h.db, keyword, pageNum)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError,
|
||||
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||
return
|
||||
}
|
||||
|
||||
status := msg
|
||||
if status == "" {
|
||||
status = sybStatusLine(result)
|
||||
}
|
||||
if service.GetSybSyncStatus().Running {
|
||||
status = "同步进行中,请稍后刷新页面查看结果 · " + status
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
if keyword != "" {
|
||||
values.Set("order_no", keyword)
|
||||
}
|
||||
|
||||
// `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。
|
||||
// 这里只取 username(只读展示)和 base_url 是否配置正确。
|
||||
username := ""
|
||||
configProblem := ""
|
||||
needLogin := false
|
||||
cfg, cfgErr := config.Load()
|
||||
switch {
|
||||
case cfgErr != nil:
|
||||
configProblem = cfgErr.Error()
|
||||
default:
|
||||
username = cfg.Syb.Username
|
||||
client, err := syb.New(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
configProblem = "顺运宝 base_url 配置有误: " + err.Error()
|
||||
} else {
|
||||
sessErr := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now())
|
||||
needLogin = errors.Is(sessErr, service.ErrSybLoginRequired)
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
|
||||
"Keyword": keyword,
|
||||
"Rows": rows,
|
||||
"Status": "尚未实现:同步或手工录入后这里显示货运单",
|
||||
"Rows": result.Rows,
|
||||
"Status": status,
|
||||
"HasAny": result.HasAny,
|
||||
"IsFiltered": result.IsFiltered,
|
||||
"NeedLogin": needLogin,
|
||||
"Username": username,
|
||||
"ConfigProblem": configProblem,
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||
}))
|
||||
}
|
||||
|
||||
// SybSync 从顺运宝同步待处理货运单。
|
||||
// sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
|
||||
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
|
||||
func sybStatusLine(result *service.SybListResult) string {
|
||||
prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total)
|
||||
if !result.HasAny {
|
||||
return "还没有货运单明细。点上方「同步」从顺运宝拉取。"
|
||||
}
|
||||
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
|
||||
}
|
||||
|
||||
// SybSync 点「同步」按钮的入口。
|
||||
//
|
||||
// MVP 阶段只做占位,见 docs/admin/01-requirements.md §11 待确认 #1。
|
||||
// 流程见工单 #46:
|
||||
//
|
||||
// 会话有效 ────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
|
||||
// 会话无效/过期 ──→ 跳回列表页,页面上弹登录框(不是报错)
|
||||
func (h *Handler) SybSync(c *gin.Context) {
|
||||
// TODO(等待确认): 顺运宝的同步方式(接口?导出文件?)还没定。
|
||||
// 定了之后在这里实现,注意完整响应要原样存进 syb_data 字段。
|
||||
fail(c, http.StatusNotImplemented,
|
||||
"同步功能待接入。顺运宝的对接方式尚未确定,"+
|
||||
"当前可以先手工录入货运单用于联调。")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := syb.New(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "顺运宝 base_url 配置有误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
|
||||
if errors.Is(err, service.ErrSybLoginRequired) {
|
||||
// `[必须]` 会话过期后点同步 → 弹登录框,不是报错。
|
||||
h.sybRedirect(c, "")
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !h.startSybSync(client, *cfg) {
|
||||
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
||||
return
|
||||
}
|
||||
h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果")
|
||||
}
|
||||
|
||||
// SybCaptcha 返回一张新的顺运宝验证码图片。
|
||||
//
|
||||
// `[必须]` 验证码和随后提交的登录必须用同一个 Cookie Jar(08 §3.2),
|
||||
// 所以这里新建的 syb.Client 要缓存起来(service.NewPendingSybLogin),
|
||||
// 供 SybLoginAndSync 复用,不能各请求各建一个客户端。
|
||||
func (h *Handler) SybCaptcha(c *gin.Context) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
client, err := service.NewPendingSybLogin(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
captcha, err := client.FetchCaptcha(c.Request.Context())
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "获取验证码失败:%s", err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, captcha.ContentType, captcha.Image)
|
||||
}
|
||||
|
||||
// SybLoginAndSync 提交验证码登录,成功后立即发起同步。
|
||||
//
|
||||
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
|
||||
// 界面上只有验证码是操作员手输的,见工单 #46。
|
||||
func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(c.PostForm("code"))
|
||||
if code == "" {
|
||||
h.sybRedirect(c, "验证码不能为空,请重新输入")
|
||||
return
|
||||
}
|
||||
|
||||
client := service.PendingSybLoginClient()
|
||||
if client == nil {
|
||||
h.sybRedirect(c, "验证码已过期,请重新获取后再试")
|
||||
return
|
||||
}
|
||||
defer service.ClearPendingSybLogin()
|
||||
|
||||
result, err := client.Login(c.Request.Context(), cfg.Syb.Username, cfg.Syb.Password, code)
|
||||
if err != nil {
|
||||
// `[必须]` err 来自 syb.Client,错误信息本身不含密码,可以直接展示。
|
||||
h.sybRedirect(c, "登录失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步。
|
||||
if err := service.SaveSybLoginSession(h.db, client, result.User.Username, result.ExpiresAt); err != nil {
|
||||
log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err)
|
||||
}
|
||||
|
||||
if !h.startSybSync(client, *cfg) {
|
||||
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||
return
|
||||
}
|
||||
h.sybRedirect(c, "登录成功,同步已开始,请稍后刷新页面查看结果")
|
||||
}
|
||||
|
||||
// startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。
|
||||
//
|
||||
// `[必须]` 同步是长任务,不能阻塞 HTTP 请求线程直到结束,也不引入后台
|
||||
// 协程池——一次只允许一个同步在跑,靠 service.TryStartSybSync 这个互斥
|
||||
// 标志挡住重复点击,见工单 #46。
|
||||
//
|
||||
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
||||
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
|
||||
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
|
||||
if !service.TryStartSybSync() {
|
||||
return false
|
||||
}
|
||||
db := h.db
|
||||
go func() {
|
||||
report := service.RunSybSync(context.Background(), db, client, cfg.Syb, time.Now())
|
||||
service.FinishSybSync(report)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。
|
||||
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
|
||||
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
||||
params := url.Values{}
|
||||
if q := c.PostForm("order_no"); q != "" {
|
||||
params.Set("order_no", q)
|
||||
}
|
||||
if msg != "" {
|
||||
params.Set("msg", msg)
|
||||
}
|
||||
target := "/syb"
|
||||
if len(params) > 0 {
|
||||
target += "?" + params.Encode()
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, target)
|
||||
}
|
||||
|
||||
// SybMatch 保存规格匹配结果。
|
||||
|
||||
@@ -60,7 +60,9 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
|
||||
// 3. 顺运宝数据
|
||||
pages.GET("/syb", h.SybList)
|
||||
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
|
||||
pages.POST("/syb/sync", h.SybSync)
|
||||
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
|
||||
pages.POST("/syb/match", h.SybMatch)
|
||||
pages.POST("/syb/create-task", h.SybCreateTask)
|
||||
pages.POST("/syb/delete", h.SybDelete)
|
||||
|
||||
@@ -131,20 +131,26 @@ type ShopeeSKU struct {
|
||||
|
||||
// ---------- 顺运宝 ----------
|
||||
|
||||
// SybOrder 是一张顺运宝货运单。
|
||||
// SybOrder 是一张顺运宝货运单里的**一个商品明细行**(不是一张货运单,
|
||||
// 一张货运单可以有多个商品,各占一行)。
|
||||
//
|
||||
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
|
||||
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
|
||||
//
|
||||
// `[必须]` ShopeeSKUID 是规格匹配的结果(人工确认或自动匹配产生),
|
||||
// 顺运宝同步**绝不能覆盖它**——顺运宝根本没有这个值,见工单 #46、
|
||||
// docs/admin/08-顺运宝接口.md §6.2。
|
||||
type SybOrder struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
ShopeeSKUID string // 匹配结果;顺运宝同步永远不写这一列,见上面的注释
|
||||
Quantity int
|
||||
PriceTwdCent int64
|
||||
ImageURL string
|
||||
SybData string // 完整货运单 JSON,原样保留
|
||||
SybData string // 完整货运单+明细 JSON,原样保留,审计用
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
+38
-3
@@ -264,7 +264,7 @@ var migrations = [][]string{
|
||||
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
||||
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
||||
// 和代码对不上的库静默启动。
|
||||
const schemaVersion = 4
|
||||
const schemaVersion = 5
|
||||
|
||||
// migrationV4 给 PDD 商品增加店铺名。
|
||||
//
|
||||
@@ -275,6 +275,31 @@ var migrationV4 = []string{
|
||||
`ALTER TABLE pdd_products ADD COLUMN shop_name TEXT;`,
|
||||
}
|
||||
|
||||
// migrationV5 是工单 #46(顺运宝货运单同步)需要的三样东西:
|
||||
// 会话缓存表、同步进度表、给 syb_orders 补一列规格原文。
|
||||
//
|
||||
// `[必须]` 三条都是新增(新表或 ADD COLUMN),不改动任何已发布的列/表,
|
||||
// 见 admin/AGENTS.md「迁移只追加」。
|
||||
var migrationV5 = []string{
|
||||
// 会话缓存。只存 Cookie 就够——08 §3.1 已确认 JWT 从不参与后续请求认证,
|
||||
// 缓存 token 没有意义,缓存 Cookie 才能免登录。
|
||||
`CREATE TABLE syb_session (
|
||||
username TEXT PRIMARY KEY,
|
||||
cookies TEXT NOT NULL, -- JSON 数组
|
||||
expires_at TEXT NOT NULL, -- min(JWT exp, 24h)
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
// 上次同步到哪。只允许一行(CHECK id = 1),increment 同步靠它算日期范围。
|
||||
`CREATE TABLE syb_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_synced_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
// 规格原文。匹配要用它,放列里才能查、才能在界面显示,
|
||||
// 对应 shopee_skus.spec_raw,两边同一个概念。
|
||||
`ALTER TABLE syb_orders ADD COLUMN product_spec TEXT;`,
|
||||
}
|
||||
|
||||
// Migrate 把数据库升到最新版本。
|
||||
// 已经是最新的就什么都不做,可以重复调用。
|
||||
func Migrate(db *sql.DB) error {
|
||||
@@ -332,6 +357,13 @@ func Migrate(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
// v5 同理,是普通的建表 + 追加列迁移,排在 v4 后面执行。
|
||||
if reached < 5 {
|
||||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -790,13 +822,16 @@ var requiredTables = []string{
|
||||
"shopee_products", "shopee_skus", "pdd_products",
|
||||
"syb_orders", "sku_mappings", "tasks", "clients",
|
||||
"idempotency_keys", "task_claims",
|
||||
"syb_session", "syb_sync_state",
|
||||
}
|
||||
|
||||
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
|
||||
// shop_name 是 v4 新增列;缺少它时查询 PDD 页面会直接失败,因此启动时
|
||||
// 就应给出明确错误,而不是等操作员点到页面才暴露。
|
||||
// shop_name 是 v4 新增列;product_spec 是 v5 新增列——两者缺失时
|
||||
// 查询对应页面会直接失败,因此启动时就应给出明确错误,
|
||||
// 而不是等操作员点到页面才暴露。
|
||||
var requiredColumns = map[string][]string{
|
||||
"pdd_products": {"shop_name"},
|
||||
"syb_orders": {"product_spec"},
|
||||
}
|
||||
|
||||
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
|
||||
|
||||
@@ -543,6 +543,149 @@ func TestMigrate_v2新结构库只更新版本号不打误导性日志(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// ── v5:顺运宝会话表、同步状态表、product_spec 列 ──────
|
||||
|
||||
// TestMigrate_不同起点最终schema一致(上面已有)用的是全量 dumpSchema 对比,
|
||||
// v5 的新表/新列不需要改那个测试就已经被覆盖——这里再加行为断言,
|
||||
// 专门锁住 v5 引入的三样东西,即使将来有人改坏了三起点收敛测试本身,
|
||||
// 这几个测试仍然能单独发现问题。
|
||||
func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
}{
|
||||
{"全新库", newFreshDB(t)},
|
||||
{"v2 老结构库", newV2DB(t)},
|
||||
{"v2 新结构库", newV2NewStructureDB(t)},
|
||||
} {
|
||||
if c.name != "全新库" {
|
||||
if err := Migrate(c.db); err != nil {
|
||||
t.Fatalf("%s 迁移失败: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
|
||||
}
|
||||
if version != schemaVersion {
|
||||
t.Fatalf("%s user_version = %d,期望 %d", c.name, version, schemaVersion)
|
||||
}
|
||||
|
||||
tables := existingTableSet(t, c.db)
|
||||
for _, table := range []string{"syb_session", "syb_sync_state"} {
|
||||
if !tables[table] {
|
||||
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
|
||||
}
|
||||
}
|
||||
|
||||
cols, err := tableColumnSet(c.db, "syb_orders")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 读取 syb_orders 列失败: %v", c.name, err)
|
||||
}
|
||||
if !cols["product_spec"] {
|
||||
t.Errorf("%s:syb_orders 迁移后应该有 product_spec 列", c.name)
|
||||
}
|
||||
|
||||
if err := CheckSchema(c.db); err != nil {
|
||||
t.Errorf("%s:迁移后应该通过自检: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// existingTableSet 返回库里当前存在的表名集合。
|
||||
func existingTableSet(t *testing.T, db *sql.DB) map[string]bool {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||||
if err != nil {
|
||||
t.Fatalf("读取表清单失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
set := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("读取表清单失败: %v", err)
|
||||
}
|
||||
set[name] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// TestMigrate_v5会话表主键只允许一个用户名一行 验证 syb_session 的主键约束:
|
||||
// 同一个用户名重复写入应该走 upsert(由 repository 层保证),底层主键必须
|
||||
// 拒绝真正的重复插入,防止同一账号在表里出现两行、读的时候不知道信哪行。
|
||||
func TestMigrate_v5会话表主键约束(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
const now = "2026-08-09T00:00:00Z"
|
||||
insert := func() error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`, "tester", `[]`, now, now)
|
||||
return err
|
||||
}
|
||||
if err := insert(); err != nil {
|
||||
t.Fatalf("插入第一条会话失败: %v", err)
|
||||
}
|
||||
if err := insert(); err == nil {
|
||||
t.Fatal("同一 username 重复插入应该被主键约束拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_v5同步状态表只允许一行 验证 CHECK (id = 1) 确实生效——
|
||||
// 这张表按设计只应该有一行(全局的"上次同步到哪"),如果这条 CHECK
|
||||
// 被误删或写错,程序里到处用 id=1 查询的代码会开始读到错误的行。
|
||||
func TestMigrate_v5同步状态表只允许id为1(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
const now = "2026-08-09T00:00:00Z"
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||||
VALUES (1, NULL, ?)`, now); err != nil {
|
||||
t.Fatalf("插入 id=1 失败: %v", err)
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||||
VALUES (2, NULL, ?)`, now)
|
||||
if err == nil {
|
||||
t.Fatal("id != 1 应该被 CHECK 约束拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_缺少syb_session表时拒绝(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
if _, err := db.Exec(`DROP TABLE syb_session`); err != nil {
|
||||
t.Fatalf("删表失败: %v", err)
|
||||
}
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "syb_session") {
|
||||
t.Fatalf("缺 syb_session 表时应该拒绝启动并指出表名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_缺少syb_sync_state表时拒绝(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
if _, err := db.Exec(`DROP TABLE syb_sync_state`); err != nil {
|
||||
t.Fatalf("删表失败: %v", err)
|
||||
}
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "syb_sync_state") {
|
||||
t.Fatalf("缺 syb_sync_state 表时应该拒绝启动并指出表名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_缺少product_spec列时拒绝(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
// modernc.org/sqlite 支持 DROP COLUMN(SQLite ≥ 3.35),
|
||||
// 用它来模拟"迁移没有完整落地、只有旧列"的库。
|
||||
if _, err := db.Exec(`ALTER TABLE syb_orders DROP COLUMN product_spec`); err != nil {
|
||||
t.Fatalf("模拟缺列失败: %v", err)
|
||||
}
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "product_spec") {
|
||||
t.Fatalf("缺 product_spec 列时应该拒绝启动并指出列名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 最终 schema 的硬约束:不依赖路径比对 ──────────────
|
||||
//
|
||||
// TestMigrate_不同起点最终schema一致 证明的是"全新库"和"v2 老库"两条路径
|
||||
@@ -884,6 +1027,11 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
||||
if err := migrateV3(db); err != nil {
|
||||
t.Fatalf("准备 v3 数据库失败: %v", err)
|
||||
}
|
||||
// 故意跳过 v4(不加 shop_name),但把 v5 补上——否则 CheckSchema 会先
|
||||
// 因为缺 v5 的表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
||||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||||
t.Fatalf("准备 v5 数据库失败: %v", err)
|
||||
}
|
||||
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 顺运宝会话缓存、同步进度和货运单明细的读写。
|
||||
//
|
||||
// 改动前必读 admin/AGENTS.md:只有本文件(和 db.go)能写 SQL,
|
||||
// service/syb.go 和 handler/web/others.go 都不许拼 SQL。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
// ---------- 会话缓存 ----------
|
||||
|
||||
// SaveSybSession 写入或更新顺运宝登录会话缓存(按用户名 upsert)。
|
||||
//
|
||||
// `[必须]` 缓存写失败不能让已经登录的顺运宝会话失效——调用方拿到错误后
|
||||
// 只应该记日志,不应该把内存里刚登录成功的会话也扔掉,
|
||||
// 见 docs/admin/08-顺运宝接口.md §8。这条约束在 service 层落实,
|
||||
// 这里只负责"写失败就如实返回错误"。
|
||||
func SaveSybSession(q Execer, username, cookiesJSON, expiresAt string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username 不能为空")
|
||||
}
|
||||
now := model.NowISO()
|
||||
_, err := q.Exec(`
|
||||
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
cookies = excluded.cookies,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
username, cookiesJSON, expiresAt, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存顺运宝会话缓存失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SybSessionCache 是缓存里的一条顺运宝会话。
|
||||
type SybSessionCache struct {
|
||||
Username string
|
||||
Cookies string // JSON 数组,原样保留,交给 syb.Client 解析
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
// GetSybSession 按用户名查缓存的会话,查不到返回 (nil, nil)。
|
||||
//
|
||||
// `[必须]` 这里只负责取数据,**不判断是否过期**——过期时间的比较、
|
||||
// 是否需要重新登录,是 service 层的业务判断(要用到"现在几点"这个
|
||||
// 会变化的量,放这里测试起来还要控制时间,不如交给上层)。
|
||||
func GetSybSession(q Execer, username string) (*SybSessionCache, error) {
|
||||
var c SybSessionCache
|
||||
err := q.QueryRow(`
|
||||
SELECT username, cookies, expires_at FROM syb_session WHERE username = ?`, username,
|
||||
).Scan(&c.Username, &c.Cookies, &c.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询顺运宝会话缓存失败: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DeleteSybSession 清除某个用户名的会话缓存(会话确认失效后调用)。
|
||||
func DeleteSybSession(q Execer, username string) error {
|
||||
if _, err := q.Exec(`DELETE FROM syb_session WHERE username = ?`, username); err != nil {
|
||||
return fmt.Errorf("清除顺运宝会话缓存失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 同步进度 ----------
|
||||
|
||||
// GetSybLastSyncedAt 查上次同步完成的时间(精确到秒的 ISO 字符串)。
|
||||
// 从没同步过时返回 ("", false, nil)。
|
||||
func GetSybLastSyncedAt(q Execer) (lastSyncedAt string, found bool, err error) {
|
||||
var s sql.NullString
|
||||
err = q.QueryRow(`SELECT last_synced_at FROM syb_sync_state WHERE id = 1`).Scan(&s)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("查询顺运宝同步进度失败: %w", err)
|
||||
}
|
||||
if !s.Valid || s.String == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return s.String, true, nil
|
||||
}
|
||||
|
||||
// SetSybLastSyncedAt 更新"上次同步到哪"。
|
||||
//
|
||||
// `[必须]` 只应该在一次同步**全部成功**之后调用——调用方(service 层)
|
||||
// 负责这个时机;这里只负责写,不判断"是否该写"。中途失败不调用这个函数,
|
||||
// 见工单 #46「中途失败不更新 last_synced_at」。
|
||||
func SetSybLastSyncedAt(q Execer, at string) error {
|
||||
now := model.NowISO()
|
||||
_, err := q.Exec(`
|
||||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_synced_at = excluded.last_synced_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
at, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新顺运宝同步进度失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 货运单明细 ----------
|
||||
|
||||
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
|
||||
//
|
||||
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
|
||||
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
|
||||
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
|
||||
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
|
||||
// docs/admin/08-顺运宝接口.md §6.2。
|
||||
//
|
||||
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
|
||||
// detail.productId,来自顺运宝,不是人工填的。
|
||||
//
|
||||
// 返回 created 表示这一行是不是本次新插入的(供上层统计"新增/更新")。
|
||||
func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
if o.SybID == "" {
|
||||
return false, fmt.Errorf("syb_id 不能为空")
|
||||
}
|
||||
|
||||
var exists int
|
||||
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
created = true
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("查询顺运宝货运单明细 %s 失败: %w", o.SybID, err)
|
||||
}
|
||||
|
||||
now := model.NowISO()
|
||||
_, err = q.Exec(`
|
||||
INSERT INTO syb_orders
|
||||
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(syb_id) DO UPDATE SET
|
||||
order_no = excluded.order_no,
|
||||
title = excluded.title,
|
||||
product_spec = excluded.product_spec,
|
||||
shopee_goods_id = excluded.shopee_goods_id,
|
||||
quantity = excluded.quantity,
|
||||
price_twd_cent = excluded.price_twd_cent,
|
||||
image_url = excluded.image_url,
|
||||
syb_data = excluded.syb_data,
|
||||
updated_at = excluded.updated_at`,
|
||||
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
|
||||
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
|
||||
// 这一列不受本语句影响,见上面的函数注释。
|
||||
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
|
||||
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
||||
o.SybData, now, now)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// nullableText 把空字符串转成 SQL NULL,非空字符串原样写入。
|
||||
// syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL
|
||||
// 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。
|
||||
func nullableText(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SybOrderFilter 是货运单列表页支持的筛选条件。
|
||||
type SybOrderFilter struct {
|
||||
Keyword string // 匹配订单号或商品标题
|
||||
}
|
||||
|
||||
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
||||
kw := filter.Keyword
|
||||
if kw == "" {
|
||||
return "", nil
|
||||
}
|
||||
like := "%" + escapeLike(kw) + "%"
|
||||
return ` WHERE (order_no LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\')`, []any{like, like}
|
||||
}
|
||||
|
||||
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
|
||||
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
|
||||
where, args := sybOrderFilterClause(filter)
|
||||
sqlText := `
|
||||
SELECT syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at
|
||||
FROM syb_orders` + where + `
|
||||
ORDER BY updated_at DESC, syb_id DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := q.Query(sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询顺运宝货运单列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []model.SybOrder
|
||||
for rows.Next() {
|
||||
var o model.SybOrder
|
||||
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
|
||||
var priceCent sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
|
||||
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
|
||||
}
|
||||
o.Title = title.String
|
||||
o.ProductSpec = productSpec.String
|
||||
o.ShopeeGoodsID = shopeeGoodsID.String
|
||||
o.ShopeeSKUID = shopeeSKUID.String
|
||||
o.PriceTwdCent = priceCent.Int64
|
||||
o.ImageURL = imageURL.String
|
||||
list = append(list, o)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// CountSybOrders 统计当前筛选条件下的货运单明细总数。
|
||||
//
|
||||
// `[必须]` 用和 ListSybOrders **完全相同**的筛选条件——分页和底部统计
|
||||
// 靠它,写成两份筛选条件迟早有一天会不一致(工单 #43 的教训)。
|
||||
func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
|
||||
where, args := sybOrderFilterClause(filter)
|
||||
sqlText := `SELECT COUNT(*) FROM syb_orders` + where
|
||||
var n int
|
||||
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountSybOrdersTotal 统计全部货运单明细数量(不带筛选),
|
||||
// 供列表页判断"是否已经同步过任何数据"。
|
||||
func CountSybOrdersTotal(q Execer) (int, error) {
|
||||
var n int
|
||||
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_orders`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
func newSybTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在(t *testing.T) {
|
||||
// 这是工单 #46 最要紧的一条:shopee_sku_id 是人工规格匹配的结果,
|
||||
// 顺运宝同步绝不能把它覆盖成空值——覆盖了不会报错,等到建采购任务
|
||||
// 才会发现匹配成果被洗掉,那时已经找不回来了。
|
||||
db := newSybTestDB(t)
|
||||
|
||||
first := model.SybOrder{
|
||||
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "初始标题",
|
||||
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
|
||||
Quantity: 1, PriceTwdCent: 23900, ImageURL: "https://x/img.jpg",
|
||||
SybData: `{"stock":{}}`,
|
||||
}
|
||||
created, err := UpsertSybOrder(db, first)
|
||||
if err != nil {
|
||||
t.Fatalf("首次写入失败: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("首次写入应该是新建")
|
||||
}
|
||||
|
||||
// 模拟操作员在界面上完成了规格匹配,手工把 shopee_sku_id 填上。
|
||||
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
|
||||
"TEST-SKU-123", "SYB-1"); err != nil {
|
||||
t.Fatalf("模拟人工匹配失败: %v", err)
|
||||
}
|
||||
|
||||
// 再次同步:标题、数量、价格都变了(模拟顺运宝那边数据更新),
|
||||
// 但 upsert 调用方不会传 shopee_sku_id 的新值(顺运宝根本没有这个字段)。
|
||||
second := model.SybOrder{
|
||||
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "更新后的标题",
|
||||
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
|
||||
Quantity: 3, PriceTwdCent: 25900, ImageURL: "https://x/img2.jpg",
|
||||
SybData: `{"stock":{"updated":true}}`,
|
||||
// 注意:这里刻意不设置 ShopeeSKUID(零值,即空字符串),
|
||||
// 模拟"同步流程从来不知道匹配结果,只管顺运宝返回的字段"。
|
||||
}
|
||||
created2, err := UpsertSybOrder(db, second)
|
||||
if err != nil {
|
||||
t.Fatalf("二次写入失败: %v", err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("二次写入应该是更新,不是新建")
|
||||
}
|
||||
|
||||
var skuID, title string
|
||||
var qty int
|
||||
if err := db.QueryRow(`SELECT shopee_sku_id, title, quantity FROM syb_orders WHERE syb_id = ?`,
|
||||
"SYB-1").Scan(&skuID, &title, &qty); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if skuID != "TEST-SKU-123" {
|
||||
t.Fatalf("shopee_sku_id 应该还是人工匹配的 TEST-SKU-123,实际 %q"+
|
||||
"——同步把匹配成果覆盖掉了,这是本工单最不能接受的问题", skuID)
|
||||
}
|
||||
// 其余允许覆盖的字段应该已经更新,证明这不是"upsert 整个没生效"的假通过。
|
||||
if title != "更新后的标题" || qty != 3 {
|
||||
t.Errorf("title/quantity 应该被同步更新,实际 title=%q quantity=%d", title, qty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
if _, err := UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "11111111111", Quantity: 1,
|
||||
SybData: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("首次写入失败: %v", err)
|
||||
}
|
||||
if _, err := UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "22222222222", Quantity: 1,
|
||||
SybData: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("二次写入失败: %v", err)
|
||||
}
|
||||
|
||||
var goodsID string
|
||||
if err := db.QueryRow(`SELECT shopee_goods_id FROM syb_orders WHERE syb_id = ?`,
|
||||
"SYB-2").Scan(&goodsID); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if goodsID != "22222222222" {
|
||||
t.Errorf("shopee_goods_id 应该被同步更新为顺运宝返回的新值,实际 %q", goodsID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: "SYB-3", OrderNo: "ORDER-3", Quantity: 1, SybData: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("第 %d 次写入失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("重复同步同一个 syb_id 应该只有 1 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybSession_保存读取和清除(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
if got, err := GetSybSession(db, "tester"); err != nil || got != nil {
|
||||
t.Fatalf("没有会话时应该返回 (nil, nil),实际 got=%v err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"abc"}]`, "2026-08-10T00:00:00Z"); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetSybSession(db, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("读取会话失败: %v", err)
|
||||
}
|
||||
if got == nil || got.ExpiresAt != "2026-08-10T00:00:00Z" {
|
||||
t.Fatalf("读取到的会话不对: %+v", got)
|
||||
}
|
||||
|
||||
// upsert:同一用户名再保存一次应该覆盖,不是新增一行。
|
||||
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"xyz"}]`, "2026-08-11T00:00:00Z"); err != nil {
|
||||
t.Fatalf("二次保存会话失败: %v", err)
|
||||
}
|
||||
got2, err := GetSybSession(db, "tester")
|
||||
if err != nil || got2 == nil || got2.ExpiresAt != "2026-08-11T00:00:00Z" {
|
||||
t.Fatalf("二次保存后应该读到新值: got=%+v err=%v", got2, err)
|
||||
}
|
||||
|
||||
if err := DeleteSybSession(db, "tester"); err != nil {
|
||||
t.Fatalf("删除会话失败: %v", err)
|
||||
}
|
||||
if got3, err := GetSybSession(db, "tester"); err != nil || got3 != nil {
|
||||
t.Fatalf("删除后应该查不到,实际 got=%v err=%v", got3, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybSyncState_首次为空之后可更新(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
_, found, err := GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("从没同步过时 found 应该是 false")
|
||||
}
|
||||
|
||||
if err := SetSybLastSyncedAt(db, "2026-08-09T14:30:00Z"); err != nil {
|
||||
t.Fatalf("更新失败: %v", err)
|
||||
}
|
||||
at, found, err := GetSybLastSyncedAt(db)
|
||||
if err != nil || !found || at != "2026-08-09T14:30:00Z" {
|
||||
t.Fatalf("读取错误: at=%q found=%v err=%v", at, found, err)
|
||||
}
|
||||
|
||||
// 再更新一次,确认是 upsert 而不是报主键冲突。
|
||||
if err := SetSybLastSyncedAt(db, "2026-08-10T09:00:00Z"); err != nil {
|
||||
t.Fatalf("二次更新失败: %v", err)
|
||||
}
|
||||
at2, _, err := GetSybLastSyncedAt(db)
|
||||
if err != nil || at2 != "2026-08-10T09:00:00Z" {
|
||||
t.Fatalf("二次更新后应该读到新值: at2=%q err=%v", at2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
mustUpsert := func(sybID, orderNo, title string) {
|
||||
if _, err := UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("写入 %s 失败: %v", sybID, err)
|
||||
}
|
||||
}
|
||||
mustUpsert("SYB-A", "260728AAA", "纯棉上衣")
|
||||
mustUpsert("SYB-B", "260728BBB", "牛仔裤")
|
||||
|
||||
rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].SybID != "SYB-A" {
|
||||
t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows)
|
||||
}
|
||||
|
||||
rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" {
|
||||
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
// 顺运宝货运单同步的编排逻辑:算日期范围、翻页拉列表和明细、
|
||||
// 字段映射、落库统计、汇总报告。
|
||||
//
|
||||
// 改动前必读 admin/AGENTS.md:本层不认识 *gin.Context,也不拼 SQL——
|
||||
// 那些分别在 handler/web/others.go 和 repository/syb.go。
|
||||
//
|
||||
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
|
||||
// 1. shopee_sku_id 绝不能被同步写入/覆盖——这条已经在
|
||||
// repository.UpsertSybOrder 的 SQL 层面保证,本文件不需要、
|
||||
// 也不允许再传一份"新的" shopee_sku_id 进去。
|
||||
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
|
||||
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// sybLocation 是顺运宝服务端的时区。
|
||||
//
|
||||
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
|
||||
// raw_data/shunyunbaoerp_stock_query.har:抓包于 2026-07-28T03:31:45Z
|
||||
// (= 11:31 UTC+8),同一响应里 created 是 "2026-07-28 10:37:59"。
|
||||
// 若 created 是 UTC,换算成 UTC+8 就是 18:37,比抓包时刻晚 7 小时,
|
||||
// 订单创建于未来,不成立;作为 UTC+8 讲得通(比抓包早 54 分钟)。
|
||||
//
|
||||
// 用 UTC 算日期会在本地(UTC+8)00:00–08:00 这段时间把"今天"算成昨天,
|
||||
// 当天早晨创建的单这一轮拉不到(下一轮的 from 仍是上次同步日,
|
||||
// 范围会覆盖回来,不会永久丢,但操作员当场会以为同步坏了)。
|
||||
// 见 docs/admin/08-顺运宝接口.md §5.3。
|
||||
//
|
||||
// `[必须]` 用 time.FixedZone 写死,不用 time.LoadLocation("Asia/Shanghai")——
|
||||
// 那要读系统 tzdata,Windows 默认没有,打包成 exe 后会失败。
|
||||
var sybLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// dateOf 把一个时刻转成顺运宝服务端时区(UTC+8)下的 YYYY-MM-DD。
|
||||
//
|
||||
// `[必须]` last_synced_at 存的仍是 UTC ISO(和全库其它时间戳一致,
|
||||
// model.NowISO 的约定不动),只在这里换算成 UTC+8 取日期。
|
||||
func dateOf(t time.Time) string {
|
||||
return t.In(sybLocation).Format(dateLayout)
|
||||
}
|
||||
|
||||
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||
//
|
||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||
// 筛选粒度是日期,last_synced_at 精确到秒,从第二天拉会漏掉当天晚些
|
||||
// 时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏,
|
||||
// 见工单 #46。
|
||||
//
|
||||
// `[必须]` 首次同步(lastSyncedAt 为空)用 syncFrom;结束日期用 now
|
||||
// 对应的日期,不用未来日期。
|
||||
func syncDateRange(lastSyncedAt string, syncFrom string, now time.Time) (from, to string, err error) {
|
||||
to = dateOf(now)
|
||||
if lastSyncedAt == "" {
|
||||
from = strings.TrimSpace(syncFrom)
|
||||
if from == "" {
|
||||
return "", "", fmt.Errorf("从未同步过,且 config.yaml 里没有配置 syb.sync_from,无法确定起始日期")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
t, ok := model.ParseISO(lastSyncedAt)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt)
|
||||
}
|
||||
return dateOf(t), to, nil
|
||||
}
|
||||
|
||||
// ---------- 同步报告 ----------
|
||||
|
||||
// SkipNote 是一条跳过或失败的说明。
|
||||
//
|
||||
// `[必须]` 有跳过或失败时要把它们列出来,不能只给个数字,见工单 #46
|
||||
// 「报告要说清楚」。
|
||||
type SkipNote struct {
|
||||
SybID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SyncReport 是一次同步的结果,供状态条显示。
|
||||
type SyncReport struct {
|
||||
From, To string
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Created int
|
||||
Updated int
|
||||
SkippedZero int // quantity <= 0 被跳过的条数
|
||||
Notes []SkipNote
|
||||
Err error
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
// Summary 组装状态条文案,格式见工单 #46「报告要说清楚」:
|
||||
//
|
||||
// 同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
|
||||
// (新增 20,更新 7,跳过 0)
|
||||
func (r SyncReport) Summary() string {
|
||||
if r.Err != nil {
|
||||
return "同步失败:" + r.Err.Error()
|
||||
}
|
||||
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
|
||||
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||
if len(r.Notes) > 0 {
|
||||
var reasons []string
|
||||
for _, n := range r.Notes {
|
||||
reasons = append(reasons, fmt.Sprintf("%s:%s", n.SybID, n.Reason))
|
||||
}
|
||||
msg += ";跳过/失败详情:" + strings.Join(reasons, ";")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ---------- 同步互斥:一次只允许一个同步在跑 ----------
|
||||
//
|
||||
// `[必须]` 同步是长任务,不能阻塞 HTTP 请求线程直到结束;也不需要为此
|
||||
// 引入后台协程池——本项目是单机内部工具,用一个互斥标志挡住重复点击
|
||||
// 即可,见工单 #46。
|
||||
|
||||
var (
|
||||
syncStateMu sync.Mutex
|
||||
syncRunning bool
|
||||
lastReport *SyncReport
|
||||
)
|
||||
|
||||
// TryStartSybSync 尝试把"同步中"标志置上;已经在跑时返回 false。
|
||||
func TryStartSybSync() bool {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
if syncRunning {
|
||||
return false
|
||||
}
|
||||
syncRunning = true
|
||||
return true
|
||||
}
|
||||
|
||||
// FinishSybSync 同步结束(不管成功失败)后调用,记录最后一份报告并
|
||||
// 放开互斥标志。
|
||||
func FinishSybSync(r SyncReport) {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
syncRunning = false
|
||||
rc := r
|
||||
lastReport = &rc
|
||||
}
|
||||
|
||||
// SybSyncStatus 是页面要显示的当前同步状态。
|
||||
type SybSyncStatus struct {
|
||||
Running bool
|
||||
Report *SyncReport // 最近一次已经完成的同步报告,从没同步过是 nil
|
||||
}
|
||||
|
||||
// GetSybSyncStatus 供页面渲染状态条用。
|
||||
func GetSybSyncStatus() SybSyncStatus {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
return SybSyncStatus{Running: syncRunning, Report: lastReport}
|
||||
}
|
||||
|
||||
// ---------- 同步主流程 ----------
|
||||
|
||||
// receiverFields 是顺运宝原始数据里属于个人信息的字段,落库前要剔掉。
|
||||
//
|
||||
// `[建议]` 见工单 #46 和 docs/admin/08-顺运宝接口.md §5:收件人姓名/
|
||||
// 电话/地址做采购决策用不到,不入库。
|
||||
var receiverFields = []string{"receiver", "receiverTel", "receiverAddr"}
|
||||
|
||||
func stripReceiverFields(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
skip := false
|
||||
for _, f := range receiverFields {
|
||||
if k == f {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roundYuanToCent 把台币元换算成分,**先四舍五入再转整数**。
|
||||
//
|
||||
// `[必须]` 08 §5.1:直接截断浮点数会算错钱(239.0*100 在浮点下可能是
|
||||
// 23899.999...),必须先 math.Round 再转 int64。
|
||||
func roundYuanToCent(yuan float64) int64 {
|
||||
return int64(math.Round(yuan * 100))
|
||||
}
|
||||
|
||||
// RunSybSync 执行一次完整的顺运宝货运单同步。
|
||||
//
|
||||
// `[必须]` 调用方负责:
|
||||
// 1. 只在 TryStartSybSync() 返回 true 时调用一次;
|
||||
// 2. 调用结束后(不管成功失败)调用 FinishSybSync(report);
|
||||
// 3. client 已经恢复了有效的登录会话(Cookie)。
|
||||
//
|
||||
// 本函数本身不检查会话是否有效——会话有效性判断属于"点同步"这一步的
|
||||
// 前置检查(service.EnsureSybLoginNeeded),不属于同步本身;同步过程中
|
||||
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
|
||||
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
|
||||
func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport {
|
||||
report := SyncReport{StartedAt: now}
|
||||
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
maxMatches := cfg.MaxMatches
|
||||
if maxMatches <= 0 {
|
||||
maxMatches = 500
|
||||
}
|
||||
|
||||
lastSyncedAt, _, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("读取上次同步进度失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
if err != nil {
|
||||
report.Err = err
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
report.From, report.To = from, to
|
||||
|
||||
total, err := client.ListTotal(ctx, from, to, pageSize)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("查询货运单总数失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if total > maxMatches {
|
||||
report.Err = fmt.Errorf(
|
||||
"日期范围 %s ~ %s 内有 %d 张货运单,超过单次同步上限 %d,"+
|
||||
"请缩小日期范围或联系维护者调大 max_matches", from, to, total, maxMatches)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if total == 0 {
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// ① 翻页拉全部货运单行。
|
||||
stockByID := map[int64]syb.StockRow{}
|
||||
var orderedIDs []int64
|
||||
for start := 0; start < total; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
rows, err := client.ListPage(ctx, from, to, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("拉取货运单列表第 %d 页失败(已获取 %d/%d 张,本次同步整体作废,"+
|
||||
"下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w",
|
||||
pageIndex, len(orderedIDs), total, err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, dup := stockByID[row.ID]; dup {
|
||||
continue
|
||||
}
|
||||
stockByID[row.ID] = row
|
||||
orderedIDs = append(orderedIDs, row.ID)
|
||||
}
|
||||
}
|
||||
report.StockCount = len(orderedIDs)
|
||||
|
||||
// ② 按 100 个一批取明细,③ 逐张货运单写库。
|
||||
//
|
||||
// `[必须]` 不放在一个大事务里——几千条明细的事务会长时间持锁;
|
||||
// 按货运单为单位提交,失败了已成功的部分保留(下次重拉会 upsert
|
||||
// 覆盖,幂等),见工单 #46。
|
||||
//
|
||||
// `[必须]` 中途失败(拉明细失败,或某张货运单写库失败)**立即停止**、
|
||||
// 不更新 last_synced_at——已经成功写入的部分不回滚(它们本身是幂等
|
||||
// 的),但"这次同步整体算成功"这件事不能发生,否则漏掉的单永远补不回来。
|
||||
const detailBatch = 100
|
||||
for i := 0; i < len(orderedIDs); i += detailBatch {
|
||||
end := i + detailBatch
|
||||
if end > len(orderedIDs) {
|
||||
end = len(orderedIDs)
|
||||
}
|
||||
batch := orderedIDs[i:end]
|
||||
|
||||
details, err := client.DetailListByStock(ctx, batch)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("拉取货运单明细失败(本次同步整体作废,"+
|
||||
"已写入的数据保留,下次重拉会 upsert 覆盖): %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
for _, d := range details {
|
||||
stockRow := stockByID[d.ID]
|
||||
if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil {
|
||||
report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+
|
||||
"已写入的数据保留): %w", d.Code, d.ID, err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 全部成功,才更新 last_synced_at。
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
// writeStockDetail 把一张货运单的全部商品明细写进 syb_orders,
|
||||
// 一张货运单一个事务(工单 #46「按货运单为单位提交」)。
|
||||
func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
|
||||
if len(detail.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||||
|
||||
stockRaw := stripReceiverFields(mergeRaw(stockRow.Raw, detail.Raw))
|
||||
|
||||
for _, item := range detail.Details {
|
||||
sybID := strconv.FormatInt(item.ID, 10)
|
||||
|
||||
if item.ProductQty <= 0 {
|
||||
report.SkippedZero++
|
||||
report.Notes = append(report.Notes, SkipNote{
|
||||
SybID: sybID, Reason: fmt.Sprintf("数量为 %d,跳过(表结构要求 quantity > 0)", item.ProductQty),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
sybData, err := buildSybDataJSON(stockRaw, item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("组装 syb_data 失败: %w", err)
|
||||
}
|
||||
|
||||
order := model.SybOrder{
|
||||
SybID: sybID,
|
||||
OrderNo: detail.Code,
|
||||
Title: item.ProductTitle,
|
||||
ProductSpec: item.ProductSpec,
|
||||
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
|
||||
// `[必须]` 不设置 ShopeeSKUID——顺运宝没有这个值,
|
||||
// repository.UpsertSybOrder 也不会用它覆盖已有的匹配结果。
|
||||
Quantity: item.ProductQty,
|
||||
PriceTwdCent: roundYuanToCent(item.ProductPrice),
|
||||
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
|
||||
SybData: sybData,
|
||||
}
|
||||
|
||||
created, err := repository.UpsertSybOrder(tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
report.Created++
|
||||
} else {
|
||||
report.Updated++
|
||||
}
|
||||
report.DetailCount++
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// mergeRaw 合并"货运单列表"和"货运单明细"两次响应里同一张货运单的
|
||||
// 外层字段(不含 details),后者字段优先覆盖前者——明细响应更贴近
|
||||
// "拉这批数据当下"的状态,见工单 #46 字段映射表 syb_data 的说明。
|
||||
func mergeRaw(list, detail map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(list)+len(detail))
|
||||
for k, v := range list {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range detail {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildSybDataJSON 组装落库的 syb_data:{"stock": ..., "detail": ...}。
|
||||
// 嵌套两个 key 而不是拍平合并,是因为 stock 和 detail 两边都有名叫
|
||||
// "id" 的字段,指的是完全不同的东西(货运单 id vs 明细行 id),
|
||||
// 拍平会互相覆盖、审计时看不出原始结构。
|
||||
func buildSybDataJSON(stockRaw, detailRaw map[string]any) (string, error) {
|
||||
payload := map[string]any{"stock": stockRaw, "detail": detailRaw}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// imageURLFromThumb 拼图片地址:{base_url}/api/p/file?id={productThumb},
|
||||
// 见工单 #46 字段映射表。productThumb 是数字 ID,不是 URL;为 0 时
|
||||
// 说明没有缩略图,返回空字符串(不拼一个指向 id=0 的坏链接)。
|
||||
func imageURLFromThumb(baseURL string, productThumb int64) string {
|
||||
if productThumb == 0 || baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + "/api/p/file?id=" + strconv.FormatInt(productThumb, 10)
|
||||
}
|
||||
|
||||
// ---------- 待登录客户端:验证码和登录共用同一个 Cookie Jar ----------
|
||||
//
|
||||
// `[必须]` 08 §3.2:验证码和登录必须用同一个 syb.Client(同一个 Cookie
|
||||
// Jar),换客户端拿到的验证码就对不上。浏览器"取验证码图片"和"提交
|
||||
// 登录表单"是两次独立的 HTTP 请求,Admin 侧要在这两次请求之间把同一个
|
||||
// 客户端存住——本项目单机单操作员使用,用一个包级变量即可,
|
||||
// 不需要按会话/用户区分。
|
||||
|
||||
var (
|
||||
pendingLoginMu sync.Mutex
|
||||
pendingLoginClient *syb.Client
|
||||
)
|
||||
|
||||
// NewPendingSybLogin 为一次新的"取验证码 → 登录"流程创建客户端,
|
||||
// 并存成"待登录"客户端,丢弃上一个(操作员点"换一张"验证码时,
|
||||
// 上一张验证码本来就废了,不需要保留旧客户端)。
|
||||
func NewPendingSybLogin(baseURL string) (*syb.Client, error) {
|
||||
c, err := syb.New(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pendingLoginMu.Lock()
|
||||
pendingLoginClient = c
|
||||
pendingLoginMu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PendingSybLoginClient 取出当前"待登录"客户端,没有则返回 nil——
|
||||
// 调用方应该提示操作员先获取验证码。
|
||||
func PendingSybLoginClient() *syb.Client {
|
||||
pendingLoginMu.Lock()
|
||||
defer pendingLoginMu.Unlock()
|
||||
return pendingLoginClient
|
||||
}
|
||||
|
||||
// ClearPendingSybLogin 清掉"待登录"客户端(登录成功或放弃时调用)。
|
||||
func ClearPendingSybLogin() {
|
||||
pendingLoginMu.Lock()
|
||||
pendingLoginClient = nil
|
||||
pendingLoginMu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- 会话有效性 ----------
|
||||
|
||||
// ErrSybLoginRequired 表示当前没有可用的顺运宝登录会话,
|
||||
// 页面应该弹登录框,而不是报错。
|
||||
var ErrSybLoginRequired = errors.New("顺运宝会话不存在或已过期,请重新登录")
|
||||
|
||||
// EnsureSybSession 检查本地缓存的顺运宝会话是否足够新鲜,够就把 Cookie
|
||||
// 恢复进传入的 client;不够就返回 ErrSybLoginRequired(`errors.Is` 判断),
|
||||
// 提示调用方走登录流程。
|
||||
//
|
||||
// `[决定]` 这里只做**本地**过期时间判断,不额外发一次
|
||||
// GET /am/user/get 去问服务端"你还活着吗"——08 §3.5 描述的"网络故障不能
|
||||
// 判定未登录"这条规则,在同步真正发起后、遇到任何一次 syb.ErrSessionInvalid
|
||||
// 时同样会触发(syb.Client.do() 对所有 /am/** 接口都做了同一套分类),
|
||||
// 不需要在这里再打一次专门的探测请求——省掉一次没有必要的网络往返,
|
||||
// 也避免"探测请求本身超时"这种情况被误判成"未登录"。
|
||||
func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.Time) error {
|
||||
cached, err := repository.GetSybSession(db, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取顺运宝会话缓存失败: %w", err)
|
||||
}
|
||||
if cached == nil {
|
||||
return ErrSybLoginRequired
|
||||
}
|
||||
expiresAt, ok := model.ParseISO(cached.ExpiresAt)
|
||||
if !ok || !now.Before(expiresAt) {
|
||||
return ErrSybLoginRequired
|
||||
}
|
||||
if err := client.ImportCookiesJSON(cached.Cookies); err != nil {
|
||||
return fmt.Errorf("恢复顺运宝会话失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 列表页 ----------
|
||||
|
||||
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
|
||||
// 模板里不做判断和格式化,和其余四个模块的做法一致。
|
||||
type SybOrderView struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
MatchText string // "已匹配" / "待匹配"
|
||||
Matched bool
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// SybListResult 是列表页要的全部数据。
|
||||
type SybListResult struct {
|
||||
Rows []SybOrderView
|
||||
Total int
|
||||
HasAny bool
|
||||
IsFiltered bool
|
||||
Page int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
|
||||
//
|
||||
// `[必须]` 匹配状态是**算出来的**(shopee_sku_id 是否非空),
|
||||
// 不是存的字段,见工单 #46——本工单不做规格匹配,这里只负责如实
|
||||
// 显示"有没有"。
|
||||
func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, error) {
|
||||
filter := repository.SybOrderFilter{Keyword: keyword}
|
||||
total, err := repository.CountSybOrders(db, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := TotalPages(total)
|
||||
page = ClampPage(page, totalPages)
|
||||
offset := (page - 1) * PageSize
|
||||
|
||||
rows, err := repository.ListSybOrders(db, filter, PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasAny, err := repository.CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SybListResult{
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(keyword) != "",
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for _, o := range rows {
|
||||
v := SybOrderView{
|
||||
SybID: o.SybID,
|
||||
OrderNo: o.OrderNo,
|
||||
Title: o.Title,
|
||||
ProductSpec: o.ProductSpec,
|
||||
ShopeeGoodsID: o.ShopeeGoodsID,
|
||||
ShopeeSKUID: o.ShopeeSKUID,
|
||||
Quantity: o.Quantity,
|
||||
ImageURL: o.ImageURL,
|
||||
UpdatedAt: formatLocalTime(o.UpdatedAt),
|
||||
}
|
||||
if o.PriceTwdCent > 0 {
|
||||
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
|
||||
} else {
|
||||
v.PriceText = placeholder
|
||||
}
|
||||
if o.Title == "" {
|
||||
v.Title = placeholder
|
||||
}
|
||||
if o.ProductSpec == "" {
|
||||
v.ProductSpec = placeholder
|
||||
}
|
||||
if o.ShopeeSKUID != "" {
|
||||
v.Matched = true
|
||||
v.MatchText = "已匹配"
|
||||
} else {
|
||||
v.MatchText = "待匹配"
|
||||
}
|
||||
result.Rows = append(result.Rows, v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSybLoginSession 登录成功后把会话缓存进数据库。
|
||||
//
|
||||
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样
|
||||
// 返回,由调用方决定"写失败了但登录已经成功,要不要继续走后面的同步",
|
||||
// 不在这里吞掉错误也不在这里替调用方做决定。
|
||||
func SaveSybLoginSession(db *sql.DB, client *syb.Client, username string, expiresAt time.Time) error {
|
||||
cookiesJSON, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出顺运宝会话 Cookie 失败: %w", err)
|
||||
}
|
||||
return repository.SaveSybSession(db, username, cookiesJSON, expiresAt.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
// ── 增量边界:本工单最容易写错的地方 ─────────────────────
|
||||
|
||||
func TestSyncDateRange_首次同步用配置的SyncFrom(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 15, 0, 0, 0, time.UTC)
|
||||
from, to, err := syncDateRange("", "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if from != "2026-07-01" {
|
||||
t.Errorf("首次同步应该用 sync_from,实际 from=%q", from)
|
||||
}
|
||||
if to != "2026-08-09" {
|
||||
t.Errorf("结束日期应该是今天,实际 to=%q", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_首次同步且未配置SyncFrom时报错(t *testing.T) {
|
||||
_, _, err := syncDateRange("", "", time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("从未同步过又没配置 sync_from 时应该报错,而不是拿一个空日期硬拉")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_增量从上次同步日期当天重拉不是第二天(t *testing.T) {
|
||||
// `[必须]` 这是工单 #46 明确点名"最容易写错的地方":
|
||||
// 上次同步 2026-08-09 14:30,下次必须还从 2026-08-09 开始拉,
|
||||
// 不能从 2026-08-10 开始——否则会漏掉 8-09 14:30 之后创建的单,
|
||||
// 而且不会报错,没人会发现。
|
||||
lastSyncedAt := "2026-08-09T14:30:00Z"
|
||||
now := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
from, to, err := syncDateRange(lastSyncedAt, "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if from != "2026-08-09" {
|
||||
t.Fatalf("增量同步应该从上次同步的当天(2026-08-09)重新拉,实际 from=%q"+
|
||||
"——如果这里算成了 2026-08-10,就是漏单且不报错的那个坑", from)
|
||||
}
|
||||
if to != "2026-08-10" {
|
||||
t.Errorf("结束日期应该是 now 对应的日期,实际 to=%q", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_上次同步时间格式错误时报错(t *testing.T) {
|
||||
_, _, err := syncDateRange("不是一个合法的时间", "2026-07-01", time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("last_synced_at 解析失败时应该报错,不能悄悄退化成一个随便的日期")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UTC(t *testing.T) {
|
||||
// `[必须]` 顺运宝 created 是 UTC+8(08 §5.2 有 HAR 实测证据)。
|
||||
// now = 2026-08-09T23:00:00Z,也就是本地(UTC+8)2026-08-10 07:00——
|
||||
// 如果日期运算错误地用了 UTC,会把"今天"算成 2026-08-09,
|
||||
// 当天早晨(UTC+8)创建的单这一轮就拉不到,操作员会以为同步坏了。
|
||||
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC)
|
||||
|
||||
_, to, err := syncDateRange("", "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if to != "2026-08-10" {
|
||||
t.Fatalf("结束日期应该按顺运宝服务端时区(UTC+8)算成 2026-08-10,实际 to=%q"+
|
||||
"——如果这里算成了 2026-08-09,就是用错了 UTC 而不是 UTC+8", to)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 金额换算 ──────────────────────────────────────────
|
||||
|
||||
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
|
||||
cases := []struct {
|
||||
yuan float64
|
||||
want int64
|
||||
}{
|
||||
{239.0, 23900},
|
||||
{612.5, 61250},
|
||||
{5.05, 505}, // 08 §5.1 明确点名的样本
|
||||
{0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := roundYuanToCent(c.yuan)
|
||||
if got != c.want {
|
||||
t.Errorf("roundYuanToCent(%v) = %d,期望 %d", c.yuan, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 端到端:httptest 假服务端 ─────────────────────────────
|
||||
//
|
||||
// `[必须]` 绝不能打真实的 shunyunbaoerp.com,见工单 #46。
|
||||
|
||||
// fakeDetail 是假服务端里一条货运单明细行的最小描述。
|
||||
type fakeDetail struct {
|
||||
ID int64
|
||||
ProductID int64
|
||||
ProductTitle string
|
||||
ProductSpec string
|
||||
ProductQty int
|
||||
ProductPrice float64
|
||||
ProductThumb int64
|
||||
}
|
||||
|
||||
// fakeStock 是假服务端里一张货运单。
|
||||
type fakeStock struct {
|
||||
ID int64
|
||||
Code string
|
||||
ShopName string
|
||||
Receiver string // 用来验证个人信息确实没有落库
|
||||
Details []fakeDetail
|
||||
}
|
||||
|
||||
// fakeSybServer 起一个 httptest 假服务端,模拟 listTotal / list /
|
||||
// detail/listByStock 三个接口,数据来自内存里的 stocks 切片。
|
||||
//
|
||||
// failListPageIndex:如果 > 0,/am/stock/list 请求到这一页时返回失败,
|
||||
// 用来测"中途失败不更新 last_synced_at"。
|
||||
func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *httptest.Server {
|
||||
t.Helper()
|
||||
byID := map[int64]fakeStock{}
|
||||
for _, s := range stocks {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/am/stock/listTotal":
|
||||
writeEnvelope(t, w, true, "ok", len(stocks), nil)
|
||||
|
||||
case "/am/stock/list":
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
pageIndex := int(body["pageIndex"].(float64))
|
||||
length := int(body["length"].(float64))
|
||||
start := int(body["start"].(float64))
|
||||
|
||||
if failListPageIndex > 0 && pageIndex == failListPageIndex {
|
||||
writeEnvelope(t, w, false, "模拟的服务端故障", nil, "500")
|
||||
return
|
||||
}
|
||||
|
||||
end := start + length
|
||||
if end > len(stocks) {
|
||||
end = len(stocks)
|
||||
}
|
||||
var list []map[string]any
|
||||
if start < len(stocks) {
|
||||
for _, s := range stocks[start:end] {
|
||||
list = append(list, map[string]any{
|
||||
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
|
||||
"receiver": s.Receiver, "orderStatus": "待出货",
|
||||
})
|
||||
}
|
||||
}
|
||||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
|
||||
|
||||
case "/am/stock/detail/listByStock":
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
var list []map[string]any
|
||||
for _, id := range body.IDs {
|
||||
s, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var details []map[string]any
|
||||
for _, d := range s.Details {
|
||||
details = append(details, map[string]any{
|
||||
"id": d.ID, "productId": d.ProductID, "productTitle": d.ProductTitle,
|
||||
"productSpec": d.ProductSpec, "productQty": d.ProductQty,
|
||||
"productPrice": d.ProductPrice, "productThumb": d.ProductThumb,
|
||||
})
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
|
||||
"receiver": s.Receiver, "details": details,
|
||||
})
|
||||
}
|
||||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
|
||||
|
||||
default:
|
||||
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string, data any, code any) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]any{"status": status, "msg": msg, "data": data, "code": code})
|
||||
if err != nil {
|
||||
t.Fatalf("构造响应失败: %v", err)
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func newSyncTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := repository.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
|
||||
// `[必须]` 端到端版本:整条 RunSybSync 流程跑完,人工匹配的
|
||||
// shopee_sku_id 必须还在——这是工单 #46 唯一"错了要几周后才发现"的点。
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{
|
||||
ID: 75104587, Code: "260728TB95MJTQ", ShopName: "测试店铺", Receiver: "张三",
|
||||
Details: []fakeDetail{
|
||||
{ID: 145306175, ProductID: 50209124255, ProductTitle: "蕾絲花邊拼接背心女",
|
||||
ProductSpec: "白色,L【建議50-60公斤】", ProductQty: 1, ProductPrice: 239.0, ProductThumb: 190639637},
|
||||
},
|
||||
},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, err := syb.New(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("创建客户端失败: %v", err)
|
||||
}
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report1 := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report1.Err != nil {
|
||||
t.Fatalf("首次同步失败: %v", report1.Err)
|
||||
}
|
||||
if report1.Created != 1 {
|
||||
t.Fatalf("首次同步应该新增 1 条,实际 Created=%d", report1.Created)
|
||||
}
|
||||
|
||||
// 操作员在界面上完成规格匹配,手工写入 shopee_sku_id。
|
||||
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
|
||||
"MANUAL-MATCHED-SKU", "145306175"); err != nil {
|
||||
t.Fatalf("模拟人工匹配失败: %v", err)
|
||||
}
|
||||
|
||||
// 再同步一次(模拟顺运宝那边这张单信息有更新)。
|
||||
report2 := RunSybSync(context.Background(), db, client, cfg, now.Add(time.Hour))
|
||||
if report2.Err != nil {
|
||||
t.Fatalf("二次同步失败: %v", report2.Err)
|
||||
}
|
||||
if report2.Updated != 1 {
|
||||
t.Fatalf("二次同步应该是更新,实际 Updated=%d Created=%d", report2.Updated, report2.Created)
|
||||
}
|
||||
|
||||
var skuID string
|
||||
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&skuID); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if skuID != "MANUAL-MATCHED-SKU" {
|
||||
t.Fatalf("同步后 shopee_sku_id 应该还是 MANUAL-MATCHED-SKU,实际 %q"+
|
||||
"——人工匹配成果被顺运宝同步洗掉了", skuID)
|
||||
}
|
||||
|
||||
// 收件人信息不应该出现在 syb_data 里。
|
||||
var sybData string
|
||||
if err := db.QueryRow(`SELECT syb_data FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&sybData); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if strings.Contains(sybData, "张三") {
|
||||
t.Errorf("syb_data 不应该包含收件人姓名,实际: %s", sybData)
|
||||
}
|
||||
|
||||
// 价格换算:239.0 元 -> 23900 分。
|
||||
var priceCent int64
|
||||
if err := db.QueryRow(`SELECT price_twd_cent FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&priceCent); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if priceCent != 23900 {
|
||||
t.Errorf("price_twd_cent 应该是 23900,实际 %d", priceCent)
|
||||
}
|
||||
|
||||
// image_url 拼接。
|
||||
var imageURL string
|
||||
if err := db.QueryRow(`SELECT image_url FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&imageURL); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
wantImage := srv.URL + "/api/p/file?id=190639637"
|
||||
if imageURL != wantImage {
|
||||
t.Errorf("image_url 应该是 %q,实际 %q", wantImage, imageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_中途失败不更新last_synced_at(t *testing.T) {
|
||||
// 两页数据,pageSize=1,第二页请求失败——模拟"拉到一半服务端出错"。
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
|
||||
{ID: 2, Code: "B", Details: []fakeDetail{{ID: 2, ProductID: 222, ProductQty: 1, ProductPrice: 1}}},
|
||||
}, 2) // 第 2 页失败
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 1, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err == nil {
|
||||
t.Fatal("第二页失败时同步应该报错")
|
||||
}
|
||||
|
||||
_, found, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("查询同步进度失败: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("中途失败不应该更新 last_synced_at——更新了的话下次同步会跳过这段区间," +
|
||||
"漏掉的单永远补不回来")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
|
||||
at, found, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("查询同步进度失败: %v", err)
|
||||
}
|
||||
if !found || at == "" {
|
||||
t.Fatal("全部成功后应该更新 last_synced_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10},
|
||||
{ID: 2, ProductID: 222, ProductQty: 0, ProductPrice: 10}, // 应该被跳过
|
||||
{ID: 3, ProductID: 333, ProductQty: -1, ProductPrice: 10}, // 应该被跳过
|
||||
}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
if report.SkippedZero != 2 {
|
||||
t.Fatalf("应该跳过 2 条 quantity<=0 的明细,实际 SkippedZero=%d", report.SkippedZero)
|
||||
}
|
||||
if len(report.Notes) != 2 {
|
||||
t.Fatalf("跳过的明细应该在报告里列出来,实际 Notes=%v", report.Notes)
|
||||
}
|
||||
if report.Created != 1 {
|
||||
t.Fatalf("只有 1 条应该真正写库,实际 Created=%d", report.Created)
|
||||
}
|
||||
|
||||
n, err := repository.CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("库里应该只有 1 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_超过最大条数报错不硬拉(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A"}, {ID: 2, Code: "B"}, {ID: 3, Code: "C"},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 2, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err == nil {
|
||||
t.Fatal("总数 3 超过 max_matches=2 时应该报错")
|
||||
}
|
||||
if !strings.Contains(report.Err.Error(), "缩小") {
|
||||
t.Errorf("错误信息应该提示缩小日期范围,实际: %v", report.Err)
|
||||
}
|
||||
|
||||
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
|
||||
t.Error("超限报错不应该更新 last_synced_at")
|
||||
}
|
||||
n, _ := repository.CountSybOrdersTotal(db)
|
||||
if n != 0 {
|
||||
t.Errorf("超限报错不应该写入任何数据,实际写了 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_一张货运单多个商品各落一行(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10, ProductSpec: "白色,L"},
|
||||
{ID: 2, ProductID: 222, ProductQty: 2, ProductPrice: 20, ProductSpec: "黑色,M"},
|
||||
}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
if report.Created != 2 {
|
||||
t.Fatalf("一张货运单两个商品应该各落一行,实际 Created=%d", report.Created)
|
||||
}
|
||||
|
||||
n, _ := repository.CountSybOrdersTotal(db)
|
||||
if n != 2 {
|
||||
t.Fatalf("库里应该有 2 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 同步互斥标志 ──────────────────────────────────────
|
||||
|
||||
func TestSybSyncMutex_同一时间只允许一个同步(t *testing.T) {
|
||||
t.Cleanup(func() { FinishSybSync(SyncReport{}) })
|
||||
|
||||
if !TryStartSybSync() {
|
||||
t.Fatal("第一次应该能拿到互斥标志")
|
||||
}
|
||||
if TryStartSybSync() {
|
||||
t.Fatal("同步进行中时,第二次不应该能拿到互斥标志")
|
||||
}
|
||||
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
|
||||
if !TryStartSybSync() {
|
||||
t.Fatal("上一次同步结束后应该能重新拿到互斥标志")
|
||||
}
|
||||
|
||||
status := GetSybSyncStatus()
|
||||
if !status.Running {
|
||||
t.Error("刚拿到互斥标志后 Running 应该是 true")
|
||||
}
|
||||
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
|
||||
status = GetSybSyncStatus()
|
||||
if status.Running {
|
||||
t.Error("FinishSybSync 之后 Running 应该是 false")
|
||||
}
|
||||
if status.Report == nil || status.Report.From != "2026-08-09" {
|
||||
t.Errorf("应该能读到最近一次的报告,实际: %+v", status.Report)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话有效性判断 ────────────────────────────────────
|
||||
|
||||
func TestEnsureSybSession_没有缓存时要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
err := EnsureSybSession(db, client, "tester", time.Now())
|
||||
if err != ErrSybLoginRequired {
|
||||
t.Fatalf("没有缓存的会话时应该返回 ErrSybLoginRequired,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSybSession_已过期时要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := repository.SaveSybSession(db, "tester", `[]`, "2026-08-01T00:00:00Z"); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
err := EnsureSybSession(db, client, "tester", now)
|
||||
if err != ErrSybLoginRequired {
|
||||
t.Fatalf("过期会话应该返回 ErrSybLoginRequired,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSybSession_未过期时恢复Cookie不要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
cookiesJSON := `[{"name":"erp_session","value":"abc"}]`
|
||||
if err := repository.SaveSybSession(db, "tester", cookiesJSON, "2026-08-10T00:00:00Z"); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := EnsureSybSession(db, client, "tester", now); err != nil {
|
||||
t.Fatalf("未过期的会话不应该要求重新登录: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSybLoginSession_写库和读回(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
|
||||
expiresAt := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
|
||||
if err := SaveSybLoginSession(db, client, "tester", expiresAt); err != nil {
|
||||
t.Fatalf("保存登录会话失败: %v", err)
|
||||
}
|
||||
|
||||
cached, err := repository.GetSybSession(db, "tester")
|
||||
if err != nil || cached == nil {
|
||||
t.Fatalf("应该能读到刚保存的会话: cached=%v err=%v", cached, err)
|
||||
}
|
||||
got, ok := model.ParseISO(cached.ExpiresAt)
|
||||
if !ok || !got.Equal(expiresAt) {
|
||||
t.Errorf("expires_at 应该是 %v,实际 %v(parsed=%v)", expiresAt, cached.ExpiresAt, got)
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,25 @@ select {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 顺运宝登录弹窗:验证码图片、表格缩略图 ─────── */
|
||||
/* 工单 #46。缩略图故意限制到很小的尺寸——表格一行放不下大图,
|
||||
要看清楚还是得点开顺运宝或蝦皮后台自己的图。 */
|
||||
.captcha-img {
|
||||
display: block;
|
||||
height: 40px;
|
||||
border: 1px solid #ccd1d6;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── 错误页 ───────────────────────────── */
|
||||
.error-box {
|
||||
background: #fff;
|
||||
|
||||
+22
-3
@@ -164,17 +164,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 顺运宝登录弹窗:验证码"换一张" ─────────────
|
||||
图片本身、以及旁边的"换一张"按钮,点了都重新请求验证码接口。
|
||||
`[必须]` 每次请求带一个新的时间戳查询参数,绕开浏览器缓存——
|
||||
否则点了"换一张"看到的还是同一张图(工单 #46)。 */
|
||||
function setupCaptchaRefresh() {
|
||||
var img = document.getElementById("login-captcha-img");
|
||||
if (!img) return;
|
||||
var base = img.getAttribute("data-captcha-refresh") || img.src;
|
||||
|
||||
function refresh() {
|
||||
img.src = base + "?_=" + Date.now();
|
||||
}
|
||||
|
||||
img.addEventListener("click", refresh);
|
||||
var btn = document.getElementById("login-captcha-refresh");
|
||||
if (btn) btn.addEventListener("click", refresh);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("table").forEach(setupCheckAll);
|
||||
setupConfirmDelete();
|
||||
setupModals();
|
||||
setupRowDetail();
|
||||
setupCaptchaRefresh();
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
/* TODO(骨架): 另外两个页面的弹窗,做法照 PDD 商品页抄:
|
||||
蝦皮数据页 -> 编辑弹窗(填 PDD 链接)
|
||||
顺运宝页 -> 规格匹配弹窗
|
||||
/* TODO(骨架): 蝦皮数据页 -> 编辑弹窗(填 PDD 链接)。
|
||||
顺运宝页的登录弹窗已经实现(setupCaptchaRefresh + 通用 setupModals);
|
||||
规格匹配弹窗仍是后续工单的范围。
|
||||
页面里放一个 id="detail-modal" 的壳子、行上写 data-detail-id,
|
||||
服务端出一个返回片段的 /<模块>/detail,这里就不用再加代码了。 */
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
// Package syb 是顺运宝 ERP 的 HTTP 客户端:验证码、登录、货运单列表、
|
||||
// 货运单明细。
|
||||
//
|
||||
// 接口契约来自 docs/admin/08-顺运宝接口.md(从抓包还原),登录/查询流程
|
||||
// 照 raw_data/shunyunbaoerp_single.py 抄,但**存储换 SQLite、不用 Redis、
|
||||
// 不用 OCR**——那两样是那个一次性脚本的需要,Admin 是常驻进程,见 08 §8。
|
||||
//
|
||||
// `[必须]` 本包只负责"怎么跟顺运宝的 HTTP 接口打交道",不碰数据库、
|
||||
// 不认识 *gin.Context。编排(算日期范围、字段映射、落库)在
|
||||
// admin/service/syb.go。
|
||||
package syb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
||||
// (docs/admin/08-顺运宝接口.md §3.5)。调用方看到这个错误应该清掉
|
||||
// 本地缓存的会话、提示操作员重新登录。
|
||||
//
|
||||
// `[必须]` 只有能明确判定"未登录"的情况才包成这个:HTTP 401/403,
|
||||
// 或响应体 status=false 且 msg 含"未登录"/"登录过期",或 code 是 -2。
|
||||
// **超时、5xx、JSON 格式错误一律是别的错误类型**,不能包成这个——
|
||||
// 网络抖一下就判定登出会触发不必要的重新登录、验证码弹个不停,
|
||||
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
|
||||
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
|
||||
|
||||
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
|
||||
// http.Client(同一个 Cookie Jar)。
|
||||
//
|
||||
// `[必须]` 08 §3.2:验证码和登录必须用**同一个** Cookie Jar,
|
||||
// 换客户端拿到的验证码就对不上——所以本包不提供"每次请求新建一个
|
||||
// Client"的用法,调用方要在整个"取验证码 → 登录"流程里复用同一个实例。
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
jar *cookiejar.Jar
|
||||
}
|
||||
|
||||
// New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。
|
||||
func New(baseURL string) (*Client, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("顺运宝 base_url 不能为空")
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建顺运宝客户端的 Cookie Jar 失败: %w", err)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
jar: jar,
|
||||
http: &http.Client{
|
||||
Jar: jar,
|
||||
// 5 秒连接 + 30 秒读取是示例脚本用的值(见 raw_data/shunyunbaoerp_single.py),
|
||||
// 这里简化成一个总超时,量级一致。
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 会话持久化:Cookie 导入/导出 ----------
|
||||
|
||||
// cookieDTO 是缓存进 syb_session.cookies 的单个 Cookie 的 JSON 形状。
|
||||
type cookieDTO struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// ExportCookiesJSON 把当前 Cookie Jar 里属于 base_url 的 Cookie
|
||||
// 导出成 JSON 数组文本,供 repository.SaveSybSession 存进去。
|
||||
func (c *Client) ExportCookiesJSON() (string, error) {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 base_url 失败: %w", err)
|
||||
}
|
||||
cookies := c.jar.Cookies(u)
|
||||
dtos := make([]cookieDTO, 0, len(cookies))
|
||||
for _, ck := range cookies {
|
||||
dtos = append(dtos, cookieDTO{Name: ck.Name, Value: ck.Value, Path: ck.Path})
|
||||
}
|
||||
b, err := json.Marshal(dtos)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 Cookie 失败: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ImportCookiesJSON 把缓存的 Cookie JSON 恢复进当前 Cookie Jar,
|
||||
// 恢复登录会话时用(重启 Admin 后免登录)。
|
||||
func (c *Client) ImportCookiesJSON(cookiesJSON string) error {
|
||||
var dtos []cookieDTO
|
||||
if err := json.Unmarshal([]byte(cookiesJSON), &dtos); err != nil {
|
||||
return fmt.Errorf("解析缓存的顺运宝 Cookie 失败: %w", err)
|
||||
}
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 base_url 失败: %w", err)
|
||||
}
|
||||
cookies := make([]*http.Cookie, 0, len(dtos))
|
||||
for _, d := range dtos {
|
||||
if d.Name == "" {
|
||||
continue
|
||||
}
|
||||
path := d.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
cookies = append(cookies, &http.Cookie{Name: d.Name, Value: d.Value, Path: path})
|
||||
}
|
||||
c.jar.SetCookies(u, cookies)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 统一响应信封 ----------
|
||||
|
||||
// envelope 是 /am/** 接口统一的响应形状,见 08 §2:
|
||||
//
|
||||
// { "status": true, "msg": "获取成功", "data": <任意>, "code": null }
|
||||
//
|
||||
// `[必须]` 判断成功只看 status === true,不看 HTTP 状态码——服务端
|
||||
// 业务失败时也可能返回 200。data 可能是对象/数组/裸整数,所以用
|
||||
// json.RawMessage 延后解析,各接口自己按预期形状再反序列化一次。
|
||||
type envelope struct {
|
||||
Status bool `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
}
|
||||
|
||||
// do 发一个请求并取出统一响应信封里的 data,同时按 §3.5 的规则
|
||||
// 把"明确未登录"和"别的错误"分开。
|
||||
//
|
||||
// `[必须]` 这一个函数是本包**唯一**发起 HTTP 请求、判断错误类型的地方;
|
||||
// 登录、会话校验、货运单列表/明细全部走它,好处是"网络故障不能判定
|
||||
// 未登录"这条规则只需要写一遍、测一遍,不会在多个接口各写一份、
|
||||
// 早晚有一处漏判。
|
||||
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) (json.RawMessage, error) {
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造请求体失败: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造顺运宝请求 %s 失败: %w", path, err)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
|
||||
// 见 ErrSessionInvalid 的注释和 08 §3.5。
|
||||
return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
|
||||
}
|
||||
if resp.StatusCode >= http.StatusInternalServerError {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)
|
||||
}
|
||||
if !env.Status {
|
||||
if isSessionInvalidMessage(env.Msg, env.Code) {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s: %s: %w", path, env.Msg, ErrSessionInvalid)
|
||||
}
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 业务失败: msg=%s code=%s",
|
||||
path, orDefault(env.Msg, "(无)"), string(env.Code))
|
||||
}
|
||||
return env.Data, nil
|
||||
}
|
||||
|
||||
// isSessionInvalidMessage 判断业务失败信息是不是"明确未登录",
|
||||
// 规则见 08 §3.5:msg 含"未登录"/"登录过期",或 code 是 -2
|
||||
// (数字或字符串两种写法都算,服务端返回哪种没有实测确认过)。
|
||||
func isSessionInvalidMessage(msg string, code json.RawMessage) bool {
|
||||
if strings.Contains(msg, "未登录") || strings.Contains(msg, "登录过期") {
|
||||
return true
|
||||
}
|
||||
c := strings.TrimSpace(string(code))
|
||||
return c == "-2" || c == `"-2"`
|
||||
}
|
||||
|
||||
func orDefault(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- 验证码 ----------
|
||||
|
||||
// Captcha 是一张验证码图片。
|
||||
type Captcha struct {
|
||||
Image []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// FetchCaptcha 取一张新的验证码图片:GET /api/p/code1?<毫秒时间戳>。
|
||||
//
|
||||
// `[必须]` 时间戳参数每次都要换,是为了绕开浏览器/中间层缓存,见 08 §3.2。
|
||||
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
|
||||
u := fmt.Sprintf("%s/api/p/code1?%d", c.baseURL, time.Now().UnixMilli())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造验证码请求失败: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取顺运宝验证码失败(网络问题): %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("获取顺运宝验证码失败,HTTP 状态码 %d", resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "image/") {
|
||||
return nil, fmt.Errorf("验证码接口没有返回图片,Content-Type=%q", ct)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取验证码图片失败: %w", err)
|
||||
}
|
||||
return &Captcha{Image: data, ContentType: ct}, nil
|
||||
}
|
||||
|
||||
// ---------- 登录 ----------
|
||||
|
||||
// LoginUser 是登录响应里的用户信息。
|
||||
type LoginUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
// LoginResult 是登录成功后要缓存的信息。
|
||||
type LoginResult struct {
|
||||
User LoginUser
|
||||
// ExpiresAt 是 min(JWT exp, 从现在起 24 小时)——08 §3.3/§3.4:
|
||||
// 会话有效期正好 24 小时,缓存不能活得比会话长。
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Login 提交用户名、密码、验证码登录。
|
||||
//
|
||||
// `[必须]` 密码只出现在请求体(走 HTTPS)。本函数、以及它调用的 do(),
|
||||
// 产生的任何错误信息都不包含密码——错误信息只带 path/msg/code,
|
||||
// 不回显请求体,防止密码进日志(admin/AGENTS.md「日志不得出现密码」)。
|
||||
func (c *Client) Login(ctx context.Context, username, password, code string) (*LoginResult, error) {
|
||||
if username == "" || password == "" || code == "" {
|
||||
return nil, fmt.Errorf("用户名、密码、验证码均不能为空")
|
||||
}
|
||||
data, err := c.do(ctx, http.MethodPost, "/am/auth/login", nil, map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"code": code,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, fmt.Errorf("登录响应格式错误: %w", err)
|
||||
}
|
||||
if payload.User.ID == 0 || payload.User.Username == "" {
|
||||
return nil, fmt.Errorf("登录响应中缺少 user 信息")
|
||||
}
|
||||
|
||||
// `[必须]` 缓存有效期取 min(JWT 剩余, 24h)——08 §3.3。
|
||||
// 解析不出 JWT(或它不含 exp)时退化成"从现在起 24 小时",
|
||||
// 不因为解析失败就直接报错——登录本身已经成功了。
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
if exp, ok := jwtExpiry(payload.Token); ok && exp.Before(expiresAt) {
|
||||
expiresAt = exp
|
||||
}
|
||||
|
||||
return &LoginResult{
|
||||
User: LoginUser{ID: payload.User.ID, Username: payload.User.Username},
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// jwtExpiry 从 JWT 的 payload 段解析 exp(Unix 秒)。
|
||||
// 解析不了返回 (零值, false),不 panic、不报错——调用方负责兜底。
|
||||
func jwtExpiry(token string) (time.Time, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
payload := parts[1]
|
||||
if m := len(payload) % 4; m != 0 {
|
||||
payload += strings.Repeat("=", 4-m)
|
||||
}
|
||||
raw, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
var claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(claims.Exp, 0), true
|
||||
}
|
||||
|
||||
// CheckSession 用 GET /am/user/get?id=<userID> 校验当前 Cookie 代表的
|
||||
// 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5:
|
||||
// 不一致说明串号了,同样按未登录处理)。
|
||||
//
|
||||
// 返回 nil 表示会话有效;返回 ErrSessionInvalid(可用 errors.Is 判断)
|
||||
// 表示明确未登录;返回其它错误表示网络/格式问题,**不代表未登录**。
|
||||
func (c *Client) CheckSession(ctx context.Context, userID int64, username string) error {
|
||||
q := url.Values{"id": {strconv.FormatInt(userID, 10)}}
|
||||
data, err := c.do(ctx, http.MethodGet, "/am/user/get", q, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var got struct {
|
||||
ID any `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
return fmt.Errorf("会话校验响应格式错误: %w", err)
|
||||
}
|
||||
if got.Username == "" {
|
||||
return fmt.Errorf("会话校验响应缺少 id/username")
|
||||
}
|
||||
gotID := fmt.Sprintf("%v", got.ID)
|
||||
if gotID != strconv.FormatInt(userID, 10) || got.Username != username {
|
||||
return ErrSessionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 货运单列表 ----------
|
||||
|
||||
// listPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体,
|
||||
// 见 08 §4.1/§4.2:按日期范围查询(同步用这个),dvalue 是
|
||||
// "起始日期,结束日期",YYYY-MM-DD,逗号分隔。
|
||||
func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[string]any {
|
||||
return map[string]any{
|
||||
"history": 0,
|
||||
"length": pageSize,
|
||||
"start": start,
|
||||
"pageTotal": 0,
|
||||
"pageIndex": pageIndex,
|
||||
"store": false,
|
||||
"columns": columnsPayload(),
|
||||
"queries": []map[string]any{
|
||||
{
|
||||
"dvalue": dateFrom + "," + dateTo,
|
||||
"tableName": "t_stock",
|
||||
"colName": "created",
|
||||
"op": 0,
|
||||
"type": 3,
|
||||
"tableAlias": "t",
|
||||
"optType": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。
|
||||
// `[必须]` data 是裸整数,不是对象,见 08 §2。
|
||||
func (c *Client) ListTotal(ctx context.Context, dateFrom, dateTo string, pageSize int) (int, error) {
|
||||
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
|
||||
listPayload(dateFrom, dateTo, 0, 1, pageSize))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int
|
||||
if err := json.Unmarshal(data, &total); err != nil {
|
||||
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// StockRow 是货运单列表里的一行。Raw 保留完整原始字段,
|
||||
// 供 service 层落库 syb_data 时和明细合并。
|
||||
type StockRow struct {
|
||||
ID int64
|
||||
Code string
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// ListPage 按日期范围翻一页货运单列表:POST /am/stock/list。
|
||||
func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, pageIndex, pageSize int) ([]StockRow, error) {
|
||||
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
|
||||
listPayload(dateFrom, dateTo, start, pageIndex, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
List []map[string]any `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("货运单列表响应格式错误: %w", err)
|
||||
}
|
||||
|
||||
rows := make([]StockRow, 0, len(wrap.List))
|
||||
for _, raw := range wrap.List {
|
||||
id, ok := toInt64(raw["id"])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("货运单列表里有一行缺少合法的 id: %v", raw)
|
||||
}
|
||||
code, _ := raw["code"].(string)
|
||||
rows = append(rows, StockRow{ID: id, Code: code, Raw: raw})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ---------- 货运单明细 ----------
|
||||
|
||||
// DetailItem 是货运单明细里的一个商品(t_stock.details[] 的一项),
|
||||
// 见 08 §6.1。
|
||||
type DetailItem struct {
|
||||
ID int64
|
||||
ProductID int64
|
||||
ProductTitle string
|
||||
ProductSpec string
|
||||
ProductQty int
|
||||
ProductPrice float64 // 元,`[必须]` 不是分,见 08 §5.1
|
||||
ProductThumb int64
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// StockDetail 是一张货运单的明细,一张货运单可以有多个商品(Details)。
|
||||
type StockDetail struct {
|
||||
ID int64
|
||||
Code string
|
||||
Details []DetailItem
|
||||
// Raw 是外层字段(不含 details),落库 syb_data 时和 StockRow.Raw 合并。
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// DetailListByStock 按货运单 id 批量取明细:
|
||||
// POST /am/stock/detail/listByStock?hist=0
|
||||
//
|
||||
// `[必须]` 一次最多传 100 个 id,超了由调用方分批,见 08 §6。
|
||||
func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDetail, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(ids) > 100 {
|
||||
return nil, fmt.Errorf("单批查询明细最多 100 个 id,实际传了 %d 个,请分批调用", len(ids))
|
||||
}
|
||||
|
||||
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/listByStock",
|
||||
url.Values{"hist": {"0"}}, map[string]any{"ids": ids})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
List []map[string]any `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("货运单明细响应格式错误: %w", err)
|
||||
}
|
||||
|
||||
out := make([]StockDetail, 0, len(wrap.List))
|
||||
for _, raw := range wrap.List {
|
||||
id, ok := toInt64(raw["id"])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id: %v", raw)
|
||||
}
|
||||
code, _ := raw["code"].(string)
|
||||
|
||||
var rawDetails []any
|
||||
if dv, ok := raw["details"].([]any); ok {
|
||||
rawDetails = dv
|
||||
}
|
||||
items := make([]DetailItem, 0, len(rawDetails))
|
||||
for _, d := range rawDetails {
|
||||
m, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemID, _ := toInt64(m["id"])
|
||||
productID, _ := toInt64(m["productId"])
|
||||
qty, _ := toInt64(m["productQty"])
|
||||
price, _ := toFloat64(m["productPrice"])
|
||||
thumb, _ := toInt64(m["productThumb"])
|
||||
title, _ := m["productTitle"].(string)
|
||||
spec, _ := m["productSpec"].(string)
|
||||
items = append(items, DetailItem{
|
||||
ID: itemID, ProductID: productID, ProductTitle: title, ProductSpec: spec,
|
||||
ProductQty: int(qty), ProductPrice: price, ProductThumb: thumb, Raw: m,
|
||||
})
|
||||
}
|
||||
|
||||
outerRaw := make(map[string]any, len(raw))
|
||||
for k, v := range raw {
|
||||
if k == "details" {
|
||||
continue
|
||||
}
|
||||
outerRaw[k] = v
|
||||
}
|
||||
|
||||
out = append(out, StockDetail{ID: id, Code: code, Details: items, Raw: outerRaw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
|
||||
|
||||
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
|
||||
// 字段用字符串传数字的情况。
|
||||
func toInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
return i, err == nil
|
||||
case string:
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
return i, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package syb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// `[必须]` 本文件全部用 httptest 起假服务端,绝不能打真实的
|
||||
// shunyunbaoerp.com——打真站会污染对方数据、可能触发风控,见工单 #46。
|
||||
|
||||
// fakeJWT 造一个"看起来像"顺运宝 JWT 的 token:header.payload.signature,
|
||||
// payload 是 base64url({"exp":...}),测试只关心 exp 能不能被正确解析出来。
|
||||
func fakeJWT(t *testing.T, exp int64) string {
|
||||
t.Helper()
|
||||
payload := fmt.Sprintf(`{"authLogin":false,"exp":%d,"iat":%d,"username":"tester"}`, exp, exp-86400)
|
||||
seg := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(payload))
|
||||
return "header." + seg + ".signature"
|
||||
}
|
||||
|
||||
func envelopeBody(t *testing.T, status bool, msg string, data any, code any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"status": status, "msg": msg, "data": data, "code": code,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造响应体失败: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ── 验证码 + 登录:同一 Cookie Jar ──────────────────────
|
||||
|
||||
func TestClient_验证码和登录用同一个CookieJar(t *testing.T) {
|
||||
var captchaCookieSeen, loginCookieSeen bool
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/p/code1":
|
||||
// 验证码接口种一个会话 Cookie。
|
||||
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "abc123", Path: "/"})
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Write([]byte("fake-jpeg-bytes"))
|
||||
case r.URL.Path == "/am/auth/login":
|
||||
// 登录请求必须带上验证码接口种下的 Cookie,
|
||||
// 证明两次请求走的是同一个 Cookie Jar。
|
||||
if ck, err := r.Cookie("erp_session"); err == nil && ck.Value == "abc123" {
|
||||
loginCookieSeen = true
|
||||
}
|
||||
w.Write(envelopeBody(t, true, "登录成功", map[string]any{
|
||||
"user": map[string]any{"id": 1001, "username": "tester"},
|
||||
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
|
||||
}, nil))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("创建客户端失败: %v", err)
|
||||
}
|
||||
|
||||
cap, err := c.FetchCaptcha(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("获取验证码失败: %v", err)
|
||||
}
|
||||
if len(cap.Image) == 0 || cap.ContentType != "image/jpeg" {
|
||||
t.Fatalf("验证码内容不对: %+v", cap)
|
||||
}
|
||||
captchaCookieSeen = true // 只要走到这里说明请求成功了
|
||||
|
||||
result, err := c.Login(context.Background(), "tester", "password123", "AB12")
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
if !captchaCookieSeen || !loginCookieSeen {
|
||||
t.Fatal("验证码和登录应该用同一个 Cookie Jar,但登录请求没带上验证码接口种的 Cookie")
|
||||
}
|
||||
if result.User.ID != 1001 || result.User.Username != "tester" {
|
||||
t.Errorf("登录结果不对: %+v", result.User)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话有效期:min(JWT exp, 24h) ────────────────────────
|
||||
|
||||
func TestClient_Login_有效期取JWT剩余和24小时的较小值(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// JWT 只剩 2 小时,应该取 2 小时,不是 24 小时。
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||
"user": map[string]any{"id": 1, "username": "tester"},
|
||||
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
|
||||
}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
result, err := c.Login(context.Background(), "tester", "pw", "code")
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
remain := time.Until(result.ExpiresAt)
|
||||
if remain > 3*time.Hour || remain < time.Hour {
|
||||
t.Errorf("有效期应该接近 JWT 剩余的 2 小时,实际剩 %v", remain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Login_JWT解析失败时退化成24小时(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||
"user": map[string]any{"id": 1, "username": "tester"},
|
||||
"token": "不是一个合法的JWT",
|
||||
}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
result, err := c.Login(context.Background(), "tester", "pw", "code")
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
remain := time.Until(result.ExpiresAt)
|
||||
if remain > 25*time.Hour || remain < 23*time.Hour {
|
||||
t.Errorf("JWT 解析失败时应该退化成 24 小时,实际剩 %v", remain)
|
||||
}
|
||||
}
|
||||
|
||||
// ── §3.5:区分"明确未登录"和"网络故障" ───────────────────
|
||||
|
||||
func TestClient_CheckSession_HTTP401判定为未登录(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if !errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("HTTP 401 应该判定为未登录,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_业务码未登录判定为未登录(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(envelopeBody(t, false, "登录过期,请重新登录", nil, "-2"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if !errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("msg 含「登录过期」应该判定为未登录,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_id或username不一致判定为未登录(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 9999, "username": "别人"}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if !errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("id/username 不一致(串号)应该判定为未登录,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_超时不判定为未登录(t *testing.T) {
|
||||
// `[必须]` 08 §3.5 最重要的一条:网络故障不能被误判成"未登录",
|
||||
// 否则网络抖一下就会触发重新登录、弹验证码,还可能把有效会话丢掉。
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
c.http.Timeout = 50 * time.Millisecond // 故意设一个比服务端延迟短的超时
|
||||
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if err == nil {
|
||||
t.Fatal("超时应该返回错误")
|
||||
}
|
||||
if errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("超时不能被判定为「未登录」,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_HTTP5xx不判定为未登录(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if err == nil {
|
||||
t.Fatal("5xx 应该返回错误")
|
||||
}
|
||||
if errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("5xx(服务端故障)不能被判定为「未登录」,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_响应格式错误不判定为未登录(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("这不是 JSON"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
err := c.CheckSession(context.Background(), 1001, "tester")
|
||||
if err == nil {
|
||||
t.Fatal("格式错误应该返回错误")
|
||||
}
|
||||
if errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("响应格式错误不能被判定为「未登录」,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CheckSession_会话有效时返回nil(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
if err := c.CheckSession(context.Background(), 1001, "tester"); err != nil {
|
||||
t.Fatalf("会话有效时应该返回 nil,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cookie 持久化:导出 → 导入 ──────────────────────────
|
||||
|
||||
func TestClient_Cookie导出后可以导入到新客户端(t *testing.T) {
|
||||
var seenCookieValue string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/set" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "the-cookie-value", Path: "/"})
|
||||
return
|
||||
}
|
||||
if ck, err := r.Cookie("erp_session"); err == nil {
|
||||
seenCookieValue = ck.Value
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c1, _ := New(srv.URL)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/set", nil)
|
||||
resp, err := c1.http.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
cookiesJSON, err := c1.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("导出 Cookie 失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(cookiesJSON, "the-cookie-value") {
|
||||
t.Fatalf("导出的 Cookie JSON 应该包含 Cookie 的值,实际: %s", cookiesJSON)
|
||||
}
|
||||
|
||||
// 新客户端(模拟重启 Admin 后新建的 Client),导入缓存的 Cookie。
|
||||
c2, _ := New(srv.URL)
|
||||
if err := c2.ImportCookiesJSON(cookiesJSON); err != nil {
|
||||
t.Fatalf("导入 Cookie 失败: %v", err)
|
||||
}
|
||||
if err := c2.CheckSession(context.Background(), 1, "x"); err != nil && !errors.Is(err, ErrSessionInvalid) {
|
||||
// 忽略——这里只是想借这个请求确认 Cookie 被带上了,不关心业务结果
|
||||
}
|
||||
if seenCookieValue != "the-cookie-value" {
|
||||
t.Fatalf("新客户端应该带上导入的 Cookie 发请求,实际服务端看到的值: %q", seenCookieValue)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 货运单列表 + 明细 ────────────────────────────────────
|
||||
|
||||
func TestClient_ListTotal和ListPage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
queries, _ := body["queries"].([]any)
|
||||
if len(queries) != 1 {
|
||||
t.Errorf("queries 应该有 1 个条件,实际 %d 个", len(queries))
|
||||
}
|
||||
q := queries[0].(map[string]any)
|
||||
if q["dvalue"] != "2026-07-25,2026-07-28" {
|
||||
t.Errorf("dvalue 拼接不对: %v", q["dvalue"])
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/am/stock/listTotal":
|
||||
w.Write(envelopeBody(t, true, "ok", 1, nil))
|
||||
case "/am/stock/list":
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||
"list": []map[string]any{
|
||||
{"id": 75104587, "code": "260728TB95MJTQ", "amtOrder": 61200},
|
||||
},
|
||||
}, nil))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
total, err := c.ListTotal(context.Background(), "2026-07-25", "2026-07-28", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("listTotal 失败: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("总数应该是 1,实际 %d", total)
|
||||
}
|
||||
|
||||
rows, err := c.ListPage(context.Background(), "2026-07-25", "2026-07-28", 0, 1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list 失败: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != 75104587 || rows[0].Code != "260728TB95MJTQ" {
|
||||
t.Fatalf("列表结果不对: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DetailListByStock_一单多商品(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("hist") != "0" {
|
||||
t.Errorf("hist 参数应该是 0,实际 %q", r.URL.Query().Get("hist"))
|
||||
}
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
ids, _ := body["ids"].([]any)
|
||||
if len(ids) != 1 || ids[0].(float64) != 75104587 {
|
||||
t.Errorf("ids 传递不对: %v", ids)
|
||||
}
|
||||
|
||||
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
||||
"list": []map[string]any{
|
||||
{
|
||||
"id": 75104587, "code": "260728TB95MJTQ", "shopName": "测试店铺",
|
||||
"amtOrder": 612.0,
|
||||
"details": []map[string]any{
|
||||
{
|
||||
"id": 145306175, "productId": 50209124255,
|
||||
"productTitle": "蕾絲花邊拼接背心女", "productSpec": "白色,L【建議50-60公斤】",
|
||||
"productQty": 1, "productPrice": 239.0, "productThumb": 190639637,
|
||||
},
|
||||
{
|
||||
"id": 145306176, "productId": 50209124256,
|
||||
"productTitle": "牛仔裤", "productSpec": "黑色,M",
|
||||
"productQty": 2, "productPrice": 439.0, "productThumb": 190639638,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
details, err := c.DetailListByStock(context.Background(), []int64{75104587})
|
||||
if err != nil {
|
||||
t.Fatalf("查询明细失败: %v", err)
|
||||
}
|
||||
if len(details) != 1 {
|
||||
t.Fatalf("应该有 1 张货运单,实际 %d", len(details))
|
||||
}
|
||||
d := details[0]
|
||||
if d.ID != 75104587 || d.Code != "260728TB95MJTQ" {
|
||||
t.Fatalf("外层字段不对: %+v", d)
|
||||
}
|
||||
if len(d.Details) != 2 {
|
||||
t.Fatalf("一张货运单应该拆出 2 个商品明细,实际 %d 个", len(d.Details))
|
||||
}
|
||||
if d.Details[0].ProductID != 50209124255 || d.Details[0].ProductSpec != "白色,L【建議50-60公斤】" {
|
||||
t.Errorf("第一个商品明细字段不对: %+v", d.Details[0])
|
||||
}
|
||||
if d.Details[0].ProductPrice != 239.0 {
|
||||
t.Errorf("单价应该是明细接口的原始值(元,未换算),实际 %v", d.Details[0].ProductPrice)
|
||||
}
|
||||
if d.Details[1].ProductID != 50209124256 || d.Details[1].ProductQty != 2 {
|
||||
t.Errorf("第二个商品明细字段不对: %+v", d.Details[1])
|
||||
}
|
||||
// details 不应该出现在外层 Raw 里,避免落库时重复。
|
||||
if _, ok := d.Raw["details"]; ok {
|
||||
t.Error("StockDetail.Raw 不应该包含 details(那是嵌套结构,已经拆到 Details 字段)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
|
||||
c, _ := New("https://example.invalid")
|
||||
ids := make([]int64, 101)
|
||||
_, err := c.DetailListByStock(context.Background(), ids)
|
||||
if err == nil {
|
||||
t.Fatal("超过 100 个 id 应该报错,不应该真的发请求")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 业务失败但不是登录问题 ──────────────────────────────
|
||||
|
||||
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(envelopeBody(t, false, "参数错误", nil, "400"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(srv.URL)
|
||||
_, err := c.ListTotal(context.Background(), "2026-01-01", "2026-01-02", 20)
|
||||
if err == nil {
|
||||
t.Fatal("业务失败应该返回错误")
|
||||
}
|
||||
if errors.Is(err, ErrSessionInvalid) {
|
||||
t.Fatalf("普通业务错误不应该被误判为未登录,实际: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "参数错误") {
|
||||
t.Errorf("错误信息应该带上服务端的 msg,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package syb
|
||||
|
||||
// columnSpec 是货运单列表请求体里 columns 数组的一项:声明服务端
|
||||
// 要返回哪一列。
|
||||
//
|
||||
// `[必须]` 完整照抄 raw_data/shunyunbaoerp_single.py 的 COLUMN_SPECS
|
||||
// (72 项),不要自己删减——docs/admin/08-顺运宝接口.md §4.1 明确说
|
||||
// "服务端可能依赖这批列做联表",删了不知道会不会漏别的字段。
|
||||
type columnSpec struct {
|
||||
TableName string
|
||||
ColName string
|
||||
FieldName string
|
||||
HasAlias int
|
||||
TableAlias string
|
||||
}
|
||||
|
||||
// stockColumnSpecs 是货运单列表查询用到的 72 个列定义,
|
||||
// 逐项抄自 raw_data/shunyunbaoerp_single.py 的 COLUMN_SPECS。
|
||||
var stockColumnSpecs = []columnSpec{
|
||||
{"t_stock", "created", "created", 0, "t"},
|
||||
{"t_stock", "order_code", "orderCode", 0, "t"},
|
||||
{"t_stock", "printer", "printer", 0, "t"},
|
||||
{"t_stock", "weight_time", "weightTime", 0, "t"},
|
||||
{"t_stock", "weight_inputer", "weightInputer", 0, "t"},
|
||||
{"t_stock", "pkg_time", "pkgTime", 0, "t"},
|
||||
{"t_stock", "code", "code", 0, "t"},
|
||||
{"t_stock", "status", "status", 0, "t"},
|
||||
{"t_stock", "order_status", "orderStatus", 0, "t"},
|
||||
{"t_stock", "purchase_status", "purchaseStatus", 0, "t"},
|
||||
{"t_stock", "exp_code", "expCode", 0, "t"},
|
||||
{"t_stock", "exp_page_code", "expPageCode", 0, "t"},
|
||||
{"t_stock", "exp_allow_print", "expAllowPrint", 0, "t"},
|
||||
{"t_stock", "exp_page_status", "expPageStatus", 0, "t"},
|
||||
{"t_stock", "page_id", "pageId", 0, "t"},
|
||||
{"t_stock", "upload_time", "uploadTime", 0, "t"},
|
||||
{"t_stock", "pay_time", "payTime", 0, "t"},
|
||||
{"t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"},
|
||||
{"t_stock", "remark9", "tsremark9", 1, "t"},
|
||||
{"t_stock", "remark9", "remark9", 0, "t"},
|
||||
{"t_stock", "detail_qty", "detailQty", 0, "t"},
|
||||
{"t_stock", "order_qty", "orderQty", 0, "t"},
|
||||
{"t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"},
|
||||
{"t_stock_detail", "shelf_code", "shelfCode", 0, "t7"},
|
||||
{"t_stock", "shelf_code", "tsshelfCode", 1, "t"},
|
||||
{"t_stock", "store_type", "storeType", 0, "t"},
|
||||
{"t_stock", "order_bag_code", "orderBagCode", 0, "t"},
|
||||
{"t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"},
|
||||
{"t_stock", "weight_consign", "weightConsign", 0, "t"},
|
||||
{"t_stock", "amt_order", "amtOrder", 0, "t"},
|
||||
{"t_stock_append", "offline_amount", "offlineAmount", 0, "t8"},
|
||||
{"t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"},
|
||||
{"t_stock", "exp_cod", "expCod", 0, "t"},
|
||||
{"t_stock", "exp_company", "expCompany", 0, "t"},
|
||||
{"t_stock", "transport", "transport", 0, "t"},
|
||||
{"t_stock", "order_origin", "orderOrigin", 0, "t"},
|
||||
{"t_stock", "exp_out_type", "expOutType", 0, "t"},
|
||||
{"t_stock", "order_platform", "orderPlatform", 0, "t"},
|
||||
{"t_stock", "exp_pkg_type", "expPkgType", 0, "t"},
|
||||
{"t_stock", "exp_pkg_code", "expPkgCode", 0, "t"},
|
||||
{"t_stock", "exp_batch", "expBatch", 0, "t"},
|
||||
{"t_stock", "exp_ti_huo", "expTiHuo", 0, "t"},
|
||||
{"t_stock", "print_time", "printTime", 0, "t"},
|
||||
{"t_stock", "receiver", "receiver", 0, "t"},
|
||||
{"t_stock", "receiver_tel", "receiverTel", 0, "t"},
|
||||
{"t_stock", "receiver_addr", "receiverAddr", 0, "t"},
|
||||
{"t_stock", "receiver_shop_name", "receiverShopName", 0, "t"},
|
||||
{"t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"},
|
||||
{"t_stock", "product_name", "productName", 0, "t"},
|
||||
{"t_stock", "is_cancel", "isCancel", 0, "t"},
|
||||
{"t_stock", "err_msg", "errMsg", 0, "t"},
|
||||
{"t_stock", "remark2", "remark2", 0, "t"},
|
||||
{"t_stock", "remark1", "remark1", 0, "t"},
|
||||
{"t_stock", "note", "note", 0, "t"},
|
||||
{"t_store", "name", "name", 0, "t1"},
|
||||
{"sys_user", "fullname", "sufullname", 1, "t3"},
|
||||
{"sys_user", "dept_label_path", "deptLabelPath", 0, "t3"},
|
||||
{"t_stock", "shop_name", "shopName", 0, "t"},
|
||||
{"t_shop", "shop_id", "shopId", 0, "t6"},
|
||||
{"t_stock", "remark4", "remark4", 0, "t"},
|
||||
{"t_stock", "remark7", "remark7", 0, "t"},
|
||||
{"t_stock", "package_time", "packageTime", 0, "t"},
|
||||
{"t_stock", "packer", "packer", 0, "t"},
|
||||
{"t_stock", "pack_type", "packType", 0, "t"},
|
||||
{"t_stock", "track_status", "trackStatus", 0, "t"},
|
||||
{"t_stock", "track_desc", "trackDesc", 0, "t"},
|
||||
{"t_stock", "err_status", "errStatus", 0, "t"},
|
||||
{"t_stock", "err_time", "errTime", 0, "t"},
|
||||
{"t_stock", "err_msg", "tserrMsg", 1, "t"},
|
||||
{"t_stock", "remark2", "tsremark2", 1, "t"},
|
||||
{"t_stock", "remark1", "tsremark1", 1, "t"},
|
||||
{"t_stock", "product_volume_str", "productVolumeStr", 0, "t"},
|
||||
}
|
||||
|
||||
// columnsPayload 把 stockColumnSpecs 转成请求体要的 JSON 形状:
|
||||
//
|
||||
// {"tableName":"t_stock","colName":"created","fieldName":"created","hasAlias":0,"tableAlias":"t"}
|
||||
func columnsPayload() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(stockColumnSpecs))
|
||||
for _, c := range stockColumnSpecs {
|
||||
out = append(out, map[string]any{
|
||||
"tableName": c.TableName,
|
||||
"colName": c.ColName,
|
||||
"fieldName": c.FieldName,
|
||||
"hasAlias": c.HasAlias,
|
||||
"tableAlias": c.TableAlias,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="toolbar">
|
||||
<form class="inline" method="post" action="/syb/sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<button type="submit">同步</button>
|
||||
</form>
|
||||
|
||||
@@ -27,6 +28,12 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .ConfigProblem}}
|
||||
<p class="missing">
|
||||
顺运宝配置有问题:{{.ConfigProblem}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -35,6 +42,7 @@
|
||||
<th>货运单 ID</th>
|
||||
<th>订单号</th>
|
||||
<th>商品标题</th>
|
||||
<th>规格</th>
|
||||
<th>蝦皮商品 ID</th>
|
||||
<th>规格 SKU</th>
|
||||
<th>数量</th>
|
||||
@@ -46,14 +54,37 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
{{/* TODO(骨架): 行渲染。价格是台币,显示成 NT$xx.xx,
|
||||
要和人民币一眼分得清。图片显示小缩略图,存的是 URL。
|
||||
双击行打开匹配弹窗。 */}}
|
||||
<tr data-detail-id="{{.SybID}}" {{if not .Matched}}class="row-warn"{{end}}>
|
||||
<td class="col-check">
|
||||
<input type="checkbox" value="{{.SybID}}" name="ids"
|
||||
aria-label="选择货运单明细 {{.SybID}}">
|
||||
</td>
|
||||
<td>{{.SybID}}</td>
|
||||
<td>{{.OrderNo}}</td>
|
||||
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
|
||||
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
|
||||
<td>{{.ShopeeGoodsID}}</td>
|
||||
{{/* 匹配状态是算出来的(shopee_sku_id 是否非空),本工单不做匹配功能,
|
||||
待匹配的行整行标黄提醒,但不提供匹配入口——那是后续工单的范围 */}}
|
||||
<td>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</td>
|
||||
<td>{{.Quantity}}</td>
|
||||
<td>{{.PriceText}}</td>
|
||||
<td>
|
||||
{{if .ImageURL}}<img src="{{.ImageURL}}" alt="" class="thumb">{{else}}—{{end}}
|
||||
</td>
|
||||
<td>{{.MatchText}}</td>
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty">
|
||||
<td colspan="11">
|
||||
还没有货运单。<br>
|
||||
<small>顺运宝同步方式尚未确定,当前可先手工录入用于联调。</small>
|
||||
<td colspan="12">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有货运单明细。<br>
|
||||
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>
|
||||
{{else}}
|
||||
还没有货运单明细。<br>
|
||||
<small>点上方「同步」从顺运宝拉取,第一次同步需要先登录(验证码需要手工输入)。</small>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -61,13 +92,52 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{/* TODO(骨架): 匹配弹窗 templates/syb/match_modal.html
|
||||
左边蝦皮规格,右边该商品 pdd_data 里的 PDD 规格下拉。
|
||||
三条硬规则:
|
||||
1. 右侧下拉选项来自 pdd_data.dimensions,不要写死"颜色/尺码"两个维度;
|
||||
2. 打开时先查 sku_mappings,有记录就自动带出并提示"已自动带出";
|
||||
3. 该商品还没采集时,直接提示"请先到蝦皮数据模块采集"并给跳转链接,
|
||||
不要显示一个空下拉让人困惑。 */}}
|
||||
<p class="hint">
|
||||
「匹配状态」是根据规格 SKU 是否已填算出来的,本页暂不提供匹配入口
|
||||
(规格匹配是后续工单的范围)。
|
||||
</p>
|
||||
|
||||
{{/* ── 登录弹窗 ─────────────────────────────────
|
||||
会话未登录/已过期时自动打开(不加 hidden);
|
||||
`[必须]` 密码不在界面上显示、也不回显到 HTML,只显示只读账号。 */}}
|
||||
<div class="modal-backdrop" id="login-modal" {{if not .NeedLogin}}hidden{{end}}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="login-modal-title">
|
||||
<div class="modal-head">
|
||||
<h2 id="login-modal-title">登录顺运宝</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form method="post" action="/syb/login-and-sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label for="login-username">账号</label>
|
||||
<input id="login-username" type="text" value="{{.Username}}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-captcha-img">验证码</label>
|
||||
{{/* 图片地址带一个时间戳查询参数,确保每次打开弹窗/点"换一张"
|
||||
都拿到新图,不被浏览器缓存吃掉旧的 */}}
|
||||
<img id="login-captcha-img" src="/syb/captcha" alt="验证码"
|
||||
class="captcha-img" data-captcha-refresh="/syb/captcha">
|
||||
<button type="button" id="login-captcha-refresh">换一张</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-code">验证码文字</label>
|
||||
<input id="login-code" type="text" name="code" required autocomplete="off"
|
||||
maxlength="8" placeholder="图片里的 4 位字母数字">
|
||||
</div>
|
||||
<p class="hint">
|
||||
账号密码取自 <code>admin/config.yaml</code>,界面上不显示密码。
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>取消</button>
|
||||
<button type="submit" class="primary">登录并同步</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -90,6 +90,31 @@ go run .
|
||||
|
||||
**这个命令是安全的**:Admin 只管理数据,不会连手机、不会下单。放心随便跑。
|
||||
|
||||
### 配置顺运宝账号(同步货运单需要,其余四个模块不需要)
|
||||
|
||||
顺运宝数据页的「同步」需要读 `admin/config.yaml`。第一次用要自己建这个文件
|
||||
(已在 `.gitignore` 里,不会被提交):
|
||||
|
||||
```powershell
|
||||
cd D:\chengma\cmautobuy\admin
|
||||
copy config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
用编辑器打开 `config.yaml`,把 `username` / `password` 改成真实的顺运宝账号密码。
|
||||
|
||||
`[必须]` 密码要加引号,纯数字密码不加引号会被 YAML 解析成整数,前导 0 也会丢:
|
||||
|
||||
```yaml
|
||||
password: "0012345" # ✓ 正确
|
||||
password: 0012345 # ✗ 解析成整数 12345
|
||||
```
|
||||
|
||||
没有这个文件时,点「同步」会提示"没有找到配置文件……请复制
|
||||
config.example.yaml",不是一句读不出原因的报错。
|
||||
|
||||
接口细节和这几个配置项各自的含义见
|
||||
[08 顺运宝接口](08-顺运宝接口.md) §8。
|
||||
|
||||
## 4. 你应该看到什么
|
||||
|
||||
左边(或顶部)是五个模块的导航:
|
||||
@@ -98,7 +123,7 @@ go run .
|
||||
|---|---|
|
||||
| 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 |
|
||||
| PDD 商品 | 维护拼多多商品档案,发起采集,查看采回来的规格价格。这个页面不依赖蝦皮和顺运宝的任何数据,单独就能跑通"建商品 → 建采集任务 → 领走执行 → 提交结果 → 显示已采集"这条闭环,见 [05 界面规范](05-ui-specification.md) §5 |
|
||||
| 顺运宝数据 | 同步货运单,匹配规格,生成采购任务 |
|
||||
| 顺运宝数据 | 同步货运单(需要先配置 `config.yaml`,见上一节)。规格匹配和生成采购任务是后续工单的范围,本页暂不提供 |
|
||||
| 采集采购 | 看采集和采购任务执行到哪一步了 |
|
||||
| 客户端列表 | 看哪些客户端在干活 |
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
||||
| v2 | 新增 `task_claims`(领取历史,见 §8)。 |
|
||||
| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 |
|
||||
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
|
||||
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
|
||||
|
||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||
@@ -419,11 +420,12 @@ UPDATE pdd_products
|
||||
|
||||
```sql
|
||||
CREATE TABLE syb_orders (
|
||||
syb_id TEXT PRIMARY KEY, -- 货运单 ID
|
||||
order_no TEXT NOT NULL, -- 订单号
|
||||
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
|
||||
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
|
||||
title TEXT, -- 商品标题
|
||||
shopee_goods_id TEXT, -- 蝦皮商品 ID
|
||||
shopee_sku_id TEXT, -- 蝦皮规格 ID
|
||||
product_spec TEXT, -- 规格原文,v5 新增,对应 shopee_skus.spec_raw
|
||||
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
|
||||
shopee_sku_id TEXT, -- 蝦皮规格 ID,人工/自动匹配的结果
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
|
||||
image_url TEXT, -- 存 URL,不存图片本身
|
||||
@@ -450,7 +452,49 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
||||
|
||||
**"编号能对上"和"本地一定查得到"是两回事,别混。**
|
||||
|
||||
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
|
||||
**"匹配状态"是派生的,不存字段**:`shopee_sku_id` 非空就是"已匹配"(本工单未做
|
||||
`sku_mappings` 联查复用,规格匹配是后续工单的范围)。
|
||||
|
||||
`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是规格匹配的
|
||||
结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`,
|
||||
不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的
|
||||
`ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。
|
||||
|
||||
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
||||
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
||||
`details[].id`,不是货运单本身的 `id`。
|
||||
|
||||
`[必须]` `price_twd_cent` 一律取 `detail/listByStock` 接口的值(元)自己 ×100
|
||||
转分、先四舍五入再转整数;不要用 `/am/stock/list` 列表接口的金额字段——
|
||||
同一响应里不同金额字段的单位不统一,见 [08 顺运宝接口](08-顺运宝接口.md) §5.1。
|
||||
|
||||
`[建议]` 收件人姓名/电话/地址不入库,`syb_data` 落库前已剔除。
|
||||
|
||||
### 5.1 `syb_session` 顺运宝会话缓存、`syb_sync_state` 同步进度(v5)
|
||||
|
||||
```sql
|
||||
CREATE TABLE syb_session (
|
||||
username TEXT PRIMARY KEY,
|
||||
cookies TEXT NOT NULL, -- JSON 数组,Cookie 名/值/路径
|
||||
expires_at TEXT NOT NULL, -- min(JWT exp, 24h)
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE syb_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- 只允许一行
|
||||
last_synced_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- 只存 Cookie,不存登录 JWT——认证完全靠 Cookie,JWT 从不参与后续请求,
|
||||
见 [08 顺运宝接口](08-顺运宝接口.md) §3.1。
|
||||
- `syb_sync_state` 只允许一行(`CHECK (id = 1)`),是全局的"上次同步到哪"。
|
||||
- `[必须]` 增量同步从 `last_synced_at` 对应的**日期当天**重新拉,不是第二天——
|
||||
`created` 筛选粒度是日期,`last_synced_at` 精确到秒,从第二天拉会漏掉当天
|
||||
晚些时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏。
|
||||
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
|
||||
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
|
||||
|
||||
## 6. `sku_mappings` 规格映射
|
||||
|
||||
|
||||
@@ -410,20 +410,49 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
[同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
||||
```
|
||||
|
||||
`[待定]` MVP 阶段**同步按钮只做占位**:点击提示"同步功能待接入",不发请求。
|
||||
`[必须]` 「同步」按工单 #46 实现:
|
||||
|
||||
```text
|
||||
点「同步」
|
||||
├─ 本地缓存的会话未过期 ──→ 后台开始同步,立即跳回列表页,
|
||||
│ 状态条显示"同步已开始,请稍后刷新页面查看结果"
|
||||
└─ 没有缓存会话/已过期 ───→ 弹出登录弹窗(不是报错):
|
||||
账号(config.yaml 带出,只读,不显示密码)
|
||||
验证码图片 [点图/换一张 可刷新]
|
||||
验证码文字输入框
|
||||
[登录并同步]
|
||||
```
|
||||
|
||||
同步是长任务,不阻塞 HTTP 请求线程——点完立即跳转,结果异步写进内存态的
|
||||
"最近一次同步报告",下次刷新页面时状态条会显示:
|
||||
|
||||
```text
|
||||
同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
|
||||
(新增 20,更新 7,跳过 0)
|
||||
```
|
||||
|
||||
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
|
||||
"已经有一个同步任务在跑",不会并发跑两个。
|
||||
|
||||
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
||||
|
||||
### 6.2 表格列
|
||||
|
||||
☐ / 货运单ID / 订单号 / 商品标题 / 蝦皮商品ID / 规格SKU / 数量 /
|
||||
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 /
|
||||
价格(台币)/ 图片 / **匹配状态** / 更新时间
|
||||
|
||||
- 图片显示小缩略图,点击看大图。`[必须]` 存 URL,不要把图片塞进数据库。
|
||||
- 匹配状态是**算出来的**(`sku_mappings` 里有没有记录),不是存的字段。
|
||||
- 完整货运单 JSON 不作为列显示,在详情里看。
|
||||
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
|
||||
可以有多个商品,各占一行。
|
||||
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
|
||||
- 匹配状态是**算出来的**(`shopee_sku_id` 是否非空),不是存的字段。
|
||||
本工单(#46)不做规格匹配功能,待匹配的行整行标黄,但不提供匹配入口。
|
||||
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
|
||||
|
||||
### 6.3 规格匹配弹窗(双击行打开)
|
||||
### 6.3 规格匹配弹窗(双击行打开)—— 未实现,见下方说明
|
||||
|
||||
`[不做]` 工单 #46(顺运宝货运单同步)明确不做这一节描述的匹配弹窗,
|
||||
`shopee_sku_id` 由同步留空,本节描述的是**规格匹配**这个后续工单要实现的目标
|
||||
界面,先记录在这里,不代表当前已经能用。
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────┐
|
||||
|
||||
@@ -243,6 +243,34 @@ amtOrder 612.0
|
||||
差 66,应该是优惠。`[必须]` **不要用「明细合计 == amtOrder」做校验**,
|
||||
会误报。
|
||||
|
||||
### 5.3 `created` 是 UTC+8,不是 UTC —— 日期范围查询最容易算错的地方
|
||||
|
||||
`[必须]` 实测 `raw_data/shunyunbaoerp_stock_query.har`:
|
||||
|
||||
```text
|
||||
HAR 记录的抓包时刻 startedDateTime 2026-07-28T03:31:45Z (= 11:31:45 UTC+8)
|
||||
同一次请求响应里的 created 2026-07-28 10:37:59
|
||||
```
|
||||
|
||||
`10:37:59` 作为 **UTC+8** 讲得通(比抓包时刻早 54 分钟,正常)。
|
||||
若把它当成 **UTC**,换算成 UTC+8 就是 18:37,比抓包时刻**晚 7 小时**——
|
||||
订单创建于尚未发生的未来,不成立。所以 `created` 是 UTC+8,不是 UTC。
|
||||
|
||||
`[必须]` §4.2「按日期范围」的 `dvalue` 筛的就是这个 `created`,
|
||||
所以**换算"今天是哪一天"也必须用 UTC+8**,不能用 UTC 或本机系统时区
|
||||
(本机系统时区不一定是 UTC+8,取决于部署环境)。用 UTC 算的话,
|
||||
在 UTC+8 的 00:00–08:00 这段时间会把"今天"算成昨天,当天早晨创建的单
|
||||
这一轮同步拉不到——虽然下一轮的起始日期仍是"上次同步日",范围会覆盖
|
||||
回来、不会永久丢单,但操作员当场点同步会以为同步坏了。
|
||||
|
||||
`[必须]` 代码里固定用 `time.FixedZone("UTC+8", 8*60*60)`,不要用
|
||||
`time.LoadLocation("Asia/Shanghai")`——那个要读系统 tzdata,Windows 上
|
||||
默认没有,打包成 exe 后会在运行时报错。
|
||||
|
||||
`[待定]` 只有一个样本(一次抓包)支撑这个结论,且没有拿到顺运宝官方
|
||||
文档确认。以后如果日期范围附近出现"该有的单没同步到",先来这里核对
|
||||
这条结论是否仍然成立。
|
||||
|
||||
---
|
||||
|
||||
## 6. 货运明细
|
||||
@@ -385,6 +413,8 @@ password: "0012345" # ✓
|
||||
- [ ] 会话失效时服务端返回的**确切**形态(HTTP 码 / `code` / `msg` 文案)
|
||||
- [ ] 同一账号多处登录是否互踢
|
||||
- [ ] 验证码错误、密码错误分别返回什么,能否区分
|
||||
- [ ] `created` 是 UTC+8 这一条(见 §5.3)只有一次抓包支撑,
|
||||
没有官方文档确认,也没有跨夏令时/时区配置的验证
|
||||
|
||||
`[必须]` 最后两条影响错误提示的准确性:分不清「密码错」和「验证码错」的话,
|
||||
操作员会一直重输密码。
|
||||
|
||||
Reference in New Issue
Block a user