feat: 按允许店铺筛选顺运宝同步 (#196)

This commit is contained in:
chengma
2026-08-12 18:48:31 +08:00
parent 4089033453
commit 8a8fff8480
17 changed files with 720 additions and 30 deletions
+55 -12
View File
@@ -213,9 +213,12 @@ type SkipNote struct {
// SyncReport 是一次同步的结果,供状态条显示。
type SyncReport struct {
From, To string
Specified bool // true 表示操作员发起的指定日期补同步
StockCount int // 拉到的货运单数
DetailCount int // 落库的商品明细行数(不含跳过的)
Specified bool // true 表示操作员发起的指定日期补同步
StockCount int // 拉到的货运单数
AcceptedCount int // 店铺准入后接受的货运单数
ShopSkipped int // 店铺不在允许列表或为空而跳过的货运单数
ShopFilterHash string // 本次固定店铺快照的 SHA-256,不保存敏感凭据
DetailCount int // 落库的商品明细行数(不含跳过的)
Created int
Updated int
SkippedZero int // quantity <= 0 被跳过的条数
@@ -289,7 +292,9 @@ func FinishSybSyncRun(db *sql.DB, runID string, report SyncReport) error {
}
return repository.FinishSybSyncRun(db, model.SybSyncRun{
RunID: runID, Status: status, StockCount: report.StockCount,
DetailCount: report.DetailCount, Created: report.Created, Updated: report.Updated,
AcceptedCount: report.AcceptedCount, ShopSkipped: report.ShopSkipped,
ShopFilterHash: report.ShopFilterHash,
DetailCount: report.DetailCount, Created: report.Created, Updated: report.Updated,
Skipped: report.SkippedZero, ErrorMessage: errorMessage,
CursorAdvanced: report.CursorAdvanced, FinishedAt: finishedAt.UTC().Format(model.TimeLayout),
})
@@ -324,8 +329,8 @@ func ListSybSyncHistory(db *sql.DB, page int) (*SybSyncHistoryResult, error) {
RunID: run.RunID, Username: run.Username,
DateRange: run.DateFrom + " ~ " + run.DateTo,
StatusText: statusText, StatusClass: statusClass,
Summary: fmt.Sprintf("货运单 %d,明细 %d(新增 %d,更新 %d,跳过 %d)",
run.StockCount, run.DetailCount, run.Created, run.Updated, run.Skipped),
Summary: fmt.Sprintf("原始货运单 %d,接受 %d,店铺跳过 %d;明细 %d(新增 %d,更新 %d,数量跳过 %d)",
run.StockCount, run.AcceptedCount, run.ShopSkipped, run.DetailCount, run.Created, run.Updated, run.Skipped),
ErrorMessage: run.ErrorMessage, StartedAt: formatLocalTime(run.StartedAt),
FinishedAt: formatLocalTime(run.FinishedAt),
}
@@ -362,8 +367,8 @@ 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)
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,原始货运单 %d 张,接受 %d 张,店铺跳过 %d 张;商品明细 %d 条(新增 %d,更新 %d,数量跳过 %d)",
r.From, r.To, r.StockCount, r.AcceptedCount, r.ShopSkipped, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
if len(r.Notes) > 0 {
var reasons []string
for _, n := range r.Notes {
@@ -548,6 +553,22 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport {
report := SyncReport{StartedAt: now, Specified: options.IsSpecified()}
allowedNames, err := repository.ListEnabledSybShopNames(db)
if err != nil {
report.Err = fmt.Errorf("读取顺运宝允许店铺失败: %w", err)
report.FinishedAt = time.Now().UTC()
return report
}
if len(allowedNames) == 0 {
report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“同步店铺”中配置并启用至少一个店铺")
report.FinishedAt = time.Now().UTC()
return report
}
allowedShops := make(map[string]struct{}, len(allowedNames))
for _, name := range allowedNames {
allowedShops[name] = struct{}{}
}
report.ShopFilterHash = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(allowedNames, "\x00"))))
pageSize := cfg.PageSize
if pageSize <= 0 {
@@ -729,8 +750,17 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
}
stockByID := listResult.stockByID
orderedIDs := listResult.orderedIDs
report.StockCount += len(orderedIDs)
rawIDs := listResult.orderedIDs
report.StockCount += len(rawIDs)
orderedIDs := make([]int64, 0, len(rawIDs))
for _, id := range rawIDs {
if !sybShopAllowed(allowedShops, stockByID[id].Raw, nil) {
report.ShopSkipped++
continue
}
orderedIDs = append(orderedIDs, id)
}
report.AcceptedCount += len(orderedIDs)
for i := 0; i < len(orderedIDs); i += detailBatch {
end := i + detailBatch
@@ -752,6 +782,11 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
}
for _, d := range details {
stockRow := stockByID[d.ID]
if !sybShopAllowed(allowedShops, stockRow.Raw, d.Raw) {
report.AcceptedCount--
report.ShopSkipped++
continue
}
if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil {
report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+
"已写入的数据保留): %w", d.Code, d.ID, err)
@@ -762,9 +797,9 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
}
if unstableTodayErr != nil {
report.Err = fmt.Errorf(
"%s 当天货运单在连续 %d 次分页期间仍有变化;已保存最后一次取得的 %d 张货运单完整明细,"+
"%s 当天货运单在连续 %d 次分页期间仍有变化;最后一次取得原始货运单 %d 张,已保存其中允许店铺 %d 张的完整明细,"+
"本次未形成稳定快照且不推进游标,下次同步将继续覆盖当天:%w",
plan.date, sybTodayListMaxAttempts, len(orderedIDs), unstableTodayErr)
plan.date, sybTodayListMaxAttempts, len(rawIDs), len(orderedIDs), unstableTodayErr)
report.FinishedAt = time.Now().UTC()
return report
}
@@ -783,6 +818,14 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
return report
}
// sybShopAllowed 用明细字段覆盖列表字段后再核对,防止列表通过但明细在同步
// 期间已变成其他店铺。detail 为空时只检查列表快照。
func sybShopAllowed(allowed map[string]struct{}, listRaw, detailRaw map[string]any) bool {
name := trimmedStringField(mergeRaw(listRaw, detailRaw), "shopName")
_, ok := allowed[name]
return ok
}
// validateDetailBatch 确认批量明细响应与请求 ID 一一对应。任何缺失、重复、
// 意外 ID 或空商品明细都会让同步失败,避免在数据不完整时推进游标。
func validateDetailBatch(requested []int64, details []syb.StockDetail) error {
+80
View File
@@ -0,0 +1,80 @@
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const maxSybShopNameLength = 500
func ListSybAllowedShops(db *sql.DB, actor *model.User) ([]model.SybAllowedShop, error) {
if actor == nil || !actor.IsAdmin() {
return nil, ErrAdminRequired
}
return repository.ListSybAllowedShops(db)
}
func CountEnabledSybAllowedShops(db *sql.DB) (int, error) {
names, err := repository.ListEnabledSybShopNames(db)
return len(names), err
}
func EnsureEnabledSybAllowedShops(db *sql.DB) error {
count, err := CountEnabledSybAllowedShops(db)
if err != nil {
return err
}
if count == 0 {
return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员配置并启用至少一个店铺"}
}
return nil
}
func CreateSybAllowedShop(db *sql.DB, actor *model.User, rawName string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
name := strings.TrimSpace(rawName)
if name == "" {
return &validationError{field: "shop_name", message: "店铺名称不能为空"}
}
if len([]rune(name)) > maxSybShopNameLength {
return &validationError{field: "shop_name", message: "店铺名称最多 500 个字符"}
}
id, err := randomID("SHOP-", 16)
if err != nil {
return fmt.Errorf("生成店铺编号失败: %w", err)
}
at := now.UTC().Format(model.TimeLayout)
err = repository.InsertSybAllowedShop(db, model.SybAllowedShop{
ShopID: id, ShopName: name, NormalizedName: name, Enabled: true,
CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at,
})
if errors.Is(err, repository.ErrSybAllowedShopExists) {
return &validationError{field: "shop_name", message: "该店铺已经在允许列表中,可直接重新启用"}
}
return err
}
func SetSybAllowedShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
if strings.TrimSpace(shopID) == "" {
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
}
found, err := repository.SetSybAllowedShopEnabled(db, shopID, enabled, now.UTC().Format(model.TimeLayout))
if err != nil {
return err
}
if !found {
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package service
import (
"errors"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestSybAllowedShop_管理员维护与精确去重(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if err := CreateSybAllowedShop(db, admin, " qwg8fkb044 ", now); err != nil {
t.Fatal(err)
}
rows, err := ListSybAllowedShops(db, admin)
if err != nil || len(rows) != 1 || rows[0].ShopName != "qwg8fkb044" || !rows[0].Enabled {
t.Fatalf("新增结果错误: rows=%+v err=%v", rows, err)
}
if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now); !IsValidationError(err) {
t.Fatalf("去除首尾空白后的重名应是表单错误,实际 %v", err)
}
if err := SetSybAllowedShopEnabled(db, admin, rows[0].ShopID, false, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 {
t.Fatalf("停用后启用数错误: count=%d err=%v", count, err)
}
if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) {
t.Fatalf("空白名单应阻止同步: %v", err)
}
}
func TestSybAllowedShop_采购员不能管理(t *testing.T) {
purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive}
if err := CreateSybAllowedShop(nil, purchaser, "店铺", time.Now()); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员新增应被拒绝: %v", err)
}
if _, err := ListSybAllowedShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员读取管理列表应被拒绝: %v", err)
}
}
+112 -3
View File
@@ -5,8 +5,11 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"testing"
"time"
@@ -198,6 +201,9 @@ func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *htt
if stocks[i].Created == "" {
stocks[i].Created = "2026-07-28"
}
if stocks[i].ShopName == "" {
stocks[i].ShopName = "测试店铺"
}
s := stocks[i]
byID[s.ID] = s
}
@@ -315,6 +321,16 @@ type integrityServerData struct {
func fakeIntegritySybServer(t *testing.T, data integrityServerData) *httptest.Server {
t.Helper()
for _, row := range data.list {
if _, ok := row["shopName"]; !ok {
row["shopName"] = "测试店铺"
}
}
for _, row := range data.details {
if _, ok := row["shopName"]; !ok {
row["shopName"] = "测试店铺"
}
}
totalCalls := 0
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -349,7 +365,100 @@ func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string,
func newSyncTestDB(t *testing.T) *sql.DB {
t.Helper()
return newTestDB(t)
db := newTestDB(t)
now := model.NowISO()
if err := repository.CreateUser(db, model.User{
UserID: "SYB-TEST-ADMIN", Username: "syb-test-admin", PasswordHash: "test",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("准备同步测试管理员失败: %v", err)
}
if err := repository.InsertSybAllowedShop(db, model.SybAllowedShop{
ShopID: "SYB-TEST-SHOP", ShopName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
CreatedByUserID: "SYB-TEST-ADMIN", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("准备同步测试允许店铺失败: %v", err)
}
return db
}
func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
db := newSyncTestDB(t)
if _, err := db.Exec(`UPDATE syb_allowed_shops SET enabled=0`); err != nil {
t.Fatal(err)
}
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
client, _ := syb.New(srv.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100}, time.Now(),
SybSyncOptions{From: "2026-08-11", To: "2026-08-11"})
if report.Err == nil || !strings.Contains(report.Err.Error(), "没有启用") {
t.Fatalf("实际错误: %v", report.Err)
}
if requests != 0 {
t.Fatalf("空白名单不应请求顺运宝,实际 %d 次", requests)
}
if report.CursorAdvanced {
t.Fatal("空白名单不得推进游标")
}
}
func TestSybShopAllowed_明细店铺覆盖列表后重新拦截(t *testing.T) {
allowed := map[string]struct{}{"测试店铺": {}}
if !sybShopAllowed(allowed, map[string]any{"shopName": " 测试店铺 "}, nil) {
t.Fatal("应忽略允许店铺名称首尾空白")
}
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}) {
t.Fatal("明细店铺变化后必须重新拦截")
}
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}) {
t.Fatal("明细店铺变为空值时必须拦截")
}
}
func TestRunSybSync_只请求并写入允许店铺(t *testing.T) {
db := newSyncTestDB(t)
detailIDs := []int64{}
stocks := []fakeStock{
{ID: 1, Code: "A", ShopName: " 测试店铺 ", Details: []fakeDetail{{ID: 11, ProductID: 111, ProductQty: 1}}},
{ID: 2, Code: "B", ShopName: "其他店铺", Details: []fakeDetail{{ID: 22, ProductID: 222, ProductQty: 1}}},
}
base := fakeSybServer(t, stocks, 0)
defer base.Close()
target, _ := url.Parse(base.URL)
reverseProxy := httputil.NewSingleHostReverseProxy(target)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/am/stock/detail/listByStock" {
var body struct {
IDs []int64 `json:"ids"`
}
data, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(data, &body)
detailIDs = append(detailIDs, body.IDs...)
r.Body = io.NopCloser(strings.NewReader(string(data)))
}
reverseProxy.ServeHTTP(w, r)
}))
defer proxy.Close()
client, _ := syb.New(proxy.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: proxy.URL, PageSize: 20, MaxMatches: 100},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
if report.Err != nil {
t.Fatal(report.Err)
}
if report.StockCount != 2 || report.AcceptedCount != 1 || report.ShopSkipped != 1 || report.Created != 1 {
t.Fatalf("店铺统计不正确: %+v", report)
}
if len(detailIDs) != 1 || detailIDs[0] != 1 {
t.Fatalf("明细请求应只有允许店铺,实际 %v", detailIDs)
}
}
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
@@ -789,7 +898,7 @@ func fakeGrowingTodaySybServer(t *testing.T, today string, growAttempts int) (*h
rows := make([]map[string]any, 0, count)
for i := 1; i <= count; i++ {
id := idOffset + int64(i)
rows = append(rows, map[string]any{"id": id, "code": fmt.Sprintf("ORDER-%d", id)})
rows = append(rows, map[string]any{"id": id, "code": fmt.Sprintf("ORDER-%d", id), "shopName": "测试店铺"})
}
return rows
}
@@ -833,7 +942,7 @@ func fakeGrowingTodaySybServer(t *testing.T, today string, growAttempts int) (*h
list := make([]map[string]any, 0, len(body.IDs))
for _, id := range body.IDs {
list = append(list, map[string]any{
"id": id, "code": fmt.Sprintf("ORDER-%d", id),
"id": id, "code": fmt.Sprintf("ORDER-%d", id), "shopName": "测试店铺",
"details": []map[string]any{{
"id": id + 1000, "productId": id + 10000, "productQty": 1,
}},