Explorar o código

feat(wechat): 新增企业微信群机器人消息发送工具模块

- 实现文本和 markdown 格式消息发送接口,支持列表项及链接格式化
- 新增文件发送功能,包含素材上传至企业微信接口获取 media_id
- 内置文件大小限制校验,处理文件上传或发送失败的异常情况
- 设计通用发送函数供其他脚本调用,无日志输出依赖调用方
- 支持消息中@成员列表,增强提醒定制化能力
- 添加模块自测示例,验证消息发送功能正确性
charley hai 1 mes
pai
achega
eb12d24a32

+ 112 - 0
deca_spider/README.md

@@ -0,0 +1,112 @@
+# 得卡 DECA 采集项目 · 运维总览
+
+> 抓取得卡 DECA 平台(`api.decalive.com`)的商家 / 在售 / 已售 / 购买记录 / 拆卡报告等数据入 MySQL,
+> 并对指定商家(当前 **881226408 魔都兄弟球星卡**)做实时提醒与每日报告(**2026/08/11 起提醒与报告统一发企业微信群机器人**)。
+> Python 3.12.10 | 公共库 `charley-utils`(`from mysql_pool import MySQLConnectionPool`)| DB 配置读运行目录 `application.yml`。
+> 深入细节见 `HANDOFF.md` 与 `docs/`;本文只讲「**要启动哪些脚本、各干什么、用哪些表**」。
+
+---
+
+## 一、需要常驻启动的脚本(生产环境要跑的)
+
+> 全部从**项目根目录**运行。带 ★ 的是数据源,必须先跑起来;报告类依赖库里已有数据。
+
+| # | 脚本 | 干什么 | 启动命令 | 频率 | 用到的表 | 需 token |
+|---|---|---|---|---|---|---|
+| 1 ★ | `buy_record_analysis/buy_record_spider.py` | **在售数据主采集 + 购买记录**:①每分钟免 token 拉全站在售落库 + 每 60s 记进度快照;②采本商家购买记录(自适应频率、售罄/结束自动停采) | `python buy_record_analysis/buy_record_spider.py` | 常驻循环(自适应) | 写 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record`、`deca_onsale_product_progress_record`、`deca_buy_record` | 否 |
+| 2 ★ | `on_sale/deca_team_spider.py` | **随机团总价采集**:选队随机抓 team-options 存队伍明细+算总价;剩余随机存 snapshot 实时算。转成剩余随机后 team-options 永久失效,故必须常驻不断采 | `python on_sale/deca_team_spider.py` | 常驻,每 5 分钟一轮 | 写 `deca_groupbuy_team_record`;更新 `deca_onsale_product_record.team_total_amount` | 部分(选队 team-options 需) |
+| 3 | `onsale_alert_spider.py` | **在售提醒(企微)**:监控 881226408,按上架时间提醒「窗口起点(20:30)后新上架」+ 进度过半 + 一车结束战报 | `python onsale_alert_spider.py` | 常驻,仅 20:30~次日 03:00 轮询 | 读写 `deca_onsale_alert_record`;读 `deca_buy_record`(结束战报的参与人数) | 否 |
+| 4 | `sold_daily_spider.py` | **已售拼团每日增量**:商家→已售→详情→拆卡报告+回放(连续无新页早停) | `python sold_daily_spider.py` | 常驻定时(每天 **08:00**,代码实际值;docstring 写的 03:00 已过时) | 写 `deca_shop_record`、`deca_product_record`、`deca_report_record`、`deca_groupbuy_team_record`、`deca_kami_record`(默认关) | **是** |
+| 5 | `on_sale/deca_on_sale_report.py` | **在售每日报告(企微 Excel)**:查库生成多 Sheet Excel 发群 | `python on_sale/deca_on_sale_report.py loop` | 定时 09:00/15:00/20:00/01:00 | 读 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record` | 否 |
+| 6 | `stats/daily_report.py` | **已售每日报告(企微 Excel)**:成交时间窗 [昨天17点,今天3点] 的平台/商家汇总+明细 | `python stats/daily_report.py` | 定时(每天 09:10) | 读 `deca_product_record`、`deca_report_record`、`deca_buy_record`、`deca_onsale_product_progress_record` | 否 |
+| 7 | `keep_awake.py` | **防 Windows 自动锁屏/息屏**,保证机器与上述常驻脚本可用(挡不住手动 Win+L / 域策略强制锁屏) | `python keep_awake.py` | 常驻 | — | 否 |
+
+**对应关系速记**:在售数据从哪来 → #1;随机团总价 → #2;实时提醒 → #3;已售入库 → #4;在售报告 → #5;已售报告 → #6。
+
+---
+
+## 二、一次性 / 手动脚本(按需跑,不常驻)
+
+| 脚本 | 干什么 | 运行 | 用到的表 |
+|---|---|---|---|
+| `init_db.py` | 读 `schema.sql` 幂等建表(首次或新增表后跑一次) | `python init_db.py` | 建全部 |
+| `sold_history_spider.py` | 已售**历史全量**深翻(首次建库存量,手动跑一次) | `python sold_history_spider.py` | 写 `deca_shop_record`、`deca_product_record`、`deca_report_record`、`deca_kami_record` |
+| `get_token.py` | **root 直读本地** App 存储提取登录态写 `token.json`(供 #4 已售、#2 随机团选队用;token 失效时跑)。设备已 root,读 DataStore `deka_settings.preferences_pb` 解析当前登录账号,覆盖写 `token.json` | `python get_token.py` | — |
+| `stats/export_teams_excel.py` | 把 `球队.json` 导出成 Excel | `python stats/export_teams_excel.py` | — |
+
+---
+
+## 三、库 / 辅助模块(被 import,不单独启动)
+
+| 模块 | 作用 |
+|---|---|
+| `deca_sold_core.py` | **核心库**:签名 / token 续期 / 带重试请求 / 已售采集管道 / 随机团总价计算。被各已售脚本 `import deca_sold_core as core`。 |
+| `auto_send_wx_msg.py` | **企微群机器人发送(主发送渠道,只此一份)**:`send_wechat_group_msg()` 发 text/markdown_v2、`send_wechat_group_file()` 发文件(Excel)。`on_sale/`、`stats/` 脚本用 `sys.path` 引导 import 根目录这份 —— **换群只改本文件 `WEBHOOK_URL` 一处**(当前指测试群)。 |
+| `on_sale/deca_on_sale_daily_spider.py` | 原「在售每日采集」,**已退休不单独常驻**;其免 token 抓取函数(`fetch_all_onsale`/`get_shop_list` 等)被 #1、#3 复用。 |
+| `deca_wechat.py`(及 `on_sale/`、`stats/` 副本) | PC 版微信(wxauto4)发送,**2026/08/11 起停用**、保留可回退(提醒脚本切 `SEND_CHANNEL="pc"` 复用)。 |
+
+---
+
+## 四、数据表一览(谁写谁读)
+
+| 表 | 说明 | 写入方 | 读取方 |
+|---|---|---|---|
+| `deca_onsale_shop_record` | 在售商家 | #1 | #5 |
+| `deca_onsale_product_record` | 在售商品(最新状态,含 `team_total_amount`) | #1、#2(改总价) | #2、#5 |
+| `deca_onsale_product_daily_record` | 在售商品每日售卖快照 | #1 | #5 |
+| `deca_onsale_product_progress_record` | 在售进度时间序列(60s,变化才写) | #1 | #6 |
+| `deca_buy_record` | 购买记录(去重买家名单) | #1 | #3、#6 |
+| `deca_groupbuy_team_record` | 随机团各队明细 | #2、#4 | 报告/统计 |
+| `deca_onsale_alert_record` | 在售提醒状态(`new_notified`/`half_notified`/`ended_notified` 去重) | #3 | #3 |
+| `deca_shop_record` | 已售商家 | #4、历史全量 | — |
+| `deca_product_record` | 已售商品(含详情/回放/`team_total_amount`) | #4、历史全量 | #6 |
+| `deca_report_record` | 拆卡报告(开出的卡+中卡用户+卡图) | #4、历史全量 | #6 |
+| `deca_kami_record` | 卡密清单(量大,`FILL_KAMI` 默认关) | #4(开关)、历史全量 | — |
+
+> 表结构以 `schema.sql`(从库真实导出)为准;所有表统一带 `gmt_create_time` / `gmt_modified_time`。
+
+---
+
+## 五、部署 / 启动要点
+
+1. **运行目录**:常驻脚本务必从项目根目录启动(`buy_record_spider` 会自动 `chdir` 到根),否则 `application.yml` / `token.json` / `./logs` 找不到。
+2. **建表**:首次先 `python init_db.py`;历史存量先跑一次 `sold_history_spider.py`。
+3. **token.json**:#4(已售)与 #2(随机团选队)需登录态;失效时跑 `get_token.py` 刷新(**root 直读本地**,替代旧 mitmproxy 代理抓包;脚本认「最新登录」账号,多账号切换时先在 App 切到目标账号再跑)。#1/#3 全链路免 token。
+4. **企微群**:提醒与报告都发 `auto_send_wx_msg.WEBHOOK_URL`(当前=测试群);换正式群**只改根目录 `auto_send_wx_msg.py` 一处**。
+5. **启动顺序建议**:`init_db` →(首次)`sold_history_spider` → 常驻 #1 #2 #3 #4 → 定时 #5 #6 →(会锁屏的机器)#7。
+6. **提醒消息不带商品链接**:分享落地页 `share-detail` 只认带 token 生成的 shareCode,免 token 一律失效;为不引入登录态已去链接、标题改加粗(详见 `docs/优化记录_得卡企微迁移_20260811.md`)。
+
+---
+
+## 六、历史遗留 / 暂不启用(勿误启)
+
+| 脚本 | 状态 |
+|---|---|
+| `on_sale/deca_sold_spider.py`(`deca_sold_record`) | 旧在售版已售采集,已被根目录 `sold_daily_spider.py` 取代 |
+| `on_sale/deca_track.py`(`deca_track_record`) | 历史遗留追踪脚本 |
+| `on_sale/km_spdier.py`(`deca_onsale_kami_record`) | 在售卡密清单采集,量大、默认不跑 |
+| `11.py` | 临时探测脚本(含旧 shareCode 样本),可删 |
+| `*_bak.py` | 备份文件 |
+
+---
+
+## 七、签名 / Token 机制(精简)
+
+- 业务接口需 `signature` 头:`signature = md5( md5(参数按 key 升序拼接) + currentTime[-6:] + "biu_card_nbclass" )`(逆向自 APK `mf/c0.java`,4 样本验证)。
+- 部分接口另需 `Authorization: Bearer <JWT>`(`access token` 15 分钟过期),走 `token/refresh` 续期规避阿里云验证码风控;`密码登录` 仅兜底。
+- 免 token 接口:全站在售 `home/search`、商品详情 `groupbuy/detail`、分享校验 `groupbuy/share-detail`。
+- 需 token 接口:已售 `sold-list`、卡密 `card/list`、拆卡报告 `open-card-report`、回放 `live/replay/detail`、随机团 `team-options`。
+- **token.json 来源**:设备已 root,`get_token.py` 直读 App 的 Jetpack DataStore(`files/datastore/deka_settings.preferences_pb`,token 存在顶层 key `switch_accounts` 的 JSON 里,账号字段 `uid`)解析当前登录账号的 `accessToken`/`refreshToken` 覆盖写;只需种一次,之后由 `deca_sold_core.ensure_token` 靠 `refreshToken` 自续期。已弃用 mitmproxy 代理抓包(`capture_token.py`/`run_capture_token.py` 已删)。
+- 完整逆向与登录续期说明见 `on_sale/README.md`。
+
+---
+
+## 八、相关文档
+
+- `HANDOFF.md` —— 完整交接文档(文件清单、DB 表、提醒逻辑、待办)。
+- `docs/选队随机与剩余随机_总价口径与采集_20260811.md` —— 随机团总价口径设计。
+- `docs/在售商品进度时间序列_采集与统计_20260811.md` —— 进度快照表与统计 SQL。
+- `docs/优化记录_得卡企微迁移_20260811.md` —— 企微迁移与去链接优化记录。
+- `docs/优化记录_deca报告用时列修复_20260812.md` —— 已售报告「到X%用时」列修复(删到100%、坍缩留空)+ 进度快照冻结排查。
+- `buy_record_analysis/README.md` —— 购买记录采集子项目(去重逻辑演进)。
+- `on_sale/README.md` —— 在售流程 + 签名/token 逆向原始说明。

+ 98 - 0
deca_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_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}

