فهرست منبع

feat(account_pool): 实现得卡账号池及多账号管理框架

- 设计多账号入池方案,支持按任务独占取号和异常自动切号
- 实现账号健康状态管理,区分 healthy、cooling、dead 状态
- 实现 token 自动续期,仅使用 refreshToken,避免密码登录滑块风险
- 支持随机取号分摊请求压力,防止单号风控击穿
- 实现闲置账号 token 保活,避免 refresh_token 过期导致账号判死
- 提供短信发送、短信登录及密码登录接口,支持自动补货与注册功能
- 自动补货功能集成接码平台,支持自动取号、发码、收码、登录及入库
- 设计专属静态 IP 绑定机制,实现 1 号 1 IP,释放 dead 号 IP 供补货回收
- 独立 MySQL 账号记录表设计与租约机制,支持分布式进程安全抢占账号
- 完善日志记录,支持风险判断及账户状态反馈,便于运维和排错
charley 4 روز پیش
والد
کامیت
8ba2325a19
46فایلهای تغییر یافته به همراه11799 افزوده شده و 0 حذف شده
  1. 183 0
      deca_auto_spider/README.md
  2. 98 0
      deca_auto_spider/YamlLoader.py
  3. 6 0
      deca_auto_spider/application.yml
  4. 696 0
      deca_auto_spider/common/account_pool.py
  5. 300 0
      deca_auto_spider/common/auto_send_wx_msg.py
  6. 37 0
      deca_auto_spider/common/deca_account_ddl.sql
  7. 592 0
      deca_auto_spider/common/deca_on_sale_daily_spider.py
  8. 966 0
      deca_auto_spider/common/deca_sold_core.py
  9. 166 0
      deca_auto_spider/common/isms_client.py
  10. 67 0
      deca_auto_spider/common/settings.py
  11. 94 0
      deca_auto_spider/docs/优化记录_deca采集翻页_20260911.md
  12. 107 0
      deca_auto_spider/docs/优化记录_得卡DECA_20260910.md
  13. 53 0
      deca_auto_spider/docs/待办_已售报告用户新老客与复购率_20260911.md
  14. 219 0
      deca_auto_spider/docs/账号池落地_得卡DECA_20260909.md
  15. 671 0
      deca_auto_spider/mysql_pool.py
  16. 809 0
      deca_auto_spider/reports/onsale_report/deca_on_sale_report.py
  17. 6 0
      deca_auto_spider/reports/sold_report/application.yml
  18. 1679 0
      deca_auto_spider/reports/sold_report/daily_report.py
  19. 97 0
      deca_auto_spider/reports/sold_report/export_teams_excel.py
  20. 0 0
      deca_auto_spider/reports/sold_report/logs/daily_report_20260908.log
  21. 21 0
      deca_auto_spider/reports/sold_report/logs/daily_report_20260911.log
  22. 15 0
      deca_auto_spider/reports/sold_report/logs/daily_report_20260914.log
  23. 0 0
      deca_auto_spider/reports/sold_report/logs/scheduler_20260908.log
  24. 0 0
      deca_auto_spider/reports/sold_report/logs/weekly_report_20260908.log
  25. 365 0
      deca_auto_spider/reports/sold_report/moddu_miss_capture_report.py
  26. 152 0
      deca_auto_spider/reports/sold_report/moddu_user_total_report.py
  27. 285 0
      deca_auto_spider/reports/sold_report/monthly_report.py
  28. 159 0
      deca_auto_spider/reports/sold_report/run_report_for_date.py
  29. 89 0
      deca_auto_spider/reports/sold_report/run_scheduler.py
  30. 109 0
      deca_auto_spider/reports/sold_report/stats_sold.sql
  31. 281 0
      deca_auto_spider/reports/sold_report/weekly_report.py
  32. BIN
      deca_auto_spider/reports/sold_report/得卡已售每日报告_20260911.xlsx
  33. BIN
      deca_auto_spider/reports/sold_report/得卡已售每日报告_20260912.xlsx
  34. BIN
      deca_auto_spider/reports/sold_report/得卡已售每日报告_20260914.xlsx
  35. 251 0
      deca_auto_spider/reports/sold_report/球队.json
  36. 12 0
      deca_auto_spider/requirements.txt
  37. 908 0
      deca_auto_spider/spiders/buy_record_spider.py
  38. 398 0
      deca_auto_spider/spiders/deca_team_spider.py
  39. 115 0
      deca_auto_spider/spiders/maintenance.py
  40. 1074 0
      deca_auto_spider/spiders/onsale_alert_spider.py
  41. 98 0
      deca_auto_spider/spiders/replenish_accounts.py
  42. 99 0
      deca_auto_spider/spiders/shop_test.py
  43. 105 0
      deca_auto_spider/spiders/sold_daily_spider.py
  44. 58 0
      deca_auto_spider/spiders/sold_history_spider.py
  45. 108 0
      deca_auto_spider/start_all.py
  46. 251 0
      deca_auto_spider/球队.json

+ 183 - 0
deca_auto_spider/README.md

@@ -0,0 +1,183 @@
+# 得卡 DECA 自动化爬虫
+
+得卡(decalive.com,国内球星卡 / 卡片交易平台)数据采集与报告系统。多任务常驻采集在售 / 已售 / 购买记录 / 选队总价,定时产出企微报告,底层由 **20 账号池 + 1 号 1 专属静态 IP** 支撑,账号可经接码平台全自动补货。
+
+> ⚠️ **上线头等风险**:本目录与旧目录 `D:\work\2026-08-02(deca_spider)` 背后是同一批得卡账号,`refreshToken` 一次性轮换。**两边同时真跑会互相踩废 token**。上线前务必先停旧目录对应任务,确认只有一处在跑。
+
+---
+
+## 一、快速开始
+
+```bash
+# 1. 进入项目根目录
+cd "D:\work\2026-09-08(deca_auto_spider)"
+
+# 2. 安装依赖(首次)
+pip install -r requirements.txt
+# 公共库 charley-utils(mysql_pool / YamlLoader)单独 editable 安装,见下「环境依赖」
+
+# 3. 一键启动全部常驻任务(采集 + 报告调度 + 运维,崩溃自动重启)
+python start_all.py
+
+# 只启动部分任务
+python start_all.py buy_record team
+```
+
+`Ctrl+C` 优雅终止所有子任务。
+
+---
+
+## 二、目录结构
+
+```
+2026-09-08(deca_auto_spider)/
+├── start_all.py             # ★统一部署入口:一键拉起全部常驻任务 + 崩溃自动重启
+├── application.yml           # 数据库配置(host 100.64.0.25 / db crawler)
+│
+├── common/        # 公共库(采集 + 报告都 import,扁平 import 名)
+│   ├── settings.py    #   ★集中配置:账号池阈值 / 接口路径 / 接码 Key / 代理 IP 池
+│   ├── account_pool.py    #   ★账号池:取号 / 切号 / 续期 / 状态机 / 巡检 / 自动补货
+│   ├── deca_sold_core.py     #   核心:签名 / do_request / 账号池接入(线程隔离)
+│   ├── isms_client.py   #   接码平台(爱接码 i-sms.app)客户端
+│   ├── deca_on_sale_daily_spider.py#   在售详情解析 / 入库(现全走免登录接口)
+│   ├── auto_send_wx_msg.py         #   企微群机器人推送
+│   └── deca_account_ddl.sql        # 账号表 deca_account_record 建表 DDL
+│
+├── spiders/              # 采集任务(脚本内自行 chdir 到项目根,共用根配置)
+│   ├── buy_record_spider.py        #购买记录(重点 5 家,8 线程并发)
+│   ├── onsale_alert_spider.py      #   上架提醒(新上架监控推送)
+│   ├── sold_daily_spider.py        # 已售每日采集
+│   ├── sold_history_spider.py      #   已售历史回补
+│   ├── deca_team_spider.py #   选队随机总价(5 分钟/轮,只扫重点 5 家)
+│   ├── replenish_accounts.py       #   ★一键补号(经接码平台自动注册入库)
+│   └── maintenance.py       #   ★运维:cooling 巡检 + Excel 定时清理
+│
+└── reports/
+    ├── onsale_report/# 在售报告(用 token,chdir 项目根)
+    │   └── deca_on_sale_report.py  #   在售报告:每天 09/15/20/01 四次(仅出 Excel)
+    └── sold_report/    # 已售报告(不用 token,chdir 自身目录)
+        ├── run_scheduler.py   #   ★调度器:日报 09:10 + 周报周一 09:20 + 月报 1 号 09:30
+     ├── daily_report.py         #   已售每日报告(重点商家明细 + 买家排行)
+        ├── weekly_report.py#   魔都球星卡周报
+        ├── monthly_report.py       #   魔都球星卡月报
+    └── application.yml 球队.json ...   # 本目录自带(因 chdir 自身目录)
+```
+
+---
+
+## 三、环境依赖
+
+- **Python 3.12.10**
+- **第三方包**(见 `requirements.txt`):`requests` `loguru` `tenacity` `schedule` `openpyxl` `matplotlib`
+- **公共库 `charley-utils`**(`mysql_pool` / `YamlLoader`,本机 editable 安装,各项目直接 import):
+
+  ```bash
+  cd D:\work\common\charley-utils
+  pip install -e .          # 换新环境导入失败时执行一次即可
+  ```
+
+  依赖 `pymysql` `DBUtils` `PyYAML`(由该包声明)。**禁止把 mysql_pool.py 复制进本项目**——统一维护一份。
+
+- **数据库**:`mysql_pool` 读**运行目录**下的 `application.yml`(`mysql.host/port/username/password/db`)。项目根与 `reports/sold_report/` 各自带一份。
+
+---
+
+## 四、配置
+
+所有账号池 / 接码 / 代理相关配置集中在 **`common/settings.py`**,改配置只动这一处:
+
+| 配置项 | 说明 |
+|---|---|
+| `POOL_TARGET_SIZE = 20` | 账号池目标规模 |
+| `LEASE_TTL_SEC = 6h` | 进程独占一个号的租约 TTL(崩溃后到期自动可再租,不死占) |
+| `DEAD_CODES = {10010}` | 续期判死码(10010 =「长期未登录,请重新登录」→ 判 dead 摘池) |
+| `ISMS_API_KEY` / `ISMS_PROJECT_KEYWORD` | 接码平台(爱接码)Key 与项目关键词「优豪卡」 |
+| `PROXY_USER/PASS/PORT` + `PROXY_IPS[20]` | 20 个国内静态 IP(1 号 1 IP 永久绑定,端口 16816) |
+
+> ⚠️ `settings.py` 含接码 API Key 与代理账密,**私有仓库专用,勿外传 / 勿提交公开仓库**。
+
+数据库配置在 `application.yml`(当前库:`100.64.0.25 / crawler`)。
+
+---
+
+## 五、账号池
+
+得卡 need_auth 接口的登录态由账号池统一管理,采集脚本无需关心 token。
+
+- **数据模型**:表 `deca_account_record`(DDL 见 `common/deca_account_ddl.sql`)。自增 id 主键、`phone` 唯一键、每号独立 access/refresh/token_exp、`proxy_url`(专属 IP)、`status`(healthy/cooling/dead)、进程独占租约字段。
+- **取号策略**:**每批随机**——每 20 个 need_auth 请求随机换一个 healthy 号,请求量摊到全池,单号风控面最小。
+- **登录态操作走专属 IP**:续期 / 短信登录都从该号绑定的静态 IP 发出,不暴露本机 IP。
+- **多线程安全**:`buy_record` 用 8 线程并发,账号池按 **thread-local 隔离**——每线程独立持号、独立续期,互不踩踏。
+- **判死与恢复**:续期命中 `10010` → 判 dead 摘池 + 企微通知;网络异常算软失败不判死;`maintenance.py` 每 10 分钟巡检恢复 cooling 号。
+- **短信登录即注册**:得卡无独立注册接口,短信验证码登录即完成注册。补号经接码平台租号 → 收码 → 短信登录入库 → 绑定空闲 IP。
+
+### 补号(账号不足时)
+
+```bash
+python spiders/replenish_accounts.py --target 20 --batch 5
+```
+
+- `--target` 池子总目标数,`--batch` 单次最多补几个。
+- ⚠️ **得卡强风控,别一次连续注册一大批**(半小时爆十几个新号是典型连坐信号),建议**分几天补**(每天 `--batch 3~5`)。
+- 补号是长任务(每号错峰停顿 30~90s、收码等待),**在终端里跑**,别用受超时限制的环境。
+- 注意接码平台余额(失败号也扣取号费)。
+
+---
+
+## 六、任务清单与调度
+
+`start_all.py` 统一管理以下 7 个常驻任务,任一崩溃退出 15s 后自动重启:
+
+| 任务名 | 脚本 | 说明 / 调度 |
+|---|---|---|
+| `buy_record` | buy_record_spider.py | 购买记录(重点 5 家,8 线程) |
+| `onsale_alert` | onsale_alert_spider.py | 上架提醒(新上架监控推送) |
+| `sold_daily` | sold_daily_spider.py | 已售每日采集 |
+| `team` | deca_team_spider.py | 选队随机总价(5 分钟/轮,只扫重点 5 家) |
+| `onsale_report` | deca_on_sale_report.py loop | 在售报告:每天 **09 / 15 / 20 / 01** 四次(仅出 Excel) |
+| `sold_report` | run_scheduler.py | 已售:日报 **09:10** + 周报**周一 09:20** + 月报 **1 号 09:30** |
+| `maintenance` | maintenance.py | 账号池 cooling 巡检(每 10 分钟)+ Excel 清理(每天 03:00,保留 30 天) |
+
+单独运行任一脚本也可(各脚本自带 bootstrap 会 chdir 到所需目录):
+
+```bash
+python spiders/buy_record_spider.py
+python reports/sold_report/run_scheduler.py
+```
+
+---
+
+## 七、服务器常驻(最简方式)
+
+`start_all.py` 已自带崩溃自动重启,服务器上只需让它在后台常驻即可。
+
+**Windows**(后台启动,日志落 `logs/`):
+
+```bash
+start /b python start_all.py
+```
+
+**Linux**(nohup 后台 + 日志重定向):
+
+```bash
+cd /path/to/2026-09-08(deca_auto_spider)
+nohup python -u start_all.py > logs/start_all.out 2>&1 &
+```
+
+> 如需开机自启 / 掉电自恢复,可进一步用 Windows 计划任务(nssm)或 Linux systemd 托管 `start_all.py`——按需再加,最简常驻用上面即可。
+
+---
+
+## 八、注意事项
+
+1. **上线前先停旧目录**:确认 `D:\work\2026-08-02(deca_spider)` 对应生产任务已停,避免同账号双跑踩废 token(见文首风险提示)。
+2. **登录态全由账号池接管**:所有 need_auth 请求统一走账号池(随机取号 + 续签 + 各号专属静态 IP)。`deca_sold_core` 仅暴露 `init_account_pool(...)` 一个开关,采集脚本启动时调用即生效,无其它登录态路径。
+3. **代理 IP 有效期**:当前 20 个静态 IP 有效期至 **2026-10-09**,到期需在快代理续费并同步 `settings.PROXY_IPS`。
+4. **版本号写入**:涉及 Python / 依赖版本时,先 `--version` 实测再写,勿凭记忆。
+
+---
+
+## 九、相关文档
+
+- 账号池落地全文:`docs/账号池落地_得卡DECA_20260909.md`(架构 / 数据模型 / 短信登录=注册 / 判死 / IP 选型 / 实测 / 上线步骤)
+- 进度台账 / 交接:`HANDOFF.md`(新窗口接手先读)

+ 98 - 0
deca_auto_spider/YamlLoader.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/12/22 10:44
+import os, re
+import yaml
+
+regex = re.compile(r'^\$\{(?P<ENV>[A-Z_\-]+:)?(?P<VAL>[\w.]+)}$')
+
+
+class YamlConfig:
+    def __init__(self, config):
+        self.config = config
+
+    def get(self, key: str):
+        return YamlConfig(self.config.get(key))
+
+    def getValueAsString(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return os.getenv(env, group['VAL'])
+            return None
+        except:
+            return self.config[key]
+
+    def getValueAsInt(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return int(os.getenv(env, group['VAL']))
+            return 0
+        except:
+            return int(self.config[key])
+
+    def getValueAsBool(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return bool(os.getenv(env, group['VAL']))
+            return False
+        except:
+            return bool(self.config[key])
+
+
+def _resolve_path(path: str) -> str:
+    """
+    解析 yaml 文件路径,按优先级查找:
+      1) 绝对路径或 cwd 下存在 → 直接用(保留旧行为,向后兼容)
+      2) 调用方主脚本所在目录 → 兜底,方便打包后从任意 cwd 启动
+    :param path: (str) 用户传入的路径,默认 'application.yml'
+    :return: (str) 实际可读取的完整路径;找不到则返回原 path 让 open() 抛错
+    """
+    # 1) 旧行为:cwd 或绝对路径
+    if os.path.exists(path):
+        return path
+
+    # 2) 主脚本目录(__main__.__file__)
+    try:
+        import __main__
+        main_file = getattr(__main__, '__file__', None)
+        if main_file:
+            candidate = os.path.join(os.path.dirname(os.path.abspath(main_file)), path)
+            if os.path.exists(candidate):
+                return candidate
+    except Exception:
+        pass
+
+    return path
+
+
+def readYaml(path: str = 'application.yml', profile: str = None) -> YamlConfig:
+    """
+    读取 yaml 配置。
+    :param path: (str) yaml 文件路径,默认 'application.yml'。
+                       优先 cwd / 绝对路径(保留旧行为),找不到再 fallback 到主脚本所在目录。
+    :param profile: (str) 可选环境后缀,如 'dev' 会额外加载 'application-dev.yml' 并 update
+    :return: (YamlConfig) 配置访问对象
+    :raises FileNotFoundError: cwd 和主脚本目录都找不到时抛出
+    """
+    real_path = _resolve_path(path)
+    with open(real_path, encoding='utf-8') as fd:
+        conf = yaml.load(fd, Loader=yaml.FullLoader)
+
+    if profile is not None:
+        result = real_path.rsplit('.', 1)
+        profiledYaml = f'{result[0]}-{profile}.{result[1]}'
+        if os.path.exists(profiledYaml):
+            with open(profiledYaml, encoding='utf-8') as fd:
+                conf.update(yaml.load(fd, Loader=yaml.FullLoader))
+
+    return YamlConfig(conf)

+ 6 - 0
deca_auto_spider/application.yml

@@ -0,0 +1,6 @@
+mysql:
+  host: ${MYSQL_HOST:100.64.0.25}
+  port: ${MYSQL_PROT:3306}
+  username: ${MYSQL_USERNAME:crawler}
+  password: ${MYSQL_PASSWORD:Pass2022}
+  db: ${MYSQL_DATABASE:crawler}

+ 696 - 0
deca_auto_spider/common/account_pool.py

@@ -0,0 +1,696 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/08
+"""得卡 DECA · 账号池(框架版,2026/09/08 搭)。
+
+把原来集中在单账号 token.json 的登录态,改为「多账号入池、按任务取号、异常自动切号、冷却复用」,
+把带 token 请求压力平摊到多账号 + 各账号专属静态 IP,根治 9/7「单账号被风控击穿致整链停摆」。
+方案与请求量测算见 HANDOFF.md 及 docs/账号池方案_得卡DECA_20260908.md。
+
+数据模型:一张 deca_account_record 表(DDL 见同目录 deca_account_ddl.sql)+ 本模块。
+每个账号自带独立 access/refresh/token_exp + 专属 proxy_url(1 号 1 IP 永久绑定)+ 健康状态。
+
+线上生命周期原则(重要):
+    · 线上**只做 refresh 续期,绝不自动跑密码登录**——密码登录会撞阿里云滑块、无人值守过不去。
+      续期失败即判 dead、摘池、企微通知,自动切下一个 healthy 号,采集不中断。
+    · 补货靠接码平台自动补货:healthy 数不足时 auto_replenish 经接码平台租号 + 短信登录(=注册)入库 +
+      绑定空闲专属 IP(见 register_one_via_isms / spiders/replenish_accounts.py),无需人工灌 token。
+
+取号粒度:进程级独占——每个常驻任务启动时 acquire 一个号、独占整个运行期(保持账号↔IP 稳定降风控),
+仅在该号挂了才 switch 换号。购买记录按商家分片时,每个分片进程各 acquire 一个号(带 owner_tag 区分)。
+
+账号供给(2026/09/09 落地):得卡无独立注册接口——短信验证码登录「登录即注册」,故:
+    - sms_send() / sms_login()  发验证码 + 短信登录入库(登录即注册;新号 INSERT、旧号补货 UPDATE)
+    - register()  等价于 sms_login(两步:先 sms_send 收码,再带 code 调用),离线人工补货/开户主路径
+    - _login()    密码登录(撞阿里云滑块),仅极端兜底,线上默认不调;短信登录不撞滑块、优先用它
+接入采集脚本(把 core.do_request 的 need_auth 取号改为走本池)属后续 step3,未在本框架内改 core。
+"""
+import os
+import time
+from datetime import datetime, timedelta
+
+from loguru import logger
+
+# ==================== 配置 ====================
+# 全部业务/敏感配置集中在 settings.py(接口路径 / 接码 Key / 代理账密 IP 池 / 阈值),改配置只动那处。
+from settings import *  # noqa: F401,F403  (T_ACCOUNT/LEASE_TTL_SEC/DEAD_CODES/ISMS_*/PROXY_*/*_PATH 等)
+
+
+class AccountPool:
+    """账号池:从 deca_account_record 取号、续期、异常切号、状态维护。
+
+    单个常驻任务进程持有一个实例:启动 acquire 一个号独占,运行期用 ensure_access 保证 token 有效,
+    请求失败调 report_failure(达阈值 cooling/dead)→ switch 换号;结束 release 归还。
+    """
+
+    def __init__(self, pool, log=None, task_tag: str = ""):
+        """初始化账号池。
+
+        Args:
+            pool (MySQLConnectionPool): MySQL 连接池。
+            log (optional): 日志对象。Defaults to None(回落 loguru.logger)。
+            task_tag (str, optional): 本进程任务标签,写入 owner_tag 便于排障与独占区分
+                (如 "buy_record:881226408" / "team" / "onsale_alert")。Defaults to ""。
+        """
+        self.pool = pool
+        self.log = log or logger
+        self.task_tag = task_tag
+        self.pid = os.getpid()
+        self.account = None                 # 当前持有的账号行 dict;None 表示未持号
+
+    # ---------- 取号 / 归还 / 切号 ----------
+
+    def acquire(self) -> dict | None:
+        """从池中租一个 healthy 且未被独占(或租约已过期)的账号,打上本进程租约并返回。
+
+        原子抢占:一条 UPDATE 按 last_used_at 升序挑一个可租的号打租约(owner_pid/lease_until),
+        再按本进程 pid + task_tag 回读刚租到的行。取号成功后写入 self.account。
+
+        Returns:
+            dict | None: 账号行 dict(含 id/phone/access_token/refresh_token/token_exp/proxy_url 等);
+                池中无可用 healthy 号时返回 None(调用方应告警/退避)。
+        """
+        lease_until = datetime.now() + timedelta(seconds=LEASE_TTL_SEC)
+        # 挑一个 healthy 且(未被占 或 租约已过期)的号,按最久未用优先做负载均衡,打上本进程租约
+        self.pool.update_one(
+            f"UPDATE {T_ACCOUNT} SET owner_tag=%s, owner_pid=%s, lease_until=%s, last_used_at=NOW() "
+            "WHERE status='healthy' AND (owner_pid IS NULL OR lease_until IS NULL OR lease_until < NOW()) "
+            "ORDER BY last_used_at ASC LIMIT 1",   # NULL 升序排最前:从未用过的号优先取,做负载均衡
+            (self.task_tag, self.pid, lease_until.strftime("%Y-%m-%d %H:%M:%S")))
+        # 回读本进程刚租到的号(owner_pid=自身 pid 唯一,别的进程不会用我的 pid)
+        row = self.pool.select_one(
+            f"SELECT id, phone, password, country_code, uid, access_token, refresh_token, token_exp, "
+            f"proxy_url, status FROM {T_ACCOUNT} WHERE owner_pid=%s AND owner_tag=%s AND status='healthy' "
+            f"ORDER BY lease_until DESC LIMIT 1",
+            (self.pid, self.task_tag))
+        if not row:
+            self.log.error(f"[账号池] 无可用 healthy 账号(task={self.task_tag})")
+            self.account = None
+            return None
+        self.account = self._row_to_dict(row)
+        self.log.info(f"[账号池] 取号成功 id={self.account['id']} phone={self.account['phone']} "
+                      f"proxy={self.account.get('proxy_url')} task={self.task_tag}")
+        return self.account
+
+    def acquire_random(self, lease_sec: int = LEASE_TTL_SEC) -> dict | None:
+        """从 healthy 池随机挑一个未被占的账号,打上租约并返回(每批随机取号用)。
+
+        与 acquire(进程级独占、按最久未用负载均衡)的区别:本方法按 RAND() 随机挑号,
+        供「每批随机」——每 N 个请求换一个随机号,把带 token 请求量摊平到所有号、降单号风控面。
+        租约默认用 LEASE_TTL_SEC(较长,覆盖持号整个使用期):一个进程持号直到换号/退出,
+        期间租约不过期,别的进程不会抢走——避免「持号时长 > 租约 → 被抢 → 两进程同号续期
+        踩废一次性轮换的 refreshToken → 误判 10010 dead」(2026/09/09 修:原 300s 过短,慢/稀疏
+        任务如 team/onsale_alert 会在换号前租约过期)。进程崩溃后租约到期自动可被再租,不死占。
+
+        并发抢占:先 RAND() 选一个候选 id,再带「仍空闲」条件 UPDATE 认领;认领失败(被别的进程抢走)
+        重试几次换一个候选。认领成功后回读该行写入 self.account。
+
+        Args:
+            lease_sec (int, optional): 租约时长(秒),覆盖持号整个使用期。Defaults to LEASE_TTL_SEC(6h)。
+
+        Returns:
+            dict | None: 账号行 dict;池中无可用 healthy 号或多次抢占失败返回 None。
+        """
+        lease_until = (datetime.now() + timedelta(seconds=lease_sec)).strftime("%Y-%m-%d %H:%M:%S")
+        for _ in range(5):   # 抢占重试:候选被别的进程抢走时换一个
+            cand = self.pool.select_one(
+                f"SELECT id FROM {T_ACCOUNT} WHERE status='healthy' "
+                "AND (owner_pid IS NULL OR lease_until IS NULL OR lease_until < NOW()) "
+                "ORDER BY RAND() LIMIT 1")
+            if not cand:
+                self.log.error(f"[账号池] 无可用 healthy 账号(随机取号,task={self.task_tag})")
+                self.account = None
+                return None
+            cid = cand[0]
+            # 带「仍空闲」条件认领:若被别的进程抢先占了,本 UPDATE 命中 0 行,回读会拿不到本 pid → 重试
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET owner_tag=%s, owner_pid=%s, lease_until=%s, last_used_at=NOW() "
+                "WHERE id=%s AND status='healthy' "
+                "AND (owner_pid IS NULL OR lease_until IS NULL OR lease_until < NOW())",
+                (self.task_tag, self.pid, lease_until, cid))
+            row = self.pool.select_one(
+                f"SELECT id, phone, password, country_code, uid, access_token, refresh_token, "
+                f"token_exp, proxy_url, status FROM {T_ACCOUNT} WHERE id=%s AND owner_pid=%s",
+                (cid, self.pid))
+            if row:
+                self.account = self._row_to_dict(row)
+                self.log.info(f"[账号池] 随机取号 id={self.account['id']} phone={self.account['phone']} "
+                              f"proxy={self.account.get('proxy_url')} lease={lease_sec}s task={self.task_tag}")
+                return self.account
+        self.log.warning(f"[账号池] 随机取号多次抢占失败(并发激烈,task={self.task_tag})")
+        self.account = None
+        return None
+
+    def release(self):
+        """归还当前账号(清空租约 owner_pid/lease_until),进程正常退出时调用。"""
+        if not self.account:
+            return
+        self.pool.update_one(
+            f"UPDATE {T_ACCOUNT} SET owner_pid=NULL, lease_until=NULL WHERE id=%s",
+            (self.account["id"],))
+        self.log.info(f"[账号池] 归还账号 id={self.account['id']}")
+        self.account = None
+
+    def switch(self) -> dict | None:
+        """当前号不可用时换号:释放当前号的租约(不改其 status,status 由 report_* 决定)后重新 acquire。
+
+        Returns:
+            dict | None: 新账号行 dict;池中无其他可用号时返回 None。
+        """
+        old = self.account
+        if old:
+            # 只解租约,不动 status——是否 cooling/dead 由 report_failure 已经写好;healthy 号只是让给别人
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET owner_pid=NULL, lease_until=NULL WHERE id=%s AND owner_pid=%s",
+                (old["id"], self.pid))
+            self.account = None
+        new = self.acquire()
+        if new:
+            self.log.warning(f"[账号池] 切号:{old['phone'] if old else '-'} → {new['phone']}")
+        else:
+            self.log.error("[账号池] 切号失败:池中已无可用 healthy 号,请尽快离线补货")
+        return new
+
+    # ---------- token 续期 ----------
+
+    @staticmethod
+    def _proxies(proxy_url) -> dict | None:
+        """把该号 proxy_url 组装成 requests proxies(让续期/短信登录也走该号专属 IP)。
+
+        Args:
+            proxy_url: 该号绑定的代理 URL;None/空表示直连。
+
+        Returns:
+            dict | None: {"http":.., "https":..};proxy_url 为空返回 None。
+        """
+        return {"http": proxy_url, "https": proxy_url} if proxy_url else None
+
+    def ensure_access(self, log=None) -> str | None:
+        """确保当前账号 access 有效:未临期直接返回;临期用该号 refresh 续期并回写 DB。
+
+        线上主路径:只 refresh、不自动登录。续期失败即判该号 dead 并切号(见 report_failure)。
+
+        Args:
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            str | None: 有效 access token;无持号/续期失败返回 None(调用方据此 switch 换号)。
+        """
+        log = log or self.log
+        acc = self.account
+        if not acc:
+            log.error("[账号池] ensure_access 时未持号")
+            return None
+        # 未临期:直接用缓存 access
+        if acc.get("access_token") and acc.get("token_exp") and time.time() < acc["token_exp"] - ACCESS_SKEW_SEC:
+            return acc["access_token"]
+        # 临期/缺失:用该号 refresh 续期(need_auth=False,不依赖全局 token)。走该号专属 proxy_url,
+        # 让续期这类登录态操作也从该号绑定的 IP 发出(不暴露本机 IP,防 20 号在登录态层被同源关联)。
+        if not acc.get("refresh_token"):
+            log.warning(f"[账号池] id={acc['id']} 无 refresh_token,无法续期")
+            self.report_failure("no_refresh_token", dead=True)
+            return None
+        try:
+            import deca_sold_core as core   # 惰性 import:避免 import 本模块即触发 core 副作用
+            resp = core.do_request(log, REFRESH_PATH, {"refreshToken": acc["refresh_token"]},
+                                   need_auth=False, use_proxy=False,
+                                   proxy_override=self._proxies(acc.get("proxy_url")))
+            code = (resp or {}).get("code")
+            msg = (resp or {}).get("msg")
+            data = (resp or {}).get("data") or {}
+            access = data.get("accessToken")
+            if not access:
+                if code in DEAD_CODES:
+                    # 实测 10010=refresh 失效(被轮换/过期/长期未登录)→ 确定判 dead 摘池(线上不自动登录)
+                    log.warning(f"[账号池] id={acc['id']} 续期判死 code={code} msg={msg}")
+                    self.report_failure(f"refresh_dead:code={code}", dead=True)
+                else:
+                    # 其他未知失败(限流/网络/临时):软失败累计,达 MAX_FAIL 才 cooling,不急着判死
+                    log.warning(f"[账号池] id={acc['id']} 续期失败(非判死码) code={code} msg={msg}")
+                    self.report_failure(f"refresh_fail:code={code}", dead=False)
+                return None
+            expires_in = int(data.get("expiresIn") or 900)
+            new_refresh = data.get("refreshToken") or acc["refresh_token"]   # 一次性轮换:有新的就换
+            new_exp = int(time.time() + expires_in)
+            # 回写 DB 该号最新 token
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET access_token=%s, refresh_token=%s, token_exp=%s, "
+                "last_success_at=NOW(), fail_count=0 WHERE id=%s",
+                (access, new_refresh, new_exp, acc["id"]))
+            acc["access_token"], acc["refresh_token"], acc["token_exp"] = access, new_refresh, new_exp
+            log.info(f"[账号池] id={acc['id']} 续期成功,有效 {expires_in}s")
+            return access
+        except Exception as e:
+            # 网络/超时等异常(非明确判死码)→ 软失败累计,不永久判死(网络问题 ≠ 账号死)
+            log.warning(f"[账号池] id={acc['id']} 续期异常(按软失败处理): {e}")
+            self.report_failure(f"refresh_exc:{e}", dead=False)
+            return None
+
+    # ---------- 状态机 ----------
+
+    def report_success(self):
+        """标记当前账号一次成功(清零 fail_count、刷新 last_success_at)。"""
+        if not self.account:
+            return
+        self.pool.update_one(
+            f"UPDATE {T_ACCOUNT} SET fail_count=0, last_success_at=NOW(), last_error=NULL WHERE id=%s",
+            (self.account["id"],))
+
+    def report_failure(self, error: str = "", dead: bool = False):
+        """记录当前账号一次失败:dead=True 直接判死摘池;否则累计失败,达 MAX_FAIL 转 cooling。
+
+        Args:
+            error (str, optional): 错误信息,写入 last_error 供排障。Defaults to ""。
+            dead (bool, optional): True=登录态死了(续期失败等)直接置 dead 并企微通知人工。
+                False=软失败(限流/偶发),累计到 MAX_FAIL 才转 cooling。Defaults to False。
+        """
+        if not self.account:
+            return
+        aid = self.account["id"]
+        err = (error or "")[:255]
+        if dead:
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET status='dead', owner_pid=NULL, lease_until=NULL, "
+                "last_error=%s WHERE id=%s", (err, aid))
+            self.log.error(f"[账号池] id={aid} 判 dead 摘池({err})→ 需离线补货")
+            # TODO(step3): 企微通知人工「账号 {phone} dead,请离线补货」,复用 auto_send_wx_msg。
+            self.account = None
+            return
+        # 软失败累计
+        self.pool.update_one(
+            f"UPDATE {T_ACCOUNT} SET fail_count=fail_count+1, last_error=%s WHERE id=%s", (err, aid))
+        row = self.pool.select_one(f"SELECT fail_count FROM {T_ACCOUNT} WHERE id=%s", (aid,))
+        fc = int(row[0]) if row and row[0] is not None else 0
+        if fc >= MAX_FAIL:
+            cd = (datetime.now() + timedelta(seconds=COOLDOWN_SEC)).strftime("%Y-%m-%d %H:%M:%S")
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET status='cooling', cooldown_until=%s, owner_pid=NULL, "
+                "lease_until=NULL WHERE id=%s", (cd, aid))
+            self.log.warning(f"[账号池] id={aid} 连续失败 {fc} 次转 cooling,冷却到 {cd}")
+            self.account = None
+
+    def revive_cooling(self) -> int:
+        """巡检:把冷却到期的 cooling 账号恢复为 healthy 重新入池(dead 不自动恢复,需人工)。
+
+        由一个后台巡检循环定期调用(如每几分钟一次)。
+
+        Returns:
+            int: 本次恢复的账号数。
+        """
+        self.pool.update_one(
+            f"UPDATE {T_ACCOUNT} SET status='healthy', fail_count=0, cooldown_until=NULL "
+            "WHERE status='cooling' AND cooldown_until IS NOT NULL AND cooldown_until < NOW()",
+            ())
+        # 恢复条数取决于底层驱动是否回报 affected rows;此处仅执行,数量另查(保持接口简单)
+        return 0
+
+    def keepalive_idle(self, idle_hours: int = 6, log=None) -> tuple[int, int]:
+        """token 保活巡检:对闲置超过 idle_hours 的 healthy 号主动刷新一次,避免 refresh_token 闲到过期被判死。
+
+        背景(2026/09/15):access token 仅 15 分钟、refresh_token 一次性轮换;号若长时间没被业务请求抽到,
+        refresh_token 会自然过期,下次一用即 10010 判死(实测闲置 30~77h 死)。故后台每小时扫一遍:
+        把「healthy 且 last_success_at 超过 idle_hours 没成功 且 当前未被占用」的号逐个强制刷新——
+        刷成功则 token 轮换保温热;刷回 10010 则本就已死、判 dead 交自动补货补。
+
+        并发安全:每个号先带「仍空闲」条件抢租约(claim)、抢到才刷、刷完释放,避免与业务进程并发刷同一号
+        踩废其一次性轮换的 refreshToken。
+
+        Args:
+            idle_hours (int, optional): 闲置阈值(小时),last_success_at 早于此才刷。Defaults to 6。
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            tuple[int, int]: (温热成功数, 判死数)。
+        """
+        log = log or self.log
+        rows = self.pool.select_all(
+            f"SELECT id FROM {T_ACCOUNT} WHERE status='healthy' "
+            "AND (last_success_at IS NULL OR last_success_at < NOW() - INTERVAL %s HOUR) "
+            "AND (owner_pid IS NULL OR lease_until IS NULL OR lease_until < NOW())",
+            (idle_hours,)) or []
+        warmed, died = 0, 0
+        for (aid,) in rows:
+            lease_until = (datetime.now() + timedelta(seconds=120)).strftime("%Y-%m-%d %H:%M:%S")  # 短租,够刷新用
+            # 带「仍空闲」条件抢占:抢到才刷(防与业务进程并发刷同一号)
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET owner_tag=%s, owner_pid=%s, lease_until=%s "
+                "WHERE id=%s AND status='healthy' "
+                "AND (owner_pid IS NULL OR lease_until IS NULL OR lease_until < NOW())",
+                (self.task_tag, self.pid, lease_until, aid))
+            row = self.pool.select_one(
+                f"SELECT id, phone, password, country_code, uid, access_token, refresh_token, "
+                f"token_exp, proxy_url, status FROM {T_ACCOUNT} WHERE id=%s AND owner_pid=%s AND status='healthy'",
+                (aid, self.pid))
+            if not row:
+                continue                                # 抢占失败(被业务进程抢走)或状态已变,跳过
+            self.account = self._row_to_dict(row)
+            self.account["token_exp"] = 0               # 置 0 强制走刷新分支,确保轮换 refresh_token(而非命中未临期缓存)
+            access = self.ensure_access(log)            # 内部:刷成功回写 token;10010 则 report_failure(dead) 已摘池
+            if access:
+                warmed += 1
+                # 仍 healthy:解租约归还(ensure_access 判死时已清 self.account+解租约,不会走到这)
+                if self.account is not None:
+                    self.pool.update_one(
+                        f"UPDATE {T_ACCOUNT} SET owner_pid=NULL, lease_until=NULL WHERE id=%s AND owner_pid=%s",
+                        (self.account["id"], self.pid))
+            else:
+                died += 1
+            self.account = None
+        if warmed or died:
+            log.info(f"[保活] 闲置>{idle_hours}h 刷新:温热 {warmed} 个,判死 {died} 个")
+        return warmed, died
+
+    # ---------- 账号供给(依赖明天抓的接口,占位)----------
+
+    def _login(self, log=None) -> bool:
+        """密码登录换取全新一套 token(照 deca_sold_core.login 的接口写实)。
+
+        ⚠️ 线上默认不调用——密码登录会撞阿里云滑块,无人值守过不去(9/7 就死在这)。仅供
+        「离线补货 / 手动兜底」场景显式调用。线上续期失败一律走 report_failure(dead=True)。
+
+        Args:
+            log (optional): 日志对象。Defaults to None。
+
+        Returns:
+            bool: 登录成功并回写 token 返回 True;失败返回 False。
+        """
+        log = log or self.log
+        acc = self.account
+        if not acc:
+            return False
+        body = {"countryCode": acc.get("country_code") or "86",
+                "password": acc.get("password"), "phone": acc.get("phone")}
+        try:
+            import deca_sold_core as core
+            resp = core.do_request(log, LOGIN_PATH, body, need_auth=False, use_proxy=False)
+            data = (resp or {}).get("data") or {}
+            access, refresh = data.get("accessToken"), data.get("refreshToken")
+            if not access or not refresh:
+                log.warning(f"[账号池] id={acc['id']} 密码登录未返回 token: {resp.get('msg') if resp else None}")
+                return False
+            new_exp = int(time.time() + int(data.get("expiresIn") or 900))
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET access_token=%s, refresh_token=%s, token_exp=%s, "
+                "status='healthy', fail_count=0, last_success_at=NOW() WHERE id=%s",
+                (access, refresh, new_exp, acc["id"]))
+            acc.update({"access_token": access, "refresh_token": refresh, "token_exp": new_exp})
+            log.info(f"[账号池] id={acc['id']} 密码登录成功(离线补货)")
+            return True
+        except Exception as e:
+            log.warning(f"[账号池] id={acc['id']} 密码登录异常: {e}")
+            return False
+
+    def sms_send(self, phone: str, country_code: str = "86", proxy_url: str = None, log=None) -> bool:
+        """向指定手机号发送登录短信验证码(登录/注册第一步)。
+
+        need_auth=False,不依赖任何登录态。用于「补货 / 新号开户」流程第一步:调用后手机收到验证码,
+        再调 sms_login。走该号将绑的专属 proxy_url,保证发码也从该号 IP 发出(与后续登录同源)。
+
+        Args:
+            phone (str): 目标手机号。
+            country_code (str, optional): 区号。Defaults to "86"。
+            proxy_url (str, optional): 该号专属 IP(走它发码,与登录同源)。Defaults to None(直连)。
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            bool: code==0 视为发送成功返回 True;否则 False。
+        """
+        log = log or self.log
+        body = {"countryCode": country_code, "phone": phone}
+        try:
+            import deca_sold_core as core   # 惰性 import:避免 import 本模块即触发 core 副作用
+            resp = core.do_request(log, SMS_SEND_PATH, body, need_auth=False, use_proxy=False,
+                                   proxy_override=self._proxies(proxy_url))
+            if resp and resp.get("code") == 0:
+                log.info(f"[账号池] 验证码已发送 phone={phone}")
+                return True
+            log.warning(f"[账号池] 发验证码失败 phone={phone}: {resp.get('msg') if resp else None}")
+            return False
+        except Exception as e:
+            log.warning(f"[账号池] 发验证码异常 phone={phone}: {e}")
+            return False
+
+    def sms_login(self, phone: str, code: str, country_code: str = "86",
+                  proxy_url: str = None, password: str = None, log=None) -> dict | None:
+        """短信验证码登录(登录即注册)并入库为 healthy 账号(新号 INSERT / 已存在号 UPDATE token)。
+
+        得卡无独立注册接口:新手机号首次短信登录即自动开户,故本方法同时承担「注册新号」与
+        「离线补货已死账号」两职。成功后把 access/refresh/token_exp/uid 落库 deca_account_record,
+        status 置 healthy、清空 fail_count 与租约,让该号重新可被 acquire。
+
+        Args:
+            phone (str): 手机号。
+            code (str): 收到的短信验证码(人工填入)。
+            country_code (str, optional): 区号。Defaults to "86"。
+            proxy_url (str, optional): 该号专属静态出口 IP(仅新号入库时写入;补货已存在号不覆盖)。
+                Defaults to None。
+            password (str, optional): 若该号另设了密码,一并存库供密码登录兜底。Defaults to None。
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            dict | None: 入库后的账号字段 dict(id/phone/uid/access_token/refresh_token/token_exp/
+                proxy_url/status);登录失败返回 None。
+        """
+        log = log or self.log
+        body = {"countryCode": country_code, "phone": phone, "code": code}
+        try:
+            import deca_sold_core as core
+            resp = core.do_request(log, SMS_LOGIN_PATH, body, need_auth=False, use_proxy=False,
+                                   proxy_override=self._proxies(proxy_url))
+            data = (resp or {}).get("data") or {}
+            access, refresh = data.get("accessToken"), data.get("refreshToken")
+            if not access or not refresh:
+                log.warning(f"[账号池] 短信登录未返回 token phone={phone}: {resp.get('msg') if resp else None}")
+                return None
+            uid = data.get("userId")
+            new_exp = int(time.time() + int(data.get("expiresIn") or 900))
+            # phone 唯一键:新号 INSERT、已存在号(补货)UPDATE 回写最新 token 并复活为 healthy。
+            # proxy_url 用 COALESCE(旧值, 新值):补货时不覆盖已绑定的专属 IP;新号才写入。
+            self.pool.update_one(
+                f"INSERT INTO {T_ACCOUNT} "
+                "(phone, password, country_code, uid, access_token, refresh_token, token_exp, "
+                " proxy_url, status, fail_count, last_success_at) "
+                "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'healthy',0,NOW()) "
+                "ON DUPLICATE KEY UPDATE "
+                "  uid=VALUES(uid), access_token=VALUES(access_token), "
+                "  refresh_token=VALUES(refresh_token), token_exp=VALUES(token_exp), "
+                "  proxy_url=COALESCE(proxy_url, VALUES(proxy_url)), "
+                "  password=COALESCE(VALUES(password), password), "
+                "  status='healthy', fail_count=0, cooldown_until=NULL, "
+                "  owner_pid=NULL, lease_until=NULL, last_success_at=NOW(), last_error=NULL",
+                (phone, password, country_code, uid, access, refresh, new_exp, proxy_url))
+            row = self.pool.select_one(
+                f"SELECT id, phone, password, country_code, uid, access_token, refresh_token, "
+                f"token_exp, proxy_url, status FROM {T_ACCOUNT} WHERE phone=%s", (phone,))
+            acc = self._row_to_dict(row) if row else None
+            log.info(f"[账号池] 短信登录入库成功 phone={phone} uid={uid} "
+                     f"id={acc['id'] if acc else '?'}(access 有效 {data.get('expiresIn')}s)")
+            return acc
+        except Exception as e:
+            log.warning(f"[账号池] 短信登录异常 phone={phone}: {e}")
+            return None
+
+    def register(self, phone: str, code: str, country_code: str = "86",
+                 proxy_url: str = None, password: str = None, log=None) -> dict | None:
+        """注册/开户一个账号并入池——得卡「登录即注册」,本方法等价于 sms_login。
+
+        得卡无独立注册接口:新手机号短信验证码登录即自动开户。故注册流程为两步(人工收验证码):
+            1) 先调 self.sms_send(phone)  → 手机收到验证码;
+            2) 人工把验证码填进来,调 self.register(phone, code, proxy_url=...) 完成开户并入库。
+        风控提示:得卡风控强,批量开户需错开时间/设备/IP,避免一批新号被批量识别连坐(见方案文档)。
+
+        Args:
+            phone (str): 手机号。
+            code (str): 短信验证码(人工填入)。
+            country_code (str, optional): 区号。Defaults to "86"。
+            proxy_url (str, optional): 该号专属静态出口 IP,入库绑定。Defaults to None。
+            password (str, optional): 如另设密码则一并存库(供兜底)。Defaults to None。
+            log (optional): 日志对象。Defaults to None。
+
+        Returns:
+            dict | None: 入库后的账号字段 dict;失败返回 None。
+        """
+        return self.sms_login(phone, code, country_code=country_code,
+                              proxy_url=proxy_url, password=password, log=log)
+
+    # ---------- 自动补货(接码平台)----------
+
+    def count_healthy(self) -> int:
+        """统计池中当前 healthy(可用)账号数,供自动补货判断是否需要补。
+
+        Returns:
+            int: healthy 账号数。
+        """
+        row = self.pool.select_one(f"SELECT COUNT(*) FROM {T_ACCOUNT} WHERE status='healthy'")
+        return int(row[0]) if row and row[0] is not None else 0
+
+    def _free_proxy_urls(self) -> list:
+        """返回 PROXY_URLS 中当前 DB 里还没被任何账号占用的空闲专属 IP(供补货分配给新号)。
+
+        Returns:
+            list: 空闲 proxy_url 列表(PROXY_URLS 减去 deca_account_record 里已占用的 proxy_url)。
+        """
+        rows = self.pool.select_all(f"SELECT proxy_url FROM {T_ACCOUNT} WHERE proxy_url IS NOT NULL") or []
+        used = {r[0] for r in rows}
+        return [u for u in PROXY_URLS if u not in used]
+
+    def _release_dead_proxies(self) -> int:
+        """把 dead 账号占用的专属 IP 释放(proxy_url 置空),供补货回收给新号(不复活、仅回收 IP)。
+
+        背景(2026/09/14):1 号 1 IP、IP 是号池规模硬上限;dead 号死了却一直占着 proxy_url,
+        当 PROXY_URLS 被 healthy+dead 占满时 _free_proxy_urls 返回空 → auto_replenish 撞「空闲 IP 已用尽」
+        永远补不进。故补货前先释放 dead 号的 IP:保留 dead 行做审计(status 不变),仅清空其 proxy_url,
+        让这些 IP 回到空闲池、由新号顶替。只动 status='dead' 的行,绝不碰 healthy/cooling 占用的 IP。
+
+        Returns:
+            int: 释放(清空 proxy_url)的 dead 账号数。
+        """
+        row = self.pool.select_one(
+            f"SELECT COUNT(*) FROM {T_ACCOUNT} WHERE status='dead' AND proxy_url IS NOT NULL")
+        n = int(row[0]) if row and row[0] is not None else 0
+        if n:
+            self.pool.update_one(
+                f"UPDATE {T_ACCOUNT} SET proxy_url=NULL WHERE status='dead' AND proxy_url IS NOT NULL", ())
+        return n
+
+    def register_one_via_isms(self, isms, project: dict, proxy_url: str = None,
+                              ascription: int = ISMS_ASCRIPTION, log=None) -> dict | None:
+        """经接码平台自动注册一个得卡账号并入库(取号→得卡发码→收码→短信登录→释放号)。
+
+        全自动单号注册:接码平台租一个能收得卡短信的号 → 得卡 sms_send 发验证码 →
+        接码平台 poll_sms 收码 → 得卡 sms_login(登录即注册)入库 → 释放接码平台号。
+
+        Args:
+            isms (ISmsClient): 已初始化的接码平台客户端。
+            project (dict): search_projects 选中的项目(含 project_id/name/token)。
+            proxy_url (str, optional): 分配给新号的专属静态 IP(入库绑定)。Defaults to None。
+            ascription (int, optional): 卡类型 2=实体卡/1=虚拟卡。Defaults to ISMS_ASCRIPTION。
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            dict | None: 入库后的账号 dict;任一步失败返回 None。
+        """
+        log = log or self.log
+        order_id = None
+        try:
+            # 1) 接码平台取号
+            nr = isms.get_number(project_id=project.get("project_id"), project_name=project.get("name"),
+                                 project_token=project.get("token"), quantity=1, ascription=ascription)
+            data = (nr.get("data") or []) if nr.get("success") else []
+            if not data:
+                log.warning(f"[补货] 接码取号失败: {nr.get('message') or nr.get('msg')}")
+                return None
+            phone = str(data[0].get("number"))
+            order_id = data[0].get("orderId") or data[0].get("order_id")
+            log.info(f"[补货] 接码取到号 {phone} order={order_id}")
+            # 2) 得卡发验证码(走该号将绑的专属 IP,与登录同源)
+            if not self.sms_send(phone, proxy_url=proxy_url, log=log):
+                return None
+            # 3) 接码平台轮询收验证码
+            code = isms.poll_sms(order_id, log=log)
+            if not code:
+                return None
+            # 4) 得卡短信登录(=注册)入库
+            acc = self.sms_login(phone, code, proxy_url=proxy_url, log=log)
+            return acc
+        except Exception as e:
+            log.warning(f"[补货] 单号注册异常: {e}")
+            return None
+        finally:
+            # 5) 释放接码平台号(无论成败,及时释放降占用)
+            if order_id is not None:
+                try:
+                    isms.release_number(order_id=order_id)
+                except Exception:
+                    pass
+
+    def auto_replenish(self, target_min: int, target_max: int, free_proxy_urls: list = None,
+                       keyword: str = ISMS_PROJECT_KEYWORD, ascription: int = ISMS_ASCRIPTION,
+                       api_key: str = None, log=None) -> int:
+        """巡检:healthy 号 < target_min 时,经接码平台自动注册补到 target_max。
+
+        供后台巡检循环定期调用。补货受两处上限约束:free_proxy_urls(空闲 IP,1 号 1 IP)与接码平台余额。
+        每补一个号随机停顿(REPLENISH_STAGGER_SEC)错峰,降一批新号被批量连坐识别的风险。
+
+        ⚠️ 依赖外部资源(有 API Key + IP + 得卡项目存在后才能真跑):
+            - api_key:接码平台 Key(传参或环境变量 ISMS_API_KEY);
+            - keyword:得卡项目关键词需 search_projects 实测确认;
+            - free_proxy_urls:可分配的空闲专属静态 IP 列表(1 号 1 IP)。
+
+        Args:
+            target_min (int): healthy 低于该值才触发补货。
+            target_max (int): 补货目标(补到 healthy 达此值)。
+            free_proxy_urls (list, optional): 可分配给新号的空闲专属 IP 列表。Defaults to None。
+            keyword (str, optional): 接码平台得卡项目搜索关键词。Defaults to ISMS_PROJECT_KEYWORD。
+            ascription (int, optional): 卡类型 2=实体卡/1=虚拟卡。Defaults to ISMS_ASCRIPTION。
+            api_key (str, optional): 接码平台 Key;None 时用配置常量 ISMS_API_KEY。Defaults to None。
+            log (optional): 日志对象。Defaults to None(用 self.log)。
+
+        Returns:
+            int: 本轮成功补充入库的账号数。
+        """
+        import random
+        log = log or self.log
+        cur = self.count_healthy()
+        if cur >= target_min:
+            return 0
+        log.info(f"[补货] healthy={cur} < {target_min},开始自动补货至 {target_max}")
+        try:
+            from isms_client import ISmsClient
+            isms = ISmsClient(api_key=api_key or ISMS_API_KEY)   # 默认用配置里的 Key,传参可覆盖
+        except Exception as e:
+            log.error(f"[补货] 接码平台客户端初始化失败(检查 ISMS_API_KEY): {e}")
+            return 0
+        # 搜得卡项目
+        pr = isms.search_projects(keyword)
+        projects = (pr.get("data") or []) if pr.get("success") else []
+        if not projects:
+            log.error(f"[补货] 接码平台未搜到项目 '{keyword}'——请确认关键词或该平台是否有得卡项目")
+            return 0
+        project = projects[0]
+        # 补货前先释放 dead 号占用的专属 IP(不复活、仅回收 IP),否则 IP 被 healthy+dead 占满时永远补不进
+        released = self._release_dead_proxies()
+        if released:
+            log.info(f"[补货] 已释放 {released} 个 dead 账号占用的专属 IP 供新号回收")
+        if free_proxy_urls is None:
+            free_proxy_urls = self._free_proxy_urls()   # 默认用配置 PROXY_URLS 里 DB 未占用的空闲 IP
+        free = list(free_proxy_urls)
+        added = 0
+        fail_streak = 0                                  # 连续失败计数:防接码没号/异常时死循环空转烧钱
+        while self.count_healthy() < target_max:
+            proxy = free.pop(0) if free else None       # 1 号 1 IP:无空闲 IP 则停(IP 是硬上限)
+            if proxy is None:
+                log.warning("[补货] 空闲 IP 已用尽,停止补货(IP 是号池规模硬上限)")
+                break
+            acc = self.register_one_via_isms(isms, project, proxy_url=proxy, ascription=ascription, log=log)
+            if acc:
+                added += 1
+                fail_streak = 0
+                log.info(f"[补货] 已补 {added} 个(最新 phone={acc['phone']} proxy={proxy})")
+            else:
+                if proxy is not None:
+                    free.insert(0, proxy)               # 本号失败,IP 归还继续给下一个用
+                fail_streak += 1
+                if fail_streak >= 5:                    # 连续 5 次失败:接码可能没号/余额不足/异常,停止避免空转
+                    log.error("[补货] 连续 5 次注册失败,停止补货(检查接码余额/项目号源/网络)")
+                    break
+                log.warning(f"[补货] 本次注册失败(连续 {fail_streak} 次),稍后重试")
+            time.sleep(random.randint(*REPLENISH_STAGGER_SEC))   # 错峰,避免一批号被连坐
+        log.info(f"[补货] 本轮补充完成 {added} 个,当前 healthy={self.count_healthy()}")
+        return added
+
+    # ---------- 内部 ----------
+
+    def _row_to_dict(self, row) -> dict:
+        """把 acquire 回读的行元组转成账号 dict(列顺序与 acquire 的 SELECT 对齐)。
+
+        Args:
+            row (tuple): SELECT 出的一行。
+
+        Returns:
+            dict: 账号字段 dict。
+        """
+        keys = ["id", "phone", "password", "country_code", "uid", "access_token",
+                "refresh_token", "token_exp", "proxy_url", "status"]
+        return dict(zip(keys, row))

+ 300 - 0
deca_auto_spider/common/auto_send_wx_msg.py

@@ -0,0 +1,300 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""企业微信群机器人通用发送模块(文本 / markdown_v2 / 文件)。
+
+对外三个入口:
+    - send_wechat_group_msg :发文本或 markdown_v2 文案,items 元素可为字符串或 (名称, 链接) 元组。
+    - send_wechat_group_file:发文件(先 upload_media 拿 media_id,再发 file 消息),供报表脚本发 Excel。
+
+作为工具库被其它脚本 import,不在模块级配置 loguru sink,日志默认落到调用方的 logger。
+
+变更记录:
+    2026/08/11 从 PC 版微信(wxauto)迁到企业微信群机器人;新增 send_wechat_group_file 发文件能力。
+    2026/08/27 上传/发送增加 tenacity 重试,专治 TLS 瞬断(UNEXPECTED_EOF_WHILE_READING)导致的偶发发送失败。
+"""
+import os
+import re
+import json
+
+import requests
+from loguru import logger
+from tenacity import (retry, stop_after_attempt, wait_exponential,
+                      retry_if_exception_type)
+
+# 企业微信群机器人 Webhook 地址(key 为群机器人凭证,按需替换;当前为测试群)
+# WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b8d398e2-f27e-42ce-af78-336867460122" # 测试
+WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fc5619c2-5699-485f-bed1-a5fdc79ca513"
+# 素材上传接口:发文件/图片前先把素材传上去换 media_id(type=file/voice;文件 5B~20MB,media_id 有效期 3 天)
+UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={key}&type={media_type}"
+
+FILE_MIN_BYTES = 5                    # 企微限制:文件不得小于 5 字节
+FILE_MAX_BYTES = 20 * 1024 * 1024     # 企微限制:文件不得大于 20MB
+
+# ---- 重试配置:仅对网络/TLS 类瞬断重试,业务错误码(errcode!=0)不重试 ----
+RETRY_TIMES = 4          # 最多尝试次数(含首次)
+RETRY_WAIT_MIN = 2       # 退避最小间隔(秒)
+RETRY_WAIT_MAX = 8       # 退避最大间隔(秒),间隔按 2/4/8 递增
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    约定:被 @retry 装饰的函数首个位置参数为 log(日志对象),本回调据此取用。
+
+    Args:
+        retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
+    """
+    log = retry_state.args[0] if retry_state.args else logger
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+@retry(stop=stop_after_attempt(RETRY_TIMES),
+       wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
+       retry=retry_if_exception_type(requests.exceptions.RequestException),
+       after=after_log, reraise=True)
+def _do_upload_media(log, url: str, file_path: str) -> requests.Response:
+    """执行素材上传的内部函数,带重试;每次尝试都重新打开文件。
+
+    重点:multipart 上传的文件流被读过一次就到 EOF,重试必须重新 open 文件,
+    否则第二次会传空体。故把 open 放进被重试的函数体内,保证每次从头读。
+
+    Args:
+        log: 日志对象(首参约定,供 after_log 取用)。
+        url (str): upload_media 接口地址(已含 key 与 type)。
+        file_path (str): 待上传文件路径。
+
+    Returns:
+        requests.Response: HTTP 状态正常的响应对象。
+
+    Raises:
+        requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
+    """
+    with open(file_path, "rb") as f:
+        # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
+        files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
+        resp = requests.post(url, files=files, timeout=(5, 60))
+    resp.raise_for_status()  # HTTP 非 2xx 抛 HTTPError(属 RequestException),触发重试
+    return resp
+
+
+@retry(stop=stop_after_attempt(RETRY_TIMES),
+       wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
+       retry=retry_if_exception_type(requests.exceptions.RequestException),
+       after=after_log, reraise=True)
+def _post_json_with_retry(log, url: str, **kwargs) -> requests.Response:
+    """带重试的 JSON POST(发文本 / 文件消息共用)。
+
+    JSON 请求体是 bytes,可安全重试;仅网络/TLS 类异常触发重试,业务错误码由调用方判断。
+
+    Args:
+        log: 日志对象(首参约定,供 after_log 取用)。
+        url (str): 请求地址。
+        **kwargs: 透传给 requests.post 的参数(headers / data / timeout 等)。
+
+    Returns:
+        requests.Response: HTTP 状态正常的响应对象。
+
+    Raises:
+        requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
+    """
+    resp = requests.post(url, **kwargs)
+    resp.raise_for_status()
+    return resp
+
+
+def _extract_key(webhook_url: str) -> str | None:
+    """从群机器人 Webhook 地址里抽出 key(上传素材接口要单独拼 key)。
+
+    Args:
+        webhook_url (str): 形如 ...webhook/send?key=xxxx 的 Webhook 地址。
+
+    Returns:
+        str | None: 抽到的 key;地址不含 key 时返回 None。
+    """
+    m = re.search(r"key=([0-9a-fA-F\-]+)", webhook_url)
+    return m.group(1) if m else None
+
+
+def build_markdown_content(items: list, title: str) -> str:
+    """把列表拼成 markdown 文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+        title (str): 文案标题(渲染为四级标题)。
+
+    Returns:
+        str: 拼好的 markdown 文本。
+    """
+    md_content = f"#### {title}\n"
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            md_content += f"{i}. [{name}]({link})\n"
+        elif isinstance(item, str):
+            md_content += f"{i}. {item}\n"
+        else:
+            md_content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            md_content += "\n---\n\n"
+    return md_content
+
+
+def build_text_content(items: list) -> str:
+    """把列表拼成纯文本文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+
+    Returns:
+        str: 拼好的纯文本。
+    """
+    content = ""
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            content += f"{i}. {name}: {link}\n"
+        elif isinstance(item, str):
+            content += f"{i}. {item}\n"
+        else:
+            content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            content += "----------------------------------\n"
+    return content
+
+
+def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
+                          msg_type="markdown", title="🚀 提醒通知") -> dict | None:
+    """发送文本 / markdown_v2 消息到企业微信群机器人。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        items (list, optional): 消息条目,元素为字符串或 (名称, 链接) 元组。Defaults to None。
+        mentioned_list (list, optional): text 类型下 @ 的成员手机号/@all 列表。Defaults to None。
+        msg_type (str, optional): 消息类型 text / markdown。Defaults to "markdown"。
+        title (str, optional): markdown 文案标题。Defaults to "🚀 提醒通知"。
+
+    Returns:
+        dict | None: 企微返回的 JSON;发送失败返回 None。
+    """
+    if items is None:
+        items = []
+    if log is None:
+        log = logger
+
+    headers = {"Content-Type": "application/json"}
+    if msg_type == "text":
+        data = {
+            "msgtype": "text",
+            "text": {
+                "content": build_text_content(items),
+                "mentioned_list": mentioned_list if mentioned_list else [],
+            },
+        }
+    else:  # 默认 markdown_v2
+        data = {
+            "msgtype": "markdown_v2",
+            "markdown_v2": {"content": build_markdown_content(items, title)},
+        }
+
+    try:
+        log.info(f"正在发送企微消息: {title}")
+        resp = _post_json_with_retry(log, WEBHOOK_URL, headers=headers,
+                                     data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
+                                     timeout=(5, 30))
+        result = resp.json()
+        if result.get("errcode") not in (0, None):  # 企微业务错误码非 0 也算失败
+            log.error(f"企微消息发送失败: {result}")
+            return None
+        log.success("企微消息发送成功")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微消息发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
+    """把本地文件上传到企微群机器人素材接口,换取 media_id。
+
+    Args:
+        log (loguru.Logger): 日志对象。
+        file_path (str): 本地文件绝对/相对路径。
+        media_type (str, optional): 素材类型 file / voice。Defaults to "file"。
+
+    Returns:
+        str | None: 上传成功返回 media_id(有效期 3 天);文件不存在/超限/上传失败返回 None。
+    """
+    key = _extract_key(WEBHOOK_URL)
+    if not key:
+        log.error("Webhook 地址里没解析到 key,无法上传素材")
+        return None
+
+    size = os.path.getsize(file_path)
+    if not (FILE_MIN_BYTES <= size <= FILE_MAX_BYTES):  # 企微限制 5B~20MB
+        log.error(f"文件大小 {size} 字节超出企微限制(5B~20MB):{file_path}")
+        return None
+
+    url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
+    try:
+        resp = _do_upload_media(log, url, file_path)  # 带重试上传,内部每次重新打开文件
+        result = resp.json()
+        if result.get("errcode") != 0:  # 业务错误(如 key 失效/文件超限),重试无意义,直接失败
+            log.error(f"企微素材上传失败: {result}")
+            return None
+        return result.get("media_id")
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微素材上传异常(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
+    """发送一个本地文件到企业微信群机器人(自动先上传素材换 media_id 再发 file 消息)。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        file_path (str, optional): 待发送文件路径(如 Excel 报表)。Defaults to None。
+
+    Returns:
+        dict | None: 企微返回的 JSON;文件不存在/上传失败/发送失败返回 None。
+    """
+    if log is None:
+        log = logger
+    if not file_path or not os.path.isfile(file_path):
+        log.error(f"待发送文件不存在: {file_path}")
+        return None
+
+    media_id = _upload_media(log, file_path, "file")
+    if not media_id:
+        return None
+
+    data = {"msgtype": "file", "file": {"media_id": media_id}}
+    try:
+        log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
+        resp = _post_json_with_retry(log, WEBHOOK_URL, headers={"Content-Type": "application/json"},
+                                     data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
+        result = resp.json()
+        if result.get("errcode") != 0:
+            log.error(f"企微文件发送失败: {result}")
+            return None
+        log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微文件发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+if __name__ == "__main__":
+    # 自测:发送一条示例 markdown 消息
+    import sys
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    send_wechat_group_msg(
+        items=["示例商品A(单价¥199,100份)", "示例商品B(单价¥299,50份)"],
+        title="🧪 企微机器人自测",
+    )

+ 37 - 0
deca_auto_spider/common/deca_account_ddl.sql

@@ -0,0 +1,37 @@
+-- 得卡 DECA 账号池 · 账号表
+-- 用途:把原来集中在单账号 token.json 的登录态,改为多账号入池、按任务取号、异常自动切号(方案见 HANDOFF.md 账号池章节)。
+-- 规范:自增 id 物理主键、phone 单独建唯一键、时间字段固定 gmt_create_time/gmt_modified_time(datetime)。
+-- 说明:一号一专属静态出口 IP(proxy_url),账号↔IP 永久绑定;status 三态 healthy/cooling/dead。
+
+CREATE TABLE IF NOT EXISTS `deca_account_record` (
+  `id`                BIGINT       NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `phone`             VARCHAR(20)  NOT NULL COMMENT '登录手机号(业务唯一标识)',
+  `password`          VARCHAR(128) DEFAULT NULL COMMENT '登录密码(密码登录/离线补货用;线上默认不自动登录)',
+  `country_code`      VARCHAR(8)   NOT NULL DEFAULT '86' COMMENT '国家区号',
+  `uid`               VARCHAR(64)  DEFAULT NULL COMMENT '账号 uid(JWT 里的账号标识,抓到后回填)',
+
+  `access_token`      TEXT         DEFAULT NULL COMMENT 'access token(续期后回写)',
+  `refresh_token`     TEXT         DEFAULT NULL COMMENT 'refresh token(一次性轮换,续期成功即更新)',
+  `token_exp`         BIGINT       DEFAULT NULL COMMENT 'access token 过期时间戳(秒,Unix)',
+
+  `proxy_url`         VARCHAR(255) DEFAULT NULL COMMENT '该号绑定的专属静态出口 IP/代理 URL(1 号 1 IP 永久绑定;必须静态独享,勿用隧道)',
+
+  `status`            VARCHAR(16)  NOT NULL DEFAULT 'healthy' COMMENT '状态:healthy=可用 / cooling=软风控冷却中 / dead=登录态死了需人工补货',
+  `fail_count`        INT          NOT NULL DEFAULT 0 COMMENT '连续失败计数(成功即清零)',
+  `cooldown_until`    DATETIME     DEFAULT NULL COMMENT 'cooling 冷却截止时间(到期由巡检恢复 healthy)',
+
+  `owner_tag`         VARCHAR(64)  DEFAULT NULL COMMENT '被哪个任务独占(如 buy_record:881226408 / team / onsale_alert)',
+  `owner_pid`         INT          DEFAULT NULL COMMENT '占用进程 pid(配合 lease_until 判租约是否失效)',
+  `lease_until`       DATETIME     DEFAULT NULL COMMENT '租约到期时间(进程崩溃后到期自动可被再租,不死占)',
+
+  `last_used_at`      DATETIME     DEFAULT NULL COMMENT '最近被取用时间(取号做负载均衡按此升序)',
+  `last_success_at`   DATETIME     DEFAULT NULL COMMENT '最近一次带 token 请求成功时间',
+  `last_error`        VARCHAR(255) DEFAULT NULL COMMENT '最近一次错误信息(排障用)',
+
+  `gmt_create_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_phone` (`phone`),
+  KEY `idx_status_used` (`status`, `last_used_at`)   -- 取号:按 status 过滤 + last_used_at 升序做负载均衡
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='得卡 DECA 账号池:多账号登录态 + 专属 IP + 健康状态';

+ 592 - 0
deca_auto_spider/common/deca_on_sale_daily_spider.py

@@ -0,0 +1,592 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/02
+"""得卡 DECA 每日增量采集爬虫(常驻无人值守)。
+
+两级采集管道:
+    1. 商家发现:翻页遍历「商家展位列表」merchant/exhibition/list,
+       提取 userId/merchantName 等,upsert 写入 deca_onsale_shop_record(存最新状态)。
+    2. 商品采集:免 token 翻页拉「全量在售」home/search(空 query,saleStatus=0),
+       upsert 写入 deca_onsale_product_record(最新状态),并按天写入
+       deca_onsale_product_daily_record(每日售卖进度快照,用于统计推进趋势)。
+       变更(2026/08/07):原为「逐商家遍历 on-sale-list[需 token]」,已改走免 token 的
+       home/search。对拍验证:home/search 与首页精选 recommend 返回的当前在售商品完全一致
+       (逐 code 交集相等),且商品项结构与 on-sale-list 同构,故 parse_product 直接复用。
+
+鉴权与签名(逆向自 APK mf/c0.java,已用 4 个抓包样本验证):
+    - signature = md5( md5(plain) + currentTime[-6:] + "biu_card_nbclass" )
+      plain = 参数按 key 升序、key 直接拼 value、无分隔符(去掉 value 为 None 的项)。
+    - currentTime 为 13 位毫秒时间戳,同一值放进 currentTime 头与参与签名。
+    - 本任务全部业务接口(商家列表 / 全量在售 home/search / 商品详情)均无需 token,仅需 signature。
+      故本模块不含 token/登录逻辑(原供 km_spdier 复用的 token 机制已随 km_spdier 一并移除)。
+"""
+import sys
+import time
+import json
+import hashlib
+from datetime import date
+
+import requests
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+logger.remove()
+logger.add("./logs/{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# ==================== 基础配置 ====================
+BASE = "https://api.decalive.com"  # 业务域名(来源:抓包)
+SALT = "biu_card_nbclass"          # 签名固定 salt(逆向自 mf/c0.java 硬编码常量)
+
+PAGE_SIZE = 20         # 在售商品每页条数(抓包实测 20,home/search 同)
+MAX_SHOP_PAGES = 50    # 商家列表翻页保护上限(接口1 无 total,靠 list 空停止 + 此上限兜底)
+MAX_SEARCH_PAGES = 100 # 全量在售 home/search 翻页保护上限(当前约 143/20≈8 页,留足冗余)
+USE_PROXY = False      # 代理开关:默认直连,遇 IP 风控再置 True
+
+# 公共请求头(来源:抓包,四接口一致;currentTime/signature/Authorization 每次动态补)
+BASE_HEADERS = {
+    "appVersion": "1.0.1",
+    "buildType": "release",
+    "timeZone": "GMT+08:00",
+    "equipmentType": "Android",
+    "Content-Type": "application/json; charset=utf-8",
+    "Accept-Encoding": "gzip",
+    "User-Agent": "okhttp/5.3.2",
+}
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    Args:
+        retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
+    """
+    # 约定挂了重试的业务函数首个位置参数为 log;取不到时回退全局 logger
+    log = retry_state.args[0] if retry_state.args else logger
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
+def get_proxys(log):
+    """获取隧道代理配置(默认不启用,见 USE_PROXY)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        dict: requests 可用的 proxies 字典。
+
+    Raises:
+        Exception: 组装代理配置异常时向上抛出以触发重试。
+    """
+    tunnel = "x371.kdltps.com:15818"
+    user, pwd = "t13753103189895", "o0yefv6z"
+    try:
+        proxy = f"http://{user}:{pwd}@{tunnel}/"
+        return {"http": proxy, "https": proxy}
+    except Exception as e:
+        log.error(f"Error getting proxy: {e}")
+        raise e
+
+
+def _md5(s: str) -> str:
+    """对字符串做 UTF-8 MD5,返回小写十六进制。
+
+    Args:
+        s (str): 待哈希明文。
+
+    Returns:
+        str: 32 位小写十六进制 MD5。
+    """
+    return hashlib.md5(s.encode("utf-8")).hexdigest()
+
+
+def make_signature(params: dict, current_time: str) -> str:
+    """按逆向还原规则计算 signature 请求头。
+
+    规则(mf/c0.java):plain = 参数按 key 升序、key 直接拼 value、无分隔符(剔除 None);
+    signature = md5( md5(plain) + current_time[-6:] + SALT )。
+
+    Args:
+        params (dict): 参与签名的参数(POST 为 body 字段)。
+        current_time (str): 13 位毫秒时间戳字符串,同一值放进 currentTime 头。
+
+    Returns:
+        str: 32 位小写十六进制签名。
+    """
+    clean = {k: v for k, v in params.items() if v is not None}
+    plain = "".join(f"{k}{clean[k]}" for k in sorted(clean))
+    return _md5(_md5(plain) + current_time[-6:] + SALT)
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def do_request(log, path: str, body: dict) -> dict | None:
+    """通用带签名 POST 请求(带重试)。
+
+    自动补 currentTime + signature 头。body 以紧凑 JSON(无空格)发送,与抓包一致。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径(以 / 开头)。
+        body (dict): JSON body,同时用于签名。
+
+    Returns:
+        dict | None: 响应 JSON。
+
+    Raises:
+        RuntimeError: HTTP 状态码非 200 时抛出以触发重试。
+    """
+    ct = str(int(time.time() * 1000))
+    headers = BASE_HEADERS.copy()
+    headers["currentTime"] = ct
+    headers["signature"] = make_signature(body, ct)
+
+    data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+    proxies = get_proxys(log) if USE_PROXY else None
+    resp = requests.post(BASE + path, headers=headers, data=data, timeout=(5, 30), proxies=proxies)
+    if resp.status_code != 200:
+        log.error(f"请求失败 {resp.status_code}: {path}")
+        raise RuntimeError(f"HTTP {resp.status_code}")
+    return resp.json()
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+def do_get(log, path: str, params: dict = None) -> dict | None:
+    """通用带签名 GET 请求(带重试)。
+
+    自动补 currentTime + signature 头(签名参数取 query)。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径(以 / 开头)。
+        params (dict, optional): URL query 参数(同时用于签名)。Defaults to None。
+
+    Returns:
+        dict | None: 响应 JSON。
+
+    Raises:
+        RuntimeError: HTTP 状态码非 200 时抛出以触发重试。
+    """
+    ct = str(int(time.time() * 1000))
+    headers = BASE_HEADERS.copy()
+    headers["currentTime"] = ct
+    headers["signature"] = make_signature(params or {}, ct)
+
+    proxies = get_proxys(log) if USE_PROXY else None
+    resp = requests.get(BASE + path, headers=headers, params=params, timeout=(5, 30), proxies=proxies)
+    if resp.status_code != 200:
+        log.error(f"请求失败 {resp.status_code}: {path}")
+        raise RuntimeError(f"HTTP {resp.status_code}")
+    return resp.json()
+
+
+# ==================== 一、商家发现 ====================
+def parse_shop(item: dict) -> dict | None:
+    """把商家展位列表项解析成 deca_onsale_shop_record 一行。
+
+    Args:
+        item (dict): merchant/exhibition/list 的 data.list 项。
+
+    Returns:
+        dict | None: 商家数据字典;无 userId 时返回 None。
+    """
+    uid = item.get("userId")
+    if not uid:
+        return None
+    return {
+        "merchant_user_id": str(uid),
+        "merchant_name": item.get("merchantName"),
+        "merchant_avatar_url": item.get("merchantAvatarUrl"),
+        "fans_count": item.get("fansCount"),
+        "active_groupbuy_count": item.get("activeGroupbuyCount"),
+    }
+
+
+def save_shops(log, rows: list, sql_pool) -> int:
+    """upsert 写入商家(存在则更新名称/头像/粉丝/在售数等动态字段)。
+
+    Args:
+        log: 日志对象。
+        rows (list[dict]): parse_shop 产出的商家字典列表。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本次写入/更新的商家数。
+    """
+    if not rows:
+        return 0
+    sql = ("INSERT INTO deca_onsale_shop_record "
+           "(merchant_user_id, merchant_name, merchant_avatar_url, fans_count, active_groupbuy_count) "
+           "VALUES (%s,%s,%s,%s,%s) "
+           "ON DUPLICATE KEY UPDATE "
+           "merchant_name=VALUES(merchant_name), merchant_avatar_url=VALUES(merchant_avatar_url), "
+           "fans_count=VALUES(fans_count), active_groupbuy_count=VALUES(active_groupbuy_count)")
+    args_list = [(r["merchant_user_id"], r["merchant_name"], r["merchant_avatar_url"],
+                  r["fans_count"], r["active_groupbuy_count"]) for r in rows]
+    sql_pool.insert_many(query=sql, args_list=args_list)
+    return len(args_list)
+
+
+def get_shop_list(log, sql_pool) -> int:
+    """翻页遍历商家展位列表,upsert 全部商家。
+
+    接口1 无 total 字段,靠 list 为空停止翻页,MAX_SHOP_PAGES 兜底。
+
+    Args:
+        log: 日志对象。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 去重后发现的商家总数。
+    """
+    seen = set()
+    page = 1
+    while page <= MAX_SHOP_PAGES:
+        try:
+            resp = do_request(log, "/api/v1/app/merchant/exhibition/list", {"page": page})
+        except Exception as e:
+            log.error(f"商家列表第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            log.info(f"商家列表返回异常: {resp.get('msg') if resp else None}")
+            break
+
+        data = resp.get("data") or {}
+        items = data.get("list") or []
+        if not items:
+            log.info(f"商家列表第 {page} 页无数据,停止翻页")
+            break
+
+        rows = []
+        for it in items:
+            row = parse_shop(it)
+            if row and row["merchant_user_id"] not in seen:
+                seen.add(row["merchant_user_id"])
+                rows.append(row)
+        save_shops(log, rows, sql_pool)
+        log.info(f"商家列表第 {page} 页完成,本页 {len(items)} 条,累计商家 {len(seen)}")
+        page += 1
+
+    return len(seen)
+
+
+# ==================== 二、商品采集 ====================
+def parse_product(item: dict) -> dict | None:
+    """把在售商品列表项解析成 deca_onsale_product_record 一行。
+
+    Args:
+        item (dict): on-sale-list 的 data.list 项(或商家内嵌 products 项,同构)。
+
+    Returns:
+        dict | None: 商品数据字典;无 code 时返回 None。
+    """
+    code = item.get("code")
+    if not code:
+        return None
+    merchant = item.get("merchant") or {}
+    return {
+        "product_code": code,
+        "merchant_user_id": str(merchant.get("merchantUserID")) if merchant.get("merchantUserID") else None,
+        "merchant_name": merchant.get("merchantName"),
+        "title": item.get("title"),
+        "card_product_title": item.get("cardProductTitle"),
+        "cover_image_url": item.get("coverImageUrl"),
+        "unit_price": item.get("unitPrice"),
+        "min_unit_price": item.get("minUnitPrice"),
+        "max_unit_price": item.get("maxUnitPrice"),
+        "card_count": item.get("cardCount"),
+        "sold_count": item.get("soldCount"),
+        "available_stock": item.get("availableStock"),
+        "groupbuy_status": item.get("groupbuyStatus"),
+        "groupbuy_status_name": item.get("groupbuyStatusName"),
+        "play_type": item.get("playType"),
+        "live_id": item.get("liveId")
+    }
+
+
+def save_products(log, rows: list, sql_pool) -> int:
+    """upsert 写入商品主表(更新售卖进度等动态字段)+ 当天售卖进度快照。
+
+    Args:
+        log: 日志对象。
+        rows (list[dict]): parse_product 产出的商品字典列表。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本次写入/更新的商品数。
+    """
+    if not rows:
+        return 0
+
+    # 1) 商品主表 upsert(最新状态)
+    cols = ["product_code", "merchant_user_id", "merchant_name", "title", "card_product_title",
+            "cover_image_url", "unit_price", "min_unit_price", "max_unit_price", "card_count",
+            "sold_count", "available_stock", "groupbuy_status", "groupbuy_status_name",
+            "play_type", "live_id"]
+    upd = [c for c in cols if c != "product_code"]  # 除唯一键外都更新为最新值
+    # is_on_sale=1:本函数写入的都是本轮 home/search 采到的当前在售商品;
+    # 重新上架的旧商品(原 is_on_sale=0)也会被 ON DUPLICATE 置回 1(自愈)。
+    prod_sql = (f"INSERT INTO deca_onsale_product_record ({', '.join(cols)}, is_on_sale) "
+                f"VALUES ({', '.join(['%s'] * len(cols))}, 1) "
+                f"ON DUPLICATE KEY UPDATE {', '.join(f'{c}=VALUES({c})' for c in upd)}, is_on_sale=1")
+    sql_pool.insert_many(query=prod_sql, args_list=[tuple(r[c] for c in cols) for r in rows])
+
+    # 2) 每日快照 upsert(product_code + snapshot_date 唯一,当天多次跑更新为最新值)
+    today = date.today().isoformat()
+    snap_cols = ["product_code", "merchant_user_id", "snapshot_date", "card_count",
+                 "sold_count", "available_stock", "groupbuy_status_name", "unit_price"]
+    snap_upd = ["card_count", "sold_count", "available_stock", "groupbuy_status_name", "unit_price"]
+    snap_sql = (f"INSERT INTO deca_onsale_product_daily_record ({', '.join(snap_cols)}) "
+                f"VALUES ({', '.join(['%s'] * len(snap_cols))}) "
+                f"ON DUPLICATE KEY UPDATE {', '.join(f'{c}=VALUES({c})' for c in snap_upd)}")
+    snap_args = [(r["product_code"], r["merchant_user_id"], today, r["card_count"],
+                  r["sold_count"], r["available_stock"], r["groupbuy_status_name"], r["unit_price"])
+                 for r in rows]
+    sql_pool.insert_many(query=snap_sql, args_list=snap_args)
+    return len(rows)
+
+
+def fetch_all_onsale(log) -> tuple[list, bool]:
+    """免 token 翻页拉取「全量在售」商品(home/search 空关键词),只解析返回、不落库。
+
+    抽出纯拉取供多方复用(get_onsale_products 落库、onsale_alert_spider 自采筛商家),
+    避免重复实现翻页/签名。home/search 免登录即返回当前全量在售,商品项与 on-sale-list 同构,
+    直接用 parse_product 解析。翻到底:本页不足一页(<PAGE_SIZE)即最后一页。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        tuple[list, bool]: (全站在售商品字典列表[parse_product 结果], 是否正常翻到底)。
+            bool 为 False 表示中途请求异常/未取全,调用方据此决定是否安全做下架对账。
+    """
+    page = 1
+    total = None
+    out = []
+    ok = False
+    while page <= MAX_SEARCH_PAGES:
+        # 空 query + saleStatus=0:实测返回当前全量在售;playType/sortType/specType=0 表示不额外过滤
+        body = {"categoryParam": 0, "page": page, "pageSize": PAGE_SIZE, "playType": 0,
+                "query": "", "saleStatus": 0, "sortType": 0, "specType": 0}
+        try:
+            resp = do_request(log, "/api/v1/app/home/search", body)  # 免登录,仅需签名
+        except Exception as e:
+            log.error(f"全量在售第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            log.info(f"全量在售返回异常: {resp.get('msg') if resp else None}")
+            break
+
+        data = resp.get("data") or {}
+        if total is None:
+            total = data.get("total")
+            log.info(f"全量在售共 {total} 个商品,开始翻页")
+        items = data.get("list") or []
+        if not items:
+            ok = True   # 翻到空页也算正常结束
+            break
+
+        out.extend(r for r in (parse_product(it) for it in items) if r)
+        if len(items) < PAGE_SIZE:  # 翻到底:本页不足一页即最后一页
+            ok = True
+            break
+        page += 1
+    return out, ok
+
+
+def get_onsale_products(log, sql_pool) -> int:
+    """免 token 拉全量在售(复用 fetch_all_onsale)并写主表 + 每日快照 + 采后下架对账。
+
+    取代旧的「逐商家遍历 on-sale-list(需 token)」路径。拉取交给 fetch_all_onsale,
+    本函数只负责落库与对账;商品真实状态以 item.groupbuyStatus 为准(saleStatus=0 取全量)。
+
+    Args:
+        log: 日志对象。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本轮写入/更新的商品数。
+    """
+    rows, ok = fetch_all_onsale(log)
+    current_codes = {r["product_code"] for r in rows}   # 本轮全量在售 code 集合,用于采后对账下架
+    saved = save_products(log, rows, sql_pool) if rows else 0
+
+    # 采后对账下架:仅在本轮正常取到非空在售集合时执行,避免请求异常时误判全部下架
+    if ok and current_codes:
+        _sweep_offsale(log, sql_pool, current_codes)
+    else:
+        log.warning(f"本轮未正常取全在售集合(ok={ok}, 集合数={len(current_codes)}),跳过下架对账")
+
+    log.info(f"全量在售商品采集完成,写入/更新 {saved} 个")
+    return saved
+
+
+def _sweep_offsale(log, sql_pool, current_codes: set) -> int:
+    """对账下架:把 is_on_sale=1 但不在本轮全量在售集合内的商品置为 is_on_sale=0。
+
+    解决 home/search 只返回「当前在售」导致的陈旧问题:上午在售、下午卖完的商品,
+    下午这轮不在 current_codes 里,会被置为不在售,从而让 deca_report 统计准确。
+    商品重新上架时,save_products 的 upsert 会把 is_on_sale 置回 1(自愈)。
+
+    Args:
+        log: 日志对象。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+        current_codes (set): 本轮 home/search 全量在售的 product_code 集合(非空)。
+
+    Returns:
+        int: 被置为下架的商品数(rowcount);取不到时返回 0。
+    """
+    placeholders = ",".join(["%s"] * len(current_codes))
+    sql = (f"UPDATE deca_onsale_product_record SET is_on_sale=0 "
+           f"WHERE is_on_sale=1 AND product_code NOT IN ({placeholders})")
+    cur = sql_pool.update_one(sql, tuple(current_codes))
+    try:
+        n = cur.rowcount if cur is not None else 0
+    except Exception:
+        n = 0
+    log.info(f"下架对账完成:{n} 个商品本轮不在全量在售集合,已置 is_on_sale=0")
+    return n
+
+
+def get_product_detail(log, code: str) -> dict:
+    """获取商品详情 groupbuy/detail,返回 data 节点(含 publishAt/saleStartAt/saleEndAt 等)。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码(列表接口的 code)。
+
+    Returns:
+        dict: 详情 data 字典;无数据时返回空字典。
+    """
+    resp = do_request(log, "/api/v1/app/groupbuy/detail", {"code": code})  # 详情接口免登录,实测不带token也返回
+    return (resp or {}).get("data") or {}
+
+
+def fill_product_details(log, sql_pool) -> int:
+    """给未补齐详情的商品拉 groupbuy/detail,回填规格/系列/模式/时间。
+
+    选取范围:publish_at 或 spec_name 任一为空的商品(前者覆盖新商品初次补时间字段,
+    后者覆盖历史存量商品的规格/系列/模式回填)。详情字段入库后基本不变,无需重复拉取。
+
+    回填字段:
+        - publish_at / sale_start_at / sale_end_at :上架、开售、结束时间
+        - spec_name       :组队规格顶层(specTypeName),如 原箱 / 单盒 / 单包 / LOT
+        - series_name     :系列名(giftInfo.items[0].seriesName),含 Hobby/Jumbo 版本词
+        - series_config   :几箱几盒几包结构化(giftInfo.items[0].seriesConfig)
+        - play_type_name  :玩法中文名(playTypeName),如 自定义随机 / 随机球队
+
+    Args:
+        log: 日志对象。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本轮成功补详情的商品数。
+    """
+    rows = sql_pool.select_all(
+        "SELECT product_code FROM deca_onsale_product_record "
+        "WHERE publish_at IS NULL OR spec_name IS NULL") or []
+    log.info(f"待补详情(时间/规格/模式)商品 {len(rows)} 个")
+    filled = 0
+    for (code,) in rows:
+        try:
+            d = get_product_detail(log, code)
+            if not d:
+                continue
+            # 系列信息在 giftInfo.items 首元素里;缺则给空字典兜底,字段 .get() 全部返回 None
+            first_item = ((d.get("giftInfo") or {}).get("items") or [{}])[0] or {}
+            sql_pool.update_one(
+                "UPDATE deca_onsale_product_record SET publish_at=%s, sale_start_at=%s, "
+                "  sale_end_at=%s, spec_name=%s, series_name=%s, series_config=%s, "
+                "  play_type_name=%s "
+                "WHERE product_code=%s",
+                (d.get("publishAt") or None, d.get("saleStartAt") or None,
+                 d.get("saleEndAt") or None,
+                 d.get("specTypeName") or None,
+                 first_item.get("seriesName") or None,
+                 first_item.get("seriesConfig") or None,
+                 d.get("playTypeName") or None,
+                 code))
+            filled += 1
+        except Exception as e:
+            log.error(f"商品 {code} 补详情失败: {e}")
+        time.sleep(0.3)  # 轻微限速
+    return filled
+
+
+# ==================== 主流程 ====================
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def main_task(log):
+    """得卡每日采集主函数:商家发现 → 免 token 全量在售商品采集(home/search, +每日快照)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 得卡采集任务" + "." * 40)
+
+    sql_pool = MySQLConnectionPool(log=log)
+    if not sql_pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+
+    try:
+        # 1) 商家发现(免 token)
+        try:
+            n = get_shop_list(log, sql_pool)
+            log.info(f"商家发现完成,去重商家 {n} 个")
+        except Exception as e:
+            log.error(f"get_shop_list error: {e}")
+
+        time.sleep(3)
+
+        # 2) 商品采集:免 token 翻页拉全量在售(home/search,取代旧的逐商家 on-sale-list[需 token])
+        try:
+            n = get_onsale_products(log, sql_pool)
+            log.info(f"在售商品采集完成,写入/更新 {n} 个")
+        except Exception as e:
+            log.error(f"get_onsale_products error: {e}")
+
+        # 3) 补商品详情:只给今日新增商品拉 groupbuy/detail 的上架/开售/结束时间
+        try:
+            n = fill_product_details(log, sql_pool)
+            log.info(f"补详情完成,本轮 {n} 个")
+        except Exception as e:
+            log.error(f"fill_product_details error: {e}")
+
+        # 4) 采集完成:生成统计报表图片,并按 deca_report.SEND_WECHAT 开关自动发微信好友
+        try:
+            import deca_report
+            out = deca_report.main()
+            log.info(f"报表已生成并处理发送: {out}")
+        except Exception as e:
+            log.error(f"生成/发送报表失败: {e}")
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"得卡采集 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
+
+
+def schedule_task():
+    """定时任务入口:每天 09:00 与 15:00 各运行一次 main_task。"""
+    # main_task(log=logger)  # 立即跑一次(调试时取消注释)
+
+    schedule.every().day.at("09:00").do(main_task, log=logger)  # 上午场
+    schedule.every().day.at("15:00").do(main_task, log=logger)  # 下午场
+    schedule.every().day.at("20:00").do(main_task, log=logger)  # 晚上场
+    schedule.every().day.at("01:00").do(main_task, log=logger)  # 凌晨场
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 966 - 0
deca_auto_spider/common/deca_sold_core.py

@@ -0,0 +1,966 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA 已售流程核心(签名 + 请求层 + 完整采集管道)。
+
+**自包含、可独立部署**:签名/请求层直接内置,不依赖 on_sale/。
+放其他机器只需带:本文件 + sold_history_spider.py + sold_daily_spider.py + application.yml
++ common/(account_pool.py / settings.py 等),并装好 charley-utils(mysql_pool) 与 requirements 依赖。
+
+签名(逆向自 APK mf/c0.java,4 样本验证):
+    signature = md5( md5(参数按 key 升序拼接) + currentTime[-6:] + "biu_card_nbclass" )
+鉴权:业务接口需 Bearer token(15 分钟过期)。token 由账号池统一管理——need_auth 请求走账号池
+    随机取号 + 自动续签 + 各号专属 IP(见「账号池接入」段),采集脚本启动时调 init_account_pool() 启用。
+
+采集管道:
+    商家列表 → 每商家历史成交 → 详情补抓 → 卡密清单(开关) → 拆卡报告 + 视频回放
+    -> deca_shop_record / deca_product_record / deca_kami_record / deca_report_record
+"""
+import time
+import json
+import hashlib
+from datetime import datetime
+from decimal import Decimal, ROUND_HALF_UP
+
+import requests
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+# ==================== 基础配置 ====================
+BASE = "https://api.decalive.com"  # 业务域名(来源:抓包)
+SALT = "biu_card_nbclass"          # 签名固定 salt(逆向自 mf/c0.java 硬编码常量)
+
+USE_PROXY = False     # 代理开关:默认直连,遇 IP 风控再置 True
+
+# 公共请求头(来源:抓包;currentTime/signature/Authorization 每次动态补)
+BASE_HEADERS = {
+    "appVersion": "1.0.0",
+    "buildType": "release",
+    "timeZone": "GMT+08:00",
+    "equipmentType": "Android",
+    "Content-Type": "application/json; charset=utf-8",
+    "Accept-Encoding": "gzip",
+    "User-Agent": "okhttp/5.3.2",
+}
+
+# 已售流程表名(无前缀)
+T_SHOP = "deca_shop_record"
+T_PROD = "deca_product_record"
+T_KAMI = "deca_kami_record"
+T_REPORT = "deca_report_record"
+T_TEAM = "deca_groupbuy_team_record"  # 2026/08/11 新增:随机团 teams 明细
+
+PAGE_SIZE = 20             # 列表每页条数
+MAX_SHOP_PAGES = 50        # 商家列表翻页上限
+MAX_SOLD_PAGES = 3         # 单商家历史成交翻页上限(服务端 sold-list 硬限最近 60 条=3 页,翻不到更多)
+MAX_CARD_PAGES = 5000      # 单商品卡密清单翻页上限
+FILL_KAMI = False          # 卡密清单采集开关:默认关(量大极耗时,每商品上万张);需要时置 True
+
+
+# ==================== 签名 / 请求 ====================
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    Args:
+        retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
+    """
+    log = retry_state.args[0] if retry_state.args else logger
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+def _before_retry_rotate(retry_state):
+    """tenacity 重试前回调:非首次尝试时强制随机换号,让「请求失败」换个账号重试(2026/09/15)。
+
+    需 token 的请求首次失败(HTTP 非 200/网络异常触发 @retry)后,重试前主动换一个随机 healthy 号,
+    避免连续撞同一个坏号/临期号把整批拖垮(配合每请求换号 rotate_every=1,双保险,不依赖其副作用)。
+    只作用于当前线程自己的号池;账号池未启用或换号失败均静默跳过,不影响免 token 请求。
+
+    Args:
+        retry_state: tenacity RetryCallState,attempt_number>1 表示这是一次重试。
+    """
+    if retry_state.attempt_number > 1 and account_pool_enabled():
+        try:
+            _thread_pool().acquire_random()   # 换一个随机 healthy 号再重试,别再撞同一个坏号
+        except Exception:
+            pass
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
+def get_proxys(log):
+    """获取隧道代理配置(默认不启用,见 USE_PROXY)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        dict: requests 可用的 proxies 字典。
+
+    Raises:
+        Exception: 组装代理配置异常时向上抛出以触发重试。
+    """
+    tunnel = "x371.kdltps.com:15818"
+    user, pwd = "t13753103189895", "o0yefv6z"
+    try:
+        proxy = f"http://{user}:{pwd}@{tunnel}/"
+        return {"http": proxy, "https": proxy}
+    except Exception as e:
+        log.error(f"Error getting proxy: {e}")
+        raise e
+
+
+def _md5(s: str) -> str:
+    """对字符串做 UTF-8 MD5,返回小写十六进制。
+
+    Args:
+        s (str): 待哈希明文。
+
+    Returns:
+        str: 32 位小写十六进制 MD5。
+    """
+    return hashlib.md5(s.encode("utf-8")).hexdigest()
+
+
+def make_signature(params: dict, current_time: str) -> str:
+    """按逆向还原规则计算 signature 请求头。
+
+    规则(mf/c0.java):plain = 参数按 key 升序、key 直接拼 value、无分隔符(剔除 None);
+    signature = md5( md5(plain) + current_time[-6:] + SALT )。
+
+    Args:
+        params (dict): 参与签名的参数(POST 为 body,GET 为 query)。
+        current_time (str): 13 位毫秒时间戳字符串,同一值放进 currentTime 头。
+
+    Returns:
+        str: 32 位小写十六进制签名。
+    """
+    clean = {k: v for k, v in params.items() if v is not None}
+    plain = "".join(f"{k}{clean[k]}" for k in sorted(clean))
+    return _md5(_md5(plain) + current_time[-6:] + SALT)
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log, before=_before_retry_rotate)
+def do_request(log, path: str, body: dict, need_auth: bool = False,
+               use_proxy: bool = None, proxy_override: dict = None) -> dict | None:
+    """通用带签名 POST 请求(带重试)。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径(以 / 开头)。
+        body (dict): JSON body,同时用于签名。
+        need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+        use_proxy (bool, optional): 是否走代理。None=沿用全局 USE_PROXY;显式 True/False 覆盖。
+            用于「带 token 的请求走直连(False)、免 token 高频请求走隧道(True)」按调用区分,
+            避免一个账号在轮换 IP 间跳动而被风控关联。Defaults to None。
+        proxy_override (dict, optional): 显式指定 requests proxies,覆盖以上所有代理判断。
+            账号池续期/短信登录用它走「该号专属 IP」,保证登录态操作也从该号绑定的 IP 发出。Defaults to None。
+
+    Returns:
+        dict | None: 响应 JSON。
+
+    Raises:
+        RuntimeError: HTTP 状态码非 200 时抛出以触发重试。
+    """
+    ct = str(int(time.time() * 1000))
+    headers = BASE_HEADERS.copy()
+    headers["currentTime"] = ct
+    headers["signature"] = make_signature(body, ct)
+    _use_proxy = USE_PROXY if use_proxy is None else use_proxy  # 按调用覆盖全局代理开关
+    proxies = get_proxys(log) if _use_proxy else None
+    if need_auth:
+        if account_pool_enabled():
+            access, pool_proxies = _pool_auth_and_proxy(log)   # 账号池:随机取号+续签+该号专属 IP
+            headers["Authorization"] = f"Bearer {access}"
+            proxies = pool_proxies                              # 走该号专属 proxy_url(None=直连),覆盖上面
+        else:
+            raise RuntimeError("need_auth 请求需先 init_account_pool() 启用账号池(已无 token.json 回退)")
+
+    if proxy_override is not None:      # 显式代理(账号池续期/短信登录走该号专属 IP),覆盖以上
+        proxies = proxy_override
+    data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+    resp = requests.post(BASE + path, headers=headers, data=data, timeout=(5, 30), proxies=proxies)
+    if resp.status_code != 200:
+        log.error(f"请求失败 {resp.status_code}: {path}")
+        raise RuntimeError(f"HTTP {resp.status_code}")
+    result = resp.json()
+    # 账号池:把请求结果反馈给当前号的健康状态机(成功清零 fail_count、刷新 last_success_at)
+    if need_auth and account_pool_enabled():
+        _ap = _thread_pool()
+        if _ap.account and (result or {}).get("code") == 0:
+            _ap.report_success()
+        # TODO(待实测得卡限流/风控码): 对软失败码调 _ap.report_failure(dead=False) 接入 cooling
+    return result
+
+
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log, before=_before_retry_rotate)
+def do_get(log, path: str, params: dict = None, need_auth: bool = False,
+           use_proxy: bool = None, proxy_override: dict = None) -> dict | None:
+    """通用带签名 GET 请求(带重试)。签名参数取 query。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径(以 / 开头)。
+        params (dict, optional): URL query 参数(同时用于签名)。Defaults to None。
+        need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+        use_proxy (bool, optional): 是否走代理。None=沿用全局 USE_PROXY;显式 True/False 覆盖。Defaults to None。
+        proxy_override (dict, optional): 显式指定 requests proxies,覆盖以上所有代理判断(走该号专属 IP 用)。Defaults to None。
+
+    Returns:
+        dict | None: 响应 JSON。
+
+    Raises:
+        RuntimeError: HTTP 状态码非 200 时抛出以触发重试。
+    """
+    ct = str(int(time.time() * 1000))
+    headers = BASE_HEADERS.copy()
+    headers["currentTime"] = ct
+    headers["signature"] = make_signature(params or {}, ct)
+    proxies = get_proxys(log) if (USE_PROXY if use_proxy is None else use_proxy) else None
+    if need_auth:
+        if account_pool_enabled():
+            access, pool_proxies = _pool_auth_and_proxy(log)   # 账号池:随机取号+续签+该号专属 IP
+            headers["Authorization"] = f"Bearer {access}"
+            proxies = pool_proxies                              # 走该号专属 proxy_url(None=直连),覆盖上面
+        else:
+            raise RuntimeError("need_auth 请求需先 init_account_pool() 启用账号池(已无 token.json 回退)")
+
+    if proxy_override is not None:      # 显式代理(走该号专属 IP),覆盖以上
+        proxies = proxy_override
+    resp = requests.get(BASE + path, headers=headers, params=params, timeout=(5, 30), proxies=proxies)
+    if resp.status_code != 200:
+        log.error(f"请求失败 {resp.status_code}: {path}")
+        raise RuntimeError(f"HTTP {resp.status_code}")
+    result = resp.json()
+    # 账号池:把请求结果反馈给当前号的健康状态机(成功清零 fail_count、刷新 last_success_at)
+    if need_auth and account_pool_enabled():
+        _ap = _thread_pool()
+        if _ap.account and (result or {}).get("code") == 0:
+            _ap.report_success()
+        # TODO(待实测得卡限流/风控码): 对软失败码调 _ap.report_failure(dead=False) 接入 cooling
+    return result
+
+
+# ==================== 账号池接入(step3,2026/09/09;2026/09/10 起为 need_auth 唯一路径)====================
+# need_auth 请求的 token 统一走账号池(随机取号 + 续签 + 各号专属 proxy_url),账号池为唯一路径。
+# 采集脚本启动时调 init_account_pool() 登记共享连接池后即可用;未登记就发 need_auth 请求会直接报错。
+# 每 _POOL_ROTATE_EVERY 个 need_auth 请求自动随机换一次号。2026/09/15:由 20 改 1——「每请求换号」,
+# 把带 token 请求摊到全池、单号不再被一小批连续复用+跨 15 分钟边界高频轮换 refresh_token 而被踩废判死
+# (10010)。配合 do_request/do_get 的「请求失败换号重试」,一号卡死也不再拖垮整批(避免购买记录 0)。
+import threading as _threading   # 线程隔离:账号池按线程持号(buy_record 8 线程并发安全)
+
+USE_ACCOUNT_POOL = False        # 账号池是否已登记;采集脚本启动调 init_account_pool() 置 True(必须登记才不缺 token)
+_pool_shared = None             # 共享 MySQLConnectionPool(各线程的 AccountPool 共用它连 DB)
+_pool_task_tag = "core"         # 任务标签,写入账号 owner_tag 便于排障
+_POOL_ROTATE_EVERY = 1          # 每 N 个 need_auth 请求随机换一次号(2026/09/15 由 20→1:每请求换号,摊平单号刷新压力)
+# ⚠️ 线程隔离(2026/09/09 修 buy_record 8 线程踩踏):每个线程持自己的 AccountPool(各一个当前号 +
+# 独立请求计数),互不共享 account/计数。这样多线程并发也不会出现「A 的 token 从 B 的 IP 发」「两线程
+# 并发续期踩废同号 refresh」「首启各抢一个号只留最后一个」等竞态。单线程任务同样安全。
+_pool_tls = _threading.local()  # 线程本地存储:每线程独立的 ap(AccountPool) 与 req_count
+
+
+def init_account_pool(pool, task_tag: str = "core", rotate_every: int = 1) -> None:
+    """启用账号池:采集脚本启动时调用一次,之后 need_auth 请求改走账号池随机取号(线程隔离)。
+
+    need_auth 请求必须先调用本函数登记账号池(否则直接报错,无其它路径)。仅登记「共享 DB
+    连接池 + task_tag + 换号周期」,各线程首次取号时按需创建自己的 AccountPool(见 _thread_pool),彼此不共享持号状态。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池(各线程 AccountPool 共用它连 DB)。
+        task_tag (str, optional): 本进程任务标签,写入账号 owner_tag 便于排障。Defaults to "core"。
+        rotate_every (int, optional): 每多少个 need_auth 请求随机换一次号。2026/09/15 默认由 20 改 1
+            (每请求换号,摊平单号刷新压力、避免一号被连续复用刷死)。Defaults to 1。
+    """
+    global _pool_shared, _pool_task_tag, USE_ACCOUNT_POOL, _POOL_ROTATE_EVERY
+    _pool_shared = pool
+    _pool_task_tag = task_tag
+    _POOL_ROTATE_EVERY = max(1, int(rotate_every))
+    USE_ACCOUNT_POOL = True
+    logger.info(f"[core] 账号池已启用(线程隔离)task_tag={task_tag} rotate_every={_POOL_ROTATE_EVERY}")
+
+
+def account_pool_enabled() -> bool:
+    """账号池是否已启用(USE_ACCOUNT_POOL 且已登记共享连接池)。
+
+    Returns:
+        bool: 已启用返回 True。
+    """
+    return USE_ACCOUNT_POOL and _pool_shared is not None
+
+
+def _thread_pool():
+    """取当前线程独立的 AccountPool(thread-local,首次为本线程惰性创建)。
+
+    Returns:
+        AccountPool: 当前线程专属实例;同线程多次调用返回同一个。
+    """
+    ap = getattr(_pool_tls, "ap", None)
+    if ap is None:
+        from account_pool import AccountPool   # 惰性 import:避免与 account_pool 惰性 import core 形成循环
+        ap = AccountPool(_pool_shared, log=logger, task_tag=_pool_task_tag)
+        _pool_tls.ap = ap
+        _pool_tls.req_count = 0
+    return ap
+
+
+def rotate_account(log) -> bool:
+    """批边界主动随机换号(作用于当前线程)。账号池未启用时空操作。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        bool: 成功取到新随机号返回 True;未启用或池中无可用号返回 False。
+    """
+    if not account_pool_enabled():
+        return False
+    return _thread_pool().acquire_random() is not None
+
+
+def release_account() -> None:
+    """归还当前线程持有的号(线程/任务结束时调;租约到期也会自动释放)。"""
+    ap = getattr(_pool_tls, "ap", None)
+    if ap is not None:
+        ap.release()
+
+
+def _pool_auth_and_proxy(log):
+    """取当前线程的号「有效 access + 该号专属 proxies」,按本线程计数每批自动随机换号。
+
+    线程隔离:ap 与计数均来自 _pool_tls(当前线程独有),故多线程并发各自持号、各自续期、各自换号,
+    不会互相踩废。每 _POOL_ROTATE_EVERY 个请求换一次随机号(换前归还旧号租约);续签失败换号重试一次。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        tuple[str, dict | None]: (有效 access token, 该号专属 proxies 或 None=直连)。
+
+    Raises:
+        RuntimeError: 池中无可用 healthy 号,或换号后仍拿不到有效 access。
+    """
+    ap = _thread_pool()
+    # 每批随机:无当前号(首次)或到换号点 → 随机取一个(换前归还旧号租约)
+    need_new = ap.account is None or (_pool_tls.req_count > 0 and _pool_tls.req_count % _POOL_ROTATE_EVERY == 0)
+    if need_new:
+        if ap.account is not None:
+            ap.release()
+        if ap.acquire_random() is None:
+            raise RuntimeError("账号池无可用 healthy 号")
+    _pool_tls.req_count += 1
+    access = ap.ensure_access(log)
+    if not access:                          # 续签失败(该号已被 ensure_access 判死/摘池)→ 换号重试一次
+        if ap.acquire_random() is None:
+            raise RuntimeError("账号池换号后仍无可用 healthy 号")
+        access = ap.ensure_access(log)
+        if not access:
+            raise RuntimeError("账号池换号后续签仍失败")
+    proxy = ap.account.get("proxy_url")
+    proxies = {"http": proxy, "https": proxy} if proxy else None   # proxy_url 为空=直连(IP 到位后自动走)
+    return access, proxies
+
+
+# ==================== 1. 商家列表 ====================
+def parse_shop(item: dict) -> dict | None:
+    """把商家展位列表项解析成 deca_shop_record 一行。
+
+    Args:
+        item (dict): merchant/exhibition/list 的 data.list 项。
+
+    Returns:
+        dict | None: 商家数据字典;无 userId 时返回 None。
+    """
+    uid = item.get("userId")
+    if not uid:
+        return None
+    return {
+        "merchant_user_id": str(uid),
+        "merchant_name": item.get("merchantName"),
+        "merchant_avatar_url": item.get("merchantAvatarUrl"),
+        "fans_count": item.get("fansCount"),
+        "active_groupbuy_count": item.get("activeGroupbuyCount"),
+    }
+
+
+def get_shop_list(log, pool) -> int:
+    """翻页遍历商家展位列表,upsert 写入 deca_shop_record。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 去重后发现的商家总数。
+    """
+    seen = set()
+    page = 1
+    sql = (f"INSERT INTO {T_SHOP} "
+           "(merchant_user_id, merchant_name, merchant_avatar_url, fans_count, active_groupbuy_count) "
+           "VALUES (%s,%s,%s,%s,%s) "
+           "ON DUPLICATE KEY UPDATE merchant_name=VALUES(merchant_name), "
+           "merchant_avatar_url=VALUES(merchant_avatar_url), fans_count=VALUES(fans_count), "
+           "active_groupbuy_count=VALUES(active_groupbuy_count)")
+    while page <= MAX_SHOP_PAGES:
+        try:
+            resp = do_request(log, "/api/v1/app/merchant/exhibition/list", {"page": page}, need_auth=True)
+        except Exception as e:
+            log.error(f"商家列表第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            break
+        items = (resp.get("data") or {}).get("list") or []
+        if not items:
+            break
+        rows = []
+        for it in items:
+            r = parse_shop(it)
+            if r and r["merchant_user_id"] not in seen:
+                seen.add(r["merchant_user_id"])
+                rows.append((r["merchant_user_id"], r["merchant_name"], r["merchant_avatar_url"],
+                             r["fans_count"], r["active_groupbuy_count"]))
+        if rows:
+            pool.insert_many(query=sql, args_list=rows)
+        log.info(f"商家列表第 {page} 页完成,累计 {len(seen)}")
+        page += 1
+    return len(seen)
+
+
+# ==================== 2. 每商家历史成交 ====================
+def parse_product(item: dict) -> dict | None:
+    """把历史成交列表项解析成 deca_product_record 一行(详情字段留待 fill_details 补)。
+
+    Args:
+        item (dict): sold-list 的 data.list 项。
+
+    Returns:
+        dict | None: 商品数据字典;无 code 时返回 None。
+    """
+    code = item.get("code")
+    if not code:
+        return None
+    m = item.get("merchant") or {}
+    return {
+        "product_code": code,
+        "merchant_user_id": str(m.get("merchantUserID")) if m.get("merchantUserID") else None,
+        "merchant_name": m.get("merchantName"),
+        "title": item.get("title"),
+        "card_product_title": item.get("cardProductTitle"),
+        "cover_image_url": item.get("coverImageUrl"),
+        "unit_price": item.get("unitPrice"),
+        "min_unit_price": item.get("minUnitPrice"),
+        "max_unit_price": item.get("maxUnitPrice"),
+        "card_count": item.get("cardCount"),
+        "sold_count": item.get("soldCount"),
+        "available_stock": item.get("availableStock"),
+        "groupbuy_status": item.get("groupbuyStatus"),
+        "groupbuy_status_name": item.get("groupbuyStatusName"),
+        "play_type": item.get("playType"),
+        "live_id": item.get("liveId"),
+        "completed_at": item.get("completedAt") or None,
+    }
+
+
+def get_sold_list(log, merchant_user_id: str, pool, incremental: bool = False) -> int:
+    """翻页拉取某商家历史成交,INSERT IGNORE 写入 deca_product_record。
+
+    服务端 sold-list 硬限只返回最近 60 条(3 页,pageSize 锁死 20),故最多翻 MAX_SOLD_PAGES
+    页;去重靠唯一键 INSERT IGNORE,不再做「连续无新页早停」(天花板 3 页,早停已无意义)。
+    incremental 参数因此不再影响翻页行为,仅保留以兼容 run_pipeline 的调用签名。
+
+    Args:
+        log: 日志对象。
+        merchant_user_id (str): 商家用户 ID。
+        pool: MySQL 连接池。
+        incremental (bool, optional): 历史遗留参数,已不影响翻页(见上)。Defaults to False。
+
+    Returns:
+        int: 本商家的入库尝试量(INSERT IGNORE 去重前,重复行会被唯一键静默丢弃)。
+    """
+    page = 1
+    saved = 0
+    while page <= MAX_SOLD_PAGES:
+        body = {"merchantUserId": merchant_user_id, "page": page, "pageSize": PAGE_SIZE}
+        try:
+            resp = do_request(log, "/api/v1/app/groupbuy/merchant/sold-list", body, need_auth=True)
+        except Exception as e:
+            log.error(f"商家 {merchant_user_id} 历史成交第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            break
+        items = (resp.get("data") or {}).get("list") or []
+        if not items:
+            break
+
+        rows = [r for r in (parse_product(it) for it in items) if r]
+        if rows:
+            pool.insert_many(table=T_PROD, data_list=rows, ignore=True)  # 靠唯一键 product_code 去重
+            saved += len(rows)
+
+        if len(items) < PAGE_SIZE:  # 本页不足一页=最后一页
+            break
+        page += 1
+        time.sleep(0.3)
+
+    if saved:
+        log.info(f"商家 {merchant_user_id} 历史成交入库 {saved} 个")
+    return saved
+
+
+# ==================== 3.5 随机团 teams 明细存储与总价计算(2026/08/11) ====================
+# 供 fill_details / backfill_team_amount / on_sale/deca_team_spider 共用的辅助层。
+# 完整口径设计与背景见 docs/选队随机与剩余随机_总价口径与采集_20260811.md。
+def _round2(x) -> Decimal | None:
+    """把数值化为 Decimal 并四舍五入到 2 位小数(元)。
+
+    Args:
+        x: 数值(int/float/str/Decimal),None/异常返回 None。
+
+    Returns:
+        Decimal | None: 保留 2 位;None 表示不可算。
+    """
+    if x is None:
+        return None
+    try:
+        return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+    except Exception:
+        return None
+
+
+def upsert_team_row(pool, code: str, play_type_name: str, source: str, team: dict,
+                    captured_at: str, snap_total: int | None = None) -> None:
+    """把一条 team 明细 upsert 进 deca_groupbuy_team_record(唯一键覆盖)。
+
+    唯一键 (product_code, team_id, data_source) 冲突时按最新覆盖——把"在售团每轮覆盖成
+    最新"自动做掉;剩余随机 snapshot(data_source='snapshot')冻结不变,重复调也无副作用。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        play_type_name (str): 采集时的 playTypeName(如 '选队随机-…' / '剩余随机')。
+        source (str): 数据来源 'team_options' / 'snapshot'。
+        team (dict): 单条 team 数据(team-options 项 或 snapshot.teams 项)。
+        captured_at (str): 采集时刻 YYYY-MM-DD HH:MM:SS。
+        snap_total (int | None, optional): 剩余随机 snapshot.totalQuantity。Defaults to None。
+    """
+    tid = team.get("teamId")
+    if tid is None:
+        return
+    cc = team.get("cardCount")                 # 选队阶段有;剩余快照无 → NULL
+    av = team.get("availableStock")
+    up = _round2(team.get("unitPrice"))
+    sold = (cc - av) if (cc is not None and av is not None) else None
+    pool._execute(
+        f"INSERT INTO {T_TEAM} "
+        f"(product_code, play_type_name, data_source, team_id, team_name_en, team_name_zh, "
+        f" team_logo_image_url, unit_price, card_count, available_stock, sold_count, "
+        f" snapshot_total_quantity, captured_at) "
+        f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
+        f"ON DUPLICATE KEY UPDATE "
+        f"  play_type_name=VALUES(play_type_name), team_name_en=VALUES(team_name_en), "
+        f"  team_name_zh=VALUES(team_name_zh), team_logo_image_url=VALUES(team_logo_image_url), "
+        f"  unit_price=VALUES(unit_price), card_count=VALUES(card_count), "
+        f"  available_stock=VALUES(available_stock), sold_count=VALUES(sold_count), "
+        f"  snapshot_total_quantity=VALUES(snapshot_total_quantity), "
+        f"  captured_at=VALUES(captured_at)",
+        (code, play_type_name, source, tid, team.get("teamNameEn"), team.get("teamNameZh"),
+         team.get("teamLogoImageUrl"), up, cc, av, sold, snap_total, captured_at),
+        commit=True)
+
+
+def fetch_team_options_or_none(log, code: str) -> list | None:
+    """打 team-options(带 token);code=0 返 list,其它(含 29000)返 None。
+
+    29000 = "商家开启剩余随机中,请稍后再试",说明该团已转成剩余随机,team-options 永久
+    不可用——这是正常业务态,不视为错误。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码。
+
+    Returns:
+        list | None: teams 列表;不可查返 None。
+    """
+    r = do_request(log, "/api/v1/app/groupbuy/team-options", {"code": code}, need_auth=True)
+    if not r or r.get("code") != 0:
+        return None
+    return (r.get("data") or {}).get("list") or []
+
+
+def compute_random_team_amount_and_persist(log, pool, code: str,
+                                           detail_data: dict, target_table: str) -> None:
+    """对随机团(选队随机/剩余随机):存 teams 明细 + 算出团总价并写目标表 team_total_amount。
+
+    · 剩余随机(playTypeName == '剩余随机'):从详情 snapshot 存 teams(首次生效,冻结不变),
+      团总价 = Σ 单价×availableStock(已售侧口径:该剩余团的总货值/卖完价)。
+    · 选队随机(playTypeName 以 '选队随机' 开头):补调一次 team-options,存 teams,
+      团总价 = Σ 单价×(cardCount − availableStock)。若已转成剩余随机(29000),team-options
+      拿不到、team_total_amount 不动(保持 NULL)——这是数据缺失的自然表达。
+    · 其它固定价团:不动 team_total_amount(NULL),报告端回落 unit_price × sold_count。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        detail_data (dict): 详情接口返回的 data 字段(复用调用方已拉到的详情,不再新调)。
+        target_table (str): 要更新 team_total_amount 的目标表名(T_PROD / 或在售表)。
+    """
+    ptn = detail_data.get("playTypeName") or ""
+    if not ptn:
+        return
+    captured_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    if ptn == "剩余随机":
+        snap = detail_data.get("remainingRandomTeamSnapshot") or {}
+        teams = snap.get("teams") or []
+        if not teams:
+            return
+        for t in teams:
+            upsert_team_row(pool, code, ptn, "snapshot", t, captured_at,
+                            snap_total=snap.get("totalQuantity"))
+        total = sum(Decimal(str(t.get("unitPrice") or "0")) * (t.get("availableStock") or 0)
+                    for t in teams)
+        pool.update_one(
+            f"UPDATE {target_table} SET team_total_amount=%s WHERE product_code=%s",
+            (_round2(total), code))
+    elif "选队随机" in ptn:
+        teams = fetch_team_options_or_none(log, code)
+        if not teams:
+            # 已转成剩余随机→ 保持 NULL(本函数下轮若该团 playTypeName 变成"剩余随机"会被另一分支接住)
+            log.info(f"[选队随机·team-options 不可用] {code}: 可能已转剩余随机")
+            return
+        for t in teams:
+            upsert_team_row(pool, code, ptn, "team_options", t, captured_at)
+        total = sum(
+            Decimal(str(t.get("unitPrice") or "0")) * ((t.get("cardCount") or 0) - (t.get("availableStock") or 0))
+            for t in teams)
+        pool.update_one(
+            f"UPDATE {target_table} SET team_total_amount=%s WHERE product_code=%s",
+            (_round2(total), code))
+
+
+def backfill_team_amount(log, pool) -> int:
+    """回补 deca_product_record 里所有还没算过 team_total_amount 的随机团。
+
+    存量老数据:fill_details 只对 publish_at/play_type_name 为空的商品拉详情,
+    历史上已经补过详情的商品不会再走 fill_details——这些老数据 team_total_amount 常年 NULL。
+    本函数专门补它们:查所有随机团(选队随机/剩余随机) 且 team_total_amount IS NULL,
+    重新拉一次详情并算出总价写回。跑一次收敛所有存量。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 本轮成功补总价的商品数。
+    """
+    rows = pool.select_all(
+        f"SELECT product_code FROM {T_PROD} "
+        f"WHERE team_total_amount IS NULL "
+        f"  AND play_type_name IS NOT NULL "
+        f"  AND (play_type_name = '剩余随机' OR play_type_name LIKE '选队随机%%')") or []
+    log.info(f"待回补 team_total_amount 商品 {len(rows)} 个")
+    filled = 0
+    for (code,) in rows:
+        try:
+            d = do_request(log, "/api/v1/app/groupbuy/detail", {"code": code}, need_auth=False)
+            data = (d or {}).get("data") or {}
+            if not data:
+                continue
+            compute_random_team_amount_and_persist(log, pool, code, data, T_PROD)
+            filled += 1
+        except Exception as e:
+            log.error(f"商品 {code} 回补 team_total_amount 失败: {e}")
+        time.sleep(0.3)
+    if filled:
+        log.info(f"回补完成 {filled} 个")
+    return filled
+
+
+# ==================== 3. 详情补抓 ====================
+def fill_details(log, pool) -> int:
+    """给未补详情的商品拉 groupbuy/detail,回填计划时间/规格/系列/模式/开卡/卡密份数。
+
+    选取范围:publish_at 或 play_type_name 任一为空的商品——前者覆盖新商品初次补时间字段,
+    后者覆盖历史存量商品的模式名回填(play_type_name 是新增字段,老数据默认为空)。
+
+    回填字段:
+        - publish_at / sale_start_at / sale_end_at :上架、开售、结束时间
+        - spec_name       :规格类型(specTypeName),如 原箱 / 单盒 / 单包 / LOT
+        - series_name     :系列名(giftInfo.items[0].seriesName),含 Hobby/Jumbo 版本词
+        - series_config   :几箱几盒几包结构化(giftInfo.items[0].seriesConfig)
+        - play_type_name  :玩法中文名(playTypeName),如 自定义随机 / 随机球队
+        - expected_card_count / checklist_card_count :预计开卡张数 / 卡密份数
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 本轮成功补详情的商品数。
+    """
+    rows = pool.select_all(
+        f"SELECT product_code FROM {T_PROD} WHERE publish_at IS NULL OR play_type_name IS NULL") or []
+    log.info(f"待补详情商品 {len(rows)} 个")
+    filled = 0
+    for (code,) in rows:
+        try:
+            d = do_request(log, "/api/v1/app/groupbuy/detail", {"code": code}, need_auth=False)  # 详情接口免登录,实测不带token也返回
+            data = (d or {}).get("data") or {}
+            if not data:
+                continue
+            gift = data.get("giftInfo") or {}
+            items = gift.get("items") or [{}]
+            first = items[0] if items else {}
+            pool.update_one(
+                f"UPDATE {T_PROD} SET publish_at=%s, sale_start_at=%s, sale_end_at=%s, spec_name=%s, "
+                "series_name=%s, series_config=%s, play_type_name=%s, "
+                "expected_card_count=%s, checklist_card_count=%s "
+                "WHERE product_code=%s",
+                (data.get("publishAt") or None, data.get("saleStartAt") or None, data.get("saleEndAt") or None,
+                 data.get("specTypeName") or None, first.get("seriesName") or None,
+                 first.get("seriesConfig") or None, data.get("playTypeName") or None,
+                 gift.get("totalExpectedCardCount"), data.get("totalCardCount"), code))
+            # 2026/08/11 新增:随机团顺手算 team_total_amount + 存 teams 明细
+            # 剩余随机走 snapshot、选队随机补一次 team-options;固定价团此函数直接跳过
+            try:
+                compute_random_team_amount_and_persist(log, pool, code, data, T_PROD)
+            except Exception as e:
+                log.error(f"商品 {code} 随机团总价计算失败: {e}")
+            filled += 1
+        except Exception as e:
+            log.error(f"商品 {code} 补详情失败: {e}")
+        time.sleep(0.3)
+    return filled
+
+
+# ==================== 4. 卡密清单 ====================
+def parse_kami(item: dict, product_code: str) -> dict | None:
+    """把卡密清单项解析成 deca_kami_record 一行。
+
+    Args:
+        item (dict): card/list 的 data.list 项。
+        product_code (str): 所属商品编码。
+
+    Returns:
+        dict | None: 数据字典;无 uuid 时返回 None。
+    """
+    uid = item.get("uuid")
+    if not uid:
+        return None
+    return {
+        "uuid": uid, "product_code": product_code,
+        "sort_order": item.get("sortOrder"), "alias_name": item.get("aliasName"),
+        "team_name_en": item.get("teamNameEn"), "team_name_zh": item.get("teamNameZh"),
+        "player_name_en": item.get("playerNameEn"), "player_name_zh": item.get("playerNameZh"),
+        "card_type": item.get("cardType"), "limit_number": item.get("limitNumber"),
+        "card_number": item.get("cardNumber"),
+    }
+
+
+def get_card_list(log, product_code: str, pool) -> int:
+    """翻页拉某商品全部卡密清单(本页<20即末页),INSERT IGNORE 入 deca_kami_record。
+
+    Args:
+        log: 日志对象。
+        product_code (str): 商品编码。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 本商品写入的卡密数(已去重)。
+    """
+    page = 1
+    saved = 0
+    while page <= MAX_CARD_PAGES:
+        body = {"code": product_code, "page": page, "pageSize": PAGE_SIZE}
+        try:
+            resp = do_request(log, "/api/v1/app/groupbuy/card/list", body, need_auth=True)
+        except Exception as e:
+            log.error(f"商品 {product_code} 卡密第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            break
+        items = (resp.get("data") or {}).get("list") or []
+        if not items:
+            break
+        rows = [r for r in (parse_kami(it, product_code) for it in items) if r]
+        if rows:
+            pool.insert_many(table=T_KAMI, data_list=rows, ignore=True)
+            saved += len(rows)
+        if len(items) < PAGE_SIZE:  # 本页不足一页=末页(total 动态不可靠)
+            break
+        page += 1
+        time.sleep(1)  # 卡密清单量大,每页 1s 降频
+    if saved:
+        log.info(f"商品 {product_code} 卡密入库 {saved} 张")
+    return saved
+
+
+def fill_card_list(log, pool) -> int:
+    """给还没采过卡密(deca_kami_record 无该 product_code)的商品拉卡密清单。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 本轮采集卡密的商品数。
+    """
+    rows = pool.select_all(
+        f"SELECT p.product_code FROM {T_PROD} p "
+        f"LEFT JOIN (SELECT DISTINCT product_code FROM {T_KAMI}) k ON k.product_code=p.product_code "
+        "WHERE k.product_code IS NULL") or []
+    log.info(f"待采卡密商品 {len(rows)} 个")
+    done = 0
+    for (code,) in rows:
+        try:
+            get_card_list(log, code, pool)
+            done += 1
+        except Exception as e:
+            log.error(f"商品 {code} 卡密采集失败: {e}")
+    return done
+
+
+# ==================== 5. 拆卡报告 + 视频回放 ====================
+def parse_report(item: dict, product_code: str) -> dict | None:
+    """把拆卡报告项解析成 deca_report_record 一行(image_url 为 images 内所有 imageUrl 拼接)。
+
+    Args:
+        item (dict): open-card-report 的 data.list 项。
+        product_code (str): 所属商品编码。
+
+    Returns:
+        dict | None: 数据字典;无 confirmedCardUuid 时返回 None。
+    """
+    uid = item.get("confirmedCardUuid")
+    if not uid:
+        return None
+    imgs = item.get("images") or []
+    urls = [i.get("imageUrl") for i in sorted(imgs, key=lambda x: x.get("imagePosition") or 0) if i.get("imageUrl")]
+    return {
+        "product_code": product_code,
+        "confirmed_card_uuid": uid,
+        "player_name_en": item.get("playerNameEn"),
+        "player_name_zh": item.get("playerNameZh"),
+        "opened_at": item.get("openedAt") or None,
+        "hit_user_nickname": item.get("hitUserNickname"),
+        "image_url": ",".join(urls) if urls else None,
+    }
+
+
+def fill_reports(log, pool) -> tuple:
+    """采集拆卡报告 + 视频回放,用状态字段驱动、拿不到下轮重试。
+
+    - 拆卡报告:report_state=0 的商品拉 open-card-report;有 list 入 deca_report_record 且置 1,
+      无 list(报告还没生成)保持 0 下轮重试。
+    - 视频回放:replay_state=0 的商品,live_id>0 拉 live/replay/detail;拿到 replay_url 更新
+      product.replay_url 且置 1,没有保持 0 重试;live_id 为空/0 直接置 2(无直播,不重试)。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        tuple[int, int]: (入报告的商品数, 补回放的商品数)。
+    """
+    # 5a. 拆卡报告
+    rep_rows = pool.select_all(f"SELECT product_code FROM {T_PROD} WHERE report_state=0") or []
+    log.info(f"待采拆卡报告商品 {len(rep_rows)} 个")
+    rep_done = 0
+    for (code,) in rep_rows:
+        try:
+            resp = do_request(log, "/api/v1/app/groupbuy/open-card-report/public/list",
+                              {"code": code}, need_auth=True)
+            lst = ((resp or {}).get("data") or {}).get("list") or []
+            if not lst:
+                continue  # 报告还没生成,report_state 保持 0,下轮重试
+            rows = [r for r in (parse_report(it, code) for it in lst) if r]
+            if rows:
+                pool.insert_many(table=T_REPORT, data_list=rows, ignore=True)
+            pool.update_one(f"UPDATE {T_PROD} SET report_state=1 WHERE product_code=%s", (code,))
+            rep_done += 1
+        except Exception as e:
+            log.error(f"商品 {code} 拆卡报告失败: {e}")
+        time.sleep(0.3)
+
+    # 5b. 视频回放
+    rp_rows = pool.select_all(f"SELECT product_code, live_id FROM {T_PROD} WHERE replay_state=0") or []
+    log.info(f"待采回放商品 {len(rp_rows)} 个")
+    rp_done = 0
+    for code, live_id in rp_rows:
+        if not live_id or int(live_id) <= 0:
+            pool.update_one(f"UPDATE {T_PROD} SET replay_state=2 WHERE product_code=%s", (code,))  # 无直播
+            continue
+        try:
+            resp = do_get(log, "/api/v1/app/live/replay/detail", {"live_id": live_id}, need_auth=True)
+            url = ((resp or {}).get("data") or {}).get("replay_url")
+            if url:
+                pool.update_one(f"UPDATE {T_PROD} SET replay_url=%s, replay_state=1 WHERE product_code=%s",
+                                (url, code))
+                rp_done += 1
+            # 拿不到 replay_url(回放还没就绪)保持 0,下轮重试
+        except Exception as e:
+            log.error(f"商品 {code} 回放失败: {e}")
+        time.sleep(0.3)
+
+    return rep_done, rp_done
+
+
+# ==================== 完整管道 ====================
+def run_pipeline(log, pool, incremental: bool):
+    """执行完整已售采集管道:商家→已售→详情→卡密(开关)→拆卡报告+回放。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        incremental (bool): True=增量(daily,连续无新页早停);False=全量深翻(history)。
+    """
+    # 1) 商家发现
+    try:
+        n = get_shop_list(log, pool)
+        log.info(f"商家发现 {n} 个")
+    except Exception as e:
+        log.error(f"get_shop_list error: {e}")
+
+    # 2) 每商家历史成交
+    try:
+        uids = [r[0] for r in (pool.select_all(f"SELECT merchant_user_id FROM {T_SHOP}") or [])]
+        log.info(f"待采商家 {len(uids)} 个")
+        for uid in uids:
+            try:
+                get_sold_list(log, uid, pool, incremental=incremental)
+            except Exception as e:
+                log.error(f"get_sold_list error(商家 {uid}): {e}")
+            time.sleep(0.3)
+    except Exception as e:
+        log.error(f"iterate_shop error: {e}")
+
+    # 3) 详情补抓
+    try:
+        log.info(f"详情补抓 {fill_details(log, pool)} 个")
+    except Exception as e:
+        log.error(f"fill_details error: {e}")
+
+    # 3.5) 随机团 team_total_amount 存量回补(2026/08/11 新增)
+    # 历史上补过详情的老数据不会再走 fill_details 的分支,这里专门补它们的 team_total_amount。
+    # 跑一次全库收敛;已算过(NOT NULL)的自然跳过。
+    try:
+        backfill_team_amount(log, pool)
+    except Exception as e:
+        log.error(f"backfill_team_amount error: {e}")
+
+    # 4) 卡密清单(默认关闭,见 FILL_KAMI;量大极耗时,需要时再开)
+    if FILL_KAMI:
+        try:
+            log.info(f"卡密采集 {fill_card_list(log, pool)} 个商品")
+        except Exception as e:
+            log.error(f"fill_card_list error: {e}")
+    else:
+        log.info("卡密采集已关闭(FILL_KAMI=False),跳过")
+
+    # 5) 拆卡报告 + 回放
+    try:
+        rep, rp = fill_reports(log, pool)
+        log.info(f"拆卡报告 {rep} 个商品,回放 {rp} 个")
+    except Exception as e:
+        log.error(f"fill_reports error: {e}")

+ 166 - 0
deca_auto_spider/common/isms_client.py

@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/09
+"""爱接码 i-sms.app 接码平台客户端(改编自官方 Python SDK 示例)。
+
+用途:账号池自动补货时,通过接码平台拿手机号 + 轮询收得卡短信验证码,实现「登录即注册」全自动。
+来源:https://github.com/i-sms-app/i-sms-api-sdk-examples/tree/main/python (标准库 urllib,无第三方依赖)。
+
+鉴权:请求头 X-API-KEY(api_key 建议从环境变量 ISMS_API_KEY 读,勿硬编码/提交公开仓库)。
+调用流程:search_projects → get_number → get_sms(轮询) → release_number。
+"""
+import os
+import json
+import time
+import urllib.parse
+import urllib.request
+import urllib.error
+
+ISMS_BASE = "https://www.i-sms.app"     # 接码平台端点
+POLL_INTERVAL_SEC = 5                    # 轮询验证码间隔(官方建议 ≥5s,过快触发 WAF)
+POLL_MAX_SEC = 90                        # 轮询验证码最长等待(收不到就快换号,少浪费取号费;官方建议 60~180s)
+# 必带浏览器 UA:urllib 默认 UA(Python-urllib/x.y) 会被爱接码 WAF 拦成 403(2026/09/09 实测)
+_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+
+
+class ISmsClient:
+    """爱接码接码平台 API 客户端:搜项目 / 取号 / 收验证码 / 释放号 / 查余额。"""
+
+    def __init__(self, api_key: str = None, base_url: str = ISMS_BASE, timeout: int = 20):
+        """初始化客户端。
+
+        Args:
+            api_key (str, optional): 接码平台 API Key;None 时从环境变量 ISMS_API_KEY 读。Defaults to None。
+            base_url (str, optional): 接口端点。Defaults to ISMS_BASE。
+            timeout (int, optional): 单次请求超时秒数。Defaults to 20。
+
+        Raises:
+            ValueError: api_key 缺失(既未传参也无 ISMS_API_KEY 环境变量)时抛出。
+        """
+        self.api_key = api_key or os.environ.get("ISMS_API_KEY")
+        if not self.api_key:
+            raise ValueError("缺少接码平台 api_key(传参或设环境变量 ISMS_API_KEY)")
+        self.base_url = base_url.rstrip("/")
+        self.timeout = timeout
+
+    def search_projects(self, keyword: str) -> dict:
+        """按关键词搜索接码项目(拿 project_id/name/token)。
+
+        Args:
+            keyword (str): 项目关键词(如 "得卡" / "DECA" / "decalive")。
+
+        Returns:
+            dict: 平台响应 JSON,成功时 data 为项目列表,每项含 project_id/name/token。
+        """
+        return self._get("/api/v2/projects", {"keyword": keyword})
+
+    def get_number(self, project_id, project_name, project_token, quantity: int = 1,
+                   phone: str = None, province: str = None, carrier: str = None,
+                   ascription: int = None) -> dict:
+        """获取一个(或多个)可用手机号。
+
+        Args:
+            project_id: 项目 ID(来自 search_projects)。
+            project_name: 项目名(来自 search_projects,需与 id/token 匹配)。
+            project_token: 项目 V2 安全 Token(来自 search_projects)。
+            quantity (int, optional): 取号数量(1~10)。Defaults to 1。
+            phone (str, optional): 指定手机号(一般留空由平台分配)。Defaults to None。
+            province (str, optional): 省份代码(见 README,留空不限)。Defaults to None。
+            carrier (str, optional): 运营商代码(CMCC/CUCC/CTCC…,留空不限)。Defaults to None。
+            ascription (int, optional): 卡类型 1=虚拟卡 / 2=实体卡(强风控平台建议 2)。Defaults to None。
+
+        Returns:
+            dict: 平台响应 JSON,成功时 data[i] 含 number/orderId,顶层含 balance。
+        """
+        return self._get("/api/v2/get_number", {
+            "project_id": project_id, "project_name": project_name,
+            "project_token": project_token, "quantity": quantity, "phone": phone,
+            "province": province, "carrier": carrier, "ascription": ascription})
+
+    def get_sms(self, order_id=None, phone_number=None, project_id=None) -> dict:
+        """获取某订单收到的短信验证码(单次查询,未到时 success=False)。
+
+        Args:
+            order_id (optional): 取号返回的订单 ID(首选)。Defaults to None。
+            phone_number (optional): 手机号(备用定位)。Defaults to None。
+            project_id (optional): 项目 ID(备用定位)。Defaults to None。
+
+        Returns:
+            dict: 平台响应 JSON,成功时含 sms_code/sms_content。
+        """
+        return self._get("/api/v1/get_sms", {
+            "order_id": order_id, "phone_number": phone_number, "project_id": project_id})
+
+    def poll_sms(self, order_id, interval: int = POLL_INTERVAL_SEC, max_sec: int = POLL_MAX_SEC,
+                 log=None) -> str | None:
+        """按固定间隔轮询验证码直到拿到或超时(对 get_sms 的封装)。
+
+        Args:
+            order_id: 取号返回的订单 ID。
+            interval (int, optional): 轮询间隔秒。Defaults to POLL_INTERVAL_SEC(5)。
+            max_sec (int, optional): 最长等待秒。Defaults to POLL_MAX_SEC(180)。
+            log (optional): 日志对象。Defaults to None。
+
+        Returns:
+            str | None: 验证码字符串;超时未收到返回 None。
+        """
+        deadline = time.time() + max_sec
+        while time.time() < deadline:
+            resp = self.get_sms(order_id=order_id)
+            if resp.get("success") and resp.get("sms_code"):
+                return str(resp["sms_code"])
+            time.sleep(interval)
+        if log:
+            log.warning(f"[接码] 订单 {order_id} 轮询 {max_sec}s 未收到验证码")
+        return None
+
+    def release_number(self, order_id=None, phone_number=None, project_id=None) -> dict:
+        """释放不再使用的号码(用完及时调,降资源占用)。
+
+        Args:
+            order_id (optional): 订单 ID(首选)。Defaults to None。
+            phone_number (optional): 手机号。Defaults to None。
+            project_id (optional): 项目 ID。Defaults to None。
+
+        Returns:
+            dict: 平台响应 JSON。
+        """
+        return self._get("/api/v1/release_number", {
+            "order_id": order_id, "phone_number": phone_number, "project_id": project_id})
+
+    def get_user_info(self) -> dict:
+        """查询账户信息与余额。
+
+        Returns:
+            dict: 平台响应 JSON(含余额,用于补货前判断额度是否充足)。
+        """
+        return self._get("/api/v1/user/info")
+
+    def _get(self, path: str, params: dict = None) -> dict:
+        """内部:发 GET 请求并解析 JSON(HTTP 错误也返回带 http_status 的 JSON,不抛异常)。
+
+        Args:
+            path (str): 接口路径。
+            params (dict, optional): query 参数(None/"" 值自动剔除)。Defaults to None。
+
+        Returns:
+            dict: 响应 JSON;HTTP 错误时返回 {success:False, ..., http_status:code}。
+        """
+        query = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
+        url = f"{self.base_url}{path}"
+        if query:
+            url = f"{url}?{urllib.parse.urlencode(query)}"
+        req = urllib.request.Request(url, headers={
+            "X-API-KEY": self.api_key, "User-Agent": _UA, "Accept": "application/json"})
+        try:
+            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+                return json.loads(resp.read().decode("utf-8"))
+        except urllib.error.HTTPError as exc:
+            body = exc.read().decode("utf-8")
+            try:
+                payload = json.loads(body)
+            except json.JSONDecodeError:
+                payload = {"success": False, "message": body}
+            payload["http_status"] = exc.code
+            return payload

+ 67 - 0
deca_auto_spider/common/settings.py

@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/09
+"""得卡 DECA 账号池 · 集中配置。
+
+把账号池/接码平台/专属代理 IP 的业务与敏感配置从 account_pool.py 抽出集中管理,
+改配置只动这一处。account_pool.py 通过 `from settings import *` 引入。
+
+⚠️ 敏感:本文件含接码平台 API Key 与代理账密(主公定写代码,私有仓库)。勿外传 / 勿提交公开仓库。
+"""
+
+# ==================== 账号表 ====================
+T_ACCOUNT = "deca_account_record"          # 账号表
+
+# ==================== 账号池运行参数 ====================
+LEASE_TTL_SEC = 6 * 3600                    # 租约 TTL:进程独占一个号的最长租期(进程崩溃后到期自动可被再租,不死占)
+COOLDOWN_SEC = 30 * 60                      # cooling 冷却时长:软风控(限流/429)后冷却多久由巡检恢复 healthy
+MAX_FAIL = 3                                # 连续失败达此值:软风控转 cooling;若是续期失败(登录态死)则直接 dead
+ACCESS_SKEW_SEC = 60                        # access 提前量:距过期 < 该秒数即视为临期、触发续期
+POOL_TARGET_SIZE = 20                       # 账号池目标规模:维持 20 个账号(auto_replenish 默认 target_max,主公定 2026/09/09)
+# refresh 续签判死码:实测(2026/09/09 测试号 19521500850)refresh 失效时服务端返回
+# code=10010 / msg「当前账号长期未登录,请重新登录」(涵盖 refresh 被轮换/过期/长期未登录)。
+# 续签命中此集合 → 确定判 dead 摘池;其他未知失败码按软失败累计(可能是限流/网络,不急着判死)。
+DEAD_CODES = {10010}
+
+# ==================== 得卡鉴权接口路径(need_auth=False)====================
+REFRESH_PATH = "/api/v1/app/auth/token/refresh"     # 续期接口
+LOGIN_PATH = "/api/v1/app/auth/password/login"      # 密码登录接口(线上默认不调,撞滑块)
+SMS_SEND_PATH = "/api/v1/app/auth/sms/send"         # 发短信验证码
+SMS_LOGIN_PATH = "/api/v1/app/auth/sms/login"       # 短信验证码登录(得卡无独立注册接口,登录即注册)
+
+# ==================== 自动补货:接码平台(爱接码 i-sms.app)====================
+# 池中可用号不足时,自动经接码平台租号 + 收验证码 + 短信登录(=注册) 入库,做到全自动补货。
+ISMS_API_KEY = "sk_b54e08d93733a49b45166e376829c7ca4cab47f5b01352d5"   # 爱接码 API Key(敏感)
+ISMS_PROJECT_KEYWORD = "优豪卡"        # 得卡对应接码项目搜索关键词(实测:项目名「优豪卡科技」project_id=119481)
+ISMS_ASCRIPTION = None                 # 卡类型:None=默认不限制(官方文档,主公指定)/ 1=虚拟卡 / 2=实体卡
+REPLENISH_STAGGER_SEC = (30, 90)      # 每补一个号后随机停顿区间(错峰,避免一批新号同时注册被批量连坐识别)
+
+# ==================== 专属静态 IP 池(快代理独享·共享版·静态型,2026/09/09 购买)====================
+# 20 个国内静态 IP,端口 16816,有效期至 2026-10-09。1 号 1 IP 永久绑定。账密敏感。
+PROXY_USER = "quxxpsyo"
+PROXY_PASS = "qnp6hh1n"
+PROXY_PORT = 16816
+PROXY_IPS = [
+    "42.51.42.213",       # 洛阳
+    "106.225.197.215",    # 南昌
+    "36.138.169.42",      # 北京
+    "14.29.243.155",      # 深圳
+    "119.96.179.83",      # 武汉
+    "223.247.135.153",    # 芜湖
+    "175.6.143.21",       # 长沙
+    "171.220.244.42",     # 成都
+    "106.119.164.164",    # 石家庄
+    "180.103.123.171",    # 苏州
+    "36.137.246.145",     # 济南
+    "171.15.113.68",      # 郑州
+    "121.204.169.128",    # 福州
+    "180.102.24.128",     # 南京
+    "36.134.183.3",       # 杭州
+    "180.138.244.109",    # 南宁
+    "14.18.113.82",       # 广州
+    "219.152.51.158",     # 重庆
+    "140.249.18.52",      # 青岛
+    "222.219.134.168",    # 昆明
+]
+PROXY_URLS = [f"http://{PROXY_USER}:{PROXY_PASS}@{ip}:{PROXY_PORT}" for ip in PROXY_IPS]  # 20 条专属代理 URL

+ 94 - 0
deca_auto_spider/docs/优化记录_deca采集翻页_20260911.md

@@ -0,0 +1,94 @@
+# deca采集翻页 优化记录
+
+## 1. 优化背景
+
+排查 `spiders/shop_test.py`(临时测试:抓单店铺历史成交存 `deca_product_record_copy1`)时发现:某店铺历史成交只抓到 60 条就停,而 App 显示 1943 条。
+
+实测确认根因是**服务端接口硬限**,不是采集逻辑漏采:
+
+- 已售接口 `/api/v1/app/groupbuy/merchant/sold-list`:`pageSize` 锁死 20(传其他值报 `code=10001 "PageSize不能小于或大于20"`),且 `page` 翻页**只开放前 3 页(60 条)**,page=4(offset≥60)返回 `total=0`、空 list。响应里的 `total`(如 1943)只是统计展示值,不代表可拉取条数。
+- 盲试 13 个常见游标参数名(`lastCompletedAt`/`beforeCompletedAt`/`cursor`/`lastCode`/`lastId` 等)全被服务端忽略;App 端手动下滑同样是 3 页到底——确认无隐藏翻页方式。
+- 在售接口 `/api/v1/app/groupbuy/merchant/on-sale-list` 同源、同样 `pageSize≤20`;实测库内在售最多的商家仅 29 个(远 <60),触不到上限。
+
+结论:这类列表接口天花板就是最近 3 页,遂把在售/已售所有列表翻页上限统一收敛到 3 页,并清理因此失效的「连续无新页早停」逻辑。
+
+## 2. 改动内容
+
+统一目标:所有在售/已售**列表**翻页上限 = 3 页;去重一律靠唯一键 `INSERT IGNORE` / upsert,不依赖翻页早停。
+
+| 文件 | 函数 | 改动 |
+|---|---|---|
+| `common/deca_sold_core.py` | `get_sold_list` | `MAX_SOLD_PAGES` 500→3;删除 `STOP_AFTER_DUPE_PAGES` 常量与 `_filter_new_codes` 函数;去掉增量早停分支,统一翻 3 页 + `INSERT IGNORE`;`incremental` 参数保留仅兼容 `run_pipeline`,不再影响翻页 |
+| `spiders/buy_record_spider.py` | `get_monitored_onsale` | `ON_SALE_MAX_PAGES` 20→3(去重 = `save_products` upsert,原有 `total`/末页早停保留) |
+| `reports/onsale_report/deca_on_sale_report.py` | `full_onsale_sweep` | `ONSALE_MAX_PAGES` 30→3(去重 = `save_products` upsert,原有早停保留) |
+| `spiders/onsale_alert_spider.py` | `fetch_onsale` | 死常量 `MAX_PROD_PAGES` 100→3,并用它替换硬编码 `while page <= 20`(去重 = `product_code` 字典去重 + `INSERT IGNORE`) |
+
+`run_pipeline` 及 `sold_daily_spider.py` / `sold_history_spider.py` 的 `incremental=` 调用**未改**(保持最小影响面)。
+
+## 3. 改动前后对比
+
+`common/deca_sold_core.py` `get_sold_list` 核心逻辑:
+
+```python
+# 改动前:分全量/增量两条路径,增量靠 _filter_new_codes + STOP_AFTER_DUPE_PAGES 连续无新页早停
+while page <= MAX_SOLD_PAGES:  # 500
+    ...
+    if incremental:
+        new_codes = _filter_new_codes(pool, [...])   # 查库筛新 code
+        rows = [r for r in rows if r["product_code"] in new_codes]
+    pool.insert_many(table=T_PROD, data_list=rows, ignore=True)
+    if incremental:
+        if new_on_page == 0:
+            dupe_pages += 1
+            if dupe_pages >= STOP_AFTER_DUPE_PAGES: break   # 连续无新页早停
+
+# 改动后:单一路径,翻满 3 页,去重全交给唯一键 INSERT IGNORE
+while page <= MAX_SOLD_PAGES:  # 3
+    ...
+    if rows:
+        pool.insert_many(table=T_PROD, data_list=rows, ignore=True)  # 靠唯一键 product_code 去重
+    if len(items) < PAGE_SIZE: break   # 不足一页=末页
+```
+
+## 4. 注意事项
+
+- **接口天花板不可逾越**:`sold_history`/`sold_daily` 对每个店铺同样只能采到最近 60 条历史成交,这是接口上限不是漏采。要积累更完整历史,只能靠每日增量长期跑(每天把新产生的成交追进库),不能指望一次性深翻。
+- **在售的 3 页是纯保护值**:在售商家在售量普遍远 <60(实测 top 29 个),正常在第 1~2 页就靠 `total`/末页早停停下,3 页上限不会触发也不会漏采。
+- **`incremental` 参数已成兼容摆设**:`get_sold_list` 保留它只为不改 `run_pipeline` 调用链;全量/增量翻页行为现已完全一致。若后续要彻底清理,需连带改 `run_pipeline` 与两个 spider 的调用。
+- **校验**:4 个文件均通过 `py_compile`;`STOP_AFTER_DUPE_PAGES` / `_filter_new_codes` 全库无残留引用。
+- `MAX_CARD_PAGES`(卡密清单)、`MAX_SHOP_PAGES`(商家列表)为不同接口,本次未涉及,保持原值。
+
+## 5. 后续增强:密集时段每小时占坑补采(同日)
+
+### 5.1 背景
+
+60 条上限本质是**按 `completedAt` 倒序的滑动窗口**:接口只吐最近 60 笔成交。若某商家在**相邻两次采集间隔内**成交 >60 笔,最早那批会被挤出前 3 页,永久漏采。而该店铺 13:00~06:00(实测真正密集在 22:00~04:00)售卖集中,每天一次 08:00 采集无法覆盖这种密集段。
+
+### 5.2 方案
+
+在 `spiders/sold_daily_spider.py` 内新增高频轻量任务(不新建脚本):
+
+- **`hourly_task(log)`**:13:00~06:00 每整点跑一次,只对 `HOURLY_MERCHANT_IDS` 里的目标商家调 `core.get_sold_list`(翻 3 页 `INSERT IGNORE` 占坑入库),**不跑**详情/随机团/卡密/报告等精加工。
+- **每天 08:00 `main_task`(完整 `run_pipeline`)不变**:负责详情补抓 / 随机团回补 / 拆卡报告等精加工。
+
+**解耦是方案成立的关键**:`run_pipeline` 里"步骤 2 占坑入库"与"步骤 3~5 精加工"完全解耦——精加工均由**扫库存量**驱动(`fill_details` 扫未补详情的行、`backfill_team_amount` 扫 team 为 NULL 的行、`fill_reports` 扫无报告的行)。所以高频只需保证 `product_code` 尽早进库不丢,08:00 完整流程会自动扫到新行补齐;`INSERT IGNORE` 幂等,高频重复抓不会重复入库。
+
+### 5.3 数据依据(频率是否够)
+
+统计库内 881226408 的成交(1912 条,跨 8/3~9/11)按「日期+小时」分桶:
+
+- **单小时成交峰值 = 41 笔**(9/9 凌晨 3 点),top15 全在 20~41 之间。
+- 每小时采一次可覆盖最近 60 条,留 60−41=19 条余量 → **每小时够,不漏**。
+- (库数据本身受"每天只采 60 条"限制,极端爆量日可能被低估。)
+
+### 5.4 改动点
+
+| 文件 | 改动 |
+|---|---|
+| `spiders/sold_daily_spider.py` | 新增常量 `HOURLY_MERCHANT_IDS`、`HOURLY_HOURS`(13~23、0~6);新增 `hourly_task`;`schedule_task` 注册 18 个整点跑 `hourly_task`;修正模块/`main_task` 已失准的"连续无新页早停"注释 |
+
+### 5.5 注意事项
+
+- **爆量风险**:若某商家新品首发单小时冲破 60,每小时仍会漏最早一段——届时对该商家提频到半小时/15 分钟(或按商家分级调度)。
+- **既有隐患(本次未改)**:`main_task` 的 `@retry(wait_fixed(3600))` 是**同步阻塞**——若 08:00 完整流程失败进入重试,会 `sleep` 一小时并阻塞整个 `schedule` 单线程,期间 `hourly_task` 也无法触发。要让高频不受完整流程故障拖累,需将 `main_task` 失败重试改为非阻塞(独立线程/进程)。
+- 扩商家:往 `HOURLY_MERCHANT_IDS` 追加 ID 即可。

+ 107 - 0
deca_auto_spider/docs/优化记录_得卡DECA_20260910.md

@@ -0,0 +1,107 @@
+# 得卡 DECA 自动化爬虫 优化记录
+
+## 1. 优化背景
+
+账号池(2026/09/10 起)已是 `need_auth` 请求**唯一路径**——根 `token.json` / `token.json.lock` / `get_token.py` 已清理下线,卡密 `km_spdier` 任务已删除。但在源码与文档里仍有**若干过期描述**沿用「老 token.json / 沿用老 ensure_token」字眼,会让接手人误以为还存在第二条登录态路径,或误以为还有临时回退方案可用。本次统一刷新所有过期描述,与现状对齐。
+
+## 2. 改动内容
+
+### 2.1 代码注释 / docstring 修正
+
+| 文件 | 旧描述 | 新描述 | 改因 |
+|---|---|---|---|
+| `common/deca_sold_core.py:231` | `need_auth 请求的取 token 统一走「账号池随机取号 + 续签 + 各号专属 proxy_url」,已无 token.json 回退。` | `need_auth 请求的 token 统一走账号池(随机取号 + 续签 + 各号专属 proxy_url),账号池为唯一路径。` | 消除「回退」对比,纯正向说明 |
+| `common/deca_sold_core.py:236` | `# 账号池是否已登记;init_account_pool() 置 True(未启用时 need_auth 报错)` | `# 账号池是否已登记;采集脚本启动调 init_account_pool() 置 True(必须登记才不缺 token)` | 强调「必须主动启用」,去掉「报错」字面 |
+| `common/deca_sold_core.py:249` | docstring `need_auth 请求必须先调用本函数登记账号池(否则直接报错,已无 token.json 回退)。仅登记「共享 DB` | docstring `need_auth 请求必须先调用本函数登记账号池(否则直接报错,无其它路径)。仅登记「共享 DB` | 同 231 |
+| `spiders/onsale_alert_spider.py:36` | `挂靠新项目根:...CWD 对齐新根(token.json/application.yml/logs 生效)` | `挂靠新项目根:...CWD 对齐新根(application.yml / logs / 账号池 DB 生效)` | token.json 已删,无 `生效` 可言 |
+| `spiders/buy_record_spider.py:56` | `复用核心签名/token/请求/代理层,让 application.yml、token.json 生效` | `复用核心签名/账号池/请求/代理层,让 application.yml / 账号池 DB 生效` | 同上 |
+| `spiders/deca_team_spider.py:32` | `与 alert / sold_daily 共用同一份根目录 token.json;续签已加跨进程锁防踩踏(deca_sold_core._token_lock,2026/08/20 根治多进程互相踩废 refreshToken 的问题)` | `与 alert / sold_daily 走同一账号池(每批随机换号 + 各号专属 IP),无共享 token.json;续签在 AccountPool.ensure_access 里按号独立轮换 refreshToken,由 report_failure 兜底判死` | 描述账号池现状 |
+| `spiders/deca_team_spider.py:47` | `CWD 对齐新根(token.json / application.yml / ./logs 均在此)` | `CWD 对齐新根(application.yml / ./logs / 账号池 DB 均在此)` | 同上 |
+| `spiders/sold_daily_spider.py:17` | `CWD 对齐新根(token.json/application.yml/logs 生效)` | `CWD 对齐新根(application.yml / logs / 账号池 DB 生效)` | 同上 |
+| `spiders/sold_history_spider.py:16` | 同上 | 同上 | 同上 |
+| `reports/onsale_report/deca_on_sale_report.py:27` | `CWD 对齐新根(token.json/application.yml/logs 生效)` | `CWD 对齐新根(application.yml / logs / 账号池 DB 生效)` | 同上 |
+
+### 2.2 README.md
+
+- **第 36 行**:删 `get_token.py` 目录条目(文件已删)。
+- **第 175 条注意事项**:原「`deca_sold_core` 里的单 token 逻辑仅作账号池关闭时的回退保留」改为「`deca_sold_core` 仅暴露 `init_account_pool(...)` 一个开关,采集脚本启动时调用即生效,无其它登录态路径」——消除「回退」暗示。
+- **第 176 条**:原「临时回退单 token」指南整条删除——回退路径已彻底不可用(token.json + get_token.py 已删)。
+
+### 2.3 HANDOFF.md(台账同步现状)
+
+| 行号 | 改动 |
+|---|---|
+| 26 | `⏳ 未做(后续):录号 + 各自 proxy_url、step3、清理 token.json` → `✅ 账号池上线齐备(2026/09/10):… token.json / token.json.lock 已清理下线` |
+| 35 | `核心:签名/do_request/token 管理(现仍是单 token.json 机制)` → `核心:签名/do_request/账号池接入(线程隔离)` |
+| 40 | `共用根 token.json/application.yml/logs` → `共用根 application.yml/logs` |
+| 47 | `用 token,chdir 新根` → `走账号池,chdir 新根` |
+| 49 | `km_spdier.py 卡密采集` 整行删除 |
+| 58 | `token.json (+ .lock) 单份,多采集进程共用(账号池落地后废弃、清理)` 整行删除 |
+| 59 | `get_token.py adb+su 从 App 读 token 灌回` 整行删除 |
+| 68 | `用 token → chdir 到新根,共用根 token.json/application.yml/logs` → `走账号池 → chdir 到新根,共用根 application.yml/logs` |
+| 96 | `人工白天离线补货(App 登录过滑块 + get_token.py 灌回)` → `人工白天补货走短信登录=注册(AccountPool.sms_login)入库,不依赖 get_token.py/ADB` |
+| 116 | `总开关 USE_ACCOUNT_POOL(默认 False 走老 token.json)` → `总开关 USE_ACCOUNT_POOL(默认 False——未登记账号池就发 need_auth 会直接 RuntimeError,账号池为唯一路径)` |
+| 119 | `要临时回退单 token.json 注释该行即可` → `账号池为 need_auth 唯一路径,无单 token 回退` |
+| 121 | `5.【清理】账号池验证 OK 后:清理 token.json…` → `5.✅【清理·2026/09/10】token.json / token.json.lock / get_token.py 已删除下线…` |
+| 173 | `⛔ 问题1/5·不动(卡密依赖)…根 token.json 不能删…问题5 token.json 清理取消` → `✅ 问题1/5·已处理(2026/09/10)卡密 km_spdier 任务已删除…根 token.json / token.json.lock / get_token.py 已删除` |
+
+### 2.4 保留不动(有意)
+
+| 位置 | 内容 | 为何保留 |
+|---|---|---|
+| `deca_sold_core.py:163/212` | 防护文案「已无 token.json 回退」 | 主公明确指示「该留」 |
+| `HANDOFF.md:10-11` | 头等风险段核心警示(旧目录用旧 token.json) | 警示仍然有效,且这是新窗口接手第一眼看到的风险提示 |
+| `HANDOFF.md:69` | 「TOKEN_FILE 写死绝对路径已改回 token.json」历史 bug 修复 | 迁移阶段台账 |
+| `common/account_pool.py:7` / `common/deca_account_ddl.sql:2` | 「把原来集中在单账号 token.json 的登录态,改为多账号入池…」 | 描述**改造背景**的模块头注释,正确 |
+| `common/deca_on_sale_daily_spider.py:22` | 「原供 km_spdier 复用的 token 机制已随 km_spdier 一并移除」 | 描述已发生的清理动作,正确 |
+| `docs/账号池落地_得卡DECA_20260909.md` | 全文「老 token.json 时代 → 账号池」对比描述 | 设计文档的「改造前 → 改造后」历史记录,台账价值 |
+
+## 3. 改动前后对比
+
+### 3.1 `common/deca_sold_core.py` 注释段
+
+**改前**(231 行):
+```python
+# need_auth 请求的取 token 统一走「账号池随机取号 + 续签 + 各号专属 proxy_url」,已无 token.json 回退。
+```
+
+**改后**:
+```python
+# need_auth 请求的 token 统一走账号池(随机取号 + 续签 + 各号专属 proxy_url),账号池为唯一路径。
+```
+
+**改前**(249 行 docstring):
+```python
+need_auth 请求必须先调用本函数登记账号池(否则直接报错,已无 token.json 回退)。仅登记「共享 DB
+```
+
+**改后**:
+```python
+need_auth 请求必须先调用本函数登记账号池(否则直接报错,无其它路径)。仅登记「共享 DB
+```
+
+### 3.2 `spiders/deca_team_spider.py` 登录态段(影响最大的一处)
+
+**改前**:
+```
+- 与 alert / sold_daily 共用同一份根目录 token.json;续签已加跨进程锁防踩踏
+  (deca_sold_core._token_lock,2026/08/20 根治多进程互相踩废 refreshToken 的问题)。
+```
+
+**改后**:
+```
+- 与 alert / sold_daily 走同一账号池(每批随机换号 + 各号专属 IP),无共享 token.json;
+  续签在 AccountPool.ensure_access 里按号独立轮换 refreshToken,由 report_failure 兜底判死。
+```
+
+## 4. 注意事项
+
+- **本次纯注释 / 文档刷新**:未改任何业务逻辑;所有改动通过 `python -m py_compile` 校验,语法零问题。
+- **接手人阅读顺序**:先看 `HANDOFF.md` 顶部「⚠️ 头等风险」段(警示仍然有效),再看「账号池上线齐备(2026/09/10)」段(现状)。
+- **设计文档不动**:`docs/账号池落地_得卡DECA_20260909.md` 是改造完成时的设计快照,记录「老 token.json → 账号池」对比,台账价值大于改写。
+- **不影响上线判断**:本次改动不触及 `core.do_request` / `core.do_get` 的运行时分支——运行时行为完全等同改动前。
+
+---
+
+**日期**:2026/09/10
+**触发**:主公审查定位 5 处过期描述,其中 2 处(163/212 防护文案)保留、3 处(231/236/249 账号池段注释)已更新为正向口径,并顺手把 README/HANDOFF/6 个入口脚本里的同类过期描述一起刷新。

+ 53 - 0
deca_auto_spider/docs/待办_已售报告用户新老客与复购率_20260911.md

@@ -0,0 +1,53 @@
+# 待办:已售报告「用户排行榜」加 新客/老客汇总 + 复购率
+
+> 状态:**待实施**(口径已与主公确认,主公要求后面再改)。确认日期 2026/09/11。
+> 目标文件:`reports/sold_report/daily_report.py`。改前直接读本文档即可。
+
+## 1. 需求
+
+在已售日报的各商家「用户排行榜」sheet 里,加上**新客/老客汇总**和**复购率**,让用户维度数据越来越完整。
+
+## 2. 已确认口径(2026/09/11 与主公确认)
+
+| 项 | 口径 |
+|---|---|
+| **新客定义** | 沿用现有 `is_new`——**本店首单即新客**:业务日窗口起点(昨 13:00)之前,该 `user_id` 在本商家 `deca_buy_record.purchased_at` 无记录 → 当天首次在本商家下单=新客,否则老客 |
+| **复购率** | **回头客率 = 老客占比** = 老客数 ÷ 本期下单总人数 = 1 − 新客占比。与新老统计一体,无需新 SQL |
+| **展示位置** | 各商家「用户排行榜」sheet **顶部加一行汇总**:`新客 X / 老客 Y / 复购率 Z%`;逐行"新"标记保留 |
+| **覆盖商家** | 全部 `REAL_BUYER_MIDS`(魔都/卡皇/尼卡/文泰/魔都体育)——只有这几家采了 `deca_buy_record` |
+
+## 3. 现状(改造起点)
+
+- **`is_new` 已经逐行算好**:`fetch_moddu_user_ranking`(`daily_report.py:512`)每行返回 `是否新用户(bool)`,口径正是"本店首单"。所以顶部汇总只是对现有数据做聚合,**不用写新 SQL**。
+- 渲染函数 `_build_user_ranking_sheet`(`daily_report.py:1316`),表头在 `daily_report.py:1337`:`["排名","用户昵称","user_id","参与车数","参与金额","车均消费","新用户"]`。
+- 调用处 `daily_report.py:1459`(`rank_by_mid[mid] = fetch_moddu_user_ranking(...)`);逐行"新"标记 `show_new_flag` **目前仅魔都开**(`daily_report.py:1498`),新用户整行淡粉底 `FILL_NEWROW=FEF2F2`。
+- 相关常量 `daily_report.py:79-160`:`REAL_BUYER_MIDS`、`MODDU_MID='881226408'`、`WIN_P=[昨13:00, 今06:00]`、`TOP_USERS=None`(当前不截断)、`MERCHANT_SHORT_NAMES`。
+
+## 4. 实现步骤(建议)
+
+1. **让 `fetch_moddu_user_ranking` 额外返回全量新客数**。当前返回 `(result[:top_n], total_users)`;新增第 3 个返回值 `new_users` = **对全量 `rows`(截断前)** 的 `is_new` 求和。
+2. `_build_user_ranking_sheet` 顶部(标题行下、表头行上)插入汇总行:
+   - `新客 = new_users`
+   - `老客 = total_users − new_users`
+   - `复购率 = 老客 / total_users`(`total_users=0` 时显示 `—`,避免除零)
+3. 对全部 `REAL_BUYER_MIDS` 都渲染该汇总行(不再限魔都);逐行"新"标记建议一并对所有真实买家商家打开(数据已具备,展示口径统一)。
+4. sheet 上注明口径与局限(见第 6 节)。
+
+## 5. ⚠️ 陷阱:汇总必须基于全量,不能基于 top_n 截断后的行
+
+`fetch_moddu_user_ranking` 返回的 `result` 是 **`rows[:top_n]` 截断后**的行,`total_users` 才是全窗口买家数。若直接对 `result` 里的 `是否新用户` 求和,**只得前 top_n 的新客数**——一旦将来把 `TOP_USERS` 设成有限值,新老/复购率就会错。
+
+所以新客数必须在**全量 `rows`**(`rows[:top_n]` 之前)上统计。当前 `TOP_USERS=None` 不截断,暂时不会暴露,但务必按全量口径写。
+
+## 6. 数据局限(需在 sheet 注明)
+
+- 各家 `deca_buy_record` **接入时间不同**,历史深度不足会把实际老客**误判为新客**,复购率偏低。
+- 散户购买记录存在**漏采**(见 `moddu_miss_capture_report.py`),进一步压低复购率。
+- `deca_buy_record` **无金额字段**,金额靠 `card_count × 团 unit_price` 估算(随机团为近似)。
+- 周报/月报当前**无**用户排行榜,本次只做日报。
+
+## 7. 验收
+
+- 各 `REAL_BUYER_MIDS` 的用户排行榜 sheet 顶部出现 `新客/老客/复购率` 汇总行,数字与逐行"新"标记一致(新客数 = 全量行中"新"的条数)。
+- `复购率 = 老客数 / 总人数`,边界(0 买家)不报错。
+- 单跑 `python daily_report.py` 或 `run_report_for_date.py <日期>` 生成 Excel 校验。

+ 219 - 0
deca_auto_spider/docs/账号池落地_得卡DECA_20260909.md

@@ -0,0 +1,219 @@
+# 得卡 DECA 账号池落地文档
+
+> 落地日期:2026/09/09 | 运行环境:Python 3.12.10
+> 代码位置:`common/account_pool.py`(账号池)、`common/deca_account_ddl.sql`(建表)、`common/deca_sold_core.py`(采集核心 + step3 接入)
+> 事实台账:`HANDOFF.md`(新窗口接手先读它,进度以其为准)
+
+---
+
+## 1. 背景与目标
+
+原采集把所有登录态集中在**单账号 + 单 `token.json`**:一个得卡账号扛全部带 token 请求(约 4 万/天,几乎全压夜间 20:00~06:00)。一旦这个账号被风控击穿,整条采集链全停(2026/09/07 即因此停摆)。
+
+账号池目标:**多账号入池、按任务随机取号、异常自动切号、把请求量摊平到多账号 + 各账号专属静态 IP**,从根上消除「单账号单点」风险,降低单号风控面。
+
+---
+
+## 2. 整体架构
+
+```
+采集脚本(buy_record/sold/team/onsale_alert/在售报告)
+        │  do_request(need_auth=True)
+        ▼
+deca_sold_core(签名 / 请求 / step3 账号池接入)
+        │  USE_ACCOUNT_POOL=True 时
+        ▼
+AccountPool(common/account_pool.py)
+        │  acquire_random → ensure_access → 该号专属 proxy_url
+        ▼
+deca_account_record 表(多账号:token / 状态 / 专属 IP / 租约)
+```
+
+- **默认关**:`USE_ACCOUNT_POOL=False`,采集走老单 `token.json`;采集入口调 `core.init_account_pool(pool)` 才切到账号池。
+- 每个常驻任务进程持一个 `AccountPool` 实例,进程内单例。
+
+---
+
+## 3. 数据模型:`deca_account_record`
+
+DDL 见 `common/deca_account_ddl.sql`,已在 `100.64.0.25/crawler` 建好(20 列)。要点:
+
+- 自增 `id` 物理主键;`phone` 单独建 `UNIQUE`(业务唯一标识);时间字段固定 `gmt_create_time`/`gmt_modified_time`。
+- 每号独立 `access_token`/`refresh_token`/`token_exp`。
+- `proxy_url`:该号绑定的**专属静态出口 IP**(1 号 1 IP 永久绑定,必须静态独享,勿用隧道)。
+- `status` 三态:`healthy`(可用)/ `cooling`(软风控冷却)/ `dead`(登录态死,需人工补货)。
+- `owner_pid`/`lease_until`:进程独占租约(进程崩溃后租约到期自动可再租,不死占)。
+- `fail_count`/`cooldown_until`/`last_used_at`/`last_success_at`/`last_error`:状态机与排障字段。
+
+---
+
+## 4. 账号供给:短信验证码登录 =「登录即注册」
+
+**得卡无独立注册接口**——新手机号首次短信验证码登录即自动开户。故供给(注册新号 / 补货已死号)统一走短信登录。
+
+接口(抓包 `抓包.txt`,签名与 `make_signature` 一致,已核验命中):
+
+| 接口 | 路径 | body | 说明 |
+|---|---|---|---|
+| 发验证码 | `/api/v1/app/auth/sms/send` | `{countryCode, phone}` | `need_auth=False` |
+| 短信登录 | `/api/v1/app/auth/sms/login` | `{countryCode, phone, code}` | `need_auth=False`,返回 accessToken/refreshToken/expiresIn/userId |
+
+`AccountPool` 方法:
+
+- `sms_send(phone)`:发验证码(登录/注册第一步)。
+- `sms_login(phone, code, proxy_url=None, password=None)`:短信登录并入库。新号 `INSERT`、已存在号(补货)`UPDATE`(`ON DUPLICATE KEY`,`proxy_url` 用 `COALESCE` 不覆盖已绑定 IP),成功即置 `healthy`、清租约。
+- `register(phone, code, ...)`:等价 `sms_login`(两步:先 `sms_send` 收码,再带 code 调用)。
+
+> 密码登录 `_login()` 保留作极端兜底,**线上默认不调**(撞阿里云滑块,无人值守过不去)。短信登录不撞滑块,是补货/开户主路径。
+
+---
+
+## 5. 取号策略:每批随机(默认)+ 独占(保留)
+
+- `acquire_random(lease_sec=300)`:从 `healthy` 池 `RAND()` 随机挑一个未被占的号,带「仍空闲」条件 `UPDATE` 认领(防并发抢占),打**短租约**。**每批随机**主路径。
+- `acquire()`:进程级独占(按 `last_used_at` 升序负载均衡),整运行期独占一个号。保留可选。
+- `switch()` / `release()`:换号 / 归还租约。
+
+**为什么每批随机**:把带 token 请求量摊平到所有号,单号日请求量大降、风控面更小。只要**每个号的请求始终走它自己的 `proxy_url`**,「账号↔IP 绑定」不破——随机的是「派哪个号上」,不是「一个号在多 IP 间跳」。
+
+**短租约的作用**:批期间对该号的续期串行化,避免多进程同时选中同号并发踩废「一次性轮换」的 `refreshToken`。
+
+---
+
+## 6. Token 续期与判死(实测)
+
+- `ensure_access(log)`:access 未临期直接返回缓存;临期用该号 `refresh` 续签并回写 DB(access/refresh/token_exp/fail_count)。**线上只 refresh、不自动密码登录**。
+- **续期一次性轮换**:续签成功即发新 refresh、旧的立即作废。多进程共用 token.json 时代靠 `.lock` 串行化;账号池每号独立,靠短租约 + `acquire_random` 抢占避免并发。
+
+### 判死码(2026/09/09 实测,测试号 19521500850 直连)
+
+refresh 失效时服务端返回:
+
+```
+code = 10010
+msg  = 当前账号长期未登录,请重新登录
+```
+
+涵盖「refresh 被轮换 / 过期 / 长期未登录」。写进 `account_pool.DEAD_CODES = {10010}`:
+
+- `ensure_access` 续签命中 `10010` → `report_failure(dead=True)`,直接判 `dead` 摘池(线上不自动登录);
+- 其他未知码 → 按软失败累计(可能限流/网络,不急着判死),达 `MAX_FAIL(3)` 转 `cooling`。
+
+---
+
+## 7. 保活决策:不加主动保活(2026/09/09 定)
+
+- **关键区分**:access 15 分钟过期,用时续签即可;真正决定账号存活的是 refresh 会不会因「长期不用」失效。
+- **依据**:既往经验——sold 任务每天仅跑 1 次(间隔 ~24h)一直能正常续签 → refresh 失效阈值 **> 24h**(很可能数天)。
+- **结论**:随机取号下只要一个号「至少每天被用到一次」就不会饿死;20 号 + 每批随机,单号日内被选中概率高。**不加定时保活**,靠既有容错兜底:**用时续签 + 续签命中 10010 自动判 dead 切号 + 企微通知人工补货**。比定时保活更省更简单。
+
+---
+
+## 8. step3:采集接入(`deca_sold_core` core 侧改造)
+
+把 `need_auth` 请求的取 token 从「单 token.json」切到「账号池」。**2026/09/09 完成并端到端验证通过**(测试号跑通商家列表 code=0)。
+
+新增函数(`deca_sold_core.py`):
+
+- `init_account_pool(pool, task_tag="core", rotate_every=20)`:**启用入口**,采集脚本启动调一次,置 `USE_ACCOUNT_POOL=True`。
+- `rotate_account(log)`:批边界主动随机换号(如每商家开始)。
+- `release_account()`:归还当前号。
+- `_pool_auth_and_proxy(log)`:取「当前号有效 access + 该号专属 proxies」,每 `rotate_every` 个请求自动随机换号,续签失败自动换号重试。
+
+`do_request`/`do_get` 的 `need_auth` 分支:启用后走账号池,请求走该号**专属 `proxy_url`**(为空=直连,IP 填了自动生效);未启用走老 `ensure_token`。
+
+**每批随机的实现**:默认每 `rotate_every=20` 个 `need_auth` 请求自动随机换一次号,**无需改采集脚本**;也可在明确批边界调 `rotate_account()` 强制换。
+
+### 采集脚本如何启用(上线时做)
+
+在各采集入口 `main` 开头加一行:
+
+```python
+core.init_account_pool(pool, task_tag="buy_record:881226408")  # 不加则维持老单 token.json
+```
+
+---
+
+## 9. 长效 IP 选型(2026/09/09 实测)
+
+**核心原则**:对得卡(国内卡片交易平台,用户全在国内),**IP 地理位置(国内)比 IP 类型(住宅 vs 机房)更重要**——异地(尤其海外)IP 是最强风控信号之一。
+
+| 候选 | 地理 | 类型 | 独占性 | 对得卡 |
+|---|---|---|---|---|
+| 123proxy 北美(实测 `76.46.19.42` 美国·辛辛那提·Charter 住宅) | 🔴 美国 | 🟢 住宅 | 独享 | 异地风控高危 + 延迟 3.9s,**不推荐** |
+| 快代理独享版 | 🟢 国内 | 🟡 机房 | 完全独享 | 地理对、类型略可疑,可用 |
+| 快代理共享版 | 🟢 国内 | 🟡 机房 | 与少数客户共用 | 有连坐风险,概率可控 |
+| 理想 | 🟢 国内 | 🟢 住宅/静态 | 独享 | 最优 |
+
+**决策进展**:先买快代理**共享版 1 个月**试(成本低,稳则用、不稳再上独享池)。
+
+- ⚠️ **购买前必须向客服确认「共享版分配的 IP 是固定不变的吗」**——固定才能绑定;动态轮换(≈隧道)不可用。
+- 落地:拿到 IP 后**先只绑测试号 id=1 的 `proxy_url` 跑几天**,观察 ①得卡认不认该 IP ②号会不会判死(10010=连坐信号);稳则扩量。
+- 持续监控信号:**「同一 IP 上多个号集体判死」= 该 IP 被关联**,立即换独享池。
+
+---
+
+## 9.5 自动补货(接码平台爱接码 i-sms.app,框架已落地)
+
+账号池巡检可用号数,不足阈值时**全自动经接码平台注册补充**(登录即注册),无需人工收码。
+
+- **接码平台**:爱接码 `https://www.i-sms.app`,鉴权 `X-API-KEY`(环境变量 `ISMS_API_KEY`)。
+- **客户端**:`common/isms_client.py`(`search_projects`/`get_number`/`get_sms`/`poll_sms`/`release_number`/`get_user_info`)。
+- **补货方法**(`AccountPool`):
+  - `count_healthy()`:统计可用号数。
+  - `register_one_via_isms(isms, project, proxy_url, ...)`:单号全自动注册(接码取号→得卡 `sms_send`→接码 `poll_sms` 收码→得卡 `sms_login` 入库→`release_number`)。
+  - `auto_replenish(target_min, target_max, free_proxy_urls, keyword, ascription, api_key)`:巡检补货,`healthy < target_min` 时补到 `target_max`;每号随机停顿 30~90s 错峰防连坐。
+
+**补货流程**:
+
+```
+search_projects("得卡") → 选项目
+get_number(项目, ascription=2实体卡) → 手机号 + orderId
+[得卡] sms_send(手机号) → 得卡发验证码
+poll_sms(orderId) 轮询(每5s,≤180s) → 收到验证码
+[得卡] sms_login(手机号, 验证码, proxy_url) → 入库 healthy
+release_number(orderId)
+```
+
+**配置(2026/09/09)**:`POOL_TARGET_SIZE=20`(池子目标 20 个账号);`ISMS_ASCRIPTION=None`(卡类型**不限制**,`isms_client` 会剔除 None 参数不发送,由平台按可用号分配);每号随机停顿 30~90s 错峰,避免一批新号被批量连坐识别。
+
+**API Key**:`ISMS_API_KEY` 常量写在 `common/account_pool.py`(主公定放代码,非 git 仓库);敏感,勿外传/勿提交公开仓库。
+
+**硬约束**:自动注册能出无限号,但**每号绑 1 个 IP,池子规模上限 = 买的 IP 数**(20 账号≈20 个 IP,或自己独享 IP 下 1 IP : 2~3 号低密度共用)。`auto_replenish` 的 `free_proxy_urls` 用尽即停。
+
+**已实测确认(2026/09/09)**:得卡接码项目关键词 `ISMS_PROJECT_KEYWORD="优豪卡"`→项目「优豪卡科技」(`project_id=119481`);Key 有效、余额=3(补号需充值);`isms_client` 必带浏览器 UA(否则被爱接码 WAF 拦 403,已内置)。
+
+**待资源才能真跑**:空闲专属静态 IP 列表(快代理共享版+静态型 20 IP,¥1000/月)填 `free_proxy_urls`。接口端点/参数码表见项目 memory `isms-api-reference`。
+
+---
+
+## 10. 实测记录汇总(2026/09/09)
+
+- 短信登录接口签名 `send`/`login` 两样本哈希与 `make_signature` 完全命中。
+- 短信登录=注册端到端跑通:`sms_send`→`sms_login`(入库)→`acquire_random`→`ensure_access`(续签 + refresh 正确轮换 + 回写 DB)→`release`。
+- 判死码 `10010` 实测确认(refresh 失效返回)。
+- step3 端到端跑通:启用账号池后 `do_request(need_auth=True)` 取商家列表,code=0、10 条。
+- IP:123proxy 北美 IP 连通得卡但异地风控高危/延迟高,弃用。**快代理共享版+静态型 20 个国内 IP**实测得卡认(洛阳 IP 发 need_auth 请求 code=0)。
+- **自动补货全链路试点成功**:接码取号 `17047146344` → 得卡发码 → 接码收到验证码短信 → `sms_login` 注册入库 + 绑 IP。接码「优豪卡」号确实能收得卡短信。
+- 配置集中 `common/settings.py`;一键补号脚本 `spiders/replenish_accounts.py`(`--target`/`--batch`);`auto_replenish` 带连续失败熔断(5 次)。
+- 账号池运维:测试号 19521500850 为主公自用(不进池);表 `TRUNCATE` 重置后 17047146344 为 id=1。
+
+---
+
+## 11. 上线步骤(TODO,按顺序)
+
+1. **买 IP**:快代理共享版 1 个月(先确认固定 IP)。
+2. **绑测试号验证**:把 IP 填进 `deca_account_record` id=1 的 `proxy_url`,跑几天观察得卡认不认、是否判死。
+3. **录号**:验证 OK 后,提供真实号,逐个 `sms_send` + 收码 + `sms_login` 入库 + 绑各自 `proxy_url`(1 号 1 IP,或自己独享 IP 低密度多号 1:2~3)。
+4. **启用采集**:各采集入口加 `core.init_account_pool(pool, task_tag=...)`;小流量观察。
+5. **购买记录分片**:`buy_record_spider` 支持按商家分片 + 错相位(待办)。
+6. **清理**:账号池验证 OK 后清理 `token.json`/`token.json.lock` 及 core 里基于 CWD 读 token.json 的逻辑。
+
+---
+
+## 12. 注意事项 / 踩坑
+
+- **跨目录同账号踩废 token**:账号池落地前,新目录脚本只做 `py_compile`,不真跑真实生产账号的续期——否则会作废旧目录任务正在用的 refresh(得卡 refresh 一次性轮换)。测试号 19521500850 是独立号,用它真跑与旧目录零冲突。
+- **proxy_url 必须静态独享**:隧道代理每次换 IP,破坏账号↔IP 绑定,等于白搭。
+- **接码平台自动补货框架已落地**(见 9.5):既定方向是全自动注册补货。风控上务必用**实体卡**、错峰注册;受 IP 数量硬约束。主力可先用真实号跑通,资源齐了再启用巡检补货。
+- **共享 IP 稳定性会漂移**:共用者变化会改变 IP 干净度,「今天稳」不代表长期稳,需持续监控集体判死信号。

+ 671 - 0
deca_auto_spider/mysql_pool.py

@@ -0,0 +1,671 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/3/25 14:14
+import re
+import pymysql
+import YamlLoader
+from loguru import logger
+from dbutils.pooled_db import PooledDB
+
+# 获取yaml配置
+yaml = YamlLoader.readYaml()
+mysqlYaml = yaml.get("mysql")
+sql_host = mysqlYaml.getValueAsString("host")
+sql_port = mysqlYaml.getValueAsInt("port")
+sql_user = mysqlYaml.getValueAsString("username")
+sql_password = mysqlYaml.getValueAsString("password")
+sql_db = mysqlYaml.getValueAsString("db")
+
+
+class MySQLConnectionPool:
+    """
+    MySQL连接池
+    """
+
+    def __init__(self, mincached=1, maxcached=2, maxconnections=3, log=None):
+        """
+        初始化连接池
+        :param mincached: 初始化时,链接池中至少创建的链接,0表示不创建
+        :param maxcached: 池中空闲连接的最大数目(0 或 None 表示池大小不受限制)
+        :param maxconnections: 允许的最大连接数(0 或 None 表示任意数量的连接)
+        :param log: 自定义日志记录器
+        """
+        # 使用 loguru 的 logger,如果传入了其他 logger,则使用传入的 logger
+        self.log = log or logger
+        self.pool = PooledDB(
+            creator=pymysql,
+            mincached=mincached,
+            maxcached=maxcached,
+            maxconnections=maxconnections,
+            blocking=True,  # 连接池中如果没有可用连接后,是否阻塞等待。True,等待;False,不等待然后报错
+            host=sql_host,
+            port=sql_port,
+            user=sql_user,
+            password=sql_password,
+            database=sql_db,
+            ping=2,  # 每次执行前检查连接有效性,防止使用已断开的连接
+            connect_timeout=5,  # 连接超时时间(秒)
+            # read_timeout=30,  # 读取超时时间(秒)
+            write_timeout=30  # 写入超时时间(秒)
+        )
+
+    # def _execute(self, query, args=None, commit=False):
+    #     """
+    #     执行SQL
+    #     :param query: SQL语句
+    #     :param args: SQL参数
+    #     :param commit: 是否提交事务
+    #     :return: 查询结果
+    #     """
+    #     try:
+    #         with self.pool.connection() as conn:
+    #             with conn.cursor() as cursor:
+    #                 cursor.execute(query, args)
+    #                 if commit:
+    #                     conn.commit()
+    #                 self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+    #                 return cursor
+    #     except Exception as e:
+    #         if commit and conn:
+    #             conn.rollback()
+    #         self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+    #         raise e
+
+    def _execute(self, query, args=None, commit=False):
+        """
+        执行SQL(带断连重试)
+        :param query: SQL语句
+        :param args: SQL参数
+        :param commit: 是否提交事务
+        :return: 查询结果
+        """
+        conn = None
+        for attempt in range(2):  # 最多重试1次
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.execute(query, args)
+                        if commit:
+                            conn.commit()
+                        self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+                        return cursor
+            except pymysql.err.InterfaceError as e:
+                # 连接已断开,重试一次
+                if attempt == 0:
+                    self.log.warning(f"数据库连接断开,正在重试... Error: {e}")
+                    continue
+                self.log.error(f"重试后仍失败: {e}, Query: {query}")
+                raise e
+            except pymysql.err.IntegrityError:
+                # 完整性错误(如重复条目)交由上层处理,避免在此打印完整堆栈污染日志
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise
+            except Exception as e:
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+                raise e
+
+    def select_one(self, query, args=None):
+        """
+        执行查询,返回单个结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchone()
+
+    def select_all(self, query, args=None):
+        """
+        执行查询,返回所有结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchall()
+
+    def insert_one(self, query, args):
+        """
+        执行单条插入语句
+        :param query: 插入语句
+        :param args: 插入参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        cursor = self._execute(query, args, commit=True)
+        return cursor.lastrowid  # 返回插入的ID
+
+    def insert_all(self, query, args_list):
+        """
+        执行批量插入语句,如果失败则逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql insert_all, SQL: {query[:100]}..., Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_all 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                conn.rollback()
+                self.log.warning(f"批量插入遇到重复,开始逐条插入。错误: {e}")
+                rowcount = 0
+                for args in args_list:
+                    try:
+                        self.insert_one(query, args)
+                        rowcount += 1
+                    except pymysql.err.IntegrityError as e2:
+                        if "Duplicate entry" in str(e2):
+                            self.log.debug(f"跳过重复条目: {e2}")
+                        else:
+                            self.log.error(f"插入失败: {e2}")
+                    except Exception as e2:
+                        self.log.error(f"插入失败: {e2}")
+                self.log.info(f"逐条插入完成: {rowcount}/{len(args_list)}条")
+            else:
+                conn.rollback()
+                self.log.exception(f"数据库完整性错误: {e}")
+                raise e
+        except Exception as e:
+            conn.rollback()
+            self.log.exception(f"批量插入失败: {e}")
+            raise e
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def insert_one_or_dict(self, table=None, data=None, query=None, args=None, commit=True, ignore=False):
+        """
+        单条插入(支持字典或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data: 字典数据 {列名: 值}
+        :param query: 直接SQL语句(与data二选一)
+        :param args: SQL参数(query使用时必需)
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 最后插入ID
+        """
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data.keys()])
+            values = ', '.join(['%s'] * len(data))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args = tuple(data.values())
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        try:
+            cursor = self._execute(query, args, commit)
+            self.log.info(f"sql insert_one_or_dict, Table: {table}, Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one_or_dict 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+            return cursor.lastrowid
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                # 重复条目用 warning 简短输出,不打印堆栈
+                self.log.warning(f"插入跳过-重复条目 Table: {table}, {e.args[1] if len(e.args) > 1 else e}")
+                return -1  # 返回 -1 表示重复条目被跳过
+            else:
+                self.log.error(f"数据库完整性错误 Table: {table}, Error: {e}")
+                raise
+        except Exception as e:
+            self.log.error(f"insert_one_or_dict 失败 Table: {table}, Error: {e}")
+            raise
+
+    def insert_many(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                    ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                # 处理唯一索引冲突
+                if "Duplicate entry" in str(e):
+                    if ignore:
+                        # 如果使用了 INSERT IGNORE,理论上不会进这里,但以防万一
+                        self.log.warning(f"批量插入遇到重复条目(ignore模式): {e}")
+                    else:
+                        # 没有使用 IGNORE,降级为逐条插入
+                        self.log.warning(f"批量插入遇到重复条目,开始逐条插入。错误: {e}")
+                        if commit:
+                            conn.rollback()
+                        
+                        rowcount = 0
+                        for j, args in enumerate(batch):
+                            try:
+                                if data_list:
+                                    # 字典模式
+                                    self.insert_one_or_dict(
+                                        table=table,
+                                        data=dict(zip(data_list[0].keys(), args)),
+                                        commit=commit,
+                                        ignore=False  # 单条插入时手动捕获重复
+                                    )
+                                else:
+                                    # 原始SQL模式
+                                    self.insert_one(query, args)
+                                rowcount += 1
+                            except pymysql.err.IntegrityError as e2:
+                                if "Duplicate entry" in str(e2):
+                                    self.log.debug(f"跳过重复条目[{i+j+1}]: {e2}")
+                                else:
+                                    self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                            except Exception as e2:
+                                self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                        total += rowcount
+                        self.log.info(f"批次逐条插入完成: 成功{rowcount}/{len(batch)}条")
+                else:
+                    # 其他完整性错误
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                # 其他数据库错误
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        if table:
+            self.log.info(f"sql insert_many, Table: {table}, Total Rows: {total}")
+        else:
+            self.log.info(f"sql insert_many, Query: {query}, Total Rows: {total}")
+        return total
+
+    def insert_many_two(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                        ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL) - 备用方法
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用INSERT IGNORE
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+    
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                if "Duplicate entry" in str(e) and not ignore:
+                    self.log.warning(f"批量插入遇到重复,降级为逐条插入: {e}")
+                    if commit:
+                        conn.rollback()
+                    rowcount = 0
+                    for args in batch:
+                        try:
+                            self.insert_one(query, args)
+                            rowcount += 1
+                        except pymysql.err.IntegrityError as e2:
+                            if "Duplicate entry" in str(e2):
+                                self.log.debug(f"跳过重复条目: {e2}")
+                            else:
+                                self.log.error(f"插入失败: {e2}")
+                        except Exception as e2:
+                            self.log.error(f"插入失败: {e2}")
+                    total += rowcount
+                else:
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        self.log.info(f"sql insert_many_two, Table: {table}, Total Rows: {total}")
+        return total
+
+    def insert_too_many(self, query, args_list, batch_size=1000):
+        """
+        执行批量插入语句,分片提交, 单次插入大于十万+时可用, 如果失败则降级为逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        :param batch_size: 每次插入的条数
+        """
+        self.log.info(f"sql insert_too_many, Query: {query}, Total Rows: {len(args_list)}")
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        conn.commit()
+                        self.log.debug(f"insert_too_many -> Total Rows: {len(batch)}")
+            except Exception as e:
+                self.log.error(f"insert_too_many error. Trying single insert. Error: {e}")
+                # 当前批次降级为单条插入
+                for args in batch:
+                    self.insert_one(query, args)
+
+    def update_one(self, query, args):
+        """
+        执行单条更新语句
+        :param query: 更新语句
+        :param args: 更新参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        return self._execute(query, args, commit=True)
+
+    def update_all(self, query, args_list):
+        """
+        执行批量更新语句,如果失败则逐条更新
+        :param query: 更新语句
+        :param args_list: 更新参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql update_all, SQL: {query}, Rows: {len(args_list)}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_all 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except Exception as e:
+            conn.rollback()
+            self.log.error(f"Error executing query: {e}")
+            # 如果批量更新失败,则逐条更新
+            rowcount = 0
+            for args in args_list:
+                self.update_one(query, args)
+                rowcount += 1
+            self.log.debug(f'Batch update failed. Updated {rowcount} rows individually.')
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def update_one_or_dict(self, table=None, data=None, condition=None, query=None, args=None, commit=True):
+        """
+        单条更新(支持字典或原始SQL)
+        :param table: 表名(字典模式必需)
+        :param data: 字典数据 {列名: 值}(与 query 二选一)
+        :param condition: 更新条件,支持以下格式:
+            - 字典: {"id": 1} → "WHERE id = %s"
+            - 字符串: "id = 1" → "WHERE id = 1"(需自行确保安全)
+            - 元组: ("id = %s", [1]) → "WHERE id = %s"(参数化查询)
+        :param query: 直接SQL语句(与 data 二选一)
+        :param args: SQL参数(query 模式下必需)
+        :param commit: 是否自动提交
+        :return: 影响行数
+        :raises: ValueError 参数校验失败时抛出
+        """
+        # 参数校验
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+            if table is None:
+                raise ValueError("Table name is required for dictionary update")
+            if condition is None:
+                raise ValueError("Condition is required for dictionary update")
+
+            # 构建 SET 子句
+            set_clause = ", ".join([f"{self._safe_identifier(k)} = %s" for k in data.keys()])
+            set_values = list(data.values())
+
+            # 解析条件
+            condition_clause, condition_args = self._parse_condition(condition)
+            query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            args = set_values + condition_args
+
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        # 执行更新
+        cursor = self._execute(query, args, commit)
+        # self.log.debug(
+        #     f"Updated table={table}, rows={cursor.rowcount}, query={query[:100]}...",
+        #     extra={"table": table, "rows": cursor.rowcount}
+        # )
+        return cursor.rowcount
+
+    def _parse_condition(self, condition):
+        """
+        解析条件为 (clause, args) 格式
+        :param condition: 字典/字符串/元组
+        :return: (str, list) SQL 子句和参数列表
+        """
+        if isinstance(condition, dict):
+            clause = " AND ".join([f"{self._safe_identifier(k)} = %s" for k in condition.keys()])
+            args = list(condition.values())
+        elif isinstance(condition, str):
+            clause = condition  # 注意:需调用方确保安全
+            args = []
+        elif isinstance(condition, (tuple, list)) and len(condition) == 2:
+            clause, args = condition[0], condition[1]
+            if not isinstance(args, (list, tuple)):
+                args = [args]
+        else:
+            raise ValueError("Condition must be dict/str/(clause, args)")
+        return clause, args
+
+    def update_many(self, table=None, data_list=None, condition_list=None, query=None, args_list=None, batch_size=500,
+                    commit=True):
+        """
+        批量更新(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param condition_list: 条件列表(必须为字典,与data_list等长)
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            if condition_list is None or len(data_list) != len(condition_list):
+                raise ValueError("Condition_list must be provided and match the length of data_list")
+            if not all(isinstance(cond, dict) for cond in condition_list):
+                raise ValueError("All elements in condition_list must be dictionaries")
+
+            # 获取第一个数据项和条件项的键
+            first_data_keys = set(data_list[0].keys())
+            first_cond_keys = set(condition_list[0].keys())
+
+            # 构造基础SQL
+            set_clause = ', '.join([self._safe_identifier(k) + ' = %s' for k in data_list[0].keys()])
+            condition_clause = ' AND '.join([self._safe_identifier(k) + ' = %s' for k in condition_list[0].keys()])
+            base_query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            total = 0
+
+            # 分批次处理
+            for i in range(0, len(data_list), batch_size):
+                batch_data = data_list[i:i + batch_size]
+                batch_conds = condition_list[i:i + batch_size]
+                batch_args = []
+
+                # 检查当前批次的结构是否一致
+                can_batch = True
+                for data, cond in zip(batch_data, batch_conds):
+                    data_keys = set(data.keys())
+                    cond_keys = set(cond.keys())
+                    if data_keys != first_data_keys or cond_keys != first_cond_keys:
+                        can_batch = False
+                        break
+                    batch_args.append(tuple(data.values()) + tuple(cond.values()))
+
+                if not can_batch:
+                    # 结构不一致,转为单条更新
+                    for data, cond in zip(batch_data, batch_conds):
+                        self.update_one_or_dict(table=table, data=data, condition=cond, commit=commit)
+                        total += 1
+                    continue
+
+                # 执行批量更新
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(base_query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args, data, cond in zip(batch_args, batch_data, batch_conds):
+                        try:
+                            self._execute(base_query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Data: {data}, Condition: {cond}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        elif query is not None:
+            # 处理原始SQL和参数列表
+            if args_list is None:
+                raise ValueError("args_list must be provided when using query")
+
+            total = 0
+            for i in range(0, len(args_list), batch_size):
+                batch_args = args_list[i:i + batch_size]
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args in batch_args:
+                        try:
+                            self._execute(query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Args: {args}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        else:
+            raise ValueError("Either data_list or query must be provided")
+
+    def check_pool_health(self):
+        """
+        检查连接池中有效连接数
+
+        # 使用示例
+        # 配置 MySQL 连接池
+        sql_pool = MySQLConnectionPool(log=log)
+        if not sql_pool.check_pool_health():
+            log.error("数据库连接池异常")
+            raise RuntimeError("数据库连接池异常")
+        """
+        try:
+            with self.pool.connection() as conn:
+                conn.ping(reconnect=True)
+                return True
+        except Exception as e:
+            self.log.error(f"Connection pool health check failed: {e}")
+            return False
+
+    def close(self):
+        """
+        关闭连接池,释放所有连接
+        """
+        try:
+            if hasattr(self, 'pool') and self.pool:
+                self.pool.close()
+                self.log.info("数据库连接池已关闭")
+        except Exception as e:
+            self.log.error(f"关闭连接池失败: {e}")
+
+    @staticmethod
+    def _safe_identifier(name):
+        """SQL标识符安全校验"""
+        if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
+            raise ValueError(f"Invalid SQL identifier: {name}")
+        return name
+
+
+if __name__ == '__main__':
+    sql_pool = MySQLConnectionPool()
+    data_dic = {'card_type_id': 111, 'card_type_name': '补充包 继承的意志【OPC-13】', 'card_type_position': 964,
+                'card_id': 5284, 'card_name': '蒙奇·D·路飞', 'card_number': 'OP13-001', 'card_rarity': 'L',
+                'card_img': 'https://source.windoent.com/OnePiecePc/Picture/1757929283612OP13-001.png',
+                'card_life': '4', 'card_attribute': '打', 'card_power': '5000', 'card_attack': '-',
+                'card_color': '红/绿', 'subscript': 4, 'card_features': '超新星/草帽一伙',
+                'card_text_desc': '【咚!!×1】【对方的攻击时】我方处于活跃状态的咚!!不多于5张的场合,可以将我方任意张数的咚!!转为休息状态。每有1张转为休息状态的咚!!,本次战斗中,此领袖或我方最多1张拥有《草帽一伙》特征的角色力量+2000。',
+                'card_offer_type': '补充包 继承的意志【OPC-13】', 'crawler_language': '简中'}
+    sql_pool.insert_one_or_dict(table="one_piece_record", data=data_dic)

+ 809 - 0
deca_auto_spider/reports/onsale_report/deca_on_sale_report.py

@@ -0,0 +1,809 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/02
+"""得卡 DECA 统计报表:从数据库生成一份 Excel(xlsx) + 一张综合图片(PNG)。
+
+产出:
+    - reports/得卡在售每日报告(新增商家/明细)YYYYMMDD_HH时.xlsx  多 Sheet 汇总(文件名带小时,每天 01/09/15/20 四档各留一份、互不覆盖):
+        Sheet 概览 / 今日新增商家 / 其他商家 / 商品明细 / 在售趋势 / 上架时段分布
+        明细字段全量、不截断:商家 / 标题 / 系列 / 规格类型 / 规格详情 / 模式 /
+                                上架时间 / 预计结束时间 / 单价 / 已售数 / 总数 /
+                                已售总价 / 新品
+        2026/08/14 按监测清单新增两 Sheet:
+          · 在售趋势     :今日+前3日 在售商家数/在售拼团数/新增商家数/新增拼团数(每日快照差分)
+          · 上架时段分布 :近 7 日 24h 上架分布(迷你条形图,运营节奏)
+        进度分析不做「固定 10/22 点」双节点:报告每天发 4 次(09/15/20/01),每次即一个时间
+        节点,商品明细「进度」列已是当次实时进度。
+    - reports/deca_report_YYYYMMDD_HHMMSS.png 综合图片(视觉版,一天多次生成互不覆盖)
+
+生成后可选自动发送到企业微信群机器人(见 SEND_WECHAT,依赖 auto_send_wx_msg.py)。
+
+用法:python deca_report.py
+"""
+import os
+import re
+import sys
+# 挂靠新项目根:sys.path 指向 common(auto_send_wx_msg / core / parse_product),CWD 对齐新根(application.yml / logs / 账号池 DB 生效)
+_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+from datetime import datetime, timedelta
+import time
+import matplotlib
+matplotlib.use("Agg")  # 无 GUI,直接保存文件
+import matplotlib.pyplot as plt
+from matplotlib import gridspec
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
+from openpyxl.utils import get_column_letter
+# 2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20,全站免 token 翻页失效,报告改为「发报告前
+# 逐商家 on-sale-list(带 token) 全站对账」拿准在售全量;复用 daily 的解析/落库/下架对账 + core 的带锁 token。
+import deca_sold_core as core
+from deca_on_sale_daily_spider import parse_product, save_products, _sweep_offsale, fill_product_details
+
+logger.remove()
+logger.add("./logs/onsale_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# 中文字体 + 负号正常显示
+plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
+plt.rcParams["axes.unicode_minus"] = False
+
+# 配色
+C_SHOP = "#2563eb"    # 主色-蓝
+C_PROD = "#f59e0b"    # 商品-橙
+C_PROG = "#16a34a"    # 进度-绿
+C_NEW = "#dc2626"     # 今日新增-红(强调)
+C_OTHER = "#0f766e"   # 其他商家-青绿(与新增红对比)
+C_CARD = "#f1f5f9"    # KPI 卡片底
+C_TEXT = "#0f172a"    # 主文字
+C_SUB = "#64748b"     # 次文字
+C_ROW = "#f8fafc"     # 表格隔行底色
+C_NEWROW = "#fef2f2"  # 新品行淡红底
+
+# 企微发送开关(生成 Excel 后自动发到企业微信群机器人;群由 auto_send_wx_msg.WEBHOOK_URL 决定)
+SEND_WECHAT = True
+PRODUCT_LIMIT = 80            # 在售商品明细最多显示的行数(防图过长)
+SHOP_LIMIT = 100              # 商家表(今日新增/其他)最多显示的行数
+# ---- 监测清单增强(2026/08/14):在售趋势 / 上架时段 配置 ----
+TREND_DAYS = 4               # 平台在售趋势展示天数(今日 + 前3日)
+LISTING_HOUR_DAYS = 7        # 上架时段分布回看天数(平台 24h 上架节奏)
+
+# ---- 发报告前全站在售对账(2026/09/05) 配置 ----
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"  # 逐商家在售(需 token)
+ONSALE_PAGE_SIZE = 20        # on-sale-list 每页条数(站方硬限 ≤20)
+ONSALE_MAX_PAGES = 3         # 单商家在售翻页上限(服务端 *-list 硬限最近 3 页;在售普遍远 <60 个,靠 total/末页早停)
+
+
+def f(v, default=0.0):
+    """把可能为 None/Decimal 的值安全转 float。
+
+    Args:
+        v: 原始值(None / Decimal / 数字)。
+        default (float, optional): 空值时的默认。Defaults to 0.0。
+
+    Returns:
+        float: 转换后的浮点数。
+    """
+    return default if v is None else float(v)
+
+
+def _fmt_time(v) -> str:
+    """把时间字段渲染成短字符串;空值显示破折号。
+
+    Args:
+        v: 时间原始值(字符串/时间戳/None/空串)。
+
+    Returns:
+        str: 截断到 16 字符的时间文本,或 "—"。
+    """
+    if v in (None, "", 0):
+        return "—"
+    return str(v)[:16]
+
+
+def _fmt_money(v) -> str:
+    """把金额渲染成短字符串:>=1万显示"X.X万",否则整数。
+
+    Args:
+        v: 金额原始值(None/Decimal/数字)。
+
+    Returns:
+        str: 格式化后的文本;空值返回 "—"。
+    """
+    x = f(v)
+    if x <= 0:
+        return "—"
+    if x >= 10000:
+        return f"{x / 10000:.1f}万"
+    return f"{x:.0f}" if x >= 100 else f"{x:.2f}"
+
+
+def _fmt_progress(sold, total) -> str:
+    """把已售/总数渲染成售出进度百分比字符串(用于 PNG 图)。
+
+    Args:
+        sold: 已售份数(sold_count)。
+        total: 总份数(card_count)。
+
+    Returns:
+        str: 形如 "68.5%" 的进度;总数为 0 或空时返回 "—"。
+    """
+    s = f(sold)
+    t = f(total)
+    if t <= 0:
+        return "—"
+    return f"{s / t * 100:.1f}%"
+
+
+def _fmt_spec(spec_name, series_config) -> str:
+    """把规格组合成一行:`spec_name · series_config`,缺失部分省略。
+
+    Args:
+        spec_name: 顶层规格(原箱/单盒/单包/LOT)。
+        series_config: 结构化规格(几张/包 几包/盒 几盒/箱 共X箱)。
+
+    Returns:
+        str: 组合后的字符串;两者都空返回 "—"。
+    """
+    parts = [p for p in (spec_name, series_config) if p]
+    return " · ".join(parts) if parts else "—"
+
+
+def _fmt_series(series_name, title) -> str:
+    """系列名优先取详情的 series_name(已含 Hobby/Jumbo);缺失时回退到标题正则粗抽。
+
+    Args:
+        series_name: 详情接口 giftInfo.items[0].seriesName。
+        title: 商品完整标题,仅在 series_name 为空时用作兜底。
+
+    Returns:
+        str: 系列文本,截断到 22 字。
+    """
+    if series_name:
+        return series_name[:22]
+    if not title:
+        return "—"
+    # 兜底:粗略抽取 "品牌 系列 版本" 段,避免展示为空
+    import re
+    m = re.search(r"(topps|Panini)\s+([\w \-]+?(?:Hobby|Jumbo|Blaster|Retail))", title, flags=re.I)
+    return (m.group(0) if m else title)[:22]
+
+
+def full_onsale_sweep(log, pool) -> int:
+    """发报告前全站在售对账:遍历库内所有商家,逐家 on-sale-list(带 token) 全量落库 + is_on_sale 下架对账。
+
+    2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效;报告改为发报告前主动扫全站——
+    按 deca_onsale_shop_record 里的商家逐家拉在售(需 token,走直连保持账号↔IP 稳定),parse_product 解析后
+    save_products upsert(is_on_sale=1 自愈),全部采完后用在售 code 并集 _sweep_offsale 把不在售的置 is_on_sale=0,
+    使概览「在售商品总数」及各表口径准确。
+
+    安全:只要有商家本轮抓取失败(网络/风控中断),即跳过下架对账——避免把该商家在售商品误判为下架。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本轮写入/更新的商品数(各商家合计)。
+    """
+    mids = [r[0] for r in (pool.select_all(
+        "SELECT merchant_user_id FROM deca_onsale_shop_record") or []) if r[0]]
+    all_codes = set()
+    saved = 0
+    failed = 0
+    for mid in mids:
+        page = 1
+        total = None
+        merchant_ok = False
+        while page <= ONSALE_MAX_PAGES:
+            body = {"merchantUserId": mid, "page": page, "pageSize": ONSALE_PAGE_SIZE}
+            try:
+                resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+            except Exception as e:
+                log.error(f"商家 {mid} 在售第 {page} 页请求失败: {e}")
+                break
+            if not resp or resp.get("code") != 0:
+                log.info(f"商家 {mid} 在售返回异常: {resp.get('msg') if resp else None}")
+                break
+            data = resp.get("data") or {}
+            if total is None:
+                total = data.get("total")
+            items = data.get("list") or []
+            rows = [r for r in (parse_product(it) for it in items) if r]
+            saved += save_products(log, rows, pool)
+            all_codes.update(r["product_code"] for r in rows)
+            # 本页为空、已采满 total、或不足一页 → 该商家翻到底
+            if not items or (total is not None and page * ONSALE_PAGE_SIZE >= total) or len(items) < ONSALE_PAGE_SIZE:
+                merchant_ok = True
+                break
+            page += 1
+        if not merchant_ok:
+            failed += 1
+    log.info(f"[全站对账] 遍历 {len(mids)} 家,写入/更新 {saved} 个,在售并集 {len(all_codes)} 个,失败 {failed} 家")
+    # 有商家抓取失败则跳过下架对账,避免把失败商家的在售品误置下架
+    if all_codes and failed == 0:
+        n = _sweep_offsale(log, pool, all_codes)
+        log.info(f"[全站对账] 下架对账完成,本轮置下架 {n} 个")
+    else:
+        log.warning(f"[全站对账] 跳过下架对账(失败 {failed} 家 / 在售集合 {len(all_codes)} 空),避免误判下架")
+    return saved
+
+
+def fetch_data(pool) -> dict:
+    """从数据库汇总报表所需数据(KPI + 今日新增商家 + 其他商家 + 各商家在售商品明细)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: 各区块所需数据集合。
+    """
+    d = {}
+    # ---- KPI ----
+    d["total_shops"] = pool.select_one("SELECT COUNT(*) FROM deca_onsale_shop_record")[0]
+    d["total_products"] = pool.select_one(
+        "SELECT COUNT(*) FROM deca_onsale_product_record WHERE is_on_sale=1")[0]
+    d["today_new_shops"] = pool.select_one(
+        "SELECT COUNT(*) FROM deca_onsale_shop_record WHERE DATE(gmt_create_time)=CURDATE()")[0]
+    # 今日新增商品口径:按商品「开售时间 publish_at」判定,而非入库时间 gmt_create_time
+    # 原因:入库时间受采集延迟/故障影响会与真实开售日错位;补抓的老商品也会被误标为今日新增
+    d["today_new_products"] = pool.select_one(
+        "SELECT COUNT(*) FROM deca_onsale_product_record "
+        "WHERE DATE(publish_at)=CURDATE() AND is_on_sale=1")[0]
+    row = pool.select_one(
+        "SELECT SUM(sold_count) FROM deca_onsale_product_daily_record WHERE snapshot_date=CURDATE()")
+    d["today_sold_total"] = int(f(row[0])) if row and row[0] is not None else 0
+
+    # ---- 一、今日新增商家(今天首次入库的商家 + 粉丝数 + 在售数 + 今日新增商品数)----
+    d["new_shop_rows"] = pool.select_all(
+        "SELECT s.merchant_name, s.fans_count, s.active_groupbuy_count, "
+        "  SUM(CASE WHEN DATE(p.publish_at)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
+        "FROM deca_onsale_shop_record s "
+        "LEFT JOIN deca_onsale_product_record p ON p.merchant_user_id = s.merchant_user_id "
+        "WHERE DATE(s.gmt_create_time)=CURDATE() "
+        "GROUP BY s.merchant_user_id, s.merchant_name, s.fans_count, s.active_groupbuy_count "
+        "ORDER BY s.active_groupbuy_count DESC, s.fans_count DESC "
+        "LIMIT %s", (SHOP_LIMIT,)) or []
+
+    # ---- 二、其他商家(非今日新增的存量商家,同格式含粉丝数)----
+    d["other_shop_rows"] = pool.select_all(
+        "SELECT s.merchant_name, s.fans_count, s.active_groupbuy_count, "
+        "  SUM(CASE WHEN DATE(p.publish_at)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
+        "FROM deca_onsale_shop_record s "
+        "LEFT JOIN deca_onsale_product_record p ON p.merchant_user_id = s.merchant_user_id "
+        "WHERE DATE(s.gmt_create_time) != CURDATE() "
+        "GROUP BY s.merchant_user_id, s.merchant_name, s.fans_count, s.active_groupbuy_count "
+        "ORDER BY s.active_groupbuy_count DESC, s.fans_count DESC, today_new DESC "
+        "LIMIT %s", (SHOP_LIMIT,)) or []
+    d["other_shop_total"] = pool.select_one(
+        "SELECT COUNT(*) FROM deca_onsale_shop_record WHERE DATE(gmt_create_time) != CURDATE()")[0]
+
+    # ---- 三、各商家在售商品明细(按商家分组;今日新增商品标记)----
+    # 新增:series_name(系列+版本Hobby/Jumbo)、spec_name(规格 原箱/单盒/LOT)、series_config(几箱几盒几包)、
+    #      play_type_name(模式 自定义随机/随机球队/...)、unit_price(单价);
+    #      已售总价:随机团(选队随机/剩余随机) 用 team_total_amount(2026/08/11 新增)——
+    #      每支球队价格不同,用单一 unit_price×sold_count 会严重失真;具体口径见
+    #      docs/选队随机与剩余随机_总价口径与采集_20260811.md。
+    #      NULL(固定价团 或 数据缺失) 回落到 unit_price×sold_count。
+    # PNG 版受 PRODUCT_LIMIT 截断(防图过长);Excel 版拉全量(product_rows_all,字段完整无截断)
+    prod_sql = (
+        "SELECT merchant_name, title, series_name, spec_name, series_config, play_type_name, "
+        "  unit_price, sold_count, card_count, publish_at, sale_end_at, "
+        "  CASE WHEN DATE(publish_at)=CURDATE() THEN 1 ELSE 0 END AS is_new, "  # 新品口径=开售日 publish_at
+        "  COALESCE(team_total_amount, unit_price * sold_count) AS total_amount "  # 随机团用 team_total_amount,其它回落
+        "FROM deca_onsale_product_record "
+        "WHERE is_on_sale=1 "                       # 只统计当前在售,排除已下架/售罄陈旧项
+        "ORDER BY merchant_name, is_new DESC, product_code")
+    d["product_rows"] = pool.select_all(prod_sql + " LIMIT %s", (PRODUCT_LIMIT,)) or []
+    d["product_rows_all"] = pool.select_all(prod_sql) or []
+    d["product_total"] = pool.select_one(
+        "SELECT COUNT(*) FROM deca_onsale_product_record WHERE is_on_sale=1")[0]
+
+    # ---- 四、监测清单增强:在售趋势(差分) / 上架时段(24h) ----
+    # 注:进度分析不做「固定 10/22 点」双节点——在售报告每天发 4 次(09/15/20/01),每次发送
+    # 本身即一个时间节点,商品明细的「进度」列就是当次发送时刻的实时进度,无需再造人工节点。
+    d["trend"] = fetch_onsale_trend(pool, TREND_DAYS)
+    d["listing_hours"] = fetch_listing_hour_dist(pool, LISTING_HOUR_DAYS)
+    return d
+
+
+def _bar(value: int, max_value: int, width: int = 20) -> str:
+    """把数值渲染成等宽条形字符串(Excel 内迷你直方图)。
+
+    Args:
+        value (int): 当前值。
+        max_value (int): 该组最大值(归一化条长用)。
+        width (int, optional): 满值时的条长(字符数)。Defaults to 20。
+
+    Returns:
+        str: 由 █ 组成的条;max_value<=0 或 value<=0 时返回空串。
+    """
+    if max_value <= 0 or value <= 0:
+        return ""
+    return "█" * max(1, round(value / max_value * width))
+
+
+def fetch_onsale_trend(pool, days: int) -> list:
+    """按每日在售快照统计近 N 天的平台在售趋势(商家数/拼团数/新增商家/新增拼团)。
+
+    在售商家数/在售拼团数取当日快照去重计数;新增数为「当日快照相对前一日的差分」
+    (当日出现、前一日没有的 merchant_user_id / product_code 数),即真正的净新增。
+    源表 deca_onsale_product_daily_record(每日一份在售快照)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        days (int): 展示天数(含今天)。
+
+    Returns:
+        list[dict]: 按日期倒序,每项含 日期/在售商家数/在售拼团数/新增商家数/新增拼团数;
+            最早一天若缺前一日基线,新增列为 None。
+    """
+    # 多取一天做最早展示日的差分基线(WHERE 覆盖到 CURDATE()-days)
+    rows = pool.select_all(
+        "SELECT snapshot_date, merchant_user_id, product_code "
+        "FROM deca_onsale_product_daily_record "
+        "WHERE snapshot_date >= CURDATE() - INTERVAL %s DAY", (days,)) or []
+    day_shops, day_prods = {}, {}
+    for snap_date, mid, code in rows:
+        day_shops.setdefault(snap_date, set()).add(mid)
+        day_prods.setdefault(snap_date, set()).add(code)
+    dates = sorted(day_shops.keys(), reverse=True)   # 新 → 旧
+    result = []
+    for snap_date in dates[:days]:
+        prev = snap_date - timedelta(days=1)          # 前一日基线
+        shops, prods = day_shops[snap_date], day_prods[snap_date]
+        if prev in day_shops:
+            new_shops = len(shops - day_shops[prev])  # 净新增商家 = 当日有、前日无
+            new_prods = len(prods - day_prods[prev])  # 净新增拼团 = 当日有、前日无
+        else:
+            new_shops = new_prods = None              # 无基线,诚实留空
+        result.append({"日期": str(snap_date), "在售商家数": len(shops), "在售拼团数": len(prods),
+                       "新增商家数": new_shops, "新增拼团数": new_prods})
+    return result
+
+
+def fetch_listing_hour_dist(pool, days: int) -> list:
+    """统计近 N 天上架(publish_at)按小时的 24 桶分布,反映平台 24h 上架节奏。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        days (int): 回看天数。
+
+    Returns:
+        list[int]: 长度 24,索引=小时(0~23),值=该小时上架的商品数。
+    """
+    since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
+    # publish_at 为 'YYYY-MM-DD HH:MM:SS' 字符串,字典序比较等价于时间比较;HOUR() 可直接解析
+    sql = ("SELECT HOUR(publish_at) h, COUNT(*) c FROM deca_onsale_product_record "
+           "WHERE publish_at IS NOT NULL AND publish_at <> '' AND publish_at >= %s "
+           "GROUP BY h")
+    dist = [0] * 24
+    for h, c in pool.select_all(sql, (since,)) or []:
+        if h is not None and 0 <= int(h) < 24:
+            dist[int(h)] = int(c)
+    return dist
+
+
+def _kpi_card(ax, title: str, value, color: str):
+    """在指定子图里画一个 KPI 卡片(大数字 + 标题)。
+
+    Args:
+        ax: matplotlib 子图。
+        title (str): 卡片标题。
+        value: 展示的数值。
+        color (str): 数字颜色。
+    """
+    ax.axis("off")
+    ax.add_patch(plt.Rectangle((0.03, 0.08), 0.94, 0.84, transform=ax.transAxes,
+                               facecolor=C_CARD, edgecolor="none", zorder=0))
+    ax.text(0.5, 0.60, str(value), transform=ax.transAxes, ha="center", va="center",
+            fontsize=28, fontweight="bold", color=color)
+    ax.text(0.5, 0.24, title, transform=ax.transAxes, ha="center", va="center",
+            fontsize=11, color=C_SUB)
+
+
+def _table(ax, title, title_color, col_labels, col_widths, cell_text,
+           left_cols=(0,), new_flags=None):
+    """在子图里画一张表(表头着色、隔行底色、指定列左对齐、新品行高亮)。
+
+    Args:
+        ax: matplotlib 子图。
+        title (str): 表标题。
+        title_color (str): 标题与表头底色。
+        col_labels (list[str]): 列名。
+        col_widths (list[float]): 各列宽(和为 1)。
+        cell_text (list[list[str]]): 单元格文本二维数组。
+        left_cols (tuple[int], optional): 需要左对齐的列下标。Defaults to (0,)。
+        new_flags (list[bool], optional): 每行是否为"今日新增",True 则整行淡红底。Defaults to None。
+    """
+    ax.axis("off")
+    ax.set_title(title, fontsize=15, fontweight="bold", color=title_color, loc="left", pad=10)
+    if not cell_text:
+        ax.text(0.01, 0.5, "暂无数据", transform=ax.transAxes, fontsize=12, color=C_SUB)
+        return
+    tbl = ax.table(cellText=cell_text, colLabels=col_labels, colWidths=col_widths,
+                   cellLoc="center", loc="upper center")
+    tbl.auto_set_font_size(False)
+    tbl.set_fontsize(10.5)
+    tbl.scale(1, 1.5)
+    ncol = len(col_labels)
+    # 表头
+    for j in range(ncol):
+        c = tbl[0, j]
+        c.set_facecolor(title_color)
+        c.set_text_props(color="white", fontweight="bold")
+        c.set_height(c.get_height() * 1.1)
+    # 数据行
+    for i in range(1, len(cell_text) + 1):
+        is_new = bool(new_flags[i - 1]) if new_flags else False
+        for j in range(ncol):
+            cell = tbl[i, j]
+            if is_new:
+                cell.set_facecolor(C_NEWROW)
+            elif i % 2 == 0:
+                cell.set_facecolor(C_ROW)
+            if j in left_cols:
+                cell.set_text_props(ha="left")
+                cell.PAD = 0.03
+            # 新品列(最后一列)红字加粗
+            if new_flags and j == ncol - 1 and cell_text[i - 1][j]:
+                cell.set_text_props(color=C_NEW, fontweight="bold")
+
+
+def _shop_cells(rows: list) -> list:
+    """把商家汇总行渲染成表格单元格文本(列:商家 / 粉丝数 / 在售商品数 / 今日新增商品)。
+
+    Args:
+        rows (list): [(merchant_name, fans_count, active_groupbuy_count, today_new_products)] 列表。
+
+    Returns:
+        list[list[str]]: 单元格二维文本。
+    """
+    return [[(r[0] or "-")[:18], str(int(f(r[1]))), str(int(f(r[2]))), str(int(f(r[3])))]
+            for r in rows]
+
+
+def build_report(d: dict, out_path: str):
+    """根据数据渲染报表图并保存(KPI + 今日新增 + 其他商家 + 各商家在售明细)。
+
+    Args:
+        d (dict): fetch_data 返回的数据集合。
+        out_path (str): 输出 PNG 路径。
+    """
+    # 一、今日新增商家
+    new_cells = _shop_cells(d["new_shop_rows"])
+    # 二、其他商家
+    other_cells = _shop_cells(d["other_shop_rows"])
+    # 三、在售商品明细(按商家分组,同商家仅首行显示名)
+    # 每行 11 列:商家/系列/规格/模式/预计结束时间/单价/已售数/总数/进度/已售总价/新品
+    # 进度 = 已售数 ÷ 总数(售出百分比)
+    prod_cells, new_flags, last_name = [], [], None
+    for r in d["product_rows"]:
+        (name, title, series_name, spec_name, series_config, play_type_name,
+         unit_price, sold, card, pub, end, is_new, total_amount) = r
+        show_name = "" if name == last_name else (name or "-")[:12]
+        last_name = name
+        sold_i = int(f(sold))
+        card_i = int(f(card))
+        # 已售总价:随机团用 SQL 里 COALESCE 好的 team_total_amount,其它回落 unit_price×sold_count
+        total = f(total_amount)
+        prod_cells.append([
+            show_name,
+            _fmt_series(series_name, title),
+            _fmt_spec(spec_name, series_config)[:26],
+            (play_type_name or "—")[:12],
+            _fmt_time(end),
+            f"¥{f(unit_price):.2f}" if f(unit_price) > 0 else "—",
+            str(sold_i),
+            str(card_i) if card_i else "—",
+            _fmt_progress(sold_i, card_i),   # 进度 = 已售数 ÷ 总数
+            _fmt_money(total),
+            "新" if is_new else "",
+        ])
+        new_flags.append(bool(is_new))
+
+    n_new = max(len(new_cells), 1)
+    n_other = max(len(other_cells), 1)
+    n_prod = max(len(prod_cells), 1)
+    fig_h = 4.0 + 0.4 * n_new + 0.4 * n_other + 0.4 * n_prod
+    # 明细表列数从 6 加到 10,图宽加到 14 以保证列不挤
+    fig = plt.figure(figsize=(14, fig_h), facecolor="white")
+    gs = gridspec.GridSpec(5, 5, figure=fig,
+                           height_ratios=[0.8, 0.9,
+                                          0.4 * n_new + 0.5,
+                                          0.4 * n_other + 0.5,
+                                          0.4 * n_prod + 0.5],
+                           hspace=0.32, wspace=0.35)
+
+    # 标题
+    ax_title = fig.add_subplot(gs[0, :])
+    ax_title.axis("off")
+    ax_title.text(0.5, 0.75, "得卡 DECA 商家在售采集统计", ha="center", va="center",
+                  fontsize=22, fontweight="bold", color=C_TEXT)
+    ax_title.text(0.98, 0.18, f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
+                  ha="right", va="center", fontsize=11, color=C_SUB)
+
+    # KPI 行(5 卡片,汇总保留)
+    kpis = [
+        ("商家总数", d["total_shops"], C_SHOP),
+        ("在售商品总数", d["total_products"], C_PROD),
+        ("今日新增商家", d["today_new_shops"], C_NEW),
+        ("今日新增商品", d["today_new_products"], C_NEW),
+        ("今日累计已售(份)", d["today_sold_total"], C_PROG),
+    ]
+    for i, (t, v, c) in enumerate(kpis):
+        _kpi_card(fig.add_subplot(gs[1, i]), t, v, c)
+
+    # 一、今日新增商家
+    _table(fig.add_subplot(gs[2, :]),
+           f"一、今日新增商家({d['today_new_shops']} 家)", C_NEW,
+           ["商家", "粉丝数", "在售商品数", "今日新增商品"], [0.40, 0.20, 0.20, 0.20],
+           new_cells, left_cols=(0,))
+
+    # 二、其他商家(存量,非今日新增)
+    o_total, o_shown = d["other_shop_total"], len(other_cells)
+    o_suffix = f"(共 {o_total} 家,显示前 {o_shown})" if o_total > o_shown else f"(共 {o_total} 家)"
+    _table(fig.add_subplot(gs[3, :]),
+           f"二、其他商家{o_suffix}", C_OTHER,
+           ["商家", "粉丝数", "在售商品数", "今日新增商品"], [0.40, 0.20, 0.20, 0.20],
+           other_cells, left_cols=(0,))
+
+    # 三、各商家在售商品明细(按商家分组,今日新上架标🆕)
+    # 列:商家 / 系列(含Hobby/Jumbo) / 规格(原箱·几箱几盒几包) / 模式(playTypeName) /
+    #     预计结束时间(sale_end_at) / 单价 / 已售数(sold_count) / 总数(card_count) /
+    #     进度(已售数÷总数) / 已售总价(单价×已售数) / 新品
+    p_total, p_shown = d["product_total"], len(prod_cells)
+    p_suffix = f"(在售商品共 {p_total},显示前 {p_shown})" if p_total > p_shown else f"(在售商品 {p_total})"
+    _table(fig.add_subplot(gs[4, :]),
+           f"三、各商家在售商品明细{p_suffix}", C_SHOP,
+           ["商家", "系列", "规格", "模式", "预计结束时间", "单价", "已售数", "总数", "进度", "已售总价", "新"],
+           [0.09, 0.17, 0.15, 0.09, 0.11, 0.07, 0.07, 0.06, 0.06, 0.09, 0.04],
+           prod_cells, left_cols=(0, 1, 2, 3), new_flags=new_flags)
+
+    fig.savefig(out_path, dpi=150, bbox_inches="tight", facecolor="white")
+    plt.close(fig)
+
+
+# ==================== Excel 产出 ====================
+# 配色与已售报告(stats/daily_report.py)统一:浅蓝底 + 深蓝表头字、淡蓝灰斑马纹、浅灰细边框。
+# 2026/08/14 起各 Sheet 表头不再用蓝/红/青绿区分底色,统一改为已售同款(主公要求两报告风格一致)。
+XL_HEADER_FILL = PatternFill("solid", fgColor="D9E1F2")     # 表头:浅蓝底(同已售 FILL_HEADER)
+XL_HEADER_FONT = Font(name="Microsoft YaHei", bold=True, color="1F3864")  # 表头:深蓝字(同已售 FONT_HEADER)
+XL_CELL_FONT = Font(name="Microsoft YaHei", size=10)        # 正文
+XL_NEWROW_FILL = PatternFill("solid", fgColor="FEF2F2")     # 新品行淡红底(功能高亮,保留)
+XL_ZEBRA_FILL = PatternFill("solid", fgColor="F5F8FC")      # 隔行淡蓝灰(同已售 FILL_ZEBRA)
+XL_THIN_BORDER = Border(left=Side(style="thin", color="D6DCE5"),
+                        right=Side(style="thin", color="D6DCE5"),
+                        top=Side(style="thin", color="D6DCE5"),
+                        bottom=Side(style="thin", color="D6DCE5"))
+
+
+def _write_sheet(ws, headers, rows, new_flags=None,
+                 num_cols=None, money_cols=None, pct_cols=None):
+    """把一份表格数据(表头+多行)写入 Sheet,套统一样式(与已售报告一致)。
+
+    Args:
+        ws: openpyxl 的 worksheet 对象。
+        headers (list[str]): 表头列名。
+        rows (list[list]): 数据行,每行长度应与 headers 一致。
+        new_flags (list[bool], optional): 每行是否为"今日新增",True 则整行淡红底。Defaults to None。
+        num_cols (set[int], optional): 需要按整数右对齐显示的列下标(0-based)。Defaults to None。
+        money_cols (set[int], optional): 需要按金额格式(¥#,##0.00) 显示的列下标(0-based)。Defaults to None。
+        pct_cols (set[int], optional): 需要按百分比格式(0.0%) 显示的列下标(0-based),值存占比小数如 0.685。Defaults to None。
+    """
+    num_cols = num_cols or set()
+    money_cols = money_cols or set()
+    pct_cols = pct_cols or set()
+
+    # 1) 写表头(浅蓝底 + 深蓝字,居中换行;与已售报告统一)
+    ws.append(headers)
+    for c in ws[1]:
+        c.font = XL_HEADER_FONT
+        c.fill = XL_HEADER_FILL
+        c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+        c.border = XL_THIN_BORDER
+    ws.row_dimensions[1].height = 26
+
+    # 2) 写数据 + 隔行/新品行样式 + 数字/金额格式
+    for i, row in enumerate(rows, start=2):
+        ws.append(row)
+        is_new = bool(new_flags[i - 2]) if new_flags else False
+        fill = XL_NEWROW_FILL if is_new else (XL_ZEBRA_FILL if i % 2 == 0 else None)
+        for j, cell in enumerate(ws[i]):
+            cell.font = XL_CELL_FONT
+            cell.border = XL_THIN_BORDER
+            if fill:
+                cell.fill = fill
+            if j in money_cols:
+                cell.number_format = "¥#,##0.00"
+                cell.alignment = Alignment(horizontal="right", vertical="center")
+            elif j in pct_cols:
+                cell.number_format = "0.0%"     # 存占比小数(0.685),显示为 68.5%
+                cell.alignment = Alignment(horizontal="right", vertical="center")
+            elif j in num_cols:
+                cell.number_format = "#,##0"
+                cell.alignment = Alignment(horizontal="right", vertical="center")
+            else:
+                cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=False)
+
+    # 3) 列宽:按内容估算,中文/表头都算宽度;上下限 [8, 60]
+    for j, header in enumerate(headers, start=1):
+        max_len = len(str(header)) * 2  # 表头基准(中文占 2)
+        for r in ws.iter_rows(min_row=2, min_col=j, max_col=j):
+            v = r[0].value
+            if v is None:
+                continue
+            s = str(v)
+            # 中文按 2 宽度,其他按 1
+            w = sum(2 if ord(ch) > 127 else 1 for ch in s)
+            if w > max_len:
+                max_len = w
+        ws.column_dimensions[get_column_letter(j)].width = max(8, min(60, max_len + 2))
+
+    # 4) 冻结表头
+    ws.freeze_panes = "A2"
+
+
+def build_excel(d: dict, out_path: str):
+    """把报表所有数据集合到一个 xlsx 文件(多 Sheet),字段完整不截断。
+
+    Sheet 结构:
+        1. 概览          :KPI 5 项 + 生成时间
+        2. 今日新增商家  :今天入库的商家(商家/粉丝数/在售商品数/今日新增商品)
+        3. 其他商家      :存量商家(同上格式)
+        4. 商品明细      :全量在售商品;14 列全字段(含标题、上架时间、规格详情、模式、进度、总价等)
+        5. 在售趋势      :今日+前3日 在售商家数/在售拼团数/新增商家数/新增拼团数(每日快照差分)
+        6. 上架时段分布  :近 7 日 24h 上架分布(含迷你条形图)
+
+    Args:
+        d (dict): fetch_data 返回的数据集合(含 product_rows_all 全量明细、trend/listing_hours
+            等增强区块数据)。
+        out_path (str): 输出 xlsx 文件路径。
+    """
+    wb = Workbook()
+
+    # Sheet 1:概览
+    ws1 = wb.active
+    ws1.title = "概览"
+    overview_rows = [
+        ["商家总数", int(f(d["total_shops"]))],
+        ["在售商品总数", int(f(d["total_products"]))],
+        ["今日新增商家", int(f(d["today_new_shops"]))],
+        ["今日新增商品", int(f(d["today_new_products"]))],
+        ["今日累计已售(份)", int(f(d["today_sold_total"]))],
+        ["生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")],
+    ]
+    _write_sheet(ws1, ["指标", "数值"], overview_rows, num_cols={1})
+
+    # Sheet 2:今日新增商家
+    ws2 = wb.create_sheet("今日新增商家")
+    new_rows = [[r[0] or "-", int(f(r[1])), int(f(r[2])), int(f(r[3]))] for r in d["new_shop_rows"]]
+    _write_sheet(ws2, ["商家", "粉丝数", "在售商品数", "今日新增商品"], new_rows,
+                 num_cols={1, 2, 3})
+
+    # Sheet 3:其他商家
+    ws3 = wb.create_sheet("其他商家")
+    other_rows = [[r[0] or "-", int(f(r[1])), int(f(r[2])), int(f(r[3]))] for r in d["other_shop_rows"]]
+    _write_sheet(ws3, ["商家", "粉丝数", "在售商品数", "今日新增商品"], other_rows,
+                 num_cols={1, 2, 3})
+
+    # Sheet 4:商品明细(全量、字段不截断)
+    ws4 = wb.create_sheet("商品明细")
+    prod_headers = ["商家", "标题", "系列", "规格类型", "规格详情", "模式",
+                    "上架时间", "预计结束时间", "单价", "已售数", "总数", "进度", "已售总价", "新品"]
+    prod_rows, new_flags = [], []
+    for r in d["product_rows_all"]:
+        (name, title, series_name, spec_name, series_config, play_type_name,
+         unit_price, sold, card, pub, end, is_new, total_amount) = r
+        sold_i = int(f(sold))
+        card_i = int(f(card))
+        unit = f(unit_price)
+        # 已售总价:随机团用 SQL 里 COALESCE 好的 team_total_amount,其它回落 unit_price×sold_count
+        total = f(total_amount)
+        progress = (sold_i / card_i) if card_i else None   # 进度 = 已售数 ÷ 总数(占比,Excel 用 0.0% 显示)
+        prod_rows.append([
+            name or "-", title or "-", series_name or "-",
+            spec_name or "-", series_config or "-", play_type_name or "-",
+            _fmt_time(pub), _fmt_time(end),
+            unit if unit > 0 else None,
+            sold_i, card_i,
+            progress,
+            total if total > 0 else None,
+            "新" if is_new else "",
+        ])
+        new_flags.append(bool(is_new))
+    _write_sheet(ws4, prod_headers, prod_rows,
+                 new_flags=new_flags,
+                 num_cols={9, 10},     # 已售数、总数
+                 pct_cols={11},        # 进度(已售数÷总数)
+                 money_cols={8, 12})   # 单价、已售总价
+
+    # Sheet 5:平台在售趋势(今日+前3日,按每日快照差分)—— 监测清单「平台层面」
+    ws5 = wb.create_sheet("在售趋势")
+    trend_rows = [[t["日期"], t["在售商家数"], t["在售拼团数"], t["新增商家数"], t["新增拼团数"]]
+                  for t in d["trend"]]
+    _write_sheet(ws5, ["日期", "在售商家数", "在售拼团数", "新增商家数(差分)", "新增拼团数(差分)"],
+                 trend_rows, num_cols={1, 2, 3, 4})
+
+    # Sheet 6:上架时段分布(近 N 日 24h 累计)—— 监测清单「运营节奏·上架时段热图」
+    ws6 = wb.create_sheet("上架时段分布")
+    lhours = d["listing_hours"]
+    lmax = max(lhours) if lhours else 0
+    lrows = [[f"{h:02d}时", lhours[h], _bar(lhours[h], lmax)] for h in range(24)]
+    _write_sheet(ws6, ["时段", "上架数", f"分布(近{LISTING_HOUR_DAYS}日累计)"], lrows,
+                 num_cols={1})
+
+    wb.save(out_path)
+
+
+def main() -> str:
+    """连库取数、生成 Excel 报表 + PNG 综合图,并按开关自动发送微信。
+
+    Returns:
+        str: 生成的 xlsx 路径;失败返回空串。
+    """
+    log = logger
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="onsale_report")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    # 发报告前先全站对账:逐商家 on-sale-list(带 token) 落库 + is_on_sale 下架对账,保证在售口径准确
+    # (免 token 的全站 home/search 已被站方砍成首屏 20,不再能翻页拿全量)
+    try:
+        full_onsale_sweep(log, pool)
+        # 回填 publish_at/规格/系列/模式(详情接口免 token)——on-sale-list 不含这些字段,
+        # 而报告「今日新增/新品/明细列」依赖它们;只补 publish_at 或 spec_name 为空的商品,已补过的不重复拉
+        fill_product_details(log, pool)
+    except Exception as e:
+        log.error(f"全站对账/补详情异常(本轮沿用库内现有在售数据继续出报告): {e}")
+    data = fetch_data(pool)
+    _OUT_DIR = os.path.join(_ROOT, "output", "onsale")  # 新目录输出统一落 output/onsale(避开代码 reports/ 目录)
+    os.makedirs(_OUT_DIR, exist_ok=True)
+
+    # 1) Excel 主产出:一份多 Sheet 汇总
+    # 文件名加"小时"以区分每天 01/09/15/20 四个时段,四份各自留档、互不覆盖
+    # 注:文件名括号内的分隔符用全角斜杠 U+FF0F,因为 Windows 不允许半角 `/` 出现在文件名里
+    xlsx_out = os.path.join(
+        _OUT_DIR, f"得卡在售每日报告(新增商家/明细){datetime.now().strftime('%Y%m%d_%H时')}.xlsx")
+    build_excel(data, xlsx_out)
+    log.info(f"Excel 报表已生成: {xlsx_out}")
+
+    # 2) PNG 综合图(2026/09/10 按主公要求停用:不再生成图片,只留 Excel)
+    # png_out = os.path.join(_OUT_DIR, f"deca_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
+    # build_report(data, png_out)
+    # log.info(f"PNG 报表已生成: {png_out}")
+
+    out = xlsx_out
+
+    # 自动发企微(失败仅告警,不影响报表产出):每次生成后把 Excel 明细发到企业微信群机器人(只发表格,不发图)
+    if SEND_WECHAT:
+        try:
+            from auto_send_wx_msg import send_wechat_group_file
+            send_wechat_group_file(log=log, file_path=out)   # out=xlsx,只发 Excel
+        except Exception as e:
+            log.warning(f"企微发送跳过: {e}")
+    return out
+
+
+if __name__ == "__main__":
+    # logger.remove()
+    # logger.add(sys.stderr, level="INFO")
+    # 用法:
+    #   python deca_on_sale_report.py       → 立即查库生成并发送一次(保留原行为)
+    #   python deca_on_sale_report.py loop  → 定时常驻:每天 09:00/15:00/20:00/01:00 各查库生成并发微信一次
+    if len(sys.argv) > 1 and sys.argv[1] == "loop":
+        import schedule
+        for _hhmm in ("09:00", "15:00", "20:00", "01:00"):
+            schedule.every().day.at(_hhmm).do(main)
+        logger.info("报告定时常驻启动:每天 09:00/15:00/20:00/01:00 查库生成并发微信(数据由 buy_record_spider 每分钟落库)")
+        while True:
+            schedule.run_pending()
+            time.sleep(1)
+    else:
+        print("报表已生成:", main())

+ 6 - 0
deca_auto_spider/reports/sold_report/application.yml

@@ -0,0 +1,6 @@
+mysql:
+  host: ${MYSQL_HOST:100.64.0.25}
+  port: ${MYSQL_PROT:3306}
+  username: ${MYSQL_USERNAME:crawler}
+  password: ${MYSQL_PASSWORD:Pass2022}
+  db: ${MYSQL_DATABASE:crawler}

+ 1679 - 0
deca_auto_spider/reports/sold_report/daily_report.py

@@ -0,0 +1,1679 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/05
+"""得卡 DECA · 已售每日统计报告(Excel 多 Sheet,每一大项一个 sheet)。
+
+统计范围:成交完成时间 completed_at 落在 [昨天 13:00:00, 今天 06:00:00](含两端)的已售商品。
+一个拼团商品 = 一个「组队」(组队售卖),故「每条明细」= 每个拼团商品。
+
+报告结构(2026/08/14 由单 Sheet 分区改为多 Sheet,每 sheet 独立列宽、蓝条只覆盖本表宽度):
+    Sheet 平台总览 :平台汇总 + 当日组齐环比(vs 昨日同窗口) + 商家 GMV 集中度(Top1/3/5/10) + 口径脚注
+    Sheet 品类·系列榜:当日品类汇总(成团数/GMV/占比,品类由标题判定) + 各系列 GMV 榜(Top,含品类列与占比)
+    Sheet 商家GMV榜 :当日组齐 GMV 前 N 商家(含占比)
+    Sheet 运营节奏 :重点商家当日运营快照(新开团/已组齐/规格) + 平台组齐时段分布(近7日24h)
+    Sheet 魔都明细 :881226408 汇总 + 每条明细(含「参与人数(购买记录)」与售卖进度里程碑列;
+                    汇总下附「购买记录覆盖检测」= 成交团 vs 已采购买记录,标注漏采多少 T(团))
+    Sheet 用户排行榜(魔都):881226408 买家榜(deca_buy_record 按 user_id 聚合,参与金额倒序,
+                    含 参与车数 / 参与金额 / 车均消费)(2026/08/17 新增);末列加「新用户」标记列
+                    ——按下单时刻 purchased_at 判,窗口起点(昨13:00)前未在魔都下过单、当天首次
+                    下单者末列标黑字「新」且整行淡粉底(2026/08/28 新增,仅魔都)
+    Sheet 卡皇明细 :274584650 汇总 + 每条明细(2026/08/24 起同魔都:真实买家口径 + 进度里程碑 + 覆盖检测)
+    Sheet 用户排行榜(卡皇):274584650 买家榜(2026/08/24 新增)
+    Sheet 尼卡明细 :538252487 汇总 + 每条明细(2026/08/24 新增,同魔都扩展明细)
+    Sheet 用户排行榜(尼卡):538252487 买家榜(2026/08/24 新增)
+    Sheet 其他商家 :其余商家各一行汇总(中卡近似口径)
+    注:原「魔都已售进度检测」独立 sheet 已于 2026/08/11 并入魔都明细(尾部到 25/50/75% 用时列)。
+    注:2026/08/24 起卡皇/尼卡也接入购买记录采集,明细升级为魔都同款扩展版并各带用户排行榜;
+        三家统称 REAL_BUYER_MIDS(真实买家口径),其余商家仍走中卡近似。
+
+口径说明:
+    - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))
+              随机团(选队随机/剩余随机)按 teams 逐队精算(team_total_amount,2026/08/11 起,
+              见 docs/选队随机与剩余随机_总价口径与采集_20260811.md);固定价团回落原公式。
+    - 成团数 = 该时段成交的拼团商品数
+    - 参与人数(魔都/卡皇/尼卡 汇总 & 明细口径) = deca_buy_record 去重买家 user_id(真实参团人头;
+                 REAL_BUYER_MIDS 三家采了购买记录,2026/08/24 起由仅魔都扩为三家)。各家汇总
+                 「参与人数(真实买家)」= 跨其全部成交团去重;明细「参与人数(本团)」= 各团单独去重,
+                 故明细逐团相加(人次) ≥ 汇总。
+    - 中卡用户数(近似)(平台大盘/其他商家口径) = 拆卡报告 hit_user_nickname 去重(仅覆盖 report_state=1
+                 有报告的商品;这些商家未采购买记录,只能用中卡用户近似,非真实参团人头,偏低)
+    - 均拼单价 = 销售额 / 成团数
+    - 人均消费 = 销售额 / 参与人数(魔都/卡皇/尼卡为真实买家;平台/其他商家为按中卡近似,偏高,仅供参考)
+    - 卡密表 deca_kami_record 当前为空(FILL_KAMI 关),故无「球队」维度,明细按商品维度出。
+
+从项目根目录运行:python stats/daily_report.py(cwd=根目录,读根目录 application.yml)
+"""
+import os
+import re
+import sys
+import time
+# 挂靠新项目根:sys.path 指向 common(auto_send_wx_msg 等公共模块);CWD 固定本目录(mysql_pool 读本目录 application.yml、报告产出落本目录)
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "common"))
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+from datetime import date, datetime
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+from mysql_pool import MySQLConnectionPool
+
+# 日志:按天切分文件,保留 7 天(常驻定时运行)
+logger.remove()
+logger.add("./logs/daily_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="INFO", retention="7 day")
+
+# 企微发送:报告生成后把 Excel 发到企业微信群机器人(只发表格,不发图;群由 auto_send_wx_msg.WEBHOOK_URL 决定)
+SEND_WECHAT = True
+
+# 要出「汇总 + 明细」的重点商家;其余商家统一进「其他商家汇总」
+# (2026/08/24 新增尼卡拆卡 538252487;2026/08/25 新增文泰卡屋 591544726)
+FOCUS_MERCHANTS = ["881226408", "274584650", "538252487", "591544726", "606370597"]  # 606370597 魔都兄弟综合体育(2026/09/09 新增)
+# 已采真实购买记录(deca_buy_record)、可用「真实买家去重」口径的商家集合。
+# 2026/08/24:由单商家(仅魔都)扩为三家;2026/08/25:再加文泰——购买记录爬虫 buy_record_spider 现已并行采
+# 魔都/卡皇/尼卡/文泰,故这些家的:汇总参与人数(真实买家去重)、明细「参与人数(本团)」列、售卖进度里程碑
+# (到25/50/75%用时)、购买记录覆盖检测、用户排行榜,全部走真实买家口径(原仅魔都如此)。其余商家仍走中卡近似。
+REAL_BUYER_MIDS = {"881226408", "274584650", "538252487", "591544726", "606370597"}  # 606370597 综合体育(2026/09/09 起也采购买记录)
+# 商家 ID → 简称:用于「用户排行榜(简称)」的 sheet 名与标题、覆盖检测提示文案
+MERCHANT_SHORT_NAMES = {"881226408": "魔都", "274584650": "卡皇", "538252487": "尼卡", "591544726": "文泰", "606370597": "魔都(体育)"}
+MODDU_MID = "881226408"  # 保留:魔都为首个接入真实购买记录的商家,扩展明细列结构/sheet 顺序以其为基准
+OUT_PREFIX = "得卡已售每日报告"     # 输出文件名前缀,实际文件名后缀加运行当天日期
+
+# 时间窗过滤(p 别名):[昨天13:00, 今天06:00](2026/08/15 终点由 03:00 延到 06:00;2026/09/01 起点由 17:00 提前到 13:00,纳入下午开卖的团)
+WIN_P = ("p.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR "
+         "AND p.completed_at <= CURDATE() + INTERVAL 6 HOUR")
+
+# 明细表列规格:(表头, 明细dict取值键, 是否千分位金额格式);取值键为「序号」时按行号自增填充。
+# 开售时间/成交时间/售卖时长 三列相邻:售卖时长=成交-开售。
+DETAIL_COLS = [
+    ("序号", "序号", False), ("团名(商品标题)", "团名", False),
+    ("系列", "系列", False), ("系列配置", "系列配置", False), ("类型", "类型", False), ("单价", "单价", True),
+    ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
+    ("中卡人数", "中卡人数", False),   # 该团拆卡报告 hit_user_nickname 去重(中卡近似),放开售时间前
+    ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
+]
+# 已采购买记录商家(REAL_BUYER_MIDS:魔都/卡皇/尼卡)扩展明细:在「中卡人数」前插「参与人数」
+# (deca_buy_record 去重买家 user_id),并在尾部并入售卖进度里程碑(到 25/50/75% 用时,源
+# deca_onsale_product_progress_record)。原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入本明细。
+# 常量名沿用 MODDU 前缀(历史沿革),2026/08/24 起卡皇/尼卡明细也复用此列规格。
+MODDU_DETAIL_COLS = [
+    ("序号", "序号", False), ("团名(商品标题)", "团名", False),
+    ("系列", "系列", False), ("系列配置", "系列配置", False), ("类型", "类型", False), ("单价", "单价", True),
+    ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
+    ("参与人数(本团)", "参与人数", False),
+    ("中卡人数", "中卡人数", False),   # 该团拆卡报告 hit_user_nickname 去重(中卡近似),放开售时间前
+    ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
+    ("到25%用时", "到25%用时", False), ("到50%用时", "到50%用时", False),
+    ("到75%用时", "到75%用时", False),
+]
+# 分区标题/大标题横向着色跨列数:取最宽的魔都明细,保证蓝条覆盖整表宽
+SECTION_SPAN = len(MODDU_DETAIL_COLS)
+# 汇总表指标键(商家/平台,dict 取值键,与显示标签解耦)
+SUMMARY_HEADERS = ["销售额", "成团数", "参与人数", "均拼单价", "人均消费"]
+
+# 平台大盘竖排汇总行:(显示标签, dict取值键)。参与人数为「REAL_BUYER_MIDS(魔都/卡皇/尼卡/文泰…)真实买家
+# + 其他商家中卡去重」的混合口径(2026/08/14 起、真实买家逐步扩到多家,见 fetch_platform_summary),
+# 标签用「重点商家真实」不写死家数,新增商家无需再改此处,避免误当纯真实人头。
+PLATFORM_ROWS = [
+    ("商家数", "商家数"), ("销售额", "销售额"), ("成团数", "成团数"),
+    ("参与人数(重点商家真实+其他中卡)", "参与人数"), ("均拼单价", "均拼单价"),
+    ("人均消费", "人均消费"),
+]
+
+# 其他商家汇总表列:(显示表头, dict取值键, 是否金额格式)。这些商家未采购买记录,参与人数
+# 只能用中卡用户近似,故表头标注「(近似)」,与魔都/卡皇/尼卡的真实买家口径区分。
+OTHER_COLS = [
+    ("商家名", "商家名", False), ("销售额", "销售额", True), ("成团数", "成团数", False),
+    ("中卡用户数(近似)", "参与人数", False), ("均拼单价", "均拼单价", True),
+    ("人均消费(按中卡近似)", "人均消费", True),
+]
+
+# ---- 监测清单增强(2026/08/14):环比 / 排行 / 集中度 / 时段分布 配置 ----
+# 昨日同窗口(用于组齐环比):[前天13:00, 昨天06:00],与 WIN_P 整体平移一天、口径一致
+WIN_P_YDAY = ("p.completed_at >= (CURDATE() - INTERVAL 2 DAY) + INTERVAL 13 HOUR "
+              "AND p.completed_at <= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 6 HOUR")
+TOP_SERIES = 15              # 产品系列销售榜展示条数
+TOP_MERCHANT = 10            # 商家 GMV 榜展示条数(监测清单要「GMV前十商家」)
+TOP_USERS = None              # 用户排行榜展示条数(按参与金额倒序取前 N;魔都/卡皇/尼卡各出一榜,None=全展示)
+CONC_TOPS = (1, 3, 5, 10)    # GMV 集中度统计的 TopN 档(Top1/3/5/10 占平台总 GMV)
+HOUR_DIST_DAYS = 7           # 组齐时段分布回看天数(反映平台 24h 组齐节奏)
+# 明细 sheet「购买记录覆盖检测」里漏采团逐条列举的上限(2026/09/11):新接入商家(如魔都(体育))
+# 购买记录尚未采齐,漏采团可能几十条,全铺出来会把「每条组队明细」表头挤到很靠下,冻结行随之
+# 落到可见区域外(WPS 报「冻结位置在可见区域之外,滚屏失效」)、版式也与其他家不一致。漏采总数在
+# 覆盖检测那行已写明、这些团在下方明细表里本就都在,故逐条列表仅作诊断,超上限只列前 N、其余汇总一句。
+MISS_LIST_CAP = 10           # 漏采明细逐条列举上限,超出部分只汇总不逐条
+
+# ---- 样式 ----
+FONT_TITLE = Font(name="Microsoft YaHei", bold=True, size=14, color="1F2A44")
+FONT_SECTION = Font(name="Microsoft YaHei", bold=True, size=12, color="FFFFFF")
+FONT_HEADER = Font(name="Microsoft YaHei", bold=True, color="1F3864")   # 表头:深蓝字
+FONT_CELL = Font(name="Microsoft YaHei", size=10)                        # 正文
+FILL_SECTION = PatternFill("solid", fgColor="4472C4")   # 分区标题:蓝底白字
+FILL_HEADER = PatternFill("solid", fgColor="D9E1F2")     # 表头:浅蓝底
+FILL_SUBTOTAL = PatternFill("solid", fgColor="FCE4D6")   # 汇总行:浅橙底
+FILL_ZEBRA = PatternFill("solid", fgColor="F5F8FC")      # 明细隔行:淡蓝灰
+FILL_NEWROW = PatternFill("solid", fgColor="FEF2F2")     # 新用户行:淡粉底(参考在售报告「新品」行 FEF2F2)
+THIN = Side(style="thin", color="D6DCE5")
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
+MONEY_FMT = "#,##0.00"
+INT_FMT = "#,##0"                                        # 计数列千分位
+ALIGN_LEFT = Alignment(horizontal="left", vertical="center")     # 文字:左对齐
+ALIGN_RIGHT = Alignment(horizontal="right", vertical="center")   # 数字/金额:右对齐
+ALIGN_CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)  # 表头:居中换行
+
+
+FONT_UP = Font(name="Microsoft YaHei", bold=True, color="1E7D32")    # 环比涨:绿
+FONT_DOWN = Font(name="Microsoft YaHei", bold=True, color="C62828")  # 环比跌:红
+
+
+def _align_by_value(value) -> Alignment:
+    """按单元格值类型选对齐方式:数字(含金额/占比)右对齐,其余(文字/时长/日期)左对齐。
+
+    Args:
+        value: 单元格值(int/float/str/None 等)。
+
+    Returns:
+        Alignment: 数字返回右对齐,其它返回左对齐(bool 视为非数字)。
+    """
+    if isinstance(value, bool):
+        return ALIGN_LEFT
+    return ALIGN_RIGHT if isinstance(value, (int, float)) else ALIGN_LEFT
+
+
+def _trend_font(text: str) -> Font:
+    """按环比文本的正负号返回涨跌配色字体(涨绿、跌红、其它常规)。
+
+    Args:
+        text (str): 环比字符串(如 "+12.3%"/"-8.0%"/"新增"/"—")。
+
+    Returns:
+        Font: 以 "+" 开头或"新增"→绿;"-" 开头→红;否则常规正文字体。
+    """
+    if not text:
+        return FONT_CELL
+    if text.startswith("+") or text == "新增":
+        return FONT_UP
+    if text.startswith("-"):
+        return FONT_DOWN
+    return FONT_CELL
+
+
+def get_window(pool) -> tuple[str, str]:
+    """取当前时间窗的起止边界(用于报告标题展示)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[str, str]: (起点datetime字符串, 终点datetime字符串)。
+    """
+    row = pool.select_all(
+        "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR, "
+        "CURDATE() + INTERVAL 6 HOUR")[0]
+    return str(row[0]), str(row[1])
+
+
+def fetch_platform_summary(pool) -> dict:
+    """统计平台大盘汇总(时间窗内全部已售商品)。
+
+    参与人数为混合口径(2026/08/14 起;2026/08/24 真实买家由仅魔都扩到魔都/卡皇/尼卡三家):
+    REAL_BUYER_MIDS 三家采了真实购买记录,用 deca_buy_record 去重真实买家;其余商家未采购买记录,
+    仍用拆卡报告 hit_user_nickname 去重的中卡用户近似。两部分人群标识不同(真实买家=user_id,
+    其他=昵称)、且三家真实买家之间也未跨商家去重(同一 user_id 跨家买会各记一次),故直接相加,属近似上界。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: 含 销售额/商家数/成团数/参与人数/均拼单价/人均消费 六项。
+    """
+    real_mids = list(REAL_BUYER_MIDS)
+    ph = ",".join(["%s"] * len(real_mids))   # 中卡子查询要排除全部真实买家商家
+    sql = f"""
+    SELECT
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(DISTINCT p.merchant_user_id)                               AS merchants,
+        COUNT(*)                                                          AS grp,
+        -- 非真实买家商家的中卡用户去重;魔都/卡皇/尼卡单独用真实买家,不计入此子查询
+        (SELECT COUNT(DISTINCT r.hit_user_nickname)
+           FROM deca_report_record r
+           JOIN deca_product_record pp ON pp.product_code = r.product_code
+          WHERE pp.merchant_user_id NOT IN ({ph})
+            AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR
+            AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS others_people
+    FROM deca_product_record p
+    WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    amount, merchants, groups, others_people = pool.select_all(sql, tuple(real_mids))[0]
+    # 平台参与人数 = 三家真实买家(deca_buy_record 各自去重后求和) + 其他商家中卡用户去重
+    real_people = sum(_fetch_real_buyers(pool, m) for m in real_mids)
+    people = real_people + (others_people or 0)
+    return _pack_summary(amount, groups, people, extra={"商家数": merchants})
+
+
+def fetch_merchant_summary(pool, mid: str) -> dict:
+    """统计单个商家的汇总(时间窗内)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 商家名/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        MAX(p.merchant_name)                                              AS mname,
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(*)                                                          AS grp,
+        (SELECT COUNT(DISTINCT r.hit_user_nickname)
+           FROM deca_report_record r
+           JOIN deca_product_record pp ON pp.product_code = r.product_code
+          WHERE pp.merchant_user_id = %s
+            AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR
+            AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS people
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    row = pool.select_all(sql, (mid, mid))
+    mname, amount, groups, people = row[0] if row else (None, None, 0, 0)
+    # 魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了真实购买记录:参与人数改用 deca_buy_record 去重真实买家,
+    # 人均消费随之按真实人头计(覆盖上面 people 的中卡近似值);其余重点商家无购买记录,仍沿用中卡近似。
+    if mid in REAL_BUYER_MIDS:
+        people = _fetch_real_buyers(pool, mid)
+    d = _pack_summary(amount, groups, people)
+    d["商家名"] = mname or mid
+    d["商家ID"] = mid
+    return d
+
+
+def fetch_merchant_details(pool, mid: str) -> list[dict]:
+    """取单个商家时间窗内每个拼团(组队)的明细,按总金额倒序。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        list[dict]: 每元素为一条明细(团名/系列/类型/单价/份数/总份数/进度/总金额/中卡人数/开售时间/成交时间/售卖时长/回放)。
+    """
+    sql = f"""
+    SELECT
+        p.title, p.series_name, p.series_config, p.spec_name, p.unit_price, p.sold_count, p.card_count,
+        ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+        p.completed_at, p.sale_start_at,
+        TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at)            AS duration_secs,
+        -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
+        (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
+          WHERE r.product_code = p.product_code
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
+        p.replay_url
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    for (title, series, series_config, spec, price, sold, card, amount,
+         completed, sale_start, duration_secs, hit_users, replay) in rows:
+        progress = round(sold / card * 100, 1) if card else None  # 售卖进度百分比
+        result.append({
+            "团名": title, "系列": series, "系列配置": series_config, "类型": spec, "单价": price,
+            "份数": sold, "总份数": card, "进度": progress, "总金额": amount,
+            "中卡人数": hit_users,                             # 该团拆卡报告去重命中用户(中卡近似)
+            "开售时间": sale_start, "成交时间": completed,   # 开售=sale_start_at,成交=completed_at
+            "售卖时长": _fmt_duration(duration_secs),         # 差值=成交-开售,反映多快卖完
+            "回放": replay,
+        })
+    return result
+
+
+def fetch_other_merchants(pool, exclude: list) -> list[dict]:
+    """统计除重点商家外的其他商家汇总(时间窗内),每商家一行,按销售额倒序。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        exclude (list[str]): 要排除的重点商家 ID 列表。
+
+    Returns:
+        list[dict]: 每元素为一个商家的汇总(商家名/销售额/成团数/参与人数/均拼单价/人均消费)。
+    """
+    placeholders = ",".join(["%s"] * len(exclude))
+    sql = f"""
+    SELECT
+        p.merchant_user_id                                                AS mid,
+        MAX(p.merchant_name)                                              AS mname,
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(*)                                                          AS grp,
+        (SELECT COUNT(DISTINCT r.hit_user_nickname)
+           FROM deca_report_record r
+           JOIN deca_product_record pp ON pp.product_code = r.product_code
+          WHERE pp.merchant_user_id = p.merchant_user_id
+            AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR
+            AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS people
+    FROM deca_product_record p
+    WHERE {WIN_P} AND p.merchant_user_id NOT IN ({placeholders})
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    GROUP BY p.merchant_user_id
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, tuple(exclude)) or []
+    result = []
+    for mid, mname, amount, groups, people in rows:
+        d = _pack_summary(amount, groups, people)
+        d["商家名"] = mname or mid
+        result.append(d)
+    return result
+
+
+def _pack_summary(amount, groups, people, extra: dict = None) -> dict:
+    """把销售额/成团数/参与人数打包成统一汇总 dict,并算均拼单价、人均消费。
+
+    Args:
+        amount (Decimal | None): 销售额。
+        groups (int | None): 成团数。
+        people (int | None): 参与人数。
+        extra (dict, optional): 额外要合并的字段(如商家数)。Defaults to None。
+
+    Returns:
+        dict: 含 销售额/成团数/参与人数/均拼单价/人均消费(+extra)。
+    """
+    amount = float(amount) if amount is not None else 0.0
+    groups = groups or 0
+    people = people or 0
+    avg_group = round(amount / groups, 2) if groups else None  # 均拼单价=销售额/成团数
+    avg_person = round(amount / people, 2) if people else None  # 人均消费=销售额/参与人数
+    d = {"销售额": round(amount, 2), "成团数": groups, "参与人数": people,
+         "均拼单价": avg_group, "人均消费": avg_person}
+    if extra:
+        d.update(extra)
+    return d
+
+
+def _fetch_real_buyers(pool, mid: str) -> int:
+    """查某商家时间窗内 deca_buy_record 去重真实买家数(跨其全部成交团)。
+
+    魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了真实购买记录,可用此口径(2026/08/24 由仅魔都扩为三家);
+    其余商家该表无数据、返回 0。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        int: 去重真实买家数;无数据时返回 0。
+    """
+    sql = """
+    SELECT COUNT(DISTINCT b.user_id)
+    FROM deca_buy_record b
+    JOIN deca_product_record pp ON pp.product_code = b.product_code
+    WHERE pp.merchant_user_id = %s
+      AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR
+      AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+    """
+    row = pool.select_all(sql, (mid,))
+    return int(row[0][0]) if row and row[0][0] is not None else 0
+
+
+def _summary_rows(is_real: bool) -> list[tuple]:
+    """按参与人数口径生成重点商家竖排汇总的(显示标签, 取值键)行规格。
+
+    Args:
+        is_real (bool): True=该商家参与人数为 deca_buy_record 真实买家(魔都/卡皇/尼卡),标签用
+            「参与人数(真实买家)」;False=中卡用户近似,标签用「中卡用户数(近似)」,人均消费
+            标签相应标注「(按中卡近似)」。
+
+    Returns:
+        list[tuple[str, str]]: 每项 (显示标签, data 取值键)。
+    """
+    people = ("参与人数(真实买家)" if is_real else "中卡用户数(近似)", "参与人数")
+    person_avg = ("人均消费" if is_real else "人均消费(按中卡近似)", "人均消费")
+    return [("销售额", "销售额"), ("成团数", "成团数"), people,
+            ("均拼单价", "均拼单价"), person_avg]
+
+
+def fetch_moddu_details(pool, mid: str) -> list[dict]:
+    """取某「已采真实购买记录」商家(mid)时间窗内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    2026/08/24:函数名沿用 moddu(魔都),但已泛化到 REAL_BUYER_MIDS 三家(魔都/卡皇/尼卡)——
+    SQL 全按 mid 参数查、对任意商家成立,故复用同一函数、按传入 mid 出各商家扩展明细。
+
+    在标准明细基础上多两类字段(原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入此处):
+        - 参与人数:deca_buy_record 去重买家 user_id(真实参团人头;仅本商家采了购买记录)。
+        - 到 25/50/75% 用时:用 deca_onsale_product_progress_record(分钟级进度序列)里
+          `MIN(captured_at) WHERE progress_pct >= X` 减开售时间 sale_start_at 得到。
+          仅当该商品在 tX 之前还有更早快照(证明从低于 X% 就开始观测)时才算真实穿越耗时;
+          若首张快照 pct 已 ≥ X(坍缩)则该列留空,不输出「首次被监控时刻−开售」的假值。
+          原「到100%用时」已于 2026/08/12 删除:满仓即下架、轮询抓不到 100% 快照,且与售卖时长重复。
+
+    进度里程碑仅"progress 表上线(2026/08/11)后覆盖到售卖过程"的团才有值;更早售卖或首张
+    快照已越过阈值的团查不到/不可信,相应列留空——商品行本身仍显示(总金额/成交时间等基础字段)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        list[dict]: 每条含 团名/系列/类型/单价/总份数/进度/总金额/参与人数(购买记录)/中卡人数/
+                    开售时间/成交时间/售卖时长/到25%用时/到50%用时/到75%用时。
+    """
+    sql = f"""
+    SELECT
+        p.title, p.series_name, p.series_config, p.spec_name, p.unit_price, p.sold_count, p.card_count,
+        ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+        p.sale_start_at, p.completed_at,
+        TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at)            AS duration_secs,
+        (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                          AS buyers,
+        -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
+        (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
+          WHERE r.product_code = p.product_code
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
+        -- 该商品最早一条进度快照时刻:用于判定里程碑是否「坍缩」(首张快照已越过阈值则该用时不可信)
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code)                          AS first_cap,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 25)  AS t25,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 50)  AS t50,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 75)  AS t75
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    for (title, series, series_config, spec, price, sold, card, amount, start, completed,
+         duration_secs, buyers, hit_users, first_cap, t25, t50, t75) in rows:
+        progress = round(sold / card * 100, 1) if card else None  # 售卖进度百分比
+        result.append({
+            "团名": title, "系列": series, "系列配置": series_config, "类型": spec, "单价": price,
+            "总份数": card, "进度": progress, "总金额": amount,
+            "参与人数": buyers,
+            "中卡人数": hit_users,                       # 该团拆卡报告去重命中用户(中卡近似)
+            "开售时间": start, "成交时间": completed,   # 开售=sale_start_at,成交=completed_at
+            "售卖时长": _fmt_duration(duration_secs),   # 成交-开售,也即整团总时长
+            # 到 X% 用时:仅当该商品在 tX 之前还有更早快照(未坍缩)时才输出真实穿越耗时,否则留空
+            "到25%用时": _milestone_used(t25, first_cap, start),
+            "到50%用时": _milestone_used(t50, first_cap, start),
+            "到75%用时": _milestone_used(t75, first_cap, start),
+        })
+    return result
+
+
+def fetch_moddu_user_ranking(pool, mid: str, top_n: int) -> tuple[list[dict], int]:
+    """取某「已采真实购买记录」商家时间窗内的用户参与排行(按参与金额倒序,取前 top_n)。
+
+    「一个拼团商品 = 一辆车(组队)」,以 deca_buy_record 购买记录按买家 user_id 聚合:
+        - 参与车数 = COUNT(DISTINCT product_code),该买家窗口内参与的不同团数。
+        - 参与金额 = SUM(card_count × 该团 unit_price)。deca_buy_record 无金额字段,故用
+          「购买份数 × 团单价」估算;固定价团精确,随机团(选队随机/剩余随机)每队价不同,
+          此处按标称单价近似。同一买家在同一团的多条购买记录已由 SUM 累加。
+        - 车均消费 = 参与金额 ÷ 参与车数。
+        - 是否新用户(2026/08/28 新增) = 窗口起点(昨13:00)前该 user_id 在本商家「无」购买记录
+          (deca_buy_record.purchased_at)——即此前从未在本商家下过单、当天首次下单者为新用户。
+          用「下单时刻 purchased_at」判历史(而非团成交 completed_at),才是「首次下单」的准确口径。
+    魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了购买记录,均可出此榜(2026/08/24 由仅魔都扩为三家)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+        top_n (int): 榜单展示上限(取金额倒序前 N)。
+
+    Returns:
+        tuple[list[dict], int, int]: (rows, total_users, new_users);rows 每项含 用户昵称/user_id/
+            参与车数/参与金额/车均消费/是否新用户(bool)(已按金额倒序并截断到 top_n);total_users 为
+            窗口内全部参与买家数(供榜单标题展示、判断截断了多少);new_users 为全量买家里的新客数
+            (本店首单),必须在截断前的全量 rows 上统计,供顶部「新客/老客/复购率」汇总(2026/09/14 新增)。
+    """
+    win_start = "(CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR"   # 窗口起点=昨13:00,判新老的分界
+    sql = f"""
+    SELECT b.user_id,
+           MAX(b.nickname)                            AS nickname,
+           COUNT(DISTINCT b.product_code)             AS cars,
+           ROUND(SUM(b.card_count * p.unit_price), 2) AS amount,
+           -- 新用户标记:窗口起点(昨13:00)前该买家在本商家无下单记录 → 当天首次在本商家下单
+           NOT EXISTS(SELECT 1 FROM deca_buy_record b0
+                        WHERE b0.merchant_user_id = %s AND b0.user_id = b.user_id
+                          AND b0.purchased_at < {win_start}) AS is_new
+    FROM deca_buy_record b
+    JOIN deca_product_record p ON p.product_code = b.product_code
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    GROUP BY b.user_id
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid, mid)) or []
+    # 新客数在「全量 rows」(截断前)上统计:is_new 为 SQL 的 NOT EXISTS 结果(1/0),第 5 列(索引 4)。
+    # 绝不能对 rows[:top_n] 求和——将来 TOP_USERS 设有限值时会只数到前 N 的新客,导致新老/复购率错(见待办第 5 节陷阱)。
+    new_users = sum(1 for r in rows if r[4])
+    result = []
+    for uid, nickname, cars, amount, is_new in rows[:top_n]:
+        amt = float(amount) if amount is not None else 0.0
+        cars = int(cars or 0)
+        result.append({
+            "用户昵称": nickname or "(匿名)",
+            "user_id": uid,
+            "参与车数": cars,
+            "参与金额": round(amt, 2),
+            "车均消费": round(amt / cars, 2) if cars else None,  # 车均=参与金额/参与车数
+            "是否新用户": bool(is_new),                          # 当天首次在本商家下单
+        })
+    return result, len(rows), new_users
+
+
+def fetch_moddu_missing_teams(pool, mid: str) -> dict:
+    """对比某「已采真实购买记录」商家成交明细与购买记录覆盖,算出漏采购买记录的 T(团)。
+
+    2026/08/24:函数名沿用 moddu,但已泛化到 REAL_BUYER_MIDS 三家(魔都/卡皇/尼卡),按 mid 参数查。
+    明细每条 = 一个成交拼团商品(T),来自 deca_product_record;购买记录 deca_buy_record 是另路采集的。
+    个别团在采到购买记录前就满仓成交下架,会「漏采」——本函数以时间窗内成交团为基准,找出
+    deca_buy_record 里没有对应 product_code 的团,供各商家明细标注覆盖缺口。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 成交团数/有记录团数/漏采团数/漏采团列表(list[(product_code, title)])。
+    """
+    total = pool.select_all(f"""
+    SELECT COUNT(*) FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """, (mid,))[0][0] or 0
+    miss = pool.select_all(f"""
+    SELECT p.product_code, p.title
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+      AND NOT EXISTS(SELECT 1 FROM deca_buy_record b WHERE b.product_code = p.product_code)
+    ORDER BY p.completed_at
+    """, (mid,)) or []
+    return {"成交团数": total, "有记录团数": total - len(miss),
+            "漏采团数": len(miss), "漏采团列表": [(pc, t) for pc, t in miss]}
+
+
+def _fmt_duration(secs) -> str | None:
+    """把售卖时长(秒)格式化为可读中文字符串。
+
+    差值口径 = 成交完成时间 completed_at − 开售时间 sale_start_at,反映该团多快卖完/售罄。
+
+    Args:
+        secs (int | None): 成交与开售之差(秒);None 或负数(时间缺失/异常)视为无效。
+
+    Returns:
+        str | None: 形如 "2小时13分" / "45分07秒" / "38秒";无效时返回 None(单元格留空)。
+    """
+    if secs is None or secs < 0:
+        return None
+    secs = int(secs)
+    h, rem = divmod(secs, 3600)
+    m, s = divmod(rem, 60)
+    if h:
+        return f"{h}小时{m}分"
+    if m:
+        return f"{m}分{s:02d}秒"
+    return f"{s}秒"
+
+
+def _diff_secs(later, earlier) -> int | None:
+    """算两个时间点相隔的秒数,兼容 varchar 与 datetime 两种输入。
+
+    Args:
+        later (str | datetime | None): 较晚的时间点(如到达某进度的时刻)。
+        earlier (str | datetime | None): 较早的时间点(如开售时间)。
+
+    Returns:
+        int | None: 相隔秒数;任一为空或解析失败时返回 None。
+    """
+    if not later or not earlier:
+        return None
+    try:
+        if isinstance(earlier, str):
+            earlier = datetime.strptime(earlier, "%Y-%m-%d %H:%M:%S")
+        if isinstance(later, str):
+            later = datetime.strptime(later, "%Y-%m-%d %H:%M:%S")
+        return int((later - earlier).total_seconds())
+    except Exception:
+        return None
+
+
+def _milestone_used(tx, first_cap, start) -> str | None:
+    """算「到某进度用时」,首张快照已越过该阈值(坍缩)时诚实留空。
+
+    到 X% 用时 = 首次 progress_pct ≥ X 的快照时刻 tx − 开售时间 start。但只有当该商品在
+    tx 之前还存在更早的快照(即 tx 晚于该商品最早快照 first_cap)时,才证明我们是从低于 X%
+    的进度观测到它穿越 X% 的,tx 才是真实穿越点;若首张快照 pct 就已 ≥ X(tx == first_cap),
+    tx 只是「首次被监控的时刻」,据此算出的是假耗时,一律留空,绝不输出坍缩值。
+
+    Args:
+        tx (datetime | str | None): 首次 progress_pct ≥ 阈值的快照时刻;None 表示从未达到该进度。
+        first_cap (datetime | str | None): 该商品最早一条快照的时刻。
+        start (datetime | str | None): 开售时间 sale_start_at。
+
+    Returns:
+        str | None: 可读用时字符串;未达到/坍缩/时间缺失时返回 None(单元格留空)。
+    """
+    if tx is None or first_cap is None:
+        return None
+    # tx 与首张快照同刻 → 第一次看到它就已越过该阈值,无法确定真实穿越时刻,坍缩留空
+    if not _diff_secs(tx, first_cap):        # None(解析失败) 或 0(同刻) 都视为不可信
+        return None
+    return _fmt_duration(_diff_secs(tx, start))
+
+
+def _style_row(ws, row_idx: int, ncol: int, font=None, fill=None, border=True, start_col: int = 1):
+    """给某一行从 start_col 起的 ncol 个单元格套字体/填充/边框。
+
+    Args:
+        ws: openpyxl worksheet。
+        row_idx (int): 行号(1-based)。
+        ncol (int): 要处理的列数(从 start_col 起)。
+        font (Font, optional): 字体。Defaults to None。
+        fill (PatternFill, optional): 填充。Defaults to None。
+        border (bool, optional): 是否加边框。Defaults to True。
+        start_col (int, optional): 起始列号(1-based),用于把整块右移(如放到主表右侧)。Defaults to 1。
+    """
+    for c in range(start_col, start_col + ncol):
+        cell = ws.cell(row=row_idx, column=c)
+        if font:
+            cell.font = font
+        if fill:
+            cell.fill = fill
+        if border:
+            cell.border = BORDER
+
+
+def _write_summary_block(ws, r: int, rows: list, data: dict, start_col: int = 1) -> int:
+    """在第 r 行起竖排写汇总(每指标一行:标签列 + 数值列),返回下一空闲行。
+
+    竖排避免与明细表共用列宽导致的错位与金额列过窄(######)。显示标签与 data 取值键解耦,
+    以便同一指标在不同口径下用不同标签(如「参与人数(真实买家)」vs「中卡用户数(近似)」)。
+    start_col 可让整块右移(明细 sheet 里落到宽的 B/C 列,避开 A=序号 的窄列)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        rows (list[tuple[str, str]]): 每项 (显示标签, data 取值键)。
+        data (dict): 汇总数据 dict。
+        start_col (int, optional): 标签列的列号(1-based),数值列为其右一列。Defaults to 1。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    for label, key in rows:
+        lc = ws.cell(row=r, column=start_col, value=label)     # 标签(加粗深蓝、浅蓝底、左对齐)
+        lc.font = FONT_HEADER
+        lc.fill = FILL_HEADER
+        lc.alignment = ALIGN_LEFT
+        lc.border = BORDER
+        value = data.get(key)
+        val = ws.cell(row=r, column=start_col + 1, value=value)  # 数值(浅橙底、右对齐)
+        if key in ("销售额", "均拼单价", "人均消费"):    # 金额格式按稳定的取值键判定
+            val.number_format = MONEY_FMT
+        elif isinstance(value, int) and not isinstance(value, bool):
+            val.number_format = INT_FMT                 # 计数千分位(成团数/参与人数/商家数)
+        val.font = FONT_CELL
+        val.fill = FILL_SUBTOTAL
+        val.alignment = _align_by_value(value)          # 数字右对齐、文字左对齐
+        val.border = BORDER
+        r += 1
+    return r
+
+
+def _write_section_title(ws, r: int, text: str, span: int = SECTION_SPAN, start_col: int = 1) -> int:
+    """在第 r 行写一行分区标题条(蓝底白字),蓝条从 start_col 起覆盖 span 列(贴合本表宽度、右侧不留空)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        text (str): 标题文字。
+        span (int, optional): 蓝条覆盖列数。Defaults to SECTION_SPAN。
+        start_col (int, optional): 标题条起始列号(1-based),用于把标题条右移(如主表右侧的副块)。Defaults to 1。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    ws.cell(row=r, column=start_col, value=text)
+    _style_row(ws, r, span, font=FONT_SECTION, fill=FILL_SECTION, border=False, start_col=start_col)
+    return r + 1
+
+
+def _write_details(ws, r: int, details: list, cols: list) -> int:
+    """在第 r 行起写一个商家的明细表(表头 + 每条组队明细)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        details (list[dict]): 明细数据,每元素为一条组队明细 dict(fetch_merchant_details
+            或 fetch_moddu_details 的返回)。
+        cols (list[tuple]): 列规格列表,每项 (表头, 取值键, 是否金额格式);取值键为「序号」
+            时按行号自增填充(DETAIL_COLS 或 MODDU_DETAIL_COLS)。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    for i, (header, _key, _money) in enumerate(cols):
+        hc = ws.cell(row=r, column=1 + i, value=header)
+        hc.alignment = ALIGN_CENTER                    # 表头居中换行
+    _style_row(ws, r, len(cols), font=FONT_HEADER, fill=FILL_HEADER)
+    r += 1
+
+    if not details:
+        ws.cell(row=r, column=1, value="(该时段无成交)").font = FONT_CELL
+        return r + 1
+
+    for idx, d in enumerate(details, 1):
+        zebra = FILL_ZEBRA if idx % 2 == 0 else None   # 偶数行淡蓝灰,便于横向读长行
+        for i, (_header, key, is_money) in enumerate(cols):
+            value = idx if key == "序号" else d.get(key)   # 「序号」列用行号,其余按键取值
+            cell = ws.cell(row=r, column=1 + i, value=value)
+            if is_money:
+                cell.number_format = MONEY_FMT
+            elif isinstance(value, int) and not isinstance(value, bool):
+                cell.number_format = INT_FMT           # 计数千分位(序号/份数/总份数/参与人数)
+            cell.font = FONT_CELL
+            cell.alignment = _align_by_value(value)    # 数字右、文字左(时长/日期/团名等)
+            cell.border = BORDER
+            if zebra:
+                cell.fill = zebra
+        r += 1
+    return r
+
+
+def _window_metrics(pool, win: str) -> dict:
+    """统计某成交时间窗内的组齐核心指标(GMV / 成团数 / 活跃商家数 / T均单价)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        win (str): 完整的 completed_at 时间窗 WHERE 片段(如 WIN_P / WIN_P_YDAY)。
+
+    Returns:
+        dict: 含 组齐GMV/成团数/活跃商家数/T均单价(成团数为 0 时 T均单价为 None)。
+    """
+    sql = f"""
+    SELECT ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS gmv,
+           COUNT(*)                           AS grp,
+           COUNT(DISTINCT p.merchant_user_id) AS merchants
+    FROM deca_product_record p
+    WHERE {win} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    gmv, groups, merchants = pool.select_all(sql)[0]
+    gmv = float(gmv) if gmv is not None else 0.0
+    groups = groups or 0
+    return {"组齐GMV": round(gmv, 2), "成团数": groups, "活跃商家数": merchants or 0,
+            "T均单价": round(gmv / groups, 2) if groups else None}  # T均单价=组齐GMV/成团数
+
+
+def fetch_groupbuy_compare(pool) -> dict:
+    """取当日窗口与昨日同窗口的组齐指标,供「当日组齐环比」区展示。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: {"today": {...}, "yday": {...}},各含 组齐GMV/成团数/活跃商家数/T均单价。
+    """
+    return {"today": _window_metrics(pool, WIN_P), "yday": _window_metrics(pool, WIN_P_YDAY)}
+
+
+def classify_category(title: str, series: str = "") -> str:
+    """从标题(+系列名)判定得卡品类(平台无独立品类字段,只能按关键词判)。
+
+    规则:优先中文运动词(篮球/NBA、足球/FIFA/世界杯、棒球/MLB、橄榄/NFL)——运动词多作标题前缀出现、判准率高;
+    再判 TCG(宝可梦、海贼王、游戏王);都不中归「其他」。实测 2053 团仅 5 个落「其他」,且那 5 个标题本就写
+    「其他运动」/为冷门 TCG(Weiss Schwarz),判类可靠。
+
+    Args:
+        title (str): 商品标题。
+        series (str, optional): 系列名(series_name),一并参与匹配。Defaults to ""。
+
+    Returns:
+        str: 品类名(篮球/足球/棒球/橄榄球/宝可梦/海贼王/游戏王/其他)。
+    """
+    t = f"{title} {series}"
+    tl = t.lower()
+    if "篮球" in t or "nba" in tl:
+        return "篮球"
+    if "足球" in t or "fifa" in tl or "世界杯" in t or "英超" in t or "欧冠" in t:
+        return "足球"
+    if "棒球" in t or "mlb" in tl:
+        return "棒球"
+    if "橄榄" in t or "nfl" in tl:
+        return "橄榄球"
+    if (any(k in t for k in ["宝可梦", "寶可夢", "皮卡丘", "朋友派对", "乐园腾龙", "绿宝石",
+                             "超级梦想", "超梦", "卡牌151", "黑白闪", "狂热", "朱紫"])
+            or re.search(r"\bsv\d", tl) or " ex " in f" {tl} "):
+        return "宝可梦"
+    if (any(k in t for k in ["海贼", "航海王", "路飞", "艾斯", "索隆", "娜美"])
+            or re.search(r"op-?\d", tl) or re.search(r"st-?\d", tl)):
+        return "海贼王"
+    if "游戏王" in t or "遊戲王" in t or "ygo" in tl:
+        return "游戏王"
+    return "其他"
+
+
+def fetch_category_series(pool, top_n: int) -> tuple[list, float, list, float]:
+    """取当日窗口内「品类汇总」与「系列榜」(均按 GMV 倒序),供品类·系列榜 sheet。
+
+    平台无品类字段,逐团用 classify_category(标题+系列) 判类后在 Python 聚合:
+      - 品类汇总:每品类的 成团数 / GMV(全部品类,倒序)。
+      - 系列榜:每系列的 成团数 / GMV / 所属品类(取该系列下出现团数最多的品类),按 GMV 取前 top_n。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        top_n (int): 系列榜取前 N。
+
+    Returns:
+        tuple[list, float, list, float]: (cat_rows, cat_total, series_rows, series_total)。
+            cat_rows 每项 (品类, 成团数, GMV);series_rows 每项 (系列, 品类, 成团数, GMV);
+            两个 total 为 GMV 合计(算占比分母,二者相等=全窗口 GMV)。
+    """
+    sql = f"""
+    SELECT p.title, COALESCE(NULLIF(p.series_name, ''), '(未标系列)') AS series,
+           ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS gmv
+    FROM deca_product_record p
+    WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    rows = pool.select_all(sql) or []
+    cat_agg = {}     # 品类 -> [成团数, GMV]
+    ser_agg = {}     # 系列 -> {"grp": n, "gmv": x, "cat": {品类: 团数}}
+    for title, series, gmv in rows:
+        g = float(gmv) if gmv is not None else 0.0
+        cat = classify_category(title or "", series or "")
+        ca = cat_agg.setdefault(cat, [0, 0.0]); ca[0] += 1; ca[1] += g
+        sa = ser_agg.setdefault(series, {"grp": 0, "gmv": 0.0, "cat": {}})
+        sa["grp"] += 1; sa["gmv"] += g
+        sa["cat"][cat] = sa["cat"].get(cat, 0) + 1
+    total = round(sum(v[1] for v in cat_agg.values()), 2)
+    cat_rows = sorted([(k, v[0], round(v[1], 2)) for k, v in cat_agg.items()],
+                      key=lambda x: x[2], reverse=True)
+    series_rows = sorted(
+        [(name, max(v["cat"], key=v["cat"].get), v["grp"], round(v["gmv"], 2))
+         for name, v in ser_agg.items()],
+        key=lambda x: x[3], reverse=True)[:top_n]
+    return cat_rows, total, series_rows, total
+
+
+# 需维护 category 品类字段的表:品类由 classify_category 从标题判定后落库,方便直接按品类查询/聚合(GROUP BY category)
+CATEGORY_TABLES = ("deca_product_record", "deca_onsale_product_record")
+
+
+def backfill_category(pool, tables: tuple = CATEGORY_TABLES, only_null: bool = True) -> int:
+    """把品类判定结果(classify_category)落库到各表的 category 列,返回累计更新行数。
+
+    平台无品类字段、品类靠标题判定;本函数将判定结果写入 category 列,供后续直接按品类查库(无需每次重算)。
+    分类逻辑与报告共用 classify_category,口径一致。按品类分组、分块 UPDATE,减少 SQL 往返。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        tables (tuple[str], optional): 要回填的表名。Defaults to CATEGORY_TABLES。
+        only_null (bool, optional): True 只补 category IS NULL 的行(日常报告前调用,仅补新增,成本低);
+            False 全量重算(分类规则调整后手动全刷一次)。Defaults to True。
+
+    Returns:
+        int: 累计更新行数。
+    """
+    from collections import defaultdict
+    total = 0
+    for tbl in tables:
+        where = "WHERE category IS NULL" if only_null else ""
+        rows = pool.select_all(
+            f"SELECT product_code, title, COALESCE(series_name, '') FROM {tbl} {where}") or []
+        by_cat = defaultdict(list)
+        for code, title, series in rows:
+            if code is None:
+                continue
+            by_cat[classify_category(title or "", series or "")].append(code)
+        for cat, codes in by_cat.items():
+            for i in range(0, len(codes), 500):            # 分块,避免超长 IN 列表
+                chunk = codes[i:i + 500]
+                ph = ",".join(["%s"] * len(chunk))
+                pool.update_one(
+                    f"UPDATE {tbl} SET category=%s WHERE product_code IN ({ph})", (cat, *chunk))
+        total += sum(len(c) for c in by_cat.values())
+    return total
+
+
+def fetch_merchant_gmv_ranking(pool) -> tuple[list, float, dict]:
+    """取当日窗口内全部商家的组齐 GMV 榜(倒序)及 Top1/3/5/10 集中度占比。
+
+    集中度 = 前 K 名商家 GMV 之和 ÷ 平台组齐总 GMV,反映头部商家对大盘的贡献集中程度。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[list, float, dict]: (rows, total_gmv, concentration);rows 每项
+            (商家名, 成团数, GMV)(全量、倒序);concentration 形如 {1: 0.32, 3: 0.55, ...}。
+    """
+    sql = f"""
+    SELECT MAX(p.merchant_name)              AS mname,
+           COUNT(*)                           AS grp,
+           ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS gmv
+    FROM deca_product_record p
+    WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    GROUP BY p.merchant_user_id
+    ORDER BY gmv DESC
+    """
+    rows = pool.select_all(sql) or []
+    gmvs = [float(r[2]) if r[2] is not None else 0.0 for r in rows]
+    total = sum(gmvs)
+    conc = {k: (sum(gmvs[:k]) / total if total else None) for k in CONC_TOPS}
+    return rows, total, conc
+
+
+def fetch_focus_ops_snapshot(pool, mid: str) -> dict:
+    """取重点商家「当日新开团 / 已组齐 / 规格分布」运营快照(口径:publish_at 落在报告时间窗 [昨13:00, 今06:00])。
+
+    「已组齐」= 当日新开团里 product_code 已出现在成交表(deca_product_record 有 completed_at)的数量;
+    「规格分布」按在售表 spec_name 聚合,形如 原箱×2 · 单盒×20。对应监测清单「魔都、卡皇当天销售细节」。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 商家名/新开团数/已组齐数/规格分布(list[(规格, 数量)])。
+    """
+    # 口径对齐报告成交时间窗(2026/08/17):新开团/已组齐/规格均按 publish_at 落在[昨13:00,今06:00]统计。
+    # 原为自然日 DATE(publish_at)=CURDATE(),会漏掉前一晚上新的团(如卡皇昨晚 22:42 上新→自然日今天为 0)。
+    base = ("FROM deca_onsale_product_record o "
+            "WHERE o.merchant_user_id = %s "
+            "AND o.publish_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR "
+            "AND o.publish_at <= CURDATE() + INTERVAL 6 HOUR")
+    # 商家名单独取(不加「当日」条件):某商家当天没上新团时仍能显示名称,避免回退成纯 ID(2026/08/17)
+    mname_row = pool.select_one(
+        "SELECT MAX(merchant_name) FROM deca_onsale_product_record WHERE merchant_user_id = %s", (mid,))
+    mname = mname_row[0] if mname_row else None
+    total_new = pool.select_one(f"SELECT COUNT(*) {base}", (mid,))[0] or 0
+    done = pool.select_one(
+        f"SELECT COUNT(*) {base} "
+        f"AND EXISTS(SELECT 1 FROM deca_product_record c "
+        f"WHERE c.product_code = o.product_code AND c.completed_at IS NOT NULL)", (mid,))[0] or 0
+    spec_rows = pool.select_all(
+        f"SELECT COALESCE(NULLIF(o.spec_name, ''), '其它') sp, COUNT(*) c {base} "
+        f"GROUP BY sp ORDER BY c DESC", (mid,)) or []
+    return {"商家名": mname or mid, "新开团数": total_new, "已组齐数": done,
+            "规格分布": [(r[0], int(r[1])) for r in spec_rows]}
+
+
+def fetch_completion_hour_dist(pool, days: int) -> list[int]:
+    """统计近 N 天全部成交(组齐)按小时的 24 桶分布,反映平台 24h 组齐节奏。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        days (int): 回看天数(含今天)。
+
+    Returns:
+        list[int]: 长度 24 的列表,索引 = 小时(0~23),值 = 该小时成交团数。
+    """
+    sql = """
+    SELECT HOUR(p.completed_at) AS h, COUNT(*) AS c
+    FROM deca_product_record p
+    WHERE p.completed_at >= CURDATE() - INTERVAL %s DAY AND p.completed_at IS NOT NULL
+    GROUP BY h
+    """
+    dist = [0] * 24
+    for h, c in pool.select_all(sql, (days,)) or []:
+        if h is not None and 0 <= int(h) < 24:
+            dist[int(h)] = int(c)
+    return dist
+
+
+def _bar(value: int, max_value: int, width: int = 20) -> str:
+    """把数值渲染成等宽条形字符串(Excel 内迷你直方图)。
+
+    Args:
+        value (int): 当前值。
+        max_value (int): 该组最大值(归一化条长用)。
+        width (int, optional): 满值时的条长(字符数)。Defaults to 20。
+
+    Returns:
+        str: 由 █ 组成的条;max_value<=0 或 value<=0 时返回空串。
+    """
+    if max_value <= 0 or value <= 0:
+        return ""
+    return "█" * max(1, round(value / max_value * width))
+
+
+def _fmt_pct_change(cur, prev) -> str:
+    """把本期/上期数值算成带符号的环比字符串。
+
+    Args:
+        cur (float | int | None): 本期值。
+        prev (float | int | None): 上期值。
+
+    Returns:
+        str: 形如 "+12.3%" / "-8.0%";上期为 0 时本期>0 返回 "新增"、否则 "—"。
+    """
+    c = float(cur) if cur is not None else 0.0
+    p = float(prev) if prev is not None else 0.0
+    if p == 0:
+        return "新增" if c > 0 else "—"
+    return f"{(c - p) / p * 100:+.1f}%"
+
+
+def _write_hgrid(ws, r: int, headers: list, rows: list, money_cols=(), pct_cols=(),
+                 start_col: int = 1, empty_hint: str = "(无数据)") -> int:
+    """在第 r 行起写一张普通横向表(表头 + 数据行),套统一样式,返回下一空闲行。
+
+    可用 start_col 让表整体右移,把长文本列落在预设的宽列上(本 sheet B 列宽 50)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        headers (list[str]): 列表头。
+        rows (list[list]): 数据行,每行长度应 ≤ len(headers)。
+        money_cols (tuple[int], optional): 需金额格式的列下标(0-based)。Defaults to ()。
+        pct_cols (tuple[int], optional): 需百分比格式的列下标(0-based,存小数如 0.32)。Defaults to ()。
+        start_col (int, optional): 起始列号(1-based)。Defaults to 1。
+        empty_hint (str, optional): rows 为空时的占位文字。Defaults to "(无数据)"。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    for i, h in enumerate(headers):
+        cell = ws.cell(row=r, column=start_col + i, value=h)
+        cell.font = FONT_HEADER
+        cell.fill = FILL_HEADER
+        cell.alignment = ALIGN_CENTER                   # 表头居中
+        cell.border = BORDER
+    r += 1
+    if not rows:
+        c = ws.cell(row=r, column=start_col, value=empty_hint)
+        c.font = FONT_CELL
+        c.border = BORDER
+        return r + 1
+    n = len(headers)
+    for ri, row in enumerate(rows):
+        zebra = FILL_ZEBRA if ri % 2 == 1 else None     # 隔行淡蓝灰
+        for i in range(n):
+            v = row[i] if i < len(row) else None
+            cell = ws.cell(row=r, column=start_col + i, value=v)
+            if i in money_cols:
+                cell.number_format = MONEY_FMT
+            elif i in pct_cols:
+                cell.number_format = "0.0%"
+            elif isinstance(v, int) and not isinstance(v, bool):
+                cell.number_format = INT_FMT            # 计数千分位(成团数等)
+            cell.font = FONT_CELL
+            # 金额/占比/数字右对齐;条形图列(█ 文本)与名称等文字左对齐
+            cell.alignment = ALIGN_RIGHT if (i in money_cols or i in pct_cols) else _align_by_value(v)
+            cell.border = BORDER
+            if zebra:
+                cell.fill = zebra
+        r += 1
+    return r
+
+
+def _set_widths(ws, widths: list):
+    """按列表顺序设置 A、B、C… 列宽(仅前 26 列,够覆盖本报告各 sheet)。
+
+    Args:
+        ws: openpyxl worksheet。
+        widths (list[float]): 各列宽度,索引 0→A、1→B…。
+    """
+    for i, w in enumerate(widths):
+        ws.column_dimensions[chr(65 + i)].width = w
+
+
+def _build_overview_sheet(ws, win: tuple, platform: dict, compare: dict,
+                          mrank_conc: dict, notes: list):
+    """构建「平台总览」sheet:平台汇总 + 当日组齐环比 + 商家 GMV 集中度 + 口径脚注。
+
+    Args:
+        ws: openpyxl worksheet。
+        win (tuple[str, str]): (成交窗起, 成交窗止)。
+        platform (dict): 平台汇总数据。
+        compare (dict): 组齐环比数据(today/yday)。
+        mrank_conc (dict): Top1/3/5/10 集中度占比。
+        notes (list[str]): 口径脚注文本。
+    """
+    _set_widths(ws, [32, 18, 18, 12])
+    r = 1
+    ws.cell(row=r, column=1, value="得卡 DECA · 已售每日统计报告")
+    _style_row(ws, r, 4, font=FONT_TITLE, border=False)
+    r += 1
+    ws.cell(row=r, column=1, value=f"成交时间窗 {win[0]} ~ {win[1]}")
+    _style_row(ws, r, 4, font=FONT_CELL, border=False)
+    r += 2
+
+    # 平台汇总(竖排 标签/数值)
+    r = _write_section_title(ws, r, "平台汇总", span=4)
+    r = _write_summary_block(ws, r, PLATFORM_ROWS, platform, start_col=1)
+    r += 1
+
+    # 当日组齐环比(金额行今日/昨日单独套金额格式;环比列涨绿跌红)
+    r = _write_section_title(ws, r, "当日组齐环比(vs 昨日同窗口)", span=4)
+    for i, h in enumerate(["指标", "今日", "昨日", "环比"]):
+        c = ws.cell(row=r, column=1 + i, value=h)
+        c.font = FONT_HEADER; c.fill = FILL_HEADER
+        c.alignment = ALIGN_CENTER; c.border = BORDER
+    r += 1
+    t_now, t_yday = compare["today"], compare["yday"]
+    for ri, (key, is_money) in enumerate((("组齐GMV", True), ("成团数", False),
+                                          ("活跃商家数", False), ("T均单价", True))):
+        zebra = FILL_ZEBRA if ri % 2 == 1 else None
+        lc = ws.cell(row=r, column=1, value=key)                      # 指标名:左对齐
+        lc.font = FONT_CELL; lc.alignment = ALIGN_LEFT
+        cur = ws.cell(row=r, column=2, value=t_now.get(key))
+        prev = ws.cell(row=r, column=3, value=t_yday.get(key))
+        for cell in (cur, prev):                                      # 今日/昨日:金额或计数、右对齐
+            cell.number_format = MONEY_FMT if is_money else INT_FMT
+            cell.font = FONT_CELL; cell.alignment = ALIGN_RIGHT
+        chg = _fmt_pct_change(t_now.get(key), t_yday.get(key))
+        cc = ws.cell(row=r, column=4, value=chg)                      # 环比:右对齐 + 涨绿跌红
+        cc.font = _trend_font(chg); cc.alignment = ALIGN_RIGHT
+        for col in range(1, 5):
+            cell = ws.cell(row=r, column=col)
+            cell.border = BORDER
+            if zebra:
+                cell.fill = zebra
+        r += 1
+    r += 1
+
+    # 商家 GMV 集中度(详细榜单在「商家GMV榜」sheet)
+    r = _write_section_title(ws, r, "商家 GMV 集中度(TopN 占平台组齐总 GMV)", span=4)
+    conc_rows = [[f"Top{k} 集中度", mrank_conc.get(k)] for k in CONC_TOPS]
+    r = _write_hgrid(ws, r, ["集中度档位", "占平台GMV"], conc_rows, pct_cols=(1,), start_col=1)
+    r += 2
+
+    # 口径脚注(长文本,向右溢出到空列显示)
+    for note in notes:
+        ws.cell(row=r, column=1, value=note).font = FONT_CELL
+        r += 1
+
+
+def _build_category_series_sheet(ws, cat_rows: list, cat_total: float,
+                                 series_rows: list, series_total: float):
+    """构建「品类·系列榜」sheet:上段品类汇总(成团数/GMV/占比) + 下段系列榜(带品类列)。
+
+    Args:
+        ws: openpyxl worksheet。
+        cat_rows (list): [(品类, 成团数, GMV)],按 GMV 倒序(全部品类)。
+        cat_total (float): 品类 GMV 合计(算占比分母)。
+        series_rows (list): [(系列, 品类, 成团数, GMV)],按 GMV 倒序、已截断 Top N。
+        series_total (float): 系列 GMV 合计(=全窗口 GMV,算占比分母)。
+    """
+    _set_widths(ws, [36, 12, 16, 10, 10])
+    # 上段:品类汇总(成团数 + GMV + 占比)
+    r = _write_section_title(ws, 1, "品类汇总(当日,按 GMV 倒序;品类由标题判定)", span=5)
+    crows = [[c, int(g), float(gmv),
+              (float(gmv) / cat_total if cat_total else None)] for c, g, gmv in cat_rows]
+    r = _write_hgrid(ws, r, ["品类", "成团数", "GMV", "占比"], crows,
+                     money_cols=(2,), pct_cols=(3,), start_col=1)
+    r += 1
+    # 下段:系列榜(比原版多「品类」列)
+    r = _write_section_title(ws, r, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)", span=5)
+    srows = [[name, cat, int(g), float(gmv),
+              (float(gmv) / series_total if series_total else None)]
+             for name, cat, g, gmv in series_rows]
+    _write_hgrid(ws, r, ["系列", "品类", "成团数", "GMV", "占比"], srows,
+                 money_cols=(3,), pct_cols=(4,), start_col=1)
+    ws.freeze_panes = "A2"   # 冻结顶部品类汇总标题
+
+
+def _build_mrank_sheet(ws, mrank_rows: list, mrank_total: float):
+    """构建「商家GMV榜」sheet:当日组齐 GMV 全部商家 + 占比。
+
+    Args:
+        ws: openpyxl worksheet。
+        mrank_rows (list): [(商家名, 成团数, GMV)](全量倒序)。
+        mrank_total (float): 平台组齐总 GMV(算占比分母)。
+    """
+    _set_widths(ws, [24, 10, 16, 10])
+    r = _write_section_title(ws, 1, f"商家 GMV 榜(当日组齐口径,全部 {len(mrank_rows)} 家)", span=4)
+    mrows = [[name, int(g), float(gmv) if gmv is not None else 0,
+              (float(gmv) / mrank_total if (mrank_total and gmv is not None) else None)]
+             for name, g, gmv in mrank_rows]
+    _write_hgrid(ws, r, ["商家", "成团数", "GMV", "占比"], mrows,
+                 money_cols=(2,), pct_cols=(3,), start_col=1)
+    ws.freeze_panes = "A3"
+
+
+def _build_ops_sheet(ws, ops: list, hour_dist: list):
+    """构建「运营节奏」sheet:重点商家当日运营快照 + 平台组齐时段分布。
+
+    Args:
+        ws: openpyxl worksheet。
+        ops (list[dict]): 各重点商家运营快照。
+        hour_dist (list[int]): 24 桶组齐时段分布。
+    """
+    _set_widths(ws, [16, 12, 22, 44])   # C 兼容 已组齐(数字) 与 时段分布条;D 放规格分布长文本
+    r = _write_section_title(ws, 1, "重点商家当日运营快照(新开团 / 已组齐 / 规格)", span=4)
+    ops_rows = [[o["商家名"], o["新开团数"], o["已组齐数"],
+                 " · ".join(f"{sp}×{c}" for sp, c in o["规格分布"]) or "—"] for o in ops]
+    r = _write_hgrid(ws, r, ["商家", "今日新开团", "已组齐", "规格分布"], ops_rows, start_col=1)
+    r += 1
+    r = _write_section_title(ws, r, f"平台组齐时段分布(近 {HOUR_DIST_DAYS} 日 24h 累计)", span=4)
+    hmax = max(hour_dist) if hour_dist else 0
+    hrows = [[f"{h:02d}时", hour_dist[h], _bar(hour_dist[h], hmax)] for h in range(24)]
+    _write_hgrid(ws, r, ["时段", "成团数", "分布"], hrows, start_col=1)
+
+
+def _build_detail_sheet(ws, title: str, summ: dict, details: list, cols: list,
+                        is_real: bool, span: int, widths: list, miss_info: dict = None,
+                        short_name: str = None):
+    """构建单个重点商家的明细 sheet:汇总(缩到 B/C 列) + 每条组队明细(从 A 列起)。
+
+    Args:
+        ws: openpyxl worksheet。
+        title (str): sheet 顶部分区标题(含商家名与时间窗)。
+        summ (dict): 该商家汇总数据。
+        details (list[dict]): 每条组队明细。
+        cols (list[tuple]): 明细列规格(DETAIL_COLS / MODDU_DETAIL_COLS)。
+        is_real (bool): 参与人数是否真实买家口径(魔都/卡皇/尼卡 True,其余 False)。
+        span (int): 标题条覆盖列数(= 明细列数)。
+        widths (list[float]): 各列宽度。
+        miss_info (dict, optional): 购买记录覆盖检测(fetch_moddu_missing_teams 返回);非 None
+            时在汇总块下方加「购买记录覆盖检测」小节,标注漏采多少 T 并列出漏团。仅已采购买记录的
+            商家(魔都/卡皇/尼卡)传入。Defaults to None。
+        short_name (str, optional): 该商家简称(魔都/卡皇/尼卡),用于覆盖检测提示里指向对应
+            「用户排行榜(简称)」sheet。Defaults to None。
+    """
+    _set_widths(ws, widths)
+    r = _write_section_title(ws, 1, title, span=span)
+    # 汇总缩到 B/C 列:标签落宽的 B(团名列)、数值落 C,避开 A=序号 的窄列
+    r = _write_summary_block(ws, r, _summary_rows(is_real=is_real), summ, start_col=2)
+    r += 1
+    # 购买记录覆盖检测(仅已采购买记录商家传入):成交团 vs 已采购买记录,漏采的 T 逐个列出(文本向右溢出显示)
+    if miss_info is not None:
+        r = _write_section_title(ws, r, "购买记录覆盖检测(成交团 vs 已采购买记录)", span=span)
+        rank_sheet = f"用户排行榜({short_name})" if short_name else "用户排行榜"
+        cov = (f"成交 {miss_info['成交团数']} 团 · 采到购买记录 {miss_info['有记录团数']} 团 · "
+               f"漏采 {miss_info['漏采团数']} 团(用户排行见「{rank_sheet}」sheet)")
+        # 漏采 >0 时用深蓝加粗字提醒;0 时常规字
+        ws.cell(row=r, column=1, value=cov).font = FONT_HEADER if miss_info["漏采团数"] else FONT_CELL
+        r += 1
+        if miss_info["漏采团数"]:
+            ws.cell(row=r, column=1,
+                    value="漏采明细(下列团未采到购买记录,未计入用户排行;均在下方明细表中):").font = FONT_CELL
+            r += 1
+            miss_list = miss_info["漏采团列表"]
+            # 只逐条列前 MISS_LIST_CAP 团,避免新接入商家漏采过多把明细表头挤到冻结区外(滚屏失效)
+            for pc, t in miss_list[:MISS_LIST_CAP]:
+                ws.cell(row=r, column=1, value=f" - {pc} {t or ''}").font = FONT_CELL
+                r += 1
+            rest = len(miss_list) - MISS_LIST_CAP
+            if rest > 0:   # 其余漏采团不再逐条列,只汇总一句(可按成交时间在 deca_product_record 查全)
+                ws.cell(row=r, column=1,
+                        value=f" … 其余 {rest} 团略(漏采共 {len(miss_list)} 团,"
+                              f"可按成交时间在 deca_product_record 查全)").font = FONT_CELL
+                r += 1
+        r += 1
+    r = _write_section_title(ws, r, f"每条组队明细(共 {len(details)} 条,按总金额倒序)", span=span)
+    hdr_row = r                                  # 明细表头行,用于冻结
+    _write_details(ws, r, details, cols)
+    ws.freeze_panes = f"A{hdr_row + 1}"          # 冻结到明细表头,滚动时表头常驻
+
+
+def _build_user_ranking_sheet(ws, rows: list, total_users: int, top_n: int, short_name: str = "魔都",
+                              show_new_flag: bool = False, new_users: int = 0):
+    """构建「用户排行榜(简称)」sheet:顶部新客/老客/复购率汇总 + 按参与金额倒序的买家榜。
+
+    2026/08/24:由仅魔都泛化到魔都/卡皇/尼卡三家,靠 short_name 区分标题与所属商家。
+    2026/08/28:show_new_flag=True 时在榜单末尾加「新用户」标记列——当天首次在本
+    商家下单的买家(窗口起点昨13:00 前无购买记录者)末列标黑字「新」且「整行」套淡粉底
+    (参考在售报告「新品」行做法),其余留空,便于在完整排行榜里一眼识别新客
+    (判定见 fetch_moddu_user_ranking 的「是否新用户」)。
+    2026/09/14:顶部加「新客/老客/复购率」汇总行(见 docs/待办_已售报告用户新老客与复购率_20260911.md)——
+        复购率 = 回头客(老客)占比 = 老客数 ÷ 本期下单总人数 = 1 − 新客占比,与新老统计一体、无需新 SQL;
+        新客数必须用全量口径的 new_users(见 fetch_moddu_user_ranking,非截断后行求和)。全部 REAL_BUYER_MIDS 都出。
+
+    Args:
+        ws: openpyxl worksheet。
+        rows (list[dict]): 用户排行数据(fetch_moddu_user_ranking 返回,已倒序截断)。
+        total_users (int): 窗口内该商家全部参与买家数(用于标题与复购率分母)。
+        top_n (int): 榜单展示上限(用于标题展示)。
+        short_name (str, optional): 商家简称(魔都/卡皇/尼卡),用于标题。Defaults to "魔都"。
+        show_new_flag (bool, optional): 是否加「新用户」标记列(需 rows 含「是否新用户」)。Defaults to False。
+        new_users (int, optional): 全量买家里的新客数(fetch_moddu_user_ranking 第 3 个返回值,
+            截断前统计),用于顶部汇总行。Defaults to 0。
+    """
+    if show_new_flag:
+        _set_widths(ws, [8, 22, 16, 12, 16, 14, 10])   # 末列「新用户」标记
+        headers = ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费", "新用户"]
+        span = 7
+    else:
+        _set_widths(ws, [8, 22, 16, 12, 16, 14])
+        headers = ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"]
+        span = 6
+    cap = "全部展示" if top_n is None else f"取前 {min(len(rows), top_n)}"
+    # 标题精简(2026/09/14):新客数移到下方汇总行、「新」标记说明在底部注释,标题只留核心,避免过长显示不全
+    title = f"用户排行榜 · {short_name}(共 {total_users} 人参与,{cap},按参与金额倒序)"
+    next_r = _write_section_title(ws, 1, title, span=span)   # 标题在第 1 行,返回第 2 行
+    # 顶部「新客/老客/复购率」汇总行(2026/09/14):复购率=回头客(老客)占比=老客数/总人数=1-新客占比;
+    # 新客数用全量口径 new_users(非截断后行求和);total_users=0 时复购率显示「—」避免除零。
+    old_users = total_users - new_users
+    repurchase = f"{old_users / total_users * 100:.1f}%" if total_users else "—"
+    sc = ws.cell(row=next_r, column=1,
+                 value=f"新客 {new_users} 人 / 老客 {old_users} 人 / 复购率(回头客占比) {repurchase}")
+    sc.font = FONT_HEADER                                    # 深蓝加粗,醒目
+    _style_row(ws, next_r, span, fill=FILL_SUBTOTAL, border=True)   # 浅橙底铺满整条(汇总语义)
+    hdr_r = next_r + 1                                       # 表头下移一行(标题→汇总→表头)
+    grid = []
+    for i, d in enumerate(rows):
+        row = [i + 1, d["用户昵称"], d["user_id"], d["参与车数"], d["参与金额"], d["车均消费"]]
+        if show_new_flag:
+            row.append("新" if d.get("是否新用户") else "")   # 新客标「新」,老客留空
+        grid.append(row)
+    r = _write_hgrid(ws, hdr_r, headers, grid, money_cols=(4, 5), start_col=1,
+                     empty_hint="(该时段无购买记录)")
+    # 新用户行整行套淡粉底(参考在售报告「新品」行 FEF2F2,覆盖隔行斑马纹),「新」字黑色常规、居中,
+    # 从长榜里一眼认出新客
+    if show_new_flag:
+        for i, d in enumerate(rows):
+            if d.get("是否新用户"):
+                for col in range(1, len(headers) + 1):
+                    ws.cell(row=hdr_r + 1 + i, column=col).fill = FILL_NEWROW
+                ws.cell(row=hdr_r + 1 + i, column=7).alignment = ALIGN_CENTER
+    r += 1
+    note = ("注:参与金额 = Σ(购买份数 × 团单价)(deca_buy_record 无金额字段,按份数×单价估算;"
+            "固定价团精确、随机团按标称单价近似);参与车数=参与的不同团数;车均消费 = 参与金额 ÷ 参与车数。"
+            " 复购率(回头客占比) = 老客数 ÷ 本期下单总人数 = 1 − 新客占比。")
+    if show_new_flag:
+        note += (" 新客/新用户 = 按「下单时刻 purchased_at」判,窗口起点(昨13:00)前从未在本商家下过单、"
+                 "当天首次下单的买家;受购买记录采集起点限制(各商家接入时间不同),此前老客可能被误计为"
+                 "新客、复购率偏低,采集越久越准。")
+    ws.cell(row=r, column=1, value=note).font = FONT_CELL
+    ws.freeze_panes = "A4"   # 冻结标题条 + 新老/复购汇总行 + 表头
+
+
+def _build_others_sheet(ws, others: list):
+    """构建「其他商家」sheet:除重点商家外各商家一行汇总(中卡近似口径)。
+
+    Args:
+        ws: openpyxl worksheet。
+        others (list[dict]): 每商家一行的汇总。
+    """
+    _set_widths(ws, [22, 14, 9, 15, 13, 16])
+    r = _write_section_title(ws, 1, f"其他商家汇总(共 {len(others)} 家,按销售额倒序)", span=6)
+    for i, (label, _key, _money) in enumerate(OTHER_COLS):
+        hc = ws.cell(row=r, column=1 + i, value=label)
+        hc.font = FONT_HEADER; hc.fill = FILL_HEADER
+        hc.alignment = ALIGN_CENTER; hc.border = BORDER
+    r += 1
+    for oi, o in enumerate(others):
+        zebra = FILL_ZEBRA if oi % 2 == 1 else None    # 隔行淡蓝灰
+        for i, (_label, key, is_money) in enumerate(OTHER_COLS):
+            value = o.get(key)
+            cell = ws.cell(row=r, column=1 + i, value=value)
+            if is_money:
+                cell.number_format = MONEY_FMT
+            elif isinstance(value, int) and not isinstance(value, bool):
+                cell.number_format = INT_FMT           # 成团数/中卡用户数千分位
+            cell.font = FONT_CELL
+            cell.alignment = _align_by_value(value)    # 商家名左对齐、数字右对齐
+            cell.border = BORDER
+            if zebra:
+                cell.fill = zebra
+        r += 1
+    ws.freeze_panes = "A2"
+
+
+# 明细 sheet 列宽(各自独立设,不再与其它表共享):
+DETAIL_WIDTHS_MODDU = [8, 48, 16, 20, 11, 13, 9, 8, 14, 14, 19, 19, 12, 11, 11, 11, 11]  # 17列:含系列配置+里程碑
+DETAIL_WIDTHS_STD = [8, 48, 16, 20, 11, 13, 9, 8, 14, 19, 19, 12, 12]                    # 13列:含系列配置(标准)
+# 重点商家 ID → 明细 sheet 名(其余走商家名兜底)
+DETAIL_SHEET_NAMES = {"881226408": "魔都明细", "274584650": "卡皇明细", "538252487": "尼卡明细", "591544726": "文泰明细", "606370597": "魔都体育明细"}
+
+
+# 「今日新增商品」独立 sheet 列规格(2026/09/14):所有商家「当窗成交 且 当窗上架」的新品团(=已售中是新的)。
+# (表头, 明细dict取值键, 是否千分位金额);取值键为「序号」时按行号自增填充。
+NEW_SOLD_COLS = [
+    ("序号", "序号", False), ("商家", "商家", False), ("团名(商品标题)", "团名", False),
+    ("系列", "系列", False), ("系列配置", "系列配置", False), ("品类", "品类", False), ("类型", "类型", False),
+    ("单价", "单价", True), ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
+    ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
+]
+NEW_SOLD_WIDTHS = [6, 20, 46, 16, 20, 10, 11, 12, 9, 8, 14, 19, 19, 12]   # 与列一一对应
+
+
+def fetch_new_sold_products(pool) -> tuple[list[dict], dict]:
+    """取全平台「当窗成交 且 当窗上架」的新品团(=已售中哪些是新的),按成交时间倒序,并附顶部统计。
+
+    口径(主公 2026/09/14 定):成交(completed_at 落在报告窗口)且开售(sale_start_at 也落在同窗口)的团——
+    当天上架当天就卖掉的新车,区别于「昨天上架、挂到今天才组齐」的老车。跨全部商家(平台级)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[list[dict], dict]: (rows, stats)。
+            rows:每团 商家/团名/系列/系列配置/品类/类型/单价/总份数/进度/总金额/开售时间/成交时间/售卖时长。
+            stats:顶部统计 = 新品团数/新品GMV/涉及商家数/当日成交总团数/当日成交总GMV/团数占比/GMV占比。
+    """
+    win_start = "(CURDATE() - INTERVAL 1 DAY) + INTERVAL 13 HOUR"
+    sql = f"""
+    SELECT p.merchant_name, p.merchant_user_id, p.title, p.series_name, p.series_config, p.category,
+           p.spec_name, p.unit_price, p.sold_count, p.card_count,
+           ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+           p.sale_start_at, p.completed_at,
+           TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at) AS duration_secs
+    FROM deca_product_record p
+    WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+      AND p.sale_start_at >= {win_start}          -- 新品:开售也落在本窗口=当窗上架
+    ORDER BY p.completed_at DESC
+    """
+    rows = pool.select_all(sql) or []
+    result, new_gmv, merchants = [], 0.0, set()
+    for (mname, mid, title, series, series_config, category, spec, price, sold, card,
+         amount, start, completed, duration_secs) in rows:
+        progress = round(sold / card * 100, 1) if card else None
+        amt = float(amount) if amount is not None else 0.0
+        new_gmv += amt
+        merchants.add(mid)
+        result.append({
+            "商家": mname or mid, "团名": title, "系列": series, "系列配置": series_config,
+            "品类": category, "类型": spec, "单价": price, "总份数": card, "进度": progress,
+            "总金额": amount, "开售时间": start, "成交时间": completed,
+            "售卖时长": _fmt_duration(duration_secs),
+        })
+    # 当日成交大盘(全部成交团,作占比分母)
+    tot = pool.select_one(
+        f"SELECT COUNT(*), ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count*p.unit_price)),2) "
+        f"FROM deca_product_record p WHERE {WIN_P} "
+        f"AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL")
+    tot_cnt = int(tot[0]) if tot and tot[0] is not None else 0
+    tot_gmv = float(tot[1]) if tot and tot[1] is not None else 0.0
+    stats = {
+        "新品团数": len(result), "新品GMV": round(new_gmv, 2), "涉及商家数": len(merchants),
+        "当日成交总团数": tot_cnt, "当日成交总GMV": round(tot_gmv, 2),
+        "团数占比": (len(result) / tot_cnt if tot_cnt else None),
+        "GMV占比": (new_gmv / tot_gmv if tot_gmv else None),
+    }
+    return result, stats
+
+
+def _build_new_sold_sheet(ws, rows: list, stats: dict, win: tuple):
+    """构建「今日新增商品」独立 sheet:顶部统计 + 全平台新品成交团清单(所有商家)。
+
+    Args:
+        ws: openpyxl worksheet。
+        rows (list[dict]): fetch_new_sold_products 返回的新品团列表(按成交时间倒序)。
+        stats (dict): 顶部统计信息(新品团数/GMV/占比/涉及商家数等)。
+        win (tuple[str, str]): (成交窗起, 成交窗止),用于标题展示。
+    """
+    _set_widths(ws, NEW_SOLD_WIDTHS)
+    span = len(NEW_SOLD_COLS)
+    r = _write_section_title(
+        ws, 1, f"今日新增商品(已售中的新品:当窗上架且当窗成交,成交窗 {win[0]} ~ {win[1]})", span=span)
+    # 顶部统计块(竖排 标签/数值):新品团数/GMV/涉及商家数/占当日成交比例
+    # 占比存真小数(如 0.768)+百分比格式,保持与上方金额/计数同为数字、右对齐,视觉整齐(不存字符串"76.8%"避免左对齐突兀)
+    stat_rows = [
+        ("新品团数", stats["新品团数"], None), ("新品GMV", stats["新品GMV"], MONEY_FMT),
+        ("涉及商家数", stats["涉及商家数"], None),
+        ("当日成交总团数", stats["当日成交总团数"], None), ("当日成交总GMV", stats["当日成交总GMV"], MONEY_FMT),
+        ("新品团数占比", stats["团数占比"], "0.0%"), ("新品GMV占比", stats["GMV占比"], "0.0%"),
+    ]
+    for label, value, fmt in stat_rows:
+        # 统计块右移到 B/C 列:标签落宽的 B 列(20)完整显示,避开 A=序号 的窄列(6)被截断
+        lc = ws.cell(row=r, column=2, value=label)
+        lc.font = FONT_HEADER; lc.fill = FILL_HEADER; lc.alignment = ALIGN_LEFT; lc.border = BORDER
+        vc = ws.cell(row=r, column=3, value=value if value is not None else "—")
+        if fmt and value is not None:
+            vc.number_format = fmt                       # 金额千分位 / 占比百分比
+        elif isinstance(value, int) and not isinstance(value, bool):
+            vc.number_format = INT_FMT
+        vc.font = FONT_CELL; vc.fill = FILL_SUBTOTAL
+        vc.alignment = ALIGN_RIGHT if value is not None else ALIGN_LEFT   # 数字统一右对齐,缺失"—"左对齐
+        vc.border = BORDER
+        r += 1
+    r += 1
+    r = _write_section_title(ws, r, f"新品成交明细(共 {len(rows)} 个,按成交时间倒序)", span=span)
+    hdr_row = r
+    _write_details(ws, r, rows, NEW_SOLD_COLS)   # 复用明细渲染(表头+行+序号+斑马纹)
+    ws.freeze_panes = f"A{hdr_row + 1}"          # 冻结标题条 + 统计块 + 明细表头
+
+
+def build_report(pool, out: str):
+    """汇总各段数据并生成多 Sheet Excel 报告(每一大项一个 sheet,各自独立列宽)。
+
+    Sheet 顺序(2026/08/24 起卡皇/尼卡、08/25 起文泰均走真实买家扩展明细 + 各带用户排行榜):
+        平台总览 / 品类·系列榜 / 商家GMV榜 / 运营节奏 /
+        魔都明细 / 用户排行榜(魔都) / 卡皇明细 / 用户排行榜(卡皇) / 尼卡明细 / 用户排行榜(尼卡) /
+        文泰明细 / 用户排行榜(文泰) / 其他商家。
+        规则:FOCUS_MERCHANTS 逐个出明细 sheet;属 REAL_BUYER_MIDS 的商家紧跟其「用户排行榜(简称)」。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+    """
+    # 报告前顺带把新成交团/在售品的品类落库到 category 列(方便按品类查库);只补 NULL 行、成本低
+    try:
+        n_cat = backfill_category(pool, only_null=True)
+        if n_cat:
+            logger.info(f"品类字段回填 {n_cat} 行(category IS NULL)")
+    except Exception as e:
+        logger.warning(f"品类字段回填跳过: {e}")   # 回填失败不阻塞报告产出
+    win = get_window(pool)
+    platform = fetch_platform_summary(pool)
+    compare = fetch_groupbuy_compare(pool)                       # 当日 vs 昨日组齐环比
+    cat_rows, cat_total, series_rows, series_total = fetch_category_series(pool, TOP_SERIES)  # 品类汇总 + 系列榜
+    mrank_rows, mrank_total, mrank_conc = fetch_merchant_gmv_ranking(pool)  # 商家 GMV 榜 + 集中度
+    ops = [fetch_focus_ops_snapshot(pool, mid) for mid in FOCUS_MERCHANTS]  # 重点商家运营快照
+    hour_dist = fetch_completion_hour_dist(pool, HOUR_DIST_DAYS)  # 组齐时段 24h 分布
+    new_sold, new_sold_stats = fetch_new_sold_products(pool)      # 今日新增商品(已售中的新品,全平台)
+    # 已采购买记录的商家(REAL_BUYER_MIDS=魔都/卡皇/尼卡)明细走扩展版(带参与人数 + 进度里程碑),
+    # 其余重点商家走标准明细(中卡近似)
+    focus = []
+    for mid in FOCUS_MERCHANTS:
+        summ = fetch_merchant_summary(pool, mid)
+        if mid in REAL_BUYER_MIDS:
+            focus.append((mid, summ, fetch_moddu_details(pool, mid), MODDU_DETAIL_COLS))
+        else:
+            focus.append((mid, summ, fetch_merchant_details(pool, mid), DETAIL_COLS))
+    others = fetch_other_merchants(pool, FOCUS_MERCHANTS)
+    # 各已采购买记录商家各自算:用户排行(按参与金额倒序) + 购买记录覆盖检测(成交团 vs 已采购买记录,看漏几个 T)
+    rank_by_mid = {}       # {mid: (rows, total_users)}
+    miss_by_mid = {}       # {mid: miss_info}
+    for mid in FOCUS_MERCHANTS:
+        if mid in REAL_BUYER_MIDS:
+            rank_by_mid[mid] = fetch_moddu_user_ranking(pool, mid, TOP_USERS)
+            miss_by_mid[mid] = fetch_moddu_missing_teams(pool, mid)
+
+    # 口径脚注(放平台总览底部;解释两种「参与人数」口径的差别)。
+    # 真实买家口径商家名按 FOCUS 顺序取 REAL_BUYER_MIDS 的简称动态拼接,新增商家自动纳入文案、无需再改此处。
+    real_names = "/".join(MERCHANT_SHORT_NAMES.get(m, m) for m in FOCUS_MERCHANTS if m in REAL_BUYER_MIDS)
+    notes = [
+        f"注:① {real_names} 各家「参与人数(真实买家)」= deca_buy_record 真实购买记录去重买家(跨其全部成交团);"
+        "各商家明细「参与人数(本团)」为各团单独去重买家,故明细逐团相加(人次) ≥ 汇总(跨团去重人头)。"
+        "(新接入商家历史团购买记录可能为 0,随后续采集逐日补齐。)",
+        f"  ② 平台大盘参与人数 = {real_names} 真实买家 + 其他商家中卡去重(两口径人群标识不同、且各真实买家商家间"
+        "也未跨商家去重,直接相加,属近似上界)。",
+        "  ③ 其他商家未采购买记录,「中卡用户数(近似)」= 拆卡报告 hit_user_nickname 去重(仅报告命中/中卡用户,"
+        "非真实参团人头,偏低),其「人均消费(按中卡近似)」据此计算、偏高,仅供参考。",
+    ]
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "平台总览"
+    _build_overview_sheet(ws, win, platform, compare, mrank_conc, notes)
+    # 品类·系列榜(Excel sheet 名禁用「/」,故用中点「·」)
+    _build_category_series_sheet(wb.create_sheet("品类·系列榜"), cat_rows, cat_total, series_rows, series_total)
+    _build_mrank_sheet(wb.create_sheet("商家GMV榜"), mrank_rows, mrank_total)
+    _build_ops_sheet(wb.create_sheet("运营节奏"), ops, hour_dist)
+    # 今日新增商品:全平台「已售中的新品」(当窗上架且当窗成交),放在重点商家明细 sheet 之前
+    _build_new_sold_sheet(wb.create_sheet("今日新增商品"), new_sold, new_sold_stats, win)
+    # 每个重点商家单独一个明细 sheet(各自独立列宽,互不迁就)
+    for (mid, summ, details, cols) in focus:
+        sheet_name = DETAIL_SHEET_NAMES.get(mid, f"{summ['商家名'][:8]}明细")
+        ws_d = wb.create_sheet(sheet_name)
+        is_real = (mid in REAL_BUYER_MIDS)
+        widths = DETAIL_WIDTHS_MODDU if is_real else DETAIL_WIDTHS_STD
+        short = MERCHANT_SHORT_NAMES.get(mid)
+        # 已采购买记录商家的明细尾部附「购买记录覆盖检测」(漏采团数);其余商家无购买记录、不检测
+        miss = miss_by_mid.get(mid)
+        _build_detail_sheet(ws_d, f"{summ['商家名']} · 汇总(成交时间窗 {win[0]} ~ {win[1]})",
+                            summ, details, cols, is_real, len(cols), widths,
+                            miss_info=miss, short_name=short)
+        # 已采购买记录商家的明细后紧跟其「用户排行榜(简称)」sheet,让同商家相关表相邻
+        if mid in REAL_BUYER_MIDS:
+            rank_rows, rank_total, rank_new = rank_by_mid[mid]
+            # 全部真实买家商家:顶部出「新客/老客/复购率」汇总 + 逐行「新」标记(2026/09/14,数据已具备、口径统一)
+            _build_user_ranking_sheet(wb.create_sheet(f"用户排行榜({short})"),
+                                      rank_rows, rank_total, TOP_USERS, short_name=short,
+                                      show_new_flag=True, new_users=rank_new)
+    _build_others_sheet(wb.create_sheet("其他商家"), others)
+
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成已售每日报告并发送到微信群(只发 Excel)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    # 绝对路径:不受运行目录影响、便于企微上传定位;文件名带当天日期避免覆盖
+    out_file = os.path.abspath(f"{OUT_PREFIX}_{date.today():%Y%m%d}.xlsx")
+    build_report(pool, out_file)
+    log.info(f"报告已生成 -> {out_file}")
+
+    # 发企微群(只发 Excel;失败仅告警,不影响报告产出)
+    if SEND_WECHAT:
+        try:
+            from auto_send_wx_msg import send_wechat_group_file
+            send_wechat_group_file(log=log, file_path=out_file)   # 只发 Excel,不发图
+        except Exception as e:
+            log.warning(f"企微发送跳过: {e}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成 + 发送(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每天 09:10 生成已售报告并发送到微信群。
+
+    错开在售报告(on_sale 09:00 发)10 分钟发送,两份报告到群时间分开、便于查阅(企微为 webhook 直发,无 UI 冲突)。
+    """
+    # run_once(logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().day.at("09:10").do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    # schedule_task()
+    main()

+ 97 - 0
deca_auto_spider/reports/sold_report/export_teams_excel.py

@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""从 球队.json 解析 teams 数据(中文名/英文名/价格/数量)并导出到 Excel。
+
+球队.json 实为 .py 风格文本:真正的 JSON 存放在 `json = \"\"\"...\"\"\"` 三引号块里,
+teams 位于 data.remainingRandomTeamSnapshot.teams。字段对应:
+    中文名 teamNameZh / 英文名 teamNameEn / 价格 unitPrice / 数量 availableStock
+"""
+import re
+import json
+import sys
+
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment
+
+import os
+os.chdir(os.path.dirname(os.path.abspath(__file__)))  # CWD 固定本目录:读 球队.json / 写 球队.xlsx 都在此
+
+SRC_FILE = "球队.json"          # 源文件(含三引号 JSON 块)
+OUT_FILE = "球队.xlsx"          # 导出的 Excel
+HEADERS = ["中文名", "英文名", "价格", "数量"]  # 列顺序
+
+
+def extract_teams(src: str) -> list[dict]:
+    """从源文件中抽取三引号 JSON 并取出 teams 列表。
+
+    Args:
+        src (str): 源文件路径。
+
+    Returns:
+        list[dict]: teams 原始字典列表;未找到返回空列表。
+
+    Raises:
+        ValueError: 源文件里找不到三引号包裹的 JSON 块时抛出。
+    """
+    txt = open(src, encoding="utf-8").read()
+    m = re.search(r'"""(.*?)"""', txt, re.S)  # 抓取三引号内的 JSON 文本
+    if not m:
+        raise ValueError(f"{src} 中未找到三引号包裹的 JSON 块")
+    data = json.loads(m.group(1).strip())
+    # teams 固定在 data.remainingRandomTeamSnapshot.teams
+    snapshot = (data.get("data") or {}).get("remainingRandomTeamSnapshot") or {}
+    return snapshot.get("teams") or []
+
+
+def export_excel(teams: list[dict], out: str):
+    """把 teams 的四个字段写入 Excel(带表头、加粗、列宽自适应)。
+
+    Args:
+        teams (list[dict]): extract_teams 返回的球队字典列表。
+        out (str): 导出的 xlsx 文件路径。
+    """
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "球队"
+
+    # 表头:加粗 + 居中
+    ws.append(HEADERS)
+    for cell in ws[1]:
+        cell.font = Font(bold=True)
+        cell.alignment = Alignment(horizontal="center")
+
+    # 数据行:价格转 float 便于 Excel 计算,数量转 int
+    for t in teams:
+        ws.append([
+            t.get("teamNameZh"),
+            t.get("teamNameEn"),
+            float(t["unitPrice"]) if t.get("unitPrice") is not None else None,
+            t.get("availableStock"),
+        ])
+
+    # 简单列宽自适应(按各列最长字符估算,中文按 2 宽计)
+    for col_idx, header in enumerate(HEADERS, 1):
+        max_len = max(
+            [len(str(header).encode("gbk", "ignore"))] +
+            [len(str(ws.cell(row=r, column=col_idx).value or "").encode("gbk", "ignore"))
+             for r in range(2, ws.max_row + 1)]
+        )
+        ws.column_dimensions[chr(64 + col_idx)].width = max_len + 2
+
+    wb.save(out)
+
+
+def main():
+    """解析 球队.json 并导出 球队.xlsx,打印导出条数。"""
+    teams = extract_teams(SRC_FILE)
+    if not teams:
+        print("未解析到 teams 数据,退出")
+        sys.exit(1)
+    export_excel(teams, OUT_FILE)
+    print(f"已导出 {len(teams)} 条球队数据 -> {OUT_FILE}")
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
deca_auto_spider/reports/sold_report/logs/daily_report_20260908.log


+ 21 - 0
deca_auto_spider/reports/sold_report/logs/daily_report_20260911.log

@@ -0,0 +1,21 @@
+[2026-09-11 10:56:23.078] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 10:56:23.131] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 10:56:23.184] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 10:56:23.285] INFO 品类字段回填 5 行(category IS NULL)
+[2026-09-11 10:56:25.836] INFO 报告已生成 -> D:\work\2026-09-08(deca_auto_spider)\reports\sold_report\得卡已售每日报告_20260911.xlsx
+[2026-09-11 14:31:03.281] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 14:31:04.204] INFO 品类字段回填 9 行(category IS NULL)
+[2026-09-11 14:31:39.585] INFO 报告已生成 -> D:\work\2026-09-08(deca_auto_spider)\reports\sold_report\得卡已售每日报告_20260911.xlsx
+[2026-09-11 14:33:08.404] INFO 报告已生成 -> D:\work\2026-09-08(deca_auto_spider)\reports\sold_report\得卡已售每日报告_20260911.xlsx
+[2026-09-11 17:46:54.296] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.572] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.634] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.703] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.767] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.871] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.930] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:54.989] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:55.058] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:55.119] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-11 17:46:55.182] INFO 品类字段回填 129 行(category IS NULL)
+[2026-09-11 17:47:20.493] INFO 报告已生成 -> D:\work\2026-09-08(deca_auto_spider)\reports\sold_report\得卡已售每日报告_20260911.xlsx

+ 15 - 0
deca_auto_spider/reports/sold_report/logs/daily_report_20260914.log

@@ -0,0 +1,15 @@
+[2026-09-14 11:07:51.860] INFO 报告已生成 -> D:\work\2026-09-08(deca_auto_spider)\reports\sold_report\得卡已售每日报告_20260914.xlsx
+[2026-09-14 11:07:52.965] INFO 正在发送企微文件: 得卡已售每日报告_20260914.xlsx
+[2026-09-14 11:07:53.807] SUCCESS 企微文件发送成功: 得卡已售每日报告_20260914.xlsx
+[2026-09-14 13:23:33.326] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 13:23:33.396] INFO 品类字段回填 1 行(category IS NULL)
+[2026-09-14 17:23:45.553] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.612] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.662] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.709] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.763] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.876] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.920] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:23:45.970] INFO 品类字段回填 57 行(category IS NULL)
+[2026-09-14 17:57:53.722] INFO >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+[2026-09-14 17:57:53.776] INFO 品类字段回填 1 行(category IS NULL)

+ 0 - 0
deca_auto_spider/reports/sold_report/logs/scheduler_20260908.log


+ 0 - 0
deca_auto_spider/reports/sold_report/logs/weekly_report_20260908.log


+ 365 - 0
deca_auto_spider/reports/sold_report/moddu_miss_capture_report.py

@@ -0,0 +1,365 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/14
+"""魔都兄弟球星卡(881226408) · 购买记录漏采分析报告(Excel)。
+
+用平台权威「已售份数」(deca_product_record.sold_count) 比对购买记录采到的份数
+(deca_buy_record.card_count 求和),量化购买记录采集的完整度(份数漏采率),并据此
+估算真实买家数与买家漏采率。
+
+统计范围:成交完成时间 completed_at 落在 [昨天 17:00:00, 今天 03:00:00](含两端)的
+          魔都成交团(与已售每日报告口径一致)。
+
+口径说明:
+    - 权威已售份数 = deca_product_record.sold_count(平台动态值,售罄团 = card_count)。
+    - 采到份数     = SUM(deca_buy_record.card_count)(同一买家多次购买按多行累加)。
+    - 份数覆盖率   = 采到份数 / 权威已售份数;份数漏采率 = 1 - 覆盖率(份数层面可精确测量)。
+    - 采到买家     = COUNT(DISTINCT user_id)(跨该团去重)。
+    - 估真实买家   = 权威已售份数 / (采到份数 / 采到买家)  ——按「采到样本的份/人」外推,近似值。
+                     漏采的多为只买 1~2 份、在两次轮询间一闪而过的散户,其份/人更低,故该估算
+                     偏保守,真实买家漏采率大概率高于此估值(即本估算是买家漏采率的下界)。
+
+输出:魔都购买记录漏采分析_YYYYMMDD.xlsx(两个 Sheet:漏采汇总 / 逐团漏采明细)。
+从项目根或 stats 目录运行:python moddu_miss_capture_report.py(读运行目录 application.yml)。
+"""
+import os
+import sys
+from datetime import date
+
+# 挂靠新项目根:CWD 固定本目录(输出 xlsx 落本目录;application.yml 在本目录)
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+from loguru import logger
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+from mysql_pool import MySQLConnectionPool
+
+sys.stdout.reconfigure(encoding="utf-8")  # Windows 控制台按 UTF-8 输出中文,避免 GBK 报错
+
+MODDU_MID = "881226408"          # 魔都兄弟球星卡商家 ID
+BIG_TEAM_SHARES = 1000           # 「大团」份数阈值:>1000 视为原箱类大团,其余为小团
+
+# 时间窗(p 别名):[昨天17:00, 今天03:00],与已售每日报告一致
+WIN_P = ("p.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR "
+         "AND p.completed_at <= CURDATE() + INTERVAL 3 HOUR")
+
+# ---- 样式 ----
+FONT_TITLE = Font(bold=True, size=14)
+FONT_HEADER = Font(bold=True)
+FILL_HEADER = PatternFill("solid", fgColor="D9E1F2")      # 表头浅蓝
+FILL_SUBTOTAL = PatternFill("solid", fgColor="FCE4D6")    # 汇总值浅橙
+FILL_BAD = PatternFill("solid", fgColor="F8CBAD")         # 漏采严重(覆盖<50%)标红橙
+THIN = Side(style="thin", color="BFBFBF")
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
+PCT_FMT = "0.0%"
+ALIGN_LEFT = Alignment(horizontal="left", vertical="center")
+
+# 逐团明细列:(表头, 取值键, 数字格式 None/'pct')
+DETAIL_COLS = [
+    ("序号", "序号", None), ("团名(商品标题)", "团名", None), ("类型", "类型", None),
+    ("随机团", "随机团", None), ("单价", "单价", None), ("总份数", "总份数", None),
+    ("已售份数(权威)", "已售份数", None), ("采到份数", "采到份数", None),
+    ("份数覆盖率", "份数覆盖率", "pct"), ("漏采份数", "漏采份数", None),
+    ("采到买家", "采到买家", None), ("估真实买家", "估真实买家", None),
+    ("估漏采买家", "估漏采买家", None), ("买家覆盖率", "买家覆盖率", "pct"),
+    ("成交时间", "成交时间", None),
+]
+
+
+def fetch_moddu_capture(pool) -> list[dict]:
+    """取魔都时间窗内每个成交团的「权威已售份数 vs 采到份数/买家」。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        list[dict]: 每元素为一个团的对比数据,按漏采份数倒序。
+    """
+    sql = f"""
+    SELECT
+        p.title, p.spec_name, p.unit_price, p.card_count, p.sold_count,
+        (p.team_total_amount IS NOT NULL)                                    AS is_random,
+        p.completed_at,
+        (SELECT COALESCE(SUM(b.card_count), 0) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                             AS cap_shares,
+        (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                             AS cap_buyers
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+      AND p.sold_count IS NOT NULL
+    ORDER BY (p.sold_count - (SELECT COALESCE(SUM(b.card_count), 0)
+              FROM deca_buy_record b WHERE b.product_code = p.product_code)) DESC
+    """
+    rows = pool.select_all(sql, (MODDU_MID,)) or []
+    result = []
+    for (title, spec, price, card, sold, is_random, completed,
+         cap_shares, cap_buyers) in rows:
+        sold = int(sold or 0)
+        cap_shares = int(cap_shares or 0)
+        cap_buyers = int(cap_buyers or 0)
+        cover = cap_shares / sold if sold else None            # 份数覆盖率
+        # 估真实买家:按「采到样本份/人」外推 —— 采到买家为 0 时无法估算,留空
+        per_user = cap_shares / cap_buyers if cap_buyers else None
+        est_buyers = round(sold / per_user) if per_user else None
+        est_miss = (est_buyers - cap_buyers) if est_buyers is not None else None
+        buyer_cover = (cap_buyers / est_buyers) if est_buyers else None
+        result.append({
+            "团名": title, "类型": spec,
+            "随机团": "是" if is_random else "",
+            "单价": float(price) if price is not None else None,
+            "总份数": int(card or 0), "已售份数": sold, "采到份数": cap_shares,
+            "份数覆盖率": cover, "漏采份数": sold - cap_shares,
+            "采到买家": cap_buyers, "估真实买家": est_buyers,
+            "估漏采买家": est_miss, "买家覆盖率": buyer_cover,
+            "成交时间": completed,
+        })
+    return result
+
+
+def _bucket(cover: float) -> str:
+    """把份数覆盖率归入分档标签。
+
+    Args:
+        cover (float): 份数覆盖率(0~1)。
+
+    Returns:
+        str: 分档标签,如 "≥90%"。
+    """
+    if cover >= 0.9:
+        return "≥90%"
+    if cover >= 0.7:
+        return "70~90%"
+    if cover >= 0.5:
+        return "50~70%"
+    if cover >= 0.2:
+        return "20~50%"
+    return "<20%"
+
+
+def build_summary(details: list[dict]) -> dict:
+    """据逐团明细汇总总体漏采指标(份数层面精确 + 买家层面估算)。
+
+    Args:
+        details (list[dict]): fetch_moddu_capture 的返回。
+
+    Returns:
+        dict: 含总体/大团/小团/分档/买家估算等汇总字段。
+    """
+    auth = sum(d["已售份数"] for d in details)          # 权威已售份数合计
+    cap = sum(d["采到份数"] for d in details)            # 采到份数合计
+    big = [d for d in details if d["已售份数"] > BIG_TEAM_SHARES]
+    small = [d for d in details if d["已售份数"] <= BIG_TEAM_SHARES]
+
+    def _cov(group):
+        a = sum(d["已售份数"] for d in group)
+        c = sum(d["采到份数"] for d in group)
+        return a, c, (c / a if a else None)
+
+    big_a, big_c, big_cov = _cov(big)
+    small_a, small_c, small_cov = _cov(small)
+
+    buckets = {"≥90%": 0, "70~90%": 0, "50~70%": 0, "20~50%": 0, "<20%": 0}
+    for d in details:
+        if d["份数覆盖率"] is not None:
+            buckets[_bucket(d["份数覆盖率"])] += 1
+
+    # 买家层面:跨团去重买家(真实报告口径) + 按整体份/人外推的真实买家数
+    # 注:采到买家总数需跨团去重,不能用逐团 cap_buyers 相加(会重复计跨团买家)
+    return {
+        "团数": len(details), "权威已售份数": auth, "采到份数": cap,
+        "份数覆盖率": (cap / auth if auth else None),
+        "份数漏采率": (1 - cap / auth if auth else None),
+        "大团数": len(big), "大团份数": big_a, "大团采到": big_c, "大团覆盖率": big_cov,
+        "小团数": len(small), "小团份数": small_a, "小团采到": small_c, "小团覆盖率": small_cov,
+        "分档": buckets,
+    }
+
+
+def fetch_distinct_buyers(pool) -> int:
+    """取魔都时间窗内跨全部成交团去重的采到买家数。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 去重买家数(deca_buy_record.user_id)。
+    """
+    sql = f"""
+    SELECT COUNT(DISTINCT b.user_id)
+    FROM deca_buy_record b
+    JOIN deca_product_record p ON p.product_code = b.product_code
+    WHERE p.merchant_user_id = %s AND {WIN_P}
+    """
+    row = pool.select_all(sql, (MODDU_MID,))
+    return int(row[0][0]) if row and row[0][0] is not None else 0
+
+
+def _style_row(ws, r, ncol, font=None, fill=None):
+    """给某行前 ncol 个单元格套字体/填充/边框。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 行号。
+        ncol (int): 列数。
+        font (Font, optional): 字体。Defaults to None。
+        fill (PatternFill, optional): 填充。Defaults to None。
+    """
+    for c in range(1, ncol + 1):
+        cell = ws.cell(row=r, column=c)
+        if font:
+            cell.font = font
+        if fill:
+            cell.fill = fill
+        cell.border = BORDER
+
+
+def write_report(details: list[dict], summary: dict, distinct_buyers: int,
+                 win_start: str, win_end: str, out: str):
+    """把汇总与逐团明细写成两个 Sheet 的 Excel。
+
+    Args:
+        details (list[dict]): 逐团对比数据。
+        summary (dict): build_summary 结果。
+        distinct_buyers (int): 跨团去重采到买家数。
+        win_start (str): 时间窗起点。
+        win_end (str): 时间窗终点。
+        out (str): 输出 xlsx 路径。
+    """
+    per_user = summary["采到份数"] / distinct_buyers if distinct_buyers else None  # 整体份/人
+    est_true = round(summary["权威已售份数"] / per_user) if per_user else None      # 估真实买家(去重)
+    est_miss = (est_true - distinct_buyers) if est_true else None
+    buyer_miss_rate = (1 - distinct_buyers / est_true) if est_true else None
+
+    wb = Workbook()
+
+    # ---------- Sheet1 漏采汇总 ----------
+    ws = wb.active
+    ws.title = "漏采汇总"
+    r = 1
+    ws.cell(row=r, column=1,
+            value=f"魔都兄弟球星卡 · 购买记录漏采分析(成交时间窗 {win_start} ~ {win_end})")
+    ws.cell(row=r, column=1).font = FONT_TITLE
+    r += 2
+
+    def kv(label, value, fmt=None, bad=False):
+        """写一行「指标名 + 值」。"""
+        nonlocal r
+        lc = ws.cell(row=r, column=1, value=label)
+        lc.font = FONT_HEADER
+        lc.fill = FILL_HEADER
+        lc.border = BORDER
+        vc = ws.cell(row=r, column=2, value=value)
+        if fmt == "pct":
+            vc.number_format = PCT_FMT
+        vc.fill = FILL_BAD if bad else FILL_SUBTOTAL
+        vc.alignment = ALIGN_LEFT
+        vc.border = BORDER
+        r += 1
+
+    ws.cell(row=r, column=1, value="一、份数层面(权威 sold_count 比对,精确)").font = FONT_HEADER
+    r += 1
+    kv("成交团数", summary["团数"])
+    kv("权威已售份数(平台)", summary["权威已售份数"])
+    kv("购买记录采到份数", summary["采到份数"])
+    kv("份数覆盖率", summary["份数覆盖率"], "pct")
+    kv("份数漏采率", summary["份数漏采率"], "pct", bad=True)
+    r += 1
+    ws.cell(row=r, column=1, value="二、大团 vs 小团(漏采集中在大团)").font = FONT_HEADER
+    r += 1
+    kv(f"大团(>{BIG_TEAM_SHARES}份,原箱类)覆盖率",
+       summary["大团覆盖率"], "pct", bad=True)
+    kv(" ·大团数 / 份数 / 采到",
+       f"{summary['大团数']} 团 / {summary['大团份数']} / {summary['大团采到']}")
+    kv(f"小团(≤{BIG_TEAM_SHARES}份)覆盖率", summary["小团覆盖率"], "pct")
+    kv(" ·小团数 / 份数 / 采到",
+       f"{summary['小团数']} 团 / {summary['小团份数']} / {summary['小团采到']}")
+    r += 1
+    ws.cell(row=r, column=1, value="三、每团份数覆盖率分布").font = FONT_HEADER
+    r += 1
+    for k, v in summary["分档"].items():
+        kv(f" {k}", f"{v} 团")
+    r += 1
+    ws.cell(row=r, column=1, value="四、买家层面(无权威名单,估算)").font = FONT_HEADER
+    r += 1
+    kv("采到去重买家(跨团)", distinct_buyers)
+    kv("采到样本 份/人", round(per_user, 2) if per_user else None)
+    kv("估真实买家(去重,按份/人外推)", est_true)
+    kv("估漏采买家", est_miss, bad=True)
+    kv("估买家漏采率(下界)", buyer_miss_rate, "pct", bad=True)
+
+    r += 1
+    for note in [
+        "说明:份数层面用平台权威 sold_count 比对,精确;买家层面平台不返回全量买家名单,",
+        "   只能按「采到样本的份/人」外推估算。漏采多为买 1~2 份、在两次轮询间一闪而过的散户,",
+        "   其份/人更低,故估买家漏采率为下界,真实漏采大概率更高。",
+        "   大团(原箱)买家名单大、成交快,购买记录接口只能采到一小部分,是漏采主因。",
+    ]:
+        ws.cell(row=r, column=1, value=note)
+        r += 1
+
+    ws.column_dimensions["A"].width = 34
+    ws.column_dimensions["B"].width = 26
+
+    # ---------- Sheet2 逐团漏采明细 ----------
+    ws2 = wb.create_sheet("逐团漏采明细")
+    for i, (header, _key, _fmt) in enumerate(DETAIL_COLS):
+        ws2.cell(row=1, column=1 + i, value=header)
+    _style_row(ws2, 1, len(DETAIL_COLS), font=FONT_HEADER, fill=FILL_HEADER)
+    for idx, d in enumerate(details, 1):
+        rr = idx + 1
+        low = d["份数覆盖率"] is not None and d["份数覆盖率"] < 0.5  # 覆盖<50% 标红橙
+        for i, (_header, key, fmt) in enumerate(DETAIL_COLS):
+            value = idx if key == "序号" else d.get(key)
+            cell = ws2.cell(row=rr, column=1 + i, value=value)
+            if fmt == "pct" and value is not None:
+                cell.number_format = PCT_FMT
+            cell.alignment = ALIGN_LEFT
+            cell.border = BORDER
+            if low and key in ("份数覆盖率", "漏采份数"):
+                cell.fill = FILL_BAD
+    ws2.freeze_panes = "A2"
+    widths = [6, 46, 8, 7, 8, 9, 13, 10, 11, 10, 9, 11, 11, 10, 20]
+    for i, w in enumerate(widths):
+        ws2.column_dimensions[chr(65 + i)].width = w
+
+    wb.save(out)
+
+
+def main():
+    """连库、算漏采、导出 Excel。"""
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    pool = MySQLConnectionPool(log=logger)
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        return
+    win = pool.select_all(
+        "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 3 HOUR")[0]
+    win_start, win_end = str(win[0]), str(win[1])
+
+    details = fetch_moddu_capture(pool)
+    summary = build_summary(details)
+    distinct_buyers = fetch_distinct_buyers(pool)
+
+    out = os.path.abspath(f"魔都购买记录漏采分析_{date.today():%Y%m%d}.xlsx")
+    write_report(details, summary, distinct_buyers, win_start, win_end, out)
+
+    # 控制台简报
+    print("=" * 56)
+    print(f"魔都购买记录漏采分析  时间窗 {win_start} ~ {win_end}")
+    print(f"成交团数            : {summary['团数']}")
+    print(f"权威已售份数        : {summary['权威已售份数']}")
+    print(f"采到份数            : {summary['采到份数']}")
+    print(f"份数覆盖率 / 漏采率 : {summary['份数覆盖率']:.1%} / {summary['份数漏采率']:.1%}")
+    print(f"大团覆盖率(>1000份) : {summary['大团覆盖率']:.1%}({summary['大团数']}团)")
+    print(f"小团覆盖率(≤1000份) : {summary['小团覆盖率']:.1%}({summary['小团数']}团)")
+    print(f"采到去重买家        : {distinct_buyers}")
+    print(f"估真实买家 / 漏采   : {summary['权威已售份数'] / (summary['采到份数'] / distinct_buyers):.0f}"
+          f" / 漏采率≈{1 - distinct_buyers / (summary['权威已售份数'] / (summary['采到份数'] / distinct_buyers)):.0%}(下界)")
+    print(f"输出文件            : {out}")
+    print("=" * 56)
+
+
+if __name__ == "__main__":
+    main()

+ 152 - 0
deca_auto_spider/reports/sold_report/moddu_user_total_report.py

@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/17
+"""得卡 DECA · 魔都(881226408)用户消费总榜(全量用户累计,非每日前 100)。
+
+与每日报告 daily_report.py 的「用户排行榜(魔都)」sheet 的区别:
+    - 每日报告:当日成交窗口 [昨 17:00, 今 06:00],按参与金额倒序取前 100。
+    - 本总榜  :累计每日窗口口径(17:00~次日 06:00)内的全部成交团,统计所有参与用户(不截断)。
+
+金额口径与每日报告一致:一个拼团商品 = 一辆车(团),按买家 user_id 聚合:
+    - 参与车数 = COUNT(DISTINCT product_code);
+    - 参与金额 = SUM(card_count × 团 unit_price)。deca_buy_record 无金额字段,故用「购买份数 ×
+      团单价」估算:固定价团精确,随机团(选队随机/剩余随机)按标称单价近似;
+    - 车均消费 = 参与金额 ÷ 参与车数。
+
+复用 daily_report 的样式常量与表格构建辅助函数,避免重复维护两套样式。
+从 stats 目录运行:python moddu_user_total_report.py(cwd=stats,读 stats/application.yml)。
+"""
+import os
+import sys
+from datetime import date
+
+from loguru import logger
+from openpyxl import Workbook
+
+# 挂靠新项目根:本目录(复用同目录 daily_report) + common 加入 path;CWD 固定本目录
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "common"))
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+import daily_report as dr
+from mysql_pool import MySQLConnectionPool
+
+MODDU_MID = "881226408"                 # 魔都兄弟球星卡商家 ID(仅其采了真实购买记录)
+OUT_PREFIX = "魔都用户消费总榜"          # 输出文件名前缀,实际文件名后缀加运行当天日期
+# 每日窗口口径:仅累计成交时间落在 [17:00, 次日 06:00] 的团(与每日报告一致;白天成交极少)
+WIN_DAILY = "(TIME(p.completed_at) >= '17:00:00' OR TIME(p.completed_at) <= '06:00:00')"
+
+
+def fetch_all_user_ranking(pool, mid: str) -> tuple[list[dict], dict]:
+    """统计魔都全量用户的累计消费排行(按参与金额倒序,不截断)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        tuple[list[dict], dict]: (rows, meta)。rows 每项含 用户昵称/user_id/参与车数/参与金额/
+            车均消费(按金额倒序、全量不截断);meta 含 用户数/团数/总金额/统计起/统计止。
+    """
+    sql = f"""
+    SELECT b.user_id, MAX(b.nickname)             AS nickname,
+           COUNT(DISTINCT b.product_code)         AS cars,
+           ROUND(SUM(b.card_count * p.unit_price), 2) AS amount
+    FROM deca_buy_record b
+    JOIN deca_product_record p ON p.product_code = b.product_code
+    WHERE p.merchant_user_id = %s AND {WIN_DAILY}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    GROUP BY b.user_id
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    total_amount = 0.0
+    for uid, nickname, cars, amount in rows:
+        amt = float(amount) if amount is not None else 0.0
+        cars = int(cars or 0)
+        total_amount += amt
+        result.append({
+            "用户昵称": nickname or "(匿名)",
+            "user_id": uid,
+            "参与车数": cars,
+            "参与金额": round(amt, 2),
+            "车均消费": round(amt / cars, 2) if cars else None,  # 车均=参与金额/参与车数
+        })
+    # 统计区间与累计团数(同一窗口口径,供表头/汇总展示)
+    span = pool.select_all(f"""
+        SELECT MIN(p.completed_at), MAX(p.completed_at), COUNT(DISTINCT p.product_code)
+        FROM deca_product_record p
+        WHERE p.merchant_user_id = %s AND {WIN_DAILY}
+          AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """, (mid,))[0]
+    meta = {"用户数": len(result), "团数": int(span[2] or 0),
+            "总金额": round(total_amount, 2),
+            "统计起": str(span[0]) if span[0] else "-",
+            "统计止": str(span[1]) if span[1] else "-"}
+    return result, meta
+
+
+def build_total_report(pool, out: str):
+    """生成魔都用户消费总榜 Excel(单 sheet:汇总 + 全量用户榜)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+    """
+    rows, meta = fetch_all_user_ranking(pool, MODDU_MID)
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "用户总榜(魔都)"
+    dr._set_widths(ws, [8, 24, 16, 12, 18, 14])
+
+    r = dr._write_section_title(
+        ws, 1,
+        f"魔都用户消费总榜(全量 {meta['用户数']} 人 · 累计 {meta['团数']} 团 · "
+        f"统计区间 {meta['统计起']} ~ {meta['统计止']})", span=6)
+
+    # 顶部汇总(竖排:总用户数 / 总团数 / 总金额;金额行单独套千分位)
+    for label, val, is_money in [("参与用户数", meta["用户数"], False),
+                                 ("累计成交团数", meta["团数"], False),
+                                 ("累计参与金额", meta["总金额"], True)]:
+        lc = ws.cell(row=r, column=1, value=label)
+        lc.font = dr.FONT_HEADER; lc.fill = dr.FILL_HEADER
+        lc.alignment = dr.ALIGN_LEFT; lc.border = dr.BORDER
+        vc = ws.cell(row=r, column=2, value=val)
+        vc.number_format = dr.MONEY_FMT if is_money else dr.INT_FMT
+        vc.font = dr.FONT_CELL; vc.fill = dr.FILL_SUBTOTAL
+        vc.alignment = dr.ALIGN_RIGHT; vc.border = dr.BORDER
+        r += 1
+    r += 1
+
+    # 全量用户榜(6 列,与每日报告同口径;数据量较大,一次写入)
+    header_row = r
+    grid = [[i + 1, d["用户昵称"], d["user_id"], d["参与车数"], d["参与金额"], d["车均消费"]]
+            for i, d in enumerate(rows)]
+    r = dr._write_hgrid(ws, header_row,
+                        ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"],
+                        grid, money_cols=(4, 5), start_col=1, empty_hint="(无购买记录)")
+    ws.freeze_panes = f"A{header_row + 1}"   # 冻结表头,滚动时常驻
+
+    # 口径注放最后
+    ws.cell(row=r + 1, column=1,
+            value="注:参与金额 = Σ(购买份数 × 团单价)(deca_buy_record 无金额字段,按份数×单价估算;"
+                  "固定价团精确、随机团按标称单价近似);仅累计成交时间落在每日窗口(17:00~次日06:00)内的团;"
+                  "参与车数=参与的不同团数;车均消费 = 参与金额 ÷ 参与车数。").font = dr.FONT_CELL
+    wb.save(out)
+
+
+def main():
+    """连库生成魔都用户消费总榜 Excel(输出到当前目录,文件名带当天日期)。"""
+    pool = MySQLConnectionPool(log=logger)
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        return
+    out = os.path.abspath(f"{OUT_PREFIX}_{date.today():%Y%m%d}.xlsx")
+    build_total_report(pool, out)
+    logger.info(f"用户消费总榜已生成 -> {out}")
+    print("已生成:", out)
+
+
+if __name__ == "__main__":
+    main()

+ 285 - 0
deca_auto_spider/reports/sold_report/monthly_report.py

@@ -0,0 +1,285 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/24
+"""得卡 DECA · 魔都兄弟球星卡「月报」统计(单 Sheet:魔都明细)。
+
+在每日报告(daily_report.py)之外新增的每月任务:把「魔都明细」sheet 的口径由「单日单场」
+放宽到「上一个完整自然月」,统计该月内魔都(881226408)所有成交组队(拼团商品)。
+
+与每日报告的关系:
+    - 明细列、汇总块、样式、里程碑用时算法,全部直接复用 daily_report,本文件不重复实现
+      渲染/样式,只重写 2 个「月口径」取数函数(把时间窗 WIN_P 换成 WIN_M)。
+    - 不含每日报告魔都明细尾部的「购买记录覆盖检测」小节(沿用周报版本)。
+    - daily_report.py 一个字不改;本文件为纯新增。
+
+时间窗口(WIN_M):上一个自然月 [上月1日 00:00:00, 本月1日 00:00:00)(左闭右开,按 completed_at 自然日历切分)。
+    基准用 MySQL DATE_SUB(CURDATE(), INTERVAL (DAYOFMONTH(CURDATE()) - 1) DAY) 取本月1日,
+    再减 1 个月得上月1日。
+    注:魔都夜间场常成交到次日凌晨,按自然日历切分时,某周日夜场溢出到周一 00:xx 的团会计入
+    对应 completed_at 所在自然月,此为月报自然月口径,非漏统计。
+
+明细列(16 列,与 daily_report 魔都明细 sheet 完全一致,见 daily_report.MODDU_DETAIL_COLS):
+    序号/团名(商品标题)/系列/类型/单价/总份数/进度%/总金额/参与人数(本团)/中卡人数/
+    开售时间/成交时间/售卖时长/到25%用时/到50%用时/到75%用时
+
+口径说明(同 daily_report):
+    - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))(随机团按 teams 精算)。
+    - 成团数 = 该月成交的拼团商品数。
+    - 参与人数(本团) = 各团 deca_buy_record 去重买家 user_id;汇总「参与人数(真实买家)」= 跨月内
+      全部成交团去重(故明细逐团相加人次 ≥ 汇总去重人头)。
+    - 中卡人数 = 该团拆卡报告 hit_user_nickname 去重(中卡近似)。
+    - 到 25/50/75% 用时 = deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+      首张快照已越阈值(坍缩)则留空。progress 表 2026/08/11 上线,更早的团相应列可能为空。
+
+从 stats 目录运行:python monthly_report.py(cwd=stats,mysql_pool 读 stats/application.yml)。
+"""
+import os
+import sys
+import time
+from datetime import timedelta
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+
+# 挂靠新项目根:sys.path 指向 common、CWD 固定本目录(与 daily_report 一致)
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), "common"))
+os.chdir(BASE_DIR)
+
+from mysql_pool import MySQLConnectionPool
+# 复用每日报告的渲染层/样式/列规格/无窗口依赖的纯算法(本文件不重复实现这些)
+from daily_report import (
+    MODDU_MID, MODDU_DETAIL_COLS, DETAIL_WIDTHS_MODDU,
+    _pack_summary, _build_detail_sheet,
+)
+
+# 日志:按天切分文件,保留 7 天(常驻定时运行)。放本文件所在目录的 logs/,不依赖 cwd。
+# 注:导入 daily_report 时其模块级已 logger.add 过每日报告 sink,这里 remove 后只保留月报 sink。
+logger.remove()
+logger.add(os.path.join(BASE_DIR, "logs", "monthly_report_{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="INFO", retention="7 day")
+
+OUT_PREFIX = "得卡-魔都-已售月报告"     # 输出文件名前缀(只查魔都一家),后缀加「上月1日_上月末日」两个日期
+# 企微发送:报告生成后把 Excel 发到企业微信群机器人(群由 auto_send_wx_msg.WEBHOOK_URL 决定,与每日报告同群)
+SEND_WECHAT = False
+
+# ---- 月时间窗(WIN_M):上一个自然月 [上月1日 00:00:00, 本月1日 00:00:00) 左闭右开 ----
+# 本月1日:DATE,不含时分秒即 00:00:00。
+_THIS_MONTH = "DATE_SUB(CURDATE(), INTERVAL (DAYOFMONTH(CURDATE()) - 1) DAY)"
+# 上月1日 = 本月1日 - 1 个月
+_LAST_MONTH = f"({_THIS_MONTH} - INTERVAL 1 MONTH)"
+
+
+def _win(alias: str) -> str:
+    """生成某表别名在「上一个自然月」窗口内的 completed_at 过滤子句。
+
+    Args:
+        alias (str): SQL 中 deca_product_record 的表别名(如 "p" / "pp")。
+
+    Returns:
+        str: 形如 "p.completed_at >= 上月1日 AND p.completed_at < 本月1日" 的过滤子句。
+    """
+    return (f"{alias}.completed_at >= {_LAST_MONTH} "
+            f"AND {alias}.completed_at < {_THIS_MONTH}")
+
+
+WIN_M = _win("p")   # 主表 p 的月窗口子句(供各取数 SQL 拼接)
+
+
+def get_month_window(pool):
+    """取上一个自然月的起止日期(供报告标题与文件名展示)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[date, date]: (上月1日 date, 上月末日 date)。上月末日 = 本月1日 - 1 天。
+    """
+    month_start, this_month = pool.select_all(
+        f"SELECT {_LAST_MONTH}, {_THIS_MONTH}")[0]
+    month_end = this_month - timedelta(days=1)   # 本月1日(右开界) 前一天即上月末日
+    return month_start, month_end
+
+
+def fetch_moddu_summary_month(pool, mid: str) -> dict:
+    """统计魔都商家「上一个自然月」的汇总(销售额/成团数/参与人数/均拼单价/人均消费)。
+
+    参与人数用 deca_buy_record 去重真实买家(跨月内全部成交团),人均消费随之按真实人头计。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 商家名/商家ID/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        MAX(p.merchant_name)                                              AS mname,
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(*)                                                          AS grp
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_M}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    mname, amount, groups = pool.select_all(sql, (mid,))[0]
+    # 参与人数(真实买家) = 月内该商家全部成交团的 deca_buy_record 去重 user_id
+    people = pool.select_all(f"""
+    SELECT COUNT(DISTINCT b.user_id)
+    FROM deca_buy_record b
+    JOIN deca_product_record pp ON pp.product_code = b.product_code
+    WHERE pp.merchant_user_id = %s AND {_win('pp')}
+    """, (mid,))[0][0] or 0
+    d = _pack_summary(amount, groups, people)
+    d["商家名"] = mname or mid
+    d["商家ID"] = mid
+    return d
+
+
+def fetch_moddu_details_month(pool, mid: str) -> list[dict]:
+    """取魔都商家「上一个自然月」内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    列与算法同 daily_report.fetch_moddu_details,仅时间窗由单日单场换为上一个自然月(WIN_M):
+        - 参与人数:deca_buy_record 去重买家 user_id(本团真实参团人头)。
+        - 到 25/50/75% 用时:deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+          首张快照已越阈值(坍缩)则留空(判定见 daily_report._milestone_used)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        list[dict]: 每条含 团名/系列/类型/单价/总份数/进度/总金额/参与人数/中卡人数/开售时间/
+            成交时间/售卖时长/到25%用时/到50%用时/到75%用时。
+    """
+    # 里程碑/时长算法直接复用 daily_report(避免重复实现坍缩判定逻辑)
+    from daily_report import _fmt_duration, _milestone_used
+    sql = f"""
+    SELECT
+        p.title, p.series_name, p.spec_name, p.unit_price, p.sold_count, p.card_count,
+        ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+        p.sale_start_at, p.completed_at,
+        TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at)            AS duration_secs,
+        (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                          AS buyers,
+        -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
+        (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
+          WHERE r.product_code = p.product_code
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
+        -- 该商品最早一条进度快照时刻:用于判定里程碑是否「坍缩」(首张快照已越阈值则用时不可信)
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code)                          AS first_cap,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 25)  AS t25,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 50)  AS t50,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 75)  AS t75
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_M}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    for (title, series, spec, price, sold, card, amount, start, completed,
+         duration_secs, buyers, hit_users, first_cap, t25, t50, t75) in rows:
+        progress = round(sold / card * 100, 1) if card else None  # 售卖进度百分比
+        result.append({
+            "团名": title, "系列": series, "类型": spec, "单价": price,
+            "总份数": card, "进度": progress, "总金额": amount,
+            "参与人数": buyers,
+            "中卡人数": hit_users,                       # 该团拆卡报告去重命中用户(中卡近似)
+            "开售时间": start, "成交时间": completed,   # 开售=sale_start_at,成交=completed_at
+            "售卖时长": _fmt_duration(duration_secs),   # 成交-开售,也即整团总时长
+            # 到 X% 用时:仅当 tX 之前还有更早快照(未坍缩)时才输出真实穿越耗时,否则留空
+            "到25%用时": _milestone_used(t25, first_cap, start),
+            "到50%用时": _milestone_used(t50, first_cap, start),
+            "到75%用时": _milestone_used(t75, first_cap, start),
+        })
+    return result
+
+
+def build_month_report(pool, out: str, title: str):
+    """生成单 Sheet(魔都明细)月报 Excel。
+
+    仅一个 sheet「魔都明细」:汇总块 + 每条组队明细(16 列),结构/样式复用
+    daily_report._build_detail_sheet。不含「购买记录覆盖检测」小节(miss_info=None)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+        title (str): sheet 顶部分区标题(含商家名与周成交时间窗)。
+    """
+    summ = fetch_moddu_summary_month(pool, MODDU_MID)
+    details = fetch_moddu_details_month(pool, MODDU_MID)
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "魔都明细"
+    # is_real=True(魔都为真实买家口径);span/widths 取魔都明细专属规格;
+    # miss_info=None → 不输出「购买记录覆盖检测」小节(沿用月报精简口径)
+    _build_detail_sheet(ws, title, summ, details, MODDU_DETAIL_COLS,
+                        True, len(MODDU_DETAIL_COLS), DETAIL_WIDTHS_MODDU, miss_info=None)
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成上一个自然月的魔都明细月报,落地到 stats 目录;默认不发送企业微信。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    log.info("开始生成魔都月报" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    month_start, month_end = get_month_window(pool)
+    title = (f"魔都兄弟球星卡 · 月汇总"
+             f"(成交自然月 {month_start} 00:00:00 ~ {month_end} 23:59:59)")
+    # 输出锚定到本脚本所在目录(stats),文件名带「上月1日_上月末日」两个日期,便于归档区分
+    out_file = os.path.join(BASE_DIR, f"{OUT_PREFIX}_{month_start:%Y%m%d}_{month_end:%Y%m%d}.xlsx")
+    build_month_report(pool, out_file, title)
+    log.info(f"月报已生成 -> {out_file}")
+
+    # 发企微群(只发 Excel;失败仅告警,不影响报告产出)——与每日报告同群同方式
+    if SEND_WECHAT:
+        try:
+            from auto_send_wx_msg import send_wechat_group_file
+            send_wechat_group_file(log=log, file_path=out_file)   # 只发 Excel,不发图
+        except Exception as e:
+            log.warning(f"企微发送跳过: {e}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每天 09:30 检查;仅每月 1 日生成上一个自然月的魔都明细月报。
+
+    当前需求是手动生成本地 Excel,不自动发送企微;如需启用定时,可由调度器调用此函数。
+    """
+    def run_on_first_day():
+        from datetime import date
+        if date.today().day == 1:
+            run_once(logger)
+
+    schedule.every().day.at("09:30").do(run_on_first_day)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 159 - 0
deca_auto_spider/reports/sold_report/run_report_for_date.py

@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/31
+"""得卡 DECA · 已售报告「按指定日期补跑」工具(本地生成,不发微信)。
+
+用途:定时任务中断、漏发时,补跑某一天「本该发出」的已售报告。
+
+原理:daily_report.py 整份报告的时间窗完全由 SQL 里的 CURDATE() 驱动
+    (报告日 D 覆盖窗口 [D-1 17:00, D 06:00])。本工具用一个「日期锚定 pool
+    包装器」拦截所有 SQL,把其中的 CURDATE() 替换为指定锚定日 DATE('YYYY-MM-DD'),
+    从而完美复现「若在该日运行时报告应有的样子」,且不改动 daily_report.py 一行。
+
+只读保证:build_report 内部会调用 backfill_category 对 category 列做 UPDATE 写库
+    (该列报告并不读取、纯即时按标题判类),本工具将其禁用为 no-op,整个补跑严格只读。
+
+用法(在 stats 目录下运行,与 application.yml 同目录):
+    python run_report_for_date.py 2026-08-30 2026-08-31   # 补跑指定日期,可多个
+    python run_report_for_date.py                          # 不传则默认补昨天+今天两份
+
+输出:stats 目录下 得卡已售每日报告_YYYYMMDD.xlsx(文件名日期 = 锚定日)。
+"""
+import os
+import sys
+from datetime import date, timedelta
+
+# 挂靠新项目根:sys.path 指向 common、CWD 固定本目录
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "common"))
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+# 复用 daily_report 的全部数据/构建逻辑;导入即触发其 logger 配置(写 ./logs 文件,不影响本文件 print)
+import daily_report as dr
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+
+class DateAnchoredPool:
+    """把传入 pool 的 SQL 里 CURDATE() 替换为指定锚定日的只读包装器。
+
+    daily_report 所有取数都经 pool.select_all / pool.select_one,且时间窗全靠 SQL 内联
+    CURDATE()。本包装器接口与真实 pool 一致,逐条改写 SQL 后转发,使整份报告按锚定日出。
+    写操作 update_one 一律拦截为 no-op,保证补跑严格只读。
+
+    Attributes:
+        _pool (MySQLConnectionPool): 被包装的真实连接池。
+        _repl (str): 用于替换 CURDATE() 的 SQL 片段,形如 DATE('2026-08-30')。
+    """
+
+    def __init__(self, pool, anchor_date: str):
+        """初始化日期锚定包装器。
+
+        Args:
+            pool (MySQLConnectionPool): 真实连接池。
+            anchor_date (str): 锚定日,格式 'YYYY-MM-DD';SQL 里 CURDATE() 将按此日计算。
+        """
+        self._pool = pool
+        self._repl = f"DATE('{anchor_date}')"   # 与 CURDATE() 同为 DATE 类型,INTERVAL 运算行为一致
+
+    def _rewrite(self, query: str) -> str:
+        """把一条 SQL 里的 CURDATE() 全部替换为锚定日。
+
+        Args:
+            query (str): 原始 SQL。
+
+        Returns:
+            str: 替换 CURDATE() 后的 SQL。
+        """
+        return query.replace("CURDATE()", self._repl)
+
+    def select_all(self, query, args=None):
+        """转发 select_all(SQL 已做 CURDATE 锚定替换)。
+
+        Args:
+            query (str): 原始 SQL。
+            args (tuple, optional): 查询参数。Defaults to None。
+
+        Returns:
+            list: 查询结果集。
+        """
+        return self._pool.select_all(self._rewrite(query), args)
+
+    def select_one(self, query, args=None):
+        """转发 select_one(SQL 已做 CURDATE 锚定替换)。
+
+        Args:
+            query (str): 原始 SQL。
+            args (tuple, optional): 查询参数。Defaults to None。
+
+        Returns:
+            tuple | None: 单行结果。
+        """
+        return self._pool.select_one(self._rewrite(query), args)
+
+    def update_one(self, query, args=None):
+        """只读补跑:拦截所有写操作为 no-op(防御性,正常路径已禁用 backfill)。
+
+        Args:
+            query (str): 被拦截的写 SQL。
+            args: 写参数。
+
+        Returns:
+            int: 恒为 0(未执行任何写)。
+        """
+        logger.warning("只读补跑:已跳过写操作(update_one)")
+        return 0
+
+    def check_pool_health(self) -> bool:
+        """转发底层连接池健康检查。
+
+        Returns:
+            bool: 连接池是否可用。
+        """
+        return self._pool.check_pool_health()
+
+
+def generate_for_date(anchor_date: str) -> str:
+    """按锚定日补跑一份已售报告到本地(不发微信)。
+
+    Args:
+        anchor_date (str): 锚定日,格式 'YYYY-MM-DD'(= 报告本该发出的那天)。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;连接池异常时返回空串。
+    """
+    real_pool = MySQLConnectionPool(log=logger)
+    if not real_pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        print(f"[FAIL] {anchor_date} 连接池异常")
+        return ""
+    pool = DateAnchoredPool(real_pool, anchor_date)
+    # 只读保证:禁用 category 回填写库(报告不读该列,禁用无影响)
+    dr.backfill_category = lambda *a, **k: 0
+    out_file = os.path.abspath(f"{dr.OUT_PREFIX}_{anchor_date.replace('-', '')}.xlsx")
+    dr.build_report(pool, out_file)
+    win = pool.select_all(
+        "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 6 HOUR")[0]
+    size_kb = os.path.getsize(out_file) / 1024
+    logger.info(f"[补跑] 锚定日 {anchor_date} 报告已生成 -> {out_file}")
+    print(f"[OK] 锚定日 {anchor_date}  窗口 {win[0]} ~ {win[1]}  ->  {out_file}  ({size_kb:.1f} KB)")
+    return out_file
+
+
+def main():
+    """命令行入口:对每个日期参数补跑一份报告;不传参则默认补昨天+今天。"""
+    args = sys.argv[1:]
+    if not args:
+        today = date.today()
+        args = [(today - timedelta(days=1)).isoformat(), today.isoformat()]
+    print(f"待补跑日期:{', '.join(args)}(本地生成,不发微信)")
+    for d in args:
+        try:
+            generate_for_date(d)
+        except Exception as e:
+            logger.exception(f"锚定日 {d} 补跑失败: {e}")
+            print(f"[FAIL] {d} 补跑失败: {e}")
+
+
+if __name__ == "__main__":
+    main()

+ 89 - 0
deca_auto_spider/reports/sold_report/run_scheduler.py

@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/24
+"""得卡 DECA · 报告统一调度启动器(一个进程同时跑:每日报告 + 魔都周报)。
+
+背景:daily_report.py 与 weekly_report.py 各自有独立的 schedule 阻塞循环,原需分别起两个进程。
+本文件把两者的 run_once 注册到同一个 schedule,单进程单循环统一调度:
+    - 每日报告 daily_report.run_once :每天 09:10(生成 + 发企微,口径见 daily_report.py)
+    - 魔都周报 weekly_report.run_once:每周一 09:20(生成 + 发企微,口径见 weekly_report.py)
+
+不改动 daily_report.py / weekly_report.py,只复用它们的 run_once(各报告仍可单独 python 直接跑)。
+
+日志:统一写 stats/logs/scheduler_YYYYMMDD.log(单进程一份,便于排障)。两个报告脚本各自
+      standalone 运行时仍写自己的日志文件,互不影响。
+
+运行:python run_scheduler.py(从 stats 目录启动;本文件会 chdir 到自身目录,保证 mysql_pool
+      读 stats/application.yml、每日报告产出也落 stats)。Ctrl+C 退出。
+"""
+import os
+import sys
+import time
+
+# 固定工作目录到本文件所在的 stats:mysql_pool 读此目录 application.yml,daily 报告(cwd 相对)产出也落此
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+os.chdir(BASE_DIR)
+# common 加入 path(auto_send_wx_msg 等公共模块在新根 common/)
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), "common"))
+
+import schedule
+from loguru import logger
+
+# 导入两个报告模块(复用其 run_once)。导入会触发各模块级 logger 配置,下面再统一覆盖为调度器日志。
+import daily_report
+import weekly_report
+import monthly_report
+
+# 统一日志:覆盖两个模块 import 时各自加的 sink,调度器只留一份合并日志
+logger.remove()
+logger.add(os.path.join(BASE_DIR, "logs", "scheduler_{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="INFO", retention="7 day")
+
+
+def _run_daily():
+    """调度回调:跑每日报告;异常内部兜底,避免单次失败拖垮整个调度循环。"""
+    try:
+        daily_report.run_once(logger)
+    except Exception as e:
+        logger.error(f"每日报告运行异常: {e}")
+
+
+def _run_weekly():
+    """调度回调:跑魔都周报;异常内部兜底,避免单次失败拖垮整个调度循环。"""
+    try:
+        weekly_report.run_once(logger)
+    except Exception as e:
+        logger.error(f"魔都周报运行异常: {e}")
+
+
+def _run_monthly():
+    """调度回调:每月 1 号跑魔都月报(非 1 号空转);异常内部兜底。"""
+    from datetime import date
+    if date.today().day != 1:      # 仅每月 1 号生成上一个自然月月报
+        return
+    try:
+        monthly_report.run_once(logger)
+    except Exception as e:
+        logger.error(f"魔都月报运行异常: {e}")
+
+
+def main():
+    """注册两个定时任务到同一 schedule 并进入单循环调度。"""
+    # 调试:需启动时立即各跑一次,取消下面注释(部署时保持注释——否则每次启动/重启都会立即补发一次报告)
+    # _run_daily()
+    # _run_weekly()
+    # _run_monthly
+    schedule.every().day.at("09:10").do(_run_daily)        # 每日报告:每天 09:10
+    schedule.every().monday.at("09:20").do(_run_weekly)    # 魔都周报:每周一 09:20(错开 10 分钟)
+    schedule.every().day.at("09:30").do(_run_monthly)      # 魔都月报:每天 09:30 检查,仅每月 1 号生成
+    logger.info("调度器启动:每日报告 每天 09:10 / 魔都周报 每周一 09:20 / 魔都月报 每月 1 号 09:30(Ctrl+C 退出)")
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 109 - 0
deca_auto_spider/reports/sold_report/stats_sold.sql

@@ -0,0 +1,109 @@
+-- ============================================================
+-- 得卡 DECA · 已售流程统计 SQL(按 completed_at 统计「昨天17点~今天6点」成交)
+-- 日期:2026/08/05
+-- 数据源:deca_product_record / deca_report_record
+-- 统计口径:
+--   0) 统计范围 = 成交完成时间 completed_at 落在 [昨天 17:00:00, 今天 06:00:00] 的已售商品
+--      —— completed_at 为 varchar 存 'YYYY-MM-DD HH:MM:SS',与 datetime 边界直接比较即可
+--      —— 起点 (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR = 昨天17:00
+--         终点 CURDATE() + INTERVAL 6 HOUR = 今天06:00(含两端)
+--      —— 表达式直接内联在每段查询,两段 SELECT 各自独立、可在 Navicat 单独选中执行
+--   1) 销售额 = SUM(sold_count * unit_price)(每商品 已售份数 × 单价 之和)
+--   2) 成团数 = 该时段成交的已售商品数(已售列表返回的都是卖过的,默认都算成团)
+--   3) 参与人数 = 该时段成交商品对应拆卡报告里的中卡用户昵称去重
+--      —— 已售接口不返回 buyer 明细,只能用拆卡报告近似,仅覆盖 report_state=1 的商品
+--   4) 均拼单价 = 销售额 / 成团数(单笔团购平均总金额,非 unit_price)
+--   5) 人均消费 = 销售额 / 参与人数
+-- 用法:每天跑一次即可(默认取昨天17点~今天6点)。要跑指定日期区间,把两处边界表达式
+--       换成 '2026-08-04 17:00:00' / '2026-08-05 06:00:00' 这样的字面量即可。
+-- ============================================================
+
+
+-- ------------------------------------------------------------
+-- 一、平台大盘(昨天成交口径,对应图片 6 个卡片)
+--   参与人数用 deca_report_record.hit_user_nickname 去重
+--   —— 只覆盖昨天成交且 report_state=1(已采到报告)的商品
+-- ------------------------------------------------------------
+SELECT
+    -- 销售额(元)
+    ROUND(SUM(p.sold_count * p.unit_price), 2)                       AS 销售额,
+
+    -- 商家数(昨天有成交商品的去重商家)
+    COUNT(DISTINCT p.merchant_user_id)                               AS 商家数,
+
+    -- 成团数(昨天成交的已售商品数)
+    COUNT(*)                                                          AS 成团,
+
+    -- 均拼单价(销售额 / 成团数,单笔团购平均总金额)
+    ROUND(SUM(p.sold_count * p.unit_price) / NULLIF(COUNT(*), 0), 2) AS 均拼单价,
+
+    -- 参与人数(昨天成交商品的拆卡报告中卡用户昵称去重)
+    (SELECT COUNT(DISTINCT r.hit_user_nickname)
+       FROM deca_report_record r
+       JOIN deca_product_record pp ON pp.product_code = r.product_code
+      WHERE pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+        AND r.hit_user_nickname IS NOT NULL
+        AND r.hit_user_nickname <> '')                                AS 参与人数,
+
+    -- 人均消费(销售额 / 参与人数)
+    ROUND(
+        SUM(p.sold_count * p.unit_price) /
+        NULLIF((SELECT COUNT(DISTINCT r.hit_user_nickname)
+                  FROM deca_report_record r
+                  JOIN deca_product_record pp ON pp.product_code = r.product_code
+                 WHERE pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+                   AND r.hit_user_nickname IS NOT NULL
+                   AND r.hit_user_nickname <> ''), 0),
+        2
+    )                                                                 AS 人均消费
+FROM deca_product_record p
+WHERE p.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR
+  AND p.completed_at <= CURDATE() + INTERVAL 6 HOUR
+  AND p.unit_price IS NOT NULL
+  AND p.sold_count IS NOT NULL;
+
+
+-- ------------------------------------------------------------
+-- 二、指定商家(881226408)昨天成交:销售额/成团数/参与人数
+-- ------------------------------------------------------------
+SELECT
+    MAX(p.merchant_name)                                                  AS 商家名,
+
+    -- 销售额
+    ROUND(SUM(p.sold_count * p.unit_price), 2)                            AS 销售额,
+
+    -- 成团数(昨天成交)
+    COUNT(*)                                                              AS 成团数,
+
+    -- 参与人数(该商家昨天成交且已采报告商品的中卡用户昵称去重)
+    (SELECT COUNT(DISTINCT r.hit_user_nickname)
+       FROM deca_report_record r
+       JOIN deca_product_record pp ON pp.product_code = r.product_code
+      WHERE pp.merchant_user_id = '881226408'
+        AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+        AND r.hit_user_nickname IS NOT NULL
+        AND r.hit_user_nickname <> '')                                    AS 参与人数,
+
+    -- 均拼单价
+    ROUND(SUM(p.sold_count * p.unit_price) / NULLIF(COUNT(*), 0), 2)      AS 均拼单价,
+
+    -- 人均消费(销售额 / 参与人数)
+    ROUND(
+        SUM(p.sold_count * p.unit_price) /
+        NULLIF((SELECT COUNT(DISTINCT r.hit_user_nickname)
+                  FROM deca_report_record r
+                  JOIN deca_product_record pp ON pp.product_code = r.product_code
+                 WHERE pp.merchant_user_id = '881226408'
+                   AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
+                   AND r.hit_user_nickname IS NOT NULL
+                   AND r.hit_user_nickname <> ''), 0),
+        2
+    )                                                                     AS 人均消费
+
+FROM deca_product_record p
+WHERE p.merchant_user_id = '881226408'
+  AND p.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR
+  AND p.completed_at <= CURDATE() + INTERVAL 6 HOUR
+  AND p.unit_price IS NOT NULL
+  AND p.sold_count IS NOT NULL
+GROUP BY p.merchant_user_id;

+ 281 - 0
deca_auto_spider/reports/sold_report/weekly_report.py

@@ -0,0 +1,281 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/24
+"""得卡 DECA · 魔都兄弟球星卡「周报」统计(单 Sheet:魔都明细)。
+
+在每日报告(daily_report.py)之外新增的每周任务:把「魔都明细」sheet 的口径由「单日单场」
+放宽到「上一个完整自然周(周一~周日)」,统计该周内魔都(881226408)所有成交组队(拼团商品)。
+
+与每日报告的关系:
+    - 明细列、汇总块、样式、里程碑用时算法,全部直接复用 daily_report,本文件不重复实现
+      渲染/样式,只重写 2 个「周口径」取数函数(把时间窗 WIN_P 换成 WIN_W)。
+    - 不含每日报告魔都明细尾部的「购买记录覆盖检测」小节(主公要求周报去掉)。
+    - daily_report.py 一个字不改;本文件为纯新增。
+
+时间窗口(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00)(左闭右开,含上周一~上周日
+    整 7 天,按 completed_at 自然日历切分)。基准用 MySQL WEEKDAY()(0=周一..6=周日)从
+    CURDATE() 回退到本周一,再减 7 天得上周一——故本任务定在每周一早上跑,正好汇总刚结束的完整周。
+    注:魔都夜间场常成交到次日凌晨,按自然日历切分时,某周日夜场溢出到周一 00:xx 的团会计入
+    「下一周」,此为主公选定的自然周(周一~周日)口径,非漏统计。
+
+明细列(16 列,与 daily_report 魔都明细 sheet 完全一致,见 daily_report.MODDU_DETAIL_COLS):
+    序号/团名(商品标题)/系列/类型/单价/总份数/进度%/总金额/参与人数(本团)/中卡人数/
+    开售时间/成交时间/售卖时长/到25%用时/到50%用时/到75%用时
+
+口径说明(同 daily_report):
+    - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))(随机团按 teams 精算)。
+    - 成团数 = 该周成交的拼团商品数。
+    - 参与人数(本团) = 各团 deca_buy_record 去重买家 user_id;汇总「参与人数(真实买家)」= 跨周内
+      全部成交团去重(故明细逐团相加人次 ≥ 汇总去重人头)。
+    - 中卡人数 = 该团拆卡报告 hit_user_nickname 去重(中卡近似)。
+    - 到 25/50/75% 用时 = deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+      首张快照已越阈值(坍缩)则留空。progress 表 2026/08/11 上线,更早的团相应列可能为空。
+
+从 stats 目录运行:python weekly_report.py(cwd=stats,mysql_pool 读 stats/application.yml)。
+"""
+import os
+import sys
+import time
+from datetime import timedelta
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+
+# 挂靠新项目根:sys.path 指向 common、CWD 固定本目录(与 daily_report 一致)
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), "common"))
+os.chdir(BASE_DIR)
+
+from mysql_pool import MySQLConnectionPool
+# 复用每日报告的渲染层/样式/列规格/无窗口依赖的纯算法(本文件不重复实现这些)
+from daily_report import (
+    MODDU_MID, MODDU_DETAIL_COLS, DETAIL_WIDTHS_MODDU,
+    _pack_summary, _build_detail_sheet,
+)
+
+# 日志:按天切分文件,保留 7 天(常驻定时运行)。放本文件所在目录的 logs/,不依赖 cwd。
+# 注:导入 daily_report 时其模块级已 logger.add 过每日报告 sink,这里 remove 后只保留周报 sink。
+logger.remove()
+logger.add(os.path.join(BASE_DIR, "logs", "weekly_report_{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="INFO", retention="7 day")
+
+OUT_PREFIX = "得卡-魔都-已售周报告"     # 输出文件名前缀(只查魔都一家),后缀加「上周一_上周日」两个日期
+# 企微发送:报告生成后把 Excel 发到企业微信群机器人(群由 auto_send_wx_msg.WEBHOOK_URL 决定,与每日报告同群)
+SEND_WECHAT = True
+
+# ---- 周时间窗(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00) 左闭右开 ----
+# 本周一:WEEKDAY() 0=周一..6=周日,从今天回退到本周一 00:00:00(DATE,不含时分秒即 00:00:00)
+_THIS_MONDAY = "(CURDATE() - INTERVAL WEEKDAY(CURDATE()) DAY)"
+# 上周一 = 本周一 - 7 天
+_LAST_MONDAY = f"({_THIS_MONDAY} - INTERVAL 7 DAY)"
+
+
+def _win(alias: str) -> str:
+    """生成某表别名在「上一个自然周」窗口内的 completed_at 过滤子句。
+
+    Args:
+        alias (str): SQL 中 deca_product_record 的表别名(如 "p" / "pp")。
+
+    Returns:
+        str: 形如 "p.completed_at >= 上周一 AND p.completed_at < 本周一" 的过滤子句。
+    """
+    return (f"{alias}.completed_at >= {_LAST_MONDAY} "
+            f"AND {alias}.completed_at < {_THIS_MONDAY}")
+
+
+WIN_W = _win("p")   # 主表 p 的周窗口子句(供各取数 SQL 拼接)
+
+
+def get_week_window(pool):
+    """取上一个自然周的起止日期(供报告标题与文件名展示)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[date, date]: (上周一 date, 上周日 date)。上周日 = 本周一 - 1 天。
+    """
+    wk_start, this_monday = pool.select_all(
+        f"SELECT {_LAST_MONDAY}, {_THIS_MONDAY}")[0]
+    last_sunday = this_monday - timedelta(days=1)   # 本周一(右开界) 前一天即上周日
+    return wk_start, last_sunday
+
+
+def fetch_moddu_summary_week(pool, mid: str) -> dict:
+    """统计魔都商家「上一个自然周」的汇总(销售额/成团数/参与人数/均拼单价/人均消费)。
+
+    参与人数用 deca_buy_record 去重真实买家(跨周内全部成交团),人均消费随之按真实人头计。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 商家名/商家ID/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        MAX(p.merchant_name)                                              AS mname,
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(*)                                                          AS grp
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_W}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    mname, amount, groups = pool.select_all(sql, (mid,))[0]
+    # 参与人数(真实买家) = 周内该商家全部成交团的 deca_buy_record 去重 user_id
+    people = pool.select_all(f"""
+    SELECT COUNT(DISTINCT b.user_id)
+    FROM deca_buy_record b
+    JOIN deca_product_record pp ON pp.product_code = b.product_code
+    WHERE pp.merchant_user_id = %s AND {_win('pp')}
+    """, (mid,))[0][0] or 0
+    d = _pack_summary(amount, groups, people)
+    d["商家名"] = mname or mid
+    d["商家ID"] = mid
+    return d
+
+
+def fetch_moddu_details_week(pool, mid: str) -> list[dict]:
+    """取魔都商家「上一个自然周」内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    列与算法同 daily_report.fetch_moddu_details,仅时间窗由单日单场换为上一个自然周(WIN_W):
+        - 参与人数:deca_buy_record 去重买家 user_id(本团真实参团人头)。
+        - 到 25/50/75% 用时:deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+          首张快照已越阈值(坍缩)则留空(判定见 daily_report._milestone_used)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        list[dict]: 每条含 团名/系列/类型/单价/总份数/进度/总金额/参与人数/中卡人数/开售时间/
+            成交时间/售卖时长/到25%用时/到50%用时/到75%用时。
+    """
+    # 里程碑/时长算法直接复用 daily_report(避免重复实现坍缩判定逻辑)
+    from daily_report import _fmt_duration, _milestone_used
+    sql = f"""
+    SELECT
+        p.title, p.series_name, p.spec_name, p.unit_price, p.sold_count, p.card_count,
+        ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+        p.sale_start_at, p.completed_at,
+        TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at)            AS duration_secs,
+        (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                          AS buyers,
+        -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
+        (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
+          WHERE r.product_code = p.product_code
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
+        -- 该商品最早一条进度快照时刻:用于判定里程碑是否「坍缩」(首张快照已越阈值则用时不可信)
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code)                          AS first_cap,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 25)  AS t25,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 50)  AS t50,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 75)  AS t75
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_W}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    for (title, series, spec, price, sold, card, amount, start, completed,
+         duration_secs, buyers, hit_users, first_cap, t25, t50, t75) in rows:
+        progress = round(sold / card * 100, 1) if card else None  # 售卖进度百分比
+        result.append({
+            "团名": title, "系列": series, "类型": spec, "单价": price,
+            "总份数": card, "进度": progress, "总金额": amount,
+            "参与人数": buyers,
+            "中卡人数": hit_users,                       # 该团拆卡报告去重命中用户(中卡近似)
+            "开售时间": start, "成交时间": completed,   # 开售=sale_start_at,成交=completed_at
+            "售卖时长": _fmt_duration(duration_secs),   # 成交-开售,也即整团总时长
+            # 到 X% 用时:仅当 tX 之前还有更早快照(未坍缩)时才输出真实穿越耗时,否则留空
+            "到25%用时": _milestone_used(t25, first_cap, start),
+            "到50%用时": _milestone_used(t50, first_cap, start),
+            "到75%用时": _milestone_used(t75, first_cap, start),
+        })
+    return result
+
+
+def build_week_report(pool, out: str, title: str):
+    """生成单 Sheet(魔都明细)周报 Excel。
+
+    仅一个 sheet「魔都明细」:汇总块 + 每条组队明细(16 列),结构/样式复用
+    daily_report._build_detail_sheet。不含「购买记录覆盖检测」小节(miss_info=None)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+        title (str): sheet 顶部分区标题(含商家名与周成交时间窗)。
+    """
+    summ = fetch_moddu_summary_week(pool, MODDU_MID)
+    details = fetch_moddu_details_week(pool, MODDU_MID)
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "魔都明细"
+    # is_real=True(魔都为真实买家口径);span/widths 取魔都明细专属规格;
+    # miss_info=None → 不输出「购买记录覆盖检测」小节(主公要求周报去掉此块)
+    _build_detail_sheet(ws, title, summ, details, MODDU_DETAIL_COLS,
+                        True, len(MODDU_DETAIL_COLS), DETAIL_WIDTHS_MODDU, miss_info=None)
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成上一个自然周的魔都明细周报,落地到 stats 目录并发送到企业微信群。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    log.info("开始生成魔都周报" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    wk_start, last_sunday = get_week_window(pool)
+    title = (f"魔都兄弟球星卡 · 周汇总"
+             f"(成交自然周 {wk_start} 00:00:00 ~ {last_sunday} 23:59:59)")
+    # 输出锚定到本脚本所在目录(stats),文件名带「上周一_上周日」两个日期,便于归档区分
+    out_file = os.path.join(BASE_DIR, f"{OUT_PREFIX}_{wk_start:%Y%m%d}_{last_sunday:%Y%m%d}.xlsx")
+    build_week_report(pool, out_file, title)
+    log.info(f"周报已生成 -> {out_file}")
+
+    # 发企微群(只发 Excel;失败仅告警,不影响报告产出)——与每日报告同群同方式
+    if SEND_WECHAT:
+        try:
+            from auto_send_wx_msg import send_wechat_group_file
+            send_wechat_group_file(log=log, file_path=out_file)   # 只发 Excel,不发图
+        except Exception as e:
+            log.warning(f"企微发送跳过: {e}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每周一 09:20 生成上一个自然周的魔都明细周报。
+
+    错开每日报告(09:10)10 分钟;周一早上跑正好汇总刚结束的完整自然周(上周一~周日)。
+    """
+    # run_once(logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().monday.at("09:20").do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

BIN
deca_auto_spider/reports/sold_report/得卡已售每日报告_20260911.xlsx


BIN
deca_auto_spider/reports/sold_report/得卡已售每日报告_20260912.xlsx


BIN
deca_auto_spider/reports/sold_report/得卡已售每日报告_20260914.xlsx


+ 251 - 0
deca_auto_spider/reports/sold_report/球队.json

@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/8/4 22:24
+json = """
+{
+  "code": 0,
+  "msg": "成功",
+  "data": {
+    "code": "GB26080464831",
+    "liveId": 0,
+    "shareCode": "Xhw8QK5OcBUkaGfbHj/+iaahaawVghp0pcgjDcoDwchmAF5DSq6pMbac+y06J/Wn7cOcTeUwsh8R/AYel2JuoEXUpc2GTn0wHuTB9Ogrp/9rm8ld88+Pxy6zYpqZHjXKgCd7Yqk=",
+    "status": 5,
+    "title": "2025-26 篮球 NBA topps Inception Hobby #7 【原箱】 魔都兄弟球星卡",
+    "playType": -1,
+    "playTypeName": "剩余随机",
+    "specType": 2,
+    "specTypeName": "原箱",
+    "specSummary": "1箱",
+    "isPresale": 1,
+    "isPresaleName": "是",
+    "saleStartAt": "2026-08-04 21:25:00",
+    "saleEndAt": "2026-08-09 21:25:00",
+    "publishAt": "2026-08-04 21:08:01",
+    "maxSaleDays": 5,
+    "singleUserLimitStatus": 2,
+    "singleUserMaxQuantity": 0,
+    "userPurchaseCount": 0,
+    "myCardCount": 0,
+    "canViewCards": 2,
+    "singleOrderLimitStatus": 2,
+    "singleOrderMaxQuantity": 0,
+    "breakRegion": 1,
+    "breakRegionName": "中国大陆",
+    "giftIntro": "整箱篮球盗梦选队LIST拼团 高低编各获得3000元无门槛券(先看分母再看分子 绝杀制 交换板算为尾编 如10编交换板算10 10)\n实体店地址 上海市徐汇区永福路125号8号楼 抖音搜索魔都兄弟每晚同步直播",
+    "coverImageUrl": "https://static.decalive.com/release/groupbuy/image/2026/08/03/08981dbd-6db8-46b4-80dd-c2f3414633c6.jpg",
+    "carouselImageUrls": [],
+    "merchantInfo": {
+      "merchantUserID": "881226408",
+      "merchantName": "魔都兄弟球星卡",
+      "merchantAvatarUrl": "https://static.decalive.com/release/avatar/2026/08/01/7a8f6512-3c0a-4e9b-b379-217b9753ac00.jpg",
+      "merchantLogoUrl": "https://static.decalive.com/release/shop/logo/2026/08/03/bb68ecaf-aa63-4483-bb46-f0e3689e431c.png",
+      "wechatId": "xiaoxiaiqu0328",
+      "fansCount": 3355,
+      "activeGroupbuyCount": 9,
+      "completedGroupbuyCount": 38,
+      "completedGroupbuyCountText": "38",
+      "isFollowing": 2,
+      "isLiveOnline": 0,
+      "depositAmount": "50000.00",
+      "shopLevel": 3,
+      "liveTimelinessHours": 1,
+      "reportTimelinessHours": 1,
+      "shipmentTimelinessHours": 0
+    },
+    "cardProduct": {
+      "productId": 4,
+      "title": "精美卡牌",
+      "coverImageUrl": "https://static.decalive.com/release/groupbuy/card-template/2026/08/01/4321af4a-005f-44d6-b60e-9a7ae37ac84a.jpg",
+      "carouselImageUrls": [
+        "https://static.decalive.com/release/groupbuy/card-template/2026/08/01/ce000bba-ec1c-4c25-a113-40a066fc8a6e.jpg"
+      ],
+      "detailImageUrl": "",
+      "unitPrice": "25.00"
+    },
+    "prizeActivities": [],
+    "unitPrice": "38.54",
+    "minUnitPrice": "1.50",
+    "maxUnitPrice": "108.00",
+    "promotionTiers": [],
+    "remainingRandomTeamSnapshot": {
+      "totalQuantity": 997,
+      "teamCount": 13,
+      "unitPrice": "38.54",
+      "teams": [
+        {
+          "teamId": 4,
+          "teamNameEn": "Charlotte Hornets",
+          "teamNameZh": "黄蜂",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/cbf61693-1fcb-4ab9-a670-859d23041b7c.png",
+          "unitPrice": "35.00",
+          "availableStock": 159
+        },
+        {
+          "teamId": 8,
+          "teamNameEn": "San Antonio Spurs",
+          "teamNameZh": "马刺",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/52a2e2a5-a463-4f92-ac3b-2c71b4720f71.png",
+          "unitPrice": "88.00",
+          "availableStock": 119
+        },
+        {
+          "teamId": 9,
+          "teamNameEn": "Los Angeles Lakers",
+          "teamNameZh": "湖人",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/a80696f7-730f-4c9d-a9e2-ce52e73fc427.png",
+          "unitPrice": "35.00",
+          "availableStock": 100
+        },
+        {
+          "teamId": 12,
+          "teamNameEn": "Philadelphia 76ers",
+          "teamNameZh": "76 人",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/10b200ce-1e23-41df-b79b-9c4b6b8edf88.png",
+          "unitPrice": "20.00",
+          "availableStock": 80
+        },
+        {
+          "teamId": 14,
+          "teamNameEn": "Memphis Grizzlies",
+          "teamNameZh": "灰熊",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/093200ea-84d7-4e40-9d8c-36d313a41a39.png",
+          "unitPrice": "8.00",
+          "availableStock": 41
+        },
+        {
+          "teamId": 15,
+          "teamNameEn": "Utah Jazz",
+          "teamNameZh": "爵士",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/84bd699a-8526-43a9-a1dd-bfb5a1082599.png",
+          "unitPrice": "15.00",
+          "availableStock": 52
+        },
+        {
+          "teamId": 16,
+          "teamNameEn": "Brooklyn Nets",
+          "teamNameZh": "篮网",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/741d0d49-8682-43d9-998e-b5185cdbdc92.png",
+          "unitPrice": "8.00",
+          "availableStock": 80
+        },
+        {
+          "teamId": 20,
+          "teamNameEn": "Golden State Warriors",
+          "teamNameZh": "勇士",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/16d09b9f-3631-42e7-bc9c-c878f286cf46.png",
+          "unitPrice": "30.00",
+          "availableStock": 64
+        },
+        {
+          "teamId": 23,
+          "teamNameEn": "Dallas Mavericks",
+          "teamNameZh": "独行侠",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/77192981-21f2-463e-93d7-7da84d201119.png",
+          "unitPrice": "108.00",
+          "availableStock": 77
+        },
+        {
+          "teamId": 26,
+          "teamNameEn": "Oklahoma City Thunder",
+          "teamNameZh": "雷霆",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/d6909e9d-acad-4ff4-85e3-db1455969dfd.png",
+          "unitPrice": "15.00",
+          "availableStock": 101
+        },
+        {
+          "teamId": 27,
+          "teamNameEn": "Washington Wizards",
+          "teamNameZh": "奇才",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/4ad14dd2-2509-4643-89c0-2545d98f81e4.png",
+          "unitPrice": "6.00",
+          "availableStock": 10
+        },
+        {
+          "teamId": 29,
+          "teamNameEn": "New York Knicks",
+          "teamNameZh": "尼克斯",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/728dd7d7-dd99-498e-aef7-751cb5e8a303.png",
+          "unitPrice": "18.00",
+          "availableStock": 52
+        },
+        {
+          "teamId": 32,
+          "teamNameEn": "other",
+          "teamNameZh": "other",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/531c9138-f0f3-4a97-b0da-54f87db6bbba.png",
+          "unitPrice": "45.00",
+          "availableStock": 62
+        }
+      ]
+    },
+    "remainingRandomStatus": 2,
+    "firstOrderDiscountStatus": 2,
+    "firstOrderDiscountAmount": "0.00",
+    "firstOrderDiscountMaxOrderCount": 0,
+    "firstOrderDiscountUnlimited": 2,
+    "firstOrderDiscountAvailable": 2,
+    "allowMerchantCoupon": 1,
+    "totalCardCount": 2906,
+    "availableStock": 700,
+    "soldCount": 2206,
+    "orderPendingPayCount": 0,
+    "progressPercent": "75.91",
+    "sameSeriesItems": [
+      {
+        "code": "GB26080464831",
+        "playTypeName": "选队随机-随机卡种-区分编号",
+        "seriesGroupNo": 7,
+        "specSummary": "1箱"
+      },
+      {
+        "code": "GB26080494626",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 14,
+        "specSummary": "1盒"
+      },
+      {
+        "code": "GB26080433796",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 13,
+        "specSummary": "1盒"
+      },
+      {
+        "code": "GB26080370926",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 1,
+        "specSummary": "8盒"
+      },
+      {
+        "code": "GB26080322335",
+        "playTypeName": "随机卡种-区分编号",
+        "seriesGroupNo": 5,
+        "specSummary": "10箱"
+      }
+    ],
+    "giftInfo": {
+      "items": [
+        {
+          "seriesTag": "",
+          "seriesName": "Inception Hobby",
+          "aliasName": "",
+          "specDetail": "原箱",
+          "seriesConfig": "7张/包 1包/盒 8盒/箱 共1箱",
+          "expectedCardCount": 56
+        }
+      ],
+      "totalExpectedCardCount": 56,
+      "checklistCardCount": 2906
+    },
+    "buttonStatus": 1,
+    "confirmedListType": 2,
+    "fansOnlyStatus": 2,
+    "privateStatus": 2,
+    "countdownType": 2,
+    "countdownTargetAt": "2026-08-09 21:25:00",
+    "countdownTimestamp": 1786281900,
+    "remainingSeconds": 428464,
+    "checklistDataType": 1
+  }
+}
+"""

+ 12 - 0
deca_auto_spider/requirements.txt

@@ -0,0 +1,12 @@
+# 得卡 DECA 自动化爬虫 · 第三方依赖(Python 3.12.10)
+# 版本为 2026/09/10 本机实测安装版本
+requests==2.33.1
+loguru==0.7.3
+tenacity==9.1.4
+schedule==1.2.2
+openpyxl==3.1.5
+matplotlib==3.10.9
+
+# 公共库 charley-utils(mysql_pool / YamlLoader)单独 editable 安装,不经此文件:
+#   cd D:\work\common\charley-utils && pip install -e .
+# 其依赖 pymysql / DBUtils / PyYAML 由 charley-utils 自身声明。

+ 908 - 0
deca_auto_spider/spiders/buy_record_spider.py

@@ -0,0 +1,908 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/05
+"""得卡 DECA 购买记录常驻采集脚本(多商品自适应频率)。
+
+功能:
+  一个进程干两件事(方案A,2026/08/08 合并):
+  (1) 每分钟复用 daily 的免 token home/search 拉全站在售,落 deca_onsale_* 三张表(供 deca_on_sale_report 查库出报告);
+  (2) 从库里取各监控商家(MERCHANT_IDS)在售 code,采其购买记录写 deca_buy_record。
+  购买记录按「近 10 条购买的时间跨度」动态调节采集频率——
+  卖得快(跨度短)密采、卖得慢(跨度长)稀采;商品下线、售卖结束或售罄自动停采(含白名单模式)。
+  数据写 deca_buy_record;去重用「倒序滑动窗口序列对齐」:purchaseRecords 是最近 10 条按时间倒序、
+  新单只从顶部推入,故按「本次窗口 (userId,cardCount) 序列相对上次整体下移了几位」求出顶部新增的 k 条,
+  只入库这 k 条——不依赖会漂移的反推时间戳,且能正确区分同用户多笔相同份数的单(靠位移而非时间)。
+  兜底:进程重启首轮 / 窗口整体换新(间隔内卖出≥10 笔)无法对齐时,退回反推时间戳「动态容差」判重
+  (秒 70s / 分钟 90s / 小时 3660s / 天 90000s)+ DB 唯一键,无重叠时告警疑似漏采。
+
+停采(重点):
+  详情返回 availableStock<=0(售罄)或已过 saleEndAt(到结束时间)即判定售卖结束,移出监控并加入
+  _ended_codes 黑名单,避免白名单模式下对已结束商品死循环重采「固定的最后 10 条」造成重复入库。
+
+自适应节奏:
+  下次间隔 ≈ 10 条跨度 / 3,硬夹在 [MIN_INTERVAL_SEC, MAX_INTERVAL_SEC]。
+  典型档位(当前 MIN=0,测试期让服务端限流自己说话):
+    - 极热(10 条 3s 内)→ 立即再刷(由 GLOBAL_MIN_GAP_SEC 兜底≥0.2s)
+    - 热(10 条 1min 内)→ 20s 一次
+    - 中(10 条 5min 内)→ 100s 一次
+    - 慢(10 条 30min 内)→ 600s 一次触顶
+
+登录态:
+  - **全链路免 token**:详情接口 groupbuy/detail 免登录;在售列表改用免 token 的 home/search(复用 daily),
+    落库后查库拿商家在售 code,不再走需 token 的 on-sale-list。彻底摆脱登录/验证码。
+
+保护:
+  - 全局请求节流 GLOBAL_MIN_GAP_SEC,避免瞬时高并发触发限流
+  - main_task @retry(stop=100, wait=3600):挂了每小时重试,跑到手动停
+  - 快代理隧道请求(走 deca_sold_core)
+
+前置:
+  - 表 deca_buy_record 及 deca_onsale_* 需先建好(schema.sql)。
+  - 依赖 on_sale/deca_on_sale_daily_spider.py 的复用函数(get_shop_list/get_onsale_products/fill_product_details);
+    该文件保留、不再单独常驻跑。报告由 deca_on_sale_report.py 独立定时查库生成。
+
+运行:项目根目录 `python buy_record_analysis/buy_record_spider.py`(采集);
+     报告另跑 `python on_sale/deca_on_sale_report.py`(可加 loop 定时,见该文件)。
+"""
+import os
+import re
+import sys
+import time
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, time as dtime
+
+# 挂靠项目根:复用核心签名/账号池/请求/代理层,让 application.yml / 账号池 DB 生效
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+import deca_sold_core as core
+from mysql_pool import MySQLConnectionPool
+# 复用 daily 的商品解析/落库(parse_product/save_products,纯解析+DB,无网络);商品在售数据改由本脚本
+# 逐监控商家 on-sale-list(带 token)采集(2026/09/05:站方把免 token 的 home/search 砍成首屏 20,
+# 全站免 token 翻页失效,故监控侧改为只按 MERCHANT_IDS 逐商家带 token 拉在售,请求量小、且不漏本商家的车)。
+# 注:本 import 会触发 daily 模块级 logger 配置,但下方 buy_record 的 logger.remove/add 在其后,会覆盖回本脚本日志
+from deca_on_sale_daily_spider import parse_product, save_products
+
+# ==================== 配置 ====================
+core.USE_PROXY = True               # 免 token 的详情接口(groupbuy/detail)走快代理隧道换 IP,防高频轮询触发 IP 风控
+# 在售列表接口:只对 MERCHANT_IDS 逐商家拉在售(需 token),走直连固定 IP(不走隧道换 IP)——
+# 让本机账号↔IP 稳定,降低账号被风控关联概率;免 token 的详情仍走隧道(详情不带登录态,不牵连账号)。
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"
+ON_SALE_PAGE_SIZE = 20              # on-sale-list 每页条数(站方硬限 ≤20)
+ON_SALE_MAX_PAGES = 3             # 单商家在售翻页上限(服务端 *-list 硬限最近 3 页;在售普遍远 <60 个,靠 total/末页早停)
+
+# 监控商家列表:采这些商家在售商品的购买记录(WATCH_CODES 为空时生效)。
+# 2026/08/24 由单商家扩为多商家并行监控:三家同走「查库拿在售 code → 自适应频率采购买记录」同一套逻辑,
+# 各商品按自身售卖节奏独立调频,互不影响;快车时段/快车上限等参数对所有商家通用(按份数与滑窗跨度自适应)。
+MERCHANT_IDS = [
+    "881226408",   # 魔都兄弟球星卡
+    "274584650",   # 卡皇拆卡(2026/08/24 新增)
+    "538252487",   # 尼卡拆卡(2026/08/24 新增)
+    "591544726",   # 文泰卡屋(2026/08/25 新增)
+    "606370597",   # 魔都兄弟综合体育(2026/09/09 新增,与球星卡同属魔都兄弟)
+]
+WATCH_CODES = []                    # 指定商品白名单:非空则只盯这些 code、跳过在售发现;空则走 MERCHANT_IDS 全在售(默认)
+ONSALE_INGEST_SEC = 60             # 全站在售落库 + 刷新监控队列间隔(常规时段 1 分钟,复用 daily 免 token home/search)
+# 快车高发时段(直播拆卡,通常 20:00~次日 06:00)专用:这类小批量拼团(本商家 31 份)上架后 2~3 分钟即售罄,
+# 寿命只比 60s 发现节奏大一个数量级,偶尔整场卡在两次全站扫描的缝隙里被漏掉(实测 2026/08/22 01:45 一辆 31 份车漏采,
+# 从没进过 deca_onsale_product_record → 从没纳入监控 → deca_buy_record 0 条 → 战报「0 人参与拆卡」)。
+# 故该时段把「全站在售落库 + 刷新监控队列」的节奏压到 FAST_WINDOW_INGEST_SEC 密探,给快车 6~9 次被发现的机会;
+# 时段外恢复 ONSALE_INGEST_SEC。全站 home/search 免 token 走直连,加密只抬直连请求量、不涉登录态。
+# 2026/09/15:快车时段起点由 20:00 提前到 13:00 —— 报告窗口 09/01 起提前到昨13:00、商家下午即开卖,
+# 原 20:00 起的密探漏掉整个下午开卖高峰(实测 09/15 一辆 4 分钟车在 13:58 结束、60s 慢档整场漏采)。
+FAST_WINDOW_INGEST_SEC = 10        # 快车时段全站在售发现间隔(2026/08/28 由 20s 改 10s):更早发现新上架车,缩短上架→纳入监控→追首条的延迟
+FAST_WINDOW_START = dtime(13, 0)   # 快车高发时段开始:13:00(2026/09/15 由 20:00 提前——报告窗口 09/01 起提前到昨13:00,商家下午即开卖,下午快车也需 10s 密探,否则 60s 慢档易整场漏采)
+FAST_WINDOW_END = dtime(6, 0)      # 快车高发时段结束:次日 06:00(窗口跨午夜)
+SHOP_DISCOVER_SEC = 600            # 商家发现 + 今日新增补详情间隔(10 分钟,变化慢无需每分钟)
+MIN_INTERVAL_SEC = 0               # 单商品购买记录最小采集间隔(测试期设 0:让实测数据决定是否要抬)
+MAX_INTERVAL_SEC = 30              # 单商品购买记录最大采集间隔(2026/08/27 由 600s 改 30s):
+                                   # 任何车卖再慢也 ≤30s 采一次,杜绝慢档 600s 上限在两次采集间隙的漏采窗口
+# 快车(小批量拼团)专用:份数小的商品售罄极快(实测最快 10 份滑窗约 64s),需比 MAX_INTERVAL_SEC 更小的上限密采,
+# 否则「刚上架 span=0 → 排 600s」会让 3~8 分钟售罄的拼团在两次采集间隙整场漏采(2026/08/10 修复)
+FAST_LANE_MAX_COUNT = 100          # 份数<=此值视为「快车」(本商家快车=30份、大车=313+),涵盖未来 20/40/50 份的小批量快车
+FAST_LANE_MAX_INTERVAL_SEC = 20    # 快车最大采集间隔(实测最快 10 份滑窗~64s,20s 留约 3x 余量)
+# 爆发密采(所有尺寸) + 大车空闲探测(2026/08/25 优化 A):
+# 「爆发」判定 = 最近 10 条购买记录全落同一分钟桶(span==0) = 上一分钟至少卖了 10 份 = 正在热卖、有翻窗漏采风险,
+# 这对任意尺寸都成立,故所有车 span==0 时统一压到 HOT_INTERVAL_SEC 顶格密采(贴单车物理上限 ≈10条/RTT≈6.7份/秒)。
+# 背景:原箱大车(上万份)开售头几分钟 15~35 份/秒秒空(实测 9786 份 24s 卖 830)、31 份快车 ~60s 秒空,旧逻辑对
+# 非快车 span<=0 返回 600s、快车返回 20s,都追不上爆发翻窗(大车覆盖率仅 40%、部分 31 份快车 58~74%)。
+# 空闲探测(span<0,无购买记录/刚纳入):密探尽快追首条,开售爆发一起即在 ~10s 内切密采。
+# 【2026/08/27~28:空闲探测已统一 IDLE_PROBE_SEC(10s)、预售 PRESALE_IDLE_SEC(60s),不再分档;慢档上限 MAX_INTERVAL_SEC(30s)】
+HOT_INTERVAL_SEC = 1               # 爆发(任意尺寸,最近10条同桶 span==0)采集间隔(1s,实际受 RTT≈1.5s 自然限流,逼近单车采集上限)
+# 空闲/首次探测间隔(大中小车统一,2026/08/27):刚纳入 / 首次接口延迟响应0条 / 预售时,还不知道车卖多快,
+# 一律 10s 密探尽快追上首条购买记录——尤其中等份数车常 6 分钟售罄、每~20s 卖>10 份,20s 采一次会翻窗漏采,故 10s。
+# 采到首条后即转 span 自适应(卖快密采、卖慢自动稀采),不再密探。
+IDLE_PROBE_SEC = 10                # 空闲/首次探测间隔(所有档统一 10s,宁密勿漏)
+PRESALE_IDLE_SEC = 60              # 预售未开卖(saleStartAt 在未来)探测间隔(60s 稀探,2026/08/28):预售期没人能买、
+                                   # 购买记录必空,10s 密探纯浪费;到开卖时间 poll 自动转 IDLE_PROBE_SEC 密探
+GLOBAL_MIN_GAP_SEC = 0.2           # 全局相邻两次详情请求最小间隔(≈每秒 5 次上限,防封的硬速率天花板)
+# 并发采集(2026/08/24):实测单条详情请求 ~1~2.7s(含长尾),串行吞吐仅 0.4~0.9 次/秒,
+# 远低于 GLOBAL_MIN_GAP_SEC 允许的 5 次/秒——瓶颈是请求延迟(latency-bound)而非节流。多商家(21+ 在售)后,
+# 单线程一轮轮完 N 个商品要 N×延迟,追不上 31 份快车(最快 45~65s 售罄、翻窗 3 次),实测魔都近 7 天捕获率
+# 均值仅 0.83、快车低至 0.35。故改线程池并发:把利用率从 ~0.5 次/秒填到接近 5 次/秒(约 10x),
+# **但不抬高 5 次/秒的天花板**——峰值压力上限不变、防封风险几乎不增。worker 数需略大于「节流上限×平均延迟」
+# 才能填满时隙(5×1.5≈8),长尾偶发占用也不至饿死;真正的速率上限由令牌桶(_slot_lock)守住,worker 多只是填满、不会超速。
+MAX_WORKERS = 8                    # 采集线程池大小(并发发详情请求,实际速率仍受 GLOBAL_MIN_GAP_SEC 令牌桶封顶)
+IDLE_SLEEP_SEC = 1                 # 主循环空转 sleep(没到点时)
+DISPATCH_SLEEP_SEC = 0.2           # 主循环派发轮询间隔(多线程下调度快节奏,及时把到点商品派给空闲 worker)
+DEDUP_TOLERANCE_SEC = 70           # 秒级基准判重容差:同(商品,user,份数)下反推时间戳相差<=此值视为同一笔;小时/天级按 _TOL_BY_UNIT 放大
+
+TABLE = "deca_buy_record"
+ONSALE_TABLE = "deca_onsale_product_record"   # 在售商品表:daily 复用函数落库,本脚本查库拿商家在售 code
+PROGRESS_TABLE = "deca_onsale_product_progress_record"   # 2026/08/11 新增:进度时间序列,append-only、变化才写
+DETAIL_PATH = "/api/v1/app/groupbuy/detail"
+
+# 日志:按天切分文件,保留 7 天;stderr 同步 INFO 便于观察
+logger.remove()
+logger.add(os.path.join(_ROOT, "logs", "{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+# logger.add("./logs/{time:YYYYMMDD.log",
+#            encoding="utf-8", rotation="00:00",
+#            format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+#            level="DEBUG", retention="7 day")
+# logger.add(sys.stderr, level="INFO",
+#            format="[{time:HH:mm:ss}] {level} {message}")
+
+# 相对时间文本:捕获数字 + 单位
+_REL_RE = re.compile(r"^(\d+)\s*(秒|分钟|小时|天)前$")
+
+# 动态判重容差(秒):相对时间越粗,反推时间戳随采集时刻漂移越大(幅度≈单位桶宽),容差须≥桶宽才能吸附同一笔的漂移副本。
+# 秒/分钟保持小容差(<同用户多笔的最小间隔~2min)以区分真实多笔;小时/天放大到略大于桶宽(3600/86400)。
+_TOL_BY_UNIT = {"秒": DEDUP_TOLERANCE_SEC, "分钟": 90, "小时": 3660, "天": 90000}
+
+# 全局请求节流(令牌桶/时隙预约):多线程并发下用锁只保护「领下一个可发时隙」这步(μs 级),sleep 在锁外,
+# 从而 N 个 worker 各领一个错开 GLOBAL_MIN_GAP_SEC 的时隙、稳定逼近 5 次/秒并发而不突刺。_next_slot=下一个可发时刻。
+_slot_lock = threading.Lock()
+_next_slot = 0.0
+
+# 调度状态锁:保护 monitored(监控队列)与 _inflight(在途 code 集合)的读改。这两个结构主线程(派发/刷新)与
+# worker(回写 next_run_ts/移出) 都会碰,须串行化;锁内只做内存操作(快),绝不含网络/DB/长 sleep,故无死锁风险。
+_state_lock = threading.Lock()
+
+# 在途集合:正被 worker 采集的 code。派发前检查,保证「同一商品同一时刻只有一个 worker」——
+# 从而 _order_idx / _last_window 这类按 code 分片的结构对同一 code 无并发,不同 code 是不同 dict key(CPython 下并发安全),无需额外加锁。
+_inflight: set = set()
+
+# 订单去重内存索引:{(product_code, user_id, card_count): [已入库订单的反推时间戳...]}
+# 判重靠「反推时间戳 ± 动态容差(_TOL_BY_UNIT)」——同一笔订单变老后的漂移能吸附回锚点,不同笔可区分
+_order_idx: dict = {}
+
+# 已判定售卖结束的商品黑名单:移出监控后加入,_refresh_monitored 跳过,避免白名单模式反复重新纳入
+_ended_codes: set = set()
+
+# 上次窗口快照:{code: [(user_id, card_count), ...]}(index0 最新),供「序列对齐」求本轮顶部新增的 k 条
+_last_window: dict = {}
+
+
+# ==================== 工具函数 ====================
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    Args:
+        retry_state: tenacity RetryCallState。
+    """
+    log = retry_state.args[0] if retry_state.args else logger
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+def parse_relative_ago(text: str, now_ts: int) -> int:
+    """把「X 秒/分钟/小时/天前」或「刚刚」反推为绝对秒级时间戳。
+
+    Args:
+        text (str): 相对时间原文(来自 purchaseRecords.purchasedAt)。
+        now_ts (int): 采集时刻的秒级 unix 时间戳。
+
+    Returns:
+        int: 反推的绝对购买时间戳(秒);无法识别时兜底返回 now_ts。
+    """
+    if not text:
+        return now_ts
+    t = text.strip()
+    if t in ("刚刚", "刚才", "现在"):
+        return now_ts
+    m = _REL_RE.match(t)
+    if not m:
+        return now_ts
+    n = int(m.group(1))
+    unit = m.group(2)
+    mult = {"秒": 1, "分钟": 60, "小时": 3600, "天": 86400}[unit]
+    return now_ts - n * mult
+
+
+def dedup_tolerance(text: str) -> int:
+    """按相对时间文本的单位返回该记录的判重容差(秒)。
+
+    相对时间越粗(小时/天),反推时间戳随采集时刻的漂移越大(幅度≈单位桶宽),
+    需用大容差才能把同一笔订单变老后的漂移副本吸附回锚点,否则会被误判为新订单重复入库。
+
+    Args:
+        text (str): 相对时间原文(如 "1分钟前" / "3小时前")。
+
+    Returns:
+        int: 该粒度下的判重容差秒数;无法识别时返回秒级基准 DEDUP_TOLERANCE_SEC。
+    """
+    m = _REL_RE.match((text or "").strip())
+    if not m:
+        return DEDUP_TOLERANCE_SEC
+    return _TOL_BY_UNIT.get(m.group(2), DEDUP_TOLERANCE_SEC)
+
+
+def is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
+    """根据详情返回判断商品售卖是否已结束(售罄或到结束时间)。
+
+    预售/未开卖的车:详情接口 availableStock 也返回 0(库存未分配)、soldCount=0、saleStartAt 在未来,
+    仅凭 availableStock<=0 会把它误判为「售罄」→ 被加入 _ended_codes 拉黑,导致真正开卖后整车漏采。
+    直播实时放量卖的车同理:卖到一半 availableStock 会瞬时抖成 0("这一刻没货可抢"≠"卖光")。
+    故 ① saleStartAt 未到直接判未结束;② 售罄改判 soldCount>=totalCardCount(卖满才算),彻底不看 availableStock
+    (2026/09/04:GB26090464871 卖 2/31 时 availableStock=0 被旧逻辑误判售罄、拉黑,漏采后续爆发,0 人参与)。
+
+    Args:
+        data (dict): 详情接口 data 层。
+        now_ts (int): 当前秒级时间戳。
+
+    Returns:
+        tuple[bool, str]: (是否已结束, 原因文本)。未结束时原因为空串。
+    """
+    # ① 开卖时间未到 → 预售态,绝不算结束(预售车 availableStock=0 是「未分配」而非「卖光」)
+    start_text = data.get("saleStartAt")
+    if start_text:
+        try:
+            start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
+            if now_ts < start_ts:
+                return False, ""
+        except (ValueError, OverflowError):
+            pass
+    # ② 售罄:改用「已售 >= 总份数」判定,不再用 availableStock。
+    #    直播实时放量卖的车,availableStock 反映的是「当前这一刻放出、还没被抢的量」而非总剩余,
+    #    卖到一半也会瞬时抖成 0("这一秒没货可抢"≠"卖光";实测 GB26090464871 卖 2/31 时 availableStock=0)。
+    #    旧逻辑凭 availableStock<=0 会把它误判售罄→拉黑→漏采后续真实抢车爆发(2026/09/04,0 人参与拆卡)。
+    #    detail 的 totalCardCount 实测可靠(=总份数);真售罄另有 is_on_sale=0 那道兜底移出监控,故从严只认卖满。
+    sold = data.get("soldCount")
+    total = data.get("totalCardCount")
+    if sold is not None and total is not None and total > 0 and sold >= total:
+        return True, f"售罄(soldCount={sold}/{total})"
+    end_text = data.get("saleEndAt")
+    if end_text:
+        try:
+            end_ts = time.mktime(time.strptime(end_text, "%Y-%m-%d %H:%M:%S"))
+            if now_ts >= end_ts:
+                return True, f"已过结束时间(saleEndAt={end_text})"
+        except (ValueError, OverflowError):
+            pass
+    return False, ""
+
+
+def is_presale(data: dict, now_ts: int) -> bool:
+    """判断商品是否处于「预售未开卖」(saleStartAt 在未来)。
+
+    预售期没人能买、购买记录必空,无需 10s 密探,60s 稀探即可省请求;到开卖时间自动转密探。
+    saleStartAt 缺失/解析失败按「已开卖」处理(保守走密探,不漏)。
+
+    Args:
+        data (dict): 详情接口 data 层。
+        now_ts (int): 当前秒级时间戳。
+
+    Returns:
+        bool: True=预售未开卖;False=已开卖或无法判断。
+    """
+    start_text = data.get("saleStartAt")
+    if not start_text:
+        return False
+    try:
+        start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
+        return now_ts < start_ts
+    except (ValueError, OverflowError):
+        return False
+
+
+def throttled_do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
+    """核心签名 POST 请求 + 全局令牌桶限速(多线程安全)。
+
+    用「时隙预约」实现全局限速:锁内只算出本次请求应发的时刻(领一个较上次错开 GLOBAL_MIN_GAP_SEC 的
+    时隙、并推进 _next_slot),随后在**锁外** sleep 到该时刻再发请求。故锁只保护 μs 级的时隙计算、
+    不串行化整个请求,N 个 worker 能各领错峰时隙、稳定逼近 5 次/秒并发而不突刺——峰值速率天花板不变。
+    详情/在售列表都走这里,共用同一根令牌桶。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径。
+        body (dict): 请求体(同时用于签名)。
+        need_auth (bool, optional): 是否带 Bearer token。详情接口不需要(False),
+            在售列表需要(True)。Defaults to False。
+
+    Returns:
+        dict | None: 响应 JSON。
+    """
+    global _next_slot
+    with _slot_lock:
+        now = time.time()
+        start = now if now >= _next_slot else _next_slot   # 领本次时隙:不早于「现在」也不早于「上次时隙+间隔」
+        _next_slot = start + GLOBAL_MIN_GAP_SEC             # 推进下一个可发时隙,后续请求依次错峰
+    wait = start - time.time()
+    if wait > 0:
+        time.sleep(wait)                                    # 锁外 sleep,不阻塞其他 worker 领时隙
+    return core.do_request(log, path, body, need_auth=need_auth)
+
+
+def compute_next_interval(span_sec: int, card_count: int | None = None) -> int:
+    """按 10 条时间跨度算下次采集间隔:空闲/预售探测、爆发顶格、卖着按跨度自适应。
+
+    span_sec 三态:>0=有真实跨度(按 span//3 自适应);==0=最近 10 条同分钟桶(超热爆发);
+    ==-1=已开卖但本轮无购买记录(接口延迟/暂无人买);<=-2=预售未开卖(均由 poll_product 标记)。
+
+    统一「爆发密采」(2026/08/25 优化 A):span==0 对任意尺寸都表示「上一分钟卖了≥10份=正在热卖」,
+    故所有车 span==0 时一律返回 HOT_INTERVAL_SEC 顶格密采,不再按尺寸区分(修掉旧逻辑「快车 span==0→20s、
+    普通/大车→600s」追不上爆发翻窗、31 份快车 58~74%、大车 40% 的漏采)。尺寸只影响另两态:
+      - 空闲(span<0,本轮无购买记录/预售):预售未开卖(span=-2)用 PRESALE_IDLE_SEC(60s)稀探省请求;已开卖但暂无记录(span=-1)
+        大中小车统一 IDLE_PROBE_SEC(10s)密探追首条(2026/08/27~28,不再分档);
+        决定「多快发现它开卖 / 追上首条购买记录」。采到首条后即转 span 自适应,不影响真慢车稀采。
+      - 转慢(span>0,有跨度):一律 span//3,硬夹进 [MIN, 对应档上限 cap],售卖变慢自动回退。
+
+    Args:
+        span_sec (int): 10 条 purchaseRecords 最新与最老的秒数差;0=同桶爆发,<0=本轮无购买记录(空闲)。
+        card_count (int, optional): 商品总份数,用于分档(仅影响空闲探测与慢档上限)。None 按普通处理。Defaults to None。
+
+    Returns:
+        int: 下次采集间隔(秒)。
+    """
+    cc = card_count or 0
+    if span_sec <= -2:    # 预售未开卖:没人能买、购买记录必空 → 稀探省请求;到点开卖 poll 会改给 -1 转密探
+        return PRESALE_IDLE_SEC
+    if span_sec < 0:      # 已开卖但本轮无购买记录(刚纳入/接口延迟响应0条):所有档统一 10s 密探,尽快追上首条
+        return IDLE_PROBE_SEC
+    if span_sec == 0:     # 最近 10 条同桶:爆发(任意尺寸) → 统一顶格密采
+        return HOT_INTERVAL_SEC
+    # 有跨度:span//3 自适应(3x 余量防翻窗),夹进 [MIN, 慢档上限 cap]。
+    # cap 只按「快车 vs 其余」分:快车份数小、卖极快,用更小上限;其余(含原大车)统一 MAX_INTERVAL_SEC。
+    cap = FAST_LANE_MAX_INTERVAL_SEC if 0 < cc <= FAST_LANE_MAX_COUNT else MAX_INTERVAL_SEC
+    return max(MIN_INTERVAL_SEC, min(cap, span_sec // 3))
+
+
+# ==================== 订单判重 ====================
+def load_order_index(pool, code: str):
+    """从库里恢复某商品已入库订单的反推时间戳到内存索引(进程启动/新商品纳入时调)。
+
+    进程重启会丢失内存索引,靠这步从 DB 恢复,避免重启后把老订单当新订单重复入库。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+    """
+    rows = pool.select_all(
+        f"SELECT user_id, card_count, purchased_at_ts FROM {TABLE} WHERE product_code=%s", (code,)) or []
+    # DB 查询在锁外;仅把「写全局 _order_idx」这步收进 _state_lock,与 worker(其他 code)的并发追加互斥
+    with _state_lock:
+        for uid, cc, ts in rows:
+            _order_idx.setdefault((code, str(uid), cc), []).append(int(ts))
+
+
+def is_duplicate_order(code: str, uid: str, cc: int, ts: int, tol: int) -> bool:
+    """判断一条购买记录是否为已入库订单的相对时间漂移(非新订单)。
+
+    在同 (code, uid, cc) 下的已入库时间戳里找是否有一个与 ts 相差 <= tol;
+    有则视为同一笔订单的漂移(重复),无则视为新订单。tol 由该记录粒度动态给出
+    (见 dedup_tolerance),粗粒度用大容差以吸附漂移副本。
+
+    Args:
+        code (str): 商品编码。
+        uid (str): 买家用户 ID。
+        cc (int): 购买份数。
+        ts (int): 本条记录反推的绝对购买时间戳(秒)。
+        tol (int): 本条记录的判重容差(秒),来自 dedup_tolerance。
+
+    Returns:
+        bool: True 表示是已有订单的漂移(应跳过),False 表示新订单(应入库)。
+    """
+    for ts_existing in _order_idx.get((code, str(uid), cc), []):
+        if abs(ts - ts_existing) <= tol:
+            return True
+    return False
+
+
+def align_new_orders(prev_keys: list, curr_keys: list) -> tuple[int, bool]:
+    """用倒序滑动窗口的整体位移,求本轮从顶部新增的订单数。
+
+    purchaseRecords 是倒序滑动窗口:新单只从顶部推入、旧单整体下移。故存在最小 k,使
+    curr_keys[k:] 与 prev_keys[:len(curr_keys)-k] 逐元素 (user_id, card_count) 相等
+    (旧记录整体下移 k 位);则 curr_keys[:k] 即本轮新增。取最小 k(最大重叠)作最保守估计,
+    残余错位由调用方的动态容差 + DB 唯一键二次兜底。
+
+    Args:
+        prev_keys (list): 上次窗口的 (user_id, card_count) 序列(index0 最新);无上次窗口传 None/[]。
+        curr_keys (list): 本次窗口的 (user_id, card_count) 序列(index0 最新)。
+
+    Returns:
+        tuple[int, bool]: (新增条数 k, 是否可靠对齐)。窗口整体换新/无上次窗口无法对齐时,
+            返回 (len(curr_keys), False),表示需退回容差兜底。
+    """
+    n = len(curr_keys)
+    if not prev_keys:
+        return n, False                          # 无上次窗口(重启首轮):交给兜底
+    for k in range(0, n + 1):
+        overlap = n - k
+        if overlap == 0:
+            return n, False                      # 与上次完全不重叠:整窗换新,疑似漏采
+        if overlap > len(prev_keys):
+            continue                             # 上次窗口不够长,尝试更大的 k
+        if curr_keys[k:] == prev_keys[:overlap]:
+            return k, True                       # 旧记录整体下移 k 位对齐成功,顶部 k 条为新增
+    return n, False
+
+
+# ==================== 商品源(复用 daily 免 token 采集 + 查库)====================
+def snapshot_onsale_progress(log, pool) -> int:
+    """采集当前在售商品的进度快照到 deca_onsale_product_progress_record(append-only,变化才写)。
+
+    每次 get_onsale_products 更新完 deca_onsale_product_record 后调一次:先从 progress 表拿每个
+    商品的最新 sold_count 做基线(一次 JOIN 一次性拿全),跟 deca_onsale_product_record 当前值
+    对比,**sold_count 变化 或 从未记录过的商品** 才 INSERT 一行——避免"没卖动"的团重复占位。
+
+    与 deca_onsale_product_daily_record(每日单点)互补:本表是分钟级细粒度时间序列,供后续
+    画进度曲线、算售卖速度、找热销时段等统计。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 本轮写入的新快照行数。
+    """
+    # 1) 基线:progress 表每个商品的最新一条 sold_count(表可能为空 → baseline={})
+    baseline_rows = pool.select_all(
+        f"SELECT p.product_code, p.sold_count "
+        f"FROM {PROGRESS_TABLE} p "
+        f"INNER JOIN (SELECT product_code, MAX(captured_at) AS max_ts "
+        f"            FROM {PROGRESS_TABLE} GROUP BY product_code) t "
+        f"  ON p.product_code=t.product_code AND p.captured_at=t.max_ts") or []
+    baseline = {code: sold for code, sold in baseline_rows}
+
+    # 2) 本轮 onsale 表里所有在售商品的当前状态(get_onsale_products 刚 upsert 过)
+    curr = pool.select_all(
+        f"SELECT product_code, merchant_user_id, sold_count, available_stock, card_count, unit_price "
+        f"FROM {ONSALE_TABLE} WHERE is_on_sale=1") or []
+
+    # 3) Python 对比:sold_count 与基线不同 或 从未记录 → 加入待插入
+    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    rows_to_insert = []
+    for code, mid, sold, avail, card, up in curr:
+        if code is None:
+            continue
+        if code in baseline and baseline[code] == sold:
+            continue                                          # 未变化,跳过
+        pct = None
+        if card and sold is not None:
+            try:
+                pct = round(sold * 100.0 / card, 2)
+            except Exception:
+                pct = None
+        rows_to_insert.append({
+            "product_code": code, "merchant_user_id": mid,
+            "sold_count": sold, "available_stock": avail, "card_count": card,
+            "progress_pct": pct, "unit_price": up, "captured_at": now,
+        })
+    if rows_to_insert:
+        pool.insert_many(table=PROGRESS_TABLE, data_list=rows_to_insert, ignore=False)
+    log.info(f"[进度快照] 变化写入 {len(rows_to_insert)} 条(当前在售 {len(curr)} 个 / 基线 {len(baseline)} 个)")
+    return len(rows_to_insert)
+
+
+def get_monitored_onsale(log, pool, merchant_ids: list) -> int:
+    """逐监控商家拉在售商品并落库(带 token 走直连)。
+
+    2026/09/05 起站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效;监控侧只关心
+    MERCHANT_IDS 这几家,故改为逐商家调 on-sale-list(需 token)分页拉取,parse_product 解析后
+    save_products upsert(is_on_sale=1 自愈)。请求量小(几家×每页 20),token 走直连保持账号↔IP 稳定。
+    注意:本函数只对指定商家落库,**不做全站下架对账**——_sweep_offsale 只能在全站全量集合上做,
+    在此做会把非监控商家的在售商品误置为下架。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        merchant_ids (list[str]): 监控商家 user_id 列表。
+
+    Returns:
+        int: 本轮写入/更新的商品数(各商家合计)。
+    """
+    saved = 0
+    for mid in merchant_ids:
+        page = 1
+        total = None
+        while page <= ON_SALE_MAX_PAGES:
+            body = {"merchantUserId": mid, "page": page, "pageSize": ON_SALE_PAGE_SIZE}
+            try:
+                resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+            except Exception as e:
+                log.error(f"商家 {mid} 在售第 {page} 页请求失败: {e}")
+                break
+            if not resp or resp.get("code") != 0:
+                log.info(f"商家 {mid} 在售返回异常: {resp.get('msg') if resp else None}")
+                break
+            data = resp.get("data") or {}
+            if total is None:
+                total = data.get("total")
+            items = data.get("list") or []
+            if not items:
+                break
+            rows = [r for r in (parse_product(it) for it in items) if r]
+            saved += save_products(log, rows, pool)
+            if total is not None and page * ON_SALE_PAGE_SIZE >= total:  # 已采满 total
+                break
+            if len(items) < ON_SALE_PAGE_SIZE:                          # 本页不足一页即末页
+                break
+            page += 1
+    return saved
+
+
+def ingest_onsale(log, pool):
+    """采监控商家在售落库 + 记录进度时间序列(供刷新监控队列 + 进度统计)。
+
+    每轮拉 MERCHANT_IDS 各商家在售(带 token,get_monitored_onsale)写 deca_onsale_product_record;
+    紧跟 snapshot_onsale_progress 记录本轮进度变化(分钟级时间序列)。各步独立 try/except,单步失败不拖垮整轮。
+    注:全站商家发现 / 今日新增补详情 / 全站下架对账等「全市场」职责已移交电脑 C 的在售报告(发报告前全量对账),
+    本脚本(服务器 B)只负责监控商家的采集,不再碰全站。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+    """
+    try:
+        n = get_monitored_onsale(log, pool, MERCHANT_IDS)
+        log.info(f"[在售落库] 监控 {len(MERCHANT_IDS)} 家在售写入/更新 {n} 个")
+    except Exception as e:
+        log.error(f"get_monitored_onsale error: {e}")
+    # 进度时间序列:紧跟 onsale 表更新之后跑(此时表里就是最新一轮的 sold_count/available_stock)
+    try:
+        snapshot_onsale_progress(log, pool)
+    except Exception as e:
+        log.error(f"snapshot_onsale_progress error: {e}")
+
+
+def fetch_on_sale_products(log, merchant_id: str, pool) -> list:
+    """从库里查某商家当前在售商品(deca_onsale_product_record,由 ingest_onsale 落库)。
+
+    在售数据已由 ingest_onsale 复用 daily 的 home/search(免 token)落库,这里只查库拿该商家
+    is_on_sale=1 的商品,避免再打一次需 token 的 on-sale-list 接口。
+
+    Args:
+        log: 日志对象。
+        merchant_id (str): 商家用户 ID。
+        pool: MySQL 连接池。
+
+    Returns:
+        list[dict]: 每项含 code / title / sold_count / card_count / available_stock。
+    """
+    rows = pool.select_all(
+        f"SELECT product_code, title, sold_count, card_count, available_stock, merchant_user_id, merchant_name "
+        f"FROM {ONSALE_TABLE} WHERE merchant_user_id=%s AND is_on_sale=1", (merchant_id,)) or []
+    out = []
+    for pc, title, sold, cc, stock, mid, mname in rows:
+        out.append({
+            "code": pc,
+            "title": title,
+            "sold_count": sold,
+            "card_count": cc,
+            "available_stock": stock,
+            "merchant_user_id": mid,
+            "merchant_name": mname,
+        })
+    return out
+
+
+# ==================== 单商品采集 ====================
+def build_row(rec: dict, code: str, now_ts: int, now_dt: str, meta: dict) -> dict | None:
+    """把一条 purchaseRecords 项映射为 deca_buy_record 行字典。
+
+    Args:
+        rec (dict): 单条 purchaseRecords 项。
+        code (str): 商品编码。
+        now_ts (int): 本轮采集时刻的秒级时间戳。
+        now_dt (str): now_ts 对应的 datetime 文本。
+        meta (dict): 该商品冗余描述,含 title / merchant_user_id / merchant_name(来自监控队列/查库),随行入库便于查看。
+
+    Returns:
+        dict | None: 行字典;缺 userId 时返回 None。
+    """
+    uid = rec.get("userId")
+    if not uid:
+        return None
+    pat_text = rec.get("purchasedAt") or ""
+    pat_ts = parse_relative_ago(pat_text, now_ts)
+    return {
+        "product_code": code,
+        "merchant_user_id": meta.get("merchant_user_id"),
+        "merchant_name": meta.get("merchant_name"),
+        "title": meta.get("title"),
+        "user_id": str(uid),
+        "nickname": rec.get("nickname"),
+        "card_count": rec.get("cardCount"),
+        "purchased_at_text": pat_text,
+        "purchased_at_ts": pat_ts,
+        "purchased_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(pat_ts)),
+        "first_seen_at": now_dt,
+    }
+
+
+def poll_product(log, pool, code: str, meta: dict) -> tuple:
+    """对单个商品拉一次详情、判售卖是否结束、入库、算 span。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        meta (dict): 该商品冗余描述(title/merchant_user_id/merchant_name),随每条购买记录入库便于查看。
+
+    Returns:
+        tuple[int, int, bool, str]: (span_sec, new_count, ended, end_reason) ——
+            10 条时间跨度秒数(0=最近10条同桶超热,-1=本轮无购买记录/空闲,供 compute_next_interval 分档调频)、
+            本轮新增入库条数、是否售卖结束、结束原因文本(未结束为空串)。
+    """
+    resp = throttled_do_request(log, DETAIL_PATH, {"code": code})
+    data = (resp or {}).get("data") or {}
+
+    now_ts = int(time.time())
+    ended, end_reason = is_sale_ended(data, now_ts)  # 结束也先把本轮记录收尾入库,再由主循环移出
+
+    recs = data.get("purchaseRecords") or []
+    if not recs:
+        # 无购买记录:预售未开卖→-2(稀探省请求);已开卖但接口延迟/暂无人买→-1(密探追首条)。均区别于同桶超热 span=0
+        return (-2 if is_presale(data, now_ts) else -1), 0, ended, end_reason
+
+    now_dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ts))
+    rows = [r for r in (build_row(x, code, now_ts, now_dt, meta) for x in recs) if r]
+    if not rows:
+        return -1, 0, ended, end_reason        # 有 recs 但无有效 userId:同样按空闲处理
+
+    # 计算 span:records[0] 最新、records[-1] 最老,均是相对时间反推
+    ts_new = rows[0]["purchased_at_ts"]
+    ts_old = rows[-1]["purchased_at_ts"]
+    span_sec = max(0, ts_new - ts_old)
+
+    # 主判重:倒序滑动窗口序列对齐——只有从顶部新增的 k 条才是本轮新订单。
+    # 判重段落收进 _state_lock(纯内存、μs 级):与主线程 refresh 对同一 code 的 _last_window/_order_idx
+    # 增删互斥,避免多线程下同键并发读改。上方网络请求、下方 DB 写均在锁外,不被串行化、不拖慢吞吐。
+    curr_keys = [(r["user_id"], r["card_count"]) for r in rows]
+    with _state_lock:
+        prev_keys = _last_window.get(code)
+        k, reliable = align_new_orders(prev_keys, curr_keys)
+        if reliable:
+            # 对齐可靠:顶部 k 条直接入库,不再走时间容差(否则会误杀同用户短间隔的多笔真实订单)
+            new_rows = rows[:k]
+            for row in new_rows:
+                _order_idx.setdefault((code, row["user_id"], row["card_count"]), []).append(row["purchased_at_ts"])
+        else:
+            # 无法对齐(进程重启首轮 / 窗口整体换新):整窗退回动态容差 + DB 唯一键兜底
+            if prev_keys:
+                log.warning(f"[{code}] 窗口无重叠,疑似漏采(采集间隔内卖出≥{len(rows)}笔),建议提高采集频率")
+            new_rows = []
+            for row in rows:
+                tol = dedup_tolerance(row["purchased_at_text"])
+                if is_duplicate_order(code, row["user_id"], row["card_count"], row["purchased_at_ts"], tol):
+                    continue
+                new_rows.append(row)
+                _order_idx.setdefault((code, row["user_id"], row["card_count"]), []).append(row["purchased_at_ts"])
+        _last_window[code] = curr_keys          # 更新窗口快照,供下轮对齐(锁内,与 refresh 的 pop 互斥)
+
+    if new_rows:
+        # DB 唯一键(含 purchased_at_ts)兜底防并发/异常重复;主判重靠窗口对齐已完成。DB 写在锁外,各线程借还连接池连接并发写。
+        pool.insert_many(table=TABLE, data_list=new_rows, ignore=True)
+
+    return span_sec, len(new_rows), ended, end_reason
+
+
+# ==================== 主循环 ====================
+def _refresh_monitored(log, pool, monitored: dict, merchant_ids: list, now: float):
+    """刷新监控队列:新商品立即入队,下线商品移出队列。
+
+    WATCH_CODES 非空 → 只监控白名单里的 code(跳过在售发现,无下线逻辑)。
+    WATCH_CODES 为空 → 查库拿 merchant_ids 各商家的在售商品(由 ingest_onsale 落库),合并去重后动态增删。
+    每个商品首次纳入监控时,从库里恢复其订单去重索引(避免重启后重复入库)。
+
+    多商家(2026/08/24):live_codes 取所有监控商家在售 code 的并集;因监控队列里的 code 只会从这些
+    商家的在售列表入队,故「不在并集内」即代表下线,移出逻辑对多商家天然正确、无需按商家分别对账。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池(用于恢复订单去重索引)。
+        monitored (dict): 监控状态字典 {code: {next_run_ts, interval, title, ...}},就地更新。
+        merchant_ids (list[str]): 商家 ID 列表。
+        now (float): 当前时间戳。
+    """
+    if WATCH_CODES:
+        # 单/多商品白名单模式:只挂想盯的 code,不动其他。
+        # 先锁内挑出待新增的 code(读 monitored 需与 worker 互斥),锁外做 DB 恢复索引,再锁内加入监控。
+        with _state_lock:
+            to_add = [c for c in WATCH_CODES if c not in _ended_codes and c not in monitored]
+        for code in to_add:
+            load_order_index(pool, code)      # 从库恢复订单去重索引(DB 在锁外,内部自带锁写 _order_idx)
+        with _state_lock:
+            for code in to_add:
+                if code in monitored:         # 双检:避免与并发刷新重复加入
+                    continue
+                monitored[code] = {
+                    "next_run_ts": now,
+                    "interval": MIN_INTERVAL_SEC,
+                    "title": f"(白名单 code) {code}",
+                    "sold_count": None,
+                    "card_count": None,
+                    "merchant_user_id": None,
+                    "merchant_name": None,
+                }
+                log.info(f"[+] 纳入监控 {code} | 白名单模式 | 已恢复历史订单索引")
+            cnt = len(monitored)
+        log.info(f"当前监控商品数:{cnt}(白名单模式,共 {len(WATCH_CODES)} 个 code)")
+        return
+
+    # 合并各监控商家的在售商品(DB 读,锁外;按 code 去重,正常不同商家不会共享 code)
+    products, seen = [], set()
+    for mid in merchant_ids:
+        for p in fetch_on_sale_products(log, mid, pool):
+            if p["code"] in seen:
+                continue
+            seen.add(p["code"])
+            products.append(p)
+    live_codes = {p["code"] for p in products}
+
+    # 锁内挑出待新增商品(未结束、未在监控);锁外逐个从库恢复订单索引;再锁内应用增删。
+    with _state_lock:
+        new_products = [p for p in products
+                        if p["code"] not in _ended_codes and p["code"] not in monitored]
+    for p in new_products:
+        load_order_index(pool, p["code"])     # 从库恢复订单去重索引(DB 在锁外)
+    with _state_lock:
+        # 新增:立即到点采
+        for p in new_products:
+            if p["code"] in monitored:        # 双检:避免与并发刷新重复加入
+                continue
+            monitored[p["code"]] = {
+                "next_run_ts": now,
+                "interval": MIN_INTERVAL_SEC,
+                "title": p["title"],
+                "sold_count": p["sold_count"],
+                "card_count": p["card_count"],
+                "merchant_user_id": p["merchant_user_id"],
+                "merchant_name": p["merchant_name"],
+            }
+            log.info(f"[+] 纳入监控 {p['code']} | {p['merchant_name']} | {p['title']} | 售 {p['sold_count']}/{p['card_count']}")
+        # 下线:移出(不在任一监控商家在售并集内即下线)。in-flight 的 worker 完成时会双检 code 是否仍在监控,故安全。
+        for code in list(monitored.keys()):
+            if code not in live_codes:
+                log.info(f"[-] 移出监控 {code} | {monitored[code].get('title')}")
+                del monitored[code]
+                _last_window.pop(code, None)     # 下线同步清窗口快照,重新上架时按重启首轮兜底
+        cnt = len(monitored)
+    log.info(f"当前监控商品数:{cnt}(商家 {len(merchant_ids)} 家)")
+
+
+def _current_ingest_interval(now: datetime) -> int:
+    """按当前时刻取「全站在售发现」间隔:快车高发时段(20:00~次日06:00)加密,其余用常规节奏。
+
+    快车寿命仅 2~3 分钟,60s 的全站发现节奏偶尔会整场错过;故该时段压到 FAST_WINDOW_INGEST_SEC
+    密探,更快把新车纳入监控。窗口跨午夜,故「晚于开始 或 早于结束」即算命中。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        int: 本轮应使用的全站在售发现间隔(秒):命中快车时段返回 FAST_WINDOW_INGEST_SEC,否则 ONSALE_INGEST_SEC。
+    """
+    t = now.time()
+    in_fast_window = t >= FAST_WINDOW_START or t < FAST_WINDOW_END
+    return FAST_WINDOW_INGEST_SEC if in_fast_window else ONSALE_INGEST_SEC
+
+
+def _poll_worker(log, pool, monitored: dict, code: str):
+    """线程池 worker:并发采集单个商品一次并回写调度状态(多线程安全)。
+
+    先在 _state_lock 内取该 code 的快照 meta(若已被 refresh 下线则跳过),随后**锁外**执行 poll_product
+    (含网络请求与 DB 写,是耗时大头,故不占锁);完成后按结果在锁内更新 next_run_ts,或(售罄/到期)移出监控
+    并拉黑。无论成败,finally 都从 _inflight 移除该 code,保证同一商品下一轮可再次被派发。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池(线程安全,各 worker 借还各自连接)。
+        monitored (dict): 监控状态字典(共享,读改均在 _state_lock 内)。
+        code (str): 本次采集的商品编码。
+    """
+    try:
+        with _state_lock:
+            meta = monitored.get(code)
+            if meta is None:
+                return                       # 已被 refresh 移出(下线),本次跳过
+            meta = dict(meta)                # 快照,避免采集期间被并发改动
+        span_sec, n_rows, ended, end_reason = poll_product(log, pool, code, meta)
+        with _state_lock:
+            if ended:
+                # 售卖结束:本轮收尾记录已在 poll_product 入库,这里移出监控 + 拉黑,避免死循环重采固定的最后 10 条
+                _ended_codes.add(code)
+                _last_window.pop(code, None)     # 清窗口快照,避免复活时误对齐
+                monitored.pop(code, None)
+                log.info(f"[{code}] 售卖已结束({end_reason}),停止采集并移出监控 | {meta.get('title')}")
+            elif code in monitored:          # 仍在监控(未被 refresh 下线)才回写下次采集时刻
+                interval = compute_next_interval(span_sec, monitored[code].get("card_count"))
+                monitored[code]["next_run_ts"] = time.time() + interval
+                monitored[code]["interval"] = interval
+                log.info(f"[{code}] 响应 {n_rows} 条 | 10条跨度 {span_sec}s | 下次 {interval}s 后 | {monitored[code]['title']}")
+    except Exception as e:
+        log.error(f"[{code}] 采集失败: {e}")
+        with _state_lock:
+            if code in monitored:
+                monitored[code]["next_run_ts"] = time.time() + 60   # 失败退避 60s,避免异常商品高频重试阻塞
+    finally:
+        with _state_lock:
+            _inflight.discard(code)          # 无论成败都释放在途标记,允许下轮重新派发
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def main_task(log):
+    """常驻主流程:拉监控商家在售(带 token) + 线程池并发采各监控商家(MERCHANT_IDS)在售商品的购买记录(免 token)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常,触发外层每小时重试。
+    """
+    mode = f"白名单模式 codes={WATCH_CODES}" if WATCH_CODES else f"商家模式 merchants={MERCHANT_IDS}"
+    log.info(f"购买记录+在售常驻采集启动 | {mode} | 并发 {MAX_WORKERS} 线程(全局限速≈{1/GLOBAL_MIN_GAP_SEC:.0f}次/秒) "
+             f"| 购买记录频率 [{MIN_INTERVAL_SEC}s, {MAX_INTERVAL_SEC}s] "
+             f"| 在售发现常规每 {ONSALE_INGEST_SEC}s、快车时段({FAST_WINDOW_START:%H:%M}~次日{FAST_WINDOW_END:%H:%M})每 {FAST_WINDOW_INGEST_SEC}s "
+             f"| 购买记录免 token;在售列表带 token 走直连 | 详情代理={core.USE_PROXY}")
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="buy_record")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("db pool 异常")
+
+    monitored: dict = {}                        # {code: {next_run_ts, interval, title, sold_count, card_count}}
+    last_ingest = 0.0                           # 上次「在售落库 + 刷新监控」时刻
+
+    # 线程池常驻:主线程只负责「落在售/刷新监控 + 派发到点商品」,实际详情采集交由 worker 并发跑。
+    # 并发把串行时被请求延迟浪费的吞吐(仅 ~0.5 次/秒)填到接近全局限速上限(≈5 次/秒),追上快车翻窗节奏、减少漏采。
+    with ThreadPoolExecutor(max_workers=MAX_WORKERS, thread_name_prefix="poll") as executor:
+        while True:
+            now = time.time()
+
+            # 1) 每(动态)间隔:商家模式先拉监控商家在售(带 token)再查库刷新监控;白名单模式只按固定 code 刷新。
+            #    快车高发时段(20:00~次日06:00)把发现节奏加密到 FAST_WINDOW_INGEST_SEC,更快把 2~3 分钟即售罄的快车纳入监控。
+            #    本步在主线程串行做(期间 worker 仍在并发采集),频率低(10~60s 一次),不拖累采集吞吐。
+            if now - last_ingest >= _current_ingest_interval(datetime.now()):
+                try:
+                    if not WATCH_CODES:
+                        ingest_onsale(log, pool)
+                    _refresh_monitored(log, pool, monitored, MERCHANT_IDS, now)
+                except Exception as e:
+                    log.error(f"在售落库/刷新监控异常: {e}")
+                last_ingest = now
+
+            # 2) 派发:把所有「到点且不在途」的商品按 next_run_ts 升序提交给线程池并发采集。
+            #    先在锁内挑出并标记在途(避免下一轮重复派发同一 code),再锁外 submit(submit 本身线程安全)。
+            with _state_lock:
+                due = sorted(
+                    (c for c, s in monitored.items() if s["next_run_ts"] <= now and c not in _inflight),
+                    key=lambda c: monitored[c]["next_run_ts"])
+                for code in due:
+                    _inflight.add(code)
+            for code in due:
+                executor.submit(_poll_worker, log, pool, monitored, code)
+
+            # 3) 有到点任务用快节奏回来继续喂满 worker;无则短歇省 CPU。真正的请求速率由令牌桶封顶,此处只是调度节拍。
+            time.sleep(DISPATCH_SLEEP_SEC if due else IDLE_SLEEP_SEC)
+
+
+def schedule_task():
+    """脚本入口:直接进入 main_task(其 tenacity retry 保证挂了每小时重试)。"""
+    main_task(log=logger)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 398 - 0
deca_auto_spider/spiders/deca_team_spider.py

@@ -0,0 +1,398 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/11
+"""得卡 DECA · 随机团 teams 明细高频采集(选队随机 + 剩余随机)。
+
+背景:
+  卡牌拼团分两种"随机团"玩法,同一团会经历两阶段:
+    · 选队随机(playTypeName 以「选队随机」开头):每支球队一价,team-options 接口(带 token)
+      返回逐队 cardCount / availableStock / unitPrice;已售数 = cardCount − availableStock。
+    · 剩余随机(playTypeName == 剩余随机):卖不动后转成的兜底阶段。团购价变成"剩余池加权
+      均价"、买家随机开一张剩余卡。team-options **对剩余随机永远返 29000**、详情 snapshot
+      冻结在转换那一刻(每队只有 unitPrice/availableStock,无原始 cardCount)。
+
+关键结论:**一旦转成剩余随机,原始 cardCount 就再也没接口能补回**。所以在选队阶段必须持续
+抓 team-options 存表(deca_groupbuy_team_record),转剩余后我们才能用「存表的原始总价 − 当前
+剩余货值」算出实时已售额。转剩余前没被我们抓到的老团,team_total_amount 留 NULL、报告端
+标注"原始数据缺失"。
+
+一轮做什么:
+  1) 从 deca_onsale_product_record 拉当前在售·选队随机团 → team-options →
+     upsert 到 teams 表(data_source=team_options,同 (code, team_id) 覆盖成最新,captured_at
+     记本轮时刻) → 算 Σ 单价×(cardCount − availableStock) 写回 team_total_amount。
+  2) 从 deca_onsale_product_record 拉当前在售·剩余随机团 → 详情 snapshot 首次存表
+     (data_source=snapshot;冻结不变,重复见到直接跳过) → 若库里有该团选队阶段的原始
+     cardCount,则算 Σ 单价×cardCount − detail.unitPrice × detail.availableStock 写回
+     team_total_amount;否则留 NULL。
+
+登录态:
+  - team-options 强制带 token(不带返 10002);
+  - 详情接口 groupbuy/detail 免登录。
+  - 与 alert / sold_daily 走同一账号池(每批随机换号 + 各号专属 IP),无共享 token.json;
+    续签在 `AccountPool.ensure_access` 里按号独立轮换 refreshToken,由 `report_failure` 兜底判死。
+
+运行:项目根目录 `python deca_team_spider.py`(2026/08/20 由 on_sale/ 迁回根目录)
+"""
+import os
+import sys
+import time
+from datetime import datetime
+from decimal import Decimal, ROUND_HALF_UP
+
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+# 挂靠新项目根:sys.path 指向 common、CWD 对齐新根(application.yml / ./logs / 账号池 DB 均在此)
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+import deca_sold_core as core              # noqa: E402
+from mysql_pool import MySQLConnectionPool  # noqa: E402
+
+# ==================== 配置 ====================
+INTERVAL_SEC = 300              # 采集间隔:5 分钟一轮(选队随机变化较快、又不至于压接口)
+BETWEEN_ITEM_SEC = 0.3          # 单商品之间的最小间隔,避免瞬时限流
+MAX_ITEM_PER_ROUND = 500        # 单轮采集商品数上限(保护;正常远小于此)
+
+ONSALE_TABLE = "deca_onsale_product_record"
+TEAM_TABLE   = "deca_groupbuy_team_record"
+
+# 只监控重点商家的选队随机(2026/09/10):原扫全站 is_on_sale=1(实测 449 个/99 家含大量陈旧未下架
+# 死团,每 5 分钟对每个打带 token 的 team-options,白烧 token 扩风控面)。改为只扫这 5 家(与购买记录
+# buy_record_spider.MERCHANT_IDS 一致),请求量骤降、省 token 降风控面。
+WATCH_MERCHANT_IDS = [
+    "881226408",   # 魔都兄弟球星卡
+    "274584650",   # 卡皇拆卡
+    "538252487",   # 尼卡拆卡
+    "591544726",   # 文泰卡屋
+    "606370597",   # 魔都兄弟综合体育
+]
+
+# ==================== 日志 ====================
+logger.remove()
+logger.add("./logs/team_spider_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+# logger.add(sys.stderr, level="INFO",
+#            format="[{time:HH:mm:ss}] {level} {message}")
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,业务函数首参约定为 log。
+
+    Args:
+        retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
+    """
+    log = retry_state.args[0] if retry_state.args else logger
+    if retry_state.outcome.failed:
+        log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+def _round2(x) -> Decimal | None:
+    """Decimal 化并四舍五入到 2 位小数。
+
+    Args:
+        x: 数值(int/float/str/Decimal),None/异常直接返回 None。
+
+    Returns:
+        Decimal | None: 保留 2 位;None 表示不可算。
+    """
+    if x is None:
+        return None
+    try:
+        return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+    except Exception:
+        return None
+
+
+def fetch_onsale_random(log, pool) -> tuple[list, list]:
+    """从 deca_onsale_product_record 查当前在售·选队随机 / 剩余随机团 code 列表。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        tuple[list[str], list[str]]: (选队随机 codes, 剩余随机 codes)。
+    """
+    rows = pool.select_all(
+        f"SELECT product_code, play_type_name FROM {ONSALE_TABLE} "
+        f"WHERE is_on_sale=1 AND play_type_name IS NOT NULL "
+        f"AND merchant_user_id IN ({','.join(['%s'] * len(WATCH_MERCHANT_IDS))}) "  # 只扫重点 5 家(省 token 降风控面)
+        f"LIMIT {MAX_ITEM_PER_ROUND}", tuple(WATCH_MERCHANT_IDS)) or []
+    xd, sy = [], []
+    for code, ptn in rows:
+        if not ptn:
+            continue
+        if "选队随机" in ptn:
+            xd.append(code)
+        elif "剩余随机" in ptn:
+            sy.append(code)
+    log.info(f"[发现] 在售·选队随机 {len(xd)} / 剩余随机 {len(sy)}")
+    return xd, sy
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def fetch_team_options(log, code: str) -> list | None:
+    """打 team-options 接口拿一个团的逐队 teams(带 token)。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码。
+
+    Returns:
+        list | None: teams 列表;接口报 29000(已转剩余随机)或失败时返回 None(None 是业务态,
+                     不视为需要重试的错误——只有网络级异常才会被 tenacity 拦下重试)。
+    """
+    r = core.do_request(log, "/api/v1/app/groupbuy/team-options", {"code": code}, need_auth=True)
+    if not r or r.get("code") != 0:
+        return None    # 29000 = 商家开启剩余随机中,正常业务态
+    return (r.get("data") or {}).get("list") or []
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def fetch_detail(log, code: str) -> dict | None:
+    """打 detail 接口拿详情(免 token)。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码。
+
+    Returns:
+        dict | None: data 字典;失败返回 None。
+    """
+    d = core.do_request(log, "/api/v1/app/groupbuy/detail", {"code": code}, need_auth=False)
+    return (d or {}).get("data") or None
+
+
+def upsert_team_row(pool, code: str, ptn: str, source: str, team: dict,
+                    captured_at: str, snap_total: int | None = None) -> None:
+    """把一条 team 明细 upsert 进 deca_groupbuy_team_record。
+
+    唯一键 (product_code, team_id, data_source) 冲突时按最新覆盖——
+    这就把「在售团每轮覆盖成最新」自动做掉了。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        ptn (str): 采集时的 playTypeName。
+        source (str): 数据来源 'team_options' / 'snapshot'。
+        team (dict): 单条 team 数据(team-options 项 或 snapshot.teams 项)。
+        captured_at (str): 采集时刻 YYYY-MM-DD HH:MM:SS。
+        snap_total (int | None, optional): 剩余随机 snapshot.totalQuantity。Defaults to None。
+    """
+    tid = team.get("teamId")
+    if tid is None:
+        return
+    cc = team.get("cardCount")                 # 选队阶段有;剩余快照无 → NULL
+    av = team.get("availableStock")
+    up = _round2(team.get("unitPrice"))
+    sold = (cc - av) if (cc is not None and av is not None) else None
+    pool._execute(
+        f"INSERT INTO {TEAM_TABLE} "
+        f"(product_code, play_type_name, data_source, team_id, team_name_en, team_name_zh, "
+        f" team_logo_image_url, unit_price, card_count, available_stock, sold_count, "
+        f" snapshot_total_quantity, captured_at) "
+        f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
+        f"ON DUPLICATE KEY UPDATE "
+        f"  play_type_name=VALUES(play_type_name), team_name_en=VALUES(team_name_en), "
+        f"  team_name_zh=VALUES(team_name_zh), team_logo_image_url=VALUES(team_logo_image_url), "
+        f"  unit_price=VALUES(unit_price), card_count=VALUES(card_count), "
+        f"  available_stock=VALUES(available_stock), sold_count=VALUES(sold_count), "
+        f"  snapshot_total_quantity=VALUES(snapshot_total_quantity), "
+        f"  captured_at=VALUES(captured_at)",
+        (code, ptn, source, tid, team.get("teamNameEn"), team.get("teamNameZh"),
+         team.get("teamLogoImageUrl"), up, cc, av, sold, snap_total, captured_at),
+        commit=True)
+
+
+def has_snapshot(pool, code: str) -> bool:
+    """判断某团是否已存过剩余随机 snapshot(冻结值,只需存一次)。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+
+    Returns:
+        bool: True 已存过、可跳过;False 未存。
+    """
+    row = pool.select_one(
+        f"SELECT 1 FROM {TEAM_TABLE} WHERE product_code=%s AND data_source='snapshot' LIMIT 1",
+        (code,))
+    return bool(row)
+
+
+def get_original_total(pool, code: str) -> Decimal | None:
+    """从 teams 表查某团选队阶段抓过的「原始总价」= Σ 单价×cardCount。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+
+    Returns:
+        Decimal | None: 原始总价;缺 team-options 记录时返回 None。
+    """
+    row = pool.select_one(
+        f"SELECT SUM(unit_price * card_count) FROM {TEAM_TABLE} "
+        f"WHERE product_code=%s AND data_source='team_options' AND card_count IS NOT NULL",
+        (code,))
+    if not row or row[0] is None:
+        return None
+    return _round2(row[0])
+
+
+def update_team_total(pool, code: str, amount: Decimal | None) -> None:
+    """把算好的团总价写回 deca_onsale_product_record.team_total_amount。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        amount (Decimal | None): 总价;None 会显式清空(表示"数据缺失")。
+    """
+    pool.update_one(
+        f"UPDATE {ONSALE_TABLE} SET team_total_amount=%s WHERE product_code=%s",
+        (amount, code))
+
+
+def process_xuandui(log, pool, code: str, captured_at: str) -> bool:
+    """处理一个在售·选队随机团:抓 team-options → 存表 → 算总价 → 回写商品。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        captured_at (str): 本轮采集时刻。
+
+    Returns:
+        bool: True 成功;False 接口不可用(如刚转成剩余随机)。
+    """
+    teams = fetch_team_options(log, code)
+    if not teams:
+        return False   # 可能这一瞬间刚转成剩余随机,本轮跳过;下轮以剩余随机身份进另一分支
+    total = Decimal("0")
+    ptn = "选队随机"       # 精确 name 已在商品行 play_type_name;此处只作 teams 表内的存档
+    for t in teams:
+        upsert_team_row(pool, code, ptn, "team_options", t, captured_at)
+        cc = t.get("cardCount") or 0
+        av = t.get("availableStock") or 0
+        up = Decimal(str(t.get("unitPrice") or "0"))
+        total += up * (cc - av)
+    update_team_total(pool, code, _round2(total))
+    return True
+
+
+def process_shengyu(log, pool, code: str, captured_at: str) -> bool:
+    """处理一个在售·剩余随机团:首次存 snapshot;每轮从 detail 取实时剩余算总价回写。
+
+    公式:team_total_amount = 原始总价(存表 Σ 单价×cardCount) − detail.unitPrice × detail.availableStock。
+    若库里没有该团选队阶段的 cardCount 记录,team_total_amount 留 NULL 表示"数据缺失"。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        captured_at (str): 本轮采集时刻(仅首次存 snapshot 时用)。
+
+    Returns:
+        bool: True 处理成功;False 详情拉失败。
+    """
+    dd = fetch_detail(log, code)
+    if not dd:
+        return False
+    # 1) 首次存 snapshot(冻结不变,只存一次)
+    if not has_snapshot(pool, code):
+        snap = dd.get("remainingRandomTeamSnapshot") or {}
+        for t in (snap.get("teams") or []):
+            upsert_team_row(pool, code, "剩余随机", "snapshot", t, captured_at,
+                            snap_total=snap.get("totalQuantity"))
+    # 2) 每轮重算实时总价
+    original = get_original_total(pool, code)
+    if original is None:
+        # 转剩余随机前没被我们抓到过 team-options → 拿不到原始总价,显式留 NULL
+        update_team_total(pool, code, None)
+        log.warning(f"[数据缺失] {code} 无选队阶段 team-options 记录,team_total_amount=NULL")
+        return True
+    up_now = Decimal(str(dd.get("unitPrice") or "0"))
+    av_now = int(dd.get("availableStock") or 0)
+    remaining_value = up_now * av_now
+    total = original - remaining_value
+    if total < 0:
+        # 边界:原始总价是"名义价",理论上不会 <0;出现即为数据异常,记日志、置 None
+        log.warning(f"[异常] {code} original={original} - remaining={remaining_value} <0,置 NULL")
+        update_team_total(pool, code, None)
+    else:
+        update_team_total(pool, code, _round2(total))
+    return True
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def main_task(log):
+    """采集主函数:一轮遍历在售随机团、更新 teams 表与 team_total_amount。
+
+    挂了每小时重试(无人值守);单轮内各商品独立 try/except,单个失败不拖垮整轮。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name}" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="team")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        xd_codes, sy_codes = fetch_onsale_random(log, pool)
+        captured_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+        # 选队随机:每轮全量刷新 team-options
+        ok_xd = fail_xd = 0
+        for c in xd_codes:
+            try:
+                if process_xuandui(log, pool, c, captured_at):
+                    ok_xd += 1
+                else:
+                    fail_xd += 1
+            except Exception as e:
+                fail_xd += 1
+                log.error(f"[选队随机·失败] {c}: {e}")
+            time.sleep(BETWEEN_ITEM_SEC)
+        log.info(f"[选队随机] 处理完成: 成功 {ok_xd} 失败 {fail_xd}")
+
+        # 剩余随机:每轮从 detail 取实时剩余、更新总价
+        ok_sy = fail_sy = 0
+        for c in sy_codes:
+            try:
+                if process_shengyu(log, pool, c, captured_at):
+                    ok_sy += 1
+                else:
+                    fail_sy += 1
+            except Exception as e:
+                fail_sy += 1
+                log.error(f"[剩余随机·失败] {c}: {e}")
+            time.sleep(BETWEEN_ITEM_SEC)
+        log.info(f"[剩余随机] 处理完成: 成功 {ok_sy} 失败 {fail_sy}")
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"{sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
+
+
+def schedule_task():
+    """定时入口:每 INTERVAL_SEC 秒跑一次 main_task。启动时立即跑一次。"""
+    main_task(log=logger)   # 启动即刻跑一轮,避免等 5 分钟才开始
+    schedule.every(INTERVAL_SEC).seconds.do(main_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 115 - 0
deca_auto_spider/spiders/maintenance.py

@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/09
+"""得卡运维常驻:账号池 cooling 巡检恢复 + 定时清理堆积的报告 Excel。
+
+两个后台定时任务(单进程 schedule 循环):
+    1) 账号池 cooling 巡检:每 10 分钟把冷却(cooling)到期的账号恢复 healthy(补账号池状态机闭环,
+       否则软失败转 cooling 的号永不自动恢复,见审查问题4)。
+    2) 报告 Excel 清理:每天 03:00 删除 EXCEL_DIRS 下超过 EXCEL_KEEP_DAYS 天的 .xlsx(报告每天生成、
+       堆积占盘),排除 EXCEL_KEEP_FILES 里的固定产物。
+
+⚠️ 待主公确认(2026/09/09):EXCEL_KEEP_DAYS 保留天数、EXCEL_DIRS 清理目录、EXCEL_KEEP_FILES 白名单。
+"""
+import os
+import sys
+import time
+import glob
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+import schedule
+from loguru import logger
+
+from mysql_pool import MySQLConnectionPool
+from account_pool import AccountPool
+
+logger.remove()
+logger.add(sys.stderr, format="[{time:HH:mm:ss}] {level} {message}", level="INFO")
+logger.add("./logs/maintenance_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss}] {level} {message}", level="INFO", retention="7 day")
+
+# ---- 配置(待主公确认)----
+EXCEL_KEEP_DAYS = 30                       # Excel 保留天数:删除早于此的报告 xlsx
+EXCEL_DIRS = [                             # 要清理的报告输出目录
+    os.path.join(_ROOT, "output", "onsale"),           # 在售报告
+    os.path.join(_ROOT, "reports", "sold_report"),     # 已售日/周/月报等
+]
+EXCEL_KEEP_FILES = {"球队.xlsx"}           # 固定产物白名单:不按天清理(非报告,勿删)
+COOLING_REVIVE_MINUTES = 10                # cooling 巡检间隔(分钟)
+KEEPALIVE_SCAN_HOURS = 1                    # token 保活巡检间隔(小时)
+KEEPALIVE_IDLE_HOURS = 6                    # 闲置超过该小时数的 healthy 号才主动刷新(远小于 refresh_token 过期时长)
+EXCEL_CLEAN_AT = "03:00"                   # 每天几点清理 Excel
+
+
+def keepalive_pool(log) -> None:
+    """巡检:对闲置超过 KEEPALIVE_IDLE_HOURS 的 healthy 号主动刷新 token,避免 refresh_token 闲到过期被判死。
+
+    Args:
+        log: 日志对象。
+    """
+    try:
+        pool = MySQLConnectionPool(log=log)
+        AccountPool(pool, log=log, task_tag="maintenance").keepalive_idle(KEEPALIVE_IDLE_HOURS, log=log)
+    except Exception as e:
+        log.warning(f"[保活] token 保活异常: {e}")
+
+
+def revive_pool(log) -> None:
+    """巡检:把账号池里 cooling 冷却到期的账号恢复 healthy。
+
+    Args:
+        log: 日志对象。
+    """
+    try:
+        pool = MySQLConnectionPool(log=log)
+        AccountPool(pool, log=log, task_tag="maintenance").revive_cooling()
+        log.info("[巡检] cooling 冷却到期账号已恢复检查完成")
+    except Exception as e:
+        log.warning(f"[巡检] cooling 恢复异常: {e}")
+
+
+def clean_old_excel(log) -> None:
+    """清理各报告目录下超过 EXCEL_KEEP_DAYS 天的 .xlsx(排除白名单固定产物)。
+
+    Args:
+        log: 日志对象。
+    """
+    cutoff = time.time() - EXCEL_KEEP_DAYS * 86400
+    removed = 0
+    for d in EXCEL_DIRS:
+        if not os.path.isdir(d):
+            continue
+        for f in glob.glob(os.path.join(d, "*.xlsx")):
+            if os.path.basename(f) in EXCEL_KEEP_FILES:      # 固定产物不清
+                continue
+            try:
+                if os.path.getmtime(f) < cutoff:
+                    os.remove(f)
+                    removed += 1
+                    log.info(f"[清理] 删除旧报告 {f}")
+            except Exception as e:
+                log.warning(f"[清理] 删除失败 {f}: {e}")
+    log.info(f"[清理] Excel 清理完成,删除 {removed} 个(保留最近 {EXCEL_KEEP_DAYS} 天)")
+
+
+def main() -> None:
+    """注册两个维护定时任务并进入单循环调度。"""
+    schedule.every(COOLING_REVIVE_MINUTES).minutes.do(revive_pool, logger)   # cooling 巡检
+    schedule.every(KEEPALIVE_SCAN_HOURS).hours.do(keepalive_pool, logger)     # token 保活巡检
+    schedule.every().day.at(EXCEL_CLEAN_AT).do(clean_old_excel, logger)      # Excel 清理
+    logger.info(f"运维守护启动:cooling 巡检每 {COOLING_REVIVE_MINUTES} 分钟 / "
+                f"token 保活每 {KEEPALIVE_SCAN_HOURS} 小时(闲置>{KEEPALIVE_IDLE_HOURS}h 刷新) / "
+                f"Excel 清理每天 {EXCEL_CLEAN_AT}(保留 {EXCEL_KEEP_DAYS} 天)")
+    revive_pool(logger)      # 启动即巡检一次
+    keepalive_pool(logger)   # 启动即保活一次
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 1074 - 0
deca_auto_spider/spiders/onsale_alert_spider.py

@@ -0,0 +1,1074 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA · 指定商家在售商品提醒(新品上架 + 进度过半,常驻轮询,支持多商家)。
+
+监控 MERCHANTS 列表中各商家的在售商品(每项 id/name/tag,加店只追加一行),三类提醒(各发一条独立消息):
+    1. 新商品上架:出现库里没有的新 product_code。
+    2. 进度过半:售卖进度 sold_count/card_count 首次达到 HALF_THRESHOLD。
+去重靠 deca_onsale_alert_record 两个标记位(new_notified/half_notified),每类每商品只提醒一次。
+
+发送渠道 SEND_CHANNEL 可切(2026/08/11 起默认企业微信):
+    - "qywx": 企业微信群机器人(markdown_v2)——发到 WEBHOOK_URL 配置的群,标题加粗、不带链接。
+    - "pc"  : PC 版微信(wxauto4 文本)发给 WX_TARGET——纯文本文案,随时可切回。
+
+消息不带商品链接(2026/08/11):得卡商品分享落地页(share-detail 接口)只认「登录态(带 token)
+生成的 shareCode」,免 token 详情返回的 shareCode 一律被判失效;为不引入登录态、降账号风控,
+提醒消息直接不挂链接。三类提醒都发:新商品上架 / 拼团进度过半 / 一车结束战报。
+
+运行时段:仅每天 RUN_START~次日 06:00 轮询(RUN_START/RUN_END 控制),其余时间休眠到下次开窗再跑。
+RUN_START 默认 20:30,可用命令行传参覆盖(见下),窗口起点同时是「新上架」时间门槛:只提醒
+publishAt 晚于该起点的商品。
+
+从根目录运行:
+    python onsale_alert_spider.py            # 默认 20:30 开始
+    python onsale_alert_spider.py 17:00      # 改为 17:00 开始:17:00 后的新上架才提醒
+    python onsale_alert_spider.py --start 17:00
+(默认企微渠道,需 WEBHOOK_URL 配好)
+"""
+import sys
+import time
+import random
+import argparse
+from datetime import datetime, time as dtime, timedelta
+
+# 挂靠新项目根:sys.path 指向 common、CWD 对齐新根(application.yml / logs / 账号池 DB 生效)
+import os
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+import deca_sold_core as core
+try:
+    import deca_wechat  # PC 微信(wxauto4),已弃用;仅 SEND_CHANNEL=="pc" 时需要
+except ImportError:
+    deca_wechat = None
+from auto_send_wx_msg import send_wechat_group_msg   # 企微机器人发送(默认渠道)
+# 复用 daily 的免 token 全站在售拉取(home/search,只拉不落库);alert 独立自采、不依赖 buy_record 落库
+from deca_on_sale_daily_spider import parse_product
+
+logger.remove()
+logger.add("./logs/onsale_alert_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# ==================== 配置 ====================
+# 监控商家列表(可扩展:加店只追加一行)。两店同属「魔都兄弟」,靠 tag 在提醒标题里区分:
+#   id  :商家用户ID(merchantUserId)   name:商家全名(消息正文用)   tag:短标签(标题【】前缀,区分同名商家)
+MERCHANTS = [
+    {"id": "881226408", "name": "魔都兄弟球星卡", "tag": "球星卡"},      # 老店
+    {"id": "606370597", "name": "魔都兄弟综合体育", "tag": "综合体育"},  # 2026/09/08 新增,与球星卡同属一家、算两个店铺
+]
+HALF_THRESHOLD = 0.5               # 进度过半阈值(0.5=50%)
+PROGRESS_TABLE = "deca_onsale_product_progress_record"  # 进度时间序列(server B 的 buy_record 每 60s 变化才写),算阶段耗时用
+SEND_CHANNEL = "qywx"              # 发送渠道:qywx=企业微信群机器人(默认) / pc=PC版微信(wxauto4,可切回)
+# WX_TARGET = "backup"               # PC 微信发送目标(好友备注名/群名),改成实际接收人
+WX_TARGET = "得卡-通知"               # PC 微信发送目标(好友备注名/群名),改成实际接收人
+# COLD_START_PUSH 已废弃(2026/08/08):改为「只提醒本轮窗口起点(20:30)后新上架」,冷启动不再发全量快照
+MIN_INTERVAL_SEC = 60              # 轮询间隔随机下限(秒)
+MAX_INTERVAL_SEC = 90             # 轮询间隔随机上限(秒),每轮在 [下限,上限] 取随机数,打散规律降风控
+RUN_START = dtime(20, 30)          # 运行窗口开始默认 20:30;可由命令行传参覆盖(见 _parse_args),同时作为「新上架」时间门槛
+RUN_END = dtime(6, 0)              # 运行窗口结束:次日 06:00(窗口跨午夜;2026/08/15 由 03:00 延到 06:00,昨天 5 点还在播)
+MAX_PROD_PAGES = 3                 # 单商家在售翻页上限(服务端 *-list 硬限最近 3 页;在售普遍远 <60 个,靠 total/末页早停)
+T_ALERT = "deca_onsale_alert_record"
+T_PROD = core.T_PROD
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"       # 商家在售商品列表(need_auth)
+DETAIL_PATH = "/api/v1/app/groupbuy/detail"                       # 商品详情(取 publishAt 判新品)
+
+# 本进程启动后「见过在售」的 product_code 集合,按商家 id 隔离(模块级内存,进程重启即清空):
+# 结构 {merchant_id: set(product_code)}——各商家互不干扰,避免不同店的 code 混在一起误判结束。
+# 结束战报只认对应商家 seen 里的 code——即只播报「程序运行之后」才结束的车;程序启动前就已结束的历史车不补发。
+# 与持久化的 ended_notified 列配合:seen 决定「管不管」,ended_notified 决定「发没发过」。
+_seen_onsale: dict = {}
+
+
+def _get_detail_data(log, code: str) -> dict:
+    """打商品详情接口(免 token)返回 data 层。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码(product_code)。
+
+    Returns:
+        dict: 详情 data 层;请求失败返回空 dict。
+    """
+    try:
+        resp = core.do_request(log, DETAIL_PATH, {"code": code}, need_auth=False)  # 详情接口免登录,实测不带token也返回
+        return (resp or {}).get("data") or {}
+    except Exception as e:
+        log.warning(f"取详情失败({code}): {e}")
+        return {}
+
+
+def get_publish_at(log, code: str) -> str | None:
+    """调详情接口取该商品的上架时间(data.publishAt),供新品判断用。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码(product_code)。
+
+    Returns:
+        str | None: publishAt 原文如 "2026-08-08 20:31:00";取不到返回 None。
+    """
+    return _get_detail_data(log, code).get("publishAt")
+
+
+def _window_start(now: datetime) -> datetime:
+    """本轮运行窗口的起点(最近一个已过去的 RUN_START,默认 20:30),作为「新上架」时间门槛。
+
+    RUN_START 可由命令行传参覆盖,故此处读全局值而非写死时刻。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        datetime: 当天时刻 >= RUN_START → 今天 RUN_START;否则 → 昨天 RUN_START(跨午夜窗口取窗口起点那天)。
+    """
+    if now.time() >= RUN_START:
+        return datetime.combine(now.date(), RUN_START)
+    return datetime.combine(now.date() - timedelta(days=1), RUN_START)
+
+
+def _is_new_arrival(publish_at_text: str, window_start: datetime) -> bool:
+    """判断商品上架时间(publishAt)是否晚于窗口起点,即本轮才新上架。
+
+    Args:
+        publish_at_text (str): 详情 publishAt,格式 "%Y-%m-%d %H:%M:%S",可能为空。
+        window_start (datetime): 本轮窗口起点(20:30)。
+
+    Returns:
+        bool: True=上架时间 >= 窗口起点(本轮新上架);空值/解析失败按 False(保守,不误报老货)。
+    """
+    if not publish_at_text:
+        return False
+    try:
+        pub = datetime.strptime(publish_at_text.strip(), "%Y-%m-%d %H:%M:%S")
+    except (ValueError, TypeError):
+        return False
+    return pub >= window_start
+
+
+def _fmt_money(v) -> str:
+    """把价格数值格式化为紧凑字符串:整数去掉小数(80.00→80),非整去尾零(1.50→1.5)。
+
+    Args:
+        v: 价格(Decimal/float/int/str)。
+
+    Returns:
+        str: 紧凑价格字符串。
+    """
+    f = float(v)
+    if f == int(f):
+        return str(int(f))
+    return f"{f:g}"
+
+
+def _fmt_price(unit, mn, mx) -> str:
+    """构造价格文案:区间价(min~max)优先,其次单价,与 App 详情页口径一致。
+
+    得卡「单箱选队」等商品 unitPrice 常为 0,真实价落在 minUnitPrice~maxUnitPrice 区间
+    (如详情页「¥1.50 ~ 80」),故区间存在且非单点时优先展示区间,避免误显 ¥0.00。
+
+    Args:
+        unit: 单价 unitPrice(可能为 0/None)。
+        mn: 最低单价 minUnitPrice(可能为 None)。
+        mx: 最高单价 maxUnitPrice(可能为 None)。
+
+    Returns:
+        str: 价格文案,如 "¥1.5~80" / "¥400" / "¥-"。
+    """
+    if mn is not None and mx is not None and float(mn) != float(mx):
+        return f"¥{_fmt_money(mn)}~{_fmt_money(mx)}"      # 区间价(单箱选队等)
+    if unit is not None and float(unit) > 0:
+        return f"¥{_fmt_money(unit)}"                     # 正常单价
+    if mn is not None and float(mn) > 0:
+        return f"¥{_fmt_money(mn)}"                       # unit 为 0/None 时退回 min
+    return "¥-"
+
+
+def _fmt_item(r: dict, kind: str, pct: float = None, plain: bool = False) -> str:
+    """构造一条通知条目:标题 + 价格 + 份数/进度 + 余·共(不带链接)。
+
+    2026/08/11 起消息不挂商品链接(share-detail 只认带 token 生成的 shareCode,免 token 一律失效,
+    为不引入登录态直接去链接);markdown 渠道把标题加粗,纯文本渠道直出标题。
+
+    Args:
+        r (dict): parse_product 产出的商品字典。
+        kind (str): "new"=新品(显示份数) / "half"=过半(显示进度%) / "onsale"=当前在售快照(显示进度%)。
+        pct (float, optional): 售卖进度百分比(0~100),kind 为 "half"/"onsale" 时用。Defaults to None。
+        plain (bool, optional): True=纯文本(PC微信)/False=markdown(企微)。Defaults to False。
+
+    Returns:
+        str: 一条通知文案(两行)。
+    """
+    # 标题行:markdown 渠道加粗突出,纯文本渠道直出
+    title_line = r["title"] if plain else f"**{r['title']}**"
+    # 价格:区间价优先(单箱选队等 unitPrice=0 的商品用 min~max),避免误显 ¥0.00
+    price_text = _fmt_price(r.get("unit_price"), r.get("min_unit_price"), r.get("max_unit_price"))
+    # 信息行:新品显示份数,过半/当前在售显示进度百分比
+    if kind in ("half", "onsale"):
+        info = (f"💰 {price_text} | 📈 进度{pct:.0f}% | "
+                f"🎯 余{r['available_stock']}/{r['card_count']}")
+    else:
+        info = (f"💰 {price_text} | 📦 {r['card_count']}份 | "
+                f"🎯 余{r['available_stock']}/{r['card_count']}")
+    return f"{title_line}\n{info}"
+
+
+def _dispatch(log, items: list, title: str) -> bool:
+    """按 SEND_CHANNEL 把一批条目发出去(pc=PC微信整段文本 / qywx=企微 markdown)。
+
+    Args:
+        log: 日志对象。
+        items (list[str]): 已构造好的通知条目列表。
+        title (str): 消息标题。
+
+    Returns:
+        bool: 发送成功返回 True;无条目或发送失败返回 False(调用方据此决定是否置位提醒标记)。
+    """
+    if not items:
+        return False
+    if SEND_CHANNEL == "pc":
+        # PC 微信:标题 + 编号清单,拼成一整段纯文本,一条消息发给 WX_TARGET
+        body = [f"{i}. {it}" for i, it in enumerate(items, 1)]
+        text = title + "\n\n" + "\n----------------------------------\n".join(body)
+        return bool(deca_wechat.send_text(text, who=WX_TARGET))
+    # 企微机器人:成功返回 dict、失败返回 None
+    return bool(send_wechat_group_msg(log=log, items=items, title=title))
+
+
+def _pc_section(title: str, items: list) -> str:
+    """把一组条目拼成 PC 微信纯文本的一个分区(小标题 + 编号清单)。
+
+    Args:
+        title (str): 分区小标题(如「新商品上架」)。
+        items (list[str]): 已构造好的通知条目列表。
+
+    Returns:
+        str: 该分区的纯文本(含小标题与编号清单);items 为空返回空串。
+    """
+    if not items:
+        return ""
+    body = [f"{i}. {it}" for i, it in enumerate(items, 1)]
+    return f"{title}({len(items)}款)\n" + "\n----------------------------------\n".join(body)
+
+
+def _dispatch_pc_combined(log, mname: str, new_items: list, half_items: list, tag: str) -> bool:
+    """PC 微信:把新品与过半两类合并为一条纯文本消息发出(减少操作,一次发完)。
+
+    Args:
+        log: 日志对象。
+        mname (str): 商家名称,用于消息大标题。
+        new_items (list[str]): 新品上架通知条目。
+        half_items (list[str]): 进度过半通知条目。
+        tag (str): 商家短标签(如「球星卡」),加到大标题【】前缀区分同名商家。
+
+    Returns:
+        bool: 发送成功返回 True;无内容或发送失败返回 False(调用方据此决定是否置位新品标记)。
+    """
+    sections = []
+    new_sec = _pc_section("【新商品上架】", new_items)
+    half_sec = _pc_section("【拼团进度过半】", half_items)
+    if new_sec:
+        sections.append(new_sec)
+    if half_sec:
+        sections.append(half_sec)
+    if not sections:
+        return False
+    header = f"【{tag}】得卡 · {mname} 在售提醒"
+    text = header + "\n\n" + "\n\n==================================\n\n".join(sections)
+    return bool(deca_wechat.send_text(text, who=WX_TARGET))
+
+
+def _progress(sold, card) -> float:
+    """计算售卖进度(0~1),卡片份数缺失或为 0 时返回 0。
+
+    Args:
+        sold (int | None): 已售份数。
+        card (int | None): 总份数。
+
+    Returns:
+        float: 进度比例 sold/card;无法计算时返回 0.0。
+    """
+    if not card or card <= 0 or sold is None:
+        return 0.0
+    return sold / card
+
+
+def fetch_onsale(log, merchant_id: str) -> tuple[list, bool]:
+    """带 token 抓指定商家的在售商品(逐页 on-sale-list,走直连)。
+
+    2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效,可能刷不到本商家的车;
+    故改为直接调本商家 on-sale-list(需 token)分页拉取,parse_product 解析后按 product_code 去重返回。
+    token 走直连保持账号↔IP 稳定;详情接口仍免 token。
+
+    Args:
+        log: 日志对象。
+        merchant_id (str): 商家用户ID(merchantUserId),来自 MERCHANTS 列表。
+
+    Returns:
+        tuple[list, bool]: (本商家在售商品字典列表[parse_product 结果,含 title/unit_price/card_count/
+            sold_count/available_stock/merchant_name 等], 是否正常翻到底)。
+            bool=False 表示中途异常/未取全,调用方据此放弃本轮下架/结束对账,避免误判。
+    """
+    rows = []
+    page = 1
+    total = None
+    ok = False
+    while page <= MAX_PROD_PAGES:
+        body = {"merchantUserId": merchant_id, "page": page, "pageSize": 20}
+        try:
+            resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+        except Exception as e:
+            log.error(f"商家 {merchant_id} 在售第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            log.info(f"商家 {merchant_id} 在售返回异常: {resp.get('msg') if resp else None}")
+            break
+        data = resp.get("data") or {}
+        if total is None:
+            total = data.get("total")
+        items = data.get("list") or []
+        rows.extend(r for r in (parse_product(it) for it in items) if r)
+        if (total is not None and page * 20 >= total) or len(items) < 20:  # 采满 total 或末页
+            ok = True
+            break
+        page += 1
+    uniq = {p["product_code"]: p for p in rows}  # 按 code 去重
+    log.info(f"商家 {merchant_id} 当前在售商品 {len(uniq)} 个(on-sale-list 带 token,ok={ok})")
+    return list(uniq.values()), ok
+
+
+def _insert_alert(pool, r: dict, progress_pct: float, new_notified: int,
+                  half_notified: int):
+    """新增一条在售监控记录(share_code 列 2026/08/11 起不再写入,消息已去链接)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        r (dict): parse_product 产出的商品字典。
+        progress_pct (float): 售卖进度百分比(0~100)。
+        new_notified (int): 新品提醒标记位 0/1。
+        half_notified (int): 过半提醒标记位 0/1。
+    """
+    sql = (f"INSERT IGNORE INTO {T_ALERT} "
+           "(product_code, merchant_user_id, merchant_name, title, unit_price, card_count, "
+           "sold_count, progress, available_stock, groupbuy_status_name, "
+           "new_notified, half_notified) "
+           "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)")
+    args = (r["product_code"], r["merchant_user_id"], r["merchant_name"], r["title"],
+            r["unit_price"], r["card_count"], r["sold_count"], round(progress_pct, 2),
+            r["available_stock"], r["groupbuy_status_name"],
+            new_notified, half_notified)
+    pool.insert_many(query=sql, args_list=[args])
+
+
+def _update_progress(pool, r: dict, progress_pct: float, half_notified: int):
+    """更新已有记录的售卖进度等动态字段(及可选的过半标记位)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        r (dict): parse_product 产出的商品字典。
+        progress_pct (float): 售卖进度百分比(0~100)。
+        half_notified (int): 过半提醒标记位 0/1(命中过半时置 1)。
+    """
+    sql = (f"UPDATE {T_ALERT} SET sold_count=%s, progress=%s, available_stock=%s, "
+           "groupbuy_status_name=%s, half_notified=%s WHERE product_code=%s")
+    pool.update_one(sql, (r["sold_count"], round(progress_pct, 2), r["available_stock"],
+                          r["groupbuy_status_name"], half_notified, r["product_code"]))
+
+
+def _mark_new_notified(pool, codes: list):
+    """把一批商品的 new_notified 置 1(仅在新品提醒发送成功后调用)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        codes (list[str]): 待置位的 product_code 列表;为空直接返回。
+    """
+    if not codes:
+        return
+    placeholders = ",".join(["%s"] * len(codes))
+    pool.update_one(
+        f"UPDATE {T_ALERT} SET new_notified=1 WHERE product_code IN ({placeholders})",
+        tuple(codes))
+
+
+def _is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
+    """据商品详情 data 判断该车是否售卖结束(售罄 或 已过销售结束时间)。
+
+    与 buy_record_spider.is_sale_ended 同逻辑,内联于此避免 import 采集脚本引入其模块级副作用。
+    详情请求失败时 data 为空 dict,两条件均不命中返回未结束——保守,防抓取抖动误判下架。
+
+    预售/未开卖的车:详情接口 availableStock 也返回 0(库存未分配)、soldCount=0、saleStartAt 在未来,
+    仅凭 availableStock<=0 会把它误判为「售罄」→ 发出假的「一车结束」战报。直播实时放量卖的车同理:
+    卖到一半 availableStock 也会瞬时抖成 0。故 ① saleStartAt 未到直接判未结束;② 售罄改判 soldCount>=totalCardCount
+    (卖满才算),彻底不看 availableStock(2026/09/04:GB26090464871 卖 2/31 时 availableStock=0 曾致 buy_record 误杀)。
+
+    Args:
+        data (dict): 商品详情接口(groupbuy/detail) data 层,可能为空 dict。
+        now_ts (int): 当前时间戳(秒)。
+
+    Returns:
+        tuple[bool, str]: (是否已结束, 原因文本);未结束时原因为空串。
+    """
+    # ① 开卖时间未到 → 预售态,绝不算结束(预售车 availableStock=0 是「未分配」而非「卖光」)
+    start_text = data.get("saleStartAt")
+    if start_text:
+        try:
+            start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
+            if now_ts < start_ts:
+                return False, ""
+        except (ValueError, OverflowError):
+            pass
+    # ② 售罄:改用「已售 >= 总份数」判定,不再用 availableStock。
+    #    直播实时放量卖的车,availableStock 是「当前放出、还没被抢的量」而非总剩余,卖到一半也会瞬时抖成 0,
+    #    旧逻辑凭 availableStock<=0 会把仍在售的车误判售罄发假战报(2026/09/04 GB26090464871 卖 2/31 时 availableStock=0)。
+    #    本函数只在「车已离开在售列表」后作二次确认,从严只认卖满,与下方「结束即全部售出」口径一致。
+    sold = data.get("soldCount")
+    total = data.get("totalCardCount")
+    if sold is not None and total is not None and total > 0 and sold >= total:
+        return True, f"售罄(soldCount={sold}/{total})"
+    end_text = data.get("saleEndAt")
+    if end_text:
+        try:
+            end_ts = time.mktime(time.strptime(end_text, "%Y-%m-%d %H:%M:%S"))
+            if now_ts >= end_ts:
+                return True, f"已过结束时间(saleEndAt={end_text})"
+        except (ValueError, OverflowError):
+            pass
+    return False, ""
+
+
+def _fmt_ended(title: str, card, price_text: str, buyers: int, plain: bool = False) -> str:
+    """构造一条「一车结束」战报正文:标题 + 价格 + 售出总件数 + 参与拆卡人数。
+
+    车结束(拼团成交/售罄)即全部份数售出,故「售出件数」直接取总份数 card_count;
+    参与拆卡人数为该车 deca_buy_record 按 user_id 去重的买家数。
+
+    Args:
+        title (str): 商品标题。
+        card (int | None): 总份数 cardCount,即售出总件数。
+        price_text (str): 已格式化的价格文案(_fmt_price 产出,区间价优先)。
+        buyers (int): 去重购买人数(参与拆卡人数)。
+        plain (bool, optional): True=纯文本(PC微信)/False=markdown(企微,标题加粗)。Defaults to False。
+
+    Returns:
+        str: 战报正文(三行)。
+    """
+    card_txt = card if card is not None else "?"
+    title_line = title if plain else f"**{title}**"
+    return (f"{title_line}\n"
+            f"💰 {price_text} | 🎯 售出 {card_txt} 件\n"
+            f"👥 {buyers} 人参与拆卡")
+
+
+def _send_ended(log, mname: str, item_text: str, tag: str) -> bool:
+    """按 SEND_CHANNEL 发送一条「一车结束」战报(每辆车一条独立消息)。
+
+    Args:
+        log: 日志对象。
+        mname (str): 商家名称,用于消息标题。
+        item_text (str): _fmt_ended 产出的战报正文。
+        tag (str): 商家短标签(如「球星卡」),加到标题【】前缀区分同名商家。
+
+    Returns:
+        bool: 发送成功返回 True;失败返回 False(调用方据此决定是否置 ended_notified)。
+    """
+    header = f"🏁【{tag}】得卡 · {mname} 一车结束"
+    if SEND_CHANNEL == "pc":
+        return bool(deca_wechat.send_text(header + "\n\n" + item_text, who=WX_TARGET))
+    return bool(send_wechat_group_msg(log=log, items=[item_text], title=header))
+
+
+def _mark_ended_notified(pool, code: str):
+    """把一辆车的 ended_notified 置 1(仅在结束战报发送成功后调用)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        code (str): 商品编码(product_code)。
+    """
+    pool.update_one(f"UPDATE {T_ALERT} SET ended_notified=1 WHERE product_code=%s", (code,))
+
+
+def _count_buyers(pool, code: str) -> int:
+    """查 deca_buy_record 中该车按 user_id 去重的购买人数(即参与拆卡人数)。
+
+    数据由 buy_record_spider(独立进程)持续采集;若该车未被采到则返回 0。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        code (str): 商品编码(product_code)。
+
+    Returns:
+        int: 去重购买人数;无记录或查询异常返回 0。
+    """
+    rows = pool.select_all(
+        "SELECT COUNT(DISTINCT user_id) FROM deca_buy_record WHERE product_code=%s", code)
+    if rows and rows[0] and rows[0][0] is not None:
+        return int(rows[0][0])
+    return 0
+
+
+def _get_last_buy_time(pool, code: str):
+    """取该车购买记录里最新一笔的 purchased_at。"""
+    rows = pool.select_all(
+        "SELECT MAX(purchased_at) FROM deca_buy_record WHERE product_code=%s", code)
+    if rows and rows[0] and rows[0][0] is not None:
+        return rows[0][0]
+    return None
+
+
+def _parse_dt(value) -> datetime | None:
+    """把接口/数据库时间值转成 datetime。"""
+    if isinstance(value, datetime):
+        return value
+    if not value:
+        return None
+    try:
+        return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S")
+    except ValueError:
+        return None
+
+
+def _count_overlap_buyers(pool, code_a: str, code_b: str) -> int:
+    """统计两辆车去重买家交集人数。"""
+    rows = pool.select_all(
+        "SELECT COUNT(DISTINCT a.user_id) "
+        "FROM deca_buy_record a "
+        "WHERE a.product_code=%s "
+        "AND EXISTS (SELECT 1 FROM deca_buy_record b "
+        "            WHERE b.product_code=%s AND b.user_id=a.user_id)",
+        (code_a, code_b))
+    if rows and rows[0] and rows[0][0] is not None:
+        return int(rows[0][0])
+    return 0
+
+
+def _count_repeat_buyers_today(pool, merchant_user_id: str, code: str,
+                               window_start: str, window_end: str) -> int:
+    """统计当前车买家里,当日(运行窗口内)重复买过本商家其他车的人数。"""
+    rows = pool.select_all(
+        "SELECT COUNT(DISTINCT b.user_id) "
+        "FROM deca_buy_record b "
+        "WHERE b.product_code=%s "
+        "AND EXISTS (SELECT 1 FROM deca_buy_record x "
+        "            WHERE x.merchant_user_id=%s "
+        "              AND x.user_id=b.user_id "
+        "              AND x.product_code<>%s "
+        "              AND x.purchased_at>=%s AND x.purchased_at<=%s)",
+        (code, merchant_user_id, code, window_start, window_end))
+    if rows and rows[0] and rows[0][0] is not None:
+        return int(rows[0][0])
+    return 0
+
+
+def _fetch_previous_realtime(pool, merchant_user_id: str, code: str, before_text: str,
+                             series_name: str | None = None) -> dict | None:
+    """按购买记录实时找某商家在当前车之前结束的上一辆车;可选限制同 series_name。"""
+    if not before_text:
+        return None
+    sql = (
+        "SELECT b.product_code, MAX(b.title) AS title, MAX(b.purchased_at) AS ended_at, "
+        "       COALESCE(NULLIF(MAX(p.series_name), ''), NULLIF(MAX(o.series_name), '')) AS series_name "
+        "FROM deca_buy_record b "
+        f"LEFT JOIN {T_PROD} p ON p.product_code=b.product_code "
+        "LEFT JOIN deca_onsale_product_record o ON o.product_code=b.product_code "
+        "WHERE b.merchant_user_id=%s AND b.product_code<>%s AND b.purchased_at<%s")
+    args = [merchant_user_id, code, before_text]
+    if series_name:
+        sql += (
+            " AND COALESCE(NULLIF(p.series_name, ''), NULLIF(o.series_name, ''), '')=%s")
+        args.append(series_name)
+    sql += " GROUP BY b.product_code ORDER BY ended_at DESC LIMIT 1"
+    rows = pool.select_all(sql, tuple(args))
+    if not rows:
+        return None
+    prev_code, title, ended_at, series = rows[0]
+    return {
+        "product_code": prev_code,
+        "title": title,
+        "ended_at": ended_at,
+        "series_name": series,
+    }
+
+
+def _biz_window(dt: datetime) -> tuple[str, str]:
+    """把某时刻归入其成交业务日窗口 [D-1 13:00, D 06:00](口径与 daily_report 一致,2026/09/01 起点由 17:00 提前到 13:00)。
+
+    夜市成交(13:00~次日06:00)算一个业务日:时刻 ≥13:00 归当日窗口起点,<06:00 归昨日窗口。战报只在
+    20:30~06:00 发、组齐时刻(取 buy_record 最后一单近似)必落该区间,两分支都正确。
+
+    Args:
+        dt (datetime): 参照时刻(车的组齐时刻)。
+
+    Returns:
+        tuple[str, str]: (窗口起, 窗口止),均 'YYYY-MM-DD HH:MM:SS'。
+    """
+    if dt.time() >= dtime(13, 0):
+        start = datetime.combine(dt.date(), dtime(13, 0))
+    else:
+        start = datetime.combine(dt.date() - timedelta(days=1), dtime(13, 0))
+    end = datetime.combine(start.date() + timedelta(days=1), dtime(6, 0))
+    return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _fmt_duration(seconds: int | None) -> str:
+    """把秒数格式化成“X小时X分钟”或“X分钟”。"""
+    if seconds is None or seconds < 0:
+        return "-"
+    total_minutes = max(0, int(seconds) // 60)
+    if total_minutes == 0:
+        return "<1分钟"          # 不足 1 分钟(含 0~59 秒):显示「<1分钟」而非「0分钟」,更直观
+    hours, minutes = divmod(total_minutes, 60)
+    if hours:
+        return f"{hours}小时{minutes:02d}分钟"
+    return f"{minutes}分钟"
+
+
+def _milestone_used(tx, first_cap, start) -> str | None:
+    """算「到某进度用时」,首张快照已越过该阈值(坍缩)时诚实留空。
+
+    到 X% 用时 = 首次 progress_pct≥X 的快照时刻 tx − 开售时间 start。仅当 tx 晚于该商品最早快照
+    first_cap 时(证明我们从低于 X% 观测到穿越 X%)才可信;若首张快照就已越阈值(tx==first_cap)则坍缩留空。
+    (与 stats/daily_report._milestone_used 同口径。)
+
+    Args:
+        tx (datetime | None): 首次 progress_pct≥阈值的快照时刻;None 表示从未达到。
+        first_cap (datetime | None): 该商品最早一条快照时刻。
+        start (datetime | None): 开售时间 sale_start_at。
+
+    Returns:
+        str | None: 可读用时;未达到/坍缩/时间缺失时返回 None。
+    """
+    if tx is None or first_cap is None or start is None:
+        return None
+    try:
+        if (tx - first_cap).total_seconds() <= 0:   # 首张快照即越阈值 → 坍缩,不可信
+            return None
+        secs = int((tx - start).total_seconds())
+    except Exception:
+        return None
+    return _fmt_duration(secs) if secs >= 0 else None
+
+
+def _milestone_line(pool, code: str, start_dt, thresholds=(25, 50, 75)) -> str | None:
+    """从进度表算「到 25/50/75% 各用时」,拼成一行 `📊 阶段耗时 …`。
+
+    源 deca_onsale_product_progress_record(首次 pct≥X 的快照时刻 − 开售时间;坍缩留空 —,见 _milestone_used)。
+    全部阈值都无值(留空/坍缩)时返回 None,不加该行。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        code (str): 商品编码。
+        start_dt (datetime | None): 开售时间(sale_start_at);缺失则返回 None。
+        thresholds (tuple[int], optional): 要展示的阈值。一车结束用 (25,50,75),过半用 (25,50)。
+
+    Returns:
+        str | None: 形如 "📊 阶段耗时 25% 1分钟 · 50% 2分钟 · 75% 3分钟";无任何可信值时返回 None。
+    """
+    if not start_dt:
+        return None
+    row = pool.select_one(
+        "SELECT MIN(captured_at), "
+        "  MIN(CASE WHEN progress_pct>=25 THEN captured_at END), "
+        "  MIN(CASE WHEN progress_pct>=50 THEN captured_at END), "
+        "  MIN(CASE WHEN progress_pct>=75 THEN captured_at END) "
+        f"FROM {PROGRESS_TABLE} WHERE product_code=%s", (code,))
+    if not row:
+        return None
+    first_cap, t25, t50, t75 = row
+    tmap = {25: t25, 50: t50, 75: t75}
+    parts, has_value = [], False
+    for x in thresholds:
+        used = _milestone_used(tmap.get(x), first_cap, start_dt)
+        if used:
+            has_value = True
+        parts.append(f"{x}% {used if used else '—'}")
+    return "📊 阶段耗时 " + " · ".join(parts) if has_value else None
+
+
+def _half_item(log, pool, r: dict, code: str, pct: float, plain: bool) -> str:
+    """构造一条「过半」通知文案,并在其后附加阶段耗时行(25/50%)。
+
+    过半路径本身没有开售时间,故顺带取一次详情拿 saleStartAt(详情免 token)算阶段耗时;
+    坍缩/无进度数据(如极快车)时不附加耗时行。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+        r (dict): parse_product 产出的商品字典。
+        code (str): 商品编码。
+        pct (float): 当前售卖进度百分比。
+        plain (bool): True=纯文本(PC微信)/False=markdown(企微)。
+
+    Returns:
+        str: 过半文案(可能含 `📊 阶段耗时` 追加行)。
+    """
+    item = _fmt_item(r, "half", pct=pct, plain=plain)
+    d = _get_detail_data(log, code)
+    start_dt = _parse_dt(d.get("saleStartAt")) if d else None
+    ms = _milestone_line(pool, code, start_dt, (25, 50))
+    return item + "\n" + ms if ms else item
+
+
+def _detect_and_report_ended(log, pool, existing: dict, onsale_codes: set,
+                             merchant: dict, seen: set):
+    """检测本进程运行后消失的车、二次确认结束后逐辆发战报并置位 ended_notified。
+
+    候选条件三取交:本进程见过该商家在售(seen) ∩ 本轮已不在在售(onsale_codes) ∩ 未播报过结束
+    (ended_notified=0)。命中的候选再打一次详情,仅 _is_sale_ended 确认售罄/过结束时间才播报——
+    防「全站抓取抖动导致某车临时缺席」被误判为结束。发送成功才置位;失败保持 0,下轮重发不丢。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+        existing (dict): 库内该商家记录 {code: {ended/title/mname/unit_price/card/sold/...}}。
+        onsale_codes (set): 本轮在售的 product_code 集合。
+        merchant (dict): 当前商家配置 {"id","name","tag"},用于兜底商家名/id 与战报标题标签。
+        seen (set): 该商家「本进程见过在售」的 product_code 集合(按商家隔离)。
+    """
+    now_ts = int(time.time())
+    for code, info in existing.items():
+        if info["ended"] == 1:
+            continue                      # 已播报过结束,跳过
+        if code not in seen:
+            continue                      # 本进程运行后没见它在售 → 属「之前的车」,不补发
+        if code in onsale_codes:
+            continue                      # 本轮仍在售,未结束
+        # 疑似消失:二次打详情确认(失败/仍在售都不判结束,下轮再看,防抓取抖动误报)
+        data = _get_detail_data(log, code)
+        ended, reason = _is_sale_ended(data, now_ts)
+        if not ended:
+            log.info(f"[疑似消失未确认结束] {code} 详情仍在售或取详情失败,跳过(可能抓取抖动) | {info['title']}")
+            continue
+        # 【2026/08/31】soldCount==0 的车不发结束战报:这类是「上架后无人问津、被商家撤下或到 saleEndAt
+        # 到期」的流车,从没成交(既不在在售 is_on_sale=0、也不在已售成交列表),购买记录必空。旧逻辑
+        # 「结束即全部售出」会把它虚报成「售出<总份数>件 / 0 人参与拆卡」,属误发(主公反馈老是误发)。
+        # 故 soldCount==0 时静默不发,并置位 ended_notified 停止下轮重复对账/重打详情。
+        # 只在详情明确返回 soldCount==0 时静默;soldCount 缺失(None)不静默,保守走正常播报,防抓取抖动误吞真售罄车。
+        sold_now = data.get("soldCount")
+        if sold_now == 0:
+            _mark_ended_notified(pool, code)
+            log.info(f"[结束静默] {code} soldCount=0 流车(无人成交/撤下),不发战报并置位防重扫 | {info['title']}")
+            continue
+        end_dt = _parse_dt(_get_last_buy_time(pool, code))
+        end_text = end_dt.strftime("%Y-%m-%d %H:%M:%S") if end_dt else ""
+        sale_start_text = data.get("saleStartAt") or ""
+        sale_start_dt = _parse_dt(sale_start_text)
+        # 售出件数=总份数(结束即全部售出):优先详情最新 cardCount,缺失回退库内旧值
+        card = data.get("cardCount")
+        if card is None:
+            card = info["card"]
+        # 价格:区间价(minUnitPrice~maxUnitPrice)优先,详情取不到再回退库内 unit_price
+        price_text = _fmt_price(data.get("unitPrice"), data.get("minUnitPrice"),
+                                data.get("maxUnitPrice"))
+        if price_text == "¥-" and info["unit_price"] is not None:
+            price_text = _fmt_price(info["unit_price"], None, None)
+        buyers = _count_buyers(pool, code)  # 该车去重购买人数(参与拆卡人数)
+        item_text = _fmt_ended(info["title"], card, price_text, buyers,
+                               plain=(SEND_CHANNEL == "pc"))
+        extra_lines = []
+        if end_dt:
+            if sale_start_dt:
+                duration_text = _fmt_duration(int((end_dt - sale_start_dt).total_seconds()))
+                extra_lines.append(f"⏱ 组齐时间 {end_dt:%m-%d %H:%M} | 共花费 {duration_text}")
+            else:
+                extra_lines.append(f"⏱ 组齐时间 {end_dt:%m-%d %H:%M} | 共花费 -")
+        else:
+            extra_lines.append("⏱ 组齐时间 未知 | 共花费 -")
+
+        # 阶段耗时 25/50/75%(100% 即共花费,不重复);源进度表,坍缩/无进度数据则不加此行
+        ms_line = _milestone_line(pool, code, sale_start_dt, (25, 50, 75))
+        if ms_line:
+            extra_lines.append(ms_line)
+
+        current_series = (data.get("giftInfo") or {}).get("items") or [{}]
+        current_series_name = ""
+        if current_series:
+            first = current_series[0] or {}
+            current_series_name = (first.get("seriesName") or "").strip()
+
+        mid = info["mid"] or merchant["id"]
+        # 先「商家上一辆」(商家真正紧邻的上一车,优先级更高),再「同系列上一辆」
+        merchant_prev = _fetch_previous_realtime(pool, mid, code, end_text)
+        if merchant_prev:
+            merchant_overlap = _count_overlap_buyers(pool, code, merchant_prev["product_code"])
+            extra_lines.append(
+                f"🔁 商家上一辆 {merchant_prev['title']}(重复 {merchant_overlap} 人)")
+        else:
+            extra_lines.append("🔁 商家上一辆 无(重复 0 人)")
+
+        same_series_prev = _fetch_previous_realtime(
+            pool, mid, code, end_text, current_series_name or None)
+        if same_series_prev:
+            same_series_overlap = _count_overlap_buyers(
+                pool, code, same_series_prev["product_code"])
+            extra_lines.append(
+                f"🔁 同系列上一辆 {same_series_prev['title']}(重复 {same_series_overlap} 人)")
+        else:
+            extra_lines.append("🔁 同系列上一辆 无(重复 0 人)")
+
+        # 📅 当日重复购买(改进1,2026/09/01):按「组齐时刻」所在业务日窗口 [D-1 13:00, D 06:00]、仅本商家,
+        # 口径与 daily_report 一致;修掉原先用 _window_start(datetime.now()) 造成的窗口错位(补发/非当晚结束车会误算 0)。
+        if end_dt:
+            win_s, win_e = _biz_window(end_dt)
+            repeat_today = _count_repeat_buyers_today(pool, mid, code, win_s, win_e)
+            extra_lines.append(f"📅 当日重复购买 {repeat_today} 人")
+        else:
+            extra_lines.append("📅 当日重复购买 - 人")
+        item_text = item_text + "\n" + "\n".join(extra_lines)
+        if _send_ended(log, info["mname"] or merchant["name"], item_text, merchant["tag"]):
+            _mark_ended_notified(pool, code)
+            log.info(f"[结束战报已发] {code} {reason} 售出{card}件 {buyers}人 | {info['title']}")
+        else:
+            log.warning(f"[结束战报发送失败] {code} 保持 ended_notified=0,下轮重发 | {info['title']}")
+
+
+def _run_merchant(log, pool, merchant: dict):
+    """跑单个商家一轮:拉在售 → 结束对账(发一车结束战报) → 比对库内状态 → 发新品/过半提醒 → 落库。
+
+    多商家共用同一套逻辑,仅按 merchant 参数化:抓哪家(id)、消息落款(name)、标题标签(tag)、
+    以及按商家隔离的「见过在售」集合(_seen_onsale[id])。三类提醒标题统一加 【tag】前缀区分同名商家。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+        merchant (dict): 商家配置 {"id","name","tag"},来自 MERCHANTS 列表。
+    """
+    mid = merchant["id"]
+    tag = merchant["tag"]
+    products, ok = fetch_onsale(log, mid)
+    if not ok:
+        log.warning(f"[{tag}] 在售抓取未取全(请求异常),本轮跳过该商家:不做新品/过半/结束判断,避免误判车下架")
+        return
+
+    # 库内该商家已监控商品:含结束标记与结束战报所需的静态字段(标题/商家名/单价/份数)
+    existing_rows = pool.select_all(
+        "SELECT product_code, new_notified, half_notified, ended_notified, "
+        "title, merchant_user_id, merchant_name, unit_price, card_count, sold_count "
+        f"FROM {T_ALERT} WHERE merchant_user_id=%s", mid) or []
+    existing = {}
+    for code, new, half, ended, title, mid_, mname_, uprice, ccount, scount in existing_rows:
+        existing[code] = {"new": new, "half": half, "ended": ended,
+                          "title": title, "mid": mid_, "mname": mname_, "unit_price": uprice,
+                          "card": ccount, "sold": scount}
+    is_cold = len(existing) == 0  # 冷启动:库内该商家零记录(仅作日志提示,逻辑与常规轮一致)
+    if is_cold:
+        log.info(f"[{tag}] 首次运行:库内该商家零记录,只提醒本轮窗口起点({RUN_START:%H:%M})后新上架的商品,老货静默建档")
+
+    seen = _seen_onsale.setdefault(mid, set())  # 该商家「本进程见过在售」的 code 集合(按商家隔离)
+    onsale_codes = {r["product_code"] for r in products}  # 本轮在售 code 集合
+    # 结束对账:只播报本进程运行后见过在售、之后确认结束的车(历史已结束车不补发;置位防重发)
+    _detect_and_report_ended(log, pool, existing, onsale_codes, merchant, seen)
+    seen.update(onsale_codes)  # 本轮在售并入「见过在售」集合,供下轮结束对账(实现「只管运行后」)
+
+    if not products:
+        log.info(f"[{tag}] 本轮无在售商品,已完成结束对账,跳过新品/过半")
+        return
+
+    plain = SEND_CHANNEL == "pc"  # PC 微信用纯文本,企微用 markdown
+    new_items = []     # 新品上架提醒文案
+    half_items = []    # 进度过半提醒文案
+    pending_new = []   # 待发新品的 product_code:仅在提醒发送成功后才置 new_notified=1
+
+    window_start = _window_start(datetime.now())  # 「新上架」时间门槛:本轮窗口起点(最近的 RUN_START,默认 20:30)
+
+    for r in products:
+        code = r["product_code"]
+        ratio = _progress(r["sold_count"], r["card_count"])
+        pct = ratio * 100
+        over_half = ratio >= HALF_THRESHOLD
+
+        if code not in existing:
+            # 库里没有的候选:打一次详情拿上架时间,只有 publishAt 晚于窗口起点(RUN_START,默认 20:30)才算「本轮新上架」
+            publish_at = get_publish_at(log, code)
+            if _is_new_arrival(publish_at, window_start):
+                # 真·本轮新上架:new_notified 先记 0,发送成功再置 1(发失败下轮自动重发,不丢)
+                _insert_alert(pool, r, pct, new_notified=0,
+                              half_notified=1 if over_half else 0)
+                new_items.append(_fmt_item(r, "new", plain=plain))
+                pending_new.append(code)
+                if over_half:  # 新品上架即已过半,一并提示(half 逻辑保持原状)
+                    half_items.append(_half_item(log, pool, r, code, pct, plain))
+            else:
+                # 上架早于窗口起点(或拿不到上架时间)的老货:静默建档、不提醒(new_notified 直接置 1,避免下轮反复判断)
+                _insert_alert(pool, r, pct, new_notified=1,
+                              half_notified=1 if over_half else 0)
+                log.info(f"[{tag}][静默建档] {code} 上架 {publish_at or '未知'} 早于窗口起点 {window_start:%m-%d %H:%M},不提醒 | {r['title']}")
+        else:
+            # 已在表:new_notified=0 视为「待发新品」(上次发失败残留 或 人工改回 0),重新纳入新品提醒
+            if existing[code]["new"] == 0:
+                new_items.append(_fmt_item(r, "new", plain=plain))
+                pending_new.append(code)
+            # 过半逻辑保持原状(half_notified 策略不改动)
+            already_half = existing[code]["half"] == 1
+            if over_half and not already_half:
+                _update_progress(pool, r, pct, half_notified=1)
+                half_items.append(_half_item(log, pool, r, code, pct, plain))
+            else:
+                _update_progress(pool, r, pct, half_notified=1 if already_half else 0)
+
+    mname = products[0].get("merchant_name") or merchant["name"]
+
+    # 常规轮次推送;sent_ok 记新品提醒是否发送成功,决定要不要置位 new_notified
+    sent_ok = False
+    if SEND_CHANNEL == "pc":
+        # PC 微信:新品 + 过半合并成一条消息发出(减少操作,一次发完)
+        if new_items or half_items:
+            log.info(f"[{tag}] 合并推送(pc):新品{len(new_items)}个、过半{len(half_items)}个")
+            sent_ok = _dispatch_pc_combined(log, mname, new_items, half_items, tag)
+    else:
+        # 企微渠道:仍按两类各发一条 markdown(新品这条成功与否决定 sent_ok),标题加【tag】前缀区分同名商家
+        if new_items:
+            log.info(f"[{tag}] 新商品上架 {len(new_items)} 个,推送(qywx)")
+            sent_ok = _dispatch(log, new_items, f"🆕【{tag}】得卡[{mname}] 新商品上架 {len(new_items)} 个")
+        if half_items:
+            log.info(f"[{tag}] 进度过半 {len(half_items)} 个,推送(qywx)")
+            _dispatch(log, half_items, f"🔥【{tag}】得卡[{mname}] 拼团进度过半 {len(half_items)} 个")
+
+    # 新品提醒发送成功后才置位 new_notified=1;失败则保持 0,下一轮继续重发(不丢)
+    if pending_new:
+        if sent_ok:
+            _mark_new_notified(pool, pending_new)
+            log.info(f"[{tag}] 新品提醒发送成功,new_notified 置 1:{len(pending_new)} 个")
+        else:
+            log.warning(f"[{tag}] 新品提醒发送失败,new_notified 保持 0,下轮重发:{len(pending_new)} 个")
+
+    if not new_items and not half_items:
+        log.info(f"[{tag}] 本轮无新品、无新达标过半商品")
+
+
+def run_once(log, pool):
+    """跑一轮监控:遍历 MERCHANTS 中每个商家各跑一轮(单家异常不拖垮其他家)。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+    """
+    for merchant in MERCHANTS:
+        try:
+            _run_merchant(log, pool, merchant)
+        except Exception as e:
+            log.error(f"[{merchant['tag']}] 处理异常,跳过该商家本轮: {e}")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(600), after=core.after_log)
+def main_task(log):
+    """在售监控主函数:建连接池 → 跑一轮监控(挂了每 10 分钟重试)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 在售提醒监控" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="onsale_alert")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        run_once(log, pool)
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"在售提醒监控 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
+
+
+def _in_run_window(now: datetime) -> bool:
+    """判断当前时刻是否在运行窗口 [RUN_START, 次日 RUN_END) 内。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        bool: 在窗口内返回 True。窗口跨午夜,故「晚于开始 或 早于结束」即算命中。
+    """
+    t = now.time()
+    return t >= RUN_START or t < RUN_END
+
+
+def _seconds_to_window(now: datetime) -> int:
+    """计算从 now 到下一次窗口开始(当天 RUN_START)的休眠秒数(仅窗口外调用)。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        int: 需休眠的秒数;若当天 RUN_START 已过则顺延到次日。
+    """
+    start = now.replace(hour=RUN_START.hour, minute=RUN_START.minute, second=0, microsecond=0)
+    if start <= now:                # 当天 RUN_START 已过 → 顺延到次日同一时刻
+        start += timedelta(days=1)
+    return int((start - now).total_seconds())
+
+
+def schedule_task():
+    """常驻循环:仅在每天 RUN_START~次日 RUN_END 运行;窗口内每轮随机间隔轮询,窗口外休眠到下次开窗。"""
+    while True:
+        now = datetime.now()
+        if not _in_run_window(now):
+            wait = _seconds_to_window(now)
+            logger.info(f"当前不在运行窗口({RUN_START:%H:%M}~次日{RUN_END:%H:%M}),休眠 {wait}s 到 {RUN_START:%H:%M} 再跑")
+            time.sleep(wait)
+            continue
+        main_task(log=logger)
+        wait = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)  # 每轮随机间隔(秒)
+        logger.info(f"下一轮 {wait}s 后运行")
+        time.sleep(wait)
+
+
+def _parse_start_time(text: str) -> dtime:
+    """把命令行传入的开始时间文本解析为 datetime.time。
+
+    Args:
+        text (str): 开始时间文本,格式 "HH:MM" 或 "HH:MM:SS",如 "20:30" / "17:00"。
+
+    Returns:
+        dtime: 解析出的 time 对象。
+
+    Raises:
+        argparse.ArgumentTypeError: 格式非法(非 HH:MM[:SS] 或时分秒越界)时抛出,供 argparse 提示用户。
+    """
+    text = text.strip()
+    for fmt in ("%H:%M:%S", "%H:%M"):
+        try:
+            return datetime.strptime(text, fmt).time()
+        except ValueError:
+            continue
+    raise argparse.ArgumentTypeError(f"开始时间格式非法:{text!r},应为 HH:MM 或 HH:MM:SS,如 20:30")
+
+
+def _parse_args() -> argparse.Namespace:
+    """解析命令行参数,取运行窗口开始时间(默认 20:30)。
+
+    支持位置参数与 --start 两种写法,二者等价,方便直接 `python xxx.py 17:00`。
+
+    Returns:
+        argparse.Namespace: 含 start(datetime.time) 属性;未传时为默认 RUN_START。
+    """
+    parser = argparse.ArgumentParser(
+        description="得卡 DECA 在售提醒:可指定运行窗口开始时间(该时间后的新上架才提醒)")
+    parser.add_argument(
+        "start", nargs="?", type=_parse_start_time, default=None,
+        help="运行窗口开始时间 HH:MM[:SS],默认 20:30;位置参数写法,如 17:00")
+    parser.add_argument(
+        "--start", dest="start_opt", type=_parse_start_time, default=None,
+        help="运行窗口开始时间 HH:MM[:SS],与位置参数等价,如 --start 17:00")
+    return parser.parse_args()
+
+
+if __name__ == "__main__":
+    # logger.add(sys.stderr, level="INFO")  # 控制台同步输出,便于观察
+    _args = _parse_args()
+    # 位置参数优先,其次 --start,都未传则保持默认 RUN_START(20:30)
+    _start = _args.start or _args.start_opt
+    if _start is not None:
+        RUN_START = _start  # 覆盖模块级默认,窗口判定与「新上架」门槛均随之改变
+        logger.info(f"运行窗口开始时间由命令行指定为 {RUN_START:%H:%M}")
+    schedule_task()

+ 98 - 0
deca_auto_spider/spiders/replenish_accounts.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/09
+"""账号池一键补号脚本(经接码平台自动注册 = 登录即注册)。
+
+用法:
+    python spiders/replenish_accounts.py               # 默认单次最多补 5 个(分天补,降连坐)
+    python spiders/replenish_accounts.py --batch 3     # 本次最多补 3 个
+    python spiders/replenish_accounts.py --target 20 --batch 20   # 补满 20(⚠️ 一次爆量连坐风险高)
+    python spiders/replenish_accounts.py --loop        # 常驻定时模式:每 2h 巡检补一次(供 start_all 长跑)
+
+补货前会先释放 dead 号占用的专属 IP(不复活、仅回收 IP),故 dead 掉号后 healthy 能自动被新号补回。
+
+风控提醒:得卡强风控,短时间同机批量注册新号是最典型连坐信号。默认单次限 5 个、每号错峰
+30~90s,建议分几天补满 POOL_TARGET_SIZE(20),让号陆续自然增长,别一次爆量。
+"""
+# ---------- bootstrap:定位新根 + 挂 common 到 sys.path + chdir 新根(共用根 application.yml/logs)----------
+import os
+import sys
+
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+import argparse
+import time
+
+import schedule
+from loguru import logger
+
+from mysql_pool import MySQLConnectionPool
+from account_pool import AccountPool, POOL_TARGET_SIZE
+
+# 定时模式默认巡检间隔(小时):常驻跑时每隔 N 小时补一次;只在 healthy 不足时才真买号,够用即空跑
+REPLENISH_EVERY_HOURS = 2
+
+# 日志:按天切分,保留 7 天
+logger.remove()
+logger.add(sys.stderr, format="[{time:HH:mm:ss}] {level} {message}", level="INFO")
+logger.add("./logs/replenish_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss}] {level} {message}", level="INFO", retention="7 day")
+
+
+def main(target: int, batch: int) -> None:
+    """补号主流程:把账号池 healthy 数补到 target,单次最多补 batch 个。
+
+    Args:
+        target (int): 账号池目标 healthy 数(补到此值为止)。
+        batch (int): 本次运行最多新增几个号(分批/分天控节奏、降连坐)。
+    """
+    pool = MySQLConnectionPool(log=logger)
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常,退出")
+        return
+    ap = AccountPool(pool, log=logger, task_tag="replenish")
+    ap.revive_cooling()   # 先把冷却(cooling)到期的号恢复 healthy,再统计是否需补(避免多注册)
+    cur = ap.count_healthy()
+    # 单次限量:本次目标 = min(总目标, 当前 + batch),避免一次爆量
+    run_target = min(target, cur + batch)
+    logger.info(f"补号开始:当前 healthy={cur},总目标={target},本次补到={run_target}(单次限 {batch} 个)")
+    if cur >= run_target:
+        logger.info("已达标,无需补货")
+        return
+    added = ap.auto_replenish(target_min=run_target, target_max=run_target)
+    logger.info(f"补号结束:本次新增 {added} 个,当前 healthy={ap.count_healthy()}(总目标 {target})")
+
+
+def schedule_loop(target: int, batch: int, hours: int) -> None:
+    """常驻定时补号:先立即补一次,之后每 hours 小时巡检补一次(供 start_all 拉起长跑)。
+
+    每轮内部会先释放 dead 号占用的专属 IP 再补(见 AccountPool.auto_replenish),故 dead 号
+    掉了也能被新号顶替、healthy 自动回到 target;只在 healthy < 目标时才真向接码平台买号。
+
+    Args:
+        target (int): 账号池目标 healthy 数。
+        batch (int): 单轮最多新增几个号(分批错峰、降连坐)。
+        hours (int): 巡检间隔小时数。
+    """
+    logger.info(f"账号池自动补货常驻启动:每 {hours} 小时巡检一次,目标 {target},单轮限 {batch} 个")
+    main(target=target, batch=batch)                 # 启动即补一次,先把当前缺口补上
+    schedule.every(hours).hours.do(main, target=target, batch=batch)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="账号池一键补号(接码平台自动注册)")
+    parser.add_argument("--target", type=int, default=POOL_TARGET_SIZE, help=f"池子总目标数,默认 {POOL_TARGET_SIZE}")
+    parser.add_argument("--batch", type=int, default=5, help="单次/单轮最多补几个(分批降连坐),默认 5")
+    parser.add_argument("--loop", action="store_true", help="常驻定时模式:每 --hours 小时巡检补一次(供 start_all 长跑)")
+    parser.add_argument("--hours", type=int, default=REPLENISH_EVERY_HOURS, help=f"定时模式巡检间隔小时,默认 {REPLENISH_EVERY_HOURS}")
+    args = parser.parse_args()
+    if args.loop:
+        schedule_loop(target=args.target, batch=args.batch, hours=args.hours)   # 常驻定时
+    else:
+        main(target=args.target, batch=args.batch)                             # 一次性补

+ 99 - 0
deca_auto_spider/spiders/shop_test.py

@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/11
+"""得卡 DECA 单店铺历史成交——全量翻页抓取(临时测试)。
+
+复用 deca_sold_core 的签名请求 / 账号池 / 商品解析,
+按 merchantUserId 深翻某店铺的全部历史成交页,INSERT IGNORE 写入 deca_product_record_copy1。
+
+从根目录运行:python spiders/shop_test.py
+"""
+import sys
+import os
+
+# 挂靠新项目根:sys.path 指向 common、CWD 对齐新根(application.yml / logs / 账号池 DB 生效)
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+import time
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+import deca_sold_core as core
+
+# ==================== 测试配置 ====================
+MERCHANT_USER_ID = "881226408"                  # 目标店铺 userId(自行填入,来源:商家展位列表 / 抓包)
+DEST_TABLE = "deca_product_record_copy1"  # 临时落库表(已手动建好,结构同 deca_product_record)
+
+logger.remove()
+logger.add("./logs/shop_test_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+logger.add(sys.stderr, level="INFO")  # 手动跑,控制台同步输出
+
+
+def fetch_shop_sold(log, merchant_user_id: str, pool) -> int:
+    """深翻某店铺全部历史成交页,写入 DEST_TABLE(全量,不早停)。
+
+    Args:
+        log: 日志对象。
+        merchant_user_id (str): 目标店铺 userId。
+        pool: MySQL 连接池。
+
+    Returns:
+        int: 累计写入的商品数(INSERT IGNORE 去重前的入库尝试量)。
+    """
+    page = 1
+    saved = 0
+    while page <= core.MAX_SOLD_PAGES:
+        body = {"merchantUserId": merchant_user_id, "page": page, "pageSize": core.PAGE_SIZE}
+        try:
+            resp = core.do_request(log, "/api/v1/app/groupbuy/merchant/sold-list", body, need_auth=True)
+        except Exception as e:
+            log.error(f"店铺 {merchant_user_id} 历史成交第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            log.warning(f"店铺 {merchant_user_id} 第 {page} 页返回异常,停止:{(resp or {}).get('code')}")
+            break
+        items = (resp.get("data") or {}).get("list") or []
+        if not items:
+            log.info(f"店铺 {merchant_user_id} 第 {page} 页无数据,翻页结束")
+            break
+
+        rows = [r for r in (core.parse_product(it) for it in items) if r]
+        if rows:
+            pool.insert_many(table=DEST_TABLE, data_list=rows, ignore=True)  # 只增不改,靠唯一键去重
+            saved += len(rows)
+        log.info(f"店铺 {merchant_user_id} 第 {page} 页完成,本页 {len(items)} 条,累计入库 {saved}")
+
+        if len(items) < core.PAGE_SIZE:  # 本页不足一页=最后一页
+            break
+        page += 1
+        time.sleep(0.3)  # 轻微限速,降低风控概率
+
+    log.info(f"店铺 {merchant_user_id} 历史成交抓取结束,累计入库 {saved} 个")
+    return saved
+
+
+def main() -> None:
+    """测试入口:初始化账号池与连接池,抓取指定店铺全部历史成交。
+
+    Raises:
+        SystemExit: 未填 MERCHANT_USER_ID 或数据库连接池异常时退出。
+    """
+    if not MERCHANT_USER_ID:
+        logger.error("请先在脚本顶部填写 MERCHANT_USER_ID(目标店铺 userId)")
+        raise SystemExit(1)
+
+    pool = MySQLConnectionPool(log=logger)
+    core.init_account_pool(pool, task_tag="shop_test")  # need_auth 请求走账号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        raise SystemExit(1)
+
+    fetch_shop_sold(logger, MERCHANT_USER_ID, pool)
+
+
+if __name__ == "__main__":
+    main()

+ 105 - 0
deca_auto_spider/spiders/sold_daily_spider.py

@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA 已售流程——每日完整采集 + 密集时段每小时占坑补采(常驻定时)。
+
+每天 08:00 完整管道(sold-list 服务端只开放最近 60 条=3 页,全量/增量翻页已一致):
+    商家列表 → 每商家已售(翻3页入库) → 详情补抓 → 随机团回补 → 卡密清单 → 拆卡报告 + 视频回放。
+拆卡报告/回放靠 report_state/replay_state 驱动,拿不到(还没生成)下轮重采。
+
+另在 13:00-06:00 售卖密集时段每小时跑一次 hourly_task:只翻目标商家 3 页已售 INSERT IGNORE
+占坑入库(不跑精加工),防止单商家两次采集间成交 >60 被挤出窗口漏采;精加工仍由 08:00 完整流程扫库补齐。
+
+从根目录运行:python sold_daily_spider.py
+"""
+import sys
+import time
+import os
+
+# 挂靠新项目根:sys.path 指向 common、CWD 对齐新根(application.yml / logs / 账号池 DB 生效)
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+import deca_sold_core as core   # 裸导入(与 common/ 内部各模块的裸导入一致,单实例);跳转靠 IDE 把 common 标为 Sources Root,见 .idea/*.iml
+
+logger.remove()
+logger.add("./logs/sold_daily_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# 密集时段(13:00-06:00 售卖集中)每小时占坑入库的目标商家配置:
+# sold-list 服务端只开放最近 60 条(3页),若相邻两次采集间隔内单商家新成交 >60,最早那批会被挤出
+# 窗口永久漏采;故高频补采尽早把 product_code 占坑入库。实测 881226408 单小时成交峰值 41<60,每小时够。
+HOURLY_MERCHANT_IDS = ["881226408"]                       # 目标商家(后续可扩多个)
+HOURLY_HOURS = list(range(13, 24)) + list(range(0, 7))    # 13,14,...,23,0,1,...,6 每整点各跑一次
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
+def main_task(log):
+    """已售每日完整采集(run_pipeline:商家→已售3页→详情→随机团→卡密→报告)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 已售每日增量采集" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="sold_daily")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        core.run_pipeline(log, pool, incremental=True)
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"已售每日增量采集 {sys._getframe().f_code.co_name} 运行结束" + "." * 20)
+
+
+def hourly_task(log):
+    """密集时段每小时轻量占坑:只翻目标商家 3 页已售 INSERT IGNORE 入库,不跑精加工。
+
+    sold-list 服务端只开放最近 60 条(3页)。若相邻两次采集间隔内单商家新成交 >60,最早那批会被
+    挤出窗口永久漏采。故在 13:00-06:00 售卖密集时段每小时补采一次,尽早把 product_code 占坑入库;
+    详情补抓 / 随机团回补 / 拆卡报告等精加工仍由每天 08:00 的 main_task(run_pipeline) 扫库存量统一
+    补齐——两者解耦,product 一旦进库即不丢,晚几小时加工无碍。
+
+    Args:
+        log: 日志对象。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 密集时段每小时占坑" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="sold_hourly")  # need_auth 请求走账号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常,跳过本轮 hourly_task")
+        return
+    for mid in HOURLY_MERCHANT_IDS:
+        try:
+            core.get_sold_list(log, mid, pool)  # 翻 3 页 INSERT IGNORE 占坑,不跑后续精加工
+        except Exception as e:
+            log.error(f"hourly_task get_sold_list error(商家 {mid}): {e}")
+    log.info(f"{sys._getframe().f_code.co_name} 本轮结束" + "." * 20)
+
+
+def schedule_task():
+    """定时任务入口:每天 08:00 完整采集(含精加工),13:00-06:00 密集时段每整点占坑补采。"""
+    main_task(log=logger)  # 启动立即完整跑一次
+
+    schedule.every().day.at("08:00").do(main_task, log=logger)   # 每天 08:00 完整流程(详情/团/报告精加工)
+    for h in HOURLY_HOURS:                                        # 密集时段每整点轻量占坑入库
+        schedule.every().day.at(f"{h:02d}:00").do(hourly_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 58 - 0
deca_auto_spider/spiders/sold_history_spider.py

@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA 已售流程——历史全量抓取(建库存量,一次性/不定时手动跑)。
+
+完整管道深翻所有商家的全部历史成交:
+    商家列表 → 每商家已售(深翻) → 详情补抓 → 卡密清单 → 拆卡报告 + 视频回放。
+写入 deca_shop_record / deca_product_record / deca_kami_record / deca_report_record。
+
+从根目录运行:python sold_history_spider.py
+"""
+import sys
+import os
+
+# 挂靠新项目根:sys.path 指向 common、CWD 对齐新根(application.yml / logs / 账号池 DB 生效)
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(_ROOT, "common"))
+os.chdir(_ROOT)
+
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+import deca_sold_core as core
+
+logger.remove()
+logger.add("./logs/sold_history_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
+def main_task(log):
+    """已售历史全量采集(深翻,incremental=False)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 已售历史全量采集" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    core.init_account_pool(pool, task_tag="sold_history")  # 启用账号池:need_auth 请求走 20 号池 + 各号专属 IP
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        core.run_pipeline(log, pool, incremental=False)
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"已售历史全量采集 {sys._getframe().f_code.co_name} 运行结束" + "." * 20)
+
+
+if __name__ == "__main__":
+    logger.add(sys.stderr, level="INFO")  # 历史全量为手动跑,控制台同步输出
+    main_task(logger)

+ 108 - 0
deca_auto_spider/start_all.py

@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/09/09
+r"""得卡统一部署守护:一键拉起全部常驻任务(采集 + 报告调度 + 运维)+ 崩溃自动重启。
+
+各任务独立进程(采集都已接账号池,多进程共用 20 号池,线程/进程隔离安全)。守护主循环监控
+子进程,任一崩溃退出即自动重启。这是部署到服务器的统一入口(放项目根目录,一眼可见)。
+
+任务清单(TASKS):
+    采集:buy_record(购买记录5家)/ onsale_alert(上架提醒)/ sold_daily(已售每日)/ team(选队随机)
+    报告:onsale_report(在售报告 每天 09/15/20/01 四次)/ sold_report(已售 日报09:10 + 周报周一09:20 + 月报1号09:30)
+    运维:maintenance(账号池 cooling 巡检 + 报告 Excel 定时清理)/ replenish(账号池自动补货 每2h)
+
+用法:
+    python start_all.py         # 启动全部
+    python start_all.py buy_record team  # 只启动指定任务(名字见 TASKS)
+    Ctrl+C        # 优雅终止所有子任务
+
+上线前确认:新目录采集真跑会用账号池 20 号;若旧目录 D:\work\2026-08-02(deca_spider) 生产仍在跑,
+需先协调(两边同时跑会重复请求、双倍量)。
+"""
+import os
+import sys
+import time
+import subprocess
+
+_ROOT = os.path.dirname(os.path.abspath(__file__))
+os.chdir(_ROOT)
+
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, format="[{time:HH:mm:ss}] {level} {message}", level="INFO")
+logger.add("./logs/start_all_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss}] {level} {message}", level="INFO", retention="7 day")
+
+# 任务名 -> 启动命令 argv(相对 _ROOT 的脚本 + 参数;spawn 时前置 sys.executable -u)。
+# 各脚本自身 bootstrap 会 chdir 到各自所需目录,故这里统一 cwd=_ROOT 即可。
+TASKS = {
+    # ---- 常驻采集(已接账号池)----
+    "buy_record":    ["spiders/buy_record_spider.py"],        # 购买记录(5 家)
+    "onsale_alert":  ["spiders/onsale_alert_spider.py", "13:00"],            # 上架提醒(新上架监控推送)
+    "sold_daily": ["spiders/sold_daily_spider.py"],            # 已售每日采集(每天 08:00)
+    "team": ["spiders/deca_team_spider.py"],      # 选队随机总价(5 分钟/轮)
+    # ---- 报告调度(各自 schedule 常驻)----
+    "onsale_report": ["reports/onsale_report/deca_on_sale_report.py", "loop"],  # 在售报告:每天 09/15/20/01 四次
+    "sold_report":   ["reports/sold_report/run_scheduler.py"],     # 已售:日报09:10 + 周报周一09:20 + 月报1号09:30
+    # ---- 运维 ----
+    "maintenance":   ["spiders/maintenance.py"],              # 账号池 cooling 巡检 + Excel 定时清理
+    "replenish":     ["spiders/replenish_accounts.py", "--loop"],  # 账号池自动补货:每2h巡检,healthy不足即补(补前先释放dead号占的IP)
+}
+RESTART_DELAY = 15   # 子进程崩溃退出后,重启前等待秒数(避免疯狂重启刷屏)
+
+
+def spawn(name: str, argv: list, procs: dict) -> None:
+    """启动一个任务为独立子进程并登记到 procs。
+
+    Args:
+        name (str): 任务名(TASKS 的 key)。
+        argv (list): 相对 _ROOT 的脚本路径 + 参数(不含解释器)。
+        procs (dict): 进程登记表 name -> Popen,就地更新。
+    """
+    p = subprocess.Popen([sys.executable, "-u"] + argv, cwd=_ROOT)
+    procs[name] = p
+    logger.info(f"启动 {name} pid={p.pid} -> {' '.join(argv)}")
+
+
+def main(names: list) -> None:
+    """启动指定任务并守护:子进程崩溃退出即自动重启,Ctrl+C 优雅终止全部。
+
+    Args:
+        names (list): 要启动的任务名列表(须是 TASKS 的 key)。
+    """
+    procs = {}
+    for name in names:
+        spawn(name, TASKS[name], procs)
+    logger.info(f"已启动 {len(procs)} 个任务,进入守护监控(崩溃自动重启):{names}")
+    try:
+        while True:
+            for name in names:
+                p = procs.get(name)
+                if p and p.poll() is not None:   # 子进程已退出(正常结束或崩溃)
+                    logger.warning(f"{name} 已退出(returncode={p.returncode}),{RESTART_DELAY}s 后重启")
+                    time.sleep(RESTART_DELAY)
+                    spawn(name, TASKS[name], procs)
+            time.sleep(5)
+    except KeyboardInterrupt:
+        logger.info("收到中断信号,正在终止所有子任务……")
+        for name, p in procs.items():
+            try:
+                p.terminate()
+                logger.info(f"已终止 {name} pid={p.pid}")
+            except Exception as e:
+                logger.warning(f"终止 {name} 失败: {e}")
+
+
+if __name__ == "__main__":
+    args = sys.argv[1:]
+    if args:
+        bad = [a for a in args if a not in TASKS]
+        if bad:
+            logger.error(f"未知任务名 {bad},可选: {list(TASKS)}")
+            sys.exit(1)
+        selected = args
+    else:
+        selected = list(TASKS)
+    main(selected)

+ 251 - 0
deca_auto_spider/球队.json

@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/8/4 22:24
+json = """
+{
+  "code": 0,
+  "msg": "成功",
+  "data": {
+    "code": "GB26080464831",
+    "liveId": 0,
+    "shareCode": "Xhw8QK5OcBUkaGfbHj/+iaahaawVghp0pcgjDcoDwchmAF5DSq6pMbac+y06J/Wn7cOcTeUwsh8R/AYel2JuoEXUpc2GTn0wHuTB9Ogrp/9rm8ld88+Pxy6zYpqZHjXKgCd7Yqk=",
+    "status": 5,
+    "title": "2025-26 篮球 NBA topps Inception Hobby #7 【原箱】 魔都兄弟球星卡",
+    "playType": -1,
+    "playTypeName": "剩余随机",
+    "specType": 2,
+    "specTypeName": "原箱",
+    "specSummary": "1箱",
+    "isPresale": 1,
+    "isPresaleName": "是",
+    "saleStartAt": "2026-08-04 21:25:00",
+    "saleEndAt": "2026-08-09 21:25:00",
+    "publishAt": "2026-08-04 21:08:01",
+    "maxSaleDays": 5,
+    "singleUserLimitStatus": 2,
+    "singleUserMaxQuantity": 0,
+    "userPurchaseCount": 0,
+    "myCardCount": 0,
+    "canViewCards": 2,
+    "singleOrderLimitStatus": 2,
+    "singleOrderMaxQuantity": 0,
+    "breakRegion": 1,
+    "breakRegionName": "中国大陆",
+    "giftIntro": "整箱篮球盗梦选队LIST拼团 高低编各获得3000元无门槛券(先看分母再看分子 绝杀制 交换板算为尾编 如10编交换板算10 10)\n实体店地址 上海市徐汇区永福路125号8号楼 抖音搜索魔都兄弟每晚同步直播",
+    "coverImageUrl": "https://static.decalive.com/release/groupbuy/image/2026/08/03/08981dbd-6db8-46b4-80dd-c2f3414633c6.jpg",
+    "carouselImageUrls": [],
+    "merchantInfo": {
+      "merchantUserID": "881226408",
+      "merchantName": "魔都兄弟球星卡",
+      "merchantAvatarUrl": "https://static.decalive.com/release/avatar/2026/08/01/7a8f6512-3c0a-4e9b-b379-217b9753ac00.jpg",
+      "merchantLogoUrl": "https://static.decalive.com/release/shop/logo/2026/08/03/bb68ecaf-aa63-4483-bb46-f0e3689e431c.png",
+      "wechatId": "xiaoxiaiqu0328",
+      "fansCount": 3355,
+      "activeGroupbuyCount": 9,
+      "completedGroupbuyCount": 38,
+      "completedGroupbuyCountText": "38",
+      "isFollowing": 2,
+      "isLiveOnline": 0,
+      "depositAmount": "50000.00",
+      "shopLevel": 3,
+      "liveTimelinessHours": 1,
+      "reportTimelinessHours": 1,
+      "shipmentTimelinessHours": 0
+    },
+    "cardProduct": {
+      "productId": 4,
+      "title": "精美卡牌",
+      "coverImageUrl": "https://static.decalive.com/release/groupbuy/card-template/2026/08/01/4321af4a-005f-44d6-b60e-9a7ae37ac84a.jpg",
+      "carouselImageUrls": [
+        "https://static.decalive.com/release/groupbuy/card-template/2026/08/01/ce000bba-ec1c-4c25-a113-40a066fc8a6e.jpg"
+      ],
+      "detailImageUrl": "",
+      "unitPrice": "25.00"
+    },
+    "prizeActivities": [],
+    "unitPrice": "38.54",
+    "minUnitPrice": "1.50",
+    "maxUnitPrice": "108.00",
+    "promotionTiers": [],
+    "remainingRandomTeamSnapshot": {
+      "totalQuantity": 997,
+      "teamCount": 13,
+      "unitPrice": "38.54",
+      "teams": [
+        {
+          "teamId": 4,
+          "teamNameEn": "Charlotte Hornets",
+          "teamNameZh": "黄蜂",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/cbf61693-1fcb-4ab9-a670-859d23041b7c.png",
+          "unitPrice": "35.00",
+          "availableStock": 159
+        },
+        {
+          "teamId": 8,
+          "teamNameEn": "San Antonio Spurs",
+          "teamNameZh": "马刺",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/52a2e2a5-a463-4f92-ac3b-2c71b4720f71.png",
+          "unitPrice": "88.00",
+          "availableStock": 119
+        },
+        {
+          "teamId": 9,
+          "teamNameEn": "Los Angeles Lakers",
+          "teamNameZh": "湖人",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/a80696f7-730f-4c9d-a9e2-ce52e73fc427.png",
+          "unitPrice": "35.00",
+          "availableStock": 100
+        },
+        {
+          "teamId": 12,
+          "teamNameEn": "Philadelphia 76ers",
+          "teamNameZh": "76 人",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/10b200ce-1e23-41df-b79b-9c4b6b8edf88.png",
+          "unitPrice": "20.00",
+          "availableStock": 80
+        },
+        {
+          "teamId": 14,
+          "teamNameEn": "Memphis Grizzlies",
+          "teamNameZh": "灰熊",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/093200ea-84d7-4e40-9d8c-36d313a41a39.png",
+          "unitPrice": "8.00",
+          "availableStock": 41
+        },
+        {
+          "teamId": 15,
+          "teamNameEn": "Utah Jazz",
+          "teamNameZh": "爵士",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/84bd699a-8526-43a9-a1dd-bfb5a1082599.png",
+          "unitPrice": "15.00",
+          "availableStock": 52
+        },
+        {
+          "teamId": 16,
+          "teamNameEn": "Brooklyn Nets",
+          "teamNameZh": "篮网",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/741d0d49-8682-43d9-998e-b5185cdbdc92.png",
+          "unitPrice": "8.00",
+          "availableStock": 80
+        },
+        {
+          "teamId": 20,
+          "teamNameEn": "Golden State Warriors",
+          "teamNameZh": "勇士",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/16d09b9f-3631-42e7-bc9c-c878f286cf46.png",
+          "unitPrice": "30.00",
+          "availableStock": 64
+        },
+        {
+          "teamId": 23,
+          "teamNameEn": "Dallas Mavericks",
+          "teamNameZh": "独行侠",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/77192981-21f2-463e-93d7-7da84d201119.png",
+          "unitPrice": "108.00",
+          "availableStock": 77
+        },
+        {
+          "teamId": 26,
+          "teamNameEn": "Oklahoma City Thunder",
+          "teamNameZh": "雷霆",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/d6909e9d-acad-4ff4-85e3-db1455969dfd.png",
+          "unitPrice": "15.00",
+          "availableStock": 101
+        },
+        {
+          "teamId": 27,
+          "teamNameEn": "Washington Wizards",
+          "teamNameZh": "奇才",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/4ad14dd2-2509-4643-89c0-2545d98f81e4.png",
+          "unitPrice": "6.00",
+          "availableStock": 10
+        },
+        {
+          "teamId": 29,
+          "teamNameEn": "New York Knicks",
+          "teamNameZh": "尼克斯",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/728dd7d7-dd99-498e-aef7-751cb5e8a303.png",
+          "unitPrice": "18.00",
+          "availableStock": 52
+        },
+        {
+          "teamId": 32,
+          "teamNameEn": "other",
+          "teamNameZh": "other",
+          "teamLogoImageUrl": "https://static.decalive.com/release/checklist/team-logo/2026/07/14/531c9138-f0f3-4a97-b0da-54f87db6bbba.png",
+          "unitPrice": "45.00",
+          "availableStock": 62
+        }
+      ]
+    },
+    "remainingRandomStatus": 2,
+    "firstOrderDiscountStatus": 2,
+    "firstOrderDiscountAmount": "0.00",
+    "firstOrderDiscountMaxOrderCount": 0,
+    "firstOrderDiscountUnlimited": 2,
+    "firstOrderDiscountAvailable": 2,
+    "allowMerchantCoupon": 1,
+    "totalCardCount": 2906,
+    "availableStock": 700,
+    "soldCount": 2206,
+    "orderPendingPayCount": 0,
+    "progressPercent": "75.91",
+    "sameSeriesItems": [
+      {
+        "code": "GB26080464831",
+        "playTypeName": "选队随机-随机卡种-区分编号",
+        "seriesGroupNo": 7,
+        "specSummary": "1箱"
+      },
+      {
+        "code": "GB26080494626",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 14,
+        "specSummary": "1盒"
+      },
+      {
+        "code": "GB26080433796",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 13,
+        "specSummary": "1盒"
+      },
+      {
+        "code": "GB26080370926",
+        "playTypeName": "随机球队",
+        "seriesGroupNo": 1,
+        "specSummary": "8盒"
+      },
+      {
+        "code": "GB26080322335",
+        "playTypeName": "随机卡种-区分编号",
+        "seriesGroupNo": 5,
+        "specSummary": "10箱"
+      }
+    ],
+    "giftInfo": {
+      "items": [
+        {
+          "seriesTag": "",
+          "seriesName": "Inception Hobby",
+          "aliasName": "",
+          "specDetail": "原箱",
+          "seriesConfig": "7张/包 1包/盒 8盒/箱 共1箱",
+          "expectedCardCount": 56
+        }
+      ],
+      "totalExpectedCardCount": 56,
+      "checklistCardCount": 2906
+    },
+    "buttonStatus": 1,
+    "confirmedListType": 2,
+    "fansOnlyStatus": 2,
+    "privateStatus": 2,
+    "countdownType": 2,
+    "countdownTargetAt": "2026-08-09 21:25:00",
+    "countdownTimestamp": 1786281900,
+    "remainingSeconds": 428464,
+    "checklistDataType": 1
+  }
+}
+"""