Просмотр исходного кода

增加top10查看和详情查看

xueyiming 15 часов назад
Родитель
Сommit
db5f7124d2

+ 7 - 0
.env.example

@@ -43,6 +43,13 @@ MYSQL_WRITE_TIMEOUT_SECONDS=30
 MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 MYSQL_ECHO=false
 
 
+# AIGC article visualization database (read-only account)
+AIGC_READONLY_MYSQL_HOST=
+AIGC_READONLY_MYSQL_PORT=3306
+AIGC_READONLY_MYSQL_USER=
+AIGC_READONLY_MYSQL_PASSWORD=
+AIGC_READONLY_MYSQL_DATABASE=
+
 # Local web authentication. The bootstrap account is only created when the
 # Local web authentication. The bootstrap account is only created when the
 # username does not already exist; changing these values will not reset it.
 # username does not already exist; changing these values will not reset it.
 AUTH_SESSION_HOURS=12
 AUTH_SESSION_HOURS=12

+ 129 - 0
alembic/versions/20260820_23_add_category_channel_content_rank.py

@@ -0,0 +1,129 @@
+"""add daily category channel-content Top 10 ranking table
+
+Revision ID: 20260820_23
+Revises: 20260820_22
+Create Date: 2026-08-20
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260820_23"
+down_revision: str | None = "20260820_22"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "global_category_channel_content_rank_di",
+        sa.Column(
+            "id",
+            sa.BigInteger(),
+            autoincrement=True,
+            nullable=False,
+            comment="自增主键",
+        ),
+        sa.Column(
+            "biz_dt",
+            sa.String(length=8),
+            nullable=False,
+            comment="业务日期,格式YYYYMMDD",
+        ),
+        sa.Column(
+            "stable_id",
+            sa.BigInteger(),
+            nullable=False,
+            comment="全局分类节点稳定ID",
+        ),
+        sa.Column(
+            "metric_type",
+            sa.String(length=32),
+            nullable=False,
+            comment="指标类型:read_rate、avg_read_rate、like_rate",
+        ),
+        sa.Column(
+            "rank_no",
+            sa.Integer(),
+            nullable=False,
+            comment="节点内当前指标排名,范围1至10",
+        ),
+        sa.Column(
+            "channel_content_id",
+            sa.String(length=128),
+            nullable=False,
+            comment="频道内容ID",
+        ),
+        sa.Column(
+            "source_element_count",
+            sa.BigInteger(),
+            nullable=False,
+            comment="节点子树内关联的有效元素数量",
+        ),
+        sa.Column(
+            "contribution_sum",
+            sa.Double(),
+            nullable=False,
+            comment="节点子树内该内容的contribution之和",
+        ),
+        sa.Column(
+            "metric_value",
+            sa.Double(),
+            nullable=False,
+            comment="内容当天对应指标的加权平均原始值",
+        ),
+        sa.Column(
+            "weighted_score",
+            sa.Double(),
+            nullable=False,
+            comment="指标值乘contribution后的加权贡献和",
+        ),
+        sa.Column(
+            "weighted_share",
+            sa.Double(),
+            nullable=False,
+            comment="该内容加权贡献和占节点该指标加权和的比例",
+        ),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+            comment="创建时间",
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+            comment="更新时间",
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "channel_content_id",
+            name="uk_global_category_channel_content_rank_content",
+        ),
+        sa.UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+            name="uk_global_category_channel_content_rank_no",
+        ),
+    )
+    op.create_index(
+        "idx_global_category_channel_content_rank_query",
+        "global_category_channel_content_rank_di",
+        ["biz_dt", "stable_id", "metric_type", "rank_no"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("global_category_channel_content_rank_di")

+ 77 - 0
alembic/versions/20260820_24_use_single_content_contribution.py

@@ -0,0 +1,77 @@
+"""use one maximum-contribution element per ranked content
+
+Revision ID: 20260820_24
+Revises: 20260820_23
+Create Date: 2026-08-20
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260820_24"
+down_revision: str | None = "20260820_23"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+TABLE_NAME = "global_category_channel_content_rank_di"
+
+
+def upgrade() -> None:
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_element_id",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="节点子树内选中的最大contribution来源元素ID",
+        ),
+    )
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_category_stable_id",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="选中来源元素直接归属的分类节点稳定ID",
+        ),
+    )
+    op.alter_column(
+        TABLE_NAME,
+        "contribution_sum",
+        new_column_name="contribution",
+        existing_type=sa.Double(),
+        existing_nullable=False,
+        comment="选中来源元素的单个contribution",
+        existing_comment="节点子树内该内容的contribution之和",
+    )
+    op.drop_column(TABLE_NAME, "source_element_count")
+
+
+def downgrade() -> None:
+    op.add_column(
+        TABLE_NAME,
+        sa.Column(
+            "source_element_count",
+            sa.BigInteger(),
+            server_default="0",
+            nullable=False,
+            comment="节点子树内关联的有效元素数量",
+        ),
+    )
+    op.alter_column(
+        TABLE_NAME,
+        "contribution",
+        new_column_name="contribution_sum",
+        existing_type=sa.Double(),
+        existing_nullable=False,
+        comment="节点子树内该内容的contribution之和",
+        existing_comment="选中来源元素的单个contribution",
+    )
+    op.drop_column(TABLE_NAME, "source_category_stable_id")
+    op.drop_column(TABLE_NAME, "source_element_id")

+ 35 - 1
api/app.py

@@ -1,8 +1,10 @@
 """FastAPI application — category tree API on port 8080."""
 """FastAPI application — category tree API on port 8080."""
+
 from __future__ import annotations
 from __future__ import annotations
 
 
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from pathlib import Path
 from pathlib import Path
+from urllib.parse import urlparse
 
 
 from typing import Literal
 from typing import Literal
 
 
@@ -12,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 from sqlalchemy import text
 from sqlalchemy import text
 from starlette.exceptions import HTTPException as StarletteHTTPException
 from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.responses import RedirectResponse
 
 
 from api.auth_middleware import AuthenticationMiddleware
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
 from api.routers.auth import router as auth_router
@@ -25,7 +28,11 @@ from api.services.agent_catalog import (
 )
 )
 from api.services.auth import ensure_bootstrap_admin
 from api.services.auth import ensure_bootstrap_admin
 from api.services.category_tree import build_category_tree
 from api.services.category_tree import build_category_tree
-from api.services.growth_category_tree import build_growth_category_tree
+from api.services.aigc_article_html import get_latest_article_html
+from api.services.growth_category_tree import (
+    build_growth_category_tree,
+    list_growth_category_channel_contents,
+)
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
 from api.services.demand_grade_videos import list_videos_for_demand_grade
