Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ba16e4f9 | ||
|
|
ff2d0ca0e5 |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 670a5592a6db8301cd115295bf820f7f4b6e06d7
|
||||
synchronized_at: 2026-09-07T03:21:47Z
|
||||
wiki_revision: c6b16b2394e12d764f610ce32b89baaf7e128642
|
||||
synchronized_at: 2026-09-07T07:01:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -73,7 +73,7 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
|
||||
## SYB 后台导入记录
|
||||
|
||||
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
|
||||
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
|
||||
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
|
||||
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
|
||||
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
|
||||
@@ -168,7 +168,7 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
|
||||
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
|
||||
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
|
||||
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
|
||||
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
|
||||
@@ -230,7 +230,7 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
|
||||
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
|
||||
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
|
||||
|
||||
## cmautobuy 商品导入
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
|
||||
synchronized_at: 2026-09-05T09:04:42Z
|
||||
wiki_revision: 17b247737f5d4f4016b4e3789f277039c0364027
|
||||
synchronized_at: 2026-09-07T07:02:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -75,7 +75,7 @@ POST /api/admin/v1/syb-products/reparse-batch
|
||||
PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
```
|
||||
|
||||
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
|
||||
synchronized_at: 2026-09-04T11:30:50Z
|
||||
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
|
||||
synchronized_at: 2026-09-07T07:02:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -228,9 +228,20 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
|
||||
|
||||
|
||||
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
|
||||
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
|
||||
由操作员明确选择日期,单次仍不得超过 31 天。
|
||||
|
||||
@@ -56,7 +56,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
|
||||
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
|
||||
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
|
||||
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
|
||||
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-products/import": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
|
||||
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
|
||||
}
|
||||
|
||||
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
|
||||
|
||||
@@ -113,8 +113,9 @@ func isImportAlreadyRunning(err error) bool {
|
||||
// `[必须]` This remains the authenticated manual entry point. Both it and the
|
||||
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
|
||||
func (handler Handler) Import(c *gin.Context) {
|
||||
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
|
||||
role := claimString(jwt.ExtractClaims(c)["rolekey"])
|
||||
if role != "admin" && role != "purchaser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
||||
return
|
||||
}
|
||||
var request ImportRequest
|
||||
|
||||
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRequiresAdminRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
|
||||
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
func TestImportRoleBoundary(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
// Authorized roles reach JSON validation; no DB or external SYB call is made.
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
want := http.StatusForbidden
|
||||
if role == "admin" || role == "purchaser" {
|
||||
want = http.StatusUnprocessableEntity
|
||||
}
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freezePaginationToday(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = previous })
|
||||
}
|
||||
|
||||
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
|
||||
f.pageIDs = func(_ string, start, call int) []int64 {
|
||||
if call == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
if call > 2 {
|
||||
return []int64{int64(2000 + start), int64(2001 + start)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
|
||||
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
|
||||
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
|
||||
t.Fatalf("did not restart at first page: %s", got)
|
||||
}
|
||||
for index, row := range rows {
|
||||
if row.ID != int64(2000+index) {
|
||||
t.Fatal("rows leaked from abandoned attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
|
||||
f.pageIDs = func(date string, start, _ int) []int64 {
|
||||
if date == "2026-08-29" && start == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
db := newSyncTestDB(t)
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
|
||||
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
ids []int64
|
||||
calls int
|
||||
}{
|
||||
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
|
||||
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
|
||||
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
|
||||
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
|
||||
f.pageIDs = func(_ string, start, _ int) []int64 {
|
||||
if start == tc.page {
|
||||
return tc.ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
|
||||
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/reparse-batch", handler.ReparseBatch)
|
||||
admin.POST("/import", handler.Import)
|
||||
// Run history is intentionally authenticated but not Casbin-gated: #50
|
||||
// makes it read-only for every role, while Import enforces admin itself.
|
||||
// makes it readable for every role; Import also requires admin/purchaser.
|
||||
readonly := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc())
|
||||
readonly.GET("/sync-runs", handler.ListSyncRuns)
|
||||
readonly.GET("/sync-runs/:runId", handler.SyncRunDetail)
|
||||
|
||||
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
seen := make(map[int64]struct{}, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
|
||||
for start := 0; start < expectedTotal; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
@@ -386,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
for _, row := range page {
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
for index, row := range page {
|
||||
if row.ID <= 0 {
|
||||
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
|
||||
}
|
||||
if _, duplicate := seen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
|
||||
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
|
||||
}
|
||||
seen[row.ID] = struct{}{}
|
||||
pageSeen[row.ID] = index + 1
|
||||
}
|
||||
for index, row := range page {
|
||||
if first, duplicate := seen[row.ID]; duplicate {
|
||||
// No rows/valid flag: overlapping pages are never eligible for
|
||||
// the existing final-attempt degraded save, even after deduping.
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
|
||||
}
|
||||
seen[row.ID] = position{page: pageIndex, row: index + 1}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(page) != expectedPageCount {
|
||||
|
||||
@@ -60,6 +60,9 @@ type fakeSYB struct {
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
listCalls []string
|
||||
pageIDs func(date string, start, call int) []int64
|
||||
detailCalls int
|
||||
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
start := int(body["start"].(float64))
|
||||
pageSize := int(body["length"].(float64))
|
||||
total := f.perDay[date]
|
||||
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
|
||||
|
||||
rows := []map[string]any{}
|
||||
for i := start; i < total && len(rows) < pageSize; i++ {
|
||||
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
rows = rows[:len(rows)-1]
|
||||
}
|
||||
if f.pageIDs != nil {
|
||||
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
|
||||
rows = nil
|
||||
for _, id := range ids {
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
})
|
||||
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.detailCalls++
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ func TestEnsurePurchaserRoleAndPolicies(t *testing.T) {
|
||||
if count != int64(len(access.PurchaserAPIs())) {
|
||||
t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs()))
|
||||
}
|
||||
var forbidden int64
|
||||
var allowed int64
|
||||
db.Model(&purchaserCasbinRule{}).
|
||||
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST").
|
||||
Count(&forbidden)
|
||||
if forbidden != 0 {
|
||||
t.Fatal("manual SYB import must remain administrator-only")
|
||||
Count(&allowed)
|
||||
if allowed != 1 {
|
||||
t.Fatal("manual SYB import must follow the current purchaser permission matrix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员可以立即同步今天和昨天的数据。</p></div>
|
||||
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员和采购员可以立即同步今天和昨天的数据。</p></div>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
v-if="canStartSync"
|
||||
type="primary"
|
||||
:loading="manualSyncStarting"
|
||||
:disabled="manualSyncStarting || hasRunningSync"
|
||||
@@ -86,7 +86,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
|
||||
},
|
||||
async startManualSync() {
|
||||
if (this.manualSyncStarting || this.hasRunningSync) return
|
||||
if (!this.canStartSync || this.manualSyncStarting || this.hasRunningSync) return
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
|
||||
this.manualSyncStarting = true
|
||||
|
||||
@@ -4,6 +4,8 @@ async function authenticate(context: any) {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
|
||||
}
|
||||
|
||||
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
|
||||
|
||||
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
await page.route('**/api/**', async route => {
|
||||
@@ -12,12 +14,12 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs/51')) return route.fulfill({ json: { code: 200, data: { item: { id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, progressPercent: 66, orderCount: 12, detailCount: 8, acceptedCount: 10, shopSkipped: 2, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z', finishedAt: '2026-08-20T01:05:00Z', errorMessage: '第 3 天读取失败,等待下个调度周期重试', shopFilterHash: 'abc123', shopBreakdown: [{ shopName: '大碼臻選', accepted: 10, skipped: 0 }, { shopName: '未知店铺', accepted: 0, skipped: 2 }] } } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, orderCount: 12, detailCount: 8, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z' }], total: 1, page: 1, pageSize: 20 } } });
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '立即同步' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '立即同步' })).toBeVisible();
|
||||
await expect(page.getByText('2026-08-18 至 2026-08-20')).toBeVisible();
|
||||
await page.getByRole('button', { name: '详情' }).click();
|
||||
await expect(page.getByText('第 3 天读取失败,等待下个调度周期重试')).toBeVisible();
|
||||
@@ -26,14 +28,15 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
|
||||
await expect(page.getByText('系统定时同步', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员可立即同步今天和昨天,受理后按钮进入运行中状态', async ({ page, context }) => {
|
||||
for (const role of ['admin', 'purchaser']) {
|
||||
test(`${role} 可立即同步今天和昨天,受理后按钮进入运行中状态`, async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
let importBody: any = null;
|
||||
let importAccepted = false;
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue();
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/import') && route.request().method() === 'POST') {
|
||||
importBody = route.request().postDataJSON();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
@@ -44,10 +47,10 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
|
||||
const items = importAccepted ? [{ id: 88, dateFrom: importBody.dateFrom, dateTo: importBody.dateTo, status: 'running', progressPercent: 0, daysProcessed: 0, daysTotal: 2, orderCount: 0, detailCount: 0, created: 0, updated: 0, operatorName: '管理员', startedAt: '2026-08-24T08:00:00Z' }] : [];
|
||||
return route.fulfill({ json: { code: 200, data: { items, total: items.length, page: 1, pageSize: 20 } } });
|
||||
}
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
const button = page.getByRole('button', { name: '立即同步' });
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
@@ -60,6 +63,37 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
|
||||
const formatDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
expect(importBody).toEqual({ dateFrom: formatDate(yesterday), dateTo: formatDate(now) });
|
||||
});
|
||||
}
|
||||
|
||||
for (const role of ['purchaser', 'viewer']) {
|
||||
test(`${role} 同步权限及失败恢复`, async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
let requests = 0;
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue();
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
|
||||
requests++;
|
||||
return route.fulfill({ status: 422, json: { code: 'INVALID_REQUEST', message: '已有同步正在执行,请稍后再试' } });
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 } } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
|
||||
const button = page.getByRole('button', { name: '立即同步' });
|
||||
if (role === 'viewer') {
|
||||
await expect(button).toHaveCount(0);
|
||||
expect(requests).toBe(0);
|
||||
} else {
|
||||
await button.click();
|
||||
await expect(page.getByText('已有同步正在执行,请稍后再试')).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
expect(requests).toBe(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('SYB 定时任务可进入执行记录,其他任务不显示该入口', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
@@ -73,7 +107,7 @@ test('SYB 定时任务可进入执行记录,其他任务不显示该入口', a
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/schedule/index');
|
||||
await page.goto('/#/schedule/index');
|
||||
await expect(page.getByText('SYB 每小时自动同步', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '执行记录', exact: true })).toHaveCount(1);
|
||||
await page.getByRole('button', { name: '执行记录', exact: true }).click();
|
||||
|
||||
Reference in New Issue
Block a user