Commit Graph
24 Commits
Author SHA1 Message Date
chengma c05c4fafdb feat: 展示顺运宝观测规格 (#140) 2026-08-11 10:46:11 +08:00
chengma 0588ef18c8 refactor: 切换蝦皮数据到目录接口 (#135) 2026-08-11 10:16:48 +08:00
chengma 107ee5ce85 feat: 建立商品目录接入基础 (#132) 2026-08-11 10:00:07 +08:00
chengma a9f22b9b0e feat: 按创建人隔离采集采购任务 (#127) 2026-08-10 23:10:28 +08:00
chengma a26219091a fix: 简化真实采购创建并恢复客户端选择 (#112) 2026-08-10 18:15:28 +08:00
chengma 3aaa40cfe0 feat: 增加真实采购任务安全模式 (#98) 2026-08-10 15:11:38 +08:00
chengma 5a77bde534 feat: 增加规格匹配建议与决策审计 (#90) 2026-08-10 12:41:17 +08:00
chengma d1165b9e17 feat: 重构顺运宝规格主链路 (#88) 2026-08-10 12:24:23 +08:00
chengma 7e74bf210d feat: 建立 MySQL 8 数据库基础 (#78) 2026-08-10 01:22:22 +08:00
chengma 4433e3af37 feat: 完成规格映射与采购任务创建 (#69) 2026-08-09 22:55:14 +08:00
chengma 05a54b044d feat: 统一顺运宝日期同步并记录历史 (#59) 2026-08-09 18:22:58 +08:00
chengma e0c0dac7a0 feat: 增加客户端采购员归属管理 (#54) 2026-08-09 16:02:25 +08:00
chengma 0a7db404a1 feat: 实现 Admin 首次初始化与网页登录 (#50) 2026-08-09 13:36:41 +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
chengma 93beef8b36 feat: 保存并展示 PDD 店铺与价格采样信息 (#31) 2026-08-07 17:13:25 +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 d2de7331bb fix: 修复迁移被原地改写导致老库缺表 (#20)
#16 原地改写了 migration v1 而不是新增一条。Migrate 只在
user_version < 版本数时才跑,老库版本号已越过 v1,改写后的语句
永远不会重跑——程序拿着对不上的库静默启动,点到 PDD 商品页才 500。

修法:v1 逐字恢复成 7ad82b7 的原样,#16 的结构改动全部挪进 v3。
全新库也走 v1→v2→v3,与老库升级跑的是同一份 v3 代码,
不需要维护两条路径。

v3 必须认两种 user_version=2:#16 的原地改写让这个版本号对应
两种不同结构(原始 v1 建的没有 pdd_products,改写后的 v1 建的已经有)。
所以 v3 每一步先查 PRAGMA table_info / sqlite_master 看实际结构
再决定做不做,只有版本号推进是无条件的;已是最终结构的库
只推版本号,日志也照实说,不谎称"新增 pdd_products"。

旧 sku_mappings 数据丢弃并打日志:新主键需要 pdd_option_key,
那是 Go 的 OptionKey() 用 json.Marshal 算的,SQL 复现不了。
硬凑一个键出来,轻则映射静默失效,重则撞上别的规格静默买错东西——
后者正是 #16 存在的全部意义。

collecting 映射成 pending:原样保留会让 MarkCollecting 永远不成功,
那个商品再也建不了采集任务,界面上表现为按钮永远置灰且无法解开。

表重建按 SQLite 官方 12 步顺序:先建 _new 再 RENAME。
实测 ALTER TABLE RENAME TO 会自动重写别的表里指向它的外键子句,
先 RENAME 让位会把 shopee_skus 的外键改成指向一张马上被删的表。

另加两道闸:启动时 CheckSchema 缺表即拒绝启动(不是警告后继续);
admin/AGENTS.md 写死"migrations 只追加、不得修改已发布条目"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:23:24 +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
chengma 02073eab37 docs: define idempotent Client registration contract (#12) 2026-08-06 17:21:36 +08:00
chengmaandClaude Opus 5 7ad82b7795 feat: 实现结果提交接口与幂等处理
补齐 submit_result / submit_failure,Admin 侧的三个接口全部可用,
Client 的完整一圈(领取 → 执行 → 提交)现在能走通了。

实现
- 幂等:idempotency_keys 表。同键同内容返回上次的响应且不重复落库,
  同键不同内容返回 409。幂等记录与业务写入在**同一事务**,
  分开写的话业务成功但幂等没记上,重试会被重复处理
- 无条件接受(契约 §4.1,最容易写错的一条):
  任务已取消、已重派给别人,都照样接受结果——客户端中途不查任务状态,
  必然会提交"Admin 这边已经不要了"的结果,而它可能真的已经下过单,
  这些数据必须留痕
- 采集任务的结果落到商品级 shopee_products.pdd_data 并置 collected;
  失败则置 failed 并把原因写进 collect_error,操作员才看得见
- 失败状态映射:retry_wait→assigned,其余同名
- 三个接口都刷新 last_seen_at

新增 task_claims 表(migrations v2)
契约要求"只有从未分配给该客户端的任务才返回 403",但 assigned_client
只记当前归属,重派后就查不出原来那台领过——而契约又要求那种情况必须接受。
没有这张表这条规则根本没法判断。顺带得到一份审计记录。

修复第二个并发 bug:事务必须 BEGIN IMMEDIATE
并发提交报 SQLITE_BUSY。根因是 Go 的 db.Begin() 默认发 BEGIN DEFERRED,
事务开始时不拿写锁,多个事务各自先读再想升级成写就互相卡死,
这种情况 busy_timeout 救不了。DSN 加 _txlock=immediate 后事务一开始
就排队拿锁。实测 6 个并发事务:默认失败 5/6,加参数后 0/6。
已写进 docs/admin/03-data-model.md §2.1。

已验证(Go 1.23.0)
- 30 个单元测试全过,并发用例重复 20 次稳定通过
- 端到端:claim 200 → 提交 200 → 重复提交返回完全相同的响应 →
  同键不同内容 409 → 没领过的客户端 403 → 任务不存在 404 →
  缺 Idempotency-Key 400;库里 task=succeeded、幂等 1 条、领取历史 1 条

说明:Gitea 尚未配置,本次无对应工单号。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:04:49 +08:00
chengmaandClaude Opus 5 6ae768463f feat: 实现客户端注册与任务领取接口
Admin 的第一个业务功能。选它打头是因为它是穿透所有层的最薄一条竖切
(HTTP → handler/api → service → repository → SQLite → handler/web → 页面),
一个工单把分层模式立起来,后面四个模块照抄;同时它是与 Client 联调的接口,
能解锁另一条并行的工作线。

实现
- POST /tasks/claim:注册 + 领取。注册就在这里做,没有单独的注册接口,
  也没有心跳(理由见 docs/admin/04-client-api.md §3)
- 领取用条件更新 + 检查影响行数防并发,SQLite 没有 SELECT FOR UPDATE
- 客户端列表页:查询、按名称搜索、批量删除
- 在线状态是**算出来的**(last_seen_at 在 10 分钟内),数据库里没有该字段
- CSRF 中间件:双提交 Cookie,手写 82 行不引依赖。
  **只挂页面路由**,/api/v1/client/* 不能加——Client 不是浏览器、没有 Cookie
- 14 个单元测试

修复一个真 bug:PRAGMA 必须写进 DSN
并发领取测试报 database is locked (SQLITE_BUSY)。根因是
PRAGMA busy_timeout 每连接生效,而 database/sql 是连接池——
db.Exec("PRAGMA ...") 只作用于当时那条连接,池子新开的连接没执行过。
单线程正常、一并发就炸。改成 DSN 传参后并发测试跑 20 次全过。
这个坑已写进 docs/admin/03-data-model.md §2.1。

与工单的两处差异
- 去掉 name_is_custom 列后,"人工改的名字不被覆盖"改用更简单的做法:
  ON CONFLICT DO UPDATE SET 里不含 name,即只在首次注册时写入。
  效果相同,零额外字段、零迁移。已同步 04 §3
- 验收项"不向 dry_run 客户端分配真实下单任务"**未实现**:
  tasks 表没有字段标记任务是否需要真实下单。当前真实下单开关默认关闭、
  MVP 全是演练模式,暂不出问题,但开真实下单前必须补该字段,需另开工单

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,并发测试重复 20 次稳定通过
- 端到端:无任务 claim 204;插入任务后 claim 200 且 payload 含
  goods_url/goods_id/options/quantity/max_price_cent、无租约无 Admin 状态;
  重复 claim 204;缺 X-Client-Id 400;POST 无 CSRF token 403;
  列表页两台客户端在线状态与统计正确

说明:Gitea 尚未配置,本次无对应工单号。
submit_result / submit_failure 及其幂等处理留给下一个工单。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:56:18 +08:00
chengmaandClaude Opus 5 4f920f9d8b docs: 排除蝦皮原始报表,并同步主按钮文案
raw_data 不进 Git
原始报表含逐商品台币销售额等商业数据,进了 Git 就是永久历史。
- .gitignore 排除 raw_data/
- 改掉三处"仓库里有一份样本"的失真表述,改为向项目负责人索取
- 06 §2.1 相应加强:既然大样本不进库,admin/testdata/ 下的脱敏小样本
  就必须提交,否则别人拉下来测试跑不了;并写明脱敏做法

主按钮文案 开始自动获取 → 获取任务 ⇄ 停止获取
只改按钮标签。"自动获取"作为功能名保留(状态栏、Tab 顺序、
协调器开关等处不动),05 §4.1 加了一句说明两者不是一回事。

已知遗留:pdd_ui.py 自身仍不一致——构造时用「获取任务」,
但状态机 485/487 行仍是「开始自动获取」/「停止自动获取」,
会覆盖掉构造时的文字。该文件有未提交改动,本次未触碰,
差异已记入 02 §3.1,需另开工单修。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:58:09 +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