+ 229 - 0
deca_spider/auto_send_wx_msg.py

@@ -0,0 +1,229 @@
+# -*- 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 发文件能力。
+"""
+import os
+import re
+import json
+
+import requests
+from loguru import logger
+
+# 企业微信群机器人 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
+
+
+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 = requests.post(WEBHOOK_URL, headers=headers,
+                             data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
+                             timeout=(5, 30))
+        resp.raise_for_status()
+        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"企微消息发送失败: {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:
+        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()
+        result = resp.json()
+        if result.get("errcode") != 0:
+            log.error(f"企微素材上传失败: {result}")
+            return None
+        return result.get("media_id")
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微素材上传异常: {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 = requests.post(WEBHOOK_URL, headers={"Content-Type": "application/json"},
+                             data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
+        resp.raise_for_status()
+        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"企微文件发送失败: {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="🧪 企微机器人自测",
+    )

+ 8 - 0
deca_spider/buy_record_analysis/requirements.txt

@@ -0,0 +1,8 @@
+-i https://mirrors.aliyun.com/pypi/simple/
+requests==2.33.1
+schedule==1.2.2
+loguru==0.7.3
+tenacity==9.1.4
+PyMySQL==1.1.2
+DBUtils==3.1.2
+PyYAML==6.0.3

+ 909 - 0
deca_spider/deca_sold_core.py

@@ -0,0 +1,909 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA 已售流程核心(自包含:签名/登录/token + 完整采集管道)。
+
+**自包含、可独立部署**:签名/token/请求层直接内置,不依赖 on_sale/。
+放其他机器只需带:本文件 + sold_history_spider.py + sold_daily_spider.py + application.yml
++ token.json,并装好 charley-utils(mysql_pool) 与 requirements 依赖。
+
+签名(逆向自 APK mf/c0.java,4 样本验证):
+    signature = md5( md5(参数按 key 升序拼接) + currentTime[-6:] + "biu_card_nbclass" )
+鉴权:业务接口需 Bearer token,15 分钟过期,靠 refreshToken 续期(无验证码);密码登录受
+    阿里云验证码风控仅作兜底。token 持久化在运行目录 token.json。
+
+采集管道:
+    商家列表 → 每商家历史成交 → 详情补抓 → 卡密清单(开关) → 拆卡报告 + 视频回放
+    -> deca_shop_record / deca_product_record / deca_kami_record / deca_report_record
+"""
+import os
+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
+TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),仅靠 refreshToken 续期
+
+# 公共请求头(来源:抓包;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",
+}
+
+# access token 缓存:access=当前令牌,refresh=续签令牌,exp=过期秒级时间戳
+_TOKEN = {"access": None, "refresh": None, "exp": 0}
+
+# 已售流程表名(无前缀)
+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 = 500       # 单商家历史成交翻页上限
+MAX_CARD_PAGES = 5000      # 单商品卡密清单翻页上限
+STOP_AFTER_DUPE_PAGES = 2  # 增量:连续这么多页无新商品即早停
+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")
+
+
+@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)
+def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
+    """通用带签名 POST 请求(带重试)。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口相对路径(以 / 开头)。
+        body (dict): JSON body,同时用于签名。
+        need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+
+    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)
+    if need_auth:
+        headers["Authorization"] = f"Bearer {ensure_token(log)}"
+
+    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, need_auth: bool = False) -> 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。
+
+    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)
+    if need_auth:
+        headers["Authorization"] = f"Bearer {ensure_token(log)}"
+
+    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()
+
+
+# ==================== 登录 / Token 管理 ====================
+def save_token():
+    """把当前 _TOKEN(access/refresh/exp) 持久化到 TOKEN_FILE。"""
+    try:
+        with open(TOKEN_FILE, "w", encoding="utf-8") as f:
+            json.dump({"access": _TOKEN.get("access"), "refresh": _TOKEN.get("refresh"),
+                       "exp": _TOKEN.get("exp", 0)}, f)
+    except Exception as e:
+        logger.warning(f"token 持久化失败: {e}")
+
+
+def load_token():
+    """从 TOKEN_FILE 恢复 _TOKEN;文件不存在或损坏时静默跳过。"""
+    if not os.path.exists(TOKEN_FILE):
+        return
+    try:
+        with open(TOKEN_FILE, "r", encoding="utf-8") as f:
+            d = json.load(f)
+        _TOKEN["access"] = d.get("access")
+        _TOKEN["refresh"] = d.get("refresh")
+        _TOKEN["exp"] = d.get("exp", 0)
+    except Exception as e:
+        logger.warning(f"token 读取失败: {e}")
+
+
+def refresh_token(log) -> bool:
+    """用 refreshToken 续签 access token 并持久化(无验证码,无人值守主路径)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        bool: 续签成功返回 True,否则 False。
+    """
+    if not _TOKEN.get("refresh"):
+        return False
+    try:
+        resp = do_request(log, "/api/v1/app/auth/token/refresh",
+                          {"refreshToken": _TOKEN["refresh"]}, need_auth=False)
+        data = (resp or {}).get("data") or {}
+        access = data.get("accessToken")
+        if not access:
+            log.warning(f"token 续签无返回 access: {resp.get('msg') if resp else None}")
+            return False
+        expires_in = int(data.get("expiresIn") or 900)
+        _TOKEN["access"] = access
+        if data.get("refreshToken"):
+            _TOKEN["refresh"] = data["refreshToken"]
+        _TOKEN["exp"] = time.time() + expires_in
+        save_token()
+        log.info(f"token 续签成功,有效 {expires_in}s")
+        return True
+    except Exception as e:
+        log.warning(f"token 续签失败: {e}")
+        return False
+
+
+def ensure_token(log) -> str:
+    """返回有效 access token:缓存有效→直接用;临期或缺失→用 refreshToken 续期。
+
+    仅走 token 续签,不再有账号密码登录兜底;refreshToken 失效时抛错,需更新 token.json。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 有效的 access token。
+
+    Raises:
+        RuntimeError: refreshToken 缺失或续期失败时抛出。
+    """
+    if _TOKEN.get("access") and time.time() < _TOKEN["exp"] - 60:
+        return _TOKEN["access"]
+    if not _TOKEN.get("refresh"):
+        load_token()
+    if _TOKEN.get("refresh") and refresh_token(log):
+        return _TOKEN["access"]
+    raise RuntimeError("无法获取 access token:refreshToken 缺失或失效。"
+                       "请在 APP 重新登录后更新 token.json(设备已 root,跑 get_token.py 直读本地抓取)。")
+
+
+# ==================== 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})
+        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 _filter_new_codes(pool, page_codes: list) -> set:
+    """在本页 code 里查出库中还不存在的(增量早停用),只查本页不整表拉取。
+
+    Args:
+        pool: MySQL 连接池。
+        page_codes (list): 本页商品 code 列表。
+
+    Returns:
+        set: 本页中库里还没有的 code 集合。
+    """
+    codes = [c for c in page_codes if c]
+    if not codes:
+        return set()
+    ph = ",".join(["%s"] * len(codes))
+    rows = pool.select_all(f"SELECT product_code FROM {T_PROD} WHERE product_code IN ({ph})", tuple(codes))
+    existing = {r[0] for r in rows} if rows else set()
+    return {c for c in codes if c not in existing}
+
+
+def get_sold_list(log, merchant_user_id: str, pool, incremental: bool = False) -> int:
+    """翻页拉取某商家历史成交,INSERT IGNORE 写入 deca_product_record。
+
+    Args:
+        log: 日志对象。
+        merchant_user_id (str): 商家用户 ID。
+        pool: MySQL 连接池。
+        incremental (bool, optional): True=增量(连续无新页早停);False=全量深翻。Defaults to False。
+
+    Returns:
+        int: 本商家写入的商品数(已去重)。
+    """
+    page = 1
+    saved = 0
+    dupe_pages = 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]
+        new_on_page = len(rows)
+        if incremental:
+            new_codes = _filter_new_codes(pool, [r["product_code"] for r in rows])
+            rows = [r for r in rows if r["product_code"] in new_codes]
+            new_on_page = len(rows)
+        if rows:
+            pool.insert_many(table=T_PROD, data_list=rows, ignore=True)
+            saved += len(rows)
+
+        if incremental:
+            if new_on_page == 0:
+                dupe_pages += 1
+                if dupe_pages >= STOP_AFTER_DUPE_PAGES:
+                    break
+            else:
+                dupe_pages = 0
+        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)。
+    """
+    ensure_token(log)  # 预取 token(后续自动续期)
+
+    # 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}")

+ 100 - 0
deca_spider/deca_wechat.py

@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/02
+"""通过 PC 版微信(wxauto4)把文件/图片发送给指定好友或群。
+
+前置条件:
+    1. 已装 wxauto4:pip install wxauto4
+       (原 wxauto 已从 PyPI 下架;wxauto4 对应微信 4.0 PC 客户端,免费,支持 Python 3.9~3.13)
+    2. Windows PC 版微信(4.0+) 已登录,窗口未最小化(wxauto4 靠 UI 自动化操作微信窗口,
+       运行期间尽量别抢占鼠标/键盘)。
+    3. 目标好友/群的备注名或昵称须与传入的 who 完全一致(测试用 "backup")。
+
+用法:
+    - 作为模块:from deca_wechat import send_files; send_files("xx.png", who="backup")
+    - 命令行:  python deca_wechat.py <文件路径> [好友名(默认backup)]
+"""
+import sys
+
+from loguru import logger
+
+# 目标匹配是否精确:True=按 who 精确匹配备注/昵称(推荐,避免误发给名字相近的人)
+EXACT_MATCH = True
+
+
+def send_files(paths, who: str = "backup") -> bool:
+    """发送一个或多个文件给微信好友/群。
+
+    Args:
+        paths (str | list[str]): 单个文件路径或路径列表。
+        who (str, optional): 目标好友备注名/昵称或群名。Defaults to "backup"。
+
+    Returns:
+        bool: 全部发送成功返回 True;库缺失、连不上微信或发送异常返回 False。
+    """
+    try:
+        from wxauto4 import WeChat
+    except ImportError:
+        logger.error("未安装 wxauto4,请执行: pip install wxauto4")
+        return False
+
+    if isinstance(paths, str):
+        paths = [paths]
+
+    try:
+        wx = WeChat()  # 绑定当前已登录的 PC 微信(未登录/窗口不可见会失败)
+    except Exception as e:
+        logger.error(f"连接 PC 微信失败(确认微信4.0已登录且窗口可见): {e}")
+        return False
+
+    ok = True
+    for p in paths:
+        try:
+            resp = wx.SendFiles(str(p), who=who, exact=EXACT_MATCH)
+            logger.info(f"微信已发送: {p} -> {who} | 返回: {resp}")
+        except Exception as e:
+            ok = False
+            logger.error(f"微信发送失败 {p} -> {who}: {e}")
+    return ok
+
+
+def send_text(msg: str, who: str = "backup") -> bool:
+    """发送一条文本消息给微信好友/群。
+
+    Args:
+        msg (str): 要发送的文本内容(可含换行,微信里会自动识别明文 URL 为可点蓝字)。
+        who (str, optional): 目标好友备注名/昵称或群名。Defaults to "backup"。
+
+    Returns:
+        bool: 发送成功返回 True;库缺失、连不上微信或发送异常返回 False。
+    """
+    try:
+        from wxauto4 import WeChat
+    except ImportError:
+        logger.error("未安装 wxauto4,请执行: pip install wxauto4")
+        return False
+
+    try:
+        wx = WeChat()  # 绑定当前已登录的 PC 微信(未登录/窗口不可见会失败)
+    except Exception as e:
+        logger.error(f"连接 PC 微信失败(确认微信4.0已登录且窗口可见): {e}")
+        return False
+
+    try:
+        resp = wx.SendMsg(msg, who)  # wxauto4: SendMsg(消息, 目标) 位置参数
+        logger.info(f"微信文本已发送 -> {who} | 返回: {resp}")
+        return True
+    except Exception as e:
+        logger.error(f"微信文本发送失败 -> {who}: {e}")
+        return False
+
+
+if __name__ == "__main__":
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    if len(sys.argv) < 2:
+        print("用法: python deca_wechat.py <文件路径> [好友名(默认backup)]")
+        sys.exit(1)
+    target = sys.argv[2] if len(sys.argv) > 2 else "backup"
+    send_files(sys.argv[1], target)

