840 lines
27 KiB
Go
840 lines
27 KiB
Go
// PDD 商品模块的业务逻辑。
|
||
//
|
||
// 这一层负责三件事,界面层和数据层都不做:
|
||
// - 从链接里解析 goods_id(解析不出就不许写库);
|
||
// - 把数据库里的原始字段翻成界面直接能显示的文字(状态、价格、时间);
|
||
// - 创建采集任务时的去重和跳过规则。
|
||
//
|
||
// 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。
|
||
package service
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"cmautobuy/admin/model"
|
||
"cmautobuy/admin/repository"
|
||
)
|
||
|
||
// ErrBadPddURL 链接里解析不出 goods_id。
|
||
//
|
||
// 这个错误必须让操作员看见,**不能容错兜底**:goods_id 上有 UNIQUE 约束,
|
||
// 防重全靠它。拿不到就没法查重,同一个商品会存成好几行,
|
||
// 采好几遍,规格映射还说不清指向哪一行。
|
||
var ErrBadPddURL = errors.New("PDD 链接无效")
|
||
|
||
// ErrPddGoodsIDChanged 编辑链接时换成了另一个商品。
|
||
var ErrPddGoodsIDChanged = errors.New("新链接指向的是另一个商品")
|
||
|
||
// ---------- 链接解析 ----------
|
||
|
||
// pddHosts 是认识的拼多多域名(含子域名)。
|
||
//
|
||
// 卡域名是为了拦住"粘错了链接"这种常见失误——粘了淘宝链接却存进 PDD 表,
|
||
// 要等客户端跑到手机上才发现。
|
||
var pddHosts = []string{"yangkeduo.com", "pinduoduo.com"}
|
||
|
||
// goodsIDParams 是可能装着 goods_id 的查询参数名,按优先级排。
|
||
var goodsIDParams = []string{"goods_id", "_x_goods_id"}
|
||
|
||
// ParsePddGoodsID 从 PDD 商品链接里取出 goods_id。
|
||
//
|
||
// 认这几种写法:
|
||
//
|
||
// https://mobile.yangkeduo.com/goods.html?goods_id=737116531267
|
||
// https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&_x_xx=1
|
||
// https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267
|
||
//
|
||
// **短链(如 p.pinduoduo.com/xxxx)一律拒绝**,因为里面没有 goods_id。
|
||
// 让操作员回 App 里复制完整链接,比在这里猜要安全得多。
|
||
func ParsePddGoodsID(raw string) (string, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return "", fmt.Errorf("%w: 链接不能为空", ErrBadPddURL)
|
||
}
|
||
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return "", fmt.Errorf("%w: 这不是一个链接(%s)", ErrBadPddURL, raw)
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return "", fmt.Errorf("%w: 链接要以 http:// 或 https:// 开头", ErrBadPddURL)
|
||
}
|
||
if !isPddHost(u.Hostname()) {
|
||
return "", fmt.Errorf(
|
||
"%w: %s 不是拼多多的网址,认得的是 yangkeduo.com 和 pinduoduo.com",
|
||
ErrBadPddURL, u.Hostname())
|
||
}
|
||
|
||
query := u.Query()
|
||
for _, name := range goodsIDParams {
|
||
if id := strings.TrimSpace(query.Get(name)); isGoodsID(id) {
|
||
return id, nil
|
||
}
|
||
}
|
||
return "", fmt.Errorf(
|
||
"%w: 链接里没有 goods_id。请在拼多多 App 的商品页点「分享」→「复制链接」,"+
|
||
"拿到带 goods_id= 的完整链接,短链接不行", ErrBadPddURL)
|
||
}
|
||
|
||
func isPddHost(host string) bool {
|
||
host = strings.ToLower(host)
|
||
for _, h := range pddHosts {
|
||
if host == h || strings.HasSuffix(host, "."+h) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isGoodsID 判断是不是一串合理长度的纯数字。
|
||
// 卡长度是为了拦住 goods_id=0 或者被截断的半截 ID。
|
||
func isGoodsID(s string) bool {
|
||
if len(s) < 6 || len(s) > 24 {
|
||
return false
|
||
}
|
||
for _, r := range s {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ---------- 界面文字 ----------
|
||
|
||
// collectStatusTexts 是采集状态的中文说法。
|
||
//
|
||
// `[必须]` 界面上必须有文字,不能只用颜色区分——
|
||
// 见 docs/admin/05-ui-specification.md §2。
|
||
var collectStatusTexts = map[model.CollectStatus]string{
|
||
model.CollectPending: "未采集",
|
||
model.CollectCollecting: "采集中",
|
||
model.CollectCollected: "已采集",
|
||
model.CollectFailed: "采集失败",
|
||
}
|
||
|
||
// CollectStatusOption 是筛选下拉框的一项。
|
||
type CollectStatusOption struct {
|
||
Value string // 空串表示"全部"
|
||
Text string
|
||
}
|
||
|
||
// CollectStatusOptions 返回筛选下拉框的全部选项。
|
||
func CollectStatusOptions() []CollectStatusOption {
|
||
return []CollectStatusOption{
|
||
{"", "全部"},
|
||
{string(model.CollectPending), collectStatusTexts[model.CollectPending]},
|
||
{string(model.CollectCollecting), collectStatusTexts[model.CollectCollecting]},
|
||
{string(model.CollectCollected), collectStatusTexts[model.CollectCollected]},
|
||
{string(model.CollectFailed), collectStatusTexts[model.CollectFailed]},
|
||
}
|
||
}
|
||
|
||
// ParseCollectStatus 校验筛选参数。认不出的一律当"全部",
|
||
// 不报错——地址栏里的参数是用户可以随便改的,不值得为它弹错误页。
|
||
func ParseCollectStatus(s string) model.CollectStatus {
|
||
status := model.CollectStatus(strings.TrimSpace(s))
|
||
if _, ok := collectStatusTexts[status]; ok {
|
||
return status
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func collectStatusText(s model.CollectStatus) string {
|
||
if t, ok := collectStatusTexts[s]; ok {
|
||
return t
|
||
}
|
||
return string(s)
|
||
}
|
||
|
||
// statusTextFor 返回一条 PDD 商品该显示的状态文字,比 collectStatusText
|
||
// 多做一件事:区分"采集中"和"采集中(超时)"。
|
||
//
|
||
// `[必须]` 超时的必须显示不一样的文字——操作员盯着「采集中」不知道它已经死了,
|
||
// 会一直傻等;显示超时他才知道可以重新采,见 #24。
|
||
// `[必须]` 只加文字,不新增筛选状态:超时不是数据库里真实存在的一种
|
||
// collect_status 取值,只是"采集中"在读取那一刻的一种呈现。
|
||
func statusTextFor(p model.PddProduct) string {
|
||
if isCollectingStale(p) {
|
||
return "采集中(超时)"
|
||
}
|
||
return collectStatusText(p.CollectStatus)
|
||
}
|
||
|
||
// isCollectingStale 判断一条"采集中"记录是不是已经超时。
|
||
//
|
||
// 现算,不依赖任何后台任务——本项目已经在并发上栽过两次,
|
||
// 能不引入并发就不引入,见 repository.MarkCollecting 的注释。
|
||
// updated_at 解析不出来时保守当作"没超时":数据本身已经有问题,
|
||
// 不该顺带触发"可以重新采集",让问题被掩盖。
|
||
func isCollectingStale(p model.PddProduct) bool {
|
||
if p.CollectStatus != model.CollectCollecting {
|
||
return false
|
||
}
|
||
t, ok := model.ParseISO(p.UpdatedAt)
|
||
if !ok {
|
||
return false
|
||
}
|
||
return time.Since(t) > model.CollectStaleAfter
|
||
}
|
||
|
||
// formatLocalTime 把库里的 ISO 8601 转成本地时区的可读写法。
|
||
// 空值或坏值都显示占位符,不显示 0001-01-01。
|
||
func formatLocalTime(iso string) string {
|
||
if strings.TrimSpace(iso) == "" {
|
||
return placeholder
|
||
}
|
||
t, ok := model.ParseISO(iso)
|
||
if !ok {
|
||
return iso // 原样显示,好让人看出数据有问题
|
||
}
|
||
return t.Local().Format("2006-01-02 15:04")
|
||
}
|
||
|
||
// placeholder 是"这里没有值"的统一写法。空单元格看着像页面坏了。
|
||
const placeholder = "—"
|
||
|
||
// formatPriceCent 把整数分显示成 ¥12.56。
|
||
//
|
||
// `[必须]` price_cent 为 null 时显示"未采到",**不能显示 ¥0.00**。
|
||
// 0 元和采不到价格是两回事,显示成 0 元会让操作员以为捡到便宜,
|
||
// 而这个数是要参与价格保护比对的(会花钱)。
|
||
func formatPriceCent(cent *int64) string {
|
||
if cent == nil {
|
||
return "未采到"
|
||
}
|
||
v := *cent
|
||
sign := ""
|
||
if v < 0 {
|
||
sign, v = "-", -v
|
||
}
|
||
return fmt.Sprintf("%s¥%d.%02d", sign, v/100, v%100)
|
||
}
|
||
|
||
// ---------- 列表 ----------
|
||
|
||
// PddProductView 是列表页一行要显示的全部内容,全部已经是字符串。
|
||
// 模板里不做判断和格式化,该在这里算完。
|
||
type PddProductView struct {
|
||
ID int64
|
||
GoodsID string
|
||
URL string
|
||
Title string // 未采集时是占位符
|
||
ShopName string // 未采到时是占位符
|
||
StatusText string
|
||
SkuCountText string // 未采集时是占位符
|
||
CollectedAt string
|
||
UpdatedAt string
|
||
CollectMsg string
|
||
IsFailed bool // 失败的行标黄,方便一眼找出来重采
|
||
}
|
||
|
||
// PddListResult 是列表页要的全部数据。
|
||
type PddListResult struct {
|
||
Rows []PddProductView
|
||
Total int // 全部未删除记录数,不受筛选影响
|
||
FilteredTotal int // 当前筛选下的总数,用于页数和筛选状态
|
||
Page int
|
||
TotalPages int
|
||
Counts map[model.CollectStatus]int
|
||
IsFiltered bool
|
||
}
|
||
|
||
// ListPddProducts 查列表并把每一行翻成界面文字。
|
||
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus, requestedPage int) (*PddListResult, error) {
|
||
page := requestedPage
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
rows, filteredTotal, err := repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
totalPages := TotalPages(filteredTotal)
|
||
clampedPage := ClampPage(page, totalPages)
|
||
if clampedPage != page {
|
||
page = clampedPage
|
||
rows, _, err = repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
counts, err := repository.CountPddProductsByStatus(db)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := &PddListResult{
|
||
Rows: make([]PddProductView, 0, len(rows)), Counts: counts,
|
||
FilteredTotal: filteredTotal, Page: page, TotalPages: totalPages,
|
||
IsFiltered: strings.TrimSpace(keyword) != "" || status != "",
|
||
}
|
||
for _, n := range counts {
|
||
result.Total += n
|
||
}
|
||
|
||
for _, r := range rows {
|
||
v := PddProductView{
|
||
ID: r.ID,
|
||
GoodsID: r.GoodsID,
|
||
URL: r.URL,
|
||
Title: r.Title,
|
||
ShopName: r.ShopName,
|
||
StatusText: statusTextFor(r.PddProduct),
|
||
CollectedAt: formatLocalTime(r.CollectedAt),
|
||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||
CollectMsg: r.CollectMsg,
|
||
IsFailed: r.CollectStatus == model.CollectFailed,
|
||
}
|
||
if v.Title == "" {
|
||
v.Title = "(未采集,采集后自动回填)"
|
||
}
|
||
if v.ShopName == "" {
|
||
v.ShopName = placeholder
|
||
}
|
||
// -1 是"还没采过"。注意 0 要如实显示 0:
|
||
// 采到 0 个规格说明采集出了问题,显示成 — 就看不出来了。
|
||
if r.SkuCount < 0 {
|
||
v.SkuCountText = placeholder
|
||
} else {
|
||
v.SkuCountText = fmt.Sprintf("%d", r.SkuCount)
|
||
}
|
||
result.Rows = append(result.Rows, v)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// StatusLine 拼底部状态条,形如:
|
||
//
|
||
// 共 24 条 · 已采集 18 · 未采集 4 · 采集中 1 · 采集失败 1
|
||
//
|
||
// 有筛选时前面加一句"筛选出 N 条",免得操作员把筛选后的行数
|
||
// 当成全部行数,以为记录被删了。
|
||
func (r *PddListResult) StatusLine() string {
|
||
order := []model.CollectStatus{
|
||
model.CollectCollected, model.CollectPending,
|
||
model.CollectCollecting, model.CollectFailed,
|
||
}
|
||
parts := []string{fmt.Sprintf("共 %d 条", r.Total)}
|
||
for _, s := range order {
|
||
parts = append(parts, fmt.Sprintf("%s %d", collectStatusText(s), r.Counts[s]))
|
||
}
|
||
line := strings.Join(parts, " · ")
|
||
if r.IsFiltered {
|
||
line = fmt.Sprintf("筛选出 %d 条 / %s", r.FilteredTotal, line)
|
||
}
|
||
return fmt.Sprintf("%s · 第 %d/%d 页", line, r.Page, r.TotalPages)
|
||
}
|
||
|
||
// ---------- 弹窗 ----------
|
||
|
||
// PddSkuView 是弹窗规格表的一行。
|
||
type PddSkuView struct {
|
||
Options []string // 按 DimensionNames 的顺序排好,模板直接 range
|
||
PriceText string
|
||
// PriceSource 在按颜色采样时显示“✓ 实测”或“推断”,不能只靠颜色区分。
|
||
PriceSource string
|
||
Available string
|
||
}
|
||
|
||
// PddProductDetail 是双击弹窗要显示的全部内容。
|
||
type PddProductDetail struct {
|
||
ID int64
|
||
GoodsID string
|
||
URL string
|
||
Title string
|
||
ShopName string
|
||
StatusText string
|
||
CollectMsg string
|
||
CollectedAt string
|
||
ArtifactRef string
|
||
|
||
// Collected 为 false 时模板显示"尚未采集",而不是一张空表。
|
||
Collected bool
|
||
ShowPriceSource bool
|
||
DimensionNames []string
|
||
SKUs []PddSkuView
|
||
|
||
// SkusError 非空表示 skus_json 存的东西解析不了。
|
||
// 这种情况要如实说出来,不能装作"没有规格"——
|
||
// 前者是数据坏了要重采,后者是商品本身没规格。
|
||
SkusError string
|
||
}
|
||
|
||
// GetPddProductDetail 读一条 PDD 商品,并把 skus_json 拆成规格表。
|
||
// 商品不存在或已删除返回 (nil, nil)。
|
||
func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
|
||
p, err := repository.GetPddProductByID(db, id)
|
||
if err != nil || p == nil {
|
||
return nil, err
|
||
}
|
||
|
||
d := &PddProductDetail{
|
||
ID: p.ID,
|
||
GoodsID: p.GoodsID,
|
||
URL: p.URL,
|
||
Title: p.Title,
|
||
ShopName: p.ShopName,
|
||
StatusText: statusTextFor(*p),
|
||
CollectMsg: p.CollectMsg,
|
||
CollectedAt: formatLocalTime(p.CollectedAt),
|
||
ArtifactRef: p.ArtifactRef,
|
||
}
|
||
if d.Title == "" {
|
||
d.Title = placeholder
|
||
}
|
||
if d.ShopName == "" {
|
||
d.ShopName = placeholder
|
||
}
|
||
if d.CollectMsg == "" {
|
||
d.CollectMsg = placeholder
|
||
}
|
||
|
||
if strings.TrimSpace(p.SkusJSON) == "" {
|
||
return d, nil
|
||
}
|
||
|
||
collected, err := parseCollected(p.SkusJSON)
|
||
if err != nil {
|
||
d.SkusError = "采集结果解析不了,需要重新采集:" + err.Error()
|
||
return d, nil
|
||
}
|
||
|
||
d.Collected = true
|
||
d.ShowPriceSource = collected.PriceGranularity == "color"
|
||
keys, names := dimensionOrder(collected)
|
||
d.DimensionNames = names
|
||
|
||
for _, sku := range collected.SKUs {
|
||
row := PddSkuView{
|
||
Options: make([]string, 0, len(keys)),
|
||
PriceText: formatPriceCent(sku.PriceCent),
|
||
Available: "否",
|
||
}
|
||
if sku.Available {
|
||
row.Available = "是"
|
||
}
|
||
if d.ShowPriceSource {
|
||
row.PriceSource = "推断"
|
||
if sameOptions(sku.Options, sku.PriceObservedAt) {
|
||
row.PriceSource = "✓ 实测"
|
||
}
|
||
}
|
||
for _, k := range keys {
|
||
value := sku.Options[k]
|
||
if value == "" {
|
||
value = placeholder
|
||
}
|
||
row.Options = append(row.Options, value)
|
||
}
|
||
d.SKUs = append(d.SKUs, row)
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
// sameOptions 判断价格读取时实际选中的组合是否就是当前这一行。
|
||
// 两个 map 必须键和值都完全一致,避免只比较颜色后把多个尺码都标成实测。
|
||
func sameOptions(options, observed map[string]string) bool {
|
||
if len(options) == 0 || len(options) != len(observed) {
|
||
return false
|
||
}
|
||
for key, value := range options {
|
||
if observed[key] != value {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// dimensionOrder 定下规格各维度的显示顺序,返回 (取值用的 key, 表头文字)。
|
||
//
|
||
// 优先用采集结果里的 dimensions;它缺失时退回"把所有 SKU 出现过的 key 排序"。
|
||
// 退回方案必须排序:Go 的 map 遍历顺序是随机的,不排的话
|
||
// 同一个商品每次刷新页面列的顺序都不一样,看着像数据在跳。
|
||
func dimensionOrder(c *collectedData) (keys, names []string) {
|
||
if len(c.Dimensions) > 0 {
|
||
for _, d := range c.Dimensions {
|
||
name := d.Name
|
||
if name == "" {
|
||
name = d.Key
|
||
}
|
||
keys = append(keys, d.Key)
|
||
names = append(names, name)
|
||
}
|
||
return keys, names
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
for _, sku := range c.SKUs {
|
||
for k := range sku.Options {
|
||
seen[k] = true
|
||
}
|
||
}
|
||
for k := range seen {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
return keys, keys
|
||
}
|
||
|
||
// ---------- 创建与编辑 ----------
|
||
|
||
// CreatePddProduct 按链接建一条 PDD 商品,返回它的 goods_id。
|
||
//
|
||
// 只需要链接,其余字段全靠采集回填。
|
||
// 重复创建同一个商品不会产生第二行,软删除过的会被复活
|
||
// (三个分支都在 repository.EnsurePddProduct 里,见 #16)。
|
||
func CreatePddProduct(db *sql.DB, rawURL string) (string, error) {
|
||
goodsID, err := ParsePddGoodsID(rawURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
p, err := repository.EnsurePddProduct(db, goodsID, strings.TrimSpace(rawURL))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return p.GoodsID, nil
|
||
}
|
||
|
||
// UpdatePddProductURL 保存弹窗里改过的链接。
|
||
//
|
||
// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改:
|
||
// 这一行上挂着采集结果和 spec_mappings,goods_id 一换,
|
||
// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。
|
||
func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error {
|
||
goodsID, err := ParsePddGoodsID(rawURL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
p, err := repository.GetPddProductByID(db, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if p == nil {
|
||
return fmt.Errorf("PDD 商品 #%d 不存在或已被删除", id)
|
||
}
|
||
if p.GoodsID != goodsID {
|
||
return fmt.Errorf(
|
||
"%w:这条记录是商品 %s,新链接是 %s。"+
|
||
"要维护另一个商品请用「创建」新建一条",
|
||
ErrPddGoodsIDChanged, p.GoodsID, goodsID)
|
||
}
|
||
return repository.UpdatePddProductURL(db, id, strings.TrimSpace(rawURL))
|
||
}
|
||
|
||
// DeletePddProducts 批量软删除,返回实际删掉的条数。
|
||
func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) {
|
||
return repository.SoftDeletePddProducts(db, dedupe(goodsIDs))
|
||
}
|
||
|
||
// ---------- 创建采集任务 ----------
|
||
|
||
// CollectTaskResult 是创建采集任务的结果,供 handler 组装状态条提示。
|
||
//
|
||
// `[必须]` 跳过原因要分开计数,不能只给一个笼统的"跳过 N 个"——
|
||
// 那种提示对操作员没有信息量:不知道该等还是该去处理别的,见 #24。
|
||
type CollectTaskResult struct {
|
||
Created int
|
||
|
||
SkippedShopeeMissing int // 浏览器提交的蝦皮商品已不存在
|
||
SkippedUnlinked int // 蝦皮商品尚未关联 PDD
|
||
SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交
|
||
SkippedCollected int // 已经采集完成,本来就不需要重新采集
|
||
SkippedDeleted int // 商品不存在或已被软删除
|
||
|
||
// RetryWaitText 是 SkippedCollecting 里最快能重试的还需等待时长,
|
||
// 形如 "12 分钟";SkippedCollecting 为 0 时是空串。
|
||
RetryWaitText string
|
||
}
|
||
|
||
// Skipped 是跳过总数,供测试和粗粒度统计用;
|
||
// 界面提示要按原因分开说,见 FormatCollectTaskMessage,不要直接显示这个数。
|
||
func (r CollectTaskResult) Skipped() int {
|
||
return r.SkippedShopeeMissing + r.SkippedUnlinked + r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
|
||
}
|
||
|
||
// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。
|
||
//
|
||
// `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending),
|
||
// 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样,
|
||
// 指定了反而会在那台机器关着的时候干等。
|
||
//
|
||
// 规则:
|
||
// - 按 goods_id 去重,勾了重复的只建一个;
|
||
// - collect_status 是 collecting 且**没超时**的跳过——有任务在跑了,
|
||
// 再建一个就是让两台机器采同一个商品,白费一趟;
|
||
// collecting 但**已超时**的(见 model.CollectStaleAfter)当作可以重建,
|
||
// 不再跳过,这是 #24 要修的死锁;
|
||
// - 已采集的跳过(本来就不需要重采);
|
||
// - 建成功后把状态置为 collecting。
|
||
//
|
||
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
||
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
||
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
||
return createPddCollectTasks(db, goodsIDs, "", "", "", nil)
|
||
}
|
||
|
||
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
|
||
// 即使浏览器伪造 clientID,也必须重新按当前登录账号校验可见范围。
|
||
func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []string, clientID string) (CollectTaskResult, error) {
|
||
visibleUserID, err := taskActorScope(actor)
|
||
if err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID, nil)
|
||
}
|
||
|
||
// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。
|
||
// 普通批量入口仍跳过 collected;本入口允许 collected,但保留旧采集数据,
|
||
// 等 Client 成功提交新结果后再由既有提交逻辑覆盖。
|
||
func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID string) (CollectTaskResult, error) {
|
||
var result CollectTaskResult
|
||
goodsID = strings.TrimSpace(goodsID)
|
||
clientID = strings.TrimSpace(clientID)
|
||
if goodsID == "" {
|
||
return result, invalidInput("商品 ID 不能为空")
|
||
}
|
||
|
||
visibleUserID, err := taskActorScope(actor)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
tx, err := db.Begin()
|
||
if err != nil {
|
||
return result, fmt.Errorf("开始事务失败: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if clientID != "" {
|
||
visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if !visible {
|
||
return result, invalidInput("所选客户端不存在或不在当前账号可见范围")
|
||
}
|
||
}
|
||
|
||
p, err := repository.GetPddProductByGoodsID(tx, goodsID)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if p == nil || p.IsDeleted() {
|
||
result.SkippedDeleted = 1
|
||
if err := tx.Commit(); err != nil {
|
||
return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
ok, err := repository.MarkRecollecting(tx, goodsID)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if !ok {
|
||
result.SkippedCollecting = 1
|
||
if wait, ok := retryWaitFor(p.UpdatedAt); ok {
|
||
result.RetryWaitText = formatRetryWait(wait)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
taskID, err := repository.NextTaskID(tx, model.TaskCollect)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if err := repository.InsertCollectTaskForClientAndUser(
|
||
tx, taskID, p.GoodsID, p.URL, clientID, actor.UserID); err != nil {
|
||
return result, err
|
||
}
|
||
result.Created = 1
|
||
if err := tx.Commit(); err != nil {
|
||
return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string,
|
||
sybSourcesByGoodsID map[string][]string) (CollectTaskResult, error) {
|
||
var result CollectTaskResult
|
||
|
||
goodsIDs = dedupe(goodsIDs)
|
||
if len(goodsIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
tx, err := db.Begin()
|
||
if err != nil {
|
||
return CollectTaskResult{}, fmt.Errorf("开始事务失败: %w", err)
|
||
}
|
||
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||
|
||
if clientID != "" {
|
||
visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID)
|
||
if err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
if !visible {
|
||
return CollectTaskResult{}, invalidInput("所选客户端不存在或不在当前账号可见范围")
|
||
}
|
||
}
|
||
|
||
// 跳过原因里"正在采集"的那些,各自还要等多久才超时可重试;
|
||
// 取其中最快的一个,给操作员一个"下一步该等多久"的具体数字。
|
||
var (
|
||
minRetryWait time.Duration
|
||
hasMinRetryWait bool
|
||
)
|
||
|
||
for _, goodsID := range goodsIDs {
|
||
p, err := repository.GetPddProductByGoodsID(tx, goodsID)
|
||
if err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
if p == nil || p.IsDeleted() {
|
||
result.SkippedDeleted++
|
||
continue
|
||
}
|
||
|
||
// 先占状态再建任务:MarkCollecting 决定"这个商品现在允不允许发起采集"
|
||
// (pending/failed,或 collecting 但已超时),它同时起到原子抢占的作用——
|
||
// 同一时刻只有一个并发请求能把它从"可采"改成"collecting"。
|
||
ok, err := repository.MarkCollecting(tx, goodsID)
|
||
if err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
if !ok {
|
||
// MarkCollecting 内部已经把"pending/failed"和"collecting 但已超时"
|
||
// 都判成允许,所以这里失败时 p 只可能是两种情况:
|
||
// - collecting 且没超时 —— 真的有任务在跑;
|
||
// - collected —— 采集已经完成,不需要重采。
|
||
// (p 是这个事务里刚读到的,事务全程持有写锁,中途不会被别人改。)
|
||
if p.CollectStatus == model.CollectCollecting {
|
||
result.SkippedCollecting++
|
||
if wait, ok := retryWaitFor(p.UpdatedAt); ok {
|
||
if !hasMinRetryWait || wait < minRetryWait {
|
||
minRetryWait, hasMinRetryWait = wait, true
|
||
}
|
||
}
|
||
} else {
|
||
result.SkippedCollected++
|
||
}
|
||
continue
|
||
}
|
||
|
||
taskID, err := repository.NextTaskID(tx, model.TaskCollect)
|
||
if err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
if err := repository.InsertCollectTaskForClientAndUser(
|
||
tx, taskID, p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
if err := repository.InsertCollectTaskSybSources(tx, taskID, sybSourcesByGoodsID[p.GoodsID]); err != nil {
|
||
return CollectTaskResult{}, err
|
||
}
|
||
result.Created++
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
if hasMinRetryWait {
|
||
result.RetryWaitText = formatRetryWait(minRetryWait)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// retryWaitFor 算一条"采集中"记录还要多久才会被判定超时、可以重新发起采集。
|
||
// updated_at 解析不出来时返回 (0, false)——不该拿一个解析不出的时间瞎猜等待时长。
|
||
func retryWaitFor(updatedAt string) (time.Duration, bool) {
|
||
t, ok := model.ParseISO(updatedAt)
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
remain := model.CollectStaleAfter - time.Since(t)
|
||
if remain < 0 {
|
||
remain = 0
|
||
}
|
||
return remain, true
|
||
}
|
||
|
||
// formatRetryWait 把还需等待的时长翻成"12 分钟"这样的人话。
|
||
// 向上取整、且至少显示 1 分钟——不然临界值会显示"0 分钟才可重试",
|
||
// 操作员会以为可以立刻点,实际上还没到点。
|
||
func formatRetryWait(remain time.Duration) string {
|
||
minutes := int(math.Ceil(remain.Minutes()))
|
||
if minutes < 1 {
|
||
minutes = 1
|
||
}
|
||
return fmt.Sprintf("%d 分钟", minutes)
|
||
}
|
||
|
||
// FormatCollectTaskMessage 把 CreatePddCollectTasks 的结果组装成状态条提示。
|
||
//
|
||
// `[必须]` 一个都没建成时不能只说"已创建 0 个",要让操作员知道下一步该干嘛
|
||
// (比如还要等多久),见 #24。
|
||
func FormatCollectTaskMessage(r CollectTaskResult) string {
|
||
var reasons []string
|
||
if r.SkippedShopeeMissing > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("%d 个蝦皮商品已不存在", r.SkippedShopeeMissing))
|
||
}
|
||
if r.SkippedUnlinked > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("%d 个未关联 PDD", r.SkippedUnlinked))
|
||
}
|
||
if r.SkippedCollecting > 0 {
|
||
reason := fmt.Sprintf("%d 个正在采集中", r.SkippedCollecting)
|
||
if r.Created > 0 {
|
||
// 已经建成了一批时,措辞换成"正在采集的",配合"跳过…"这句话通顺
|
||
reason = fmt.Sprintf("%d 个正在采集的", r.SkippedCollecting)
|
||
} else if r.RetryWaitText != "" {
|
||
reason += fmt.Sprintf("(还需等待约 %s才可重试)", r.RetryWaitText)
|
||
}
|
||
reasons = append(reasons, reason)
|
||
}
|
||
if r.SkippedCollected > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("%d 个已采集的", r.SkippedCollected))
|
||
}
|
||
if r.SkippedDeleted > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("%d 个已删除的", r.SkippedDeleted))
|
||
}
|
||
|
||
if r.Created == 0 {
|
||
if len(reasons) == 0 {
|
||
return "没有创建任何任务"
|
||
}
|
||
return "没有创建任何任务:" + strings.Join(reasons, "、")
|
||
}
|
||
|
||
msg := fmt.Sprintf("已创建 %d 个采集任务,等待客户端领取", r.Created)
|
||
if len(reasons) > 0 {
|
||
msg += fmt.Sprintf("(跳过 %s)", strings.Join(reasons, "、"))
|
||
}
|
||
return msg
|
||
}
|
||
|
||
// dedupe 去掉重复项并保持原有顺序,顺带丢掉空串。
|
||
func dedupe(values []string) []string {
|
||
seen := make(map[string]bool, len(values))
|
||
out := make([]string, 0, len(values))
|
||
for _, v := range values {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" || seen[v] {
|
||
continue
|
||
}
|
||
seen[v] = true
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|