diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 22c6f62..7111b6f 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -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) } diff --git a/server/app/goauto/sybimport/discover.go b/server/app/goauto/sybimport/discover.go new file mode 100644 index 0000000..ab803c7 --- /dev/null +++ b/server/app/goauto/sybimport/discover.go @@ -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 +} diff --git a/server/app/goauto/sybimport/import_handler.go b/server/app/goauto/sybimport/import_handler.go index 0fdfd62..c4d4f3c 100644 --- a/server/app/goauto/sybimport/import_handler.go +++ b/server/app/goauto/sybimport/import_handler.go @@ -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,然后重启服务端" +} diff --git a/server/app/goauto/sybimport/router.go b/server/app/goauto/sybimport/router.go index 64422fc..393695d 100644 --- a/server/app/goauto/sybimport/router.go +++ b/server/app/goauto/sybimport/router.go @@ -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) } diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index 29498d4..f35effd 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -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 } diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go index 58fcf28..e61f03a 100644 --- a/server/app/goauto/sybimport/sync_test.go +++ b/server/app/goauto/sybimport/sync_test.go @@ -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) + } +} diff --git a/server/app/goauto/sybshop/handler.go b/server/app/goauto/sybshop/handler.go new file mode 100644 index 0000000..817f9b0 --- /dev/null +++ b/server/app/goauto/sybshop/handler.go @@ -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}) +} diff --git a/server/app/goauto/sybshop/router.go b/server/app/goauto/sybshop/router.go new file mode 100644 index 0000000..16ee21c --- /dev/null +++ b/server/app/goauto/sybshop/router.go @@ -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) +} diff --git a/server/app/goauto/sybshop/router_test.go b/server/app/goauto/sybshop/router_test.go new file mode 100644 index 0000000..8e9f335 --- /dev/null +++ b/server/app/goauto/sybshop/router_test.go @@ -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) + } + } +}