+ 237 - 0
deca_spider/get_token.py

@@ -0,0 +1,237 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/12
+"""得卡 DECA App token 抓取脚本(root 直读本地存储,替代 mitmproxy 代理抓包)。
+
+设备已 root(Magisk)且 得卡 App 已登录时,直接从 App 私有目录的
+Jetpack DataStore 文件读出当前登录态的 accessToken / refreshToken,
+算好过期时间,覆盖写入项目根目录 token.json,供 deca_sold_core.py 等复用。
+
+与千岛版差异(都是 root 直读,只是存储格式不同):
+    千岛 token 明文存 SharedPreferences xml,一条 grep 即可;
+    得卡 token 存 DataStore 的 protobuf 文件 deka_settings.preferences_pb 里,
+    藏在顶层 key `switch_accounts` 的 JSON 数组内(每个账号一条 userInfo),
+    需按 protobuf 解出该 string,再解 JSON 取 accessToken 等字段。
+
+App 登录 / 切号后该 pb 会实时更新,App 后台也会用 refreshToken 自动续期并回写,
+所以本脚本读到的必然是「此刻有效」的 token。token.json 只需种一次,之后
+deca_sold_core.ensure_token 会靠 refreshToken 自续期,refreshToken 失效时再跑本脚本。
+
+用法:
+    python get_token.py            # 读设备 → 覆盖写 token.json → 打印脱敏摘要
+"""
+import os
+import sys
+import json
+import time
+import base64
+import subprocess
+
+# 得卡 App 包名(pm list packages 实测:有好卡 · 得卡)
+PACKAGE_NAME = "com.youhaoka.deka"
+
+# 登录态所在的 DataStore(Preferences,protobuf 二进制)文件
+PB_PATH = f"/data/data/{PACKAGE_NAME}/files/datastore/deka_settings.preferences_pb"
+
+# token 藏身的顶层 DataStore key,value 是一段 JSON 数组(多账号切换列表)
+ACCOUNTS_KEY = "switch_accounts"
+
+# token.json 与本脚本同目录(deca_sold_core 从运行目录读它)
+TOKEN_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "token.json")
+
+
+def _read_varint(buf: bytes, i: int) -> tuple[int, int]:
+    """从字节流指定位置解一个 protobuf varint。
+
+    Args:
+        buf (bytes): 原始字节流。
+        i (int): 起始下标。
+
+    Returns:
+        tuple[int, int]: (解出的整数值, 解析后的新下标)。
+    """
+    shift = 0
+    result = 0
+    while True:
+        b = buf[i]
+        i += 1
+        result |= (b & 0x7F) << shift
+        if not b & 0x80:                       # 最高位为 0 表示 varint 结束
+            return result, i
+        shift += 7
+
+
+def _get_pref_string(raw: bytes, key: str) -> str | None:
+    """从 DataStore Preferences protobuf 里取指定 key 的 string value。
+
+    DataStore 结构:顶层 map<string, Value>(field 1,每条是一个 entry message),
+    entry 内 field 1=key(string)、field 2=Value message;Value 里 field 5=string 值。
+
+    Args:
+        raw (bytes): deka_settings.preferences_pb 的完整字节。
+        key (str): 要取的顶层 key 名,如 "switch_accounts"。
+
+    Returns:
+        str | None: 该 key 的字符串值;未找到返回 None。
+    """
+    i = 0
+    while i < len(raw):
+        tag, i = _read_varint(raw, i)
+        field, wire = tag >> 3, tag & 7
+        if field == 1 and wire == 2:           # 顶层 map entry(length-delimited)
+            ln, i = _read_varint(raw, i)
+            entry = raw[i:i + ln]
+            i += ln
+            j = 0
+            k = v = None
+            while j < len(entry):
+                t2, j = _read_varint(entry, j)
+                f2, w2 = t2 >> 3, t2 & 7
+                if f2 == 1 and w2 == 2:         # entry.key
+                    kl, j = _read_varint(entry, j)
+                    k = entry[j:j + kl].decode("utf-8", "replace")
+                    j += kl
+                elif f2 == 2 and w2 == 2:       # entry.value(Value message)
+                    vl, j = _read_varint(entry, j)
+                    vb = entry[j:j + vl]
+                    j += vl
+                    m = 0
+                    while m < len(vb):          # 在 Value 里找 field 5=string
+                        t3, m = _read_varint(vb, m)
+                        f3, w3 = t3 >> 3, t3 & 7
+                        if f3 == 5 and w3 == 2:
+                            sl, m = _read_varint(vb, m)
+                            v = vb[m:m + sl].decode("utf-8", "replace")
+                            m += sl
+                        elif w3 == 0:           # 跳过其它类型字段(bool/int 等)
+                            _, m = _read_varint(vb, m)
+                        elif w3 == 2:
+                            xl, m = _read_varint(vb, m)
+                            m += xl
+                        else:
+                            break
+                else:
+                    break
+            if k == key:
+                return v
+        elif wire == 0:                         # 跳过非 map 的 varint 字段
+            _, i = _read_varint(raw, i)
+        elif wire == 2:                         # 跳过非 map 的 length-delimited 字段
+            ln, i = _read_varint(raw, i)
+            i += ln
+        else:
+            break
+    return None
+
+
+def _decode_jwt_exp(access: str) -> int | None:
+    """从 JWT accessToken 的 payload 里解出 exp(过期 unix 秒)。
+
+    Args:
+        access (str): JWT 字符串(header.payload.signature)。
+
+    Returns:
+        int | None: exp 时间戳;解析失败返回 None。
+    """
+    try:
+        payload = access.split(".")[1]
+        payload += "=" * (-len(payload) % 4)                       # 补齐 base64url 填充
+        data = json.loads(base64.urlsafe_b64decode(payload))
+        return int(data["exp"]) if "exp" in data else None
+    except Exception:
+        return None
+
+
+def read_device_token() -> dict:
+    """从已登录的 得卡 App 读取当前账号的 token 三元组。
+
+    通过 `adb exec-out su -c cat` 以 root 读 DataStore pb 文件(exec-out 保留二进制、
+    不做 CRLF 转换),解出 switch_accounts;多账号时取 tokenObtainedAtMs 最大者
+    (最近获取 = 当前登录账号);exp 优先用 JWT 解,兜底 tokenObtainedAtMs+expiresIn。
+
+    Returns:
+        dict: {"access": str, "refresh": str, "exp": int, "userId": ..., "phone": str}。
+
+    Raises:
+        RuntimeError: adb 缺失 / 超时 / 未读到 pb / 未登录(无账号)时抛出。
+    """
+    cmd = ["adb", "exec-out", "su", "-c", f"cat {PB_PATH}"]
+    try:
+        result = subprocess.run(cmd, capture_output=True, timeout=20)
+    except FileNotFoundError as e:
+        raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 未找到 adb,请确认已配置环境变量") from e
+    except subprocess.TimeoutExpired as e:
+        raise RuntimeError(f"[{sys._getframe().f_code.co_name}] adb 命令超时,请检查设备连接") from e
+
+    raw = result.stdout or b""
+    if not raw:
+        err = (result.stderr or b"").decode("utf-8", "replace").strip() or "未知错误"
+        raise RuntimeError(
+            f"[{sys._getframe().f_code.co_name}] 读取 pb 失败:{err}\n"
+            f"排查:1) adb devices 是否有设备 2) 设备是否已 root 3) 得卡 App 是否已登录"
+        )
+
+    accounts_json = _get_pref_string(raw, ACCOUNTS_KEY)
+    if not accounts_json:
+        raise RuntimeError(
+            f"[{sys._getframe().f_code.co_name}] pb 里没有 {ACCOUNTS_KEY},请确认 得卡 App 已登录"
+        )
+    try:
+        accounts = json.loads(accounts_json)
+    except Exception as e:
+        raise RuntimeError(f"[{sys._getframe().f_code.co_name}] {ACCOUNTS_KEY} 不是合法 JSON:{e}") from e
+    if not accounts:
+        raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 账号列表为空,请在 得卡 App 登录")
+
+    # 多账号时取 tokenObtainedAtMs 最大(最近登录/续期)的那个作为当前账号
+    def _obtained(acc: dict) -> int:
+        """取账号 userInfo 里的 tokenObtainedAtMs,缺失按 0。"""
+        return int((acc.get("userInfo") or {}).get("tokenObtainedAtMs") or 0)
+
+    cur = max(accounts, key=_obtained)
+    info = cur.get("userInfo") or {}
+    access = info.get("accessToken")
+    refresh = info.get("refreshToken")
+    if not access or not refresh:
+        raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 当前账号缺 accessToken/refreshToken,请重登")
+
+    exp = _decode_jwt_exp(access)
+    if exp is None:                                                # JWT 解不出 → 用获取时刻 + expiresIn
+        obtained_s = _obtained(cur) // 1000 or int(time.time())
+        exp = obtained_s + int(info.get("expiresIn") or 900)
+
+    return {"access": access, "refresh": refresh, "exp": exp,
+            "userId": info.get("userId"), "phone": info.get("phone")}
+
+
+def write_token_json(tk: dict) -> None:
+    """把 token 三元组覆盖写入 token.json(格式与旧抓包脚本一致,消费方零改动)。
+
+    Args:
+        tk (dict): read_device_token 的返回值,至少含 access/refresh/exp。
+    """
+    payload = {"access": tk["access"], "refresh": tk["refresh"], "exp": tk["exp"]}
+    with open(TOKEN_FILE, "w", encoding="utf-8") as f:
+        json.dump(payload, f, ensure_ascii=False)
+
+
+def main() -> None:
+    """读设备 token → 覆盖写 token.json → 打印脱敏摘要。"""
+    tk = read_device_token()
+    write_token_json(tk)
+    acc, exp, now = tk["access"], tk["exp"], int(time.time())
+    left = exp - now
+    print(f"[OK] token.json 已更新 -> {TOKEN_FILE}")
+    print(f"   userId : {tk.get('userId')}   phone : {tk.get('phone')}")
+    print(f"   access : JWT 长{len(acc)} 头[{acc[:10]}] 尾[{acc[-6:]}]")
+    print(f"   exp    : {exp} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(exp))})"
+          f"  剩余 {left} 秒 {'有效' if left > 0 else '已过期(App 侧会自动续期,稍后重跑)'}")
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except RuntimeError as exc:
+        print(exc)
+        sys.exit(1)

