diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py index 5a3612c..86ec294 100644 --- a/backend/alembic/versions/001_initial.py +++ b/backend/alembic/versions/001_initial.py @@ -13,7 +13,8 @@ contents, content_collaborators, content_assignees, content_analytics, publish_records, committees, committee_members, - meetings, meeting_reminders, meeting_assignees, meeting_participants + meetings, meeting_reminders, meeting_assignees, meeting_participants, + wechat_article_stats, wechat_stats_aggregates """ from alembic import op @@ -337,7 +338,84 @@ def upgrade(): ) + # ------------------------------------------------------------------ + # wechat_article_stats + # ------------------------------------------------------------------ + op.create_table( + "wechat_article_stats", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("publish_record_id", sa.Integer(), + sa.ForeignKey("publish_records.id", ondelete="CASCADE"), nullable=False), + sa.Column("article_category", + sa.Enum("release", "technical", "activity", name="article_category_enum"), + nullable=False, server_default="technical"), + sa.Column("stat_date", sa.Date(), nullable=False), + sa.Column("read_count", sa.Integer(), server_default="0"), + sa.Column("read_user_count", sa.Integer(), server_default="0"), + sa.Column("read_original_count", sa.Integer(), server_default="0"), + sa.Column("like_count", sa.Integer(), server_default="0"), + sa.Column("wow_count", sa.Integer(), server_default="0"), + sa.Column("share_count", sa.Integer(), server_default="0"), + sa.Column("comment_count", sa.Integer(), server_default="0"), + sa.Column("favorite_count", sa.Integer(), server_default="0"), + sa.Column("forward_count", sa.Integer(), server_default="0"), + sa.Column("new_follower_count", sa.Integer(), server_default="0"), + sa.Column("unfollow_count", sa.Integer(), server_default="0"), + sa.Column("community_id", sa.Integer(), + sa.ForeignKey("communities.id", ondelete="CASCADE"), nullable=False), + sa.Column("collected_at", sa.DateTime(), server_default=sa.func.now()), + sa.UniqueConstraint("publish_record_id", "stat_date", name="uq_article_stat_date"), + ) + op.create_index("ix_wechat_article_stats_publish_record_id", "wechat_article_stats", ["publish_record_id"]) + op.create_index("ix_wechat_article_stats_stat_date", "wechat_article_stats", ["stat_date"]) + op.create_index("ix_wechat_article_stats_article_category", "wechat_article_stats", ["article_category"]) + op.create_index("ix_wechat_article_stats_community_id", "wechat_article_stats", ["community_id"]) + op.create_index("ix_wechat_stats_category_date", "wechat_article_stats", ["article_category", "stat_date"]) + op.create_index("ix_wechat_stats_community_date", "wechat_article_stats", ["community_id", "stat_date"]) + + # ------------------------------------------------------------------ + # wechat_stats_aggregates + # ------------------------------------------------------------------ + op.create_table( + "wechat_stats_aggregates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("community_id", sa.Integer(), + sa.ForeignKey("communities.id", ondelete="CASCADE"), nullable=False), + sa.Column("period_type", + sa.Enum("daily", "weekly", "monthly", "quarterly", + "semi_annual", "annual", name="period_type_enum"), + nullable=False), + sa.Column("period_start", sa.Date(), nullable=False), + sa.Column("period_end", sa.Date(), nullable=False), + sa.Column("article_category", + sa.Enum("release", "technical", "activity", name="article_category_enum"), + nullable=True), + sa.Column("total_articles", sa.Integer(), server_default="0"), + sa.Column("total_read_count", sa.Integer(), server_default="0"), + sa.Column("total_read_user_count", sa.Integer(), server_default="0"), + sa.Column("total_like_count", sa.Integer(), server_default="0"), + sa.Column("total_wow_count", sa.Integer(), server_default="0"), + sa.Column("total_share_count", sa.Integer(), server_default="0"), + sa.Column("total_comment_count", sa.Integer(), server_default="0"), + sa.Column("total_favorite_count", sa.Integer(), server_default="0"), + sa.Column("total_forward_count", sa.Integer(), server_default="0"), + sa.Column("total_new_follower_count", sa.Integer(), server_default="0"), + sa.Column("avg_read_count", sa.Integer(), server_default="0"), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()), + sa.UniqueConstraint( + "community_id", "period_type", "period_start", "article_category", + name="uq_stats_aggregate", + ), + ) + op.create_index("ix_wechat_stats_aggregates_community_id", "wechat_stats_aggregates", ["community_id"]) + op.create_index("ix_wechat_stats_aggregates_period_type", "wechat_stats_aggregates", ["period_type"]) + op.create_index("ix_wechat_stats_aggregates_period_start", "wechat_stats_aggregates", ["period_start"]) + op.create_index("ix_aggregate_period", "wechat_stats_aggregates", ["community_id", "period_type", "period_start"]) + + def downgrade(): + op.drop_table("wechat_stats_aggregates") + op.drop_table("wechat_article_stats") op.drop_table("meeting_participants") op.drop_table("meeting_assignees") op.drop_index("idx_reminder_scheduled_status", table_name="meeting_reminders") diff --git a/backend/app/api/wechat_stats.py b/backend/app/api/wechat_stats.py new file mode 100644 index 0000000..45ad810 --- /dev/null +++ b/backend/app/api/wechat_stats.py @@ -0,0 +1,185 @@ +"""微信公众号文章统计 API 路由。 + +提供文章分类管理、每日统计录入、趋势数据查询、 +排名看板等端点。 +""" + +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_community, get_current_user +from app.database import get_db +from app.models import User +from app.models.publish_record import PublishRecord +from app.schemas.wechat_stats import ( + ArticleCategoryUpdate, + ArticleRankItem, + TrendResponse, + WechatArticleStatOut, + WechatDailyStatBatchCreate, + WechatDailyStatCreate, + WechatStatsOverview, +) +from app.services.wechat_stats import wechat_stats_service + +router = APIRouter() + + +# ── 概览 ── + +@router.get("/overview", response_model=WechatStatsOverview) +def get_wechat_stats_overview( + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取微信公众号统计概览。""" + return wechat_stats_service.get_overview(db, community_id=community_id) + + +# ── 趋势数据(折线图) ── + +@router.get("/trend", response_model=TrendResponse) +def get_wechat_stats_trend( + period_type: str = Query( + default="daily", + description="统计周期: daily/weekly/monthly/quarterly/semi_annual/annual", + pattern="^(daily|weekly|monthly|quarterly|semi_annual|annual)$", + ), + category: str | None = Query( + default=None, + description="文章分类: release/technical/activity,为空则全部", + ), + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取微信统计趋势折线图数据。""" + return wechat_stats_service.get_trend( + db, + community_id=community_id, + period_type=period_type, + category=category, + start_date=start_date, + end_date=end_date, + ) + + +# ── 文章排名 ── + +@router.get("/ranking", response_model=list[ArticleRankItem]) +def get_wechat_article_ranking( + category: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=200), + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取微信文章阅读量排名(最新前 N 篇)。""" + return wechat_stats_service.get_article_ranking( + db, community_id=community_id, category=category, limit=limit + ) + + +# ── 单篇文章每日统计 ── + +@router.get("/articles/{publish_record_id}/daily", response_model=list[WechatArticleStatOut]) +def get_article_daily_stats( + publish_record_id: int, + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取某篇文章的每日统计数据。""" + record = db.query(PublishRecord).get(publish_record_id) + if not record: + raise HTTPException(status_code=404, detail="发布记录不存在") + return wechat_stats_service.get_article_daily_stats( + db, + publish_record_id=publish_record_id, + start_date=start_date, + end_date=end_date, + ) + + +# ── 文章分类管理 ── + +@router.put("/articles/{publish_record_id}/category") +def update_article_category( + publish_record_id: int, + body: ArticleCategoryUpdate, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """更新文章的统计分类。""" + record = db.query(PublishRecord).get(publish_record_id) + if not record: + raise HTTPException(status_code=404, detail="发布记录不存在") + rows = wechat_stats_service.update_article_category( + db, publish_record_id=publish_record_id, category=body.article_category + ) + return {"updated": rows, "article_category": body.article_category} + + +# ── 统计数据录入 ── + +@router.post("/daily-stats", response_model=WechatArticleStatOut, status_code=status.HTTP_201_CREATED) +def create_daily_stat( + body: WechatDailyStatCreate, + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """录入/更新单条每日统计数据。""" + record = db.query(PublishRecord).get(body.publish_record_id) + if not record: + raise HTTPException(status_code=404, detail="发布记录不存在") + if record.channel != "wechat": + raise HTTPException(status_code=400, detail="仅支持微信渠道的文章") + + return wechat_stats_service.create_daily_stat( + db, data=body.model_dump(), community_id=community_id + ) + + +@router.post("/daily-stats/batch", response_model=list[WechatArticleStatOut], status_code=status.HTTP_201_CREATED) +def batch_create_daily_stats( + body: WechatDailyStatBatchCreate, + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """批量录入每日统计数据。""" + return wechat_stats_service.batch_create_daily_stats( + db, items=[item.model_dump() for item in body.items], community_id=community_id + ) + + +# ── 聚合重建 ── + +@router.post("/aggregates/rebuild") +def rebuild_aggregates( + period_type: str = Query( + default="daily", + pattern="^(daily|weekly|monthly|quarterly|semi_annual|annual)$", + ), + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + community_id: int = Depends(get_current_community), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """重建统计聚合数据。""" + count = wechat_stats_service.rebuild_aggregates( + db, + community_id=community_id, + period_type=period_type, + start_date=start_date, + end_date=end_date, + ) + return {"rebuilt_count": count, "period_type": period_type} diff --git a/backend/app/database.py b/backend/app/database.py index 0536132..fa74661 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -50,6 +50,7 @@ def init_db(): password_reset, publish_record, user, + wechat_stats, ) Base.metadata.create_all(bind=engine) seed_default_admin() diff --git a/backend/app/main.py b/backend/app/main.py index be89305..b965295 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ meetings, publish, upload, + wechat_stats, ) from app.config import settings from app.core.logging import get_logger, setup_logging @@ -143,6 +144,7 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(committees.router, prefix="/api/committees", tags=["Governance"]) app.include_router(meetings.router, prefix="/api/meetings", tags=["Governance"]) app.include_router(community_dashboard.router, prefix="/api/communities", tags=["Community Dashboard"]) +app.include_router(wechat_stats.router, prefix="/api/wechat-stats", tags=["WeChat Statistics"]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f4c05fe..5621080 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.password_reset import PasswordResetToken from app.models.publish_record import PublishRecord from app.models.user import User, community_users +from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate __all__ = [ "User", @@ -22,4 +23,6 @@ "Meeting", "MeetingReminder", "MeetingParticipant", + "WechatArticleStat", + "WechatStatsAggregate", ] diff --git a/backend/app/models/wechat_stats.py b/backend/app/models/wechat_stats.py new file mode 100644 index 0000000..7fe5504 --- /dev/null +++ b/backend/app/models/wechat_stats.py @@ -0,0 +1,162 @@ +"""微信公众号文章统计数据模型。 + +支持按文章分类(版本发布/技术文章/活动)存储阅读量、 +粉丝互动数据,以及多维度时间聚合统计。 +""" + +from datetime import datetime + +from sqlalchemy import ( + Column, + Date, + DateTime, + ForeignKey, + Index, + Integer, + UniqueConstraint, +) +from sqlalchemy import ( + Enum as SAEnum, +) +from sqlalchemy.orm import relationship + +from app.database import Base + + +class WechatArticleStat(Base): + """微信公众号文章每日统计快照。 + + 存储每篇已发布到微信的文章的每日阅读量、点赞、分享、 + 评论、收藏、新增粉丝等互动数据。 + """ + __tablename__ = "wechat_article_stats" + + id = Column(Integer, primary_key=True, index=True) + publish_record_id = Column( + Integer, + ForeignKey("publish_records.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # 文章分类:release / technical / activity + article_category = Column( + SAEnum("release", "technical", "activity", name="article_category_enum"), + nullable=False, + default="technical", + index=True, + ) + # 统计日期(每日快照的日期) + stat_date = Column(Date, nullable=False, index=True) + + # ── 阅读量指标 ── + read_count = Column(Integer, default=0) # 总阅读数 + read_user_count = Column(Integer, default=0) # 阅读人数(去重) + read_original_count = Column(Integer, default=0) # 阅读原文数 + + # ── 粉丝互动指标 ── + like_count = Column(Integer, default=0) # 点赞数 + wow_count = Column(Integer, default=0) # 在看数 + share_count = Column(Integer, default=0) # 分享数 + comment_count = Column(Integer, default=0) # 评论数 + favorite_count = Column(Integer, default=0) # 收藏数 + forward_count = Column(Integer, default=0) # 转发数 + + # ── 粉丝增长 ── + new_follower_count = Column(Integer, default=0) # 文章带来的新增关注 + unfollow_count = Column(Integer, default=0) # 文章后取关数 + + # ── 元数据 ── + community_id = Column( + Integer, + ForeignKey("communities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + collected_at = Column(DateTime, default=datetime.utcnow) + + # ── 关系 ── + publish_record = relationship("PublishRecord", backref="wechat_stats") + community = relationship("Community") + + __table_args__ = ( + # 每篇文章每天只保留一条记录 + UniqueConstraint("publish_record_id", "stat_date", name="uq_article_stat_date"), + Index("ix_wechat_stats_category_date", "article_category", "stat_date"), + Index("ix_wechat_stats_community_date", "community_id", "stat_date"), + ) + + def __repr__(self): + return ( + f"" + ) + + +class WechatStatsAggregate(Base): + """微信公众号统计聚合表。 + + 存储按天/周/月/季/半年/年维度预聚合的统计数据, + 便于前端快速展示折线图。 + """ + __tablename__ = "wechat_stats_aggregates" + + id = Column(Integer, primary_key=True, index=True) + community_id = Column( + Integer, + ForeignKey("communities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # 聚合维度:daily / weekly / monthly / quarterly / semi_annual / annual + period_type = Column( + SAEnum( + "daily", "weekly", "monthly", "quarterly", + "semi_annual", "annual", + name="period_type_enum", + ), + nullable=False, + index=True, + ) + # 时间段起始日期 + period_start = Column(Date, nullable=False, index=True) + # 时间段结束日期 + period_end = Column(Date, nullable=False) + # 文章分类(可为 NULL 表示全部分类汇总) + article_category = Column( + SAEnum("release", "technical", "activity", name="article_category_enum"), + nullable=True, + index=True, + ) + + # ── 汇总指标 ── + total_articles = Column(Integer, default=0) + total_read_count = Column(Integer, default=0) + total_read_user_count = Column(Integer, default=0) + total_like_count = Column(Integer, default=0) + total_wow_count = Column(Integer, default=0) + total_share_count = Column(Integer, default=0) + total_comment_count = Column(Integer, default=0) + total_favorite_count = Column(Integer, default=0) + total_forward_count = Column(Integer, default=0) + total_new_follower_count = Column(Integer, default=0) + + # ── 平均值指标 ── + avg_read_count = Column(Integer, default=0) + + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + community = relationship("Community") + + __table_args__ = ( + UniqueConstraint( + "community_id", "period_type", "period_start", "article_category", + name="uq_stats_aggregate", + ), + Index("ix_aggregate_period", "community_id", "period_type", "period_start"), + ) + + def __repr__(self): + return ( + f"" + ) diff --git a/backend/app/schemas/wechat_stats.py b/backend/app/schemas/wechat_stats.py new file mode 100644 index 0000000..c6195fc --- /dev/null +++ b/backend/app/schemas/wechat_stats.py @@ -0,0 +1,151 @@ +"""微信公众号文章统计相关 Pydantic Schema。""" + +from datetime import date, datetime + +from pydantic import BaseModel, Field + +# ── 文章分类 ── + +class ArticleCategoryUpdate(BaseModel): + """更新文章分类请求。""" + article_category: str = Field( + ..., + description="文章分类: release(版本发布), technical(技术文章), activity(活动)", + pattern="^(release|technical|activity)$", + ) + + +# ── 每日统计 ── + +class WechatArticleStatOut(BaseModel): + """单篇文章每日统计输出。""" + id: int + publish_record_id: int + article_category: str + stat_date: date + read_count: int + read_user_count: int + read_original_count: int + like_count: int + wow_count: int + share_count: int + comment_count: int + favorite_count: int + forward_count: int + new_follower_count: int + unfollow_count: int + collected_at: datetime + + model_config = {"from_attributes": True} + + +class WechatDailyStatCreate(BaseModel): + """手动录入/采集每日统计数据。""" + publish_record_id: int + article_category: str = Field( + default="technical", + pattern="^(release|technical|activity)$", + ) + stat_date: date + read_count: int = 0 + read_user_count: int = 0 + read_original_count: int = 0 + like_count: int = 0 + wow_count: int = 0 + share_count: int = 0 + comment_count: int = 0 + favorite_count: int = 0 + forward_count: int = 0 + new_follower_count: int = 0 + unfollow_count: int = 0 + + +class WechatDailyStatBatchCreate(BaseModel): + """批量录入每日统计数据。""" + items: list[WechatDailyStatCreate] + + +# ── 聚合统计 ── + +class WechatStatsAggregateOut(BaseModel): + """聚合统计输出。""" + id: int + community_id: int + period_type: str + period_start: date + period_end: date + article_category: str | None + total_articles: int + total_read_count: int + total_read_user_count: int + total_like_count: int + total_wow_count: int + total_share_count: int + total_comment_count: int + total_favorite_count: int + total_forward_count: int + total_new_follower_count: int + avg_read_count: int + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ── 图表数据 ── + +class TrendDataPoint(BaseModel): + """折线图数据点。""" + date: str = Field(description="日期标签,例如 '2026-02-01' 或 '2026-W05'") + read_count: int = 0 + read_user_count: int = 0 + like_count: int = 0 + wow_count: int = 0 + share_count: int = 0 + comment_count: int = 0 + favorite_count: int = 0 + forward_count: int = 0 + new_follower_count: int = 0 + + +class TrendResponse(BaseModel): + """折线图趋势响应。""" + period_type: str + category: str | None = Field(None, description="null 表示全部分类") + data_points: list[TrendDataPoint] + + +class CategorySummary(BaseModel): + """分类汇总。""" + category: str + category_label: str + article_count: int + total_read_count: int + total_like_count: int + total_share_count: int + total_comment_count: int + avg_read_count: int + + +class WechatStatsOverview(BaseModel): + """微信统计概览。""" + total_wechat_articles: int + total_read_count: int + total_interaction_count: int = Field(description="点赞+在看+分享+评论+收藏") + category_summary: list[CategorySummary] + top_articles: list[dict] = Field( + default_factory=list, + description="阅读量 Top 10 文章", + ) + + +class ArticleRankItem(BaseModel): + """文章排名项。""" + publish_record_id: int + content_id: int + title: str + article_category: str + read_count: int + like_count: int + share_count: int + comment_count: int + published_at: datetime | None diff --git a/backend/app/services/wechat_stats.py b/backend/app/services/wechat_stats.py new file mode 100644 index 0000000..3065dc5 --- /dev/null +++ b/backend/app/services/wechat_stats.py @@ -0,0 +1,554 @@ +"""微信公众号文章统计服务。 + +提供每日统计采集、多维度聚合计算、趋势数据查询等功能。 +""" + +from datetime import date, datetime, timedelta + +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from app.models.content import Content +from app.models.publish_record import PublishRecord +from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate + +# ── 分类标签映射 ── + +CATEGORY_LABELS = { + "release": "版本发布", + "technical": "技术文章", + "activity": "活动", +} + + +class WechatStatsService: + """微信公众号统计服务。""" + + # ── 每日统计 CRUD ── + + def create_daily_stat( + self, db: Session, *, data: dict, community_id: int + ) -> WechatArticleStat: + """创建或更新某篇文章某天的统计。""" + existing = db.query(WechatArticleStat).filter( + WechatArticleStat.publish_record_id == data["publish_record_id"], + WechatArticleStat.stat_date == data["stat_date"], + ).first() + + if existing: + for key, value in data.items(): + if key not in ("publish_record_id", "stat_date"): + setattr(existing, key, value) + existing.collected_at = datetime.utcnow() + db.commit() + db.refresh(existing) + return existing + + stat = WechatArticleStat( + **data, + community_id=community_id, + ) + db.add(stat) + db.commit() + db.refresh(stat) + return stat + + def batch_create_daily_stats( + self, db: Session, *, items: list[dict], community_id: int + ) -> list[WechatArticleStat]: + """批量创建/更新每日统计。""" + results = [] + for item in items: + stat = self.create_daily_stat(db, data=item, community_id=community_id) + results.append(stat) + return results + + def get_article_daily_stats( + self, + db: Session, + *, + publish_record_id: int, + start_date: date | None = None, + end_date: date | None = None, + ) -> list[WechatArticleStat]: + """获取某篇文章的每日统计列表。""" + query = db.query(WechatArticleStat).filter( + WechatArticleStat.publish_record_id == publish_record_id + ) + if start_date: + query = query.filter(WechatArticleStat.stat_date >= start_date) + if end_date: + query = query.filter(WechatArticleStat.stat_date <= end_date) + return query.order_by(WechatArticleStat.stat_date).all() + + # ── 文章分类更新 ── + + def update_article_category( + self, + db: Session, + *, + publish_record_id: int, + category: str, + ) -> int: + """更新某篇文章所有统计记录的分类。返回更新行数。""" + rows = db.query(WechatArticleStat).filter( + WechatArticleStat.publish_record_id == publish_record_id + ).update({"article_category": category}) + db.commit() + return rows + + # ── 概览 ── + + def get_overview(self, db: Session, *, community_id: int) -> dict: + """获取微信统计概览数据。""" + total_wechat = db.query(PublishRecord).filter( + PublishRecord.community_id == community_id, + PublishRecord.channel == "wechat", + PublishRecord.status.in_(["draft", "published"]), + ).count() + + latest_date_subq = db.query( + func.max(WechatArticleStat.stat_date) + ).filter( + WechatArticleStat.community_id == community_id + ).scalar() + + total_read = 0 + total_interaction = 0 + category_summary = [] + + if latest_date_subq: + cat_stats = db.query( + WechatArticleStat.article_category, + func.count(WechatArticleStat.id).label("article_count"), + func.sum(WechatArticleStat.read_count).label("total_read"), + func.sum(WechatArticleStat.like_count).label("total_like"), + func.sum(WechatArticleStat.share_count).label("total_share"), + func.sum(WechatArticleStat.comment_count).label("total_comment"), + ).filter( + WechatArticleStat.community_id == community_id, + WechatArticleStat.stat_date == latest_date_subq, + ).group_by( + WechatArticleStat.article_category + ).all() + + for cat, count, reads, likes, shares, comments in cat_stats: + reads = reads or 0 + likes = likes or 0 + shares = shares or 0 + comments = comments or 0 + total_read += reads + total_interaction += likes + shares + comments + category_summary.append({ + "category": cat, + "category_label": CATEGORY_LABELS.get(cat, cat), + "article_count": count, + "total_read_count": reads, + "total_like_count": likes, + "total_share_count": shares, + "total_comment_count": comments, + "avg_read_count": reads // count if count else 0, + }) + + top_articles = [] + if latest_date_subq: + rows = db.query( + WechatArticleStat, + Content.title, + Content.id.label("content_id"), + PublishRecord.published_at, + ).join( + PublishRecord, + WechatArticleStat.publish_record_id == PublishRecord.id, + ).join( + Content, + PublishRecord.content_id == Content.id, + ).filter( + WechatArticleStat.community_id == community_id, + WechatArticleStat.stat_date == latest_date_subq, + ).order_by( + WechatArticleStat.read_count.desc() + ).limit(10).all() + + for stat, title, content_id, published_at in rows: + top_articles.append({ + "publish_record_id": stat.publish_record_id, + "content_id": content_id, + "title": title, + "article_category": stat.article_category, + "read_count": stat.read_count, + "like_count": stat.like_count, + "share_count": stat.share_count, + "comment_count": stat.comment_count, + "published_at": published_at.isoformat() if published_at else None, + }) + + return { + "total_wechat_articles": total_wechat, + "total_read_count": total_read, + "total_interaction_count": total_interaction, + "category_summary": category_summary, + "top_articles": top_articles, + } + + # ── 趋势数据 ── + + def get_trend( + self, + db: Session, + *, + community_id: int, + period_type: str = "daily", + category: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + ) -> dict: + """获取趋势折线图数据。""" + query = db.query(WechatStatsAggregate).filter( + WechatStatsAggregate.community_id == community_id, + WechatStatsAggregate.period_type == period_type, + ) + if category: + query = query.filter(WechatStatsAggregate.article_category == category) + else: + query = query.filter(WechatStatsAggregate.article_category.is_(None)) + + if start_date: + query = query.filter(WechatStatsAggregate.period_start >= start_date) + if end_date: + query = query.filter(WechatStatsAggregate.period_end <= end_date) + + aggregates = query.order_by(WechatStatsAggregate.period_start).all() + + if aggregates: + data_points = [] + for agg in aggregates: + label = self._period_label(agg.period_type, agg.period_start) + data_points.append({ + "date": label, + "read_count": agg.total_read_count, + "read_user_count": agg.total_read_user_count, + "like_count": agg.total_like_count, + "wow_count": agg.total_wow_count, + "share_count": agg.total_share_count, + "comment_count": agg.total_comment_count, + "favorite_count": agg.total_favorite_count, + "forward_count": agg.total_forward_count, + "new_follower_count": agg.total_new_follower_count, + }) + return {"period_type": period_type, "category": category, "data_points": data_points} + + if period_type == "daily": + return self._compute_daily_trend( + db, community_id=community_id, category=category, + start_date=start_date, end_date=end_date, + ) + + return self._compute_period_trend( + db, community_id=community_id, period_type=period_type, + category=category, start_date=start_date, end_date=end_date, + ) + + def _compute_daily_trend( + self, db: Session, *, community_id: int, category: str | None, + start_date: date | None, end_date: date | None, + ) -> dict: + """从原始表计算每日趋势。""" + query = db.query( + WechatArticleStat.stat_date, + func.sum(WechatArticleStat.read_count).label("read_count"), + func.sum(WechatArticleStat.read_user_count).label("read_user_count"), + func.sum(WechatArticleStat.like_count).label("like_count"), + func.sum(WechatArticleStat.wow_count).label("wow_count"), + func.sum(WechatArticleStat.share_count).label("share_count"), + func.sum(WechatArticleStat.comment_count).label("comment_count"), + func.sum(WechatArticleStat.favorite_count).label("favorite_count"), + func.sum(WechatArticleStat.forward_count).label("forward_count"), + func.sum(WechatArticleStat.new_follower_count).label("new_follower_count"), + ).filter(WechatArticleStat.community_id == community_id) + + if category: + query = query.filter(WechatArticleStat.article_category == category) + if start_date: + query = query.filter(WechatArticleStat.stat_date >= start_date) + if end_date: + query = query.filter(WechatArticleStat.stat_date <= end_date) + + rows = query.group_by(WechatArticleStat.stat_date).order_by(WechatArticleStat.stat_date).all() + + data_points = [] + for row in rows: + data_points.append({ + "date": row.stat_date.isoformat(), + "read_count": row.read_count or 0, + "read_user_count": row.read_user_count or 0, + "like_count": row.like_count or 0, + "wow_count": row.wow_count or 0, + "share_count": row.share_count or 0, + "comment_count": row.comment_count or 0, + "favorite_count": row.favorite_count or 0, + "forward_count": row.forward_count or 0, + "new_follower_count": row.new_follower_count or 0, + }) + + return {"period_type": "daily", "category": category, "data_points": data_points} + + def _compute_period_trend( + self, db: Session, *, community_id: int, period_type: str, + category: str | None, start_date: date | None, end_date: date | None, + ) -> dict: + """从原始每日数据聚合出周/月/季/半年/年趋势。""" + daily_result = self._compute_daily_trend( + db, community_id=community_id, category=category, + start_date=start_date, end_date=end_date, + ) + + if not daily_result["data_points"]: + return {"period_type": period_type, "category": category, "data_points": []} + + buckets: dict[str, dict] = {} + for dp in daily_result["data_points"]: + d = date.fromisoformat(dp["date"]) + bucket_key = self._get_bucket_key(d, period_type) + + if bucket_key not in buckets: + buckets[bucket_key] = { + "date": bucket_key, + "read_count": 0, "read_user_count": 0, "like_count": 0, + "wow_count": 0, "share_count": 0, "comment_count": 0, + "favorite_count": 0, "forward_count": 0, "new_follower_count": 0, + } + b = buckets[bucket_key] + for field in [ + "read_count", "read_user_count", "like_count", "wow_count", + "share_count", "comment_count", "favorite_count", + "forward_count", "new_follower_count", + ]: + b[field] += dp[field] + + data_points = sorted(buckets.values(), key=lambda x: x["date"]) + return {"period_type": period_type, "category": category, "data_points": data_points} + + # ── 聚合计算 ── + + def rebuild_aggregates( + self, db: Session, *, community_id: int, period_type: str = "daily", + start_date: date | None = None, end_date: date | None = None, + ) -> int: + """重建聚合数据。返回更新/创建的记录数。""" + if not end_date: + end_date = date.today() + if not start_date: + start_date = end_date - timedelta(days=365) + + categories = [None, "release", "technical", "activity"] + count = 0 + + for cat in categories: + trend = self._compute_daily_trend( + db, community_id=community_id, category=cat, + start_date=start_date, end_date=end_date, + ) + if not trend["data_points"]: + continue + + buckets: dict[str, list] = {} + for dp in trend["data_points"]: + d = date.fromisoformat(dp["date"]) + bucket_key = self._get_bucket_key(d, period_type) + if bucket_key not in buckets: + buckets[bucket_key] = [] + buckets[bucket_key].append(dp) + + for bucket_key, points in buckets.items(): + period_start_d = self._bucket_key_to_start(bucket_key, period_type) + period_end_d = self._bucket_key_to_end(period_start_d, period_type) + + totals = { + "total_read_count": sum(p["read_count"] for p in points), + "total_read_user_count": sum(p["read_user_count"] for p in points), + "total_like_count": sum(p["like_count"] for p in points), + "total_wow_count": sum(p["wow_count"] for p in points), + "total_share_count": sum(p["share_count"] for p in points), + "total_comment_count": sum(p["comment_count"] for p in points), + "total_favorite_count": sum(p["favorite_count"] for p in points), + "total_forward_count": sum(p["forward_count"] for p in points), + "total_new_follower_count": sum(p["new_follower_count"] for p in points), + } + total_articles = len(points) + avg_read = totals["total_read_count"] // total_articles if total_articles else 0 + + cat_filter = ( + WechatStatsAggregate.article_category == cat + if cat else WechatStatsAggregate.article_category.is_(None) + ) + existing = db.query(WechatStatsAggregate).filter( + WechatStatsAggregate.community_id == community_id, + WechatStatsAggregate.period_type == period_type, + WechatStatsAggregate.period_start == period_start_d, + cat_filter, + ).first() + + if existing: + existing.period_end = period_end_d + existing.total_articles = total_articles + existing.avg_read_count = avg_read + for k, v in totals.items(): + setattr(existing, k, v) + else: + agg = WechatStatsAggregate( + community_id=community_id, + period_type=period_type, + period_start=period_start_d, + period_end=period_end_d, + article_category=cat, + total_articles=total_articles, + avg_read_count=avg_read, + **totals, + ) + db.add(agg) + count += 1 + + db.commit() + return count + + # ── 文章排行榜 ── + + def get_article_ranking( + self, db: Session, *, community_id: int, + category: str | None = None, limit: int = 100, + ) -> list[dict]: + """获取最新前 N 篇文章的统计排名。""" + latest_subq = db.query( + WechatArticleStat.publish_record_id, + func.max(WechatArticleStat.stat_date).label("max_date"), + ).filter( + WechatArticleStat.community_id == community_id, + ).group_by(WechatArticleStat.publish_record_id).subquery() + + query = db.query( + WechatArticleStat, + Content.title, + Content.id.label("content_id"), + PublishRecord.published_at, + ).join( + latest_subq, + and_( + WechatArticleStat.publish_record_id == latest_subq.c.publish_record_id, + WechatArticleStat.stat_date == latest_subq.c.max_date, + ), + ).join( + PublishRecord, + WechatArticleStat.publish_record_id == PublishRecord.id, + ).join( + Content, + PublishRecord.content_id == Content.id, + ).filter(WechatArticleStat.community_id == community_id) + + if category: + query = query.filter(WechatArticleStat.article_category == category) + + rows = query.order_by(WechatArticleStat.read_count.desc()).limit(limit).all() + + result = [] + for stat, title, content_id, published_at in rows: + result.append({ + "publish_record_id": stat.publish_record_id, + "content_id": content_id, + "title": title, + "article_category": stat.article_category, + "read_count": stat.read_count, + "like_count": stat.like_count, + "share_count": stat.share_count, + "comment_count": stat.comment_count, + "published_at": published_at.isoformat() if published_at else None, + }) + return result + + # ── 工具方法 ── + + @staticmethod + def _period_label(period_type: str, d: date) -> str: + if period_type == "daily": + return d.isoformat() + elif period_type == "weekly": + iso = d.isocalendar() + return f"{iso[0]}-W{iso[1]:02d}" + elif period_type == "monthly": + return d.strftime("%Y-%m") + elif period_type == "quarterly": + q = (d.month - 1) // 3 + 1 + return f"{d.year}-Q{q}" + elif period_type == "semi_annual": + h = 1 if d.month <= 6 else 2 + return f"{d.year}-H{h}" + elif period_type == "annual": + return str(d.year) + return d.isoformat() + + @staticmethod + def _get_bucket_key(d: date, period_type: str) -> str: + if period_type == "daily": + return d.isoformat() + elif period_type == "weekly": + iso = d.isocalendar() + return f"{iso[0]}-W{iso[1]:02d}" + elif period_type == "monthly": + return d.strftime("%Y-%m") + elif period_type == "quarterly": + q = (d.month - 1) // 3 + 1 + return f"{d.year}-Q{q}" + elif period_type == "semi_annual": + h = 1 if d.month <= 6 else 2 + return f"{d.year}-H{h}" + elif period_type == "annual": + return str(d.year) + return d.isoformat() + + @staticmethod + def _bucket_key_to_start(key: str, period_type: str) -> date: + if period_type == "daily": + return date.fromisoformat(key) + elif period_type == "weekly": + year, week = key.split("-W") + return date.fromisocalendar(int(year), int(week), 1) + elif period_type == "monthly": + return date.fromisoformat(key + "-01") + elif period_type == "quarterly": + year, q = key.split("-Q") + month = (int(q) - 1) * 3 + 1 + return date(int(year), month, 1) + elif period_type == "semi_annual": + year, h = key.split("-H") + month = 1 if h == "1" else 7 + return date(int(year), month, 1) + elif period_type == "annual": + return date(int(key), 1, 1) + return date.fromisoformat(key) + + @staticmethod + def _bucket_key_to_end(start: date, period_type: str) -> date: + if period_type == "daily": + return start + elif period_type == "weekly": + return start + timedelta(days=6) + elif period_type == "monthly": + if start.month == 12: + return date(start.year + 1, 1, 1) - timedelta(days=1) + return date(start.year, start.month + 1, 1) - timedelta(days=1) + elif period_type == "quarterly": + month = start.month + 3 + year = start.year + (1 if month > 12 else 0) + month = month - 12 if month > 12 else month + return date(year, month, 1) - timedelta(days=1) + elif period_type == "semi_annual": + month = start.month + 6 + year = start.year + (1 if month > 12 else 0) + month = month - 12 if month > 12 else month + return date(year, month, 1) - timedelta(days=1) + elif period_type == "annual": + return date(start.year, 12, 31) + return start + + +wechat_stats_service = WechatStatsService() diff --git a/backend/tests/test_wechat_stats_api.py b/backend/tests/test_wechat_stats_api.py new file mode 100644 index 0000000..74e1d9d --- /dev/null +++ b/backend/tests/test_wechat_stats_api.py @@ -0,0 +1,567 @@ +"""微信公众号文章统计 API 测试。""" + +from datetime import date, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.community import Community +from app.models.content import Content +from app.models.publish_record import PublishRecord +from app.models.user import User +from app.models.wechat_stats import WechatArticleStat + + +# ── 测试专用 fixtures ── + + +@pytest.fixture(scope="function") +def wechat_content(db_session: Session, test_community: Community, test_user: User) -> Content: + """创建一篇用于测试的内容。""" + content = Content( + title="测试微信文章", + content_markdown="# 测试内容", + community_id=test_community.id, + created_by_user_id=test_user.id, + owner_id=test_user.id, + status="published", + ) + db_session.add(content) + db_session.commit() + db_session.refresh(content) + return content + + +@pytest.fixture(scope="function") +def wechat_publish_record( + db_session: Session, wechat_content: Content, test_community: Community +) -> PublishRecord: + """创建一条微信渠道的发布记录。""" + record = PublishRecord( + content_id=wechat_content.id, + channel="wechat", + status="published", + community_id=test_community.id, + published_at=datetime(2025, 1, 15, 10, 0, 0), + ) + db_session.add(record) + db_session.commit() + db_session.refresh(record) + return record + + +@pytest.fixture(scope="function") +def wechat_stat( + db_session: Session, wechat_publish_record: PublishRecord, test_community: Community +) -> WechatArticleStat: + """创建一条微信文章每日统计记录。""" + stat = WechatArticleStat( + publish_record_id=wechat_publish_record.id, + article_category="technical", + stat_date=date(2025, 1, 15), + read_count=1000, + read_user_count=800, + like_count=50, + share_count=20, + comment_count=10, + community_id=test_community.id, + ) + db_session.add(stat) + db_session.commit() + db_session.refresh(stat) + return stat + + +# ── 每日统计录入 ── + + +class TestDailyStatCreate: + """测试 POST /api/wechat-stats/daily-stats 端点。""" + + def test_create_daily_stat_success( + self, + client: TestClient, + auth_headers: dict, + wechat_publish_record: PublishRecord, + ): + """正常录入一条每日统计。""" + payload = { + "publish_record_id": wechat_publish_record.id, + "stat_date": "2025-01-20", + "article_category": "technical", + "read_count": 500, + "like_count": 30, + } + resp = client.post("/api/wechat-stats/daily-stats", json=payload, headers=auth_headers) + assert resp.status_code == 201, resp.json() + data = resp.json() + assert data["publish_record_id"] == wechat_publish_record.id + assert data["read_count"] == 500 + assert data["like_count"] == 30 + assert data["stat_date"] == "2025-01-20" + + def test_create_daily_stat_upsert( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + wechat_publish_record: PublishRecord, + ): + """对已有日期再次录入时应执行 upsert 更新。""" + payload = { + "publish_record_id": wechat_publish_record.id, + "stat_date": "2025-01-15", + "article_category": "technical", + "read_count": 2000, + } + resp = client.post("/api/wechat-stats/daily-stats", json=payload, headers=auth_headers) + assert resp.status_code == 201, resp.json() + assert resp.json()["read_count"] == 2000 + + def test_create_daily_stat_wrong_channel( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + wechat_content: Content, + test_community: Community, + ): + """非 wechat 渠道的发布记录应返回 400。""" + hugo_record = PublishRecord( + content_id=wechat_content.id, + channel="hugo", + status="published", + community_id=test_community.id, + ) + db_session.add(hugo_record) + db_session.commit() + db_session.refresh(hugo_record) + + payload = { + "publish_record_id": hugo_record.id, + "stat_date": "2025-01-20", + "article_category": "technical", + } + resp = client.post("/api/wechat-stats/daily-stats", json=payload, headers=auth_headers) + assert resp.status_code == 400 + + def test_create_daily_stat_not_found( + self, client: TestClient, auth_headers: dict + ): + """发布记录不存在时应返回 404。""" + payload = { + "publish_record_id": 999999, + "stat_date": "2025-01-20", + "article_category": "technical", + } + resp = client.post("/api/wechat-stats/daily-stats", json=payload, headers=auth_headers) + assert resp.status_code == 404 + + def test_requires_auth(self, client: TestClient, wechat_publish_record: PublishRecord): + """未认证时应返回 401。""" + payload = { + "publish_record_id": wechat_publish_record.id, + "stat_date": "2025-01-20", + "article_category": "technical", + } + resp = client.post("/api/wechat-stats/daily-stats", json=payload) + assert resp.status_code == 401 + + +# ── 批量录入 ── + + +class TestBatchDailyStats: + """测试 POST /api/wechat-stats/daily-stats/batch 端点。""" + + def test_batch_create_success( + self, + client: TestClient, + auth_headers: dict, + wechat_publish_record: PublishRecord, + ): + """批量录入多条统计数据。""" + payload = { + "items": [ + { + "publish_record_id": wechat_publish_record.id, + "stat_date": "2025-02-01", + "article_category": "release", + "read_count": 300, + }, + { + "publish_record_id": wechat_publish_record.id, + "stat_date": "2025-02-02", + "article_category": "release", + "read_count": 400, + }, + ] + } + resp = client.post("/api/wechat-stats/daily-stats/batch", json=payload, headers=auth_headers) + assert resp.status_code == 201, resp.json() + data = resp.json() + assert len(data) == 2 + reads = {item["stat_date"]: item["read_count"] for item in data} + assert reads["2025-02-01"] == 300 + assert reads["2025-02-02"] == 400 + + def test_batch_empty_list( + self, client: TestClient, auth_headers: dict + ): + """空列表应返回空数组。""" + resp = client.post("/api/wechat-stats/daily-stats/batch", json={"items": []}, headers=auth_headers) + assert resp.status_code == 201 + assert resp.json() == [] + + +# ── 单篇文章每日统计查询 ── + + +class TestArticleDailyStats: + """测试 GET /api/wechat-stats/articles/{id}/daily 端点。""" + + def test_get_article_daily_stats( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + wechat_publish_record: PublishRecord, + ): + """获取指定文章的每日统计列表。""" + resp = client.get( + f"/api/wechat-stats/articles/{wechat_publish_record.id}/daily", + headers=auth_headers, + ) + assert resp.status_code == 200, resp.json() + data = resp.json() + assert len(data) >= 1 + assert data[0]["read_count"] == 1000 + assert data[0]["stat_date"] == "2025-01-15" + + def test_get_article_daily_stats_with_date_range( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + wechat_publish_record: PublishRecord, + ): + """日期范围过滤应正确返回数据。""" + resp = client.get( + f"/api/wechat-stats/articles/{wechat_publish_record.id}/daily", + params={"start_date": "2025-01-10", "end_date": "2025-01-20"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + def test_get_article_daily_stats_out_of_range( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + wechat_publish_record: PublishRecord, + ): + """日期范围不覆盖数据时应返回空列表。""" + resp = client.get( + f"/api/wechat-stats/articles/{wechat_publish_record.id}/daily", + params={"start_date": "2024-01-01", "end_date": "2024-01-31"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_get_article_daily_stats_not_found( + self, client: TestClient, auth_headers: dict + ): + """发布记录不存在时应返回 404。""" + resp = client.get("/api/wechat-stats/articles/999999/daily", headers=auth_headers) + assert resp.status_code == 404 + + +# ── 文章分类更新 ── + + +class TestArticleCategoryUpdate: + """测试 PUT /api/wechat-stats/articles/{id}/category 端点。""" + + def test_update_category_success( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + wechat_publish_record: PublishRecord, + ): + """成功更新文章分类。""" + resp = client.put( + f"/api/wechat-stats/articles/{wechat_publish_record.id}/category", + json={"article_category": "release"}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.json() + data = resp.json() + assert data["article_category"] == "release" + assert data["updated"] >= 1 + + def test_update_category_invalid_value( + self, + client: TestClient, + auth_headers: dict, + wechat_publish_record: PublishRecord, + ): + """无效分类值应返回 422。""" + resp = client.put( + f"/api/wechat-stats/articles/{wechat_publish_record.id}/category", + json={"article_category": "invalid_category"}, + headers=auth_headers, + ) + assert resp.status_code == 422 + + def test_update_category_not_found( + self, client: TestClient, auth_headers: dict + ): + """发布记录不存在时应返回 404。""" + resp = client.put( + "/api/wechat-stats/articles/999999/category", + json={"article_category": "release"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + +# ── 统计概览 ── + + +class TestWechatStatsOverview: + """测试 GET /api/wechat-stats/overview 端点。""" + + def test_get_overview_empty( + self, client: TestClient, auth_headers: dict + ): + """无数据时返回零值概览。""" + resp = client.get("/api/wechat-stats/overview", headers=auth_headers) + assert resp.status_code == 200, resp.json() + data = resp.json() + assert "total_wechat_articles" in data + assert "total_read_count" in data + assert "category_summary" in data + assert "top_articles" in data + + def test_get_overview_with_data( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """有数据时概览应包含正确统计信息。""" + resp = client.get("/api/wechat-stats/overview", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + # 应至少包含一个发布记录 + assert data["total_wechat_articles"] >= 1 + + def test_overview_requires_auth(self, client: TestClient): + """未认证时应返回 401。""" + resp = client.get("/api/wechat-stats/overview") + assert resp.status_code == 401 + + +# ── 趋势数据 ── + + +class TestWechatStatsTrend: + """测试 GET /api/wechat-stats/trend 端点。""" + + def test_get_trend_daily_empty( + self, client: TestClient, auth_headers: dict + ): + """无数据时应返回空 data_points。""" + resp = client.get("/api/wechat-stats/trend", headers=auth_headers) + assert resp.status_code == 200, resp.json() + data = resp.json() + assert "period_type" in data + assert "data_points" in data + assert data["period_type"] == "daily" + + def test_get_trend_with_data( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """有数据时每日趋势应包含数据点。""" + resp = client.get( + "/api/wechat-stats/trend", + params={"period_type": "daily", "start_date": "2025-01-01", "end_date": "2025-01-31"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["data_points"]) >= 1 + point = data["data_points"][0] + assert "date" in point + assert "read_count" in point + + def test_get_trend_monthly( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """月维度趋势。""" + resp = client.get( + "/api/wechat-stats/trend", + params={"period_type": "monthly"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["period_type"] == "monthly" + + def test_get_trend_invalid_period( + self, client: TestClient, auth_headers: dict + ): + """无效 period_type 应返回 422。""" + resp = client.get( + "/api/wechat-stats/trend", + params={"period_type": "invalid"}, + headers=auth_headers, + ) + assert resp.status_code == 422 + + def test_get_trend_with_category_filter( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """按分类过滤趋势数据。""" + resp = client.get( + "/api/wechat-stats/trend", + params={"period_type": "daily", "category": "technical"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["category"] == "technical" + + +# ── 文章排名 ── + + +class TestWechatStatsRanking: + """测试 GET /api/wechat-stats/ranking 端点。""" + + def test_get_ranking_empty( + self, client: TestClient, auth_headers: dict + ): + """无数据时应返回空列表。""" + resp = client.get("/api/wechat-stats/ranking", headers=auth_headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_get_ranking_with_data( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """有数据时排名列表应包含正确字段。""" + resp = client.get("/api/wechat-stats/ranking", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + item = data[0] + assert "publish_record_id" in item + assert "title" in item + assert "read_count" in item + assert "article_category" in item + + def test_get_ranking_with_category_filter( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """按分类过滤排名。""" + resp = client.get( + "/api/wechat-stats/ranking", + params={"category": "technical"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + for item in data: + assert item["article_category"] == "technical" + + def test_get_ranking_limit( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """limit 参数应正确限制返回数量。""" + resp = client.get( + "/api/wechat-stats/ranking", + params={"limit": 1}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) <= 1 + + def test_get_ranking_requires_auth(self, client: TestClient): + """未认证时应返回 401。""" + resp = client.get("/api/wechat-stats/ranking") + assert resp.status_code == 401 + + +# ── 聚合重建 ── + + +class TestRebuildAggregates: + """测试 POST /api/wechat-stats/aggregates/rebuild 端点。""" + + def test_rebuild_aggregates_no_data( + self, client: TestClient, auth_headers: dict + ): + """无数据时重建应返回 rebuilt_count=0。""" + resp = client.post("/api/wechat-stats/aggregates/rebuild", headers=auth_headers) + assert resp.status_code == 200, resp.json() + data = resp.json() + assert "rebuilt_count" in data + assert "period_type" in data + assert data["period_type"] == "daily" + + def test_rebuild_aggregates_with_data( + self, + client: TestClient, + auth_headers: dict, + wechat_stat: WechatArticleStat, + ): + """有数据时应成功重建聚合。""" + resp = client.post( + "/api/wechat-stats/aggregates/rebuild", + params={ + "period_type": "daily", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + }, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["rebuilt_count"] >= 1 + + def test_rebuild_aggregates_invalid_period( + self, client: TestClient, auth_headers: dict + ): + """无效 period_type 应返回 422。""" + resp = client.post( + "/api/wechat-stats/aggregates/rebuild", + params={"period_type": "bad_period"}, + headers=auth_headers, + ) + assert resp.status_code == 422 + + def test_rebuild_aggregates_requires_auth(self, client: TestClient): + """未认证时应返回 401。""" + resp = client.post("/api/wechat-stats/aggregates/rebuild") + assert resp.status_code == 401 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 33889bd..9b486b1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -44,10 +44,20 @@ 内容日历 - - - 发布管理 - + + + + + 发布渠道 + + + + 微信阅读统计 + +