Files
cmautobuy/admin/repository/shopee.go
T
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

49 lines
1.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"fmt"
"cmautobuy/admin/model"
)
// SetCollectResult 把采集回来的 PDD 商品数据存到商品级。
//
// `[必须]` pdd_data 存在**商品级**(shopee_products),不是订单级——
// 一个 PDD 商品采一次,所有相关订单共用这份结果。
// 见 docs/admin/03-data-model.md §3.1。
func SetCollectResult(q Execer, goodsID, pddData string) error {
if goodsID == "" {
return nil // 任务没关联蝦皮商品(比如手工造的测试任务),跳过
}
now := model.NowISO()
_, err := q.Exec(`
UPDATE shopee_products
SET pdd_data = ?, collect_status = 'collected',
collect_error = NULL, collected_at = ?, updated_at = ?
WHERE goods_id = ?`,
pddData, now, now, goodsID)
if err != nil {
return fmt.Errorf("保存商品 %s 的采集结果失败: %w", goodsID, err)
}
return nil
}
// SetCollectFailed 标记采集失败,并记下原因。
//
// 错误信息要能在界面上看见,否则操作员不知道为什么采不到。
func SetCollectFailed(q Execer, goodsID, errMsg string) error {
if goodsID == "" {
return nil
}
now := model.NowISO()
_, err := q.Exec(`
UPDATE shopee_products
SET collect_status = 'failed', collect_error = ?, updated_at = ?
WHERE goods_id = ?`,
errMsg, now, goodsID)
if err != nil {
return fmt.Errorf("标记商品 %s 采集失败出错: %w", goodsID, err)
}
return nil
}