+ 30 - 0
deca_spider/init_db.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/02
+"""执行 schema.sql 建表(幂等,CREATE TABLE IF NOT EXISTS)。"""
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+
+def main():
+    """读取 schema.sql 并逐条执行建表语句,最后打印已建表清单。"""
+    with open("schema.sql", "r", encoding="utf-8") as f:
+        raw = f.read()
+
+    # 去掉整行注释,再按分号切分为独立语句
+    lines = [ln for ln in raw.splitlines() if not ln.strip().startswith("--")]
+    stmts = [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
+
+    pool = MySQLConnectionPool(log=logger)
+    for stmt in stmts:
+        pool._execute(stmt, commit=True)
+        head = stmt.split("(")[0].strip().replace("\n", " ")
+        logger.info(f"执行成功: {head}")
+
+    rows = pool.select_all("SHOW TABLES LIKE 'deca\\_%'")
+    logger.info(f"当前 deca 表: {[r[0] for r in rows]}")
+
+
+if __name__ == "__main__":
+    main()

+ 88 - 0
deca_spider/keep_awake.py

@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/08
+"""防止 Windows 自动锁屏 / 息屏,保持机器处于唤醒可用状态。
+
+原理说明:
+Windows 锁屏界面运行在独立的 Secure Desktop(Winlogon)上,普通用户态脚本
+无法向其注入按键,因此「锁了再自动输密码解锁」在常规权限下无法实现。本脚本
+改为从源头阻止自动锁屏——通过两条手段叠加保证覆盖面:
+    1. SetThreadExecutionState 持续声明「系统 + 显示器」处于使用中,阻止息屏与
+       随之而来的空闲锁屏;
+    2. 每隔一段时间模拟一次无副作用的按键(F15),重置系统空闲计时器,覆盖
+       「计算机不活动限制」这类基于空闲时长的锁屏策略。
+注意:本脚本无法阻止手动 Win+L、动态锁、或域策略强制锁屏——那类锁屏一旦发生
+只能人工解锁。
+"""
+import sys
+import time
+import ctypes
+from ctypes import wintypes
+
+# ---- 常量:SetThreadExecutionState 标志位 ----
+ES_CONTINUOUS = 0x80000000        # 持续生效,直到下次调用清除
+ES_SYSTEM_REQUIRED = 0x00000001   # 阻止系统进入睡眠
+ES_DISPLAY_REQUIRED = 0x00000002  # 阻止显示器关闭(连带阻止空闲锁屏)
+
+# ---- 常量:模拟按键 ----
+VK_F15 = 0x7E                     # F15 键,绝大多数程序不响应,用作无副作用「保活」键
+KEYEVENTF_KEYUP = 0x0002          # 按键抬起事件标志
+
+# ---- 常量:轮询与保活间隔(秒)----
+NUDGE_INTERVAL = 60               # 每 60 秒模拟一次按键,重置空闲计时器
+
+
+def prevent_sleep_lock() -> bool:
+    """声明系统与显示器处于使用中,阻止息屏与空闲锁屏。
+
+    Returns:
+        bool: True 表示调用成功,False 表示 SetThreadExecutionState 返回 0(失败)。
+    """
+    # 返回值为 0 表示失败;非 0 为上一次的执行状态标志
+    result = ctypes.windll.kernel32.SetThreadExecutionState(
+        ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
+    )
+    return result != 0
+
+
+def allow_sleep_lock() -> None:
+    """清除保活声明,恢复系统默认的息屏 / 锁屏行为。"""
+    # 只传 ES_CONTINUOUS 表示撤销之前的 SYSTEM/DISPLAY 声明
+    ctypes.windll.kernel32.SetThreadExecutionState(ES_CONTINUOUS)
+
+
+def nudge_input() -> None:
+    """模拟一次 F15 按下并抬起,重置系统空闲计时器。"""
+    user32 = ctypes.windll.user32
+    # 按下再抬起,模拟一次完整敲击;F15 不影响正常输入与前台程序
+    user32.keybd_event(VK_F15, 0, 0, 0)
+    user32.keybd_event(VK_F15, 0, KEYEVENTF_KEYUP, 0)
+
+
+def keep_awake() -> None:
+    """主循环:持续保持机器唤醒,直到用户 Ctrl+C 退出。
+
+    循环内每 NUDGE_INTERVAL 秒重申一次保活声明并模拟一次按键,
+    退出时恢复系统默认锁屏行为。
+    """
+    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 防锁屏已启动,每 {NUDGE_INTERVAL}s 保活一次,按 Ctrl+C 退出")
+    try:
+        while True:
+            ok = prevent_sleep_lock()   # 重申一次,防止声明被其他程序清除
+            nudge_input()               # 敲一下 F15,重置空闲计时器
+            status = "ok" if ok else "FAILED"
+            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 保活心跳 -> {status}")
+            time.sleep(NUDGE_INTERVAL)
+    except KeyboardInterrupt:
+        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 收到退出信号,恢复默认锁屏行为")
+    finally:
+        allow_sleep_lock()              # 无论如何都恢复系统默认,避免留下副作用
+
+
+if __name__ == "__main__":
+    # 仅支持 Windows;其他平台上 windll 不存在,直接提示退出
+    if not sys.platform.startswith("win"):
+        print("本脚本仅适用于 Windows")
+        sys.exit(1)
+    keep_awake()

+ 671 - 0
deca_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)

+ 98 - 0
deca_spider/on_sale/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_spider/on_sale/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}

+ 671 - 0
deca_spider/on_sale/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)

+ 667 - 0
deca_spider/onsale_alert_spider.py

@@ -0,0 +1,667 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA · 指定商家在售商品提醒(新品上架 + 进度过半,常驻轮询)。
+
+监控 MERCHANT_ID 商家的在售商品,两类提醒(各发一条独立消息):
+    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 一律被判失效;为不引入登录态、降账号风控,
+提醒消息直接不挂链接。三类提醒都发:新商品上架 / 拼团进度过半 / 一车结束战报。
+
+运行时段:仅每天 20:30~次日 06:00 轮询(RUN_START/RUN_END 控制),其余时间休眠到下次 20:30 再跑。
+
+从根目录运行:python onsale_alert_spider.py(默认企微渠道,需 WEBHOOK_URL 配好)
+"""
+import sys
+import time
+import random
+from datetime import datetime, time as dtime, timedelta
+
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+import deca_sold_core as core
+import deca_wechat                                   # PC 版微信发送(wxauto4)
+from auto_send_wx_msg import send_wechat_group_msg   # 企微机器人发送(备用渠道)
+# 复用 daily 的免 token 全站在售拉取(home/search,只拉不落库);alert 独立自采、不依赖 buy_record 落库
+from on_sale.deca_on_sale_daily_spider import fetch_all_onsale
+
+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")
+
+# ==================== 配置 ====================
+MERCHANT_ID = "881226408"          # 监控的商家用户ID
+HALF_THRESHOLD = 0.5               # 进度过半阈值(0.5=50%)
+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
+RUN_END = dtime(6, 0)              # 运行窗口结束:次日 06:00(窗口跨午夜;2026/08/15 由 03:00 延到 06:00,昨天 5 点还在播)
+MAX_PROD_PAGES = 100               # 在售商品翻页保护上限
+T_ALERT = "deca_onsale_alert_record"
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"       # 商家在售商品列表(need_auth)
+DETAIL_PATH = "/api/v1/app/groupbuy/detail"                       # 商品详情(取 publishAt 判新品)
+
+# 本进程启动后「见过在售」的本商家 product_code 集合(模块级内存,进程重启即清空):
+# 结束战报只认这些 code——即只播报「程序运行之后」才结束的车;程序启动前就已结束的历史车不补发。
+# 与持久化的 ended_notified 列配合:_seen_onsale 决定「管不管」,ended_notified 决定「发没发过」。
+_seen_onsale: set = set()
+
+
+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),作为「新上架」时间门槛。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        datetime: 20:30~午夜 → 今天 20:30;午夜~03:00 → 昨天 20:30(跨午夜窗口取窗口起点那天)。
+    """
+    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) -> bool:
+    """PC 微信:把新品与过半两类合并为一条纯文本消息发出(减少操作,一次发完)。
+
+    Args:
+        log: 日志对象。
+        mname (str): 商家名称,用于消息大标题。
+        new_items (list[str]): 新品上架通知条目。
+        half_items (list[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"得卡 · {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) -> tuple[list, bool]:
+    """免 token 抓全站在售、筛出 MERCHANT_ID 的在售商品(alert 独立自采,不依赖 buy_record 落库)。
+
+    复用 daily 的 fetch_all_onsale(home/search 免 token、只拉不落库)拿全站在售,
+    再按 merchant_user_id 筛出本商家、按 product_code 去重。全链路免 token。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        tuple[list, bool]: (本商家在售商品字典列表[parse_product 结果,含 title/unit_price/card_count/
+            sold_count/available_stock/merchant_name 等], 全站是否正常翻到底)。
+            bool=False 表示全站抓取中途异常/未取全,调用方据此放弃本轮下架/结束对账,避免误判。
+    """
+    all_products, ok = fetch_all_onsale(log)
+    rows = {p["product_code"]: p for p in all_products
+            if p.get("merchant_user_id") == MERCHANT_ID}
+    log.info(f"商家 {MERCHANT_ID} 当前在售商品 {len(rows)} 个(全站 {len(all_products)} 筛出,免 token,ok={ok})")
+    return list(rows.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,两条件均不命中返回未结束——保守,防抓取抖动误判下架。
+
+    Args:
+        data (dict): 商品详情接口(groupbuy/detail) data 层,可能为空 dict。
+        now_ts (int): 当前时间戳(秒)。
+
+    Returns:
+        tuple[bool, str]: (是否已结束, 原因文本);未结束时原因为空串。
+    """
+    stock = data.get("availableStock")
+    if stock is not None and stock <= 0:
+        return True, f"售罄(availableStock={stock})"
+    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) -> bool:
+    """按 SEND_CHANNEL 发送一条「一车结束」战报(每辆车一条独立消息)。
+
+    Args:
+        log: 日志对象。
+        mname (str): 商家名称,用于消息标题。
+        item_text (str): _fmt_ended 产出的战报正文。
+
+    Returns:
+        bool: 发送成功返回 True;失败返回 False(调用方据此决定是否置 ended_notified)。
+    """
+    header = f"🏁 得卡 · {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 _detect_and_report_ended(log, pool, existing: dict, onsale_codes: set):
+    """检测本进程运行后消失的车、二次确认结束后逐辆发战报并置位 ended_notified。
+
+    候选条件三取交:本进程见过在售(_seen_onsale) ∩ 本轮已不在在售(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 集合。
+    """
+    now_ts = int(time.time())
+    for code, info in existing.items():
+        if info["ended"] == 1:
+            continue                      # 已播报过结束,跳过
+        if code not in _seen_onsale:
+            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
+        # 售出件数=总份数(结束即全部售出):优先详情最新 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"))
+        if _send_ended(log, info["mname"] or MERCHANT_ID, item_text):
+            _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_once(log, pool):
+    """跑一轮监控:拉在售 → 结束对账(发一车结束战报) → 比对库内状态 → 发新品/过半提醒 → 落库。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+    """
+    products, ok = fetch_onsale(log)
+    if not ok:
+        log.warning("全站在售抓取未取全(请求异常),本轮跳过:不做新品/过半/结束判断,避免误判车下架")
+        return
+
+    # 库内该商家已监控商品:含结束标记与结束战报所需的静态字段(标题/商家名/单价/份数)
+    existing_rows = pool.select_all(
+        "SELECT product_code, new_notified, half_notified, ended_notified, "
+        "title, merchant_name, unit_price, card_count, sold_count "
+        f"FROM {T_ALERT} WHERE merchant_user_id=%s", MERCHANT_ID) or []
+    existing = {}
+    for code, new, half, ended, title, mname_, uprice, ccount, scount in existing_rows:
+        existing[code] = {"new": new, "half": half, "ended": ended,
+                          "title": title, "mname": mname_, "unit_price": uprice,
+                          "card": ccount, "sold": scount}
+    is_cold = len(existing) == 0  # 冷启动:库内该商家零记录(仅作日志提示,逻辑与常规轮一致)
+    if is_cold:
+        log.info("首次运行:库内该商家零记录,只提醒本轮窗口起点(20:30)后新上架的商品,老货静默建档")
+
+    onsale_codes = {r["product_code"] for r in products}  # 本轮在售 code 集合
+    # 结束对账:只播报本进程运行后见过在售、之后确认结束的车(历史已结束车不补发;置位防重发)
+    _detect_and_report_ended(log, pool, existing, onsale_codes)
+    _seen_onsale.update(onsale_codes)  # 本轮在售并入「见过在售」集合,供下轮结束对账(实现「只管运行后」)
+
+    if not products:
+        log.info("本商家本轮无在售商品,已完成结束对账,跳过新品/过半")
+        return
+
+    plain = SEND_CHANNEL == "pc"  # PC 微信用纯文本,企微用 markdown
+    new_items = []     # 新品上架提醒文案
+    half_items = []    # 进度过半提醒文案
+    pending_new = []   # 待发新品的 product_code:仅在提醒发送成功后才置 new_notified=1
+
+    window_start = _window_start(datetime.now())  # 「新上架」时间门槛:本轮窗口起点(最近的 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 晚于窗口起点(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(_fmt_item(r, "half", pct=pct, plain=plain))
+            else:
+                # 上架早于窗口起点(或拿不到上架时间)的老货:静默建档、不提醒(new_notified 直接置 1,避免下轮反复判断)
+                _insert_alert(pool, r, pct, new_notified=1,
+                              half_notified=1 if over_half else 0)
+                log.info(f"[静默建档] {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(_fmt_item(r, "half", pct=pct, plain=plain))
+            else:
+                _update_progress(pool, r, pct, half_notified=1 if already_half else 0)
+
+    mname = products[0].get("merchant_name") or MERCHANT_ID
+
+    # 常规轮次推送;sent_ok 记新品提醒是否发送成功,决定要不要置位 new_notified
+    sent_ok = False
+    if SEND_CHANNEL == "pc":
+        # PC 微信:新品 + 过半合并成一条消息发出(减少操作,一次发完)
+        if new_items or half_items:
+            log.info(f"合并推送(pc):新品{len(new_items)}个、过半{len(half_items)}个")
+            sent_ok = _dispatch_pc_combined(log, mname, new_items, half_items)
+    else:
+        # 企微渠道:仍按两类各发一条 markdown(新品这条成功与否决定 sent_ok)
+        if new_items:
+            log.info(f"新商品上架 {len(new_items)} 个,推送(qywx)")
+            sent_ok = _dispatch(log, new_items, f"🆕 得卡[{mname}] 新商品上架 {len(new_items)} 个")
+        if half_items:
+            log.info(f"进度过半 {len(half_items)} 个,推送(qywx)")
+            _dispatch(log, half_items, f"🔥 得卡[{mname}] 拼团进度过半 {len(half_items)} 个")
+
+    # 新品提醒发送成功后才置位 new_notified=1;失败则保持 0,下一轮继续重发(不丢)
+    if pending_new:
+        if sent_ok:
+            _mark_new_notified(pool, pending_new)
+            log.info(f"新品提醒发送成功,new_notified 置 1:{len(pending_new)} 个")
+        else:
+            log.warning(f"新品提醒发送失败,new_notified 保持 0,下轮重发:{len(pending_new)} 个")
+
+    if not new_items and not half_items:
+        log.info("本轮无新品、无新达标过半商品")
+
+
+@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)
+    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:                # 当天 20:30 已过 → 顺延到次日 20:30
+        start += timedelta(days=1)
+    return int((start - now).total_seconds())
+
+
+def schedule_task():
+    """常驻循环:仅在每天 20:30~次日06:00 运行;窗口内每轮随机间隔轮询,窗口外休眠到下次开窗。"""
+    while True:
+        now = datetime.now()
+        if not _in_run_window(now):
+            wait = _seconds_to_window(now)
+            logger.info(f"当前不在运行窗口(20:30~次日06:00),休眠 {wait}s 到 20:30 再跑")
+            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)
+
+
+if __name__ == "__main__":
+    # logger.add(sys.stderr, level="INFO")  # 控制台同步输出,便于观察
+    schedule_task()

