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

86 lines
3.3 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 web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ShopeeList 渲染蝦皮数据列表页。
//
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
// 列定义见 docs/admin/05-ui-specification.md §4.2。
func (h *Handler) ShopeeList(c *gin.Context) {
keyword := c.Query("goods_id")
// TODO(骨架): 调 service 查列表
// rows, err := service.ListShopeeSKUs(h.db, keyword, page, size)
// 注意:搜索要走数据库查询,不要一次查全量再在内存里过滤。
var rows []gin.H
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"Rows": rows,
"Status": "尚未实现:导入后这里显示商品和规格",
}))
}
// ShopeeImport 处理 Excel 上传导入。
//
// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3):
// - 商品汇总行(商品規格ID 为 "-")进 shopee_products,
// SKU 行进 shopee_skus,两者要分开处理;
// - 按列名找索引,不要写死列号;
// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜;
// - **只能 upsert,绝不允许先清空再导入**,
// 否则人工填的 PDD 链接会被洗掉;
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_data / collect_status。
func (h *Handler) ShopeeImport(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "没有收到文件,请重新选择 Excel 后再提交。")
return
}
// TODO(骨架): 校验大小和扩展名(只允许 .xlsx),
// 落盘到 data/uploads/ 时用自己生成的文件名,
// 不要直接用 file.Filename 拼路径(路径穿越)。
_ = file
// TODO(骨架): 调 service.ImportShopeeExcel(path),
// 返回 {商品数, SKU数, 解析失败行号列表} 并显示出来。
// 失败行不要静默跳过。
fail(c, http.StatusNotImplemented,
"导入功能尚未实现。已保留你选择的文件,未写入数据库。")
}
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
//
// 这是 PDD 链接唯一的录入口,见 docs/admin/05-ui-specification.md §4.3。
func (h *Handler) ShopeeSave(c *gin.Context) {
// TODO(骨架): 保存并根据 PDD 链接是否为空更新 collect_status
// (空 -> no_link,非空且未采集 -> pending)
fail(c, http.StatusNotImplemented, "保存功能尚未实现。")
}
// ShopeeDelete 批量删除勾选的行。
func (h *Handler) ShopeeDelete(c *gin.Context) {
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
}
// ShopeeCollect 发起采集任务。
//
// 入口在编辑弹窗里,紧挨 PDD 链接输入框——不要放到表格每一行,
// 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事。
//
// 规则:
// - PDD 链接为空不允许发起;
// - collect_status 已经是 collecting 的跳过并说明跳过了几个;
// - 批量采集要**按商品去重**后再建任务。
func (h *Handler) ShopeeCollect(c *gin.Context) {
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
}