Commit Graph
12 Commits
Author SHA1 Message Date
chengma e0c0dac7a0 feat: 增加客户端采购员归属管理 (#54) 2026-08-09 16:02:25 +08:00
chengma a18d4e67ae feat: 顺运宝支持指定日期同步 (#53) 2026-08-09 13:24:02 +08:00
chengma fc03388ed4 docs: 定义 Admin 登录与账号管理基线 (#49) 2026-08-09 13:12:03 +08:00
chengmaandClaude Opus 5 5e426cacf6 feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经
进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。

按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面
手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。

shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有
这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空,
把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在
DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。

增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而
last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。
宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at,
否则下次跳过这段区间,漏的单永远补不回来。

日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间:
抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是
"2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时,
订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天,
当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation——
那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。

金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder
在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。

迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec),
v1-v4 逐字未动,CheckSchema 覆盖新表新列。

会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do()
一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。

测试全部用 httptest 假服务端,不打真实站点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:49:13 +08:00
chengmaandClaude Opus 5 edfe39cc35 feat: 蝦皮数据页分页与状态筛选 (#43)
导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从
「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品
仍然找不到。所以分页和状态筛选一起做。

HTML 3.4MB → 16.7KB。

状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。
显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果
总数:「待补规格:6 个商品」。

列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早
有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。

page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。
总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。

「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时
JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。

分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用
<span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。

这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供
后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。
05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。

实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容
会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里
把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:40:07 +08:00
chengma 93beef8b36 feat: 保存并展示 PDD 店铺与价格采样信息 (#31) 2026-08-07 17:13:25 +08:00
chengmaandClaude Opus 5 f0d7da37cf feat: 采集采购页,采集与采购统一展示 (#19)
原来的采购任务页是骨架:var rows []gin.H 从不查库,页面永远为空;
TODO 写的还是只查 task_type = 'purchase'。所以 #18 建出来的采集任务
在界面上哪儿都看不到——用户点完「创建采集任务」只能盯着
collect_status 猜。

模块名定为「采集采购」(用户指定),直接点出这页装的是哪两类任务,
比泛称「任务」更能让人一眼知道点进去看什么。路由 /tasks 不变,
改路由会让已有书签和文档链接全失效,没有收益。

列不按类型并列——两种任务字段完全不同,并列会让采集任务行一半是空列。
改成固定列 + 一列「目标」把业务信息概括成一句话:
  采集  PDD 737116531267
  采购  SO-001 · M/黑色 · 2件 · ≤¥42.00
拼接逻辑在 service 层,模板只负责显示。

统计和列表共用同一个筛选条件拼装函数。分开写的话总有一天会忘了
给统计也加条件,数字和表格对不上,操作员会以为页面坏了。

无主任务的客户端列显示「—」。#17 之后采集任务默认无主,这列会大量为空。

详情弹窗只读,采购专有字段(数量、价格上限、目标规格)在采集任务里
整段不出现,不显示空行。复用 #18 的弹窗机制,app.js 无需改动。

顺带清掉 PDD 页加进来之后一直没跟上的模块计数:多处「四个模块/四个页面」
改成五个。其中 06-quality-security.md 那两处是验证清单,
照着做的人只会测四个页面,PDD 页永远不在回归范围里。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:05 +08:00
chengmaandClaude Opus 5 e40ce5a13c fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果
才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生,
这个商品就永久报废,界面上没有任何入口能救,只能改数据库。

改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。

判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程:
后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上
栽过两次,能不引入并发就不引入。

15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队
等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用,
而卡死的代价是商品永久报废。

已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。
可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md,
免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。

时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释:
改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。

界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」
不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数,
一个都没建成时告诉操作员还要等多久。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:42:54 +08:00
chengmaandClaude Opus 5 96267f4a18 feat: PDD 商品页工具条文案与宽度调整 (#23)
三处:
- 「创建」改成「添加 PDD 商品」。同一条工具条上还有「创建采集任务」,
  两个都以「创建」开头,第一次用的人分不清哪个是加商品、哪个是发起采集。
- 搜索框收到 30%。
- 「搜索」按钮紧挨输入框——这是收窄的自然结果:表单里没有会伸展的
  元素之后,多余空间留在按钮之后,按钮就不会被顶到很右边。

用 .search-narrow 类而不是改 .toolbar input[type="text"]:
后者被五个页面共用(蝦皮/顺运宝/任务/客户端/PDD 各一个搜索框),
改了会把另外四页一起改掉。

用 flex 而不是 width:工具条是 flex 布局,width 会被 flex-grow 盖掉。
min-width: 160px 仍从通用规则继承,窄屏时不会缩到没法输入。

表单上的 grow 类保留,它负责把「删除」按钮顶到最右侧。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:15:35 +08:00
chengmaandClaude Opus 5 eb8d357f5a feat: PDD 商品页面 (#18)
打通 Client↔Admin 闭环的一环:录入 PDD 链接 → 创建采集任务 →
客户端领走去采 → 规格和价格显示在页面上。#16 写的 MarkCollecting 和
SoftDeletePddProduct 至此才有生产调用方。

链接解析严格、不做容错兜底:goods_id 上有 UNIQUE 约束,防重全靠它。
猜一个的话同一商品会存成好几行、采好几遍,规格映射还说不清指向哪一行。
短链一律拒绝,卡域名是为了拦"粘了淘宝链接"这种失误。

创建采集任务先占状态再建任务:MarkCollecting 只在 pending/failed 时成功,
同时充当"有没有人已经在采"的判断,与建任务在同一事务里,
所以并发点多次只会建出一个任务(实测 4 并发 → 1 条)。

submit.go 的 collectedData 增加 Dimensions —— 没有它就只能按 Go 的 map
遍历,而 map 无序,同一商品每次刷新"颜色/尺码"的先后都可能变。

顺带修 #16 一处缺陷:EnsurePddProduct 复活分支清空了 skus_json 却漏了
title,导致复活后状态显示"未采集"但标题还留着旧值。

审查打回一次:清理"PDD 链接唯一的录入口"这一过期说法(全库 5 处),
以及 shopee.go 里"空 → no_link"的过期 TODO —— no_link 已在 #16
从 CHECK 约束删除,照写会直接撞约束。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:27:06 +08:00
chengmaandClaude Opus 5 998c06a2bf feat: PDD 商品数据独立成表 (#16)
原来 pdd_data 是 shopee_products 上的一个 JSON 字段,两个蝦皮商品指向
同一个 PDD 链接时会各存一份、各采一次;collect_status 描述的是 PDD 商品的
状态,却挂在蝦皮商品上,两份可能不一致。

更要紧的是 PDD 商品变动频繁(A 下架就得换 B),而 sku_mappings 只按
shopee_sku_id 做键——换商品后旧映射还在,B 恰好有同名规格但完全是另一件货
时会静默买错,事后查不出来。

改动
- 新增 pdd_products 表:id 主键 + goods_id UNIQUE + 4 个状态值(去掉
  no_link,「未填链接」改由 shopee_products.pdd_goods_id 为空表达)+
  软删除可复活
- shopee_products 去掉 pdd_data / collect_status / collect_error /
  collected_at,pdd_goods_id 改为引用
- sku_mappings 主键改为 (shopee_sku_id, pdd_goods_id),新增 pdd_option_key。
  查映射永远带上当前 PDD 商品,换商品后天然查不到旧映射,不需要删数据;
  换回原商品时旧映射直接复用
- 新增 OptionKey():用 json.Marshal 实现(Go 序列化 map 按键名排序,
  天然规范化),不自己拼字符串——规格文字里可能含 = 或 ;。
  存映射和查 SKU 必须用同一个函数,各写一遍会静默算出不同结果
- 采集结果改落 pdd_products,新增两条校验:
  返回的 goods_id 与请求不符 → 整体回滚拒绝(422),不静默存下;
  skus 为空数组 → 置 failed 而非 collected,否则界面显示"已采集"
  但数据毫无用处

实施时超出工单但必要的三处
- TaskExists 重构为 GetTaskInfo:原函数只返回蝦皮 goods_id,
  而采集结果要按 PDD goods_id 落库,不改取不到正确的键
- 复活时一并清空旧采集结果(skus_json / collect_msg / collected_at),
  否则复活后会显示"已采集"但数据是删除前的
- 删除 repository/shopee.go:两个函数签名全变且已迁到 pdd.go,留着是死代码

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,55 个测试
- 端到端补验了工单未覆盖的 HTTP 层:goods_id 不符返回 422
  COLLECT_GOODS_MISMATCH 且整体回滚(skus_json 空、任务仍 claimed、
  幂等记录 0 条);skus 为空返回 200 但状态 failed

遗留
- MarkCollecting / SoftDeletePddProduct 暂无调用方,等界面工单接上
- artifact_ref 存 diagnostics 原始 JSON,未按 client-001:artifacts/... 规范化,
  因 Client 侧尚未定义 diagnostics 结构
- 界面未实现(工单明确排除),四个页面仍为骨架

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:27:39 +08:00
chengmaandClaude Opus 5 90379347b4 docs: 建立 Admin 子项目的文档基线
仓库从单子项目变成两个:client(Python + PyQt5 桌面端)和
admin(Go + Gin + HTML 模板 Web 管理端)。两者技术栈完全不同,
规则各自独立,只通过三个 HTTP 接口交互。

新增 admin/AGENTS.md 和 docs/admin/ 共 9 份文档,结构与 docs/client/ 对齐。

关键设计决策(均已与用户确认)
- 四模块:蝦皮数据 / 顺运宝数据 / 采购任务 / 客户端列表,
  统一三段式布局(工具条 / 带勾选的表格 / 状态条)
- 蝦皮数据拆成商品表 + SKU 表:报表本身就是两层结构,
  且 PDD 链接是商品级的,放 SKU 级会重复维护
- pdd_data 存商品级,一个 PDD 商品采一次,所有订单共用
- SKU 映射独立成表且可复用,同一蝦皮 SKU 只需人工匹配一次
- PDD 链接人工填写,采集按钮就放在填链接的编辑弹窗里
- 任务分配给指定客户端;不加心跳,注册在领取时完成,
  在线状态由 last_seen_at 派生
- SQLite 驱动固定 modernc.org/sqlite(纯 Go 免 cgo,go build 直接出 exe)
- 不引入前端框架和 npm 构建,只允许原生 JS 或 htmx

基于样本文件 raw_data/蝦皮数据样本.xlsx 实测得出的约束
- 11287 行 = 5195 商品汇总行 + 6092 SKU 行,导入必须分开处理
- 商品規格ID 零重复,是天然主键
- 规格原文两种格式各占 53.4% / 46.6%,只解析【】会漏掉一半
- 平均每商品仅 1.17 个 SKU,报表不是全量目录,
  因此必须支持手动新增,且货运单外键不能加硬约束

同步更新
- 根 AGENTS.md 开头的指针改为两个子项目对照表
- docs/README.md 重构为双子项目索引
- .gitignore 加 admin/data/、admin.exe、Excel 锁文件

说明:Gitea 尚未配置,本次无对应工单号。
raw_data/ 未提交,含台币销售额等商业数据,待用户决定。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:49:04 +08:00