Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions backend/alembic/versions/008_add_wechat_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Add WeChat article statistics tables.

Revision ID: 008_add_wechat_stats
Revises: 007_add_assignees_and_work_status
Create Date: 2026-02-10
"""

from alembic import op
import sqlalchemy as sa

# revision identifiers
revision = "008_add_wechat_stats"
down_revision = "007_add_assignees_and_work_status"
branch_labels = None
depends_on = None


def upgrade() -> None:
# 文章分类枚举(供 SQLite 使用 VARCHAR 替代)
article_category_enum = sa.Enum(
"release", "technical", "activity",
name="article_category_enum",
)
period_type_enum = sa.Enum(
"daily", "weekly", "monthly", "quarterly",
"semi_annual", "annual",
name="period_type_enum",
)

# ── 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", 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()),
)
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"])
op.create_unique_constraint("uq_article_stat_date", "wechat_article_stats", ["publish_record_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", 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", 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()),
)
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"])
op.create_unique_constraint(
"uq_stats_aggregate",
"wechat_stats_aggregates",
["community_id", "period_type", "period_start", "article_category"],
)


def downgrade() -> None:
op.drop_table("wechat_stats_aggregates")
op.drop_table("wechat_article_stats")
202 changes: 202 additions & 0 deletions backend/app/api/wechat_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""微信公众号文章统计 API 路由。

提供文章分类管理、每日统计录入、趋势数据查询、
排名看板等端点。
"""

from datetime import date
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session

from app.database import get_db
from app.core.dependencies import get_current_user, get_current_community
from app.models import User
from app.models.publish_record import PublishRecord
from app.schemas.wechat_stats import (
ArticleCategoryUpdate,
WechatArticleStatOut,
WechatDailyStatCreate,
WechatDailyStatBatchCreate,
WechatStatsAggregateOut,
TrendResponse,
WechatStatsOverview,
ArticleRankItem,
)
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: Optional[str] = Query(
default=None,
description="文章分类: release/technical/activity, 为空则全部",
),
start_date: Optional[date] = Query(default=None, description="起始日期"),
end_date: Optional[date] = Query(default=None, description="结束日期"),
community_id: int = Depends(get_current_community),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取微信统计趋势折线图数据。

支持 daily / weekly / monthly / quarterly / semi_annual / annual 六种周期。
可按文章分类(release/technical/activity)筛选。
"""
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: Optional[str] = Query(default=None, description="按分类筛选"),
limit: int = Query(default=100, ge=1, le=200, description="返回数量"),
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: Optional[date] = Query(default=None),
end_date: Optional[date] = 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: Optional[date] = Query(default=None),
end_date: Optional[date] = 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}
1 change: 1 addition & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def init_db():
password_reset,
committee,
meeting,
wechat_stats,
)
Base.metadata.create_all(bind=engine)
seed_default_admin()
Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from app.config import settings
from app.database import init_db
from app.api import contents, upload, publish, analytics, auth, communities, committees, channels, meetings, dashboard
from app.api import contents, upload, publish, analytics, auth, communities, committees, channels, meetings, dashboard, wechat_stats


@asynccontextmanager
Expand Down Expand Up @@ -84,6 +84,7 @@ async def general_exception_handler(request: Request, exc: Exception):
app.include_router(channels.router, prefix="/api/channels", tags=["Channels"])
app.include_router(committees.router, prefix="/api/committees", tags=["Governance"])
app.include_router(meetings.router, prefix="/api/meetings", tags=["Governance"])
app.include_router(wechat_stats.router, prefix="/api/wechat-stats", tags=["WeChat Statistics"])


@app.get("/api/health")
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.models.password_reset import PasswordResetToken
from app.models.committee import Committee, CommitteeMember
from app.models.meeting import Meeting, MeetingReminder
from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate

__all__ = [
"User",
Expand All @@ -21,4 +22,6 @@
"CommitteeMember",
"Meeting",
"MeetingReminder",
"WechatArticleStat",
"WechatStatsAggregate",
]
Loading
Loading