+ 8 - 0
deca_spider/requirements.txt

@@ -0,0 +1,8 @@
+-i https://mirrors.aliyun.com/pypi/simple/
+requests==2.33.1
+schedule==1.2.2
+loguru==0.7.3
+tenacity==9.1.4
+PyMySQL==1.1.2
+DBUtils==3.1.2
+PyYAML==6.0.3

+ 291 - 0
deca_spider/schema.sql

@@ -0,0 +1,291 @@
+-- ============================================================
+-- 得卡 DECA 表结构(从数据库真实结构导出,2026/08/04 重构后)
+-- 在售流程 deca_onsale_* / 已售流程 deca_*(结构同onsale)/ deca_report_record
+-- ============================================================
+
+-- ---------- 在售(onsale)流程 ----------
+CREATE TABLE `deca_onsale_shop_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `merchant_user_id` varchar(32) NOT NULL COMMENT '商家用户ID(接口 userId,接口2的 merchantUserId)',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称',
+  `merchant_avatar_url` varchar(512) DEFAULT NULL COMMENT '商家头像URL',
+  `fans_count` int DEFAULT NULL COMMENT '粉丝数(动态)',
+  `active_groupbuy_count` int 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_merchant_user_id` (`merchant_user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=176 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商家列表(最新状态)';
+
+CREATE TABLE `deca_onsale_product_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(接口 code,如 GB26080178297)',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID(merchant.merchantUserID)',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称',
+  `title` varchar(1000) DEFAULT NULL COMMENT '商品标题',
+  `card_product_title` varchar(1000) DEFAULT NULL COMMENT '卡牌商品副标题(cardProductTitle)',
+  `cover_image_url` varchar(512) DEFAULT NULL COMMENT '封面图URL',
+  `unit_price` decimal(12,2) DEFAULT NULL COMMENT '单价(unitPrice)',
+  `min_unit_price` decimal(12,2) DEFAULT NULL COMMENT '最低单价',
+  `max_unit_price` decimal(12,2) DEFAULT NULL COMMENT '最高单价',
+  `card_count` int DEFAULT NULL COMMENT '总份数(cardCount)',
+  `sold_count` int DEFAULT NULL COMMENT '已售份数(soldCount,动态)',
+  `available_stock` int DEFAULT NULL COMMENT '剩余库存(availableStock,动态)',
+  `groupbuy_status` int DEFAULT NULL COMMENT '拼团状态码(groupbuyStatus)',
+  `groupbuy_status_name` varchar(32) DEFAULT NULL COMMENT '拼团状态名(groupbuyStatusName,如 拼团中)',
+  `is_on_sale` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否当前在售(1是/0否;每轮采集不在全量在售集合内则置0)',
+  `play_type` int DEFAULT NULL COMMENT '玩法类型(playType)',
+  `live_id` bigint DEFAULT NULL COMMENT '关联直播ID(liveId)',
+  `publish_at` varchar(32) DEFAULT NULL COMMENT '上架时间(详情 publishAt)',
+  `sale_start_at` varchar(32) DEFAULT NULL COMMENT '售卖开始时间(详情 saleStartAt)',
+  `sale_end_at` varchar(32) DEFAULT NULL COMMENT '售卖结束时间(详情 saleEndAt)',
+  `gmt_create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  `spec_name` varchar(64) DEFAULT NULL COMMENT '规格(specTypeName)',
+  `series_name` varchar(255) DEFAULT NULL COMMENT '系列名称(giftInfo.seriesName)',
+  `series_config` varchar(255) DEFAULT NULL COMMENT '系列配置(giftInfo.seriesConfig)',
+  `expected_card_count` int DEFAULT NULL COMMENT '预计开卡张数(totalExpectedCardCount)',
+  `checklist_card_count` int DEFAULT NULL COMMENT '卡密份数(totalCardCount)',
+  `replay_url` varchar(512) DEFAULT NULL COMMENT '视频回放链接(replay_url)',
+  `report_state` tinyint DEFAULT '0' COMMENT '拆卡报告采集状态 0未采/1已采/2无/3失败',
+  `replay_state` tinyint DEFAULT '0' COMMENT '视频回放采集状态 0未采/1已采/2无/3失败',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_product_code` (`product_code`),
+  KEY `idx_merchant_user_id` (`merchant_user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=40 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-在售商品(最新状态)';
+
+CREATE TABLE `deca_onsale_product_daily_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(code)',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID',
+  `snapshot_date` date NOT NULL COMMENT '快照日期(YYYY-MM-DD)',
+  `card_count` int DEFAULT NULL COMMENT '当日总份数',
+  `sold_count` int DEFAULT NULL COMMENT '当日累计已售',
+  `available_stock` int DEFAULT NULL COMMENT '当日剩余库存',
+  `groupbuy_status_name` varchar(32) DEFAULT NULL COMMENT '当日拼团状态名',
+  `unit_price` decimal(12,2) 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_code_date` (`product_code`,`snapshot_date`),
+  KEY `idx_snapshot_date` (`snapshot_date`)
+) ENGINE=InnoDB AUTO_INCREMENT=40 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商品每日售卖进度快照';
+
+CREATE TABLE `deca_onsale_kami_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `uuid` varchar(64) NOT NULL COMMENT '卡牌唯一标识(uuid)',
+  `product_code` varchar(32) DEFAULT NULL COMMENT '所属商品编码(请求 code)',
+  `sort_order` int DEFAULT NULL COMMENT '序号(sortOrder)',
+  `alias_name` varchar(64) DEFAULT NULL COMMENT '别名(aliasName,如 TCU1)',
+  `team_name_en` varchar(128) DEFAULT NULL COMMENT '球队英文名(teamNameEn)',
+  `team_name_zh` varchar(128) DEFAULT NULL COMMENT '球队中文名(teamNameZh)',
+  `player_name_en` varchar(128) DEFAULT NULL COMMENT '球员英文名(playerNameEn)',
+  `player_name_zh` varchar(128) DEFAULT NULL COMMENT '球员中文名(playerNameZh)',
+  `card_type` varchar(255) DEFAULT NULL COMMENT '卡种(cardType)',
+  `limit_number` varchar(64) DEFAULT NULL COMMENT '限编(limitNumber)',
+  `card_number` varchar(64) DEFAULT NULL COMMENT '卡编(cardNumber,如 No.1)',
+  `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_uuid` (`uuid`),
+  KEY `idx_product_code` (`product_code`)
+) ENGINE=InnoDB AUTO_INCREMENT=15933 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商品卡牌清单(checklist)';
+
+-- ---------- 已售流程(结构复制onsale) ----------
+CREATE TABLE `deca_shop_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `merchant_user_id` varchar(32) NOT NULL COMMENT '商家用户ID(接口 userId,接口2的 merchantUserId)',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称',
+  `merchant_avatar_url` varchar(512) DEFAULT NULL COMMENT '商家头像URL',
+  `fans_count` int DEFAULT NULL COMMENT '粉丝数(动态)',
+  `active_groupbuy_count` int 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_merchant_user_id` (`merchant_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商家列表(最新状态)';
+
+CREATE TABLE `deca_product_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(接口 code,如 GB26080178297)',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID(merchant.merchantUserID)',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称',
+  `title` varchar(1000) DEFAULT NULL COMMENT '商品标题',
+  `card_product_title` varchar(1000) DEFAULT NULL COMMENT '卡牌商品副标题(cardProductTitle)',
+  `cover_image_url` varchar(512) DEFAULT NULL COMMENT '封面图URL',
+  `unit_price` decimal(12,2) DEFAULT NULL COMMENT '单价(unitPrice)',
+  `min_unit_price` decimal(12,2) DEFAULT NULL COMMENT '最低单价',
+  `max_unit_price` decimal(12,2) DEFAULT NULL COMMENT '最高单价',
+  `card_count` int DEFAULT NULL COMMENT '总份数(cardCount)',
+  `sold_count` int DEFAULT NULL COMMENT '已售份数(soldCount,动态)',
+  `available_stock` int DEFAULT NULL COMMENT '剩余库存(availableStock,动态)',
+  `groupbuy_status` int DEFAULT NULL COMMENT '拼团状态码(groupbuyStatus)',
+  `groupbuy_status_name` varchar(32) DEFAULT NULL COMMENT '拼团状态名(groupbuyStatusName,如 拼团中)',
+  `play_type` int DEFAULT NULL COMMENT '玩法类型(playType)',
+  `live_id` bigint DEFAULT NULL COMMENT '关联直播ID(liveId)',
+  `publish_at` varchar(32) DEFAULT NULL COMMENT '上架时间(详情 publishAt)',
+  `sale_start_at` varchar(32) DEFAULT NULL COMMENT '售卖开始时间(详情 saleStartAt)',
+  `sale_end_at` varchar(32) DEFAULT NULL COMMENT '售卖结束时间(详情 saleEndAt)',
+  `gmt_create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  `spec_name` varchar(64) DEFAULT NULL COMMENT '规格(specTypeName)',
+  `series_name` varchar(255) DEFAULT NULL COMMENT '系列名称(giftInfo.seriesName)',
+  `series_config` varchar(255) DEFAULT NULL COMMENT '系列配置(giftInfo.seriesConfig)',
+  `expected_card_count` int DEFAULT NULL COMMENT '预计开卡张数(totalExpectedCardCount)',
+  `checklist_card_count` int DEFAULT NULL COMMENT '卡密份数(totalCardCount)',
+  `replay_url` varchar(512) DEFAULT NULL COMMENT '视频回放链接(replay_url)',
+  `report_state` tinyint DEFAULT '0' COMMENT '拆卡报告采集状态 0未采/1已采/2无/3失败',
+  `replay_state` tinyint DEFAULT '0' COMMENT '视频回放采集状态 0未采/1已采/2无/3失败',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_product_code` (`product_code`),
+  KEY `idx_merchant_user_id` (`merchant_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-在售商品(最新状态)';
+
+CREATE TABLE `deca_kami_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `uuid` varchar(64) NOT NULL COMMENT '卡牌唯一标识(uuid)',
+  `product_code` varchar(32) DEFAULT NULL COMMENT '所属商品编码(请求 code)',
+  `sort_order` int DEFAULT NULL COMMENT '序号(sortOrder)',
+  `alias_name` varchar(64) DEFAULT NULL COMMENT '别名(aliasName,如 TCU1)',
+  `team_name_en` varchar(128) DEFAULT NULL COMMENT '球队英文名(teamNameEn)',
+  `team_name_zh` varchar(128) DEFAULT NULL COMMENT '球队中文名(teamNameZh)',
+  `player_name_en` varchar(128) DEFAULT NULL COMMENT '球员英文名(playerNameEn)',
+  `player_name_zh` varchar(128) DEFAULT NULL COMMENT '球员中文名(playerNameZh)',
+  `card_type` varchar(255) DEFAULT NULL COMMENT '卡种(cardType)',
+  `limit_number` varchar(64) DEFAULT NULL COMMENT '限编(limitNumber)',
+  `card_number` varchar(64) DEFAULT NULL COMMENT '卡编(cardNumber,如 No.1)',
+  `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_uuid` (`uuid`),
+  KEY `idx_product_code` (`product_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商品卡牌清单(checklist)';
+
+-- ---------- 拆卡报告 ----------
+CREATE TABLE `deca_report_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) DEFAULT NULL COMMENT '商品编码',
+  `confirmed_card_uuid` varchar(64) NOT NULL COMMENT '拆开的卡唯一标识(confirmedCardUuid)',
+  `player_name_en` varchar(128) DEFAULT NULL COMMENT '球员英文名',
+  `player_name_zh` varchar(128) DEFAULT NULL COMMENT '球员中文名',
+  `opened_at` varchar(32) DEFAULT NULL COMMENT '开卡时间(openedAt)',
+  `hit_user_nickname` varchar(64) DEFAULT NULL COMMENT '中卡用户昵称(hitUserNickname)',
+  `image_url` varchar(1024) DEFAULT NULL COMMENT '卡图URL(images内所有imageUrl逗号拼接)',
+  `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_confirmed_card_uuid` (`confirmed_card_uuid`),
+  KEY `idx_product_code` (`product_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-拆卡报告(开出的卡+中卡用户+卡图)';
+
+-- ---------- 在售商品提醒监控(onsale_alert_spider.py 用) ----------
+-- 监控指定商家在售商品:新商品上架提醒 + 进度过半提醒;两个 notified 标记位用于去重防重复提醒
+CREATE TABLE `deca_onsale_alert_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(接口 code)',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称',
+  `title` varchar(1000) DEFAULT NULL COMMENT '商品标题',
+  `unit_price` decimal(12,2) DEFAULT NULL COMMENT '单价(unitPrice)',
+  `card_count` int DEFAULT NULL COMMENT '总份数(cardCount)',
+  `sold_count` int DEFAULT NULL COMMENT '已售份数(soldCount,动态)',
+  `progress` decimal(5,2) DEFAULT NULL COMMENT '售卖进度百分比(sold/card*100)',
+  `available_stock` int DEFAULT NULL COMMENT '剩余库存(availableStock)',
+  `groupbuy_status_name` varchar(32) DEFAULT NULL COMMENT '拼团状态名(groupbuyStatusName)',
+  `share_code` varchar(512) DEFAULT NULL COMMENT '分享码(shareResp.shareCode,已urlencode,拼分享URL用)',
+  `new_notified` tinyint DEFAULT '0' COMMENT '新品上架提醒已发 0未发/1已发',
+  `half_notified` tinyint DEFAULT '0' COMMENT '进度过半提醒已发 0未发/1已发',
+  `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_product_code` (`product_code`),
+  KEY `idx_merchant_user_id` (`merchant_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-在售商品提醒监控(新品上架/进度过半)';
+
+-- ---------- 商品购买记录累积(buy_record_analysis/test_buy_record.py 用) ----------
+-- 详情页 purchaseRecords 只返回最近 10 条,靠周期轮询累积买家名单
+-- 「订单粒度」去重:同 user 对同商品可下多笔相同份数的单(如王**要 x10 两笔间隔2分钟),需区分。
+-- 唯一键含 purchased_at_ts 精确到秒作 DB 兜底;主判重在应用层:反推时间戳±70s内视为同一笔(相对时间漂移)、否则新订单。
+CREATE TABLE `deca_buy_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(接口 code,如 GB26080417234)',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID(冗余,便于查看)',
+  `merchant_name` varchar(128) DEFAULT NULL COMMENT '商家名称(冗余,便于查看)',
+  `title` varchar(1000) DEFAULT NULL COMMENT '商品标题(冗余,便于查看)',
+  `user_id` varchar(32) NOT NULL COMMENT '买家用户ID(purchaseRecords.userId)',
+  `nickname` varchar(64) DEFAULT NULL COMMENT '买家昵称(脱敏,如 D**l)',
+  `card_count` int DEFAULT NULL COMMENT '本次购买份数(cardCount)',
+  `purchased_at_text` varchar(32) DEFAULT NULL COMMENT '首次抓到时的相对时间原文(如 1分钟前)',
+  `purchased_at_ts` bigint DEFAULT NULL COMMENT '反推的绝对购买时间戳(秒),去重键之一',
+  `purchased_at` datetime DEFAULT NULL COMMENT '反推的绝对购买时间',
+  `first_seen_at` datetime 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_buy` (`product_code`,`user_id`,`card_count`,`purchased_at_ts`),
+  KEY `idx_product_code` (`product_code`),
+  KEY `idx_user_id` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-商品购买记录(详情页purchaseRecords周期累积)';
+
+-- ============================================================
+-- 2026/08/11 新增:随机团(选队随机/剩余随机) teams 明细
+-- 背景:选队随机每队价格不同,team-options 一旦转成剩余随机就永久 29000,
+--       原始 cardCount 拿不到;必须在选队阶段持续抓 team-options 存下来,
+--       转剩余随机后才能算出「原始总价 - 当前剩余货值」。
+-- 存储策略:
+--   - 在售团(data_source=team_options):每轮覆盖成最新一份(同 product_code+team_id
+--     直接更新,captured_at 记录采集时刻);
+--   - 已售团 或 剩余随机快照(data_source=snapshot):存完成时/首次采到时的最终一份。
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `deca_groupbuy_team_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码(接口 code)',
+  `play_type_name` varchar(64) DEFAULT NULL COMMENT '采集时状态:选队随机-.../剩余随机',
+  `data_source` varchar(16) NOT NULL COMMENT '数据来源:team_options(选队阶段) / snapshot(剩余随机冻结快照)',
+  `team_id` int NOT NULL COMMENT '队ID(teamId)',
+  `team_name_en` varchar(64) DEFAULT NULL COMMENT '队英文名(teamNameEn)',
+  `team_name_zh` varchar(64) DEFAULT NULL COMMENT '队中文名(teamNameZh)',
+  `team_logo_image_url` varchar(512) DEFAULT NULL COMMENT '队 logo URL',
+  `unit_price` decimal(12,2) DEFAULT NULL COMMENT '该队单价(unitPrice)',
+  `card_count` int DEFAULT NULL COMMENT '该队原始总份(cardCount,仅选队阶段有;剩余快照无→NULL)',
+  `available_stock` int DEFAULT NULL COMMENT '剩余份数(选队=当前剩余;剩余快照=冻结在转换点的剩余)',
+  `sold_count` int DEFAULT NULL COMMENT '已售份数(选队=card_count-available_stock;剩余快照逐队算不出→NULL)',
+  `snapshot_total_quantity` int DEFAULT NULL COMMENT '剩余随机快照 totalQuantity(仅 snapshot 时有)',
+  `captured_at` datetime NOT 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_product_team` (`product_code`,`team_id`,`data_source`),
+  KEY `idx_product_code` (`product_code`),
+  KEY `idx_captured_at` (`captured_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-拼团 teams 明细(随机团逐队价格/份数,含选队实时与剩余随机冻结快照)';
+
+-- 两张商品表加「团总价」列,报告读它优先(NULL 则回落到 unit_price*sold_count)
+-- 语义:随机团(选队随机/剩余随机) 用 teams 表按状态算出的总价;固定价团保持 NULL(报告端回落原公式)
+-- 首次执行请手动跑(MySQL 不支持 ADD COLUMN IF NOT EXISTS,已加过会报 1060 忽略即可):
+ALTER TABLE `deca_onsale_product_record` ADD COLUMN `team_total_amount` decimal(14,2) DEFAULT NULL COMMENT '按 teams 算的总价(随机团用;NULL=固定价团或数据缺失)' AFTER `checklist_card_count`;
+ALTER TABLE `deca_product_record`        ADD COLUMN `team_total_amount` decimal(14,2) DEFAULT NULL COMMENT '按 teams 算的总价(随机团用;NULL=固定价团或数据缺失)' AFTER `checklist_card_count`;
+
+-- ============================================================
+-- 2026/08/11 新增:全站在售商品进度时间序列(append-only)
+-- 用途:后续统计"什么时候卖了多少百分比"——售卖速度、进度曲线、时段热度。
+-- 触发:buy_record_spider 每 60 秒 ingest_onsale 之后,对比该商品上一次的
+--       sold_count/available_stock,**变化才 INSERT**(避免没卖动的商品重复占位)。
+-- 与 deca_onsale_product_daily_record 的区别:daily 是每日单点、日粒度;本表是
+-- 变化时点、分钟级细粒度,适合画曲线。
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `deca_onsale_product_progress_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `product_code` varchar(32) NOT NULL COMMENT '商品编码',
+  `merchant_user_id` varchar(32) DEFAULT NULL COMMENT '所属商家用户ID(冗余便于查询)',
+  `sold_count` int DEFAULT NULL COMMENT '当时已售份数',
+  `available_stock` int DEFAULT NULL COMMENT '当时剩余库存',
+  `card_count` int DEFAULT NULL COMMENT '当时总份数',
+  `progress_pct` decimal(6,2) DEFAULT NULL COMMENT '进度百分比(sold_count/card_count*100,冗余便于查询)',
+  `unit_price` decimal(12,2) DEFAULT NULL COMMENT '当时单价(用于快速估算金额;随机团另见 team_total_amount)',
+  `captured_at` datetime NOT NULL COMMENT '采集时刻',
+  `gmt_create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_code_time` (`product_code`,`captured_at`),
+  KEY `idx_captured_at` (`captured_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='得卡-在售商品进度时间序列(变化才写,用于售卖速度/进度曲线统计)';

+ 62 - 0
deca_spider/sold_daily_spider.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""得卡 DECA 已售流程——每日新增增量采集(常驻定时)。
+
+完整管道,增量模式(每商家历史成交连续无新页即早停,只采新成交):
+    商家列表 → 每商家已售(增量) → 详情补抓 → 卡密清单 → 拆卡报告 + 视频回放。
+拆卡报告/回放靠 report_state/replay_state 驱动,拿不到(还没生成)下轮重采。
+
+从根目录运行:python sold_daily_spider.py(每天 03:00 一次)
+"""
+import sys
+import time
+
+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
+
+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")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
+def main_task(log):
+    """已售每日增量采集(连续无新页早停,incremental=True)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name} 已售每日增量采集" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    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 schedule_task():
+    """定时任务入口:每天 03:00 增量采集一次。"""
+    main_task(log=logger)  # 立即跑一次(调试时取消注释)
+
+    schedule.every().day.at("08:00").do(main_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 51 - 0
deca_spider/sold_history_spider.py

@@ -0,0 +1,51 @@
+# -*- 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
+
+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)
+    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)

+ 98 - 0
deca_spider/stats/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_spider/stats/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}

+ 1330 - 0
deca_spider/stats/daily_report.py

@@ -0,0 +1,1330 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/05
+"""得卡 DECA · 已售每日统计报告(Excel 多 Sheet,每一大项一个 sheet)。
+
+统计范围:成交完成时间 completed_at 落在 [昨天 17:00:00, 今天 06:00:00](含两端)的已售商品。
+一个拼团商品 = 一个「组队」(组队售卖),故「每条明细」= 每个拼团商品。
+
+报告结构(2026/08/14 由单 Sheet 分区改为多 Sheet,每 sheet 独立列宽、蓝条只覆盖本表宽度):
+    Sheet 平台总览 :平台汇总 + 当日组齐环比(vs 昨日同窗口) + 商家 GMV 集中度(Top1/3/5/10) + 口径脚注
+    Sheet 产品系列榜:当日各系列 GMV 榜(Top,含占比)
+    Sheet 商家GMV榜 :当日组齐 GMV 前 N 商家(含占比)
+    Sheet 运营节奏 :重点商家当日运营快照(新开团/已组齐/规格) + 平台组齐时段分布(近7日24h)
+    Sheet 魔都明细 :881226408 汇总 + 每条明细(含「参与人数(购买记录)」与售卖进度里程碑列;
+                    汇总下附「购买记录覆盖检测」= 成交团 vs 已采购买记录,标注漏采多少 T(团))
+    Sheet 用户排行榜(魔都):881226408 买家榜(deca_buy_record 按 user_id 聚合,参与金额倒序,
+                    含 参与车数 / 参与金额 / 车均消费)(2026/08/17 新增)
+    Sheet 卡皇明细 :274584650 汇总 + 每条明细
+    Sheet 其他商家 :其余商家各一行汇总(中卡近似口径)
+    注:原「魔都已售进度检测」独立 sheet 已于 2026/08/11 并入魔都明细(尾部到 25/50/75% 用时列)。
+
+口径说明:
+    - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))
+              随机团(选队随机/剩余随机)按 teams 逐队精算(team_total_amount,2026/08/11 起,
+              见 docs/选队随机与剩余随机_总价口径与采集_20260811.md);固定价团回落原公式。
+    - 成团数 = 该时段成交的拼团商品数
+    - 参与人数(魔都汇总 & 明细口径) = deca_buy_record 去重买家 user_id(真实参团人头;仅 881226408
+                 采了购买记录)。魔都汇总「参与人数(真实买家)」= 跨其全部成交团去重(2026/08/14 起由
+                 中卡近似切为真实买家);明细「参与人数(本团)」= 各团单独去重,故明细逐团相加(人次) ≥ 汇总。
+    - 中卡用户数(近似)(平台大盘/其他商家口径) = 拆卡报告 hit_user_nickname 去重(仅覆盖 report_state=1
+                 有报告的商品;这些商家未采购买记录,只能用中卡用户近似,非真实参团人头,偏低)
+    - 均拼单价 = 销售额 / 成团数
+    - 人均消费 = 销售额 / 参与人数(魔都为真实买家;平台/其他商家为按中卡近似,偏高,仅供参考)
+    - 卡密表 deca_kami_record 当前为空(FILL_KAMI 关),故无「球队」维度,明细按商品维度出。
+
+从项目根目录运行:python stats/daily_report.py(cwd=根目录,读根目录 application.yml)
+"""
+import os
+import sys
+import time
+# 把项目根目录加入 import 路径:企微发送模块 auto_send_wx_msg.py 只在根目录留一份(WEBHOOK_URL 单点维护)
+sys.path.insert(0, os.path.dirname(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
+
+# 两个要出「汇总 + 明细」的重点商家;其余商家统一进「其他商家汇总」
+FOCUS_MERCHANTS = ["881226408", "274584650"]
+# 魔都兄弟球星卡:其每条明细走扩展版——多「参与人数」列(deca_buy_record 去重买家),
+# 尾部并入售卖进度里程碑(从 progress 表算到 25/50/75% 各用了多久;首张快照已越过阈值则留空)。仅本商家如此。
+MODDU_MID = "881226408"
+OUT_PREFIX = "得卡已售每日报告"     # 输出文件名前缀,实际文件名后缀加运行当天日期
+
+# 时间窗过滤(p 别名):[昨天17:00, 今天06:00](2026/08/15 由 03:00 延到 06:00,凌晨仍在播)
+WIN_P = ("p.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR "
+         "AND p.completed_at <= CURDATE() + INTERVAL 6 HOUR")
+
+# 明细表列规格:(表头, 明细dict取值键, 是否千分位金额格式);取值键为「序号」时按行号自增填充。
+# 开售时间/成交时间/售卖时长 三列相邻:售卖时长=成交-开售。
+DETAIL_COLS = [
+    ("序号", "序号", False), ("团名(商品标题)", "团名", False),
+    ("系列", "系列", False), ("类型", "类型", False), ("单价", "单价", True),
+    ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
+    ("中卡人数", "中卡人数", False),   # 该团拆卡报告 hit_user_nickname 去重(中卡近似),放开售时间前
+    ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
+]
+# 魔都(881226408)专属明细:在「中卡人数」前插「参与人数」(deca_buy_record 去重买家 user_id),
+# 并在尾部并入售卖进度里程碑(到 25/50/75% 用时,源 deca_onsale_product_progress_record)。
+# 原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入本明细,不再单独出 sheet。
+MODDU_DETAIL_COLS = [
+    ("序号", "序号", 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取值键)。参与人数为「魔都真实买家 + 其他商家中卡去重」
+# 的混合口径(2026/08/14 起,见 fetch_platform_summary),故标签显式标注,避免误当纯真实人头。
+PLATFORM_ROWS = [
+    ("商家数", "商家数"), ("销售额", "销售额"), ("成团数", "成团数"),
+    ("参与人数(魔都真实+其他中卡)", "参与人数"), ("均拼单价", "均拼单价"),
+    ("人均消费", "人均消费"),
+]
+
+# 其他商家汇总表列:(显示表头, dict取值键, 是否金额格式)。这些商家未采购买记录,参与人数
+# 只能用中卡用户近似,故表头标注「(近似)」,与魔都真实买家口径区分。
+OTHER_COLS = [
+    ("商家名", "商家名", False), ("销售额", "销售额", True), ("成团数", "成团数", False),
+    ("中卡用户数(近似)", "参与人数", False), ("均拼单价", "均拼单价", True),
+    ("人均消费(按中卡近似)", "人均消费", True),
+]
+
+# ---- 监测清单增强(2026/08/14):环比 / 排行 / 集中度 / 时段分布 配置 ----
+# 昨日同窗口(用于组齐环比):[前天17:00, 昨天06:00],与 WIN_P 整体平移一天、口径一致
+WIN_P_YDAY = ("p.completed_at >= (CURDATE() - INTERVAL 2 DAY) + INTERVAL 17 HOUR "
+              "AND p.completed_at <= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 6 HOUR")
+TOP_SERIES = 15              # 产品系列销售榜展示条数
+TOP_MERCHANT = 10            # 商家 GMV 榜展示条数(监测清单要「GMV前十商家」)
+TOP_USERS = None              # 魔都用户排行榜展示条数(按参与金额倒序取前 N;仅魔都采了购买记录)
+CONC_TOPS = (1, 3, 5, 10)    # GMV 集中度统计的 TopN 档(Top1/3/5/10 占平台总 GMV)
+HOUR_DIST_DAYS = 7           # 组齐时段分布回看天数(反映平台 24h 组齐节奏)
+
+# ---- 样式 ----
+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")      # 明细隔行:淡蓝灰
+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 17 HOUR, "
+        "CURDATE() + INTERVAL 6 HOUR")[0]
+    return str(row[0]), str(row[1])
+
+
+def fetch_platform_summary(pool) -> dict:
+    """统计平台大盘汇总(时间窗内全部已售商品)。
+
+    参与人数为混合口径(2026/08/14 起):魔都(881226408)采了真实购买记录,用 deca_buy_record
+    去重真实买家;其余商家未采购买记录,仍用拆卡报告 hit_user_nickname 去重的中卡用户近似。
+    两部分人群标识不同(魔都=user_id,其他=昵称)、无法跨口径去重,故直接相加,属近似上界。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: 含 销售额/商家数/成团数/参与人数/均拼单价/人均消费 六项。
+    """
+    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 <> %s
+            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 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, (MODDU_MID,))[0]
+    # 平台参与人数 = 魔都真实买家(deca_buy_record 去重) + 其他商家中卡用户去重
+    people = _fetch_real_buyers(pool, MODDU_MID) + (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 17 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)
+    # 魔都(881226408)采了真实购买记录:参与人数改用 deca_buy_record 去重真实买家,人均消费随之
+    # 按真实人头计(覆盖上面 people 的中卡近似值);其余重点商家无购买记录,仍沿用中卡近似。
+    if mid == MODDU_MID:
+        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.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, 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, "类型": 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 17 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 去重真实买家数(跨其全部成交团)。
+
+    仅魔都(881226408)采了真实购买记录,故只有它能用此口径;其余商家该表无数据、返回 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 17 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)时间窗内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    在标准明细基础上多两类字段(原「魔都已售进度检测」独立 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.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, 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 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 累加。
+        - 车均消费 = 参与金额 ÷ 参与车数。
+    仅魔都(881226408)采了购买记录,故只有它能出此榜。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+        top_n (int): 榜单展示上限(取金额倒序前 N)。
+
+    Returns:
+        tuple[list[dict], int]: (rows, total_users);rows 每项含 用户昵称/user_id/参与车数/
+            参与金额/车均消费(已按金额倒序并截断到 top_n);total_users 为窗口内全部参与
+            买家数(供榜单标题展示、判断截断了多少)。
+    """
+    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_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,)) or []
+    result = []
+    for uid, nickname, cars, amount 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,  # 车均=参与金额/参与车数
+        })
+    return result, len(rows)
+
+
+def fetch_moddu_missing_teams(pool, mid: str) -> dict:
+    """对比「魔都」成交明细与购买记录覆盖,算出漏采购买记录的 T(团)。
+
+    魔都明细每条 = 一个成交拼团商品(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):
+    """给某一行的前 ncol 个单元格套字体/填充/边框。
+
+    Args:
+        ws: openpyxl worksheet。
+        row_idx (int): 行号(1-based)。
+        ncol (int): 要处理的列数(从第 1 列起)。
+        font (Font, optional): 字体。Defaults to None。
+        fill (PatternFill, optional): 填充。Defaults to None。
+        border (bool, optional): 是否加边框。Defaults to True。
+    """
+    for c in range(1, ncol + 1):
+        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) -> int:
+    """在第 r 行写一行分区标题条(蓝底白字),蓝条只覆盖 span 列(贴合本表宽度、右侧不留空)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        text (str): 标题文字。
+        span (int, optional): 蓝条覆盖列数。Defaults to SECTION_SPAN。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    ws.cell(row=r, column=1, value=text)
+    _style_row(ws, r, span, font=FONT_SECTION, fill=FILL_SECTION, border=False)
+    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 fetch_series_ranking(pool, top_n: int) -> tuple[list, float]:
+    """取当日窗口内各产品系列的销售榜(按 GMV 倒序)及全窗口总 GMV(算占比用)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        top_n (int): 取前 N 个系列。
+
+    Returns:
+        tuple[list, float]: (rows, total_gmv);rows 每项 (系列名, 成团数, GMV)。
+    """
+    sql = f"""
+    SELECT COALESCE(NULLIF(p.series_name, ''), '(未标系列)')        AS series,
+           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 series
+    ORDER BY gmv DESC
+    """
+    rows = pool.select_all(sql) or []
+    total = sum(float(r[2]) for r in rows if r[2] is not None)   # 全部系列合计(算占比分母)
+    return rows[:top_n], 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 落在报告时间窗 [昨17: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 落在[昨17: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 17 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_series_sheet(ws, series_rows: list, series_total: float):
+    """构建「产品系列榜」sheet:当日各系列 GMV 榜 + 占比。
+
+    Args:
+        ws: openpyxl worksheet。
+        series_rows (list): [(系列名, 成团数, GMV)]。
+        series_total (float): 全窗口总 GMV(算占比分母)。
+    """
+    _set_widths(ws, [36, 10, 16, 10])
+    r = _write_section_title(ws, 1, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)", span=4)
+    srows = [[name, int(g), float(gmv) if gmv is not None else 0,
+              (float(gmv) / series_total if (series_total and gmv is not None) else None)]
+             for name, g, gmv in series_rows]
+    _write_hgrid(ws, r, ["系列", "成团数", "GMV", "占比"], srows,
+                 money_cols=(2,), pct_cols=(3,), start_col=1)
+    ws.freeze_panes = "A3"   # 冻结标题条 + 表头
+
+
+def _build_mrank_sheet(ws, mrank_rows: list, mrank_total: float):
+    """构建「商家GMV榜」sheet:当日组齐 GMV 前 N 商家 + 占比。
+
+    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 榜(当日组齐口径,前 {TOP_MERCHANT})", 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[:TOP_MERCHANT]]
+    _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):
+    """构建单个重点商家的明细 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。
+    """
+    _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)
+        cov = (f"成交 {miss_info['成交团数']} 团 · 采到购买记录 {miss_info['有记录团数']} 团 · "
+               f"漏采 {miss_info['漏采团数']} 团(用户排行见「用户排行榜(魔都)」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
+            for pc, t in miss_info["漏采团列表"]:
+                ws.cell(row=r, column=1, value=f" - {pc} {t or ''}").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):
+    """构建「用户排行榜(魔都)」sheet:按参与金额倒序的买家榜(参与车数/参与金额/车均消费)。
+
+    Args:
+        ws: openpyxl worksheet。
+        rows (list[dict]): 用户排行数据(fetch_moddu_user_ranking 返回,已倒序截断)。
+        total_users (int): 窗口内魔都全部参与买家数(用于标题展示)。
+        top_n (int): 榜单展示上限(用于标题展示)。
+    """
+    _set_widths(ws, [8, 22, 16, 12, 16, 14])
+    cap = "全部展示" if top_n is None else f"取前 {min(len(rows), top_n)}"
+    r = _write_section_title(
+        ws, 1, f"用户排行榜 · 魔都(共 {total_users} 人参与,{cap},按参与金额倒序)", span=6)
+    grid = [[i + 1, d["用户昵称"], d["user_id"], d["参与车数"], d["参与金额"], d["车均消费"]]
+            for i, d in enumerate(rows)]
+    r = _write_hgrid(ws, r, ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"],
+                     grid, money_cols=(4, 5), start_col=1, empty_hint="(该时段无购买记录)")
+    r += 1
+    ws.cell(row=r, column=1,
+            value="注:参与金额 = Σ(购买份数 × 团单价)(deca_buy_record 无金额字段,按份数×单价估算;"
+                  "固定价团精确、随机团按标称单价近似);参与车数=参与的不同团数;"
+                  "车均消费 = 参与金额 ÷ 参与车数。").font = FONT_CELL
+    ws.freeze_panes = "A3"   # 冻结标题条 + 表头
+
+
+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, 11, 13, 9, 8, 14, 14, 19, 19, 12, 11, 11, 11]  # 15 列(含里程碑)
+DETAIL_WIDTHS_STD = [8, 48, 16, 11, 13, 9, 8, 14, 19, 19, 12]                    # 11 列(标准)
+# 重点商家 ID → 明细 sheet 名(其余走商家名兜底)
+DETAIL_SHEET_NAMES = {"881226408": "魔都明细", "274584650": "卡皇明细"}
+
+
+def build_report(pool, out: str):
+    """汇总各段数据并生成多 Sheet Excel 报告(每一大项一个 sheet,各自独立列宽)。
+
+    Sheet 顺序:平台总览 / 产品系列榜 / 商家GMV榜 / 运营节奏 / 魔都明细 / 用户排行榜(魔都) /
+        卡皇明细 / 其他商家。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+    """
+    win = get_window(pool)
+    platform = fetch_platform_summary(pool)
+    compare = fetch_groupbuy_compare(pool)                       # 当日 vs 昨日组齐环比
+    series_rows, series_total = fetch_series_ranking(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 分布
+    # 魔都(MODDU_MID)明细走扩展版(带参与人数 + 进度里程碑),其余重点商家走标准明细
+    focus = []
+    for mid in FOCUS_MERCHANTS:
+        summ = fetch_merchant_summary(pool, mid)
+        if mid == MODDU_MID:
+            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)
+    moddu_user_rank, moddu_user_total = fetch_moddu_user_ranking(pool, MODDU_MID, TOP_USERS)
+    moddu_miss = fetch_moddu_missing_teams(pool, MODDU_MID)
+
+    # 口径脚注(放平台总览底部;解释两种「参与人数」口径的差别)
+    notes = [
+        "注:① 魔都兄弟球星卡「参与人数(真实买家)」= deca_buy_record 真实购买记录去重买家(跨其全部成交团);"
+        "各商家明细「参与人数(本团)」为各团单独去重买家,故明细逐团相加(人次) ≥ 汇总(跨团去重人头)。",
+        "  ② 平台大盘参与人数 = 魔都真实买家 + 其他商家中卡去重(两口径人群标识不同、无法跨口径去重,直接相加,属近似上界)。",
+        "  ③ 其他商家未采购买记录,「中卡用户数(近似)」= 拆卡报告 hit_user_nickname 去重(仅报告命中/中卡用户,"
+        "非真实参团人头,偏低),其「人均消费(按中卡近似)」据此计算、偏高,仅供参考。",
+    ]
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "平台总览"
+    _build_overview_sheet(ws, win, platform, compare, mrank_conc, notes)
+    _build_series_sheet(wb.create_sheet("产品系列榜"), 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(各自独立列宽,互不迁就)
+    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 == MODDU_MID)
+        widths = DETAIL_WIDTHS_MODDU if is_real else DETAIL_WIDTHS_STD
+        # 魔都明细尾部附「购买记录覆盖检测」(漏采团数);其余商家无购买记录、不检测
+        miss = moddu_miss if mid == MODDU_MID else None
+        _build_detail_sheet(ws_d, f"{summ['商家名']} · 汇总(成交时间窗 {win[0]} ~ {win[1]})",
+                            summ, details, cols, is_real, len(cols), widths, miss_info=miss)
+        # 魔都明细后紧跟「用户排行榜(魔都)」sheet,让魔都相关表相邻
+        if mid == MODDU_MID:
+            _build_user_ranking_sheet(wb.create_sheet("用户排行榜(魔都)"),
+                                      moddu_user_rank, moddu_user_total, TOP_USERS)
+    _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()