@@ -236,6 +243,33 @@ def growth_category_tree(
     return build_growth_category_tree(biz_dt=biz_dt)
     return build_growth_category_tree(biz_dt=biz_dt)
 
 
 
 
+@app.get("/api/growth-category-tree/{stable_id}/channel-contents")
+def growth_category_channel_contents(
+    stable_id: int,
+    metric: Literal["read_rate", "avg_read_rate", "like_rate"] = Query(...),
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+) -> dict:
+    """Return the selected metric's daily Top 10 contents for one node."""
+    return list_growth_category_channel_contents(
+        stable_id=stable_id,
+        metric=metric,
+        biz_dt=biz_dt,
+    )
+
+
+@app.get("/api/growth-channel-content/{channel_content_id}/view")
+def growth_channel_content_view(channel_content_id: str) -> RedirectResponse:
+    """Redirect to the latest external visualization page for one content."""
+    target = get_latest_article_html(channel_content_id)
+    if target is None:
+        raise HTTPException(status_code=404, detail="article visualization page not found")
+    target = target.strip()
+    parsed = urlparse(target)
+    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+        raise HTTPException(status_code=422, detail="article visualization target is not a URL")
+    return RedirectResponse(url=target, status_code=302)
+
+
 @app.get("/api/demand-belong-category")
 @app.get("/api/demand-belong-category")
 def demand_belong_category() -> dict:
 def demand_belong_category() -> dict:
     """Return all active demand_belong_category rows in one response."""
     """Return all active demand_belong_category rows in one response."""

+ 3 - 3
api/auth_middleware.py

@@ -27,6 +27,8 @@ _NORMAL_USER_PATHS = {
     ("POST", "/api/video-discovery/feedback"),
     ("POST", "/api/video-discovery/feedback"),
 }
 }
 _NORMAL_USER_PATTERNS = (
 _NORMAL_USER_PATTERNS = (
+    re.compile(r"^/api/growth-category-tree/\d+/channel-contents$"),
+    re.compile(r"^/api/growth-channel-content/[^/]+/view$"),
     re.compile(r"^/api/video-discovery/demands/\d+$"),
     re.compile(r"^/api/video-discovery/demands/\d+$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
@@ -45,9 +47,7 @@ def normal_user_can_access(method: str, path: str) -> bool:
 
 
 def _is_protected_path(path: str) -> bool:
 def _is_protected_path(path: str) -> bool:
     return (
     return (
-        path == "/api"
-        or path.startswith("/api/")
-        or path in {"/docs", "/redoc", "/openapi.json"}
+        path == "/api" or path.startswith("/api/") or path in {"/docs", "/redoc", "/openapi.json"}
     )
     )
 
 
 
 

+ 62 - 0
api/services/aigc_article_html.py

@@ -0,0 +1,62 @@
+"""Read the latest article visualization HTML from the external AIGC database."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import Any
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.pool import QueuePool
+
+from supply_infra.config import get_infra_settings
+
+
+@lru_cache(maxsize=1)
+def _get_aigc_readonly_engine() -> Any:
+    settings = get_infra_settings()
+    if not all(
+        (
+            settings.aigc_readonly_mysql_host,
+            settings.aigc_readonly_mysql_user,
+            settings.aigc_readonly_mysql_database,
+        )
+    ):
+        raise RuntimeError("AIGC read-only MySQL connection is not configured")
+    return create_engine(
+        settings.aigc_readonly_mysql_url,
+        poolclass=QueuePool,
+        pool_size=2,
+        max_overflow=0,
+        pool_timeout=10,
+        pool_recycle=1800,
+        pool_pre_ping=True,
+        connect_args={
+            "connect_timeout": 10,
+            "read_timeout": 30,
+            "write_timeout": 30,
+        },
+    )
+
+
+def get_latest_article_html(channel_content_id: str) -> str | None:
+    statement = text(
+        """
+        SELECT t2.html
+        FROM aigc_task_input_usage t1
+        JOIN aigc_task_callback_data t2
+          ON t2.task_instance_id = t1.task_instance_id
+        WHERE t1.biz_unique_id = :channel_content_id
+        ORDER BY t2.id DESC
+        LIMIT 1
+        """
+    )
+    with _get_aigc_readonly_engine().connect() as connection:
+        value = connection.scalar(
+            statement,
+            {"channel_content_id": channel_content_id},
+        )
+    if value is None:
+        return None
+    if isinstance(value, bytes):
+        return value.decode("utf-8", errors="replace")
+    return str(value)

+ 48 - 0
api/services/growth_category_tree.py

@@ -10,6 +10,9 @@ from supply_infra.db.models.global_category_content_weight import (
     GlobalCategoryContentWeight,
     GlobalCategoryContentWeight,
 )
 )
 from supply_infra.db.models.global_v2 import GlobalCategoryV2
 from supply_infra.db.models.global_v2 import GlobalCategoryV2
+from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
+    GlobalCategoryChannelContentRankRepository,
+)
 from supply_infra.db.repositories.global_category_content_weight_repo import (
 from supply_infra.db.repositories.global_category_content_weight_repo import (
     GlobalCategoryContentWeightRepository,
     GlobalCategoryContentWeightRepository,
 )
 )
@@ -20,6 +23,7 @@ GROWTH_DIM_META: list[dict[str, str]] = [
     {"key": "avg_read_rate", "label": "平均阅读率"},
     {"key": "avg_read_rate", "label": "平均阅读率"},
     {"key": "like_rate", "label": "点赞率"},
     {"key": "like_rate", "label": "点赞率"},
 ]
 ]
+GROWTH_DIM_KEYS = frozenset(dim["key"] for dim in GROWTH_DIM_META)
 
 
 
 
 def _normalize_parent_id(parent_id: int | None, category_ids: set[int]) -> int | None:
 def _normalize_parent_id(parent_id: int | None, category_ids: set[int]) -> int | None:
@@ -93,3 +97,47 @@ def build_growth_category_tree(biz_dt: str | None = None) -> dict[str, Any]:
         "dims": GROWTH_DIM_META,
         "dims": GROWTH_DIM_META,
         "nodes": nodes,
         "nodes": nodes,
     }
     }
+
+
+def list_growth_category_channel_contents(
+    *,
+    stable_id: int,
+    metric: str,
+    biz_dt: str | None = None,
+) -> dict[str, Any]:
+    if metric not in GROWTH_DIM_KEYS:
+        raise ValueError(f"Unsupported growth metric: {metric}")
+
+    with get_session() as session:
+        weight_repo = GlobalCategoryContentWeightRepository(session)
+        resolved_dt = biz_dt or weight_repo.get_latest_completed_biz_dt()
+        rows = (
+            GlobalCategoryChannelContentRankRepository(session).list_top(
+                biz_dt=resolved_dt,
+                stable_id=stable_id,
+                metric_type=metric,
+            )
+            if resolved_dt
+            else []
+        )
+        items = [
+            {
+                "rank_no": int(row.rank_no),
+                "channel_content_id": row.channel_content_id,
+                "source_element_id": int(row.source_element_id),
+                "source_category_stable_id": int(row.source_category_stable_id),
+                "contribution": float(row.contribution),
+                "metric_value": float(row.metric_value),
+                "weighted_score": float(row.weighted_score),
+                "weighted_share": float(row.weighted_share),
+            }
+            for row in rows
+        ]
+
+    return {
+        "biz_dt": resolved_dt,
+        "stable_id": stable_id,
+        "metric": metric,
+        "total": len(items),
+        "items": items,
+    }

+ 33 - 3
supply_infra/config.py

@@ -73,6 +73,28 @@ class InfraSettings(BaseSettings):
     )
     )
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
 
 
+    # Read-only AIGC article visualization database
+    aigc_readonly_mysql_host: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_HOST",
+    )
+    aigc_readonly_mysql_port: int = Field(
+        default=3306,
+        alias="AIGC_READONLY_MYSQL_PORT",
+    )
+    aigc_readonly_mysql_user: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_USER",
+    )
+    aigc_readonly_mysql_password: SecretStr = Field(
+        default=SecretStr(""),
+        alias="AIGC_READONLY_MYSQL_PASSWORD",
+    )
+    aigc_readonly_mysql_database: str = Field(
+        default="",
+        alias="AIGC_READONLY_MYSQL_DATABASE",
+    )
+
     # Local web authentication
     # Local web authentication
     auth_session_hours: int = Field(default=12, ge=1, le=168, alias="AUTH_SESSION_HOURS")
     auth_session_hours: int = Field(default=12, ge=1, le=168, alias="AUTH_SESSION_HOURS")
     auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
     auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
@@ -264,9 +286,7 @@ class InfraSettings(BaseSettings):
                 "PIPELINE_HEARTBEAT_SECONDS must be smaller than PIPELINE_LEASE_SECONDS"
                 "PIPELINE_HEARTBEAT_SECONDS must be smaller than PIPELINE_LEASE_SECONDS"
             )
             )
         if self.pipeline_max_active_steps > self.pipeline_worker_processes:
         if self.pipeline_max_active_steps > self.pipeline_worker_processes:
-            raise ValueError(
-                "PIPELINE_MAX_ACTIVE_STEPS cannot exceed PIPELINE_WORKER_PROCESSES"
-            )
+            raise ValueError("PIPELINE_MAX_ACTIVE_STEPS cannot exceed PIPELINE_WORKER_PROCESSES")
         if self.mysql_max_overflow != 0:
         if self.mysql_max_overflow != 0:
             raise ValueError("MYSQL_MAX_OVERFLOW must remain 0 for connection budgeting")
             raise ValueError("MYSQL_MAX_OVERFLOW must remain 0 for connection budgeting")
         required = self.pipeline_required_connections
         required = self.pipeline_required_connections
@@ -295,6 +315,16 @@ class InfraSettings(BaseSettings):
             return self.mysql_pool_size_control
             return self.mysql_pool_size_control
         return self.mysql_pool_size
         return self.mysql_pool_size
 
 
+    @property
+    def aigc_readonly_mysql_url(self) -> str:
+        user = quote_plus(self.aigc_readonly_mysql_user)
+        password = quote_plus(self.aigc_readonly_mysql_password.get_secret_value())
+        return (
+            f"mysql+pymysql://{user}:{password}"
+            f"@{self.aigc_readonly_mysql_host}:{self.aigc_readonly_mysql_port}"
+            f"/{self.aigc_readonly_mysql_database}?charset=utf8mb4"
+        )
+
     @property
     @property
     def pipeline_required_connections(self) -> int:
     def pipeline_required_connections(self) -> int:
         control_processes = 2  # scheduler + reconciler
         control_processes = 2  # scheduler + reconciler

+ 4 - 0
supply_infra/db/models/__init__.py

@@ -26,6 +26,9 @@ from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.global_category_content_weight import (
 from supply_infra.db.models.global_category_content_weight import (
     GlobalCategoryContentWeight,
     GlobalCategoryContentWeight,
 )
 )
+from supply_infra.db.models.global_category_channel_content_rank import (
+    GlobalCategoryChannelContentRank,
+)
 from supply_infra.db.models.global_v2 import (
 from supply_infra.db.models.global_v2 import (
     GlobalCategoryV2,
     GlobalCategoryV2,
     GlobalElementV2,
     GlobalElementV2,
@@ -69,6 +72,7 @@ __all__ = [
     "GlobalTreeCategory",
     "GlobalTreeCategory",
     "GlobalTreeElement",
     "GlobalTreeElement",
     "GlobalCategoryContentWeight",
     "GlobalCategoryContentWeight",
+    "GlobalCategoryChannelContentRank",
     "GlobalCategoryV2",
     "GlobalCategoryV2",
     "GlobalElementV2",
     "GlobalElementV2",
     "GlobalSourceElementData",
     "GlobalSourceElementData",

+ 95 - 0
supply_infra/db/models/global_category_channel_content_rank.py

@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import (
+    BigInteger,
+    DateTime,
+    Double,
+    Index,
+    Integer,
+    String,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class GlobalCategoryChannelContentRank(Base):
+    """Daily Top 10 channel contents contributing to each category metric."""
+
+    __tablename__ = "global_category_channel_content_rank_di"
+    __table_args__ = (
+        UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "channel_content_id",
+            name="uk_global_category_channel_content_rank_content",
+        ),
+        UniqueConstraint(
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+            name="uk_global_category_channel_content_rank_no",
+        ),
+        Index(
+            "idx_global_category_channel_content_rank_query",
+            "biz_dt",
+            "stable_id",
+            "metric_type",
+            "rank_no",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(
+        BigInteger, primary_key=True, autoincrement=True, comment="自增主键"
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False, comment="业务日期,格式YYYYMMDD")
+    stable_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="全局分类节点稳定ID")
+    metric_type: Mapped[str] = mapped_column(
+        String(32), nullable=False, comment="指标类型:read_rate、avg_read_rate、like_rate"
+    )
+    rank_no: Mapped[int] = mapped_column(
+        Integer, nullable=False, comment="节点内当前指标排名,范围1至10"
+    )
+    channel_content_id: Mapped[str] = mapped_column(
+        String(128), nullable=False, comment="频道内容ID"
+    )
+    source_element_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default="0",
+        comment="节点子树内选中的最大contribution来源元素ID",
+    )
+    source_category_stable_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        server_default="0",
+        comment="选中来源元素直接归属的分类节点稳定ID",
+    )
+    contribution: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="选中来源元素的单个contribution"
+    )
+    metric_value: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="内容当天对应指标的加权平均原始值"
+    )
+    weighted_score: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="指标值乘contribution后的加权贡献和"
+    )
+    weighted_share: Mapped[float] = mapped_column(
+        Double, nullable=False, comment="该内容加权贡献和占节点该指标加权和的比例"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now(), comment="创建时间"
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 109 - 0
supply_infra/db/repositories/global_category_channel_content_rank_repo.py

@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.channel_content_data import ChannelContentData
+from supply_infra.db.models.global_category_channel_content_rank import (
+    GlobalCategoryChannelContentRank,
+)
+from supply_infra.db.models.global_category_content_weight import (
+    GlobalCategoryContentWeight,
+)
+from supply_infra.db.models.global_v2 import GlobalSourceElementData
+
+_INSERT_BATCH_SIZE = 1000
+
+
+class GlobalCategoryChannelContentRankRepository:
+    def __init__(self, session: Session) -> None:
+        self.session = session
+
+    def load_daily_topology(self, biz_dt: str) -> list[dict[str, Any]]:
+        rows = self.session.execute(
+            select(
+                GlobalCategoryContentWeight.stable_id,
+                GlobalCategoryContentWeight.parent_stable_id,
+                GlobalCategoryContentWeight.status,
+            ).where(GlobalCategoryContentWeight.biz_dt == biz_dt)
+        ).all()
+        return [
+            {
+                "stable_id": int(row.stable_id),
+                "parent_stable_id": (
+                    int(row.parent_stable_id) if row.parent_stable_id is not None else None
+                ),
+                "status": str(row.status),
+            }
+            for row in rows
+        ]
+
+    def load_direct_content_metrics(self, biz_dt: str) -> list[dict[str, Any]]:
+        rows = self.session.execute(
+            select(
+                GlobalSourceElementData.global_category_stable_id.label("stable_id"),
+                GlobalSourceElementData.source_element_id,
+                GlobalSourceElementData.post_id.label("channel_content_id"),
+                GlobalSourceElementData.contribution,
+                ChannelContentData.read_rate,
+                ChannelContentData.avg_read_rate,
+                ChannelContentData.like_rate,
+            )
+            .join(
+                ChannelContentData,
+                (ChannelContentData.channel_content_id == GlobalSourceElementData.post_id)
+                & (ChannelContentData.dt == biz_dt),
+            )
+            .where(
+                GlobalSourceElementData.contribution.is_not(None),
+                GlobalSourceElementData.global_category_stable_id.is_not(None),
+                GlobalSourceElementData.post_id.is_not(None),
+            )
+        ).all()
+        return [
+            {
+                "stable_id": int(row.stable_id),
+                "source_element_id": int(row.source_element_id),
+                "channel_content_id": str(row.channel_content_id),
+                "contribution": float(row.contribution),
+                "read_rate": float(row.read_rate or 0.0),
+                "avg_read_rate": float(row.avg_read_rate or 0.0),
+                "like_rate": float(row.like_rate or 0.0),
+            }
+            for row in rows
+        ]
+
+    def replace_day(self, biz_dt: str, rows: list[dict[str, Any]]) -> int:
+        self.session.execute(
+            delete(GlobalCategoryChannelContentRank).where(
+                GlobalCategoryChannelContentRank.biz_dt == biz_dt
+            )
+        )
+        for start in range(0, len(rows), _INSERT_BATCH_SIZE):
+            self.session.execute(
+                GlobalCategoryChannelContentRank.__table__.insert(),
+                rows[start : start + _INSERT_BATCH_SIZE],
+            )
+        return len(rows)
+
+    def list_top(
+        self,
+        *,
+        biz_dt: str,
+        stable_id: int,
+        metric_type: str,
+    ) -> list[GlobalCategoryChannelContentRank]:
+        return list(
+            self.session.scalars(
+                select(GlobalCategoryChannelContentRank)
+                .where(
+                    GlobalCategoryChannelContentRank.biz_dt == biz_dt,
+                    GlobalCategoryChannelContentRank.stable_id == stable_id,
+                    GlobalCategoryChannelContentRank.metric_type == metric_type,
+                )
+                .order_by(GlobalCategoryChannelContentRank.rank_no)
+                .limit(10)
+            ).all()
+        )

+ 206 - 0
supply_infra/scheduler/jobs/compute_global_category_channel_content_rankings.py

@@ -0,0 +1,206 @@
+"""Compute daily Top 10 channel-content contributions for every category node."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+from collections import defaultdict
+from datetime import timedelta
+from typing import Any
+
+from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
+    GlobalCategoryChannelContentRankRepository,
+)
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dates import china_now, validate_biz_dt
+from supply_infra.scheduler.cli_result import run_cli
+
+logger = logging.getLogger(__name__)
+
+_TOP_N = 10
+_METRICS = ("read_rate", "avg_read_rate", "like_rate")
+
+
+def _resolve_biz_dt(biz_dt: str | None) -> str:
+    if biz_dt:
+        return validate_biz_dt(biz_dt)
+    return (china_now() - timedelta(days=1)).strftime("%Y%m%d")
+
+
+def _postorder(topology: dict[int, int | None]) -> list[int]:
+    children: dict[int, list[int]] = defaultdict(list)
+    for stable_id, parent_id in topology.items():
+        if parent_id is not None and parent_id in topology:
+            children[parent_id].append(stable_id)
+    for child_ids in children.values():
+        child_ids.sort()
+
+    order: list[int] = []
+    visiting: set[int] = set()
+    visited: set[int] = set()
+
+    def visit(stable_id: int) -> None:
+        if stable_id in visited:
+            return
+        if stable_id in visiting:
+            raise RuntimeError(f"Category cycle detected at stable_id={stable_id}")
+        visiting.add(stable_id)
+        for child_id in children.get(stable_id, []):
+            visit(child_id)
+        visiting.remove(stable_id)
+        visited.add(stable_id)
+        order.append(stable_id)
+
+    for stable_id in sorted(topology):
+        visit(stable_id)
+    return order
+
+
+def _is_better_candidate(
+    candidate: dict[str, float | int],
+    current: dict[str, float | int] | None,
+) -> bool:
+    """Prefer the highest contribution, then the smallest source ID for stable ties."""
+    if current is None:
+        return True
+    candidate_contribution = float(candidate["contribution"])
+    current_contribution = float(current["contribution"])
+    if candidate_contribution != current_contribution:
+        return candidate_contribution > current_contribution
+    return int(candidate["source_element_id"]) < int(current["source_element_id"])
+
+
+def _build_top_rows(
+    *,
+    biz_dt: str,
+    stable_id: int,
+    content_by_id: dict[str, dict[str, float | int]],
+) -> list[dict[str, Any]]:
+    rows: list[dict[str, Any]] = []
+    for metric in _METRICS:
+        ranked = sorted(
+            content_by_id.items(),
+            key=lambda item: (
+                -(float(item[1][metric]) * float(item[1]["contribution"])),
+                item[0],
+            ),
+        )[:_TOP_N]
+        node_weighted_sum = sum(
+            float(values[metric]) * float(values["contribution"])
+            for values in content_by_id.values()
+        )
+        for index, (channel_content_id, values) in enumerate(ranked, start=1):
+            contribution = float(values["contribution"])
+            metric_value = float(values[metric])
+            weighted_score = metric_value * contribution
+            rows.append(
+                {
+                    "biz_dt": biz_dt,
+                    "stable_id": stable_id,
+                    "metric_type": metric,
+                    "rank_no": index,
+                    "channel_content_id": channel_content_id,
+                    "source_element_id": int(values["source_element_id"]),
+                    "source_category_stable_id": int(values["source_category_stable_id"]),
+                    "contribution": contribution,
+                    "metric_value": metric_value,
+                    "weighted_score": weighted_score,
+                    "weighted_share": (
+                        weighted_score / node_weighted_sum if node_weighted_sum else 0.0
+                    ),
+                }
+            )
+    return rows
+
+
+def compute_global_category_channel_content_rankings(
+    biz_dt: str | None = None,
+) -> dict[str, Any]:
+    resolved_dt = _resolve_biz_dt(biz_dt)
+    with get_session() as session:
+        repository = GlobalCategoryChannelContentRankRepository(session)
+        topology_rows = repository.load_daily_topology(resolved_dt)
+        direct_rows = repository.load_direct_content_metrics(resolved_dt)
+
+    if not topology_rows:
+        raise RuntimeError(f"No category weight snapshot for biz_dt={resolved_dt}")
+    incomplete = [int(row["stable_id"]) for row in topology_rows if row["status"] != "completed"]
+    if incomplete:
+        raise RuntimeError(
+            f"Category weights are incomplete for biz_dt={resolved_dt}: {incomplete[:10]}"
+        )
+
+    topology = {
+        int(row["stable_id"]): (
+            int(row["parent_stable_id"]) if row["parent_stable_id"] is not None else None
+        )
+        for row in topology_rows
+    }
+    direct_by_node: dict[int, dict[str, dict[str, float | int]]] = defaultdict(dict)
+    for source in direct_rows:
+        stable_id = int(source["stable_id"])
+        if stable_id not in topology:
+            continue
+        channel_content_id = str(source["channel_content_id"])
+        candidate: dict[str, float | int] = {
+            "source_element_id": int(source["source_element_id"]),
+            "source_category_stable_id": stable_id,
+            "contribution": float(source["contribution"]),
+            **{metric: float(source[metric]) for metric in _METRICS},
+        }
+        current = direct_by_node[stable_id].get(channel_content_id)
+        if _is_better_candidate(candidate, current):
+            direct_by_node[stable_id][channel_content_id] = candidate
+
+    candidates_by_node = dict(direct_by_node)
+    result_rows: list[dict[str, Any]] = []
+    nodes_with_content = 0
+    for stable_id in _postorder(topology):
+        content_by_id = candidates_by_node.pop(stable_id, {})
+        if content_by_id:
+            nodes_with_content += 1
+            result_rows.extend(
+                _build_top_rows(
+                    biz_dt=resolved_dt,
+                    stable_id=stable_id,
+                    content_by_id=content_by_id,
+                )
+            )
+
+        parent_id = topology[stable_id]
+        if parent_id is None or parent_id not in topology:
+            continue
+        parent_content = candidates_by_node.setdefault(parent_id, {})
+        for channel_content_id, candidate in content_by_id.items():
+            current = parent_content.get(channel_content_id)
+            if _is_better_candidate(candidate, current):
+                parent_content[channel_content_id] = candidate
+
+    with get_session() as session:
+        inserted = GlobalCategoryChannelContentRankRepository(session).replace_day(
+            resolved_dt, result_rows
+        )
+
+    result = {
+        "success": True,
+        "biz_dt": resolved_dt,
+        "top_n": _TOP_N,
+        "nodes": len(topology),
+        "nodes_with_content": nodes_with_content,
+        "source_rows": len(direct_rows),
+        "inserted": inserted,
+    }
+    logger.info("Global category channel-content rankings completed: %s", result)
+    return result
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Compute daily category channel-content Top 10 rankings"
+    )
+    parser.add_argument("biz_dt", nargs="?", help="Business date YYYYMMDD")
+    args = parser.parse_args()
+    run_cli(
+        lambda: compute_global_category_channel_content_rankings(args.biz_dt),
+        label="compute_global_category_channel_content_rankings",
+    )

+ 12 - 6
supply_infra/scheduler/jobs/sync_global_v2_snapshot.py

@@ -1,4 +1,5 @@
 """Independent daily ODPS -> MySQL refresh for the global V2 snapshot tables."""
 """Independent daily ODPS -> MySQL refresh for the global V2 snapshot tables."""
+
 from __future__ import annotations
 from __future__ import annotations
 
 
 import argparse
 import argparse
@@ -62,9 +63,9 @@ def sync_global_v2_snapshot(partition_date: str | None = None) -> dict[str, Any]
             elements=elements,
             elements=elements,
             source_elements=source_elements,
             source_elements=source_elements,
         )
         )
