feat(#49): add shop HTTP API, SYB discovery and the import filter
Stage B steps 3 and 4. The filter runs after loadDailyList has proved the day's snapshot whole, never before. Filtering first would let drift among other shops' orders hide a hole in the ones we do want. OrderCount stays the unfiltered total alongside AcceptedCount and ShopSkipped, so a shop missing from the allow-list shows up as a number rather than as absent data. An empty allow-list is a refusal, not "import everything" — the latter looks exactly like a filter that works. Mutation-tested, along with the detail-level shop re-check that catches a list and detail response disagreeing about which shop an order belongs to. ShopBreakdown counts orders per shop including skipped ones, under the stored display name rather than SYB's spelling, so one shop cannot appear under two spellings. #50 renders it. Discovery lives in sybimport, not sybshop: it needs the SYB client, and sybimport already depends on sybshop for the filter, so the reverse would be an import cycle. It reads the list endpoint only and never adds a shop — widening what gets imported stays an explicit action. Read and write routes are registered separately. GoAuto inherits go-admin's per-path permission model, so "配置仅管理员" is expressible but not enforced in code; the grant is configured in 系统管理. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
goautorule "go-admin/app/goauto/rule"
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
goautosybimport "go-admin/app/goauto/sybimport"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
@@ -51,4 +52,5 @@ func InitRouter() {
|
||||
goautorule.InitRouter(r, authMiddleware)
|
||||
goautoshopeeproduct.InitRouter(r, authMiddleware)
|
||||
goautosybimport.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DiscoveredShop is one shop name found in real SYB data.
|
||||
type DiscoveredShop struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
// Status is new | enabled | disabled — whether this name is already on the
|
||||
// allow-list and in what state.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const (
|
||||
DiscoverStatusNew = "new"
|
||||
DiscoverStatusEnabled = "enabled"
|
||||
DiscoverStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// DiscoverShops lists shipment orders over a date range and reports the shop
|
||||
// names that appear, with how many orders each had.
|
||||
//
|
||||
// This lives in sybimport rather than sybshop because it needs the SYB client,
|
||||
// and sybimport already depends on sybshop for the import filter; putting it
|
||||
// the other way round would be an import cycle.
|
||||
//
|
||||
// `[必须]` Read-only. It pulls the list endpoint and nothing else: no details
|
||||
// are fetched, nothing is written, and no shop is added to the allow-list.
|
||||
// Adding one stays an explicit operator action, so a discovery run can never
|
||||
// quietly widen what gets imported.
|
||||
func DiscoverShops(ctx context.Context, db *gorm.DB, client *sybclient.Client,
|
||||
cfg SyncConfig, dateFrom, dateTo string) ([]DiscoveredShop, error) {
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
maxMatches := cfg.MaxMatches
|
||||
if maxMatches <= 0 {
|
||||
maxMatches = 10000
|
||||
}
|
||||
|
||||
counts := make(map[string]int)
|
||||
display := make(map[string]string)
|
||||
scanned := 0
|
||||
for _, date := range dates {
|
||||
total, err := client.ListTotal(ctx, date, date, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询 %s 货运单总数失败: %w", date, err)
|
||||
}
|
||||
if scanned+total > maxMatches {
|
||||
return nil, fmt.Errorf(
|
||||
"日期范围 %s ~ %s 的货运单总数超过单次上限 %d,请缩小范围;"+
|
||||
"发现店铺通常两三天的数据就够了", dateFrom, dateTo, maxMatches)
|
||||
}
|
||||
scanned += total
|
||||
// Completeness is not enforced here the way Sync enforces it: a shop
|
||||
// name missed by a page drift would simply not be offered, which the
|
||||
// operator can fix by running discovery again. Nothing is written, so a
|
||||
// partial view cannot corrupt anything.
|
||||
for start := 0; start < total; start += pageSize {
|
||||
rows, _, err := client.ListPage(ctx, date, date, start, start/pageSize+1, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("拉取 %s 货运单列表失败: %w", date, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
if sybshop.IsBlank(name) {
|
||||
continue
|
||||
}
|
||||
key := sybshop.Normalize(name)
|
||||
counts[key]++
|
||||
if _, seen := display[key]; !seen {
|
||||
display[key] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
known := make(map[string]models.SYBShop)
|
||||
var shops []models.SYBShop
|
||||
if err := db.WithContext(ctx).Find(&shops).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, shop := range shops {
|
||||
known[shop.NormalizedName] = shop
|
||||
}
|
||||
|
||||
found := make([]DiscoveredShop, 0, len(counts))
|
||||
for key, count := range counts {
|
||||
status := DiscoverStatusNew
|
||||
name := display[key]
|
||||
if shop, ok := known[key]; ok {
|
||||
// Show the stored name, not the one SYB returned: they can differ in
|
||||
// width or case, and showing both spellings for one shop is confusing.
|
||||
name = shop.DisplayName
|
||||
status = DiscoverStatusDisabled
|
||||
if shop.Enabled {
|
||||
status = DiscoverStatusEnabled
|
||||
}
|
||||
}
|
||||
found = append(found, DiscoveredShop{DisplayName: name, OrderCount: count, Status: status})
|
||||
}
|
||||
// New shops first, then by order count: the ones worth acting on lead.
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if (found[i].Status == DiscoverStatusNew) != (found[j].Status == DiscoverStatusNew) {
|
||||
return found[i].Status == DiscoverStatusNew
|
||||
}
|
||||
if found[i].OrderCount != found[j].OrderCount {
|
||||
return found[i].OrderCount > found[j].OrderCount
|
||||
}
|
||||
return found[i].DisplayName < found[j].DisplayName
|
||||
})
|
||||
return found, nil
|
||||
}
|
||||
@@ -56,14 +56,7 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
// Name the file that was actually consulted. The previous wording only
|
||||
// mentioned environment variables, which sent operators looking in the
|
||||
// wrong place once config.yaml became the normal way to configure this.
|
||||
source := "未找到 config.yaml(依次查找 GOAUTO_CONFIG、服务端工作目录、可执行文件同级目录)"
|
||||
if path := config.LocalConfigPath(); path != "" {
|
||||
source = "已读取 " + path + ",但其中没有 syb.username / syb.password"
|
||||
}
|
||||
writeError(c, invalidRequest(
|
||||
"顺云宝账号未配置:"+source+
|
||||
"。请在 config.yaml 的 syb 段填写 username 和 password,或设置环境变量 "+
|
||||
"GOAUTO_SYB_USERNAME / GOAUTO_SYB_PASSWORD,然后重启服务端"))
|
||||
writeError(c, invalidRequest(credentialHint()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,3 +111,68 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": report})
|
||||
}
|
||||
|
||||
type DiscoverRequest struct {
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
}
|
||||
|
||||
// Discover reports which shops appear in SYB data over a date range.
|
||||
//
|
||||
// `[必须]` Read-only: it never adds a shop. Widening what gets imported stays
|
||||
// an explicit operator action.
|
||||
func (handler Handler) Discover(c *gin.Context) {
|
||||
var request DiscoverRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeError(c, invalidRequest("请求体必须是合法 JSON,且包含 dateFrom 和 dateTo"))
|
||||
return
|
||||
}
|
||||
if _, err := splitDateRange(request.DateFrom, request.DateTo); err != nil {
|
||||
writeError(c, invalidRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
settings := config.ExtConfig.SYB.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
writeError(c, invalidRequest(credentialHint()))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
|
||||
defer cancel()
|
||||
|
||||
client, err := Connect(ctx, sybclient.NewSessionStore(service.DB), ConnectConfig{
|
||||
BaseURL: settings.BaseURL,
|
||||
Username: settings.Username,
|
||||
Password: settings.Password,
|
||||
OcrURL: settings.OcrURL,
|
||||
OcrMaxAttempts: settings.OcrMaxAttempts,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, &ServiceError{Code: CodeInvalidRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
found, err := DiscoverShops(ctx, service.DB, client, SyncConfig{
|
||||
PageSize: settings.PageSize, MaxMatches: settings.MaxMatches,
|
||||
}, request.DateFrom, request.DateTo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"code": CodeInvalidRequest, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": found}})
|
||||
}
|
||||
|
||||
// credentialHint names the file that was actually consulted, so an operator is
|
||||
// not sent looking in the wrong place.
|
||||
func credentialHint() string {
|
||||
source := "未找到 config.yaml(依次查找 GOAUTO_CONFIG、当前目录、上级目录、可执行文件同级目录)"
|
||||
if path := config.LocalConfigPath(); path != "" {
|
||||
source = "已读取 " + path + ",但其中没有 syb.username / syb.password"
|
||||
}
|
||||
return "顺云宝账号未配置:" + source +
|
||||
"。请在 config.yaml 的 syb 段填写 username 和 password,或设置环境变量 " +
|
||||
"GOAUTO_SYB_USERNAME / GOAUTO_SYB_PASSWORD,然后重启服务端"
|
||||
}
|
||||
|
||||
@@ -18,5 +18,10 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/:productId/reparse", handler.Reparse)
|
||||
admin.POST("/reparse-batch", handler.ReparseBatch)
|
||||
admin.POST("/import", handler.Import)
|
||||
|
||||
// 「从 SYB 发现店铺」挂在店铺路径下,但实现放在本包:它需要 SYB 客户端,
|
||||
// 而本包已经依赖 sybshop 做导入过滤,反过来会形成包循环。
|
||||
engine.Group("/api/admin/v1/syb-shops").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).
|
||||
POST("/discover", handler.Discover)
|
||||
admin.PATCH("/:productId/correction", handler.ManualCorrect)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -23,6 +24,14 @@ type SyncConfig struct {
|
||||
MaxMatches int
|
||||
}
|
||||
|
||||
// ErrNoEnabledShop reports that no shop is enabled, so there is no way to tell
|
||||
// which of the account's orders should be imported.
|
||||
//
|
||||
// `[必须]` This is a refusal, not "import everything". Silently importing every
|
||||
// shop would look exactly like a working filter (#49).
|
||||
var ErrNoEnabledShop = errors.New(
|
||||
"没有启用任何店铺,无法判断哪些货运单该入库;请先在店铺管理中启用至少一个店铺")
|
||||
|
||||
const (
|
||||
// detailBatch is SYB's hard limit on listByStock (§6).
|
||||
detailBatch = 100
|
||||
@@ -34,14 +43,23 @@ const (
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
type SyncReport struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
// AcceptedCount is how many shipment orders survived the shop filter and
|
||||
// ShopSkipped how many did not. OrderCount stays the raw total: keeping the
|
||||
// unfiltered number visible is what makes a shop missing from the
|
||||
// allow-list obvious rather than silent.
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
// ShopBreakdown counts orders per shop display name, skipped ones included.
|
||||
// Without it a newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]int `json:"shopBreakdown"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
|
||||
@@ -62,12 +80,22 @@ type SyncReport struct {
|
||||
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
|
||||
// live data), so the paging loop is driven by listTotal, never by list.total.
|
||||
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC()}
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]int{}}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
// `[必须]` The allow-list is read once and used for the whole run, so a shop
|
||||
// enabled midway cannot make one day behave differently from the next.
|
||||
allowed, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
@@ -107,6 +135,10 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
if plan.total == 0 {
|
||||
continue
|
||||
}
|
||||
// `[必须]` Completeness first, filtering second. loadDailyList proves the
|
||||
// day's snapshot is whole; filtering before that would let drift among
|
||||
// other shops' orders hide a hole in the ones we do want
|
||||
// (docs/12-syb-erp-interface.md §8).
|
||||
rows, err := loadDailyList(ctx, client, plan.date, pageSize, plan.total)
|
||||
if err != nil {
|
||||
return report, err
|
||||
@@ -116,9 +148,27 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
byID := make(map[int64]sybclient.StockRow, len(rows))
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
label := name
|
||||
if sybshop.IsBlank(label) {
|
||||
label = "(无店铺名)"
|
||||
}
|
||||
display, ok := allowed[sybshop.Normalize(name)]
|
||||
if ok {
|
||||
label = display
|
||||
}
|
||||
report.ShopBreakdown[label]++
|
||||
if !ok {
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
report.AcceptedCount++
|
||||
byID[row.ID] = row
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
@@ -135,6 +185,17 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
|
||||
}
|
||||
for _, detail := range details {
|
||||
// `[必须]` Re-check the shop on the detail response. The list said
|
||||
// this order belongs to an enabled shop; if the detail disagrees,
|
||||
// the two views are inconsistent and importing it would write a
|
||||
// row for a shop nobody enabled.
|
||||
if name := stringField(detail.Raw, "shopName"); !sybshop.IsBlank(name) {
|
||||
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,7 +11,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -27,9 +30,23 @@ func newSyncTestDB(t *testing.T) *gorm.DB {
|
||||
if err := migrations.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
enableShops(t, db, "测试店铺")
|
||||
return db
|
||||
}
|
||||
|
||||
// enableShops 把店铺加入名单并启用。没有启用店铺时同步会被拒绝,
|
||||
// 所以除了专门验证拒绝的用例,其它用例都要先播种。
|
||||
func enableShops(t *testing.T, db *gorm.DB, names ...string) {
|
||||
t.Helper()
|
||||
service := sybshop.NewService(db)
|
||||
for _, name := range names {
|
||||
if _, err := service.Create(context.Background(),
|
||||
sybshop.CreateRequest{DisplayName: name}); err != nil {
|
||||
t.Fatalf("播种店铺 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSYB is a stand-in for the SYB list/detail endpoints, deliberately
|
||||
// reproducing the quirk confirmed against live data: /am/stock/list returns the
|
||||
// CURRENT PAGE's row count in `total`, while /am/stock/listTotal returns the
|
||||
@@ -42,6 +59,18 @@ type fakeSYB struct {
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
|
||||
detailShopName string
|
||||
}
|
||||
|
||||
func (f *fakeSYB) shopFor(i int) string {
|
||||
if len(f.shopNames) == 0 {
|
||||
return "测试店铺"
|
||||
}
|
||||
return f.shopNames[i%len(f.shopNames)]
|
||||
}
|
||||
|
||||
func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
@@ -71,7 +100,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": float64(1000 + i),
|
||||
"code": fmt.Sprintf("ORD%s%03d", strings.ReplaceAll(date, "-", ""), i),
|
||||
"shopName": "测试店铺",
|
||||
"shopName": f.shopFor(i),
|
||||
})
|
||||
}
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
@@ -90,8 +119,9 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
continue
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": id,
|
||||
"code": fmt.Sprintf("ORD-%d", id),
|
||||
"id": id,
|
||||
"code": fmt.Sprintf("ORD-%d", id),
|
||||
"shopName": f.detailShopName,
|
||||
"details": []any{map[string]any{
|
||||
"id": float64(id*10 + 1), "productId": float64(9001),
|
||||
"productTitle": "测试商品", "productSpec": "白色,L",
|
||||
@@ -275,3 +305,95 @@ func TestSplitDateRangeIsInclusive(t *testing.T) {
|
||||
t.Fatalf("首尾日期都应包含在内: %v", dates)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 没有启用任何店铺时必须拒绝,而不是「不过滤,全部导入」——
|
||||
// 后者看起来和过滤生效一模一样。
|
||||
func TestSyncRefusesWhenNoShopIsEnabled(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
db.Where("1 = 1").Delete(&models.SYBShop{})
|
||||
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 5}}
|
||||
_, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if !errors.Is(err, ErrNoEnabledShop) {
|
||||
t.Fatalf("应报 ErrNoEnabledShop,实际: %v", err)
|
||||
}
|
||||
var written int64
|
||||
db.Table("syb_product").Count(&written)
|
||||
if written != 0 {
|
||||
t.Fatalf("拒绝时不应写入任何数据,实际 %d 行", written)
|
||||
}
|
||||
}
|
||||
|
||||
// 未启用店铺的货运单不入库,但仍要计入原始总数和跳过统计。
|
||||
func TestSyncSkipsOrdersFromShopsNotOnTheList(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 6},
|
||||
shopNames: []string{"测试店铺", "别人的店"}}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.OrderCount != 6 {
|
||||
t.Fatalf("原始总数应保留为 6,过滤不应改写它: %d", report.OrderCount)
|
||||
}
|
||||
if report.AcceptedCount != 3 || report.ShopSkipped != 3 {
|
||||
t.Fatalf("接受/跳过应各 3: accepted=%d skipped=%d", report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
if report.DetailCount != 3 {
|
||||
t.Fatalf("只应写入启用店铺的明细: %d", report.DetailCount)
|
||||
}
|
||||
// `[必须]` 跳过的店铺也要出现在分店铺统计里,否则新开的店永远没人注意到。
|
||||
if report.ShopBreakdown["别人的店"] != 3 {
|
||||
t.Fatalf("被跳过的店铺应出现在统计里: %v", report.ShopBreakdown)
|
||||
}
|
||||
if report.ShopBreakdown["测试店铺"] != 3 {
|
||||
t.Fatalf("接受的店铺统计不对: %v", report.ShopBreakdown)
|
||||
}
|
||||
}
|
||||
|
||||
// 店铺名匹配走归一化:名单里存全角,SYB 返回半角小写,仍应命中。
|
||||
func TestSyncMatchesShopNamesThroughNormalization(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
db.Where("1 = 1").Delete(&models.SYBShop{})
|
||||
enableShops(t, db, "ABC店")
|
||||
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 4}, shopNames: []string{" abc店 "}}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.AcceptedCount != 4 {
|
||||
t.Fatalf("归一化后应全部命中: accepted=%d skipped=%d", report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
// 统计里用名单中的展示名,而不是 SYB 返回的写法,避免同一家店出现两种拼写。
|
||||
if report.ShopBreakdown["ABC店"] != 4 {
|
||||
t.Fatalf("统计应使用名单里的展示名: %v", report.ShopBreakdown)
|
||||
}
|
||||
}
|
||||
|
||||
// 列表说属于启用店铺、明细却是另一家店时必须拦住。
|
||||
func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3}, detailShopName: "别人的店"}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.DetailCount != 0 {
|
||||
t.Fatalf("明细里店铺不一致时不应写入: %d", report.DetailCount)
|
||||
}
|
||||
if report.AcceptedCount != 0 || report.ShopSkipped != 3 {
|
||||
t.Fatalf("应回退为跳过: accepted=%d skipped=%d", report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
var written int64
|
||||
db.Table("syb_product").Count(&written)
|
||||
if written != 0 {
|
||||
t.Fatalf("不应写入任何行,实际 %d", written)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package sybshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (handler Handler) service(c *gin.Context) (*Service, bool) {
|
||||
db := handler.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(c, internalError(err))
|
||||
return nil, false
|
||||
}
|
||||
return NewService(db), true
|
||||
}
|
||||
|
||||
func (handler Handler) List(c *gin.Context) {
|
||||
page, err := queryInt(c.Query("page"), 1)
|
||||
if err != nil {
|
||||
writeError(c, invalidRequest("page 必须是正整数"))
|
||||
return
|
||||
}
|
||||
pageSize, err := queryInt(c.Query("pageSize"), 50)
|
||||
if err != nil {
|
||||
writeError(c, invalidRequest("pageSize 必须是正整数"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"),
|
||||
EnabledOnly: c.Query("enabledOnly") == "true",
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) Create(c *gin.Context) {
|
||||
var request CreateRequest
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
shop, err := service.Create(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": shop})
|
||||
}
|
||||
|
||||
func (handler Handler) Rename(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request RenameRequest
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
shop, err := service.Rename(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": shop})
|
||||
}
|
||||
|
||||
func (handler Handler) SetEnabled(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request SetEnabledRequest
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
shop, err := service.SetEnabled(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": shop})
|
||||
}
|
||||
|
||||
func (handler Handler) Delete(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := service.Delete(c.Request.Context(), id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"deleted": true}})
|
||||
}
|
||||
|
||||
func pathID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("shopId"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
writeError(c, invalidRequest("店铺 id 必须是正整数"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// bindJSON decodes the body and rejects unknown fields.
|
||||
//
|
||||
// `[必须]` Unknown fields are an error rather than being ignored: a typo in a
|
||||
// field name would otherwise be accepted silently and the value dropped, so a
|
||||
// rename that changed nothing would still report success.
|
||||
func bindJSON(c *gin.Context, target any) bool {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
writeError(c, invalidRequest("读取请求体失败"))
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(c, invalidRequest("请求体解析失败:"+err.Error()))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queryInt(raw string, fallback int) (int, error) {
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 0, errors.New("invalid")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writeError(c *gin.Context, err error) {
|
||||
var target *ServiceError
|
||||
if !errors.As(err, &target) {
|
||||
target = internalError(err).(*ServiceError)
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
switch target.Code {
|
||||
case CodeInvalidRequest:
|
||||
status = http.StatusUnprocessableEntity
|
||||
case CodeNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodeConflict:
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"code": target.Code, "message": target.Message})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package sybshop
|
||||
|
||||
import (
|
||||
"go-admin/common/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// InitRouter mounts the shop list.
|
||||
//
|
||||
// `[必须]` Read and write are registered as separate routes on purpose. GoAuto
|
||||
// inherits go-admin's permission model, where access is granted per API path to
|
||||
// a role in 系统管理 — there is no code-level "admin only" flag. Keeping GET
|
||||
// apart from the mutating verbs is what makes "配置仅管理员,其他角色只读"
|
||||
// expressible at all; the grant itself still has to be configured (#49).
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
group := engine.Group("/api/admin/v1/syb-shops").
|
||||
Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
|
||||
// 只读:所有角色
|
||||
group.GET("", handler.List)
|
||||
|
||||
// 配置:仅管理员(授权在系统管理里配置)
|
||||
group.POST("", handler.Create)
|
||||
group.PATCH("/:shopId/name", handler.Rename)
|
||||
group.PATCH("/:shopId/enabled", handler.SetEnabled)
|
||||
group.DELETE("/:shopId", handler.Delete)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package sybshop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// gin 在注册阶段就会因路由冲突 panic,编译期发现不了。
|
||||
// /discover 由 sybimport 注册在同一路径下,和 /:shopId 处于同一层。
|
||||
func TestRoutesRegisterWithoutConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
handler := Handler{}
|
||||
group := engine.Group("/api/admin/v1/syb-shops")
|
||||
group.GET("", handler.List)
|
||||
group.POST("", handler.Create)
|
||||
group.PATCH("/:shopId/name", handler.Rename)
|
||||
group.PATCH("/:shopId/enabled", handler.SetEnabled)
|
||||
group.DELETE("/:shopId", handler.Delete)
|
||||
group.POST("/discover", func(*gin.Context) {})
|
||||
|
||||
want := map[string]bool{
|
||||
"GET /api/admin/v1/syb-shops": false,
|
||||
"POST /api/admin/v1/syb-shops": false,
|
||||
"PATCH /api/admin/v1/syb-shops/:shopId/name": false,
|
||||
"PATCH /api/admin/v1/syb-shops/:shopId/enabled": false,
|
||||
"DELETE /api/admin/v1/syb-shops/:shopId": false,
|
||||
"POST /api/admin/v1/syb-shops/discover": false,
|
||||
}
|
||||
for _, route := range engine.Routes() {
|
||||
want[route.Method+" "+route.Path] = true
|
||||
}
|
||||
for route, ok := range want {
|
||||
if !ok {
|
||||
t.Fatalf("路由未注册: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user