Browse Source

Merge branch 'feature_20260707_MARK_add_aliyun_log' of Server/long_articles_agentic_supply into master

luojunhui 4 days ago
parent
commit
07cf1790eb
32 changed files with 822 additions and 87 deletions
  1. 36 0
      src/config/logging_config.py
  2. 9 3
      src/domains/content_quality/_const.py
  3. 20 21
      src/domains/content_quality/_utils.py
  4. 21 2
      src/domains/content_quality/article_quality_category.py
  5. 28 1
      src/domains/content_quality/article_quality_quantify.py
  6. 23 4
      src/domains/content_quality/article_quality_title.py
  7. 2 2
      src/domains/content_supply/_utils.py
  8. 18 1
      src/domains/content_supply/account_article_fetch.py
  9. 18 1
      src/domains/content_supply/account_article_update.py
  10. 13 1
      src/domains/content_supply/account_fetch.py
  11. 13 2
      src/domains/content_supply/article_fetch_detail.py
  12. 29 3
      src/domains/content_supply/article_search.py
  13. 17 2
      src/domains/demand_enqueue_task/fetch_demands.py
  14. 251 0
      src/handlers/_logging.py
  15. 7 3
      src/handlers/account_article_fetch.py
  16. 7 3
      src/handlers/account_article_update.py
  17. 7 3
      src/handlers/account_fetch.py
  18. 7 3
      src/handlers/article_fetch_detail.py
  19. 7 3
      src/handlers/article_quality_category.py
  20. 7 3
      src/handlers/article_quality_quantify.py
  21. 7 3
      src/handlers/article_quality_title.py
  22. 7 3
      src/handlers/article_search.py
  23. 17 5
      src/handlers/demand_enqueue.py
  24. 7 2
      src/handlers/test_hello_world.py
  25. 2 0
      src/infra/__init__.py
  26. 7 1
      src/infra/external/volcengine.py
  27. 12 1
      src/infra/observability/__init__.py
  28. 45 0
      src/infra/observability/context.py
  29. 32 0
      src/infra/observability/log_schema.py
  30. 22 5
      src/infra/spider/wechat/gzh.py
  31. 122 6
      src/infra/xxl_jobs/xxl_executor.py
  32. 2 0
      src/server/app.py

+ 36 - 0
src/config/logging_config.py

@@ -52,6 +52,14 @@ LOGGING_CONFIG: dict = {
             "formatter": "default",
             "formatter": "default",
             "encoding": "utf-8",
             "encoding": "utf-8",
         },
         },
