Skip to content
Merged
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
80 changes: 79 additions & 1 deletion backend/alembic/versions/001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
185 changes: 185 additions & 0 deletions backend/app/api/wechat_stats.py
Original file line number Diff line number Diff line change
@@ -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}
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,
publish_record,
user,
wechat_stats,
)
Base.metadata.create_all(bind=engine)
seed_default_admin()
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
meetings,
publish,
upload,
wechat_stats,
)
from app.config import settings
from app.core.logging import get_logger, setup_logging
Expand Down Expand Up @@ -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")
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.publish_record import PublishRecord
from app.models.user import User, community_users
from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate

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