-        inserted["channel_content_data"] = ChannelContentDataRepository(
-            session
-        ).insert_new(channel_content)
+        inserted["channel_content_data"] = ChannelContentDataRepository(session).insert_new(
+            channel_content
+        )
 
 
     from supply_infra.scheduler.jobs.compute_global_category_content_weights import (
     from supply_infra.scheduler.jobs.compute_global_category_content_weights import (
         compute_global_category_content_weights,
         compute_global_category_content_weights,
@@ -72,15 +73,20 @@ def sync_global_v2_snapshot(partition_date: str | None = None) -> dict[str, Any]
 
 
     weight_result = compute_global_category_content_weights(resolved_date)
     weight_result = compute_global_category_content_weights(resolved_date)
 
 
+    from supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings import (
+        compute_global_category_channel_content_rankings,
+    )
+
+    ranking_result = compute_global_category_channel_content_rankings(resolved_date)
+
     result = {
     result = {
         "success": True,
         "success": True,
         "partition_date": resolved_date,
         "partition_date": resolved_date,
         "fetched": fetched,
         "fetched": fetched,
         "inserted": inserted,
         "inserted": inserted,
-        "skipped_existing": {
-            table: fetched[table] - inserted[table] for table in fetched
-        },
+        "skipped_existing": {table: fetched[table] - inserted[table] for table in fetched},
         "category_content_weights": weight_result,
         "category_content_weights": weight_result,
+        "category_channel_content_rankings": ranking_result,
     }
     }
     logger.info("Global V2 snapshot sync completed: %s", result)
     logger.info("Global V2 snapshot sync completed: %s", result)
     return result
     return result

+ 65 - 0
tests/api/test_growth_category_tree.py

@@ -1,11 +1,13 @@
 from collections.abc import Generator
 from collections.abc import Generator
 from contextlib import contextmanager
 from contextlib import contextmanager
 from types import SimpleNamespace
 from types import SimpleNamespace
+from unittest.mock import MagicMock
 
 
 from sqlalchemy import create_engine
 from sqlalchemy import create_engine
 from sqlalchemy.orm import Session, sessionmaker
 from sqlalchemy.orm import Session, sessionmaker
 
 
 from api.auth_middleware import normal_user_can_access
 from api.auth_middleware import normal_user_can_access
+from api.services import aigc_article_html
 from api.services import growth_category_tree as service
 from api.services import growth_category_tree as service
 from api.services.growth_category_tree import _build_growth_tree
 from api.services.growth_category_tree import _build_growth_tree
 from supply_infra.db.base import Base
 from supply_infra.db.base import Base
@@ -17,6 +19,8 @@ from supply_infra.db.models.global_v2 import GlobalCategoryV2
 
 
 def test_growth_tree_api_is_available_to_normal_users() -> None:
 def test_growth_tree_api_is_available_to_normal_users() -> None:
     assert normal_user_can_access("GET", "/api/growth-category-tree")
     assert normal_user_can_access("GET", "/api/growth-category-tree")
+    assert normal_user_can_access("GET", "/api/growth-category-tree/38/channel-contents")
+    assert normal_user_can_access("GET", "/api/growth-channel-content/content-1/view")
 
 
 
 
 def test_build_growth_tree_uses_stable_ids_and_daily_scores() -> None:
 def test_build_growth_tree_uses_stable_ids_and_daily_scores() -> None:
@@ -132,3 +136,64 @@ def test_build_growth_category_tree_serializes_before_session_closes(monkeypatch
 
 
     assert payload["biz_dt"] == "20260819"
     assert payload["biz_dt"] == "20260819"
     assert payload["nodes"][0]["weights"]["avg_read_rate"] == 1.5
     assert payload["nodes"][0]["weights"]["avg_read_rate"] == 1.5
+
+
+def test_list_growth_category_channel_contents_serializes_top_ten(monkeypatch) -> None:
+    @contextmanager
+    def get_test_session():
+        yield MagicMock()
+
+    weight_repository = MagicMock()
+    weight_repository.get_latest_completed_biz_dt.return_value = "20260819"
+    rank_repository = MagicMock()
+    rank_repository.list_top.return_value = [
+        SimpleNamespace(
+            rank_no=1,
+            channel_content_id="content-1",
+            source_element_id=101,
+            source_category_stable_id=12,
+            contribution=0.8,
+            metric_value=0.4,
+            weighted_score=1.0,
+            weighted_share=0.25,
+        )
+    ]
+    monkeypatch.setattr(service, "get_session", get_test_session)
+    monkeypatch.setattr(
+        service,
+        "GlobalCategoryContentWeightRepository",
+        lambda _session: weight_repository,
+    )
+    monkeypatch.setattr(
+        service,
+        "GlobalCategoryChannelContentRankRepository",
+        lambda _session: rank_repository,
+    )
+
+    payload = service.list_growth_category_channel_contents(
+        stable_id=38,
+        metric="read_rate",
+    )
+
+    assert payload["biz_dt"] == "20260819"
+    assert payload["total"] == 1
+    assert payload["items"][0]["weighted_score"] == 1.0
+    rank_repository.list_top.assert_called_once_with(
+        biz_dt="20260819",
+        stable_id=38,
+        metric_type="read_rate",
+    )
+
+
+def test_latest_article_html_uses_parameterized_content_id(monkeypatch) -> None:
+    connection = MagicMock()
+    connection.scalar.return_value = b"<html>article</html>"
+    engine = MagicMock()
+    engine.connect.return_value.__enter__.return_value = connection
+    monkeypatch.setattr(aigc_article_html, "_get_aigc_readonly_engine", lambda: engine)
+
+    html = aigc_article_html.get_latest_article_html("content-1")
+
+    assert html == "<html>article</html>"
+    parameters = connection.scalar.call_args.args[1]
+    assert parameters == {"channel_content_id": "content-1"}

+ 106 - 0
tests/supply_infra/scheduler/test_global_category_channel_content_rankings.py

@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings import (
+    _build_top_rows,
+    compute_global_category_channel_content_rankings,
+)
+
+
+def _content(weighted_score: float) -> dict[str, float | int]:
+    return {
+        "source_element_id": int(weighted_score),
+        "source_category_stable_id": 10,
+        "contribution": 1.0,
+        "read_rate": weighted_score,
+        "avg_read_rate": weighted_score * 2,
+        "like_rate": weighted_score / 10,
+    }
+
+
+def test_build_top_rows_keeps_only_ten_per_metric() -> None:
+    content_by_id = {f"content-{index:02d}": _content(float(index)) for index in range(1, 13)}
+
+    rows = _build_top_rows(
+        biz_dt="20260819",
+        stable_id=10,
+        content_by_id=content_by_id,
+    )
+
+    assert len(rows) == 30
+    for metric in ("read_rate", "avg_read_rate", "like_rate"):
+        metric_rows = [row for row in rows if row["metric_type"] == metric]
+        assert [row["rank_no"] for row in metric_rows] == list(range(1, 11))
+        assert metric_rows[0]["channel_content_id"] == "content-12"
+        assert metric_rows[-1]["channel_content_id"] == "content-03"
+
+
+@patch(
+    "supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings."
+    "GlobalCategoryChannelContentRankRepository"
+)
+@patch("supply_infra.scheduler.jobs.compute_global_category_channel_content_rankings.get_session")
+def test_parent_ranking_merges_direct_and_descendant_contents(
+    mock_get_session,
+    repository_cls,
+) -> None:
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repository = repository_cls.return_value
+    repository.load_daily_topology.return_value = [
+        {"stable_id": 1, "parent_stable_id": None, "status": "completed"},
+        {"stable_id": 2, "parent_stable_id": 1, "status": "completed"},
+    ]
+    repository.load_direct_content_metrics.return_value = [
+        {
+            "stable_id": 1,
+            "source_element_id": 11,
+            "channel_content_id": "content-a",
+            "contribution": 1.0,
+            "read_rate": 1.0,
+            "avg_read_rate": 2.0,
+            "like_rate": 0.1,
+        },
+        {
+            "stable_id": 2,
+            "source_element_id": 22,
+            "channel_content_id": "content-a",
+            "contribution": 2.0,
+            "read_rate": 1.0,
+            "avg_read_rate": 2.0,
+            "like_rate": 0.1,
+        },
+        {
+            "stable_id": 2,
+            "source_element_id": 23,
+            "channel_content_id": "content-b",
+            "contribution": 1.0,
+            "read_rate": 2.0,
+            "avg_read_rate": 3.0,
+            "like_rate": 0.2,
+        },
+    ]
+    saved: list[dict] = []
+
+    def replace_day(_biz_dt: str, rows: list[dict]) -> int:
+        saved.extend(deepcopy(rows))
+        return len(rows)
+
+    repository.replace_day.side_effect = replace_day
+
+    result = compute_global_category_channel_content_rankings("20260819")
+
+    root_read_rows = [
+        row for row in saved if row["stable_id"] == 1 and row["metric_type"] == "read_rate"
+    ]
+    assert root_read_rows[0]["channel_content_id"] == "content-a"
+    assert root_read_rows[0]["source_element_id"] == 22
+    assert root_read_rows[0]["source_category_stable_id"] == 2
+    assert root_read_rows[0]["contribution"] == pytest.approx(2.0)
+    assert root_read_rows[0]["weighted_score"] == pytest.approx(2.0)
+    assert root_read_rows[0]["metric_value"] == pytest.approx(1.0)
+    assert result["nodes_with_content"] == 2
+    assert result["source_rows"] == 3

+ 9 - 1
tests/supply_infra/scheduler/test_global_v2_snapshot.py

@@ -63,7 +63,13 @@ def test_snapshot_inserts_new_rows_for_all_three_tables(mock_get_odps, mock_get_
                 "compute_global_category_content_weights",
                 "compute_global_category_content_weights",
                 return_value={"success": True, "completed_total": 1},
                 return_value={"success": True, "completed_total": 1},
             ):
             ):
-                result = sync_global_v2_snapshot("20260819")
+                with patch(
+                    "supply_infra.scheduler.jobs."
+                    "compute_global_category_channel_content_rankings."
+                    "compute_global_category_channel_content_rankings",
+                    return_value={"success": True, "inserted": 3},
+                ) as compute_rankings:
+                    result = sync_global_v2_snapshot("20260819")
 
 
     assert result["success"] is True
     assert result["success"] is True
     odps.fetch_global_categories_v2.assert_called_once_with("20260819")
     odps.fetch_global_categories_v2.assert_called_once_with("20260819")
@@ -77,6 +83,8 @@ def test_snapshot_inserts_new_rows_for_all_three_tables(mock_get_odps, mock_get_
         "channel_content_data": 0,
         "channel_content_data": 0,
     }
     }
     repo_cls.return_value.insert_new.assert_called_once()
     repo_cls.return_value.insert_new.assert_called_once()
+    compute_rankings.assert_called_once_with("20260819")
+    assert result["category_channel_content_rankings"]["inserted"] == 3
 
 
 
 
 @patch("supply_infra.scheduler.jobs.sync_global_v2_snapshot.get_session")
 @patch("supply_infra.scheduler.jobs.sync_global_v2_snapshot.get_session")

+ 35 - 1
web/src/api/growthCategory.ts

@@ -1,4 +1,23 @@
-import type { CategoryTreeResponse } from '../types/category'
+import type { CategoryTreeResponse, WeightDimKey } from '../types/category'
+
+export interface GrowthChannelContentRankItem {
+  rank_no: number
+  channel_content_id: string
+  source_element_id: number
+  source_category_stable_id: number
+  contribution: number
+  metric_value: number
+  weighted_score: number
+  weighted_share: number
+}
+
+export interface GrowthChannelContentRankResponse {
+  biz_dt: string | null
+  stable_id: number
+  metric: WeightDimKey
+  total: number
+  items: GrowthChannelContentRankItem[]
+}
 
 
 export async function fetchGrowthCategoryTree(
 export async function fetchGrowthCategoryTree(
   bizDt?: string | null,
   bizDt?: string | null,
@@ -16,3 +35,18 @@ export async function fetchGrowthCategoryTree(
   }
   }
   return response.json()
   return response.json()
 }
 }