+        "handler_quality": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "filename": _log("handler_quality"),
+            "maxBytes": 10 * 1024 * 1024,
+            "backupCount": 3,
+            "formatter": "default",
+            "encoding": "utf-8",
+        },
         "domain_supply": {
         "domain_supply": {
             "class": "logging.handlers.RotatingFileHandler",
             "class": "logging.handlers.RotatingFileHandler",
             "filename": _log("domain_content_supply"),
             "filename": _log("domain_content_supply"),
@@ -68,6 +76,14 @@ LOGGING_CONFIG: dict = {
             "formatter": "default",
             "formatter": "default",
             "encoding": "utf-8",
             "encoding": "utf-8",
         },
         },
+        "domain_quality": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "filename": _log("domain_content_quality"),
+            "maxBytes": 10 * 1024 * 1024,
+            "backupCount": 3,
+            "formatter": "default",
+            "encoding": "utf-8",
+        },
         "infra": {
         "infra": {
             "class": "logging.handlers.RotatingFileHandler",
             "class": "logging.handlers.RotatingFileHandler",
             "filename": _log("infra"),
             "filename": _log("infra"),
@@ -122,6 +138,21 @@ LOGGING_CONFIG: dict = {
             "level": "INFO",
             "level": "INFO",
             "propagate": False,
             "propagate": False,
         },
         },
+        "src.handlers.article_quality_category": {
+            "handlers": ["handler_quality", "console"],
+            "level": "INFO",
+            "propagate": False,
+        },
+        "src.handlers.article_quality_quantify": {
+            "handlers": ["handler_quality", "console"],
+            "level": "INFO",
+            "propagate": False,
+        },
+        "src.handlers.article_quality_title": {
+            "handlers": ["handler_quality", "console"],
+            "level": "INFO",
+            "propagate": False,
+        },
         # domain
         # domain
         "src.domains.content_supply": {
         "src.domains.content_supply": {
             "handlers": ["domain_supply", "console"],
             "handlers": ["domain_supply", "console"],
@@ -133,6 +164,11 @@ LOGGING_CONFIG: dict = {
             "level": "INFO",
             "level": "INFO",
             "propagate": False,
             "propagate": False,
         },
         },
+        "src.domains.content_quality": {
+            "handlers": ["domain_quality", "console"],
+            "level": "INFO",
+            "propagate": False,
+        },
         # infra
         # infra
         "src.infra": {
         "src.infra": {
             "handlers": ["infra", "console"],
             "handlers": ["infra", "console"],

+ 9 - 3
src/domains/content_quality/_const.py

@@ -44,7 +44,8 @@ class ContentQualityConst:
     # LLM 参数
     # LLM 参数
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════
 
 
-    LLM_BATCH_SIZE = 20
+    LLM_BATCH_SIZE = 20  # 品类识别:ID 显式绑定,20 条安全
+    TITLE_BATCH_SIZE = 10  # 标题评分:对齐精度要求高,控制在 10 条/批
     LLM_INTERVAL_SEC = 2
     LLM_INTERVAL_SEC = 2
 
 
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════
@@ -69,8 +70,13 @@ class ContentQualityConst:
 7.阵营对立:7分
 7.阵营对立:7分
 8.军事危机暗示:5分
 8.军事危机暗示:5分
 
 
-基于以上的爆款标题特征分评分标准,如果下列标题符合某个特征的、那么就加上这个特征对应的权重,标题最终的得分是各个特征分的总和。请直接输出最终的分数,不用给任何理由和描述、解释。
-待评分的标题为:
+基于以上的爆款标题特征分评分标准,如果下列标题符合某个特征的、那么就加上这个特征对应的权重,标题最终的得分是各个特征分的总和。如果标题完全不符合任何特征,则分数为0。
+
+输入是一个 LIST,每个元素是一个元组,元组的第一个元素是标题的 ID,第二个元素是标题的文本。
+输出格式为 JSON 对象,key 为标题 id(数字),value 为评分(0-100 的整数)。
+仅输出 JSON,不要 markdown 包裹,不要任何其他内容。
+
+输入的 LIST 是:
 {title_list}"""
 {title_list}"""
 
 
     PROMPT_TITLE_CATEGORY = """
     PROMPT_TITLE_CATEGORY = """

+ 20 - 21
src/domains/content_quality/_utils.py

@@ -1,7 +1,5 @@
 """内容质量评估工具函数 —— Prompt 构建 + LLM 响应解析"""
 """内容质量评估工具函数 —— Prompt 构建 + LLM 响应解析"""
 
 
-import hashlib
-import re
 import logging
 import logging
 from typing import Dict, List
 from typing import Dict, List
 
 
@@ -12,20 +10,16 @@ from ._const import ContentQualityConst
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
-def md5(text: str) -> str:
-    return hashlib.md5(text.encode("utf-8")).hexdigest()
-
-
 # ═══════════════════════════════════════════════════════════════
 # ═══════════════════════════════════════════════════════════════
 # Prompt 构建
 # Prompt 构建
 # ═══════════════════════════════════════════════════════════════
 # ═══════════════════════════════════════════════════════════════
 
 
 
 
 def build_title_quality_prompt(titles: List[Dict]) -> str:
 def build_title_quality_prompt(titles: List[Dict]) -> str:
-    """构造标题质量评分 prompt,每行一个标题"""
-    lines = [t["title"] for t in titles]
+    """构造标题质量评分 prompt,格式: [(id, title), ...] 元组列表"""
+    tuples = [(t["id"], t["title"]) for t in titles]
     return ContentQualityConst.PROMPT_TITLE_QUALITY.format(
     return ContentQualityConst.PROMPT_TITLE_QUALITY.format(
-        title_list="\n".join(lines),
+        title_list=str(tuples),
     )
     )
 
 
 
 
@@ -46,7 +40,10 @@ def parse_title_quality_response(
     raw: str | None,
     raw: str | None,
     batch_titles: List[Dict],
     batch_titles: List[Dict],
 ) -> List[Dict]:
 ) -> List[Dict]:
-    """解析 prompt1 返回值:LLM 按行输出纯数字分数,按顺序对应输入标题
+    """解析标题质量评分返回值:{id: score} JSON 对象,key 为标题 id
+
+    与品类识别 prompt 对齐——ID 显式绑定,消除位置依赖导致的分数错位。
+    如果某条标题在 LLM 响应中缺失 key 或值非法,则静默跳过(不影响同批次其他标题)。
 
 
     返回: [{"id": 1, "score": 85}, ...]
     返回: [{"id": 1, "score": 85}, ...]
     """
     """
@@ -55,18 +52,21 @@ def parse_title_quality_response(
         logger.warning("LLM 标题质量返回为空")
         logger.warning("LLM 标题质量返回为空")
         return result
         return result
 
 
-    # 从文本中提取所有数字
-    scores = re.findall(r"\d+", raw)
-    if not scores:
-        logger.warning("LLM 标题质量未提取到分数: %s", raw[:200])
+    parsed = safe_json_parse(raw)
+    if not isinstance(parsed, dict):
+        logger.warning("LLM 标题质量返回非 JSON 对象: %s", raw[:200])
         return result
         return result
 
 
-    for i, s in enumerate(scores):
-        if i >= len(batch_titles):
-            break
-        score = int(s)
-        if 0 <= score <= 100:
-            result.append({"id": batch_titles[i]["id"], "score": score})
+    for t in batch_titles:
+        score_val = parsed.get(str(t["id"]))
+        if score_val is None:
+            continue
+        try:
+            score = int(score_val)
+            if 0 <= score <= 100:
+                result.append({"id": t["id"], "score": score})
+        except (ValueError, TypeError):
+            pass
 
 
     return result
     return result
 
 
@@ -98,7 +98,6 @@ def parse_category_response(
 
 
 
 
 __all__ = [
 __all__ = [
-    "md5",
     "build_title_quality_prompt",
     "build_title_quality_prompt",
     "build_category_prompt",
     "build_category_prompt",
     "parse_title_quality_response",
     "parse_title_quality_response",

+ 21 - 2
src/domains/content_quality/article_quality_category.py

@@ -2,7 +2,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict
+from typing import Dict, TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.external.volcengine import fetch_deepseek_completion
 from src.infra.external.volcengine import fetch_deepseek_completion
@@ -12,26 +12,37 @@ from ._const import ContentQualityConst
 from ._mapper import QualityMapper
 from ._mapper import QualityMapper
 from ._utils import build_category_prompt, parse_category_response
 from ._utils import build_category_prompt, parse_category_response
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class ArticleQualityCategory(ContentQualityConst):
 class ArticleQualityCategory(ContentQualityConst):
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = QualityMapper(pool)
         self.mapper = QualityMapper(pool)
+        self._tlog = task_logger
         self._evaluated = 0
         self._evaluated = 0
         self._lock = asyncio.Lock()
         self._lock = asyncio.Lock()
 
 
     async def deal(self, *, limit: int | None = None) -> dict:
     async def deal(self, *, limit: int | None = None) -> dict:
         _limit = limit if limit is not None else self.CATEGORY_DEAL_LIMIT
         _limit = limit if limit is not None else self.CATEGORY_DEAL_LIMIT
 
 
+        if self._tlog:
+            await self._tlog.phase_start("category", limit=_limit)
+
         # Step 0: 种子同步
         # Step 0: 种子同步
         seeded = await self.mapper.seed_categories(limit=_limit)
         seeded = await self.mapper.seed_categories(limit=_limit)
         logger.info("品类种子同步: seeded=%d", seeded)
         logger.info("品类种子同步: seeded=%d", seeded)
+        if self._tlog:
+            await self._tlog.info("phase.start", phase="category", message=f"种子同步完成: {seeded}", seeded=seeded)
 
 
         # Step 1: LLM 识别
         # Step 1: LLM 识别
         rows = await self.mapper.fetch_pending_categories(limit=_limit)
         rows = await self.mapper.fetch_pending_categories(limit=_limit)
         if not rows:
         if not rows:
             logger.info("无待识别品类")
             logger.info("无待识别品类")
+            if self._tlog:
+                await self._tlog.phase_end("category", seeded=seeded, evaluated=0)
             return {"seeded": seeded, "evaluated": 0}
             return {"seeded": seeded, "evaluated": 0}
 
 
         batches = []
         batches = []
@@ -68,6 +79,14 @@ class ArticleQualityCategory(ContentQualityConst):
             self._evaluated,
             self._evaluated,
             len(result["errors"]),
             len(result["errors"]),
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end(
+                "category",
+                seeded=seeded,
+                batches=len(batches),
+                evaluated=self._evaluated,
+                errors=len(result["errors"]),
+            )
         return {"seeded": seeded, "evaluated": self._evaluated}
         return {"seeded": seeded, "evaluated": self._evaluated}
 
 
     async def _evaluate_one_batch(self, batch: Dict) -> None:
     async def _evaluate_one_batch(self, batch: Dict) -> None:

+ 28 - 1
src/domains/content_quality/article_quality_quantify.py

@@ -1,6 +1,7 @@
 """量化评估 Service —— 前置校验 + 入库 + 量化计算"""
 """量化评估 Service —— 前置校验 + 入库 + 量化计算"""
 
 
 import logging
 import logging
+from typing import TYPE_CHECKING
 
 
 from tqdm import tqdm
 from tqdm import tqdm
 
 
@@ -9,20 +10,29 @@ from src.infra.database.mysql.manager import MysqlManager
 from ._const import ContentQualityConst
 from ._const import ContentQualityConst
 from ._mapper import QualityMapper
 from ._mapper import QualityMapper
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class ArticleQualityQuantify(ContentQualityConst):
 class ArticleQualityQuantify(ContentQualityConst):
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = QualityMapper(pool)
         self.mapper = QualityMapper(pool)
+        self._tlog = task_logger
 
 
     async def deal(self, *, limit: int | None = None) -> dict:
     async def deal(self, *, limit: int | None = None) -> dict:
         _limit = limit if limit is not None else self.QUANTIFY_DEAL_LIMIT
         _limit = limit if limit is not None else self.QUANTIFY_DEAL_LIMIT
 
 
+        if self._tlog:
+            await self._tlog.phase_start("quantify_validate", limit=_limit)
+
         # 取已完成深翻的账号,挨个处理
         # 取已完成深翻的账号,挨个处理
         account_ids = await self.mapper.fetch_crawled_account_ids()
         account_ids = await self.mapper.fetch_crawled_account_ids()
         if not account_ids:
         if not account_ids:
             logger.info("无已完成深翻的账号")
             logger.info("无已完成深翻的账号")
+            if self._tlog:
+                await self._tlog.phase_end("quantify_validate", accounts=0)
             return {
             return {
                 "validated": 0,
                 "validated": 0,
                 "skipped": 0,
                 "skipped": 0,
@@ -48,9 +58,26 @@ class ArticleQualityQuantify(ContentQualityConst):
                 pbar.update(1)
                 pbar.update(1)
                 pbar.set_postfix(inserted=total_inserted, skipped=total_skipped)
                 pbar.set_postfix(inserted=total_inserted, skipped=total_skipped)
 
 
+        if self._tlog:
+            await self._tlog.phase_end(
+                "quantify_validate",
+                validated=total_validated,
+                skipped=total_skipped,
+                inserted=total_inserted,
+                errors=total_errors,
+            )
+
+        if self._tlog:
+            await self._tlog.phase_start("quantify_compute", limit=_limit)
         c = await self._compute(limit=_limit)
         c = await self._compute(limit=_limit)
         total_computed = c["computed"]
         total_computed = c["computed"]
         total_errors += c["errors"]
         total_errors += c["errors"]
+        if self._tlog:
+            await self._tlog.phase_end(
+                "quantify_compute",
+                computed=total_computed,
+                errors=c["errors"],
+            )
 
 
         logger.info(
         logger.info(
             "量化评估完成: validated=%d skipped=%d inserted=%d computed=%d errors=%d",
             "量化评估完成: validated=%d skipped=%d inserted=%d computed=%d errors=%d",

+ 23 - 4
src/domains/content_quality/article_quality_title.py

@@ -2,7 +2,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict, List
+from typing import Dict, List, TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.external.volcengine import fetch_deepseek_completion
 from src.infra.external.volcengine import fetch_deepseek_completion
@@ -12,32 +12,43 @@ from ._const import ContentQualityConst
 from ._mapper import QualityMapper
 from ._mapper import QualityMapper
 from ._utils import build_title_quality_prompt, parse_title_quality_response
 from ._utils import build_title_quality_prompt, parse_title_quality_response
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class ArticleQualityTitle(ContentQualityConst):
 class ArticleQualityTitle(ContentQualityConst):
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = QualityMapper(pool)
         self.mapper = QualityMapper(pool)
+        self._tlog = task_logger
         self._evaluated = 0
         self._evaluated = 0
         self._lock = asyncio.Lock()
         self._lock = asyncio.Lock()
 
 
     async def deal(self, *, limit: int | None = None) -> dict:
     async def deal(self, *, limit: int | None = None) -> dict:
         _limit = limit if limit is not None else self.TITLE_DEAL_LIMIT
         _limit = limit if limit is not None else self.TITLE_DEAL_LIMIT
 
 
+        if self._tlog:
+            await self._tlog.phase_start("title_scoring", limit=_limit)
+
         # Step 0: 种子同步
         # Step 0: 种子同步
         seeded = await self.mapper.seed_titles(limit=_limit)
         seeded = await self.mapper.seed_titles(limit=_limit)
         logger.info("标题种子同步: seeded=%d", seeded)
         logger.info("标题种子同步: seeded=%d", seeded)
+        if self._tlog:
+            await self._tlog.info("phase.start", phase="title_scoring", message=f"种子同步完成: {seeded}", seeded=seeded)
 
 
         # Step 1: LLM 评分
         # Step 1: LLM 评分
         rows = await self.mapper.fetch_pending_titles(limit=_limit)
         rows = await self.mapper.fetch_pending_titles(limit=_limit)
         if not rows:
         if not rows:
             logger.info("无待评分标题")
             logger.info("无待评分标题")
+            if self._tlog:
+                await self._tlog.phase_end("title_scoring", seeded=seeded, evaluated=0)
             return {"seeded": seeded, "evaluated": 0}
             return {"seeded": seeded, "evaluated": 0}
 
 
         # 拆批
         # 拆批
         batches = []
         batches = []
-        for start in range(0, len(rows), self.LLM_BATCH_SIZE):
-            batch = rows[start : start + self.LLM_BATCH_SIZE]
+        for start in range(0, len(rows), self.TITLE_BATCH_SIZE):
+            batch = rows[start : start + self.TITLE_BATCH_SIZE]
             batch_ids = [r["id"] for r in batch]
             batch_ids = [r["id"] for r in batch]
             titles = [{"id": r["id"], "title": r["title"]} for r in batch]
             titles = [{"id": r["id"], "title": r["title"]} for r in batch]
             prompt = build_title_quality_prompt(titles)
             prompt = build_title_quality_prompt(titles)
@@ -74,6 +85,14 @@ class ArticleQualityTitle(ContentQualityConst):
             self._evaluated,
             self._evaluated,
             len(result["errors"]),
             len(result["errors"]),
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end(
+                "title_scoring",
+                seeded=seeded,
+                batches=len(batches),
+                evaluated=self._evaluated,
+                errors=len(result["errors"]),
+            )
         return {"seeded": seeded, "evaluated": self._evaluated}
         return {"seeded": seeded, "evaluated": self._evaluated}
 
 
     async def _evaluate_one_batch(self, batch: Dict) -> None:
     async def _evaluate_one_batch(self, batch: Dict) -> None:

+ 2 - 2
src/domains/content_supply/_utils.py

@@ -1,12 +1,12 @@
 """供给域工具函数 —— API 响应解析、数据转换、搜索/账号/文章通用工具"""
 """供给域工具函数 —— API 响应解析、数据转换、搜索/账号/文章通用工具"""
 
 
-import hashlib
 import logging
 import logging
 import re
 import re
 from datetime import datetime
 from datetime import datetime
 from typing import Dict, List, Tuple
 from typing import Dict, List, Tuple
 from urllib.parse import parse_qs, urlparse
 from urllib.parse import parse_qs, urlparse
 
 
+from src.infra.shared.tools import str_to_md5
 from src.infra.spider.wechat.gzh import get_article_list_from_account
 from src.infra.spider.wechat.gzh import get_article_list_from_account
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -94,7 +94,7 @@ def parse_search_result(
         "wx_sn": extract_wx_sn(article_url) or "",
         "wx_sn": extract_wx_sn(article_url) or "",
         "biz": extract_biz(article_url) or "",
         "biz": extract_biz(article_url) or "",
         "title": title,
         "title": title,
-        "title_md5": hashlib.md5(title.encode("utf-8")).hexdigest() if title else "",
+        "title_md5": str_to_md5(title) if title else "",
         "cover_url": article.get("cover_url", ""),
         "cover_url": article.get("cover_url", ""),
         "publish_time": publish_time,
         "publish_time": publish_time,
     }
     }

+ 18 - 1
src/domains/content_supply/account_article_fetch.py

@@ -12,6 +12,7 @@
 import asyncio
 import asyncio
 import logging
 import logging
 import time
 import time
+from typing import TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.shared.async_tasks import run_tasks_with_async_worker_group
 from src.infra.shared.async_tasks import run_tasks_with_async_worker_group
@@ -20,14 +21,18 @@ from ._const import ContentSupplyConst
 from ._mapper import AccountMapper
 from ._mapper import AccountMapper
 from ._utils import fetch_article_page
 from ._utils import fetch_article_page
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class AccountArticleFetch(ContentSupplyConst):
 class AccountArticleFetch(ContentSupplyConst):
     """从 accounts 取 gh_id → 调文章列表 API → 写入 articles 表"""
     """从 accounts 取 gh_id → 调文章列表 API → 写入 articles 表"""
 
 
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = AccountMapper(pool)
         self.mapper = AccountMapper(pool)
+        self._tlog = task_logger
 
 
     async def deal(self, *, limit: int = 10) -> dict:
     async def deal(self, *, limit: int = 10) -> dict:
         """处理 N 个账号的文章抓取
         """处理 N 个账号的文章抓取
@@ -35,9 +40,14 @@ class AccountArticleFetch(ContentSupplyConst):
         Returns:
         Returns:
             {"accounts": int, "articles_written": int, "errors": int}
             {"accounts": int, "articles_written": int, "errors": int}
         """
         """
+        if self._tlog:
+            await self._tlog.phase_start("article_fetch", limit=limit)
+
         accounts = await self.mapper.list_accounts_for_article_crawl(limit=limit)
         accounts = await self.mapper.list_accounts_for_article_crawl(limit=limit)
         if not accounts:
         if not accounts:
             logger.info("无待抓取文章的账号")
             logger.info("无待抓取文章的账号")
+            if self._tlog:
+                await self._tlog.phase_end("article_fetch", accounts=0)
             return {"accounts": 0, "articles_written": 0, "errors": 0}
             return {"accounts": 0, "articles_written": 0, "errors": 0}
 
 
         total_articles = 0
         total_articles = 0
@@ -95,6 +105,13 @@ class AccountArticleFetch(ContentSupplyConst):
             total_articles,
             total_articles,
             errors_count,
             errors_count,
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end(
+                "article_fetch",
+                accounts=len(accounts),
+                articles_written=total_articles,
+                errors=errors_count,
+            )
         return {
         return {
             "accounts": len(accounts),
             "accounts": len(accounts),
             "articles_written": total_articles,
             "articles_written": total_articles,

+ 18 - 1
src/domains/content_supply/account_article_update.py

@@ -9,6 +9,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+from typing import TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 
 
@@ -16,14 +17,18 @@ from ._const import ContentSupplyConst
 from ._mapper import AccountMapper
 from ._mapper import AccountMapper
 from ._utils import fetch_article_page
 from ._utils import fetch_article_page
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class AccountArticleUpdate(ContentSupplyConst):
 class AccountArticleUpdate(ContentSupplyConst):
     """取已完成深翻的账号 → 拉第一页文章 → 按 wx_sn 更新指标"""
     """取已完成深翻的账号 → 拉第一页文章 → 按 wx_sn 更新指标"""
 
 
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = AccountMapper(pool)
         self.mapper = AccountMapper(pool)
+        self._tlog = task_logger
 
 
     async def deal(self, *, limit: int = 10) -> dict:
     async def deal(self, *, limit: int = 10) -> dict:
         """处理 N 个账号的文章指标更新
         """处理 N 个账号的文章指标更新
@@ -31,9 +36,14 @@ class AccountArticleUpdate(ContentSupplyConst):
         Returns:
         Returns:
             {"accounts": int, "articles_updated": int, "errors": int}
             {"accounts": int, "articles_updated": int, "errors": int}
         """
         """
+        if self._tlog:
+            await self._tlog.phase_start("article_update", limit=limit)
+
         accounts = await self.mapper.list_accounts_for_article_update(limit=limit)
         accounts = await self.mapper.list_accounts_for_article_update(limit=limit)
         if not accounts:
         if not accounts:
             logger.info("无待更新文章指标的账号")
             logger.info("无待更新文章指标的账号")
+            if self._tlog:
+                await self._tlog.phase_end("article_update", accounts=0)
             return {"accounts": 0, "articles_updated": 0, "errors": 0}
             return {"accounts": 0, "articles_updated": 0, "errors": 0}
 
 
         total_updated = 0
         total_updated = 0
@@ -61,6 +71,13 @@ class AccountArticleUpdate(ContentSupplyConst):
             total_updated,
             total_updated,
             errors,
             errors,
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end(
+                "article_update",
+                accounts=len(accounts),
+                articles_updated=total_updated,
+                errors=errors,
+            )
         return {
         return {
             "accounts": len(accounts),
             "accounts": len(accounts),
             "articles_updated": total_updated,
             "articles_updated": total_updated,

+ 13 - 1
src/domains/content_supply/account_fetch.py

@@ -8,6 +8,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+from typing import TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.spider.wechat.gzh import get_account_from_url
 from src.infra.spider.wechat.gzh import get_account_from_url
@@ -16,14 +17,18 @@ from ._const import ContentSupplyConst
 from ._mapper import AccountMapper
 from ._mapper import AccountMapper
 from ._utils import parse_account_response
 from ._utils import parse_account_response
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class AccountFetch(ContentSupplyConst):
 class AccountFetch(ContentSupplyConst):
     """从 relation 取待抓 biz → 查重 → 调账号 API → 写入 accounts 表"""
     """从 relation 取待抓 biz → 查重 → 调账号 API → 写入 accounts 表"""
 
 
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.mapper = AccountMapper(pool)
         self.mapper = AccountMapper(pool)
+        self._tlog = task_logger
 
 
     async def deal(self, *, limit: int = 100) -> dict:
     async def deal(self, *, limit: int = 100) -> dict:
         """串行处理待抓账号
         """串行处理待抓账号
@@ -31,9 +36,14 @@ class AccountFetch(ContentSupplyConst):
         Returns:
         Returns:
             {"pending": int, "success": int, "skipped": int, "failed": int}
             {"pending": int, "success": int, "skipped": int, "failed": int}
         """
         """
+        if self._tlog:
+            await self._tlog.phase_start("account_fetch", limit=limit)
+
         rows = await self.mapper.fetch_pending_relations(limit=limit)
         rows = await self.mapper.fetch_pending_relations(limit=limit)
         if not rows:
         if not rows:
             logger.info("无待抓账号")
             logger.info("无待抓账号")
+            if self._tlog:
+                await self._tlog.phase_end("account_fetch", pending=0)
             return {"pending": 0, "success": 0, "skipped": 0, "failed": 0}
             return {"pending": 0, "success": 0, "skipped": 0, "failed": 0}
 
 
         success = 0
         success = 0
@@ -73,6 +83,8 @@ class AccountFetch(ContentSupplyConst):
             skipped,
             skipped,
             failed,
             failed,
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end("account_fetch", pending=len(rows), success=success, skipped=skipped, failed=failed)
         return {
         return {
             "pending": len(rows),
             "pending": len(rows),
             "success": success,
             "success": success,

+ 13 - 2
src/domains/content_supply/article_fetch_detail.py

@@ -6,7 +6,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict
+from typing import Dict, TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.spider.wechat.gzh import get_article_detail
 from src.infra.spider.wechat.gzh import get_article_detail
@@ -15,15 +15,19 @@ from ._const import ContentSupplyConst
 from ._mapper import ArticleSearchRelationMapper, ArticleDetailMapper
 from ._mapper import ArticleSearchRelationMapper, ArticleDetailMapper
 from ._utils import parse_detail
 from ._utils import parse_detail
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class ArticleFetchDetail(ContentSupplyConst):
 class ArticleFetchDetail(ContentSupplyConst):
     """从 relation 表取待拉项 → 调详情 API → 写入 detail 表"""
     """从 relation 表取待拉项 → 调详情 API → 写入 detail 表"""
 
 
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.relation_mapper = ArticleSearchRelationMapper(pool)
         self.relation_mapper = ArticleSearchRelationMapper(pool)
         self.detail_mapper = ArticleDetailMapper(pool)
         self.detail_mapper = ArticleDetailMapper(pool)
+        self._tlog = task_logger
 
 
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════
     # 入口
     # 入口
@@ -41,6 +45,9 @@ class ArticleFetchDetail(ContentSupplyConst):
         if limit is None:
         if limit is None:
             limit = self.DETAIL_DEAL_LIMIT
             limit = self.DETAIL_DEAL_LIMIT
 
 
+        if self._tlog:
+            await self._tlog.phase_start("detail_fetch", limit=limit)
+
         # 将低于阈值的低质文章标记为 FAIL,不拉详情
         # 将低于阈值的低质文章标记为 FAIL,不拉详情
         dropped = await self.relation_mapper.mark_low_quality(limit=limit)
         dropped = await self.relation_mapper.mark_low_quality(limit=limit)
         if dropped:
         if dropped:
@@ -49,6 +56,8 @@ class ArticleFetchDetail(ContentSupplyConst):
                 dropped,
                 dropped,
                 self.MIN_QUALITY_SCORE,
                 self.MIN_QUALITY_SCORE,
             )
             )
+            if self._tlog:
+                await self._tlog.info("item.processed", message=f"标记低质文章: {dropped} 条", phase="detail_fetch", dropped=dropped)
 
 
         items = await self.relation_mapper.fetch_pending(limit=limit)
         items = await self.relation_mapper.fetch_pending(limit=limit)
         if not items:
         if not items:
@@ -83,6 +92,8 @@ class ArticleFetchDetail(ContentSupplyConst):
             success,
             success,
             failed,
             failed,
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end("detail_fetch", pending=len(items), success=success, failed=failed)
         return {
         return {
             "pending": len(items),
             "pending": len(items),
             "success": success,
             "success": success,

+ 29 - 3
src/domains/content_supply/article_search.py

@@ -7,7 +7,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict
+from typing import Dict, TYPE_CHECKING
 
 
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.database.mysql.manager import MysqlManager
 from src.infra.spider.wechat.gzh import weixin_search
 from src.infra.spider.wechat.gzh import weixin_search
@@ -16,6 +16,9 @@ from ._const import ContentSupplyConst
 from ._mapper import ArticleSearchRelationMapper
 from ._mapper import ArticleSearchRelationMapper
 from ._utils import parse_search_result, is_valid_keyword
 from ._utils import parse_search_result, is_valid_keyword
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -25,8 +28,9 @@ class DemandSearchArticle(ContentSupplyConst):
     按 dt 分区读取,experiment_id 只透传不过滤。
     按 dt 分区读取,experiment_id 只透传不过滤。
     """
     """
 
 
-    def __init__(self, pool: MysqlManager):
+    def __init__(self, pool: MysqlManager, task_logger: "TaskLogger | None" = None):
         self.relation_mapper = ArticleSearchRelationMapper(pool)
         self.relation_mapper = ArticleSearchRelationMapper(pool)
+        self._tlog = task_logger
 
 
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════
     # 入口
     # 入口
@@ -40,9 +44,14 @@ class DemandSearchArticle(ContentSupplyConst):
         Returns:
         Returns:
             {"queued": int, "processed": int, "results_written": int}
             {"queued": int, "processed": int, "results_written": int}
         """
         """
+        if self._tlog:
+            await self._tlog.phase_start("search", dt=dt, limit=limit)
+
         items = await self.relation_mapper.fetch_queue_pending(dt, limit=limit)
         items = await self.relation_mapper.fetch_queue_pending(dt, limit=limit)
         if not items:
         if not items:
             logger.info("无待处理队列项")
             logger.info("无待处理队列项")
+            if self._tlog:
+                await self._tlog.phase_end("search", queued=0, processed=0)
             return {"queued": 0, "processed": 0, "results_written": 0}
             return {"queued": 0, "processed": 0, "results_written": 0}
 
 
         total_written = 0
         total_written = 0
@@ -57,6 +66,15 @@ class DemandSearchArticle(ContentSupplyConst):
                     item["id"],
                     item["id"],
                     item.get("experiment_id"),
                     item.get("experiment_id"),
                 )
                 )
+                if self._tlog:
+                    await self._tlog.item_processed(
+                        "item.failed",
+                        message=f"搜索失败: queue_id={item['id']}",
+                        phase="search",
+                        successful=False,
+                        queue_id=item["id"],
+                        experiment_id=item.get("experiment_id"),
+                    )
                 await self.relation_mapper.queue_mark_failed(
                 await self.relation_mapper.queue_mark_failed(
                     item["id"],
                     item["id"],
                     reason=str(e),
                     reason=str(e),
@@ -86,6 +104,10 @@ class DemandSearchArticle(ContentSupplyConst):
             len(items),
             len(items),
             total_written,
             total_written,
         )
         )
+        if self._tlog:
+            await self._tlog.phase_end(
+                "search", queued=len(items), processed=processed, results_written=total_written
+            )
         return {
         return {
             "queued": len(items),
             "queued": len(items),
             "processed": processed,
             "processed": processed,
@@ -171,9 +193,13 @@ class DemandSearchArticle(ContentSupplyConst):
         cached = await self.relation_mapper.cache_get(keyword, page)
         cached = await self.relation_mapper.cache_get(keyword, page)
         if cached:
         if cached:
             logger.info("缓存命中: keyword=%s, page=%d", keyword, page)
             logger.info("缓存命中: keyword=%s, page=%d", keyword, page)
+            if self._tlog:
+                await self._tlog.info("cache.hit", message=f"缓存命中: {keyword}", phase="search", keyword=keyword, page=page)
             return cached, True
             return cached, True
 
 
-        result = await weixin_search(keyword, page=cursor)
+        if self._tlog:
+            await self._tlog.info("cache.miss", message=f"缓存未命中, 调API: {keyword}", phase="search", keyword=keyword, page=page)
+        result = await weixin_search(keyword, page=cursor, tlog=self._tlog)
         if result and result.get("code") == 0:
         if result and result.get("code") == 0:
             await self.relation_mapper.cache_set(keyword, page, result)
             await self.relation_mapper.cache_set(keyword, page, result)
         return result, False
         return result, False

+ 17 - 2
src/domains/demand_enqueue_task/fetch_demands.py

@@ -9,7 +9,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict, List, Set
+from typing import Dict, List, Set, TYPE_CHECKING
 
 
 from tqdm import tqdm
 from tqdm import tqdm
 
 
@@ -20,6 +20,9 @@ from src.infra.database.postgresql.manager import PgManager
 from ._const import DemandConst
 from ._const import DemandConst
 from ._mapper import VideoDeconstructMapper
 from ._mapper import VideoDeconstructMapper
 
 
+if TYPE_CHECKING:
+    from src.handlers._logging import TaskLogger
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 # ODPS SELECT 列清单(匹配结果表)
 # ODPS SELECT 列清单(匹配结果表)
@@ -44,9 +47,10 @@ class FetchDemands(DemandConst):
         items = await fetcher.deal("20260701")
         items = await fetcher.deal("20260701")
     """
     """
 
 
-    def __init__(self, config: OdpsConfig, pg: PgManager):
+    def __init__(self, config: OdpsConfig, pg: PgManager, task_logger: "TaskLogger | None" = None):
         self._odps_config = config
         self._odps_config = config
         self._deconstruct_mapper = VideoDeconstructMapper(pg)
         self._deconstruct_mapper = VideoDeconstructMapper(pg)
+        self._tlog = task_logger
 
 
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════
     # 入口
     # 入口
@@ -58,6 +62,8 @@ class FetchDemands(DemandConst):
         valid_accounts = await self._load_valid_accounts()
         valid_accounts = await self._load_valid_accounts()
         if not valid_accounts:
         if not valid_accounts:
             logger.warning("白名单为空, dt=%s, 终止", dt)
             logger.warning("白名单为空, dt=%s, 终止", dt)
+            if self._tlog:
+                await self._tlog.warn("item.failed", "白名单为空", phase="fetch", dt=dt)
             return []
             return []
 
 
         # 2. 查询匹配视频(ODPS)
         # 2. 查询匹配视频(ODPS)
@@ -77,6 +83,15 @@ class FetchDemands(DemandConst):
         # 5. 获取视频解构 + 展开搜索词
         # 5. 获取视频解构 + 展开搜索词
         items = await self._explode_search_items(deduped)
         items = await self._explode_search_items(deduped)
         logger.info("搜索词展开: %d 条队列项", len(items))
         logger.info("搜索词展开: %d 条队列项", len(items))
+        if self._tlog:
+            await self._tlog.info(
+                "phase.end", phase="fetch",
+                message=f"搜索词展开完成: {len(items)} 条",
+                dt=dt,
+                raw_matches=len(rows),
+                deduped=len(deduped),
+                items=len(items),
+            )
         return items
         return items
 
 
     # ═══════════════════════════════════════════════════════════════
     # ═══════════════════════════════════════════════════════════════

+ 251 - 0
src/handlers/_logging.py

@@ -0,0 +1,251 @@
+"""handler 共享日志工具 —— TaskLogger + 便捷工厂
+
+镜像 _utils.py 的 get_db() 模式:app.py 启动时注入 LogService,handler 通过
+get_task_logger() 获得 TaskLogger 实例,自动管理 trace_id 和计时。
+
+trace_id 传递链路:
+  XxlJobExecutor.run() → _current_trace_id.set(...) → ContextVar
+    └─ handler(param) → get_task_logger()  → 自动读取 ContextVar 中的 trace_id
+         └─ service.deal(..., task_logger=tlog)  → 传入 TaskLogger
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import TYPE_CHECKING
+
+from src.infra.observability import (
+    LogCategory,
+    LogLevel,
+    LogStatus,
+    LogRecord,
+    TaskEvent,
+)
+from src.infra.observability.context import (
+    _current_trace_id,
+    get_log_service,
+)
+from src.infra.shared.tools import generate_task_trace_id
+
+if TYPE_CHECKING:
+    from src.infra.observability.logging import LogService
+
+logger = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════════════════════════
+# TaskLogger —— 任务级日志工具
+# ═══════════════════════════════════════════════════════════════
+
+
+class TaskLogger:
+    """任务日志器 —— 封装 LogService 提供业务友好的结构化日志 API
+
+    自动管理 trace_id、计时、事件标准化。每个 handler 实例创建一个。
+
+    使用方式:
+        tlog = get_task_logger("articleSearch")
+        await tlog.task_started(dt="20260701", limit=2000)
+        try:
+            ...
+            await tlog.phase_start("search")
+            ...
+            await tlog.phase_end("search", items=100)
+            await tlog.task_completed(processed=100)
+        except Exception as e:
+            await tlog.task_failed(e)
+    """
+
+    def __init__(
+        self,
+        log_service: "LogService",
+        task_name: str,
+        trace_id: str | None = None,
+    ):
+        self._ls = log_service
+        self._task_name = task_name
+        self.trace_id = trace_id or _current_trace_id.get() or generate_task_trace_id()
+        self._started_at = time.monotonic()
+        self._phase_timers: dict[str, float] = {}
+
+    # ── 生命周期 ──
+
+    async def task_started(self, message: str = "", **extra) -> None:
+        """任务开始 —— 在 handler 入口调用一次"""
+        await self._emit(
+            level=LogLevel.INFO,
+            event=TaskEvent.TASK_STARTED,
+            message=message or f"{self._task_name} 开始执行",
+            status=LogStatus.PENDING,
+            **extra,
+        )
+
+    async def task_completed(self, message: str = "", **extra) -> None:
+        """任务完成 —— handler 正常结束时调用"""
+        await self._emit(
+            level=LogLevel.INFO,
+            event=TaskEvent.TASK_COMPLETED,
+            message=message or f"{self._task_name} 执行完成",
+            status=LogStatus.SUCCESS,
+            **extra,
+        )
+
+    async def task_failed(self, error: Exception | None = None, message: str = "", **extra) -> None:
+        """任务失败 —— handler 异常时调用"""
+        if not message:
+            message = str(error) if error else f"{self._task_name} 执行失败"
+        await self._emit(
+            level=LogLevel.ERROR,
+            event=TaskEvent.TASK_FAILED,
+            message=message,
+            status=LogStatus.FAILED,
+            error_type=type(error).__name__ if error else "",
+            error_message=str(error) if error else "",
+            **extra,
+        )
+
+    # ── 阶段标记 ──
+
+    async def phase_start(self, phase: str, message: str = "", **extra) -> None:
+        """阶段开始 —— 标记管道阶段"""
+        self._phase_timers[phase] = time.monotonic()
+        await self._emit(
+            level=LogLevel.INFO,
+            event=TaskEvent.PHASE_START,
+            phase=phase,
+            message=message or f"阶段开始: {phase}",
+            **extra,
+        )
+
+    async def phase_end(self, phase: str, message: str = "", **extra) -> None:
+        """阶段结束 —— 自动计算阶段耗时"""
+        phase_start = self._phase_timers.pop(phase, self._started_at)
+        phase_duration = int((time.monotonic() - phase_start) * 1000)
+        await self._emit(
+            level=LogLevel.INFO,
+            event=TaskEvent.PHASE_END,
+            phase=phase,
+            message=message or f"阶段结束: {phase}",
+            duration_ms=phase_duration,
+            **extra,
+        )
+
+    # ── 业务事件 ──
+
+    async def info(
+        self,
+        event: str | TaskEvent,
+        message: str = "",
+        phase: str = "",
+        **extra,
+    ) -> None:
+        """通用信息日志"""
+        await self._emit(
+            level=LogLevel.INFO,
+            event=event if isinstance(event, str) else event.value,
+            phase=phase,
+            message=message,
+            **extra,
+        )
+
+    async def warn(
+        self,
+        event: str | TaskEvent,
+        message: str = "",
+        phase: str = "",
+        **extra,
+    ) -> None:
+        """警告日志"""
+        await self._emit(
+            level=LogLevel.WARN,
+            event=event if isinstance(event, str) else event.value,
+            phase=phase,
+            message=message,
+            **extra,
+        )
+
+    async def error(
+        self,
+        event: str | TaskEvent,
+        message: str = "",
+        exc: Exception | None = None,
+        phase: str = "",
+        **extra,
+    ) -> None:
+        """错误日志"""
+        await self._emit(
+            level=LogLevel.ERROR,
+            event=event if isinstance(event, str) else event.value,
+            phase=phase,
+            message=message or (str(exc) if exc else ""),
+            error_type=type(exc).__name__ if exc else "",
+            error_message=str(exc) if exc else "",
+            status=LogStatus.FAILED,
+            **extra,
+        )
+
+    async def item_processed(
+        self,
+        event: str | TaskEvent,
+        message: str = "",
+        phase: str = "",
+        successful: bool = True,
+        **extra,
+    ) -> None:
+        """单条处理结果"""
+        await self._emit(
+            level=LogLevel.INFO,
+            event=event if isinstance(event, str) else event.value,
+            phase=phase,
+            message=message,
+            status=LogStatus.SUCCESS if successful else LogStatus.FAILED,
+            **extra,
+        )
+
+    # ── 内部方法 ──
+
+    async def _emit(
+        self,
+        level: LogLevel,
+        event: str,
+        message: str = "",
+        phase: str = "",
+        status: LogStatus = LogStatus.SUCCESS,
+        duration_ms: int = 0,
+        error_type: str = "",
+        error_message: str = "",
+        **extra,
+    ) -> None:
+        """构造 LogRecord 并推送到 SLS"""
+        total_elapsed = int((time.monotonic() - self._started_at) * 1000)
+        record = LogRecord(
+            level=level,
+            category=LogCategory.TASK,
+            event=event,
+            message=message,
+            trace_id=self.trace_id,
+            task_name=self._task_name,
+            phase=phase,
+            duration_ms=duration_ms or total_elapsed,
+            status=status,
+            error_type=error_type,
+            error_message=error_message,
+            extra=extra,
+        )
+        await self._ls.log(record)
+
+
+# ═══════════════════════════════════════════════════════════════
+# 便捷工厂
+# ═══════════════════════════════════════════════════════════════
+
+
+def get_task_logger(task_name: str, trace_id: str | None = None) -> TaskLogger:
+    """从注入的 LogService 创建 TaskLogger —— handler 入口一行获取
+
+    使用方式:
+        from src.handlers._logging import get_task_logger
+        tlog = get_task_logger("articleSearch")
+    """
+    return TaskLogger(get_log_service(), task_name, trace_id=trace_id)

+ 7 - 3
src/handlers/account_article_fetch.py

@@ -11,6 +11,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_supply import AccountArticleFetch, ContentSupplyConst
 from src.domains.content_supply import AccountArticleFetch, ContentSupplyConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,14 +19,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("accountArticleFetch")
 @xxl_job("accountArticleFetch")
 async def account_article_fetch(param: str) -> dict:
 async def account_article_fetch(param: str) -> dict:
     limit = parse_limit(param, ContentSupplyConst.ARTICLE_DEAL_LIMIT)
     limit = parse_limit(param, ContentSupplyConst.ARTICLE_DEAL_LIMIT)
-    logger.info("accountArticleFetch started, limit=%d", limit)
+    tlog = get_task_logger("accountArticleFetch")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        fetcher = AccountArticleFetch(mysql)
+        fetcher = AccountArticleFetch(mysql, task_logger=tlog)
         result = await fetcher.deal(limit=limit)
         result = await fetcher.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("账号文章抓取失败")
         logger.exception("账号文章抓取失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "账号文章抓取异常"}
         return {"code": 500, "msg": "账号文章抓取异常"}
 
 
     logger.info(
     logger.info(
@@ -34,6 +37,7 @@ async def account_article_fetch(param: str) -> dict:
         result["articles_written"],
         result["articles_written"],
         result["errors"],
         result["errors"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"accounts={result['accounts']}, written={result['articles_written']}, errors={result['errors']}",
         "msg": f"accounts={result['accounts']}, written={result['articles_written']}, errors={result['errors']}",

+ 7 - 3
src/handlers/account_article_update.py

@@ -12,6 +12,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_supply import AccountArticleUpdate, ContentSupplyConst
 from src.domains.content_supply import AccountArticleUpdate, ContentSupplyConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -19,14 +20,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("accountArticleUpdate")
 @xxl_job("accountArticleUpdate")
 async def account_article_update(param: str) -> dict:
 async def account_article_update(param: str) -> dict:
     limit = parse_limit(param, ContentSupplyConst.ARTICLE_DEAL_LIMIT)
     limit = parse_limit(param, ContentSupplyConst.ARTICLE_DEAL_LIMIT)
-    logger.info("accountArticleUpdate started, limit=%d", limit)
+    tlog = get_task_logger("accountArticleUpdate")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        updater = AccountArticleUpdate(mysql)
+        updater = AccountArticleUpdate(mysql, task_logger=tlog)
         result = await updater.deal(limit=limit)
         result = await updater.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("文章指标更新失败")
         logger.exception("文章指标更新失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "文章指标更新异常"}
         return {"code": 500, "msg": "文章指标更新异常"}
 
 
     logger.info(
     logger.info(
@@ -35,6 +38,7 @@ async def account_article_update(param: str) -> dict:
         result["articles_updated"],
         result["articles_updated"],
         result["errors"],
         result["errors"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"accounts={result['accounts']}, updated={result['articles_updated']}, errors={result['errors']}",
         "msg": f"accounts={result['accounts']}, updated={result['articles_updated']}, errors={result['errors']}",

+ 7 - 3
src/handlers/account_fetch.py

@@ -10,6 +10,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_supply import AccountFetch, ContentSupplyConst
 from src.domains.content_supply import AccountFetch, ContentSupplyConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -17,14 +18,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("accountFetch")
 @xxl_job("accountFetch")
 async def account_fetch(param: str) -> dict:
 async def account_fetch(param: str) -> dict:
     limit = parse_limit(param, ContentSupplyConst.ACCOUNT_DEAL_LIMIT)
     limit = parse_limit(param, ContentSupplyConst.ACCOUNT_DEAL_LIMIT)
-    logger.info("accountFetch started, limit=%d", limit)
+    tlog = get_task_logger("accountFetch")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        fetcher = AccountFetch(mysql)
+        fetcher = AccountFetch(mysql, task_logger=tlog)
         result = await fetcher.deal(limit=limit)
         result = await fetcher.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("账号抓取失败")
         logger.exception("账号抓取失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "账号抓取异常"}
         return {"code": 500, "msg": "账号抓取异常"}
 
 
     logger.info(
     logger.info(
@@ -34,6 +37,7 @@ async def account_fetch(param: str) -> dict:
         result["skipped"],
         result["skipped"],
         result["failed"],
         result["failed"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"success={result['success']}, skipped={result['skipped']}, failed={result['failed']}",
         "msg": f"success={result['success']}, skipped={result['skipped']}, failed={result['failed']}",

+ 7 - 3
src/handlers/article_fetch_detail.py

@@ -11,6 +11,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_supply import ArticleFetchDetail, ContentSupplyConst
 from src.domains.content_supply import ArticleFetchDetail, ContentSupplyConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,14 +19,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("articleFetchDetail")
 @xxl_job("articleFetchDetail")
 async def article_fetch_detail(param: str) -> dict:
 async def article_fetch_detail(param: str) -> dict:
     limit = parse_limit(param, ContentSupplyConst.DETAIL_DEAL_LIMIT)
     limit = parse_limit(param, ContentSupplyConst.DETAIL_DEAL_LIMIT)
-    logger.info("articleFetchDetail started, limit=%d", limit)
+    tlog = get_task_logger("articleFetchDetail")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        fetcher = ArticleFetchDetail(mysql)
+        fetcher = ArticleFetchDetail(mysql, task_logger=tlog)
         result = await fetcher.deal(limit=limit)
         result = await fetcher.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("详情拉取失败")
         logger.exception("详情拉取失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "详情拉取异常"}
         return {"code": 500, "msg": "详情拉取异常"}
 
 
     logger.info(
     logger.info(
@@ -34,6 +37,7 @@ async def article_fetch_detail(param: str) -> dict:
         result["success"],
         result["success"],
         result["failed"],
         result["failed"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"success={result['success']}, failed={result['failed']}",
         "msg": f"success={result['success']}, failed={result['failed']}",

+ 7 - 3
src/handlers/article_quality_category.py

@@ -12,6 +12,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_quality import ArticleQualityCategory, ContentQualityConst
 from src.domains.content_quality import ArticleQualityCategory, ContentQualityConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -19,14 +20,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("articleQualityCategory")
 @xxl_job("articleQualityCategory")
 async def article_quality_category(param: str) -> dict:
 async def article_quality_category(param: str) -> dict:
     limit = parse_limit(param, ContentQualityConst.CATEGORY_DEAL_LIMIT)
     limit = parse_limit(param, ContentQualityConst.CATEGORY_DEAL_LIMIT)
-    logger.info("articleQualityCategory started, limit=%d", limit)
+    tlog = get_task_logger("articleQualityCategory")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        service = ArticleQualityCategory(mysql)
+        service = ArticleQualityCategory(mysql, task_logger=tlog)
         result = await service.deal(limit=limit)
         result = await service.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("品类识别失败")
         logger.exception("品类识别失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "异常"}
         return {"code": 500, "msg": "异常"}
 
 
     logger.info(
     logger.info(
@@ -34,6 +37,7 @@ async def article_quality_category(param: str) -> dict:
         result["seeded"],
         result["seeded"],
         result["evaluated"],
         result["evaluated"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"seeded={result['seeded']} evaluated={result['evaluated']}",
         "msg": f"seeded={result['seeded']} evaluated={result['evaluated']}",

+ 7 - 3
src/handlers/article_quality_quantify.py

@@ -12,6 +12,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_quality import ArticleQualityQuantify, ContentQualityConst
 from src.domains.content_quality import ArticleQualityQuantify, ContentQualityConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -19,14 +20,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("articleQualityQuantify")
 @xxl_job("articleQualityQuantify")
 async def article_quality_quantify(param: str) -> dict:
 async def article_quality_quantify(param: str) -> dict:
     limit = parse_limit(param, ContentQualityConst.QUANTIFY_DEAL_LIMIT)
     limit = parse_limit(param, ContentQualityConst.QUANTIFY_DEAL_LIMIT)
-    logger.info("articleQualityQuantify started, limit=%d", limit)
+    tlog = get_task_logger("articleQualityQuantify")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        service = ArticleQualityQuantify(mysql)
+        service = ArticleQualityQuantify(mysql, task_logger=tlog)
         result = await service.deal(limit=limit)
         result = await service.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("量化评估失败")
         logger.exception("量化评估失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "异常"}
         return {"code": 500, "msg": "异常"}
 
 
     logger.info(
     logger.info(
@@ -37,6 +40,7 @@ async def article_quality_quantify(param: str) -> dict:
         result["computed"],
         result["computed"],
         result["errors"],
         result["errors"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": (
         "msg": (

+ 7 - 3
src/handlers/article_quality_title.py

@@ -12,6 +12,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_quality import ArticleQualityTitle, ContentQualityConst
 from src.domains.content_quality import ArticleQualityTitle, ContentQualityConst
 from src.handlers._utils import get_db, parse_limit
 from src.handlers._utils import get_db, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -19,14 +20,16 @@ logger = logging.getLogger(__name__)
 @xxl_job("articleQualityTitle")
 @xxl_job("articleQualityTitle")
 async def article_quality_title(param: str) -> dict:
 async def article_quality_title(param: str) -> dict:
     limit = parse_limit(param, ContentQualityConst.TITLE_DEAL_LIMIT)
     limit = parse_limit(param, ContentQualityConst.TITLE_DEAL_LIMIT)
-    logger.info("articleQualityTitle started, limit=%d", limit)
+    tlog = get_task_logger("articleQualityTitle")
+    await tlog.task_started(limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        service = ArticleQualityTitle(mysql)
+        service = ArticleQualityTitle(mysql, task_logger=tlog)
         result = await service.deal(limit=limit)
         result = await service.deal(limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("标题评分失败")
         logger.exception("标题评分失败")
+        await tlog.task_failed(e)
         return {"code": 500, "msg": "异常"}
         return {"code": 500, "msg": "异常"}
 
 
     logger.info(
     logger.info(
@@ -34,6 +37,7 @@ async def article_quality_title(param: str) -> dict:
         result["seeded"],
         result["seeded"],
         result["evaluated"],
         result["evaluated"],
     )
     )
+    await tlog.task_completed(**result)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"seeded={result['seeded']} evaluated={result['evaluated']}",
         "msg": f"seeded={result['seeded']} evaluated={result['evaluated']}",

+ 7 - 3
src/handlers/article_search.py

@@ -12,6 +12,7 @@ import logging
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.content_supply import DemandSearchArticle, ContentSupplyConst
 from src.domains.content_supply import DemandSearchArticle, ContentSupplyConst
 from src.handlers._utils import get_db, parse_params, parse_limit
 from src.handlers._utils import get_db, parse_params, parse_limit
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -21,14 +22,16 @@ async def article_search(param: str) -> dict:
     p = parse_params(param)
     p = parse_params(param)
     dt = p.get("dt") or None
     dt = p.get("dt") or None
     limit = parse_limit(param, ContentSupplyConst.SEARCH_DEAL_LIMIT)
     limit = parse_limit(param, ContentSupplyConst.SEARCH_DEAL_LIMIT)
-    logger.info("articleSearch started, dt=%s, limit=%d", dt, limit)
+    tlog = get_task_logger("articleSearch")
+    await tlog.task_started(dt=dt, limit=limit)
 
 
     try:
     try:
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
-        searcher = DemandSearchArticle(mysql)
+        searcher = DemandSearchArticle(mysql, task_logger=tlog)
         result = await searcher.deal(dt, limit=limit)
         result = await searcher.deal(dt, limit=limit)
-    except Exception:
+    except Exception as e:
         logger.exception("搜索失败, dt=%s", dt)
         logger.exception("搜索失败, dt=%s", dt)
+        await tlog.task_failed(e, dt=dt)
         return {"code": 500, "msg": "搜索异常"}
         return {"code": 500, "msg": "搜索异常"}
 
 
     logger.info(
     logger.info(
@@ -38,6 +41,7 @@ async def article_search(param: str) -> dict:
         result["processed"],
         result["processed"],
         result["results_written"],
         result["results_written"],
     )
     )
+    await tlog.task_completed(**result, dt=dt)
     return {
     return {
         "code": 200,
         "code": 200,
         "msg": f"processed={result['processed']}, written={result['results_written']}",
         "msg": f"processed={result['processed']}, written={result['results_written']}",

+ 17 - 5
src/handlers/demand_enqueue.py

@@ -14,6 +14,7 @@ from src.config.odps import OdpsConfig
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
 from src.domains.demand_enqueue_task import FetchDemands, EnqueueDemands
 from src.domains.demand_enqueue_task import FetchDemands, EnqueueDemands
 from src.handlers._utils import get_db, parse_dt
 from src.handlers._utils import get_db, parse_dt
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -25,35 +26,46 @@ def _default_dt() -> str:
 @xxl_job("demandEnqueue")
 @xxl_job("demandEnqueue")
 async def demand_enqueue(param: str) -> dict:
 async def demand_enqueue(param: str) -> dict:
     dt = parse_dt(param, _default_dt())
     dt = parse_dt(param, _default_dt())
-    logger.info("demandEnqueue started, dt=%s", dt)
+    tlog = get_task_logger("demandEnqueue")
+    await tlog.task_started(dt=dt)
 
 
     # 1. ODPS 拉取 → PG 查搜索词
     # 1. ODPS 拉取 → PG 查搜索词
     try:
     try:
+        await tlog.phase_start("fetch", dt=dt)
         pg = get_db().pg_manager("pg")
         pg = get_db().pg_manager("pg")
-        fetcher = FetchDemands(OdpsConfig(), pg)
+        fetcher = FetchDemands(OdpsConfig(), pg, task_logger=tlog)
         items = await fetcher.deal(dt)
         items = await fetcher.deal(dt)
-    except Exception:
+    except Exception as e:
         logger.exception("ODPS 拉取失败, dt=%s", dt)
         logger.exception("ODPS 拉取失败, dt=%s", dt)
+        await tlog.task_failed(e, dt=dt)
         return {"code": 500, "msg": "ODPS 拉取异常"}
         return {"code": 500, "msg": "ODPS 拉取异常"}
 
 
+    await tlog.phase_end("fetch", item_count=len(items), dt=dt)
+
     if not items:
     if not items:
         logger.info("无队列项生成, dt=%s", dt)
         logger.info("无队列项生成, dt=%s", dt)
+        await tlog.task_completed(message="无队列项", dt=dt, items=0)
         return {"code": 200, "msg": "no items"}
         return {"code": 200, "msg": "no items"}
 
 
     # 2. MySQL 入队
     # 2. MySQL 入队
     try:
     try:
+        await tlog.phase_start("enqueue", dt=dt)
         mysql = get_db().mysql_manager("long_articles")
         mysql = get_db().mysql_manager("long_articles")
         enqueuer = EnqueueDemands(mysql)
         enqueuer = EnqueueDemands(mysql)
         n = await enqueuer.deal(items)
         n = await enqueuer.deal(items)
-    except Exception:
+        accounts = len(set(it["account"] for it in items))
+    except Exception as e:
         logger.exception("MySQL 入队失败, dt=%s", dt)
         logger.exception("MySQL 入队失败, dt=%s", dt)
+        await tlog.task_failed(e, dt=dt)
         return {"code": 500, "msg": "入队异常"}
         return {"code": 500, "msg": "入队异常"}
 
 
-    accounts = len(set(it["account"] for it in items))
+    await tlog.phase_end("enqueue", enqueued=n, accounts=accounts, dt=dt)
+
     logger.info(
     logger.info(
         "demandEnqueue done: enqueued %d items (%d accounts) for dt=%s",
         "demandEnqueue done: enqueued %d items (%d accounts) for dt=%s",
         n,
         n,
         accounts,
         accounts,
         dt,
         dt,
     )
     )
+    await tlog.task_completed(enqueued=n, accounts=accounts, dt=dt)
     return {"code": 200, "msg": f"enqueued={n}, accounts={accounts}"}
     return {"code": 200, "msg": f"enqueued={n}, accounts={accounts}"}

+ 7 - 2
src/handlers/test_hello_world.py

@@ -3,13 +3,18 @@
 import logging
 import logging
 
 
 from src.infra.xxl_jobs import xxl_job
 from src.infra.xxl_jobs import xxl_job
+from src.handlers._logging import get_task_logger
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 @xxl_job("testHelloWorld")
 @xxl_job("testHelloWorld")
 async def test_hello_world(param: str) -> dict:
 async def test_hello_world(param: str) -> dict:
-    """测试 handler:打印 hello world"""
+    """测试 handler:验证 SLS 日志全链路 trace_id 穿透"""
+    tlog = get_task_logger("testHelloWorld")
+    await tlog.task_started(param=param)
     logger.info("testHelloWorld triggered, param=%s", param)
     logger.info("testHelloWorld triggered, param=%s", param)
-    print(f"hello world! param: {param}")
+    msg = f"hello world! param: {param}"
+    print(msg)
+    await tlog.task_completed(message=msg)
     return {"code": 200, "msg": "hello world printed"}
     return {"code": 200, "msg": "hello world printed"}

+ 2 - 0
src/infra/__init__.py

@@ -20,6 +20,7 @@ from src.infra.observability import (
     LogCategory,
     LogCategory,
     LogLevel,
     LogLevel,
     LogStatus,
     LogStatus,
+    TaskEvent,
     LogRecord,
     LogRecord,
     LogService,
     LogService,
     SLS_INDEX_CONFIG,
     SLS_INDEX_CONFIG,
@@ -76,6 +77,7 @@ __all__ = [
     "LogCategory",
     "LogCategory",
     "LogLevel",
     "LogLevel",
     "LogStatus",
     "LogStatus",
+    "TaskEvent",
     "LogRecord",
     "LogRecord",
     "LogService",
     "LogService",
     "SLS_INDEX_CONFIG",
     "SLS_INDEX_CONFIG",

+ 7 - 1
src/infra/external/volcengine.py

@@ -19,7 +19,13 @@ deepseek_model = config.volcengine.deepseek_v4_pro
 def fetch_deepseek_completion(
 def fetch_deepseek_completion(
     prompt: str,
     prompt: str,
     output_type: str = "text",
     output_type: str = "text",
-) -> Optional[Dict | List]:
+) -> Optional[Dict | List | str]:
+    """调用火山引擎 DeepSeek API
+
+    Args:
+        prompt: 提示词
+        output_type: "text" 返回原始文本, "json" 返回解析后的 JSON
+    """
     input_messages = [{"role": "user", "content": prompt}]
     input_messages = [{"role": "user", "content": prompt}]
     kwargs: dict[str, Any] = {
     kwargs: dict[str, Any] = {
         "model": deepseek_model,
         "model": deepseek_model,

+ 12 - 1
src/infra/observability/__init__.py

@@ -1,18 +1,29 @@
-"""可观测性设施层 —— 结构化日志模型、SLS 索引定义、日志服务"""
+"""可观测性设施层 —— 结构化日志模型、SLS 索引定义、日志服务、追踪上下文"""
 
 
 from src.infra.observability.log_schema import (
 from src.infra.observability.log_schema import (
     LogCategory,
     LogCategory,
     LogLevel,
     LogLevel,
     LogStatus,
     LogStatus,
+    TaskEvent,
     LogRecord,
     LogRecord,
     SLS_INDEX_CONFIG,
     SLS_INDEX_CONFIG,
 )
 )
 from src.infra.observability.logging import LogService
 from src.infra.observability.logging import LogService
+from src.infra.observability.context import (
+    _current_trace_id,
+    set_log_service,
+    get_log_service,
+)
 
 
 __all__ = [
 __all__ = [
     "LogCategory",
     "LogCategory",
     "LogLevel",
     "LogLevel",
+    "LogStatus",
+    "TaskEvent",
     "LogRecord",
     "LogRecord",
     "SLS_INDEX_CONFIG",
     "SLS_INDEX_CONFIG",
     "LogService",
     "LogService",
+    "_current_trace_id",
+    "set_log_service",
+    "get_log_service",
 ]
 ]

+ 45 - 0
src/infra/observability/context.py

@@ -0,0 +1,45 @@
+"""可观测性上下文 —— trace_id ContextVar + LogService 模块级注入
+
+本模块位于 infra/observability,处于依赖链底层:
+  - handlers 依赖 infra → 可安全 import
+  - xxl_executor 本身在 infra → 同级 import,不违反对称
+
+避免 core/ 新建依赖 infra 的方向——core 当前不依赖 infra,保持干净。
+"""
+
+from __future__ import annotations
+
+import contextvars
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from src.infra.observability.logging import LogService
+
+# ═══════════════════════════════════════════════════════════════
+# trace_id 上下文变量 —— xxl_executor 写入,TaskLogger 读取
+# ═══════════════════════════════════════════════════════════════
+
+_current_trace_id: contextvars.ContextVar[str] = contextvars.ContextVar(
+    "task_trace_id", default=""
+)
+
+# ═══════════════════════════════════════════════════════════════
+# LogService 注入槽 —— app.py 启动时注入
+# ═══════════════════════════════════════════════════════════════
+
+_log_service_ref: "LogService | None" = None
+
+
+def set_log_service(ls: "LogService") -> None:
+    """启动时注入 LogService,与 set_db() 对称"""
+    global _log_service_ref
+    _log_service_ref = ls
+
+
+def get_log_service() -> "LogService":
+    """运行时获取已注入的 LogService,未注入则抛 RuntimeError"""
+    if _log_service_ref is None:
+        raise RuntimeError(
+            "LogService 尚未注入,请确认 app.py 中已调用 set_log_service()"
+        )
+    return _log_service_ref

+ 32 - 0
src/infra/observability/log_schema.py

@@ -115,6 +115,31 @@ class LogStatus(str, Enum):
     PENDING = "pending"
     PENDING = "pending"
 
 
 
 
+class TaskEvent(str, Enum):
+    """任务事件命名标准 —— 统一 event 字段取值,方便 SLS 精确检索"""
+
+    # ── 任务生命周期 ──
+    TASK_RECEIVED = "task.received"  # XXL-JOB 调度到达
+    TASK_STARTED = "task.started"  # handler 开始执行
+    TASK_COMPLETED = "task.completed"  # handler 正常结束
+    TASK_FAILED = "task.failed"  # handler 异常终止
+    TASK_TIMEOUT = "task.timeout"  # handler 超时
+    TASK_CANCELLED = "task.cancelled"  # handler 被取消
+
+    # ── 阶段标记 ──
+    PHASE_START = "phase.start"
+    PHASE_END = "phase.end"
+
+    # ── 业务事件 ──
+    ITEM_PROCESSED = "item.processed"  # 单条处理完成
+    ITEM_FAILED = "item.failed"  # 单条处理失败
+    API_CALL = "api.call"  # 外部 API 调用
+    DB_OPERATION = "db.operation"  # 数据库操作
+    LLM_CALL = "llm.call"  # LLM 推理调用
+    CACHE_HIT = "cache.hit"  # 缓存命中
+    CACHE_MISS = "cache.miss"  # 缓存未命中
+
+
 # ==================== 结构化日志模型 ====================
 # ==================== 结构化日志模型 ====================
 
 
 
 
@@ -148,6 +173,10 @@ class LogRecord(BaseModel):
 
 
     # ── 业务标识 ──
     # ── 业务标识 ──
     task_name: str = Field(default="", description="任务名,如 content_generation")
     task_name: str = Field(default="", description="任务名,如 content_generation")
+    phase: str = Field(
+        default="",
+        description="当前管道阶段: search/detail/account/article/update/quality/category/title/quantify/enqueue",
+    )
     agent_name: str = Field(default="", description="Agent 名,如 ContentWriter")
     agent_name: str = Field(default="", description="Agent 名,如 ContentWriter")
     tool_name: str = Field(default="", description="Tool 名,如 WebSearchTool")
     tool_name: str = Field(default="", description="Tool 名,如 WebSearchTool")
 
 
@@ -179,6 +208,7 @@ class LogRecord(BaseModel):
             ("trace_id", self.trace_id),
             ("trace_id", self.trace_id),
             ("span_id", self.span_id),
             ("span_id", self.span_id),
             ("task_name", self.task_name),
             ("task_name", self.task_name),
+            ("phase", self.phase),
             ("agent_name", self.agent_name),
             ("agent_name", self.agent_name),
             ("tool_name", self.tool_name),
             ("tool_name", self.tool_name),
             ("duration_ms", str(self.duration_ms)),
             ("duration_ms", str(self.duration_ms)),
@@ -210,7 +240,9 @@ SLS_INDEX_CONFIG = {
         {"key": "category", "type": "text", "alias": "log_category"},
         {"key": "category", "type": "text", "alias": "log_category"},
         {"key": "event", "type": "text"},
         {"key": "event", "type": "text"},
         {"key": "trace_id", "type": "text"},
         {"key": "trace_id", "type": "text"},
+        {"key": "span_id", "type": "text"},
         {"key": "task_name", "type": "text"},
         {"key": "task_name", "type": "text"},
+        {"key": "phase", "type": "text"},
         {"key": "agent_name", "type": "text"},
         {"key": "agent_name", "type": "text"},
         {"key": "tool_name", "type": "text"},
         {"key": "tool_name", "type": "text"},
         {"key": "duration_ms", "type": "long", "stats": True},
         {"key": "duration_ms", "type": "long", "stats": True},

+ 22 - 5
src/infra/spider/wechat/gzh.py

@@ -31,7 +31,7 @@ HEADERS = {"Content-Type": "application/json"}
 
 
 @retry(**retry_desc)
 @retry(**retry_desc)
 async def get_article_detail(
 async def get_article_detail(
-    article_link: str, is_count: bool = False, is_cache: bool = True
+    article_link: str, is_count: bool = False, is_cache: bool = True, tlog=None
 ) -> dict | None:
 ) -> dict | None:
     """获取公众号文章详情
     """获取公众号文章详情
 
 
@@ -39,6 +39,7 @@ async def get_article_detail(
         article_link: 文章链接
         article_link: 文章链接
         is_count: 是否仅返回计数
         is_count: 是否仅返回计数
         is_cache: 是否优先使用缓存
         is_cache: 是否优先使用缓存
+        tlog: 可选的 TaskLogger,用于记录 API 调用事件
     """
     """
     target_url = f"{BASE_URL}/detail"
     target_url = f"{BASE_URL}/detail"
     payload = json.dumps(
     payload = json.dumps(
@@ -54,13 +55,15 @@ async def get_article_detail(
             response = await http_client.post(target_url, headers=HEADERS, data=payload)
             response = await http_client.post(target_url, headers=HEADERS, data=payload)
     except Exception as e:
     except Exception as e:
         logger.error("get_article_detail API请求失败: %s, link=%s", e, article_link)
         logger.error("get_article_detail API请求失败: %s, link=%s", e, article_link)
+        if tlog:
+            await tlog.error("api.call", f"详情API请求失败: {article_link[:80]}", exc=e, phase="detail_fetch")
         return None
         return None
     return response
     return response
 
 
 
 
 @retry(**retry_desc)
 @retry(**retry_desc)
 async def get_article_list_from_account(
 async def get_article_list_from_account(
-    account_id: str, index: str | None = None, is_cache: bool = True
+    account_id: str, index: str | None = None, is_cache: bool = True, tlog=None
 ) -> dict | None:
 ) -> dict | None:
     """获取公众号文章列表
     """获取公众号文章列表
 
 
@@ -68,6 +71,7 @@ async def get_article_list_from_account(
         account_id: 公众号 ID
         account_id: 公众号 ID
         index: 分页游标
         index: 分页游标
         is_cache: 是否优先使用缓存
         is_cache: 是否优先使用缓存
+        tlog: 可选的 TaskLogger
     """
     """
     target_url = f"{BASE_URL}/blogger"
     target_url = f"{BASE_URL}/blogger"
     payload = json.dumps(
     payload = json.dumps(
@@ -88,6 +92,13 @@ async def get_article_list_from_account(
             account_id,
             account_id,
             index,
             index,
         )
         )
+        if tlog:
+            await tlog.error(
+                "api.call",
+                f"文章列表API请求失败: account_id={account_id[:40]}",
+                exc=e,
+                phase="article_fetch",
+            )
         return None
         return None
     return response
     return response
 
 
@@ -131,12 +142,13 @@ def get_source_account_from_article(article_link: str) -> dict | None:
 
 
 
 
 @retry(**retry_desc)
 @retry(**retry_desc)
-async def weixin_search(keyword: str, page: str = "1") -> dict | None:
+async def weixin_search(keyword: str, page: str = "1", tlog=None) -> dict | None:
     """微信关键词搜索
     """微信关键词搜索
 
 
     Args:
     Args:
         keyword: 搜索关键词
         keyword: 搜索关键词
         page: 分页游标
         page: 分页游标
+        tlog: 可选的 TaskLogger
     """
     """
     url = f"{BASE_URL}/keyword"
     url = f"{BASE_URL}/keyword"
     payload = json.dumps({"keyword": keyword, "cursor": page})
     payload = json.dumps({"keyword": keyword, "cursor": page})
@@ -147,16 +159,19 @@ async def weixin_search(keyword: str, page: str = "1") -> dict | None:
         logger.error(
         logger.error(
             "weixin_search API请求失败: %s, keyword=%s, page=%s", e, keyword, page
             "weixin_search API请求失败: %s, keyword=%s, page=%s", e, keyword, page
         )
         )
+        if tlog:
+            await tlog.error("api.call", f"搜索API请求失败: {keyword[:40]}", exc=e, phase="search")
         return None
         return None
     return response
     return response
 
 
 
 
 @retry(**retry_desc)
 @retry(**retry_desc)
-async def get_account_from_url(article_url) -> dict | None:
-    """微信关键词搜索
+async def get_account_from_url(article_url, tlog=None) -> dict | None:
+    """通过文章链接获取公众号账号信息
 
 
     Args:
     Args:
         article_url: 文章链接
         article_url: 文章链接
+        tlog: 可选的 TaskLogger
     """
     """
     url = f"{BASE_URL}/account_info"
     url = f"{BASE_URL}/account_info"
     payload = json.dumps({"content_link": article_url})
     payload = json.dumps({"content_link": article_url})
@@ -167,5 +182,7 @@ async def get_account_from_url(article_url) -> dict | None:
         logger.error(
         logger.error(
             "get_account_from_url API请求失败: %s, article_url=%s", e, article_url
             "get_account_from_url API请求失败: %s, article_url=%s", e, article_url
         )
         )
+        if tlog:
+            await tlog.error("api.call", f"账号API请求失败: {article_url[:80]}", exc=e, phase="account_fetch")
         return None
         return None
     return response
     return response

+ 122 - 6
src/infra/xxl_jobs/xxl_executor.py

@@ -15,6 +15,15 @@ from typing import Any, Dict, Optional
 import aiohttp
 import aiohttp
 
 
 from src.config.xxljob import XxlJobConfig
 from src.config.xxljob import XxlJobConfig
+from src.infra.shared.tools import generate_task_trace_id
+from src.infra.observability.context import _current_trace_id, get_log_service
+from src.infra.observability import (
+    LogCategory,
+    LogLevel,
+    LogStatus,
+    LogRecord,
+    TaskEvent,
+)
 from .xxl_decorator import xxl_registry
 from .xxl_decorator import xxl_registry
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -129,7 +138,7 @@ class XxlJobExecutor:
             except Exception:
             except Exception:
                 pass
                 pass
 
 
-    async def _callback(self, log_id: int, log_date_time: int, result: dict) -> None:
+    async def _callback(self, log_id: int, log_date_time: int, result: dict) -> bool:
         """向 Admin 回调上报任务执行结果 —— POST /api/callback
         """向 Admin 回调上报任务执行结果 —— POST /api/callback
 
 
         对标 Java 版的 TriggerCallbackThread.callback()。
         对标 Java 版的 TriggerCallbackThread.callback()。
@@ -138,10 +147,13 @@ class XxlJobExecutor:
             log_id: XXL-JOB 调度日志 ID
             log_id: XXL-JOB 调度日志 ID
             log_date_time: 调度时间戳(毫秒)
             log_date_time: 调度时间戳(毫秒)
             result: handler 返回的 {"code": 200, "msg": "..."} 或异常结果
             result: handler 返回的 {"code": 200, "msg": "..."} 或异常结果
+
+        Returns:
+            True 如果至少向一个 Admin 成功上报,False 全部失败
         """
         """
         if not log_id:
         if not log_id:
             logger.warning("Skip callback: logId is empty")
             logger.warning("Skip callback: logId is empty")
-            return
+            return False
 
 
         body = {
         body = {
             "logId": log_id,
             "logId": log_id,
@@ -159,13 +171,14 @@ class XxlJobExecutor:
                     if resp.status == 200:
                     if resp.status == 200:
                         data = await resp.json()
                         data = await resp.json()
                         if data.get("code") == 200:
                         if data.get("code") == 200:
-                            return
+                            return True
                         logger.warning(
                         logger.warning(
                             "Callback to %s rejected: %s", admin_url, data.get("msg")
                             "Callback to %s rejected: %s", admin_url, data.get("msg")
                         )
                         )
             except Exception as e:
             except Exception as e:
                 logger.warning("Callback to %s failed: %s", admin_url, e)
                 logger.warning("Callback to %s failed: %s", admin_url, e)
         logger.error("All callback attempts failed for logId=%s", log_id)
         logger.error("All callback attempts failed for logId=%s", log_id)
+        return False
 
 
     async def _heartbeat_loop(self) -> None:
     async def _heartbeat_loop(self) -> None:
         """定期心跳:每隔 registry_interval 秒重新注册(续约)"""
         """定期心跳:每隔 registry_interval 秒重新注册(续约)"""
@@ -223,8 +236,13 @@ class XxlJobExecutor:
                 f"Registered: {', '.join(registered) if registered else '(none)'}",
                 f"Registered: {', '.join(registered) if registered else '(none)'}",
             }
             }
 
 
+        # 生成 trace_id 并写入 ContextVar,穿透到 handler → service 全链路
+        trace_id = generate_task_trace_id()
+        _current_trace_id.set(trace_id)
+
         async def _execute_and_callback():
         async def _execute_and_callback():
             """后台执行 handler 并回调 Admin 上报结果"""
             """后台执行 handler 并回调 Admin 上报结果"""
+            _log_task_received(trace_id, handler_name, job_id, param)
             try:
             try:
                 if timeout and timeout > 0:
                 if timeout and timeout > 0:
                     result = await asyncio.wait_for(handler(param), timeout=timeout)
                     result = await asyncio.wait_for(handler(param), timeout=timeout)
@@ -234,17 +252,26 @@ class XxlJobExecutor:
                 logger.error(
                 logger.error(
                     "Job %s(id=%s) timeout after %ss", handler_name, job_id, timeout
                     "Job %s(id=%s) timeout after %ss", handler_name, job_id, timeout
                 )
                 )
+                _log_task_timeout(trace_id, handler_name, job_id, timeout)
                 result = {"code": 500, "msg": f"timeout after {timeout}s"}
                 result = {"code": 500, "msg": f"timeout after {timeout}s"}
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 logger.info("Job %s(id=%s) cancelled", handler_name, job_id)
                 logger.info("Job %s(id=%s) cancelled", handler_name, job_id)
+                _log_task_cancelled(trace_id, handler_name, job_id)
                 result = {"code": 500, "msg": "job cancelled"}
                 result = {"code": 500, "msg": "job cancelled"}
             except Exception as e:
             except Exception as e:
                 logger.exception("Job %s(id=%s) failed", handler_name, job_id)
                 logger.exception("Job %s(id=%s) failed", handler_name, job_id)
+                _log_task_failed(trace_id, handler_name, job_id, e)
                 result = {"code": 500, "msg": str(e)}
                 result = {"code": 500, "msg": str(e)}
 
 
             # 回调 Admin 上报执行结果
             # 回调 Admin 上报执行结果
-            await self._callback(log_id, log_date_time, result)
-            logger.info("Job %s(id=%s) callback done: %s", handler_name, job_id, result)
+            callback_ok = await self._callback(log_id, log_date_time, result)
+            if callback_ok:
+                logger.info(
+                    "Job %s(id=%s) callback succeeded: %s",
+                    handler_name,
+                    job_id,
+                    result,
+                )
 
 
         task = asyncio.create_task(_execute_and_callback())
         task = asyncio.create_task(_execute_and_callback())
         if job_id is not None:
         if job_id is not None:
@@ -254,9 +281,10 @@ class XxlJobExecutor:
         task.add_done_callback(lambda t: self._running_jobs.pop(job_id, None))
         task.add_done_callback(lambda t: self._running_jobs.pop(job_id, None))
 
 
         logger.info(
         logger.info(
-            "Job %s(id=%s) dispatched, will callback when done",
+            "Job %s(id=%s) dispatched, trace_id=%s, will callback when done",
             handler_name,
             handler_name,
             job_id,
             job_id,
+            trace_id,
         )
         )
         return {"code": 200, "msg": None}
         return {"code": 200, "msg": None}
 
 
@@ -336,3 +364,91 @@ class XxlJobExecutor:
             return ip
             return ip
         except Exception:
         except Exception:
             return "127.0.0.1"
             return "127.0.0.1"
+
+
+# ═══════════════════════════════════════════════════════════════
+# SLS 日志辅助函数 —— XXL-JOB 生命周期事件
+# ═══════════════════════════════════════════════════════════════
+
+
+def _log_task_received(
+    trace_id: str, handler_name: str, job_id: int, param: str
+) -> None:
+    """记录 task.received 到 SLS(非阻塞,fire-and-forget)"""
+    try:
+        ls = get_log_service()
+        record = LogRecord(
+            level=LogLevel.INFO,
+            category=LogCategory.TASK,
+            event=TaskEvent.TASK_RECEIVED,
+            message=f"XXL-JOB 调度到达: {handler_name}",
+            trace_id=trace_id,
+            task_name=handler_name,
+            extra={"job_id": job_id, "param": param},
+        )
+        asyncio.create_task(ls.log(record))
+    except RuntimeError:
+        pass  # LogService 未注入(测试环境),静默跳过
+
+
+def _log_task_timeout(
+    trace_id: str, handler_name: str, job_id: int, timeout: int
+) -> None:
+    """记录 task.timeout"""
+    try:
+        ls = get_log_service()
+        record = LogRecord(
+            level=LogLevel.ERROR,
+            category=LogCategory.TASK,
+            event=TaskEvent.TASK_TIMEOUT,
+            message=f"任务超时: {handler_name} (timeout={timeout}s)",
+            trace_id=trace_id,
+            task_name=handler_name,
+            status=LogStatus.TIMEOUT,
+            extra={"job_id": job_id, "timeout_s": timeout},
+        )
+        asyncio.create_task(ls.log(record))
+    except RuntimeError:
+        pass
+
+
+def _log_task_cancelled(trace_id: str, handler_name: str, job_id: int) -> None:
+    """记录 task.cancelled"""
+    try:
+        ls = get_log_service()
+        record = LogRecord(
+            level=LogLevel.WARN,
+            category=LogCategory.TASK,
+            event=TaskEvent.TASK_CANCELLED,
+            message=f"任务被取消: {handler_name}",
+            trace_id=trace_id,
+            task_name=handler_name,
+            status=LogStatus.CANCELLED,
+            extra={"job_id": job_id},
+        )
+        asyncio.create_task(ls.log(record))
+    except RuntimeError:
+        pass
+
+
+def _log_task_failed(
+    trace_id: str, handler_name: str, job_id: int, error: Exception
+) -> None:
+    """记录 task.failed(handler 未捕获的顶层异常)"""
+    try:
+        ls = get_log_service()
+        record = LogRecord(
+            level=LogLevel.ERROR,
+            category=LogCategory.TASK,
+            event=TaskEvent.TASK_FAILED,
+            message=str(error),
+            trace_id=trace_id,
+            task_name=handler_name,
+            status=LogStatus.FAILED,
+            error_type=type(error).__name__,
+            error_message=str(error),
+            extra={"job_id": job_id},
+        )
+        asyncio.create_task(ls.log(record))
+    except RuntimeError:
+        pass

+ 2 - 0
src/server/app.py

@@ -13,6 +13,7 @@ from src.infra.xxl_jobs import XxlJobExecutor
 from src.config.logging_config import setup_logging
 from src.config.logging_config import setup_logging
 from src.handlers import discover_and_register
 from src.handlers import discover_and_register
 from src.handlers._utils import set_db
 from src.handlers._utils import set_db
+from src.infra.observability.context import set_log_service
 from src.server.xxl_endpoints import set_xxl_executor
 from src.server.xxl_endpoints import set_xxl_executor
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -46,6 +47,7 @@ def create_app(global_config: GlobalConfig = None) -> Quart:
 
 
     set_xxl_executor(xxl_executor)
     set_xxl_executor(xxl_executor)
     set_db(db)
     set_db(db)
+    set_log_service(log_service)
 
 
     app = Quart(__name__)
     app = Quart(__name__)
     app = cors(app, allow_origin="*")
     app = cors(app, allow_origin="*")