// 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 }