+
+export async function fetchGrowthCategoryTopContents(
+  stableId: number,
+  bizDt: string,
+  metric: WeightDimKey,
+): Promise<GrowthChannelContentRankResponse> {
+  const params = new URLSearchParams({ biz_dt: bizDt, metric })
+  const response = await fetch(
+    `/api/growth-category-tree/${stableId}/channel-contents?${params.toString()}`,
+  )
+  if (!response.ok) {
+    throw new Error(`加载节点文章 Top 10 失败: ${response.status} ${response.statusText}`)
+  }
+  return response.json()
+}

+ 7 - 0
web/src/components/IcicleHeatTree.vue

@@ -87,6 +87,11 @@ const props = withDefaults(
   },
   },
 )
 )
 
 
+const emit = defineEmits<{
+  nodeSelected: [node: { id: number; name: string }]
+  tabChange: [tab: HeatTabKey]
+}>()
+
 const canvasRef = ref<HTMLCanvasElement | null>(null)
 const canvasRef = ref<HTMLCanvasElement | null>(null)
 const viewportRef = ref<HTMLElement | null>(null)
 const viewportRef = ref<HTMLElement | null>(null)
 const wrapRef = ref<HTMLElement | null>(null)
 const wrapRef = ref<HTMLElement | null>(null)
