diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index e7b490e..71dc5e4 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.- -wiki_revision: 6207da4ffac6b5821eda7869b9ea335b7585826d -synchronized_at: 2026-08-31T16:06:21Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:29:41Z # 项目档案 diff --git a/docs/01-workflow.md b/docs/01-workflow.md index 874e57c..b56be19 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Development-Workflow wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.- -wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1 -synchronized_at: 2026-08-27T09:37:51Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:29:46Z # 开发工作流 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 55f28a7..4c8c0fa 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,16 +2,8 @@ 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: de7e400503d6e7b9c335657114893de345bfead5 -synchronized_at: 2026-08-31T03:33:36Z - - - -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: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a -synchronized_at: 2026-08-29T01:39:35Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:29:50Z # 架构与代码地图 @@ -56,7 +48,7 @@ Android Portal/Agent - Android 本地互斥与服务端原子领取共同保证单设备串行。 - 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。 - 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。 -- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。 +- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。 ## 最小业务数据 @@ -318,3 +310,26 @@ PddProductDetailCollector - `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。 - `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。 - Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。 + +## 蝦皮规格自动匹配批处理(#195) + +- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。 +- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。 +- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。 +- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。 +- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。 + +## SYB 异常规格 AI 定时解析(#198) + +- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。 +- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。 +- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。 +- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。 + +## 定时任务持久化执行历史(#199) + +- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。 +- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。 +- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。 +- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。 +- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index eb39519..3ee2ac2 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: 0bb8d63fe2a3c6b1021bfa18800f5062de726868 -synchronized_at: 2026-08-31T16:06:55Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:02Z # 本地开发与验证 diff --git a/docs/05-common-changes.md b/docs/05-common-changes.md index 8a143d7..6b0c3ca 100644 --- a/docs/05-common-changes.md +++ b/docs/05-common-changes.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Common-Changes wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.- -wiki_revision: b7d5520da954c544248e979c4ababb86d2784a43 -synchronized_at: 2026-08-27T09:38:36Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:12Z # 常见修改指南 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index f9986a5..8babea8 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584 -synchronized_at: 2026-08-28T03:35:05Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:16Z # 故障排查 diff --git a/docs/07-mvp-requirements.md b/docs/07-mvp-requirements.md index 971007f..f3754e8 100644 --- a/docs/07-mvp-requirements.md +++ b/docs/07-mvp-requirements.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.- -wiki_revision: 0d9924b4566e567edf167ba678fedde28a41cfd3 -synchronized_at: 2026-08-28T02:27:12Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:20Z # 产品需求总览与当前 MVP diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 676cd52..d7d42d2 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: 0eed28dd5dc629ce66071833d5fefbdf24d567d3 -synchronized_at: 2026-08-24T02:35:58Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:28Z # 当前 MVP 交付工单索引 diff --git a/docs/10-real-device-acceptance.md b/docs/10-real-device-acceptance.md index 2ca7b76..13ff385 100644 --- a/docs/10-real-device-acceptance.md +++ b/docs/10-real-device-acceptance.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: OnePlus-Real-Device-Acceptance wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.- -wiki_revision: 5b74a3b915cda6307cd59087bf9ce295f4c5e806 -synchronized_at: 2026-08-21T08:08:57Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:32Z # 一加真机验收记录 diff --git a/docs/11-pdd-detail-rule-migration-analysis.md b/docs/11-pdd-detail-rule-migration-analysis.md index 4cf7728..0b3ce0c 100644 --- a/docs/11-pdd-detail-rule-migration-analysis.md +++ b/docs/11-pdd-detail-rule-migration-analysis.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: PDD-Detail-Rule-Migration-Analysis wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.- -wiki_revision: 1fe9ed083c4fc565a50b4186571233d1dfab58a7 -synchronized_at: 2026-08-17T03:28:38Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:37Z # PDD 商品详情采集规则迁移分析 diff --git a/docs/12-syb-erp-interface.md b/docs/12-syb-erp-interface.md index 1f2bb0a..09da324 100644 --- a/docs/12-syb-erp-interface.md +++ b/docs/12-syb-erp-interface.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: SYB-ERP-Interface-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.- -wiki_revision: f596931468caf524cbd9d90b3c299b32a8cf8228 -synchronized_at: 2026-08-28T02:29:07Z +wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6 +synchronized_at: 2026-09-04T11:30:50Z # 12 顺云宝(SYB)ERP 接口契约 @@ -393,19 +393,32 @@ POST /am/stock/detail/listByStock?hist=0 顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。 -### 6.3 `productSpec` 的格式和蝦皮报表完全一致 +### 6.3 `productSpec` 的角色顺序不是固定的 ```text 蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】 顺运宝 productSpec 白色,L【建議50-60公斤】 - 黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】 + 均碼,黑色 ``` -`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。 -第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。 +2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与 +`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。 + +`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧 +恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为 +尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或 +跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容, +并继续执行原有复杂分隔符和空值校验。 + +`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入 +虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、 +映射及历史采购任务保持不变。 + +`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD +精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或 +规格比对不明确时保持存疑,不得跨颜色/尺码猜测。 ### 6.4 由此推导出的匹配路径 - ```text productId ──→ shopee_products.goods_id 商品级,直接相等 productSpec ──→ SpecKey() ──→ 稳定规格身份键 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index 41a0efa..315b3c9 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- -wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e -synchronized_at: 2026-08-31T16:07:03Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:08Z # 部署与运维 @@ -39,6 +39,34 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true 发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。 +### API 长请求超时约束 + +Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。 + +Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。 + ## 回滚 服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。 + +## 蝦皮规格自动匹配任务运维(#195) + +- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。 +- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。 +- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。 +- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。 +- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。 + +## SYB 异常规格 AI 定时任务(#198) + +- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。 +- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。 +- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。 +- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。 + +## 定时任务执行历史运维(#199) + +- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。 +- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。 +- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。 +- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。 diff --git a/docs/README.md b/docs/README.md index 6310262..a1276f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Home wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home -wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a -synchronized_at: 2026-08-31T16:06:14Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:29:34Z # GoAuto 文档中心 diff --git a/docs/templates/deployment.md b/docs/templates/deployment.md index fef6762..8f39efd 100644 --- a/docs/templates/deployment.md +++ b/docs/templates/deployment.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-Template wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.- -wiki_revision: 389f09d292b0aa408f26f1967e33eec82d37cedf -synchronized_at: 2026-08-27T09:39:50Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:41Z # 部署文档模板 diff --git a/docs/templates/task-archive.md b/docs/templates/task-archive.md index 68640a8..081266e 100644 --- a/docs/templates/task-archive.md +++ b/docs/templates/task-archive.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Task-Archive-Template wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.- -wiki_revision: 53927b3b69fa26943f323f9be53153f80ed54530 -synchronized_at: 2026-08-24T08:23:07Z +wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 +synchronized_at: 2026-09-04T11:30:46Z > 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。 diff --git a/server/app/goauto/sybimport/parse.go b/server/app/goauto/sybimport/parse.go index d7b4ffc..d82fd60 100644 --- a/server/app/goauto/sybimport/parse.go +++ b/server/app/goauto/sybimport/parse.go @@ -13,6 +13,13 @@ import ( var bracketPattern = regexp.MustCompile(`【[^】]*】`) +// explicitSizePattern recognizes only values whose spelling carries a strong +// size signal. SYB has now been observed returning both "color,size" and +// "size,color". A color dictionary would inevitably guess at product-specific +// labels, so role reversal is allowed only when exactly one side matches this +// deliberately narrow pattern. +var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`) + // ambiguousPattern flags leftover separators or multi-token noise after // bracket stripping — the signal that a "clean" split still isn't reliable. // Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips @@ -29,8 +36,8 @@ type ParseResult struct { Note string } -// Parse implements the #41 rule: split on the last comma into color/size, -// strip 【...】 annotations, and classify the result. It never guesses a +// Parse implements the #41/#216 rule: split on the last comma, strip 【...】 +// annotations, and classify the result. It never guesses a // missing value and never invents a color or size that is not literally // present in the input. // @@ -41,9 +48,13 @@ type ParseResult struct { // brackets stripped, becomes the size candidate; color stays empty. A // single token with no separator cannot be split into two dimensions // without guessing which one it is. -// - comma present, last segment (size) and first segment(s) (color) both -// non-empty after stripping brackets, and the color candidate has no -// leftover separator/whitespace -> success. +// - comma present and exactly one side has an explicit size signal -> that +// side is size and the other side is color. This supports both observed +// SYB orders without allowing AI or a fuzzy color dictionary to swap roles. +// - comma present and neither side has an explicit size signal -> retain the +// established SYB color,size contract for backward compatibility. +// - comma present and both sides have explicit size signals -> uncertain; +// there is no safe color decision. // - comma present but either side is empty after stripping, or the color // candidate still carries a leftover '+' or internal whitespace -> the // split happened but is not trustworthy -> uncertain. @@ -65,12 +76,20 @@ func Parse(raw string) ParseResult { return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"} } - colorPart := stripBrackets(trimmed[:lastComma]) - sizePart := stripBrackets(trimmed[lastComma+1:]) + firstPart := stripBrackets(trimmed[:lastComma]) + secondPart := stripBrackets(trimmed[lastComma+1:]) + colorPart, sizePart := firstPart, secondPart if colorPart == "" || sizePart == "" { return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"} } + firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart) + if firstIsSize && secondIsSize { + return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"} + } + if firstIsSize { + colorPart, sizePart = secondPart, firstPart + } if ambiguousPattern.MatchString(colorPart) { return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"} } diff --git a/server/app/goauto/sybimport/parse_test.go b/server/app/goauto/sybimport/parse_test.go index ab22d6f..66eaf03 100644 --- a/server/app/goauto/sybimport/parse_test.go +++ b/server/app/goauto/sybimport/parse_test.go @@ -19,6 +19,27 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) { } } +func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) { + result := sybimport.Parse("均碼,黑色") + if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess { + t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result) + } +} + +func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) { + result := sybimport.Parse("黑色,均码") + if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess { + t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result) + } +} + +func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) { + result := sybimport.Parse("M,L") + if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" { + t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result) + } +} + func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) { result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】") if result.Status != models.SYBParseStatusUncertain { diff --git a/server/app/goauto/sybimport/role_repair.go b/server/app/goauto/sybimport/role_repair.go new file mode 100644 index 0000000..eed48f7 --- /dev/null +++ b/server/app/goauto/sybimport/role_repair.go @@ -0,0 +1,164 @@ +package sybimport + +import ( + "context" + "encoding/json" + "sort" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + + "gorm.io/gorm" +) + +// SpecRoleRepairReport contains only aggregate migration evidence. It does not +// expose order numbers, raw source payloads, or specification text. +type SpecRoleRepairReport struct { + SYBRows int + ShopeeProducts int +} + +type specRoleChange struct { + OldColor string + OldSize string +} + +// RepairReversedSpecRoles corrects only the historical shape that #216 can +// prove was role-reversed: the newly parsed color/size are exactly the old +// size/color. Human-confirmed rows are never overwritten. RawJSON is read for +// reparsing and deliberately excluded from every update. +func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairReport, error) { + report := SpecRoleRepairReport{} + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var records []models.SYBProduct + if err := tx.Where("manually_confirmed = ?", false).Find(&records).Error; err != nil { + return err + } + + changesByProduct := map[uint64][]specRoleChange{} + for _, record := range records { + var raw rawDetailSpec + if json.Unmarshal([]byte(record.RawJSON), &raw) != nil { + continue + } + parsed := Parse(raw.ProductSpec) + if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor || + parsed.Color != record.TargetSize || parsed.Size != record.TargetColor { + continue + } + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{ + "target_color": parsed.Color, + "target_size": parsed.Size, + "parse_status": parsed.Status, + "parse_note": parsed.Note, + }).Error; err != nil { + return err + } + report.SYBRows++ + if record.ShopeeProductID != nil { + productID := *record.ShopeeProductID + changesByProduct[productID] = append(changesByProduct[productID], specRoleChange{ + OldColor: record.TargetColor, + OldSize: record.TargetSize, + }) + } + } + + for productID, changes := range changesByProduct { + changed, err := reconcileRepairedShopeeSpecs(tx, productID, changes) + if err != nil { + return err + } + if changed { + report.ShopeeProducts++ + } + } + return nil + }) + return report, err +} + +func reconcileRepairedShopeeSpecs(tx *gorm.DB, productID uint64, changes []specRoleChange) (bool, error) { + var product models.ShopeeProduct + if err := tx.First(&product, productID).Error; err != nil { + return false, err + } + existing, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + return false, err + } + + var rows []models.SYBProduct + if err := tx.Where("shopee_product_id = ? AND (parse_status = ? OR manually_confirmed = ?)", + productID, models.SYBParseStatusSuccess, true).Find(&rows).Error; err != nil { + return false, err + } + usedColors, usedSizes := map[string]bool{}, map[string]bool{} + for _, row := range rows { + if row.TargetColor != "" { + usedColors[row.TargetColor] = true + } + if row.TargetSize != "" { + usedSizes[row.TargetSize] = true + } + } + oldColors, oldSizes := map[string]bool{}, map[string]bool{} + for _, change := range changes { + oldColors[change.OldColor] = true + oldSizes[change.OldSize] = true + } + + cleaned := make([]shopeeproduct.SpecDimension, 0, len(existing)) + for _, dimension := range existing { + values := make([]shopeeproduct.SpecValue, 0, len(dimension.Values)) + for _, value := range dimension.Values { + remove := value.Source == shopeeproduct.ValueSourceImport && value.Mapping == nil + switch dimension.Role { + case shopeeproduct.RoleColor: + remove = remove && oldColors[value.Name] && !usedColors[value.Name] + case shopeeproduct.RoleSize: + remove = remove && oldSizes[value.Name] && !usedSizes[value.Name] + default: + remove = false + } + if !remove { + values = append(values, value) + } + } + dimension.Values = values + cleaned = append(cleaned, dimension) + } + + incoming := make([]shopeeproduct.SpecDimension, 0, 2) + if values := sortedImportValues(usedColors); len(values) > 0 { + incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: values}) + } + if values := sortedImportValues(usedSizes); len(values) > 0 { + incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: values}) + } + reconciled := shopeeproduct.Merge(cleaned, incoming) + if err := shopeeproduct.Validate(reconciled); err != nil { + return false, err + } + encoded, err := shopeeproduct.Marshal(reconciled) + if err != nil { + return false, err + } + if encoded == product.SpecsJSON { + return false, nil + } + return true, tx.Model(&models.ShopeeProduct{}).Where("id = ?", productID).Update("specs_json", encoded).Error +} + +func sortedImportValues(set map[string]bool) []shopeeproduct.SpecValue { + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + values := make([]shopeeproduct.SpecValue, 0, len(names)) + for _, name := range names { + values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport}) + } + return values +} diff --git a/server/app/goauto/sybimport/role_repair_test.go b/server/app/goauto/sybimport/role_repair_test.go new file mode 100644 index 0000000..4fb9721 --- /dev/null +++ b/server/app/goauto/sybimport/role_repair_test.go @@ -0,0 +1,126 @@ +package sybimport + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" +) + +func TestRepairReversedSpecRolesIsSafeAndIdempotent(t *testing.T) { + db := newSyncTestDB(t) + manualMapping := &shopeeproduct.Mapping{PDDValue: "藏青", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed} + specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{ + {Name: "均碼", Source: shopeeproduct.ValueSourceImport}, + {Name: "手工蓝", Source: shopeeproduct.ValueSourceManual, Mapping: manualMapping}, + }}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}}, + }) + if err != nil { + t.Fatal(err) + } + product := models.ShopeeProduct{ShopeeItemID: "ROLE-REPAIR", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + raw := `{"productSpec":"均碼,黑色"}` + row := models.SYBProduct{OrderCode: "ROLE-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID, + ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: raw} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + manual := models.SYBProduct{OrderCode: "MANUAL-ORDER", DetailID: 2, StockID: 2, ShopeeItemID: product.ShopeeItemID, + ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "人工颜色", TargetSize: "人工尺码", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, ManuallyConfirmed: true, RawJSON: raw} + if err := db.Create(&manual).Error; err != nil { + t.Fatal(err) + } + + report, err := RepairReversedSpecRoles(context.Background(), db) + if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 { + t.Fatalf("unexpected repair report=%+v err=%v", report, err) + } + var repaired, untouched models.SYBProduct + db.First(&repaired, row.ID) + db.First(&untouched, manual.ID) + if repaired.TargetColor != "黑色" || repaired.TargetSize != "均碼" || repaired.RawJSON != raw { + t.Fatalf("repaired row is invalid: %+v", repaired) + } + if untouched.TargetColor != "人工颜色" || untouched.TargetSize != "人工尺码" { + t.Fatalf("manual row was overwritten: %+v", untouched) + } + + db.First(&product, product.ID) + reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + t.Fatal(err) + } + assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false) + assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色", false) + assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "黑色", true) + assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "均碼", true) + assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "手工蓝", true) + + replay, err := RepairReversedSpecRoles(context.Background(), db) + if err != nil || replay.SYBRows != 0 || replay.ShopeeProducts != 0 { + t.Fatalf("repair was not idempotent: report=%+v err=%v", replay, err) + } +} + +func TestRepairReversedSpecRolesKeepsAmbiguousColorUncertain(t *testing.T) { + db := newSyncTestDB(t) + specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色+白色", Source: shopeeproduct.ValueSourceImport}}}, + }) + if err != nil { + t.Fatal(err) + } + product := models.ShopeeProduct{ShopeeItemID: "ROLE-UNCERTAIN", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{OrderCode: "UNCERTAIN-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID, + ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色+白色", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色+白色"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + + report, err := RepairReversedSpecRoles(context.Background(), db) + if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 { + t.Fatalf("unexpected repair report=%+v err=%v", report, err) + } + db.First(&row, row.ID) + if row.TargetColor != "黑色+白色" || row.TargetSize != "均碼" || row.ParseStatus != models.SYBParseStatusUncertain { + t.Fatalf("ambiguous color role/status was not preserved safely: %+v", row) + } + db.First(&product, product.ID) + reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + t.Fatal(err) + } + assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false) + assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色+白色", false) +} + +func assertSpecValue(t *testing.T, specs []shopeeproduct.SpecDimension, role, name string, want bool) { + t.Helper() + found := false + for _, dimension := range specs { + if dimension.Role != role { + continue + } + for _, value := range dimension.Values { + if value.Name == name { + found = true + } + } + } + if found != want { + t.Fatalf("role=%s value=%q found=%v want=%v specs=%+v", role, name, found, want, specs) + } +} diff --git a/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair.go b/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair.go new file mode 100644 index 0000000..6e97011 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair.go @@ -0,0 +1,34 @@ +package version_local + +import ( + "context" + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/app/goauto/sybimport" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This migration repairs SYB rows whose two specification roles were stored +// in reverse before #216 recognized both observed source orders. The domain +// repair also removes only now-unreferenced, unmapped import values from the +// corresponding Shopee archive; human facts and raw source JSON are retained. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderRepair) +} + +func migrateSYBSpecRoleOrderRepair(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair_test.go b/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair_test.go new file mode 100644 index 0000000..f9ba90c --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787984000000_syb_spec_role_order_repair_test.go @@ -0,0 +1,62 @@ +package version_local + +import ( + "testing" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestMigrateSYBSpecRoleOrderRepair(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + if err := goautomigrations.Migrate(db); err != nil { + t.Fatal(err) + } + specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}}, + }) + if err != nil { + t.Fatal(err) + } + product := models.ShopeeProduct{ShopeeItemID: "MIGRATION-ROLE", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + row := models.SYBProduct{OrderCode: "MIGRATION-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID, + ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色"}`} + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + + const version = "1787984000000" + if err := migrateSYBSpecRoleOrderRepair(db, version); err != nil { + t.Fatalf("执行规格角色修复迁移失败: %v", err) + } + if err := db.First(&row, row.ID).Error; err != nil { + t.Fatal(err) + } + if row.TargetColor != "黑色" || row.TargetSize != "均碼" { + t.Fatalf("迁移未修正规格角色: %q/%q", row.TargetColor, row.TargetSize) + } + var records int64 + if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil { + t.Fatal(err) + } + if records != 1 { + t.Fatalf("迁移应记录一次版本,实际 %d", records) + } +} diff --git a/server/cmd/migrate/migration/version-local/1787984100000_syb_spec_role_order_uncertain_repair.go b/server/cmd/migrate/migration/version-local/1787984100000_syb_spec_role_order_uncertain_repair.go new file mode 100644 index 0000000..21c81d4 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787984100000_syb_spec_role_order_uncertain_repair.go @@ -0,0 +1,30 @@ +package version_local + +import ( + "context" + "runtime" + + "go-admin/app/goauto/sybimport" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// The first #216 local migration was exercised against live data before this +// follow-up case was known. Rerunning the idempotent repair lets already- +// migrated installations correct proven role reversals whose color remains +// uncertain, while fresh installations simply execute an empty second pass. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderUncertainRepair) +} + +func migrateSYBSpecRoleOrderUncertainRepair(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}