+ 69 - 0
deca_spider/stats/deca_wechat.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/02
+"""通过 PC 版微信(wxauto4)把文件/图片发送给指定好友或群。
+
+前置条件:
+    1. 已装 wxauto4:pip install wxauto4
+       (原 wxauto 已从 PyPI 下架;wxauto4 对应微信 4.0 PC 客户端,免费,支持 Python 3.9~3.13)
+    2. Windows PC 版微信(4.0+) 已登录,窗口未最小化(wxauto4 靠 UI 自动化操作微信窗口,
+       运行期间尽量别抢占鼠标/键盘)。
+    3. 目标好友/群的备注名或昵称须与传入的 who 完全一致(测试用 "backup")。
+
+用法:
+    - 作为模块:from deca_wechat import send_files; send_files("xx.png", who="backup")
+    - 命令行:  python deca_wechat.py <文件路径> [好友名(默认backup)]
+"""
+import sys
+
+from loguru import logger
+
+# 目标匹配是否精确:True=按 who 精确匹配备注/昵称(推荐,避免误发给名字相近的人)
+EXACT_MATCH = True
+
+
+def send_files(paths, who: str = "得卡-通知") -> bool:
+    """发送一个或多个文件给微信好友/群。
+
+    Args:
+        paths (str | list[str]): 单个文件路径或路径列表。
+        who (str, optional): 目标好友备注名/昵称或群名。Defaults to "backup"。
+
+    Returns:
+        bool: 全部发送成功返回 True;库缺失、连不上微信或发送异常返回 False。
+    """
+    try:
+        from wxauto4 import WeChat
+    except ImportError:
+        logger.error("未安装 wxauto4,请执行: pip install wxauto4")
+        return False
+
+    if isinstance(paths, str):
+        paths = [paths]
+
+    try:
+        wx = WeChat()  # 绑定当前已登录的 PC 微信(未登录/窗口不可见会失败)
+    except Exception as e:
+        logger.error(f"连接 PC 微信失败(确认微信4.0已登录且窗口可见): {e}")
+        return False
+
+    ok = True
+    for p in paths:
+        try:
+            resp = wx.SendFiles(str(p), who=who, exact=EXACT_MATCH)
+            logger.info(f"微信已发送: {p} -> {who} | 返回: {resp}")
+        except Exception as e:
+            ok = False
+            logger.error(f"微信发送失败 {p} -> {who}: {e}")
+    return ok
+
+
+if __name__ == "__main__":
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    if len(sys.argv) < 2:
+        print("用法: python deca_wechat.py <文件路径> [好友名(默认backup)]")
+        sys.exit(1)
+    target = sys.argv[2] if len(sys.argv) > 2 else "backup"
+    send_files(sys.argv[1], target)