@@ -470,6 +475,7 @@ function cellText(node: PreparedNode): string {
 function onTabClick(key: HeatTabKey) {
 function onTabClick(key: HeatTabKey) {
   if (activeTab.value === key) return
   if (activeTab.value === key) return
   activeTab.value = key
   activeTab.value = key
+  emit('tabChange', key)
 }
 }
 
 
 function resetViewTransform() {
 function resetViewTransform() {
@@ -502,6 +508,7 @@ function selectNode(
 ) {
 ) {
   if (source === 'canvas') clearDemandListSelection()
   if (source === 'canvas') clearDemandListSelection()
   selectedNode.value = node
   selectedNode.value = node
+  emit('nodeSelected', { id: node.id, name: node.name })
   if (withFocus) {
   if (withFocus) {
     focus.value = node
     focus.value = node
     startDepth.value = node.path.length - 1
     startDepth.value = node.path.length - 1

+ 331 - 12
web/src/views/GrowthHeatMapView.vue

@@ -1,8 +1,13 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import IcicleHeatTree from '../components/IcicleHeatTree.vue'
 import IcicleHeatTree from '../components/IcicleHeatTree.vue'
-import { fetchGrowthCategoryTree } from '../api/growthCategory'
-import type { CategoryNode, WeightDimMeta } from '../types/category'
+import {
+  fetchGrowthCategoryTopContents,
+  fetchGrowthCategoryTree,
+  type GrowthChannelContentRankItem,
+} from '../api/growthCategory'
+import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
+import type { HeatTabKey } from '../types/heatTree'
 
 
 const GROWTH_HEAT_TABS: WeightDimMeta[] = [
 const GROWTH_HEAT_TABS: WeightDimMeta[] = [
   { key: 'read_rate', label: '阅读率' },
   { key: 'read_rate', label: '阅读率' },
@@ -14,6 +19,19 @@ const nodes = ref<CategoryNode[]>([])
 const bizDt = ref<string | null>(null)
 const bizDt = ref<string | null>(null)
 const loading = ref(true)
 const loading = ref(true)
 const error = ref<string | null>(null)
 const error = ref<string | null>(null)
+const selectedNode = ref<{ id: number; name: string } | null>(null)
+const activeMetric = ref<WeightDimKey>('read_rate')
+const rankItems = ref<GrowthChannelContentRankItem[]>([])
+const rankLoading = ref(false)
+const rankError = ref<string | null>(null)
+const rankPanelCollapsed = ref(false)
+let rankRequestId = 0
+
+const activeMetricLabel = computed(
+  () =>
+    GROWTH_HEAT_TABS.find((tab) => tab.key === activeMetric.value)?.label ??
+    activeMetric.value,
+)
 
 
 onMounted(async () => {
 onMounted(async () => {
   try {
   try {
@@ -26,22 +44,127 @@ onMounted(async () => {
     loading.value = false
     loading.value = false
   }
   }
 })
 })
+
+async function loadRankings() {
+  if (!selectedNode.value || !bizDt.value) return
+  const requestId = ++rankRequestId
+  rankLoading.value = true
+  rankError.value = null
+  try {
+    const response = await fetchGrowthCategoryTopContents(
+      selectedNode.value.id,
+      bizDt.value,
+      activeMetric.value,
+    )
+    if (requestId === rankRequestId) rankItems.value = response.items ?? []
+  } catch (cause) {
+    if (requestId === rankRequestId) {
+      rankItems.value = []
+      rankError.value = cause instanceof Error ? cause.message : String(cause)
+    }
+  } finally {
+    if (requestId === rankRequestId) rankLoading.value = false
+  }
+}
+
+function onNodeSelected(node: { id: number; name: string }) {
+  selectedNode.value = node
+  void loadRankings()
+}
+
+function onTabChange(tab: HeatTabKey) {
+  if (tab === 'full') return
+  activeMetric.value = tab
+  if (selectedNode.value) void loadRankings()
+}
+
+function formatScore(value: number): string {
+  if (!Number.isFinite(value)) return '—'
+  if (Math.abs(value) >= 100) return value.toFixed(2)
+  if (Math.abs(value) >= 1) return value.toFixed(4)
+  return value.toFixed(6)
+}
+
+function formatContribution(value: number): string {
+  return Number.isFinite(value) ? value.toFixed(2) : '—'
+}
+
+function openArticle(channelContentId: string) {
+  const target = `/api/growth-channel-content/${encodeURIComponent(channelContentId)}/view`
+  window.open(target, '_blank', 'noopener,noreferrer')
+}
 </script>
 </script>
 
 
 <template>
 <template>
   <div class="view">
   <div class="view">
     <div v-if="loading" class="state">正在加载增长分类树与权重…</div>
     <div v-if="loading" class="state">正在加载增长分类树与权重…</div>
     <div v-else-if="error" class="state error">{{ error }}</div>
     <div v-else-if="error" class="state error">{{ error }}</div>
-    <IcicleHeatTree
+    <div
       v-else
       v-else
-      :nodes="nodes"
-      :biz-dt="bizDt"
-      title="增长全局热度地图"
-      subtitle="global_category_v2 分类树 · 横向为层级,纵向为分支规模"
-      :heat-tabs="GROWTH_HEAT_TABS"
-      initial-tab="read_rate"
-      heat-only
-    />
+      class="content-layout"
+      :class="{ 'rank-panel-collapsed': rankPanelCollapsed }"
+    >
+      <IcicleHeatTree
+        class="heat-map"
+        :nodes="nodes"
+        :biz-dt="bizDt"
+        title="增长全局热度地图"
+        subtitle="global_category_v2 分类树 · 横向为层级,纵向为分支规模"
+        :heat-tabs="GROWTH_HEAT_TABS"
+        initial-tab="read_rate"
+        heat-only
+        @node-selected="onNodeSelected"
+        @tab-change="onTabChange"
+      />
+
+      <aside class="rank-panel" :class="{ collapsed: rankPanelCollapsed }">
+        <header class="rank-header" :class="{ collapsed: rankPanelCollapsed }">
+          <button
+            type="button"
+            class="rank-collapse-button"
+            :title="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
+            :aria-label="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
+            :aria-expanded="!rankPanelCollapsed"
+            @click="rankPanelCollapsed = !rankPanelCollapsed"
+          >
+            {{ rankPanelCollapsed ? '‹' : '›' }}
+          </button>
+          <div v-if="!rankPanelCollapsed" class="rank-header-copy">
+            <span class="rank-eyebrow">CHANNEL CONTENT TOP 10</span>
+            <h2>{{ selectedNode?.name || '请选择分类节点' }}</h2>
+            <p v-if="selectedNode">
+              {{ activeMetricLabel }}贡献权重 · stable_id {{ selectedNode.id }}
+            </p>
+            <p v-else>点击左侧热力图节点查看当前维度的文章榜单</p>
+          </div>
+        </header>
+
+        <template v-if="!rankPanelCollapsed">
+          <div v-if="rankLoading" class="rank-state">正在加载 Top 10…</div>
+          <div v-else-if="rankError" class="rank-state error">{{ rankError }}</div>
+          <div v-else-if="!selectedNode" class="rank-state">尚未选择节点</div>
+          <div v-else-if="!rankItems.length" class="rank-state">该节点当前维度暂无文章</div>
+          <ol v-else class="rank-list">
+            <li v-for="item in rankItems" :key="item.channel_content_id">
+              <button type="button" @click="openArticle(item.channel_content_id)">
+                <span class="rank-no">{{ item.rank_no }}</span>
+                <span class="rank-main">
+                  <code>{{ item.channel_content_id }}</code>
+                  <small>
+                    source_element {{ item.source_element_id }}
+                    · contribution {{ formatContribution(item.contribution) }}
+                  </small>
+                </span>
+                <span class="rank-score">
+                  <strong>{{ formatScore(item.weighted_score) }}</strong>
+                  <small>{{ activeMetricLabel }}贡献权重分</small>
+                </span>
+              </button>
+            </li>
+          </ol>
+        </template>
+      </aside>
+    </div>
   </div>
   </div>
 </template>
 </template>
 
 
@@ -52,6 +175,202 @@ onMounted(async () => {
   padding-bottom: 8px;
   padding-bottom: 8px;
 }
 }
 
 
+.content-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 360px;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+}
+
+.content-layout.rank-panel-collapsed {
+  grid-template-columns: minmax(0, 1fr) 44px;
+}
+
+.heat-map {
+  min-width: 0;
+}
+
+.rank-panel {
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.rank-panel.collapsed {
+  background: #f8fafc;
+}
+
+.rank-header {
+  flex-shrink: 0;
+  padding: 16px;
+  border-bottom: 1px solid #e8edf2;
+  background: #f8fafc;
+}
+
+.rank-header.collapsed {
+  display: flex;
+  height: 100%;
+  justify-content: center;
+  padding: 8px 5px;
+  border-bottom: 0;
+}
+
+.rank-header-copy {
+  min-width: 0;
+}
+
+.rank-collapse-button {
+  float: right;
+  width: 28px;
+  height: 28px;
+  margin: 0 0 6px 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 7px;
+  background: #fff;
+  color: #475569;
+  font-size: 18px;
+  line-height: 1;
+  cursor: pointer;
+}
+
+.rank-collapse-button:hover {
+  border-color: #818cf8;
+  color: #4338ca;
+}
+
+.rank-header.collapsed .rank-collapse-button {
+  float: none;
+  margin: 0;
+}
+
+.rank-eyebrow {
+  color: #6366f1;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.1em;
+}
+
+.rank-header h2 {
+  margin: 7px 0 4px;
+  color: #0f172a;
+  font-size: 17px;
+}
+
+.rank-header p {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.rank-state {
+  display: grid;
+  flex: 1;
+  place-items: center;
+  padding: 24px;
+  color: #94a3b8;
+  font-size: 13px;
+  text-align: center;
+}
+
+.rank-state.error {
+  color: #b91c1c;
+}
+
+.rank-list {
+  margin: 0;
+  padding: 8px;
+  overflow-y: auto;
+  list-style: none;
+}
+
+.rank-list li + li {
+  border-top: 1px solid #eef2f7;
+}
+
+.rank-list button {
+  display: grid;
+  width: 100%;
+  padding: 12px 8px;
+  grid-template-columns: 28px minmax(0, 1fr) auto;
+  gap: 8px;
+  align-items: center;
+  border: 0;
+  border-radius: 7px;
+  background: transparent;
+  color: inherit;
+  cursor: pointer;
+  text-align: left;
+}
+
+.rank-list button:hover {
+  background: #f1f5f9;
+}
+
+.rank-no {
+  display: grid;
+  width: 24px;
+  height: 24px;
+  place-items: center;
+  border-radius: 7px;
+  background: #e0e7ff;
+  color: #4338ca;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.rank-main,
+.rank-score {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.rank-main code {
+  overflow: hidden;
+  color: #334155;
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rank-main small,
+.rank-score small {
+  color: #94a3b8;
+  font-size: 10px;
+}
+
+.rank-score {
+  align-items: flex-end;
+}
+
+.rank-score strong {
+  color: #dc2626;
+  font-size: 13px;
+  font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 1100px) {
+  .view {
+    height: auto;
+  }
+
+  .content-layout {
+    grid-template-columns: 1fr;
+    grid-template-rows: 760px minmax(360px, auto);
+  }
+
+  .rank-panel {
+    min-height: 360px;
+  }
+}
+
 .state {
 .state {
   padding: 64px 24px;
   padding: 64px 24px;
   color: #64748b;
   color: #64748b;