feat: 完善周期报告与用户行为分析

- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
2026-08-19 11:53:34 +08:00
parent 833763c461
commit efe835be81
75 changed files with 3325 additions and 578 deletions

View File

@@ -29,7 +29,7 @@ router = APIRouter()
@router.get("/content-generation/config/{config_type}")
def get_content_generation_config(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -39,7 +39,7 @@ def get_content_generation_config(
@router.put("/content-generation/config/{config_type}")
def save_content_generation_config(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
payload: ContentGenerationConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
@@ -66,7 +66,7 @@ def save_content_generation_config(
@router.post("/content-generation/config/{config_type}/reset")
def reset_content_generation_config(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -89,7 +89,7 @@ def reset_content_generation_config(
@router.get("/content-generation/config/{config_type}/history")
def content_generation_history(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=5, le=100),
db: Session = Depends(get_db),
@@ -118,7 +118,7 @@ def content_generation_history(
@router.get("/content-generation/config/{config_type}/history/{config_id}")
def content_generation_history_detail(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
config_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
@@ -138,7 +138,7 @@ def content_generation_history_detail(
@router.post("/content-generation/config/{config_type}/history/{config_id}/restore")
def restore_content_generation_config(
config_type: Literal["help_card", "share_draft"],
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
config_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),

View File

@@ -17,7 +17,6 @@ from app.schemas.admin import (
)
from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
from app.services.topic_session_service import TopicSessionService
router = APIRouter()
@@ -94,11 +93,7 @@ def assign_user_entitlement(
)
db.commit()
db.refresh(entitlement)
view = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
)
view = EntitlementService.active_entitlement(db, user)
return api_success(entitlement_dict(view))
@@ -128,11 +123,7 @@ def renew_user_entitlement(
target_id=user.id,
)
db.commit()
view = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
)
view = EntitlementService.active_entitlement(db, user)
return api_success(entitlement_dict(view))
@@ -180,11 +171,9 @@ def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveReque
plan.plan_type = payload.planType
plan.description = payload.description.strip() if payload.description else None
plan.validity_days = payload.validityDays
plan.monthly_topic_limit = payload.monthlyTopicLimit
plan.enable_growth_profile = payload.enableGrowthProfile
plan.enable_periodic_reports = payload.enablePeriodicReports
plan.allow_help_card = payload.allowHelpCard
plan.allow_share_draft = payload.allowShareDraft
plan.deduct_quota = payload.deductQuota
plan.status = payload.status
plan.sort_order = payload.sortOrder

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.services.user_behavior_service import UserBehaviorService
router = APIRouter()
@router.get("/user-behavior/overview")
def behavior_overview(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(UserBehaviorService.overview(db, start=start, end=end))
@router.get("/user-behavior/users")
def behavior_users(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=5, le=100),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(
UserBehaviorService.users(
db,
start=start,
end=end,
keyword=keyword,
page=page,
page_size=pageSize,
)
)
@router.get("/user-behavior/user/{user_id}/timeline")
def behavior_timeline(
user_id: int,
start: date | None = Query(default=None),
end: date | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=10, le=100),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
result = UserBehaviorService.timeline(
db,
user_id=user_id,
start=start,
end=end,
page=page,
page_size=pageSize,
)
if result["user"] is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return api_success(result)

View File

@@ -11,7 +11,7 @@ from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import BaseModel
from pydantic import ValidationError
from sqlalchemy import extract, func, or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -134,11 +134,7 @@ def create_user(
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit()
db.refresh(user)
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@@ -303,15 +299,11 @@ def user_operation_detail(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(
{
"user": _user_dict(user, entitlement_dict(entitlement)),
"metrics": _user_metrics(db, user=user, monthly_topic_limit=entitlement.monthly_topic_limit),
"metrics": _user_metrics(db, user=user),
"growthProfile": growth_profile_dict(GrowthProfileService.get_growth_profile(db, user.id)),
"recentTopics": _recent_topics(db, user.id),
"recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)],
@@ -453,11 +445,7 @@ def update_user(
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit()
db.refresh(user)
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@@ -506,7 +494,6 @@ def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
user_ids = [user.id for user in users]
if not user_ids:
return {}
counts = _monthly_topic_counts(db, user_ids)
explicit = _active_entitlement_rows(db, user_ids)
previous_expired = _latest_expired_entitlement_rows(db, user_ids)
default_plan = EntitlementService.default_plan(db)
@@ -514,14 +501,13 @@ def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
for user in users:
if user.id in explicit:
entitlement, plan = explicit[user.id]
view = view_from_plan(plan, monthly_topic_used=counts.get(user.id, 0), entitlement=entitlement, source="assigned")
view = view_from_plan(plan, entitlement=entitlement, source="assigned")
else:
if default_plan is None:
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0))
view = EntitlementService.active_entitlement(db, user)
else:
view = view_from_plan(
default_plan,
monthly_topic_used=counts.get(user.id, 0),
entitlement=None,
source="default",
)
@@ -578,16 +564,11 @@ def _latest_expired_entitlement_rows(db: Session, user_ids: list[int]) -> dict[i
return result
def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -> dict:
def _user_metrics(db: Session, *, user: User) -> dict:
now = datetime.now(UTC).replace(tzinfo=None)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
recent_start = now - timedelta(days=30)
total_sessions = db.scalar(select(func.count(ChatSession.id)).where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)) or 0
total_messages = db.scalar(select(func.count(ChatMessage.id)).where(ChatMessage.user_id == user.id)) or 0
total_topics = db.scalar(select(func.count(TopicSession.id)).where(TopicSession.user_id == user.id)) or 0
month_topics = db.scalar(
select(func.count(TopicSession.id)).where(TopicSession.user_id == user.id, TopicSession.started_at >= month_start)
) or 0
recent_active_days = db.scalar(
select(func.count(func.distinct(func.date(ChatMessage.created_at)))).where(
ChatMessage.user_id == user.id,
@@ -607,12 +588,9 @@ def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -
).one()
help_card_count = db.scalar(select(func.count(TeacherHelpCard.id)).where(TeacherHelpCard.user_id == user.id)) or 0
share_draft_count = db.scalar(select(func.count(ShareDraft.id)).where(ShareDraft.user_id == user.id)) or 0
usage_ratio = (month_topics / monthly_topic_limit) if monthly_topic_limit else None
return {
"totalSessions": int(total_sessions),
"totalMessages": int(total_messages),
"totalTopics": int(total_topics),
"monthTopics": int(month_topics),
"recentActiveDays": int(recent_active_days),
"lastMessageAt": last_message_at,
"aiRequestCount": int(token_row[5] or 0),
@@ -623,7 +601,7 @@ def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -
"costCurrency": token_row[4] or "CNY",
"helpCardCount": int(help_card_count),
"shareDraftCount": int(share_draft_count),
"isHighFrequency": bool(usage_ratio is not None and usage_ratio >= 0.8) or recent_active_days >= 15,
"isHighFrequency": recent_active_days >= 15,
"isInactive": total_sessions == 0 or last_message_at is None or last_message_at < recent_start,
}
@@ -667,23 +645,6 @@ def _recent_share_drafts(db: Session, user_id: int, *, limit: int = 10) -> list[
)
def _monthly_topic_counts(db: Session, user_ids: list[int]) -> dict[int, int]:
if not user_ids:
return {}
now = datetime.now(UTC).replace(tzinfo=None)
rows = db.execute(
select(TopicSession.user_id, func.count(TopicSession.id))
.where(
TopicSession.user_id.in_(user_ids),
TopicSession.quota_deducted == 1,
extract("year", TopicSession.started_at) == now.year,
extract("month", TopicSession.started_at) == now.month,
)
.group_by(TopicSession.user_id)
).all()
return {int(user_id): int(count) for user_id, count in rows}
def _apply_user_payload(
user: User,
payload: AdminUserCreateRequest | AdminUserImportItem,

View File

@@ -10,6 +10,7 @@ from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendS
from app.schemas.sso import SsoExchangeRequest
from app.services.auth_service import AuthService
from app.services.captcha_service import CaptchaService
from app.services.periodic_report_lazy_service import PeriodicReportLazyService
from app.services.security_state_service import client_ip
from app.services.sso_service import SsoService
@@ -30,12 +31,14 @@ def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(ge
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
result = AuthService.login_with_sms(db, payload.phone, payload.code)
PeriodicReportLazyService.check_after_authentication(db, user=result["user"])
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
@router.post("/sso/exchange")
def exchange_sso(payload: SsoExchangeRequest, request: Request, db: Session = Depends(get_db)) -> dict:
result = SsoService.exchange(db, code=payload.code, ip=client_ip(request))
PeriodicReportLazyService.check_after_authentication(db, user=result["user"])
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.auth_context import UserAuthContext
from app.core.database import get_db
from app.core.dependencies import get_current_user_context
from app.core.responses import api_success
from app.schemas.behavior import UserBehaviorBatchCreate
from app.services.user_behavior_service import UserBehaviorService
router = APIRouter()
@router.post("/events")
def record_behavior_events(
payload: UserBehaviorBatchCreate,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
accepted = UserBehaviorService.record_batch(db, user=current.user, items=payload.events)
return api_success({"accepted": accepted})

View File

@@ -6,6 +6,7 @@ from app.core.dependencies import enforce_admin_access
from app.api import (
admin_auth,
admin_content_generation,
admin_user_behavior,
admin_agent_records,
admin_agent_batch,
admin_dashboard,
@@ -24,6 +25,7 @@ from app.api import (
integration_sso,
user,
voice,
behavior,
)
api_router = APIRouter()
@@ -34,10 +36,12 @@ api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(voice.router, prefix="/voice", tags=["voice"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(behavior.router, prefix="/behavior", tags=["user-behavior"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
guard = [Depends(enforce_admin_access)]
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
api_router.include_router(admin_user_behavior.router, prefix="/admin", tags=["admin-user-behavior"], dependencies=guard)
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
api_router.include_router(admin_agent_batch.router, prefix="/admin", tags=["admin-agent-batch"], dependencies=guard)
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -10,8 +10,8 @@ from app.models.user import User
from app.schemas.user import UserProfile
from app.services.entitlement_service import EntitlementService, entitlement_dict
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
from app.services.topic_session_service import TopicSessionService
from app.services.periodic_report_service import PeriodicReportService, periodic_report_user_dict
from app.services.periodic_report_lazy_service import PeriodicReportLazyService
router = APIRouter()
@@ -22,11 +22,7 @@ def profile(
current_user: User = Depends(get_current_user),
) -> dict:
data = UserProfile.model_validate(current_user).model_dump(mode="json")
view = EntitlementService.active_entitlement(
db,
current_user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, current_user.id),
)
view = EntitlementService.active_entitlement(db, current_user)
data["entitlement"] = entitlement_dict(view)
return api_success(data)
@@ -67,13 +63,20 @@ def growth_profile(
@router.get("/periodic-report/list")
def periodic_reports(
limit: int = 20,
ensure: bool = Query(default=False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
entitlement = EntitlementService.active_entitlement(db, current_user)
if not entitlement.enable_periodic_reports:
return api_success([])
if ensure:
PeriodicReportLazyService.enqueue_missing_reports(db, user=current_user)
db.commit()
reports = PeriodicReportService.list_user_reports(
db,
user_id=current_user.id,
limit=max(1, min(limit, 50)),
statuses=("success",),
statuses=("pending", "running", "success", "failed", "empty"),
)
return api_success([periodic_report_dict(item) for item in reports])
return api_success([periodic_report_user_dict(item) for item in reports])