feat: 完善周期报告与用户行为分析
- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
24
ai_knowledge_base_v2/apps/backend/app/api/behavior.py
Normal file
24
ai_knowledge_base_v2/apps/backend/app/api/behavior.py
Normal 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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user