+ 94 - 0
deca_spider/stats/export_teams_excel.py

@@ -0,0 +1,94 @@
+# -*- 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
+
+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()

+ 362 - 0
deca_spider/stats/moddu_miss_capture_report.py

@@ -0,0 +1,362 @@
+# -*- 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
+
+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()

+ 150 - 0
deca_spider/stats/moddu_user_total_report.py

@@ -0,0 +1,150 @@
+# -*- 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
+
+# 脚本所在目录(stats)加入 import 路径:复用同目录 daily_report 的样式与辅助函数
+sys.path.insert(0, 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()

+ 671 - 0
deca_spider/stats/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)

+ 109 - 0
deca_spider/stats/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;

+ 1 - 0
deca_spider/token.json

@@ -0,0 +1 @@
+{"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiI1NDIwOTg5MzQiLCJwbHQiOjEsInV0IjoxLCJwb3J0YWwiOiJhcHAiLCJleHAiOjE3ODcwMTIxMDAsImlhdCI6MTc4NzAxMTIwMH0.cPBmW56f_qgqZVdyMg47WBEbSwZ7Q2GYETU6-fMsl44", "refresh": "8a8c9a4360e8d843b7805c2eb6a3efea2e61c92e386e890a86ecd936aed6c445", "exp": 1787012100.789561}