Compare commits

...
Author SHA1 Message Date
QiuSW 3b95d4e879 feat(yeeke): sync reshipped returns and exclude new matching (#345) 2026-09-28 16:30:37 +08:00
QiuSW bce4897390 merge: serve Admin SPA at / and document nginx deployment (#346) 2026-09-28 16:16:20 +08:00
QiuSWandClaude Opus 5.5 c13dcd3ebc docs: record nginx 9527 config, content-based checks and migration (#346)
Mirror of Wiki Deployment-and-Operations revision 3a76e16: current host
122.228.200.167, the standard 9527 vhost (nginx serves dist, / returns
index.html) and why, release verification by content rather than status
code, a server-migration checklist, and the 2026-09-28 migration fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:14:43 +08:00
QiuSWandClaude Opus 5.5 0b01b92c98 fix(server): serve the Admin SPA at / when dist exists (#346)
go-admin registered its welcome page on GET / in every non-prod mode, so a
reverse proxy that forwarded / to the server showed 「GO-ADMIN欢迎您」
instead of the Admin (happened after the 2026-09-28 server migration).
When dist/index.html exists, / now returns it; without a dist (vite
development) the previous welcome/prod behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:07:49 +08:00
QiuSWandClaude Opus 5.5 9dace6ac89 fix(sybinnercode): add identity/stall/id guards to #289 assignment (#344)
Port the remaining consistency guards from cmautobuy's
planExistingMatchedInnerCodeItems into assignExistingBoundItems:

- Reject the whole multi-piece group if any candidate's raw ProductSpec,
  sku or variationSku differs from the lowest-ID candidate. Candidates
  are matched via NormalizeSpecKey, so raw values can legitimately differ
  even when normalized keys agree; auto-assigning across genuinely
  different items must be blocked.
- Re-verify each candidate against stallMatches when record.Stall is
  non-empty, since the no-SKU fallback path in matchEvidence can hand
  back candidates that were never stall-checked.
- Reject candidates with a non-positive or duplicate detail ID.

Added one regression test per guard plus a happy-path test confirming
legitimate multi-piece assignment still succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 10:35:16 +08:00
QiuSWandClaude Opus 5.5 e99bbc2327 fix(sybinnercode): align stall article matching with cmautobuy rules (#344)
Port cmautobuy's innerCodeStallMatches rule set (#259/#273 fixes) into
strictStall's underlying match: split stall on the last '#', compare the
article only against alphanumeric tokens, require leading-zero equivalence
plus stall-name confirmation for numeric articles, exact token match for
non-numeric articles, and a ProductSpec-prefix rule. This replaces the old
plain substring containment that could bind an inbound code to the wrong
SYB product detail (weight numbers mistaken for articles, short numeric
articles matching inside long codes, 067/67 not aligning, stall names
containing '#' splitting incorrectly).

Also fixes #289: when N single-piece inbound codes are matched against N
existing qty=1 SYB details and some details already carry a correctly
bound code out of ID order, planRecord now preserves those existing
bindings (matching by code value first via assignExistingBoundItems) and
only assigns the remaining blank details to the missing codes, instead of
reassigning by index/ID order and overwriting a correct binding.

Added regression tests for both fixes, including an end-to-end
RunMatchJob test reproducing the #289 bug against the pre-fix assignment
(verified to fail on the old code, pass on the new code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 10:31:11 +08:00
QiuSW abdf90775c docs: record unified SYB writeback sessions (#343) 2026-09-27 23:00:46 +08:00
QiuSW 934a7beabc fix(purchase): unify SYB session refresh for writeback (#343) 2026-09-27 22:56:54 +08:00
QiuSW 24100f04d4 merge: place return matching after purchase (#342) 2026-09-27 17:24:06 +08:00
QiuSW 266418ac47 fix(syb): place return matching after purchase (#342) 2026-09-27 17:23:57 +08:00
QiuSW 5e5fdd797e merge: arrange SYB filter row (#342) 2026-09-27 17:19:45 +08:00
QiuSW 58c8c077ad fix(syb): arrange created-time filter row (#342) 2026-09-27 17:19:35 +08:00
QiuSW 9f679f6403 merge: SYB created-time filter and page size (#342) 2026-09-27 16:59:54 +08:00
QiuSW 977ac235e2 feat(syb): filter products by created time (#342) 2026-09-27 16:59:22 +08:00
QiuSWandClaude Opus 5.5 573f5b8cf3 feat(syb): support 200/500 rows per page on SYB products list (#339)
Bump the syb-products page-size options to 20/50/100/200/500 with a new
100 default, cap the server-side sybimport.List page size at 500, chunk
the per-page purchase-readiness preview into <=100-id requests, and add
per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购,
创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a
batch endpoint's cap. 创建采购's 100-item server cap is left untouched.
Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-27 16:52:09 +08:00
QiuSW 1491a9677a fix(access): allow after-sales purchase readiness (#341) 2026-09-27 11:59:31 +08:00
QiuSW d4d32c6e89 docs: restore wiki mirrors after return-match update (#338) 2026-09-27 11:46:28 +08:00
QiuSW b7038633f0 test(returnmatch): cover after-sales access and migration repair (#341) 2026-09-27 11:38:43 +08:00
QiuSW 844f611d77 fix(access): repair return-match API catalog migration (#341) 2026-09-27 11:25:01 +08:00
QiuSW d415971268 feat(returnmatch): publish return matching and after-sales access (#338 #341) 2026-09-27 11:16:18 +08:00
QiuSW 6fe588d5e3 fix(web): show after-sales GoAuto actions (#341) 2026-09-27 11:04:25 +08:00
QiuSW 17cbfaec85 feat(access): add after-sales role and accounts (#341) 2026-09-27 10:49:34 +08:00
50 changed files with 1950 additions and 225 deletions
+36
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 04cc54a554fc3bdc30273e6a5ace5fff5d95386f
synchronized_at: 2026-09-28T08:28:30Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
synchronized_at: 2026-09-27T02:47:22Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
@@ -520,3 +536,23 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
## GoAuto 售后角色与退货模块权限(#341)
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
## #338 退货匹配(2026-09-27)
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
## Yeeke 已重出同步与匹配边界(#345)
- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。
- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
+36
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
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: 57ae18796e441eeed7b0ffc2a833fe6b4b5055f1
synchronized_at: 2026-09-28T08:28:33Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
synchronized_at: 2026-09-27T02:47:26Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
@@ -680,3 +696,23 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
## 管理端售后角色(#341)
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
## 退货匹配(#338)
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
## Yeeke 双状态退货同步(#345)
- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。
- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
+7
View File
@@ -525,6 +525,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
`not_selected`。
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
+89 -3
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 3a76e16d43c940425f68cc4748944213e980ad60
synchronized_at: 2026-09-28T08:10:00Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
synchronized_at: 2026-09-27T02:47:34Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
@@ -10,13 +26,47 @@ synchronized_at: 2026-09-22T02:56:36Z
## 当前线上拓扑
- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。
- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。
- GoAuto 服务监听:`127.0.0.1:8010`。
- 常驻服务:systemd `goauto.service`。
- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。
- 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。
- 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。
- Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。
## Nginx 入口(9527)
线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置:
```nginx
server {
listen 9527 default_server;
server_name 122.228.200.167 _;
root /home/goauto/current/dist;
index index.html;
client_max_body_size 100m;
allow all;
location = / {
try_files /index.html =404;
}
location / {
try_files $uri @goauto_backend;
}
location @goauto_backend {
proxy_pass http://127.0.0.1:8010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}
```
`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。
## Agent HTTP 例外
服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置:
@@ -33,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。
2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200):
- `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」;
- 前端路由(如 `GET /login`):同样返回 Admin 的 index.html;
- `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200;
- `GET /api/v1/captcha`:`application/json` 且 `code=200`;
- 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401;
- 结构日志无 panic/fatal/1146/1054。
4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。
5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。
@@ -45,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 服务器迁移清单
更换线上服务器时逐项核对,每项都要在新机上回读确认:
1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload;
2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`;
3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度;
4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务;
5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致;
6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服);
7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录;
8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
@@ -153,3 +222,20 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
## #341 售后角色与账号迁移
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
## #338 退货匹配发布
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346)
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
+34 -6
View File
@@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST"
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
// an HTML page, which is far more confusing than a plain 404.
func InitSPARouter(engine *gin.Engine) {
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index := filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
dist, index, ok := spaIndex()
if !ok {
return
}
@@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) {
})
}
// spaIndex resolves the built frontend directory and reports whether its
// index.html exists.
func spaIndex() (dist, index string, ok bool) {
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index = filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
return dist, index, false
}
return dist, index, true
}
// registerRootRoute decides what `GET /` returns (#346).
//
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
// that forwarded `/` to this server (instead of serving dist itself) showed
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
// the 2026-09-28 server migration. The welcome page is kept only for
// development without a dist, where the frontend runs under vite.
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
if _, index, ok := spaIndex(); ok {
r.GET("/", func(c *gin.Context) { c.File(index) })
return
}
if mode != "prod" {
r.GET("/", welcome)
}
}
// isAPIPath reports whether a path belongs to the server rather than the SPA.
func isAPIPath(path string) bool {
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
+43
View File
@@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
}
}
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
// welcome page, even in non-prod modes where the welcome page used to own `/`.
func TestRootServesSPAWhenDistExists(t *testing.T) {
gin.SetMode(gin.TestMode)
dist := filepath.Join(t.TempDir(), "dist")
if err := os.MkdirAll(dist, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(SPADirEnv, dist)
for _, mode := range []string{"dev", "test", "prod"} {
engine := gin.New()
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
InitSPARouter(engine)
response := do(engine, http.MethodGet, "/")
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
}
}
}
// Without a dist (development under vite) the previous behaviour is kept:
// welcome page outside prod, nothing registered in prod.
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
dev := gin.New()
registerRootRoute(dev, "dev", welcome)
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
}
prod := gin.New()
registerRootRoute(prod, "prod", welcome)
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
t.Fatalf("prod without dist should not register /, got %d", response.Code)
}
}
+1 -3
View File
@@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) {
go ws.WebsocketManager.SendService()
go ws.WebsocketManager.SendAllService()
if config.ApplicationConfig.Mode != "prod" {
r.GET("/", apis.GoAdmin)
}
registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin)
r.GET("/info", handler.Ping)
}
+2
View File
@@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string {
return ModuleYeekeSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
return ModuleYeekeReturns
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
return ModuleSYBProducts
default:
return ""
}
+12
View File
@@ -3,6 +3,9 @@ package access
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
const RolePurchaser = "purchaser"
// RoleAfterSales is the fixed role key for the internal after-sales users.
const RoleAfterSales = "after_sales"
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
// APIs that the purchaser role may call; every other API remains admin-only.
type APIPermission struct {
@@ -132,6 +135,15 @@ var AdminAPIs = []APIPermission{
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
+1
View File
@@ -41,6 +41,7 @@ func MigratedModels() []any {
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBSessionAuthLease{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
+11
View File
@@ -504,6 +504,17 @@ type SYBSession struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// SYBSessionAuthLease serializes re-authentication across sync and writeback
// workers. Cookies and passwords never live in this table.
type SYBSessionAuthLease struct {
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"size:128;not null;uniqueIndex"`
Owner string `gorm:"size:36;not null"`
ExpiresAt time.Time `gorm:"not null"`
}
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
// several Shopee product lines, and the same Shopee product can appear more
// than once within one order at different colors/sizes/quantities — each such
+2 -2
View File
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
return true
}
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role == "admin" || role == "purchaser" {
if role == "admin" || role == "purchaser" || role == "after_sales" {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
c.Abort()
return false
}
@@ -0,0 +1,26 @@
package purchase
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := allowedOperator(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("state metadata: %+v", saved)
}
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
t.Fatal("unrelated business facts changed")
}
for _, replayID := range []string{rid, uuid.NewString()} {
@@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
return nil
}
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).Error
}
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
t.Fatal("automatically repeated write")
}
after := loadBackfillTask(t, s.DB, task.ID)
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
wantTaskWriteback := models.PurchaseWritebackStatusFailed
if tc.want == "succeeded" {
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
}
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
t.Fatal("changed purchase/payment/logistics facts")
}
})
@@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string {
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
}
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
// only. It never logs in, never triggers OCR and never deletes the cached
// session (that stays the exclusive responsibility of sybimport.Connect's
// login/refresh path) — it only reports whether the cached cookies still
// work, via CheckSession, so the caller can classify the failure (#330).
// restoreOrderWritebackClient uses the same session acquisition path as SYB
// sync. A valid cached session is reused; an explicitly invalid session is
// refreshed under the shared database auth lease so concurrent workers do not
// request multiple captcha codes.
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
cfg := config.ExtConfig.SYB.Resolved()
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
if err != nil {
return nil, err
}
if session.UserID <= 0 {
return nil, errSessionUserIDMissing
}
c, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
return nil, err
}
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
// imports cleanly and only fails later inside read(), which would record
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
// Any error here — ErrSessionInvalid or network/format — is treated as
// session-class; only ErrSessionInvalid is a confirmed logout, but a
// network/format error is not confirmed-valid either, so it is still
// retried rather than attempted as a write.
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
return nil, err
}
return c, nil
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
})
}
// One short-lived claim at a time across processes. No business writes occur
@@ -184,7 +162,20 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if status == "succeeded" {
updates["completed_at"] = w.Now()
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
return err
}
// Keep the admin task state aligned with the authoritative writeback
// record. Conflicts and unknown outcomes remain failed until a human
// resolves them; they must never appear as successful.
taskStatus := models.PurchaseWritebackStatusFailed
if status == "succeeded" {
taskStatus = models.PurchaseWritebackStatusSucceeded
}
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
"writeback_status": taskStatus,
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
}).Error
}
// finishSessionUnavailable is the bounded-retry counterpart of finish for
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
@@ -201,7 +192,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
} else {
updates["lease_expires_at"] = nil
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
return err
}
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
}
var task models.PurchaseTask
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
+7 -3
View File
@@ -44,14 +44,14 @@ func operatorFromContext(c *gin.Context) (string, string) {
return role, username
}
// requireCanPurchase mirrors the admin/purchaser write gate this codebase
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
// writes and require it; the two list/detail read endpoints do not.
func requireCanPurchase(c *gin.Context) bool {
role, _ := operatorFromContext(c)
if role != "admin" && role != "purchaser" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"})
if role != "admin" && role != "purchaser" && role != "after_sales" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
return false
}
return true
@@ -77,6 +77,10 @@ func (h Handler) BatchMatch(c *gin.Context) {
}
_, operator := operatorFromContext(c)
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
if errors.Is(err, ErrTooManyItems) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
@@ -0,0 +1,26 @@
package returnmatch
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestReturnMatchWriteRoles(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := requireCanPurchase(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -0,0 +1,38 @@
package returnmatch
import (
"context"
"errors"
"testing"
"time"
"go-admin/app/goauto/models"
)
func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) {
db := testDB(t)
s := NewService(db)
deadline := time.Now().Add(24 * time.Hour)
ret := seedReturn(t, db, "红色", &deadline)
sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now())
pool, err := s.availableReturnPool(context.Background())
if err != nil || len(pool) != 1 {
t.Fatalf("waiting pool=%v err=%v", pool, err)
}
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil {
t.Fatal(err)
}
pool, err = s.availableReturnPool(context.Background())
if err != nil || len(pool) != 0 {
t.Fatalf("reshipped pool=%v err=%v", pool, err)
}
_, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test")
if !errors.Is(err, errReturnNoLongerEligible) {
t.Fatalf("final recheck=%v", err)
}
var count int64
db.Model(&models.ReturnMatch{}).Count(&count)
if count != 0 {
t.Fatal("reshipped item was allocated")
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// InitRouter mounts the #338 return-matching admin surface. List/detail are
// readable by any authenticated admin user; the write actions (batch match,
// confirm, cancel, remark) additionally require admin/purchaser via
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
+31
View File
@@ -68,10 +68,23 @@ const (
ReasonConflict = "conflict"
)
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
// size can now go up to 500/page, so the button's selection can exceed the
// previously-unbounded batch-match request size).
const maxBatchMatchItems = 500
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
// maxBatchMatchItems SYB product ids; the handler turns this into an
// INVALID_REQUEST response instead of a 500.
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
// ever called from the batch-match button (ticked rows) — no scheduler, no
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
if len(req.SYBProductIDs) > maxBatchMatchItems {
return BatchMatchResponse{}, ErrTooManyItems
}
resp, err := s.batchMatch(ctx, req)
if len(req.SYBProductIDs) > 0 {
// The batch record is written after the per-row transactions have
@@ -225,6 +238,11 @@ func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchM
}
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
if insertErr != nil {
if errors.Is(insertErr, errReturnNoLongerEligible) {
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"})
resp.SkippedCount++
continue
}
if errors.Is(insertErr, errStageNoLongerEligible) {
// #338 review fix: the stage was re-checked under the same
// FOR UPDATE lock purchase.create takes, right before insert.
@@ -279,6 +297,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false).
Find(&rows).Error
if err != nil {
return nil, err
@@ -299,6 +318,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
// it between BatchMatch's outer screening pass and this point (#338 review
// fix: race between matching and purchase creation).
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship")
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
@@ -327,6 +347,17 @@ func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome Ma
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
var pkg models.YeekeReturnPackage
if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil {
return err
}
// Same package -> item lock order as sync upsert; avoid a lock cycle.
if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" {
return errReturnNoLongerEligible
}
sybIDCopy := syb.ID
returnIDCopy := outcome.ReturnItemID
deadline := outcome.DestroyDeadline
+27 -1
View File
@@ -69,7 +69,8 @@ func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color
func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem {
t.Helper()
pkg := models.YeekeReturnPackage{
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
ClaimStatus: "1",
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
}
if err := db.Create(&pkg).Error; err != nil {
@@ -111,6 +112,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) {
}
}
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
db := testDB(t)
s := NewService(db)
ids := make([]uint64, maxBatchMatchItems+1)
for i := range ids {
ids[i] = uint64(i + 1)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
if !errors.Is(err, ErrTooManyItems) {
t.Fatalf("expected ErrTooManyItems, got %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("expected empty response on rejection, got %+v", resp)
}
var batchCount int64
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
t.Fatal(err)
}
if batchCount != 0 {
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
}
}
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
+4
View File
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
// DB exposes the store connection to the shared session manager; callers do
// not receive any session data through this accessor.
func (s *SessionStore) DB() *gorm.DB { return s.db }
// Session is one cached SYB login.
type Session struct {
Username string
@@ -0,0 +1,147 @@
package sybclient
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LoginConfig contains the non-secret connection settings and credentials
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
type LoginConfig struct {
BaseURL string
Username string
Password string
OcrURL string
OcrMaxAttempts int
}
// AcquireSession reuses a valid cached session and performs at most one
// re-login per account at a time. Callers waiting for another process to
// refresh the session never request another captcha.
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
if cfg.Username == "" {
return nil, fmt.Errorf("顺云宝账号未配置")
}
client, err := New(cfg.BaseURL)
if err != nil {
return nil, err
}
store := NewSessionStore(db)
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
return nil, importErr
}
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
if checkErr == nil {
return client, nil
}
if !errors.Is(checkErr, ErrSessionInvalid) {
return nil, checkErr
}
if cfg.Password == "" {
return nil, checkErr
}
} else if !errors.Is(loadErr, ErrNoSession) {
return nil, loadErr
} else if cfg.Password == "" {
return nil, loadErr
}
owner := uuid.NewString()
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
return nil, err
} else if claimed {
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
// Another worker may have completed login between our first probe and
// acquiring the lease; always re-check before requesting a captcha.
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
return nil, err
}
return validSessionOrError(ctx, client, store, cfg.Username)
}
// A different worker owns the login lease. Wait for its session, bounded by
// the caller's context; do not trigger a second login.
for {
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
}
select {
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, err
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, err
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
return nil, err
}
return client, nil
}
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, false
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, false
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
// Network failures are not treated as logout, but they also do not
// authorize a write; the caller will retry through the normal worker.
return nil, false
}
return client, true
}
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
ocr, err := NewOcrClient(cfg.OcrURL, 0)
if err != nil {
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
}
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
if result == nil {
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
}
cookies, err := client.ExportCookiesJSON()
if err != nil {
return err
}
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
}
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
now := time.Now().UTC()
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
return false, err
}
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
return r.RowsAffected == 1, r.Error
}
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
}
@@ -0,0 +1,35 @@
package sybclient
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
if err != nil || !first {
t.Fatalf("first owner should claim: %v %v", first, err)
}
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || second {
t.Fatalf("second owner must wait: %v %v", second, err)
}
releaseAuthLease(ctx, db, "operator", "first")
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || !second {
t.Fatalf("lease should be reusable: %v %v", second, err)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
})
if err != nil {
writeError(c, err)
+52 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -44,6 +45,8 @@ type ListRequest struct {
OrderCodes []string
ParseStatus string
ProcessStage string
CreatedFrom string
CreatedTo string
}
type ListResponse struct {
@@ -65,10 +68,20 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
request.PageSize = 100
if request.PageSize > 500 {
request.PageSize = 500
}
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return ListResponse{}, err
}
if createdFrom != nil {
query = query.Where("created_at >= ?", *createdFrom)
}
if createdTo != nil {
query = query.Where("created_at < ?", *createdTo)
}
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
// range. The bounds are interpreted in the server's local timezone, matching
// the timestamps written by GORM for this service.
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if from == "" && to == "" {
return nil, nil, nil
}
parse := func(value, label string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
}
return &parsed, nil
}
start, err := parse(from, "createdFrom")
if err != nil {
return nil, nil, err
}
endDay, err := parse(to, "createdTo")
if err != nil {
return nil, nil, err
}
if start != nil && endDay != nil && start.After(*endDay) {
return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
}
if endDay != nil {
end := endDay.AddDate(0, 0, 1)
endDay = &end
}
return start, endDay, nil
}
func normalizeOrderCodes(raw []string) ([]string, error) {
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybimport"
@@ -93,6 +94,76 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
}
}
func TestServiceListCapsPageSizeAt500(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i := 0; i < 3; i++ {
detail := realDetailA()
detail.ID += uint64(i)
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatalf("apply detail %d: %v", i, err)
}
}
service := sybimport.NewService(db)
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
if err != nil {
t.Fatalf("list with oversized page size: %v", err)
}
if overLimit.PageSize != 500 {
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
}
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
if err != nil {
t.Fatalf("list at exactly 500: %v", err)
}
if within.PageSize != 500 {
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
}
}
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
rowOrder := order
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
rowOrder.StockID += uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
if err != nil {
t.Fatal(err)
}
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
t.Fatal(err)
}
}
service := sybimport.NewService(db)
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
if err != nil || between.Total != 2 {
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
}
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
if err != nil || fromOnly.Total != 1 {
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
}
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
if err != nil || toOnly.Total != 1 {
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
}
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
t.Fatalf("reversed created date range should be rejected: %v", err)
}
}
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
+4 -48
View File
@@ -480,54 +480,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) {
// blip as a logout would trigger needless logins and could throw away a
// perfectly good session.
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
if cfg.Username == "" || cfg.Password == "" {
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
}
client, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
cached, err := store.Load(ctx, cfg.Username, time.Now())
switch {
case err == nil:
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
return client, nil
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
return nil, delErr
}
}
}
case errors.Is(err, sybclient.ErrNoSession):
// Nothing cached; fall through to a fresh login.
default:
return nil, err
}
if cfg.OcrURL == "" {
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
}
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
if err != nil {
return nil, err
}
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
if result == nil {
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
}
jar, err := client.ExportCookiesJSON()
if err != nil {
return nil, err
}
if err := store.Save(ctx, sybclient.Session{
Username: cfg.Username, UserID: result.User.ID,
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
}); err != nil {
return nil, err
}
return client, nil
return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
})
}
// ConnectConfig carries the login settings. The password is passed through and
+206 -5
View File
@@ -10,6 +10,7 @@ import (
"sort"
"strings"
"time"
"unicode"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
@@ -193,13 +194,20 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
if len(matches) == 1 && matches[0].ProductQty == count {
chosen = []sybclient.DetailItem{matches[0]}
} else if len(matches) == count && count > 1 {
sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID })
for _, item := range matches {
if item.ProductQty != 1 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil
}
}
chosen = matches
codes := make([]string, count)
for i, it := range record.Items {
codes[i] = it.Code
}
assigned, reason := assignExistingBoundItems(record.Stall, codes, matches)
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
chosen = assigned
} else if len(matches) > 1 {
return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil
} else {
@@ -246,6 +254,83 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil
}
// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。
// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标
// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选
// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未
// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。
// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充):
// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在
// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配;
// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效
// 或重复的商品明细 ID。
func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
sorted := append([]sybclient.DetailItem(nil), matches...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
seenIDs := make(map[int64]bool, len(sorted))
for _, item := range sorted {
if item.ID <= 0 || seenIDs[item.ID] {
return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配"
}
seenIDs[item.ID] = true
}
first := sorted[0]
wantSpec := first.ProductSpec
wantSKU := rawText(first.Raw["sku"])
wantVariationSKU := rawText(first.Raw["variationSku"])
for _, item := range sorted {
if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU {
return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配"
}
}
if strings.TrimSpace(stall) != "" {
for _, item := range sorted {
if !stallMatches(stall, item) {
return nil, "重复候选的档口及货号不一致,不能自动逐件分配"
}
}
}
assigned := make([]sybclient.DetailItem, len(codes))
taken := make([]bool, len(codes))
codeIndex := make(map[string]int, len(codes))
for i, code := range codes {
codeIndex[code] = i
}
blanks := make([]int, 0, len(sorted))
for si, item := range sorted {
remote := rawText(item.Raw["innerExpCode"])
if remote == "" {
blanks = append(blanks, si)
continue
}
idx, ok := codeIndex[remote]
if !ok {
return nil, "候选商品明细存在非目标入库码,不能自动逐件分配"
}
if taken[idx] {
return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配"
}
assigned[idx] = item
taken[idx] = true
}
bi := 0
for i := range assigned {
if taken[i] {
continue
}
if bi >= len(blanks) {
return nil, "现成空白明细不足,不能完成逐件分配"
}
assigned[i] = sorted[blanks[bi]]
taken[i] = true
bi++
}
if bi != len(blanks) {
return nil, "现成空白明细多于待写入入库码,不能自动逐件分配"
}
return assigned, ""
}
func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem {
result := []sybclient.DetailItem{}
for _, item := range items {
@@ -305,15 +390,131 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI
return nil
}
result := []sybclient.DetailItem{}
name, article, has := strings.Cut(stall, "#")
for _, item := range items {
blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec
if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) {
if stallMatches(stall, item) {
result = append(result, item)
}
}
return result
}
// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复):
// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
func stallMatches(stall string, item sybclient.DetailItem) bool {
sku := rawText(item.Raw["sku"])
variation := rawText(item.Raw["variationSku"])
blob := sku + " " + variation + " " + item.ProductSpec
if strings.Contains(blob, stall) {
return true
}
name, article, hasArticle := splitStall(stall)
if !hasArticle {
return false
}
if article == "" {
return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
}
if isNumericArticle(article) {
nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
if !nameMatches {
return false
}
return textHasNumericArticle(sku, article) ||
textHasNumericArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, true)
}
return textHasExactArticle(sku, article) ||
textHasExactArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, false)
}
// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。
func splitStall(stall string) (name, article string, ok bool) {
stall = strings.TrimSpace(stall)
separator := strings.LastIndex(stall, "#")
if separator < 0 {
return stall, "", false
}
return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true
}
func isNumericArticle(article string) bool {
if article == "" {
return false
}
for _, char := range article {
if !unicode.IsDigit(char) {
return false
}
}
return true
}
func textHasNumericArticle(text, article string) bool {
target := normalizeNumericArticle(article)
for _, token := range articleTokens(text) {
if isNumericArticle(token) && normalizeNumericArticle(token) == target {
return true
}
}
return false
}
func normalizeNumericArticle(article string) string {
normalized := strings.TrimLeft(article, "0")
if normalized == "" {
return "0"
}
return normalized
}
func textHasExactArticle(text, article string) bool {
for _, token := range articleTokens(text) {
if token == article {
return true
}
}
return false
}
func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool {
productSpec = strings.TrimSpace(productSpec)
separator := strings.IndexAny(productSpec, " ,,")
if separator <= 0 {
return false
}
prefix := strings.TrimSpace(productSpec[:separator])
if numeric {
return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article)
}
return prefix == article
}
// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为
// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。
func articleTokens(text string) []string {
tokens := make([]string, 0)
start := -1
runes := []rune(text)
for index, char := range runes {
if unicode.IsLetter(char) || unicode.IsDigit(char) {
if start < 0 {
start = index
}
continue
}
if start >= 0 {
tokens = append(tokens, string(runes[start:index]))
start = -1
}
}
if start >= 0 {
tokens = append(tokens, string(runes[start:]))
}
return tokens
}
func rawText(value any) string {
switch v := value.(type) {
case string:
@@ -147,6 +147,209 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) {
}
}
// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) ---
func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) {
// #273: "50公斤" must not be treated as if the article were the bare number 50.
item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "")
if stallMatches("档口甲#50", item) {
t.Fatalf("weight-like text must not match numeric article 50")
}
}
func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) {
// #273: a long code with internal digits (PDD256437) must not spuriously
// match a short numeric article (256) via substring containment.
item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "")
if stallMatches("档口甲#256", item) {
t.Fatalf("long code must not match numeric article 256 via substring")
}
}
func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) {
// #259/#273: "067" and "67" are equivalent articles only when the stall
// name also matches; a different stall name must not match.
sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "")
if !stallMatches("档口甲#67", sameStall) {
t.Fatalf("067 should be treated as equivalent to 67 when stall name matches")
}
differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "")
if stallMatches("档口甲#67", differentStall) {
t.Fatalf("067 must not match 67 when the stall name differs")
}
}
func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) {
item := detail(1, "黑色,L", 1, "ABC12", "", "")
if stallMatches("档口甲#AB", item) {
t.Fatalf("non-numeric article must require an exact token match, not substring")
}
exact := detail(2, "黑色,L", 1, "AB", "", "")
if !stallMatches("档口甲#AB", exact) {
t.Fatalf("exact non-numeric token should match")
}
}
func TestStallMatchesHandlesHashInsideStallName(t *testing.T) {
// Splits on the LAST '#' so a stall name that itself contains '#' still
// yields the correct article.
item := detail(1, "黑色,L", 1, "档口#甲-67", "", "")
if !stallMatches("档口#甲#67", item) {
t.Fatalf("stall name containing '#' should still resolve article via last '#'")
}
}
func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) {
// Non-numeric article: ProductSpec prefix match does not additionally
// require the stall name to appear in sku/variationSku.
item := detail(1, "AB 黑色,L", 1, "", "", "")
if !stallMatches("档口甲#AB", item) {
t.Fatalf("ProductSpec starting with the article should match")
}
notPrefix := detail(2, "黑色,ABL", 1, "", "", "")
if stallMatches("档口甲#AB", notPrefix) {
t.Fatalf("article appearing mid-spec (not as prefix) must not match")
}
}
func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) {
item := detail(1, "黑色,L", 1, "档口甲专柜", "", "")
if !stallMatches("档口甲#", item) {
t.Fatalf("empty article should fall back to stall-name containment")
}
}
// --- #289: existing-binding-preserving multi-piece assignment ---
func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) {
// Two single-piece candidates already carry codes, but the previously
// bound code (IC-2) sits on the LOWER-ID detail while the target order
// expects it second. A naive ID-order/index assignment would strip the
// existing correct binding from detail 20 and try to overwrite it.
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "" {
t.Fatalf("unexpected reason: %s", reason)
}
if assigned[1].ID != 20 {
t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1])
}
if assigned[0].ID != 21 {
t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0])
}
}
func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) {
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason == "" {
t.Fatalf("expected rejection for detail already holding a non-target code")
}
}
func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) {
// Candidates can share a NormalizeSpecKey-normalized spec while their raw
// ProductSpec/sku/variationSku differ; auto-assignment across genuinely
// different items must be rejected.
d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "")
d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "")
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" {
t.Fatalf("expected identity-mismatch rejection, got %q", reason)
}
}
func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) {
// The no-SKU fallback path in matchEvidence can hand back candidates that
// were never checked against the stall at all. Both candidates share an
// identical identity (so the identity guard passes) but neither one's
// sku/spec actually satisfies the record's stall/article requirement.
d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "")
d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "")
_, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "重复候选的档口及货号不一致,不能自动逐件分配" {
t.Fatalf("expected stall-mismatch rejection, got %q", reason)
}
}
func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) {
invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "")
valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" {
t.Fatalf("expected rejection for non-positive detail ID")
}
dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" {
t.Fatalf("expected rejection for duplicate detail IDs")
}
}
func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) {
// Both candidates share identical raw spec/sku/variationSku and both
// individually satisfy the stall check; the guards must not block the
// legitimate happy path.
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "" {
t.Fatalf("unexpected reason: %s", reason)
}
if assigned[0].ID != 21 || assigned[1].ID != 20 {
t.Fatalf("assigned=%+v", assigned)
}
}
func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) {
// End-to-end regression for #289: N=2 single-piece candidates already
// carrying one previously bound code, with the bound detail's ID not
// matching sequential/ID order relative to the target codes. The plan
// must leave the already-correct binding untouched and only place the
// missing code onto the still-blank detail.
db := testDB(t)
// SourceSKURaw is intentionally left blank: with the same source SKU on
// both candidate rows the source-SKU path would reject as "duplicate"
// before ever reaching the stall-based multi-item assignment being
// regression-tested here.
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
records := []models.SYBInnerCodeRecord{record}
jobID := createMatchJob(t, db, records)
recordID := records[0].ID
// Detail 20 (lower ID) already carries IC-2 (bound out of sequence);
// detail 21 (higher ID) is still blank and should receive IC-1.
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{
detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"),
detail(21, "黑色,L", 1, "SKU-1", "A#1", ""),
}}
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var plan models.SYBInnerCodePlan
if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil {
t.Fatal(err)
}
var items []plannedRemoteItem
if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("items=%+v", items)
}
byCode := map[string]plannedRemoteItem{}
for _, item := range items {
byCode[item.Code] = item
}
if byCode["IC-2"].DetailID != 20 {
t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"])
}
if byCode["IC-1"].DetailID != 21 {
t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"])
}
}
type captureStarter struct {
jobID string
committed bool
+112
View File
@@ -0,0 +1,112 @@
package yeeke
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
)
func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) {
db := testDB(t)
phase := 0
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Status string `json:"status"`
Page int `json:"pageNo"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Error(err)
return
}
calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page))
w.Header().Set("Content-Type", "application/json")
if phase == 0 && req.Status == "2" {
fmt.Fprint(w, page(nil, 0, 0))
return
}
if req.Status == "1" {
if req.Page == 1 {
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2))
} else {
fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2))
}
} else {
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1))
}
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var original models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&original)
phase = 1
calls = nil
for run := 0; run < 2; run++ {
rep, err := s.Sync(context.Background(), "manual")
if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v", rep, err)
}
}
if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" {
t.Fatalf("independent pagination: %v", calls)
}
var current models.YeekeReturnPackage
db.First(&current, original.ID)
if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" {
t.Fatalf("current=%+v", current)
}
var count int64
db.Model(&models.YeekeReturnPackage{}).Count(&count)
if count != 2 {
t.Fatalf("packages=%d", count)
}
db.Model(&models.YeekeReturnItem{}).Count(&count)
if count != 2 {
t.Fatalf("items=%d", count)
}
}
func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) {
db := testDB(t)
old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"}
if err := db.Create(&old).Error; err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Status string `json:"status"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Status == "2" {
w.WriteHeader(http.StatusBadGateway)
return
}
fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
rep, err := s.Sync(context.Background(), "manual")
if err == nil || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v", rep, err)
}
db.First(&old, old.ID)
if old.SyncStatus != "ok" {
t.Fatal("incomplete combined sync marked reshipped package missing")
}
var run models.YeekeSyncRun
db.First(&run, rep.RunID)
if run.ErrorMessage == "" {
t.Fatal("missing state/page failure diagnostic")
}
}
+79 -64
View File
@@ -54,10 +54,8 @@ type Report struct {
}
// knownClaimStatuses lists the status values the sync code currently
// understands. The list surface (POST .../relation/list) is queried with
// status=1, so "1" is the only value observed in practice; anything else is
// flagged rather than silently accepted or rejected (#336).
var knownClaimStatuses = map[string]bool{"1": true}
// understands: waiting to ship (1) and reshipped (2), confirmed by HAR.
var knownClaimStatuses = map[string]bool{"1": true, "2": true}
func external(v any) string { return fmt.Sprint(v) }
func stamp(t *yeekeclient.Timestamp) *time.Time {
@@ -194,86 +192,103 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
}
s.db.Model(r).Updates(updates)
}()
seen := map[string]bool{}
var firstWriteErr error
seenPackages := map[string]string{}
// complete tracks whether the page walk ended NATURALLY (empty page,
// short page, or reaching p.Pages) as opposed to the duplicate-
// fingerprint break or MaxPages exhaustion (#338): only a naturally
// complete run is trusted to mark absent items/packages "missing" below,
// since a duplicate/MaxPages stop means the walk never actually finished
// seeing everything yeeke currently has.
complete := false
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
for a := 0; ; a++ {
p, e = s.client.List(ctx, page, s.cfg.PageSize)
if e == nil || a >= s.cfg.Retry {
complete := true
for _, status := range []string{"1", "2"} {
seen := map[string]bool{}
statusComplete := false
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
for a := 0; ; a++ {
p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status)
if e == nil || a >= s.cfg.Retry {
break
}
select {
case <-ctx.Done():
runErr = ctx.Err()
errMsg = truncateRunError(runErr.Error())
return rep, runErr
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
}
}
if e != nil {
// A failed page never overwrites what earlier pages already wrote
// (#336): the run simply stops here and everything upserted so far
// stays as-is, reported through Read/Created/Updated above.
runErr = e
errMsg = truncateRunError(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e))
return rep, runErr
}
rep.TotalPages++
if len(p.Records) == 0 {
statusComplete = true
break
}
select {
case <-ctx.Done():
runErr = ctx.Err()
errMsg = truncateRunError(runErr.Error())
return rep, runErr
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
}
if e != nil {
// A failed page never overwrites what earlier pages already wrote
// (#336): the run simply stops here and everything upserted so far
// stays as-is, reported through Read/Created/Updated above.
runErr = e
errMsg = truncateRunError(e.Error())
return rep, runErr
}
rep.TotalPages = page
if len(p.Records) == 0 {
complete = true
break
}
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
seen[finger] = true
for _, x := range p.Records {
created, updated, recovered, err := s.upsert(ctx, x)
if err != nil {
rep.Failed++
if firstWriteErr == nil {
firstWriteErr = err
seen[finger] = true
for _, x := range p.Records {
key, currentStatus := packageKey(x), external(x.Status)
if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) {
rep.Skipped++
continue
}
created, updated, recovered, err := s.upsert(ctx, x)
seenPackages[key] = currentStatus
if err != nil {
rep.Failed++
if firstWriteErr == nil {
firstWriteErr = err
}
continue
}
rep.Read++
rep.Recovered += recovered
if created {
rep.Created++
} else if updated {
rep.Updated++
} else {
rep.Skipped++
}
continue
}
rep.Read++
rep.Recovered += recovered
if created {
rep.Created++
} else if updated {
rep.Updated++
} else {
rep.Skipped++
if len(p.Records) < s.cfg.PageSize {
statusComplete = true
break
}
if p.Pages > 0 && page >= p.Pages {
statusComplete = true
break
}
}
if len(p.Records) < s.cfg.PageSize {
complete = true
break
}
if p.Pages > 0 && page >= p.Pages {
complete = true
if !statusComplete {
complete = false
errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status)
break
}
}
if !complete {
runErr = errors.New(errMsg)
return rep, runErr
}
rep.Status = "succeeded"
if rep.Failed > 0 && firstWriteErr != nil {
// Surface why records failed instead of a bare counter.
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
if rep.Read == 0 {
rep.Status = "failed"
runErr = errors.New(errMsg)
}
rep.Status = "failed"
runErr = errors.New(errMsg)
}
// #338: only a naturally complete run with zero write failures is
// trusted to mark items/packages the sync no longer sees as "missing".
@@ -288,7 +303,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
rep.MissingMarked = marked
}
}
return rep, nil
return rep, runErr
}
// markMissing implements #338's completion-triggered availability flip: any
+4 -10
View File
@@ -204,11 +204,8 @@ func TestDuplicateFingerprintStopsMarking(t *testing.T) {
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err)
}
var p2 models.YeekeReturnPackage
@@ -250,11 +247,8 @@ func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
s.client, _ = yeekeclient.New(srv2.URL)
s.cfg.MaxPages = 2
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err)
}
var p2 models.YeekeReturnPackage
+2 -5
View File
@@ -108,10 +108,7 @@ func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" {
if err == nil || rep.Status != "failed" {
t.Fatalf("rep=%+v", rep)
}
var n int64
@@ -138,7 +135,7 @@ func TestPagingStopsOnEmptyPage(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
if rep.Status != "succeeded" || rep.TotalPages != 2 || rep.Read != 0 {
t.Fatalf("rep=%+v", rep)
}
}
+9 -1
View File
@@ -371,8 +371,16 @@ func (f *FlexInt) UnmarshalJSON(b []byte) error {
}
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
return c.ListStatus(ctx, pageNo, pageSize, "1")
}
// ListStatus reads only the two HAR-confirmed return statuses.
func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) {
if status != "1" && status != "2" {
return ReturnPage{}, fmt.Errorf("unsupported yeeke return status")
}
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"}
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": status, "relationFlag": "1", "column": "createTime", "order": "desc"}
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
if e != nil {
return ReturnPage{}, e
@@ -0,0 +1,36 @@
package yeekeclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestListStatusUsesConfirmedHARFilters(t *testing.T) {
var statuses []string
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" {
t.Error("HAR filters changed")
}
statuses = append(statuses, body["status"].(string))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`))
}))
defer s.Close()
c, _ := New(s.URL)
for _, status := range []string{"1", "2"} {
if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil {
t.Fatal(err)
}
}
if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil {
t.Fatal("unsupported status allowed")
}
if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" {
t.Fatal(statuses)
}
}
@@ -0,0 +1,138 @@
package version_local
import (
"errors"
"fmt"
"os"
"runtime"
"strings"
adminmodels "go-admin/app/admin/models"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
}
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
// copies the current purchaser menu/API grants, so the two roles stay aligned
// for the current product surface (including yeeke 退货同步 and 退货商品).
// The initial password is deliberately supplied only at migration time via an
// environment variable; it is never stored in source, logs, or issue text.
func migrateAfterSalesRole(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
role, err := ensureAfterSalesRole(tx)
if err != nil {
return err
}
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
return err
}
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
}
role := migrationmodels.SysRole{}
if err := db.Where("role_key = ?", access.RoleAfterSales).
Assign(migrationmodels.SysRole{
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
Admin: false, DataScope: purchaser.DataScope,
Remark: "GoAuto 售后业务角色(系统维护)",
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
return migrationmodels.SysRole{}, err
}
return role, nil
}
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return err
}
if err := db.Exec(`
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT ?, source.menu_id
FROM sys_role_menu AS source
WHERE source.role_id = ?
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu AS target
WHERE target.role_id = ? AND target.menu_id = source.menu_id
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
return fmt.Errorf("clone purchaser menus: %w", err)
}
if err := db.Exec(`
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
FROM casbin_rule AS source
WHERE source.ptype = 'p' AND source.v0 = ?
AND NOT EXISTS (
SELECT 1 FROM casbin_rule AS target
WHERE target.ptype = source.ptype AND target.v0 = ?
AND target.v1 = source.v1 AND target.v2 = source.v2
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
return fmt.Errorf("clone purchaser API policies: %w", err)
}
return nil
}
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
missing := make([]string, 0, len(afterSalesUsers))
for _, username := range afterSalesUsers {
var existing adminmodels.SysUser
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
missing = append(missing, username)
case err != nil:
return fmt.Errorf("find after-sales user %q: %w", username, err)
case existing.RoleId != roleID:
return fmt.Errorf("after-sales user %q already exists with another role", username)
}
}
if len(missing) == 0 {
return nil
}
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
if password == "" {
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
}
// Validate before creating any account; the value itself is never logged or
// written to a repository artifact.
if len(password) < 1 {
return errors.New("after-sales initial password is empty")
}
for _, username := range missing {
user := adminmodels.SysUser{
Username: username, Password: password, NickName: username,
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
}
if err := db.Create(&user).Error; err != nil {
return fmt.Errorf("create after-sales user %q: %w", username, err)
}
}
return nil
}
@@ -0,0 +1,87 @@
package version_local
import (
"os"
"testing"
adminmodels "go-admin/app/admin/models"
"go-admin/app/goauto/access"
migrationmodels "go-admin/cmd/migrate/migration/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
t.Fatal(err)
}
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
if err = db.Create(&purchaser).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
t.Fatal(err)
}
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
t.Fatal(err)
}
role, err := ensureAfterSalesRole(db)
if err != nil {
t.Fatal(err)
}
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
t.Fatal(err)
}
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(afterSalesUsers)) {
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
}
var user adminmodels.SysUser
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
t.Fatal("initial password was not stored as a bcrypt hash")
}
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("got %d after-sales policies, want 1", count)
}
}
@@ -0,0 +1,76 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// #341 follow-up: the after-sales role was created before #338 added the
// return-match API surface. Add only the reviewed return-match grants so the
// existing role can use the new SYB action without recreating or changing its
// users.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
}
type afterSalesReturnMatchAPI struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
Title string `gorm:"column:title;size:128"`
Path string `gorm:"column:path;size:128"`
Type string `gorm:"column:type;size:16"`
Action string `gorm:"column:action;size:16"`
}
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
type afterSalesReturnMatchPolicy struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
Ptype string `gorm:"column:ptype;size:100"`
V0 string `gorm:"column:v0;size:100"`
V1 string `gorm:"column:v1;size:100"`
V2 string `gorm:"column:v2;size:100"`
V3 string `gorm:"column:v3;size:100"`
V4 string `gorm:"column:v4;size:100"`
V5 string `gorm:"column:v5;size:100"`
}
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
var role struct {
RoleID int `gorm:"column:role_id"`
}
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
return fmt.Errorf("find after-sales role: %w", err)
}
for _, permission := range access.AdminAPIs {
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
continue
}
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
FirstOrCreate(&api).Error; err != nil {
return fmt.Errorf("ensure return-match API: %w", err)
}
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
var count int64
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Create(&policy).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,63 @@
package version_local
import (
"testing"
"go-admin/app/goauto/access"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAfterSalesReturnMatchCatalogAndGrants(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &afterSalesReturnMatchAPI{}, &afterSalesReturnMatchPolicy{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
t.Fatal(err)
}
for _, version := range []string{"test-first", "test-repeat"} {
if err = migrateAfterSalesReturnMatch(db, version); err != nil {
t.Fatal(err)
}
}
var apis []afterSalesReturnMatchAPI
if err = db.Find(&apis).Error; err != nil {
t.Fatal(err)
}
if len(apis) != 8 {
t.Fatalf("API count=%d", len(apis))
}
for _, api := range apis {
if api.Path == "" || api.Action == "" {
t.Fatalf("empty API path/action: id=%d", api.ID)
}
}
var count int64
if err = db.Model(&afterSalesReturnMatchPolicy{}).Where("v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 8 {
t.Fatalf("policy count=%d", count)
}
// Simulate the original deployed catalogue bug, then repair and repeat.
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("id > 0").Updates(map[string]any{"path": "", "action": ""}).Error; err != nil {
t.Fatal(err)
}
for _, version := range []string{"repair-first", "repair-repeat"} {
if err = migrateFixReturnMatchAPICatalog(db, version); err != nil {
t.Fatal(err)
}
}
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("path = '' OR action = ''").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("unrepaired API rows=%d", count)
}
}
@@ -0,0 +1,34 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// Repair the API catalogue rows created by 1789801200000 before the path and
// action fields were populated. This is idempotent and only touches the
// return-match endpoints.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
}
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
for _, permission := range access.AdminAPIs {
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
continue
}
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
}).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,26 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBSessionAuthLease)
}
// The lease is additive and contains no credentials. It serializes automatic
// SYB re-authentication across sync and purchase writeback workers.
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -91,7 +91,7 @@ export default {
}
},
computed: {
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
stockSizeOptions() { return this.stockSpecValues('size') },
+61 -21
View File
@@ -3,16 +3,21 @@
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></el-form-item>
<div class="search-row search-row-primary">
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="店铺">
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
</div>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
@@ -75,7 +80,7 @@
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -333,6 +338,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
const PURCHASE_BATCH_MAX = 100
const COLLECTION_BATCH_MAX = 100
const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
@@ -353,7 +364,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -368,24 +379,38 @@ export default {
computed: {
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
if (this.aiMatchCandidates.length) return ''
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
return reasons[0] || '所选明细不满足 AI 匹配前提'
},
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchDurationText() {
@@ -412,7 +437,8 @@ export default {
this.$refs.productTable?.clearSelection()
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -432,7 +458,7 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -477,16 +503,29 @@ export default {
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
// #339: the page can now show up to 500 rows, but the preview endpoint
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
// caps a single request at 100 ids, so this splits the current page into
// sequential chunks of <=100 and merges their results. Any chunk failing
// falls back to the same page-wide failure state the single-request path
// used before, so rows never end up in a partially-updated mix of real
// and failed readiness.
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
this.purchaseReadiness = {}
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
this.purchaseReadinessLoading = true
const chunkSize = 100
const merged = {}
try {
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
for (let start = 0; start < ids.length; start += chunkSize) {
const chunk = ids.slice(start, start + chunkSize)
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
for (const item of r.data.items) merged[item.sybProductId] = item
}
this.purchaseReadiness = merged
await this.$nextTick()
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
this.$refs.productTable?.clearSelection()
@@ -823,7 +862,9 @@ export default {
<style lang="scss" scoped>
.page-card { min-height: calc(100vh - 124px); }
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-form{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-row{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px}
.search-row-secondary{justify-content:flex-start}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
@@ -855,7 +896,6 @@ export default {
.section-title{margin:0 0 12px}
.drawer-actions{display:flex;gap:12px;margin-top:16px}
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
@media(max-width:1280px){.search-row-primary{align-items:flex-start}.search-row-primary .toolbar-actions{margin-left:auto}}
@media(max-width:900px){.split{grid-template-columns:1fr}}
</style>
+1 -1
View File
@@ -89,7 +89,7 @@ export default {
}
},
computed: {
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
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) }) },
+4 -11
View File
@@ -172,24 +172,17 @@ export default {
{ label: '退货待确认', value: 'matched' },
{ label: '已用退货', value: 'confirmed' }
],
// yeeke's only confirmed claim-status value is the raw string "1"
// (HAR evidence, #336 requirement doc). Any other value the sync sees
// is preserved verbatim and flagged status_unrecognized — never
// guessed into a label here. "unknown" is a server-side pseudo-filter
// matching status_unrecognized=true, not a real yeeke value.
// HAR-confirmed business statuses; unknown remains a server-side filter.
claimStatusOptions: [
{ label: '已认领', value: '1' },
{ label: '待出货', value: '1' },
{ label: '已重出', value: '2' },
{ label: '未知(原样展示)', value: 'unknown' }
]
}
},
created() { this.load(); this.loadSummary(); this.loadShops() },
methods: {
// "1" is yeeke's only confirmed known status; anything else reaching
// this function (status_unrecognized=false from the server) is an
// unmapped value we have not seen yet — show it as-is rather than a
// bare digit or a guessed Chinese label.
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } },
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
@@ -71,7 +71,7 @@ export default {
}
},
computed: {
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load() },
+27
View File
@@ -0,0 +1,27 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8')
const script = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import[^\n]+\n/gm, '').replace('export default', 'return')
const options = new Function(script)()
test('Yeeke status filters and table/detail labels agree for both HAR statuses', () => {
const filters = options.data().claimStatusOptions
for (const [value, label] of [['1', '待出货'], ['2', '已重出']]) {
assert.equal(filters.find(item => item.value === value).label, label)
assert.equal(options.methods.claimStatusMeta(value).label, label)
}
assert.equal(filters.find(item => item.value === 'unknown').label, '未知(原样展示)')
assert.equal(options.methods.claimStatusMeta('9').label, '其他:9')
})
test('clearing the status filter restores all records and reloads', () => {
let reloads = 0
const vm = { ...options.data(), load() { reloads++ } }
vm.query.claimStatus = '2'
options.methods.reset.call(vm)
assert.equal(vm.query.claimStatus, '')
assert.equal(vm.query.page, 1)
assert.equal(reloads, 1)
})