-
+
{{ item.eventName }}
{{ eventTypeLabel(item.eventType) }} · {{ targetLabel(item.targetType, item.targetId) }}
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/UserDetailDrawer.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/UserDetailDrawer.vue
index 94ca24e..9fc4655 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/components/UserDetailDrawer.vue
+++ b/ai_knowledge_base_v2/apps/admin-web/src/components/UserDetailDrawer.vue
@@ -4,6 +4,7 @@ import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AdminUserDetail } from "../types/api";
+import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
defineEmits<{ "update:modelValue": [value: boolean] }>();
@@ -171,11 +172,11 @@ function reportTriggerLabel(value: string) {
最近登录{{ detail.user.lastLoginAt || "-" }}
+ >{{ formatDateTime(detail.user.lastLoginAt) }}
最近发言{{ detail.metrics.lastMessageAt || "-" }}
+ >{{ formatDateTime(detail.metrics.lastMessageAt) }}
生成记录
- 下次执行:{{ report.nextRunAt }}完成时间:{{ report.finishedAt || "-" }}
+ 下次执行:{{ formatDateTime(report.nextRunAt) }}完成时间:{{ formatDateTime(report.finishedAt) }}
最近错误:{{ report.errorMessage }}
@@ -303,8 +304,8 @@ function reportTriggerLabel(value: string) {
Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}
- 开始:{{ topic.startedAt }}更新:{{ topic.updatedAt }}
+ 开始:{{ formatDateTime(topic.startedAt) }}更新:{{ formatDateTime(topic.updatedAt) }}
@@ -319,10 +320,10 @@ function reportTriggerLabel(value: string) {
}}
开始执行:{{ topic.summary.lastStartedAt }}开始执行:{{ formatDateTime(topic.summary.lastStartedAt) }}
完成:{{ topic.summary.finishedAt }}完成:{{ formatDateTime(topic.summary.finishedAt) }}
@@ -392,8 +393,8 @@ function reportTriggerLabel(value: string) {
>
- 生成:{{ card.createdAt }}复制:{{ card.copiedAt || "-" }}生成:{{ formatDateTime(card.createdAt) }}复制:{{ formatDateTime(card.copiedAt) }}来源:{{ card.source }}
{{ card.content }}
@@ -420,8 +421,8 @@ function reportTriggerLabel(value: string) {
>
- 生成:{{ draft.createdAt }}复制:{{ draft.copiedAt || "-" }}生成:{{ formatDateTime(draft.createdAt) }}复制:{{ formatDateTime(draft.copiedAt) }}来源:{{ draft.source }}
{{ draft.content }}
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue
index a7b20ba..e720a84 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue
+++ b/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue
@@ -12,6 +12,7 @@ import AdminPagination from "./AdminPagination.vue";
import TableRowActions from "./TableRowActions.vue";
import UserDetailDrawer from "./UserDetailDrawer.vue";
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
+import { formatDate, formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
const loading = ref(false);
const users = ref([]);
@@ -150,14 +151,6 @@ function entitlementStatusType(row: AdminUser) {
return "info";
}
-function formatEntitlementDate(value?: string | null) {
- if (!value) return "长期";
- const date = new Date(value);
- return Number.isNaN(date.getTime())
- ? value
- : date.toLocaleDateString("zh-CN");
-}
-
async function assignUserEntitlement(row: AdminUser, planId: number) {
row.entitlement = await api.assignUserEntitlement(row.id, { planId });
ElMessage.success("用户权益已更新");
@@ -186,9 +179,7 @@ async function createStudent() {
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
- expiredAt: studentForm.expiredAt
- ? `${studentForm.expiredAt}T23:59:59`
- : null,
+ expiredAt: toUtcIsoFromBusinessDate(studentForm.expiredAt, "end") ?? null,
});
ElMessage.success("学员已添加");
resetStudentForm();
@@ -412,8 +403,8 @@ async function deleteUser(row: AdminUser) {
>{{ row.entitlement?.name || "默认基础版" }} ·
{{
row.entitlement?.lifecycleStatus === "expired_fallback"
- ? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期`
- : `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}`
+ ? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatDate(row.entitlement?.previousExpiredAt, "长期")} 到期`
+ : `有效至 ${formatDate(row.entitlement?.expiredAt, "长期")}`
}}
@@ -424,7 +415,7 @@ async function deleteUser(row: AdminUser) {
-
+ {{ formatDateTime(row.lastLoginAt) }}
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
index a62d989..07210f1 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
@@ -159,10 +159,15 @@ export const api = {
request(`/admin/dashboard/traffic${queryString({ grain })}`),
userBehaviorOverview: (query: { start?: string; end?: string } = {}) =>
request(`/admin/user-behavior/overview${queryString(query)}`),
- userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
+ userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
request>(`/admin/user-behavior/users${queryString(query)}`),
userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) =>
request(`/admin/user-behavior/user/${userId}/timeline${queryString(query)}`),
+ exportUserBehavior: (query: { start?: string; end?: string; keyword?: string; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
+ download(
+ `/admin/user-behavior/export${queryString(query)}`,
+ `用户行为分析_${query.start || "开始"}_${query.end || "当前"}.xlsx`,
+ ),
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
@@ -335,10 +340,12 @@ export const api = {
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
request>(`/admin/sso/audit/list${queryString(query)}`),
chats: (query: ChatRecordQuery = {}) => request>(`/admin/chat/list${queryString(query)}`),
- chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
+ chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
request(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
request>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
+ exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
+ download(`/admin/chat/${sessionId}/export`, filename),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request>(`/admin/ai-log/list${queryString(query)}`),
@@ -359,7 +366,7 @@ export const api = {
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request>(`/admin/retrieval-log/${id}`),
- attentionList: (query: { page?: number; pageSize?: number } = {}) => request>(`/admin/attention/list${queryString(query)}`),
+ attentionList: (query: { priority?: string; status?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number } = {}) => request>(`/admin/attention/list${queryString(query)}`),
attentionConfig: () => request("/admin/attention/config"),
attentionPreviewConfig: () => request("/admin/attention/preview/config"),
saveAttentionConfig: (payload: AttentionConfig) => request("/admin/attention/config", { method: "PUT", body: JSON.stringify(payload) }),
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
index 3783407..4655a29 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
@@ -608,6 +608,7 @@ export interface AttentionItem {
priority: string;
status: string;
handlerNote?: string | null;
+ targetAvailable?: boolean;
createdAt: string;
}
@@ -725,6 +726,7 @@ export interface ChatDetail {
session: ChatRecord;
messages: ChatMessageRecord[];
messagesPage?: PageResult;
+ focusedMessageId?: number | null;
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts b/ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
new file mode 100644
index 0000000..e6fb00d
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
@@ -0,0 +1,81 @@
+export const BUSINESS_TIME_ZONE = "Asia/Shanghai";
+const BUSINESS_OFFSET = "+08:00";
+const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
+
+export function parseApiDateTime(value?: string | Date | null): Date | null {
+ if (!value) return null;
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value;
+ }
+ const text = value.trim();
+ if (!text) return null;
+ const isoText = text.includes(" ") ? text.replace(" ", "T") : text;
+ const normalized = DATE_ONLY_PATTERN.test(isoText)
+ ? `${isoText}T00:00:00${BUSINESS_OFFSET}`
+ : EXPLICIT_OFFSET_PATTERN.test(isoText)
+ ? isoText
+ : `${isoText}Z`;
+ const parsed = new Date(normalized);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function businessParts(value?: string | Date | null) {
+ const parsed = parseApiDateTime(value);
+ if (!parsed) return null;
+ const parts = new Intl.DateTimeFormat("zh-CN", {
+ timeZone: BUSINESS_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ hourCycle: "h23",
+ }).formatToParts(parsed);
+ return Object.fromEntries(parts.map((part) => [part.type, part.value]));
+}
+
+export function formatDateTime(
+ value?: string | Date | null,
+ fallback = "-",
+): string {
+ const parts = businessParts(value);
+ if (!parts) return fallback;
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
+}
+
+export function formatDate(
+ value?: string | Date | null,
+ fallback = "-",
+): string {
+ const parts = businessParts(value);
+ if (!parts) return fallback;
+ return `${parts.year}-${parts.month}-${parts.day}`;
+}
+
+export function formatTime(
+ value?: string | Date | null,
+ fallback = "-",
+): string {
+ const parts = businessParts(value);
+ if (!parts) return fallback;
+ return `${parts.hour}:${parts.minute}`;
+}
+
+export function toUtcIsoFromBusinessDate(
+ value: string,
+ boundary: "start" | "end" = "start",
+): string | undefined {
+ if (!DATE_ONLY_PATTERN.test(value)) return undefined;
+ const localTime = boundary === "start" ? "00:00:00.000" : "23:59:59.999";
+ return new Date(`${value}T${localTime}${BUSINESS_OFFSET}`).toISOString();
+}
+
+export function toUtcIsoFromBusinessDateTime(value: string): string | undefined {
+ if (!value) return undefined;
+ const normalized = value.length === 16 ? `${value}:00` : value;
+ const parsed = new Date(`${normalized}${BUSINESS_OFFSET}`);
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
+}
diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0040_human_attention_filters.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0040_human_attention_filters.py
new file mode 100644
index 0000000..3c86b71
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0040_human_attention_filters.py
@@ -0,0 +1,25 @@
+"""add human attention time filter index
+
+Revision ID: 0040_attention_filters
+Revises: 0039_attention_config
+"""
+
+from alembic import op
+
+
+revision = "0040_attention_filters"
+down_revision = "0039_attention_config"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_index(
+ "ix_human_attention_created",
+ "sys_human_attention_record",
+ ["created_at", "id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_human_attention_created", table_name="sys_human_attention_record")
diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0041_utc_time_contract.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0041_utc_time_contract.py
new file mode 100644
index 0000000..17d85ac
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0041_utc_time_contract.py
@@ -0,0 +1,87 @@
+"""normalize legacy server timestamps to UTC
+
+Revision ID: 0041_utc_time_contract
+Revises: 0040_attention_filters
+"""
+
+from alembic import op
+from sqlalchemy import text
+
+
+revision = "0041_utc_time_contract"
+down_revision = "0040_attention_filters"
+branch_labels = None
+depends_on = None
+
+
+# Columns populated by MySQL CURRENT_TIMESTAMP. Fields that application
+# services already wrote explicitly in UTC are intentionally excluded.
+SERVER_TIMESTAMP_COLUMNS: dict[str, tuple[str, ...]] = {
+ "sys_admin": ("created_at", "updated_at"),
+ "sys_agent_batch_test": ("created_at", "updated_at"),
+ "sys_agent_batch_test_item": ("created_at", "updated_at"),
+ "sys_ai_request_log": ("created_at",),
+ "sys_chat_session": ("created_at", "updated_at"),
+ "sys_content_generation_config": ("updated_at",),
+ "sys_entitlement_plan": ("created_at", "updated_at"),
+ "sys_growth_profile_revision": ("created_at",),
+ "sys_human_attention_history": ("created_at",),
+ "sys_human_attention_job": ("created_at",),
+ "sys_human_attention_record": ("created_at", "updated_at"),
+ "sys_knowledge": ("created_at", "updated_at"),
+ "sys_knowledge_publish_log": ("created_at",),
+ "sys_knowledge_retrieval_log": ("created_at",),
+ "sys_knowledge_review_record": ("created_at",),
+ "sys_knowledge_sync_job": ("created_at",),
+ "sys_knowledge_version": ("created_at",),
+ "sys_log_retention_policy": ("updated_at",),
+ "sys_message_feedback": ("created_at",),
+ "sys_operation_log": ("created_at",),
+ "sys_periodic_report": ("created_at", "updated_at"),
+ "sys_prompt": ("updated_at",),
+ "sys_question_insight_cleaned_question": ("created_at", "updated_at"),
+ "sys_role": ("created_at", "updated_at"),
+ "sys_share_draft": ("created_at",),
+ "sys_sso_client": ("created_at", "updated_at"),
+ "sys_sso_login_audit": ("created_at",),
+ "sys_storage_snapshot": ("created_at",),
+ "sys_system_config": ("updated_at",),
+ "sys_teacher_help_card": ("created_at",),
+ "sys_topic_session": ("created_at", "updated_at"),
+ "sys_topic_summary": ("created_at", "updated_at"),
+ "sys_user": ("created_at", "updated_at"),
+ "sys_user_behavior_event": ("created_at",),
+ "sys_user_entitlement": ("created_at", "updated_at"),
+ "sys_user_external_identity": ("created_at", "updated_at"),
+ "sys_user_growth_profile": ("created_at", "updated_at"),
+ "sys_user_kb": ("created_at", "updated_at"),
+}
+
+
+def _server_offset_seconds() -> int:
+ connection = op.get_bind()
+ if connection.dialect.name != "mysql":
+ return 0
+ return int(connection.scalar(text("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())")) or 0)
+
+
+def _shift_server_timestamps(offset_seconds: int) -> None:
+ if not offset_seconds:
+ return
+ connection = op.get_bind()
+ operation = "SUB" if offset_seconds > 0 else "ADD"
+ seconds = abs(offset_seconds)
+ for table_name, columns in SERVER_TIMESTAMP_COLUMNS.items():
+ assignments = ", ".join(
+ f"`{column}` = DATE_{operation}(`{column}`, INTERVAL {seconds} SECOND)"
+ for column in columns
+ )
+ connection.execute(text(f"UPDATE `{table_name}` SET {assignments}"))
+
+
+def upgrade() -> None:
+ _shift_server_timestamps(_server_offset_seconds())
+
+
+def downgrade() -> None:
+ _shift_server_timestamps(-_server_offset_seconds())
diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0042_record_export_permission.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0042_record_export_permission.py
new file mode 100644
index 0000000..084ced9
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0042_record_export_permission.py
@@ -0,0 +1,48 @@
+"""add granular record export permission
+
+Revision ID: 0042_record_export_permission
+Revises: 0041_utc_time_contract
+"""
+
+from __future__ import annotations
+
+import json
+
+from alembic import op
+from sqlalchemy import text
+
+
+revision = "0042_record_export_permission"
+down_revision = "0041_utc_time_contract"
+branch_labels = None
+depends_on = None
+
+
+def _rewrite_permissions(*, add_export: bool) -> None:
+ connection = op.get_bind()
+ rows = connection.execute(text("SELECT id, permissions FROM sys_role")).mappings().all()
+ for row in rows:
+ try:
+ permissions = list(json.loads(row["permissions"] or "[]"))
+ except (TypeError, ValueError, json.JSONDecodeError):
+ continue
+ changed = False
+ if add_export and "records.view" in permissions and "records.export" not in permissions:
+ permissions.append("records.export")
+ changed = True
+ if not add_export and "records.export" in permissions:
+ permissions = [item for item in permissions if item != "records.export"]
+ changed = True
+ if changed:
+ connection.execute(
+ text("UPDATE sys_role SET permissions = :permissions WHERE id = :role_id"),
+ {"permissions": json.dumps(sorted(set(permissions))), "role_id": row["id"]},
+ )
+
+
+def upgrade() -> None:
+ _rewrite_permissions(add_export=True)
+
+
+def downgrade() -> None:
+ _rewrite_permissions(add_export=False)
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
index 09f6e00..a2b829a 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
@@ -10,7 +10,9 @@ 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.core.time_utils import as_utc_naive, business_day_boundary
from app.models.admin import Admin
+from app.models.chat import ChatMessage
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
@@ -95,7 +97,9 @@ def _cleanup_before(payload: dict) -> datetime:
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
- return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
+ if len(raw) == 10:
+ return business_day_boundary(datetime.fromisoformat(raw).date())
+ return as_utc_naive(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@@ -151,21 +155,46 @@ def _knowledge_type_label(value: str) -> str:
@router.get("/attention/list")
def attention_list(
- priority: str = Query(default=""),
- statusValue: str = Query(default="", alias="status"),
+ priority: str = Query(default="", pattern="^(|urgent|important|normal)$"),
+ statusValue: str = Query(default="", alias="status", pattern="^(|pending|processing|resolved|ignored)$"),
+ dateFrom: datetime | None = Query(default=None),
+ dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
+ if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
query = select(HumanAttentionRecord)
if priority:
query = query.where(HumanAttentionRecord.priority == priority)
if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue)
+ if dateFrom is not None:
+ query = query.where(HumanAttentionRecord.created_at >= dateFrom)
+ if dateTo is not None:
+ query = query.where(HumanAttentionRecord.created_at <= dateTo)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
- return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
+ message_keys = {
+ (message_id, session_id)
+ for message_id, session_id in db.execute(
+ select(ChatMessage.id, ChatMessage.session_id).where(
+ ChatMessage.id.in_([item.message_id for item in rows])
+ )
+ ).all()
+ } if rows else set()
+ items = [
+ _attention_dict(
+ item,
+ target_available=(item.message_id, item.session_id) in message_keys,
+ )
+ for item in rows
+ ]
+ return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}")
@@ -251,8 +280,8 @@ def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
return data
-def _attention_dict(item: HumanAttentionRecord) -> dict:
- return {
+def _attention_dict(item: HumanAttentionRecord, *, target_available: bool | None = None) -> dict:
+ data = {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
@@ -267,6 +296,9 @@ def _attention_dict(item: HumanAttentionRecord) -> dict:
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
+ if target_available is not None:
+ data["targetAvailable"] = target_available
+ return data
def _json(raw: str | None):
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
index 1d0f6da..747607f 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
@@ -18,6 +18,7 @@ from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService
+from app.core.time_utils import business_day_boundary
router = APIRouter()
@@ -29,12 +30,8 @@ def dashboard(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
- start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
- if end:
- end_dt = datetime.strptime(end, "%Y-%m-%d")
- end_dt = end_dt.replace(hour=23, minute=59, second=59)
- else:
- end_dt = None
+ start_dt = business_day_boundary(datetime.strptime(start, "%Y-%m-%d").date()) if start else None
+ end_dt = business_day_boundary(datetime.strptime(end, "%Y-%m-%d").date(), end_exclusive=True) if end else None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
index 36ce801..9a17b43 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, load_only
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
+from app.core.time_utils import as_utc_naive, to_business_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
@@ -27,6 +28,7 @@ from app.services.question_insight_export_service import QuestionInsightExportSe
from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
+from app.services.chat_export_service import ChatExportService
router = APIRouter()
@@ -45,6 +47,8 @@ def chat_list(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
query = _chat_query(
keyword=keyword,
user_id=userId,
@@ -72,6 +76,8 @@ def export_chats(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
rows = db.execute(
_chat_query(
keyword=keyword,
@@ -98,8 +104,8 @@ def export_chats(
source_client.name if source_client else "千问千答直接访问",
session.title,
session.message_count,
- session.last_message_at,
- session.updated_at,
+ to_business_naive(session.last_message_at),
+ to_business_naive(session.updated_at),
]
)
content = "\ufeff" + output.getvalue()
@@ -132,11 +138,53 @@ def chat_messages(
return api_success(page_result([_message_dict(item) for item in messages], total=total, page=page, page_size=pageSize))
+@router.get("/chat/{session_id}/export")
+def export_chat_detail(
+ session_id: int,
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> StreamingResponse:
+ session_row = db.execute(
+ select(ChatSession, User, SsoClient)
+ .join(User, User.id == ChatSession.user_id, isouter=True)
+ .join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
+ .where(ChatSession.id == session_id)
+ ).first()
+ if session_row is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
+ session, user, source_client = session_row
+ messages = db.scalars(
+ select(ChatMessage)
+ .where(ChatMessage.session_id == session_id)
+ .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
+ ).all()
+ stream = ChatExportService.build(
+ session=session,
+ user=user,
+ source_name=source_client.name if source_client else "千问千答直接访问",
+ messages=messages,
+ )
+ OperationLogService.write(
+ db,
+ admin_id=current_admin.id,
+ module="records",
+ action="export_chat_detail",
+ target_id=session.id,
+ )
+ db.commit()
+ return StreamingResponse(
+ stream,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": f'attachment; filename="chat_session_{session.id}.xlsx"'},
+ )
+
+
@router.get("/chat/{session_id}")
def chat_detail(
session_id: int,
messagePage: int = Query(default=1, ge=1),
messagePageSize: int = Query(default=20, ge=10, le=100),
+ focusMessageId: int | None = Query(default=None, gt=0),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -154,6 +202,28 @@ def chat_detail(
session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
+ if focusMessageId is not None:
+ focused_message = db.scalar(
+ select(ChatMessage).where(
+ ChatMessage.id == focusMessageId,
+ ChatMessage.session_id == session_id,
+ )
+ )
+ if focused_message is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
+ focused_position = db.scalar(
+ select(func.count()).select_from(ChatMessage).where(
+ ChatMessage.session_id == session_id,
+ or_(
+ ChatMessage.created_at < focused_message.created_at,
+ (
+ (ChatMessage.created_at == focused_message.created_at)
+ & (ChatMessage.id <= focused_message.id)
+ ),
+ ),
+ )
+ ) or 1
+ messagePage = (focused_position - 1) // messagePageSize + 1
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
@@ -198,6 +268,7 @@ def chat_detail(
"topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
+ "focusedMessageId": focusMessageId,
}
)
@@ -288,6 +359,8 @@ def question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
return api_success(
QuestionInsightService.summarize(
db,
@@ -309,6 +382,8 @@ def refresh_question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
result = QuestionInsightService.refresh(
db,
date_from=dateFrom,
@@ -334,6 +409,8 @@ def export_question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
+ dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
+ dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
result = QuestionInsightService.summarize(
@@ -387,9 +464,9 @@ def _chat_query(
if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None:
- query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
+ query = query.where(ChatSession.updated_at >= date_from)
if date_to is not None:
- query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
+ query = query.where(ChatSession.updated_at <= date_to)
if status:
query = query.where(
exists()
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py
index e8985a6..50d6974 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py
@@ -3,12 +3,15 @@ from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi.responses import Response
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.admin_service import OperationLogService
+from app.services.user_behavior_export_service import UserBehaviorExportService
from app.services.user_behavior_service import UserBehaviorService
@@ -30,6 +33,8 @@ def behavior_users(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
+ sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
+ sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=5, le=100),
db: Session = Depends(get_db),
@@ -43,10 +48,42 @@ def behavior_users(
keyword=keyword,
page=page,
page_size=pageSize,
+ sort_by=sortBy,
+ sort_order=sortOrder,
)
)
+@router.get("/user-behavior/export")
+def export_behavior_report(
+ start: date | None = Query(default=None),
+ end: date | None = Query(default=None),
+ keyword: str = Query(default="", max_length=50),
+ sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
+ sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> Response:
+ stream = UserBehaviorExportService.build_workbook(
+ db,
+ start=start,
+ end=end,
+ keyword=keyword,
+ sort_by=sortBy,
+ sort_order=sortOrder,
+ )
+ OperationLogService.write(db, admin_id=current_admin.id, module="behavior", action="export")
+ db.commit()
+ start_label = start.isoformat() if start else "default"
+ end_label = end.isoformat() if end else "today"
+ filename = f"user_behavior_report_{start_label}_{end_label}.xlsx"
+ return Response(
+ content=stream.getvalue(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
@router.get("/user-behavior/user/{user_id}/timeline")
def behavior_timeline(
user_id: int,
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
index 8c9bc29..ccf58b3 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
@@ -17,6 +17,7 @@ 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.core.time_utils import as_utc_naive, business_datetime_to_utc_naive, business_day_boundary
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
@@ -475,7 +476,7 @@ def update_user(
if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None:
- user.expired_at = payload.expiredAt.replace(tzinfo=None)
+ user.expired_at = as_utc_naive(payload.expiredAt)
db.add(user)
if payload.entitlementPlanId is not None:
EntitlementService.assign_user_plan(
@@ -698,7 +699,7 @@ def _apply_user_payload(
user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
- user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
+ user.expired_at = as_utc_naive(payload.expiredAt) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int:
@@ -763,14 +764,17 @@ def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "":
return None
if isinstance(value, datetime):
- return value.replace(tzinfo=None)
+ return business_datetime_to_utc_naive(value)
if isinstance(value, date):
- return datetime.combine(value, datetime.min.time())
+ return business_day_boundary(value, end_exclusive=True) - timedelta(microseconds=1)
text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try:
- return datetime.strptime(text, fmt)
+ parsed = datetime.strptime(text, fmt)
+ if "%H" not in fmt:
+ return business_day_boundary(parsed.date(), end_exclusive=True) - timedelta(microseconds=1)
+ return business_datetime_to_utc_naive(parsed)
except ValueError:
continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/feedback.py b/ai_knowledge_base_v2/apps/backend/app/api/feedback.py
index ebf6c96..30afdc0 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/feedback.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/feedback.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from datetime import UTC, date, datetime, time, timedelta
+from datetime import UTC, date, datetime, timedelta
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -18,6 +18,7 @@ from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success
+from app.core.time_utils import business_day_boundary, to_business_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback
@@ -136,9 +137,9 @@ def _feedback_query(read_status: str, start_date: date | None, end_date: date |
if read_status != "all":
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
if start_date is not None:
- query = query.where(MessageFeedback.created_at >= datetime.combine(start_date, time.min))
+ query = query.where(MessageFeedback.created_at >= business_day_boundary(start_date))
if end_date is not None:
- query = query.where(MessageFeedback.created_at < datetime.combine(end_date + timedelta(days=1), time.min))
+ query = query.where(MessageFeedback.created_at < business_day_boundary(end_date, end_exclusive=True))
return query
@@ -159,8 +160,8 @@ def _feedback_workbook(rows: list[tuple]) -> Workbook:
_excel_safe_text(session.title),
message.id,
_excel_safe_text(ensure_ai_generated_notice(message.content)),
- feedback.created_at,
- feedback.read_at,
+ to_business_naive(feedback.created_at),
+ to_business_naive(feedback.read_at),
])
header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]:
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/database.py b/ai_knowledge_base_v2/apps/backend/app/core/database.py
index d3e1ab6..6f483b4 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/database.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/database.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Generator
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
@@ -20,6 +20,16 @@ if not settings.database_url.startswith("sqlite"):
pool_recycle=settings.db_pool_recycle,
)
engine = create_engine(settings.database_url, **engine_options)
+if engine.dialect.name == "mysql":
+ @event.listens_for(engine, "connect")
+ def _set_mysql_session_timezone(dbapi_connection, _connection_record) -> None:
+ """Keep CURRENT_TIMESTAMP and application-written values on one UTC contract."""
+ cursor = dbapi_connection.cursor()
+ try:
+ cursor.execute("SET time_zone = '+00:00'")
+ finally:
+ cursor.close()
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
register_knowledge_cache_invalidation_events()
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
index 03452b6..c78c735 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
@@ -126,7 +126,9 @@ def enforce_admin_access(
else:
permission = "attention.view" if method == "GET" else "attention.edit"
elif path.startswith("user-behavior"):
- permission = "behavior.view"
+ permission = "behavior.export" if path.startswith("user-behavior/export") else "behavior.view"
+ elif path == "chat/export" or (path.startswith("chat/") and path.endswith("/export")) or path.startswith("question-insights/export"):
+ permission = "records.export"
else:
permission = "records.view"
require_permission(admin, permission)
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/responses.py b/ai_knowledge_base_v2/apps/backend/app/core/responses.py
index 7746cf1..2e9a3dd 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/responses.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/responses.py
@@ -2,10 +2,12 @@ from __future__ import annotations
from typing import Any
+from app.core.time_utils import normalize_api_datetimes
+
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
- return {"code": 0, "message": message, "data": data}
+ return {"code": 0, "message": message, "data": normalize_api_datetimes(data)}
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
- return {"code": code, "message": message, "data": data}
+ return {"code": code, "message": message, "data": normalize_api_datetimes(data)}
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/time_utils.py b/ai_knowledge_base_v2/apps/backend/app/core/time_utils.py
new file mode 100644
index 0000000..8973f04
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/core/time_utils.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import UTC, date, datetime, time, timedelta
+from typing import Any
+from zoneinfo import ZoneInfo
+
+
+BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
+
+
+def utc_now_naive() -> datetime:
+ """Return the database timestamp convention: UTC without tzinfo."""
+ return datetime.now(UTC).replace(tzinfo=None)
+
+
+def as_utc_naive(value: datetime) -> datetime:
+ """Normalize an API datetime to the database UTC-naive convention."""
+ if value.tzinfo is None:
+ return value
+ return value.astimezone(UTC).replace(tzinfo=None)
+
+
+def business_day_boundary(value: date, *, end_exclusive: bool = False) -> datetime:
+ """Convert a Beijing calendar-day boundary to UTC-naive database time."""
+ local_value = datetime.combine(value, time.min, tzinfo=BUSINESS_TIMEZONE)
+ if end_exclusive:
+ local_value += timedelta(days=1)
+ return local_value.astimezone(UTC).replace(tzinfo=None)
+
+
+def business_datetime_to_utc_naive(value: datetime) -> datetime:
+ """Interpret a timezone-free operator input as Beijing local time."""
+ if value.tzinfo is not None:
+ return as_utc_naive(value)
+ return value.replace(tzinfo=BUSINESS_TIMEZONE).astimezone(UTC).replace(tzinfo=None)
+
+
+def to_business_naive(value: datetime | None) -> datetime | None:
+ """Convert UTC database time to a timezone-free Beijing value for Excel."""
+ if value is None:
+ return None
+ aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+ return aware.astimezone(BUSINESS_TIMEZONE).replace(tzinfo=None)
+
+
+def api_datetime(value: datetime) -> str:
+ """Serialize datetimes with an explicit offset; naive values are database UTC."""
+ aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value
+ return aware.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
+
+
+def normalize_api_datetimes(value: Any) -> Any:
+ """Recursively enforce the API time contract for ordinary JSON responses."""
+ if isinstance(value, datetime):
+ return api_datetime(value)
+ if isinstance(value, date):
+ return value.isoformat()
+ if isinstance(value, dict):
+ return {key: normalize_api_datetimes(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [normalize_api_datetimes(item) for item in value]
+ if isinstance(value, tuple):
+ return [normalize_api_datetimes(item) for item in value]
+ return value
diff --git a/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
index 3aba52b..94c4b30 100644
--- a/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
+++ b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
@@ -248,6 +248,9 @@ class KnowledgeRetrievalCandidate(Base):
class HumanAttentionRecord(Base):
__tablename__ = "sys_human_attention_record"
+ __table_args__ = (
+ Index("ix_human_attention_created", "created_at", "id"),
+ )
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
index fa487ea..25b16f3 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
@@ -17,11 +17,11 @@ PERMISSION_TREE = [
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
{"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]},
- {"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]},
+ {"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看记录"}, {"code": "records.export", "name": "导出记录/洞察"}]},
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理/删除关注项"}, {"code": "attention.config", "name": "查看/修改筛选规则"}, {"code": "attention.preview", "name": "使用历史消息预览筛选效果"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈列表/筛选分页"}, {"code": "feedback.detail", "name": "查看详情/标记已读"}, {"code": "feedback.export", "name": "导出反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
- {"code": "behavior", "name": "用户行为分析", "children": [{"code": "behavior.view", "name": "查看行为总览和用户轨迹"}]},
+ {"code": "behavior", "name": "用户行为分析", "children": [{"code": "behavior.view", "name": "查看行为总览和用户轨迹"}, {"code": "behavior.export", "name": "导出用户行为报表"}]},
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
]
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
index 986ec41..dbab26f 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
@@ -141,10 +141,10 @@ class AdminDashboardService:
msg_filter.append(ChatMessage.created_at >= start)
ai_filter.append(AiRequestLog.created_at >= start)
if end:
- user_filter.append(User.created_at <= end)
- session_filter.append(ChatSession.created_at <= end)
- msg_filter.append(ChatMessage.created_at <= end)
- ai_filter.append(AiRequestLog.created_at <= end)
+ user_filter.append(User.created_at < end)
+ session_filter.append(ChatSession.created_at < end)
+ msg_filter.append(ChatMessage.created_at < end)
+ ai_filter.append(AiRequestLog.created_at < end)
user_where = and_(true(), *user_filter)
session_where = and_(true(), *session_filter)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_export_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_export_service.py
new file mode 100644
index 0000000..afadca8
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_export_service.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+from io import BytesIO
+
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.worksheet.table import Table, TableStyleInfo
+
+from app.core.ai_content_label import ensure_ai_generated_notice
+from app.core.time_utils import to_business_naive
+from app.models.chat import ChatMessage, ChatSession
+from app.models.user import User
+
+
+class ChatExportService:
+ """Build a complete, pagination-independent workbook for one chat session."""
+
+ @staticmethod
+ def build(
+ *,
+ session: ChatSession,
+ user: User | None,
+ source_name: str,
+ messages: Iterable[ChatMessage],
+ ) -> BytesIO:
+ message_rows = list(messages)
+ workbook = Workbook()
+ info_sheet = workbook.active
+ info_sheet.title = "会话信息"
+ _write_session_info(
+ info_sheet,
+ session=session,
+ user=user,
+ source_name=source_name,
+ message_count=len(message_rows),
+ )
+
+ message_sheet = workbook.create_sheet("完整对话")
+ _write_messages(message_sheet, message_rows)
+
+ stream = BytesIO()
+ workbook.save(stream)
+ stream.seek(0)
+ return stream
+
+
+def _write_session_info(
+ sheet,
+ *,
+ session: ChatSession,
+ user: User | None,
+ source_name: str,
+ message_count: int,
+) -> None:
+ sheet.sheet_view.showGridLines = False
+ sheet.append(["项目", "内容"])
+ rows = [
+ ("会话ID", session.id),
+ ("会话标题", _excel_safe(session.title)),
+ ("用户ID", session.user_id),
+ ("用户姓名", _excel_safe(user.name if user else "")),
+ ("手机号", _excel_safe(user.phone if user else "")),
+ ("会话来源", _excel_safe(source_name)),
+ ("消息总数", message_count),
+ ("最后消息时间", to_business_naive(session.last_message_at)),
+ ("会话更新时间", to_business_naive(session.updated_at)),
+ ]
+ for label, value in rows:
+ sheet.append([label, value])
+ _style_header(sheet[1])
+ sheet.freeze_panes = "A2"
+ sheet.column_dimensions["A"].width = 20
+ sheet.column_dimensions["B"].width = 72
+ for row in sheet.iter_rows(min_row=2):
+ row[0].font = Font(bold=True, color="31584F")
+ row[1].alignment = Alignment(vertical="top", wrap_text=True)
+ for row_number in (9, 10):
+ sheet.cell(row_number, 2).number_format = "yyyy-mm-dd hh:mm:ss"
+
+
+def _write_messages(sheet, messages: Iterable[ChatMessage]) -> None:
+ headers = ["序号", "消息ID", "发送方", "发送时间", "消息内容", "状态", "输入Token", "输出Token", "耗时(ms)"]
+ sheet.append(headers)
+ count = 0
+ for count, message in enumerate(messages, start=1):
+ content = ensure_ai_generated_notice(message.content) if message.role == "assistant" else message.content
+ sheet.append(
+ [
+ count,
+ message.id,
+ "用户" if message.role == "user" else "大本营答疑助手",
+ to_business_naive(message.created_at),
+ _excel_safe(content),
+ message.message_status,
+ message.token_input,
+ message.token_output,
+ message.response_time_ms,
+ ]
+ )
+ _style_header(sheet[1])
+ sheet.freeze_panes = "A2"
+ sheet.auto_filter.ref = f"A1:I{max(1, sheet.max_row)}"
+ widths = (8, 12, 20, 22, 88, 14, 14, 14, 14)
+ for index, width in enumerate(widths, start=1):
+ sheet.column_dimensions[chr(64 + index)].width = width
+ for row in sheet.iter_rows(min_row=2):
+ row[3].number_format = "yyyy-mm-dd hh:mm:ss"
+ row[4].alignment = Alignment(vertical="top", wrap_text=True)
+ if count:
+ table = Table(displayName="ChatMessages", ref=f"A1:I{sheet.max_row}")
+ table.tableStyleInfo = TableStyleInfo(
+ name="TableStyleMedium4",
+ showFirstColumn=False,
+ showLastColumn=False,
+ showRowStripes=True,
+ )
+ sheet.add_table(table)
+
+
+def _style_header(cells) -> None:
+ fill = PatternFill("solid", fgColor="176B57")
+ for cell in cells:
+ cell.fill = fill
+ cell.font = Font(color="FFFFFF", bold=True)
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+
+
+def _excel_safe(value: object | None) -> str:
+ text = str(value or "")
+ return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py b/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py
index 60f5b30..4da5d99 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.user import User
+from app.core.time_utils import as_utc_naive
DEFAULT_PLAN_TYPE = "basic"
@@ -260,12 +261,12 @@ class EntitlementService:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
now = _now()
if effective_at is not None:
- effective_at = effective_at.replace(tzinfo=None)
+ effective_at = as_utc_naive(effective_at)
if expired_at is None and plan.validity_days:
start = effective_at or now
expired_at = start + timedelta(days=plan.validity_days)
elif expired_at is not None:
- expired_at = expired_at.replace(tzinfo=None)
+ expired_at = as_utc_naive(expired_at)
if expired_at is not None and effective_at is not None and expired_at < effective_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间")
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/maintenance_service.py b/ai_knowledge_base_v2/apps/backend/app/services/maintenance_service.py
index c2f2332..6ad736d 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/maintenance_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/maintenance_service.py
@@ -13,6 +13,7 @@ from app.services.redis_client import get_sync_redis_client
from app.services.entitlement_service import EntitlementService
from app.services.user_behavior_service import UserBehaviorService
from app.core.config import get_settings
+from app.core.time_utils import utc_now_naive
class MaintenanceService:
@@ -43,11 +44,12 @@ class MaintenanceService:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if not policy or not policy.enabled or not policy.retention_days:
return
- if policy.last_run_at and policy.last_run_at.date() >= datetime.now().date():
+ now = utc_now_naive()
+ if policy.last_run_at and policy.last_run_at.date() >= now.date():
return
- before = datetime.now() - timedelta(days=policy.retention_days)
+ before = now - timedelta(days=policy.retention_days)
deleted = cls.delete_retrieval_logs(db, before)
- policy.last_run_at = datetime.now()
+ policy.last_run_at = now
policy.last_result = json.dumps({"status": "success", "deleted": deleted, "before": before.isoformat()}, ensure_ascii=False)
db.add(policy)
db.commit()
@@ -55,7 +57,7 @@ class MaintenanceService:
with SessionLocal() as db:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if policy:
- policy.last_run_at = datetime.now()
+ policy.last_run_at = utc_now_naive()
policy.last_result = json.dumps({"status": "failed", "error": str(exc)[:1000]}, ensure_ascii=False)
db.add(policy)
db.commit()
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/periodic_report_service.py b/ai_knowledge_base_v2/apps/backend/app/services/periodic_report_service.py
index f8f709b..b314a18 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/periodic_report_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/periodic_report_service.py
@@ -629,4 +629,4 @@ def _local_datetime(value: datetime) -> datetime:
except ZoneInfoNotFoundError:
timezone = ZoneInfo("Asia/Shanghai")
aware_utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
- return aware_utc.astimezone(timezone).replace(tzinfo=None)
+ return aware_utc.astimezone(timezone)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_export_service.py b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_export_service.py
index 8fa52f1..e44daac 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_export_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_export_service.py
@@ -8,6 +8,7 @@ from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
from app.core.ai_content_label import AI_GENERATED_NOTICE
+from app.core.time_utils import to_business_naive, utc_now_naive
class QuestionInsightExportService:
@@ -116,7 +117,7 @@ def _append_summary(sheet, result: dict) -> None:
("导出问题组", summary.get("visibleClusterCount", 0)),
("清洗规则版本", summary.get("cleanerVersion", "")),
("内容标识", AI_GENERATED_NOTICE),
- ("导出时间", datetime.now()),
+ ("导出时间", to_business_naive(utc_now_naive())),
("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"),
]
for label, value in rows:
@@ -165,6 +166,6 @@ def _excel_safe(value: object | None) -> str:
def _excel_datetime(value: object) -> object:
- if isinstance(value, datetime) and value.tzinfo is not None:
- return value.replace(tzinfo=None)
+ if isinstance(value, datetime):
+ return to_business_naive(value)
return value
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py
index 9f584a5..27a5633 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py
@@ -17,6 +17,7 @@ from app.models.chat import ChatMessage, ChatSession
from app.models.insight import QuestionInsightCleanedQuestion
from app.models.logs import AiRequestLog
from app.models.user import User
+from app.core.time_utils import as_utc_naive
CLEANER_VERSION = "v1"
@@ -249,9 +250,9 @@ def _load_unprocessed_user_messages(
)
)
if date_from is not None:
- query = query.where(ChatMessage.created_at >= date_from.replace(tzinfo=None))
+ query = query.where(ChatMessage.created_at >= as_utc_naive(date_from))
if date_to is not None:
- query = query.where(ChatMessage.created_at <= date_to.replace(tzinfo=None))
+ query = query.where(ChatMessage.created_at <= as_utc_naive(date_to))
return list(
db.execute(
query.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()).limit(limit)
@@ -268,9 +269,9 @@ def _load_persisted_cleaned_questions(
) -> list[QuestionInsightCleanedQuestion]:
range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION]
if date_from is not None:
- range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= date_from.replace(tzinfo=None))
+ range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= as_utc_naive(date_from))
if date_to is not None:
- range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= date_to.replace(tzinfo=None))
+ range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= as_utc_naive(date_to))
latest_messages = (
select(
@@ -312,9 +313,9 @@ def _load_ai_logs(
) -> list[AiRequestLog]:
query = select(AiRequestLog)
if date_from is not None:
- query = query.where(AiRequestLog.created_at >= date_from.replace(tzinfo=None))
+ query = query.where(AiRequestLog.created_at >= as_utc_naive(date_from))
if date_to is not None:
- query = query.where(AiRequestLog.created_at <= date_to.replace(tzinfo=None))
+ query = query.where(AiRequestLog.created_at <= as_utc_naive(date_to))
return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.id.desc()).limit(limit)).all())
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_export_service.py b/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_export_service.py
new file mode 100644
index 0000000..4034787
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_export_service.py
@@ -0,0 +1,203 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+from io import BytesIO
+from copy import copy
+
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.utils import get_column_letter
+from sqlalchemy.orm import Session
+
+from app.services.user_behavior_service import EVENT_CATALOG, UserBehaviorService
+from app.core.time_utils import to_business_naive, utc_now_naive
+
+
+TITLE_FILL = PatternFill("solid", fgColor="176B55")
+HEADER_FILL = PatternFill("solid", fgColor="DDEFE9")
+TITLE_FONT = Font(color="FFFFFF", bold=True, size=16)
+HEADER_FONT = Font(color="214239", bold=True)
+EVENT_TYPE_LABELS = {"page": "页面", "dialog": "弹窗", "button": "按钮"}
+SORT_LABELS = {
+ "userName": "用户名称",
+ "phone": "手机号",
+ "eventCount": "交互次数",
+ "lastEventAt": "最后交互时间",
+}
+FONT_NAME = "Hiragino Sans GB"
+
+
+class UserBehaviorExportService:
+ @staticmethod
+ def build_workbook(
+ db: Session,
+ *,
+ start: date | None,
+ end: date | None,
+ keyword: str,
+ sort_by: str,
+ sort_order: str,
+ ) -> BytesIO:
+ overview = UserBehaviorService.overview(
+ db,
+ start=start,
+ end=end,
+ ranking_limit=len(EVENT_CATALOG),
+ )
+ users = UserBehaviorService.users_for_export(
+ db,
+ start=start,
+ end=end,
+ keyword=keyword,
+ sort_by=sort_by,
+ sort_order=sort_order,
+ )
+ workbook = Workbook()
+ summary = workbook.active
+ summary.title = "报表说明"
+ _write_summary(summary, overview, keyword=keyword, sort_by=sort_by, sort_order=sort_order)
+ _write_daily(workbook.create_sheet("每日趋势"), overview["daily"])
+ _write_ranking(workbook.create_sheet("功能使用频率"), overview["eventRanking"])
+ _write_users(workbook.create_sheet("用户汇总"), users)
+ stream = BytesIO()
+ workbook.save(stream)
+ stream.seek(0)
+ return stream
+
+
+def _write_summary(sheet, overview: dict, *, keyword: str, sort_by: str, sort_order: str) -> None:
+ sheet.sheet_view.showGridLines = False
+ sheet.merge_cells("A1:D1")
+ title = sheet["A1"]
+ title.value = "用户行为分析报表"
+ title.fill = TITLE_FILL
+ title.font = TITLE_FONT
+ title.alignment = Alignment(horizontal="center", vertical="center")
+ sheet.row_dimensions[1].height = 30
+ metadata = [
+ ("统计周期", f'{overview["startDate"]} 至 {overview["endDate"]}'),
+ ("用户筛选", _excel_safe(keyword.strip()) if keyword.strip() else "全部用户"),
+ ("用户汇总排序", f'{SORT_LABELS[sort_by]}·{"升序" if sort_order == "asc" else "降序"}'),
+ ("数据保留", f'{overview["retentionDays"]} 天'),
+ ("导出时间", to_business_naive(utc_now_naive())),
+ ]
+ for row_index, (label, value) in enumerate(metadata, start=3):
+ sheet.cell(row_index, 1, label).font = HEADER_FONT
+ sheet.cell(row_index, 2, value)
+ metrics = [
+ ("交互事件", overview["totalEvents"]),
+ ("活跃用户", overview["activeUsers"]),
+ ("页面/弹窗打开", overview["pageDialogOpens"]),
+ ("关键按钮点击", overview["buttonClicks"]),
+ ]
+ sheet.cell(10, 1, "核心指标").fill = HEADER_FILL
+ sheet.cell(10, 1).font = HEADER_FONT
+ sheet.cell(10, 2, "数值").fill = HEADER_FILL
+ sheet.cell(10, 2).font = HEADER_FONT
+ for row_index, (label, value) in enumerate(metrics, start=11):
+ sheet.cell(row_index, 1, label)
+ sheet.cell(row_index, 2, value).number_format = "#,##0"
+ sheet.column_dimensions["A"].width = 22
+ sheet.column_dimensions["B"].width = 34
+ sheet.column_dimensions["C"].width = 16
+ sheet.column_dimensions["D"].width = 16
+ sheet["B7"].number_format = "yyyy-mm-dd hh:mm:ss"
+ _finish_sheet(sheet, landscape=True)
+
+
+def _write_daily(sheet, rows: list[dict]) -> None:
+ _write_tabular_sheet(sheet, ["日期", "交互事件", "活跃用户"])
+ for item in rows:
+ sheet.append([date.fromisoformat(item["date"]), item["eventCount"], item["activeUsers"]])
+ for cell in sheet["A"][1:]:
+ cell.number_format = "yyyy-mm-dd"
+ for column in ("B", "C"):
+ for cell in sheet[column][1:]:
+ cell.number_format = "#,##0"
+ _finish_tabular_sheet(sheet, widths=(15, 15, 15))
+
+
+def _write_ranking(sheet, rows: list[dict]) -> None:
+ _write_tabular_sheet(sheet, ["排名", "功能名称", "事件类型", "使用次数", "用户数", "事件代码"])
+ for index, item in enumerate(rows, start=1):
+ sheet.append(
+ [
+ index,
+ _excel_safe(item["eventName"]),
+ EVENT_TYPE_LABELS.get(item["eventType"], item["eventType"]),
+ item["count"],
+ item["userCount"],
+ item["eventCode"],
+ ]
+ )
+ for column in ("A", "D", "E"):
+ for cell in sheet[column][1:]:
+ cell.number_format = "#,##0"
+ _finish_tabular_sheet(sheet, widths=(10, 24, 14, 14, 14, 30))
+
+
+def _write_users(sheet, rows: list[dict]) -> None:
+ _write_tabular_sheet(sheet, ["用户ID", "用户", "手机号", "交互次数", "最后交互时间"])
+ for item in rows:
+ sheet.append(
+ [
+ item["userId"],
+ _excel_safe(item["userName"]),
+ _excel_safe(item["phone"]),
+ item["eventCount"],
+ to_business_naive(item["lastEventAt"]),
+ ]
+ )
+ for cell in sheet["A"][1:]:
+ cell.number_format = "0"
+ for cell in sheet["C"][1:]:
+ cell.number_format = "@"
+ for cell in sheet["D"][1:]:
+ cell.number_format = "#,##0"
+ for cell in sheet["E"][1:]:
+ cell.number_format = "yyyy-mm-dd hh:mm:ss"
+ _finish_tabular_sheet(sheet, widths=(12, 22, 18, 14, 22))
+
+
+def _write_tabular_sheet(sheet, headers: list[str]) -> None:
+ sheet.sheet_view.showGridLines = False
+ sheet.append(headers)
+ for cell in sheet[1]:
+ cell.fill = HEADER_FILL
+ cell.font = HEADER_FONT
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ sheet.row_dimensions[1].height = 24
+ sheet.freeze_panes = "A2"
+
+
+def _finish_tabular_sheet(sheet, *, widths: tuple[int, ...]) -> None:
+ sheet.auto_filter.ref = sheet.dimensions
+ for index, width in enumerate(widths, start=1):
+ sheet.column_dimensions[get_column_letter(index)].width = width
+ for row in sheet.iter_rows(min_row=2):
+ for cell in row:
+ cell.alignment = Alignment(vertical="top", wrap_text=True)
+ _finish_sheet(sheet, landscape=True)
+
+
+def _finish_sheet(sheet, *, landscape: bool) -> None:
+ for row in sheet.iter_rows():
+ for cell in row:
+ font = copy(cell.font)
+ font.name = FONT_NAME
+ cell.font = font
+ sheet.sheet_properties.pageSetUpPr.fitToPage = True
+ sheet.page_setup.fitToWidth = 1
+ sheet.page_setup.fitToHeight = 0
+ sheet.page_setup.orientation = "landscape" if landscape else "portrait"
+ sheet.page_setup.paperSize = sheet.PAPERSIZE_A4
+ sheet.page_margins.left = 0.3
+ sheet.page_margins.right = 0.3
+ sheet.page_margins.top = 0.5
+ sheet.page_margins.bottom = 0.5
+ sheet.print_area = sheet.dimensions
+
+
+def _excel_safe(value: object) -> str:
+ text = "" if value is None else str(value)
+ return f"'{text}" if text.startswith(("=", "+", "-", "@")) else text
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_service.py b/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_service.py
index 9d4100a..a1e9785 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/user_behavior_service.py
@@ -1,7 +1,6 @@
from __future__ import annotations
-from datetime import UTC, date, datetime, time, timedelta
-from zoneinfo import ZoneInfo
+from datetime import UTC, date, datetime, timedelta
from sqlalchemy import case, delete, func, or_, select
from sqlalchemy.exc import IntegrityError
@@ -11,6 +10,7 @@ from app.models.behavior import UserBehaviorEvent
from app.models.user import User
from app.schemas.behavior import UserBehaviorEventCreate
from app.core.config import get_settings
+from app.core.time_utils import BUSINESS_TIMEZONE, business_day_boundary, to_business_naive
EVENT_CATALOG: dict[str, tuple[str, str]] = {
@@ -48,6 +48,8 @@ EVENT_CATALOG: dict[str, tuple[str, str]] = {
TARGET_TYPES = {"session", "message", "help_card", "share_draft", "report"}
DEFAULT_RANGE_DAYS = 7
MAX_RANGE_DAYS = 31
+USER_SORT_FIELDS = {"userName", "phone", "eventCount", "lastEventAt"}
+USER_SORT_ORDERS = {"asc", "desc"}
class UserBehaviorService:
@@ -97,7 +99,7 @@ class UserBehaviorService:
return accepted
@staticmethod
- def overview(db: Session, *, start: date | None, end: date | None) -> dict:
+ def overview(db: Session, *, start: date | None, end: date | None, ranking_limit: int = 20) -> dict:
start_dt, end_dt = _date_range(start, end)
base = (UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt)
total, active_users, page_opens, button_clicks = db.execute(
@@ -119,7 +121,7 @@ class UserBehaviorService:
.where(*base)
.group_by(UserBehaviorEvent.event_code, UserBehaviorEvent.event_name, UserBehaviorEvent.event_type)
.order_by(func.count(UserBehaviorEvent.id).desc(), UserBehaviorEvent.event_code)
- .limit(20)
+ .limit(max(1, min(ranking_limit, len(EVENT_CATALOG))))
).all()
if db.bind and db.bind.dialect.name == "mysql":
day_expression = func.date(func.convert_tz(UserBehaviorEvent.occurred_at, "+00:00", "+08:00"))
@@ -135,8 +137,8 @@ class UserBehaviorService:
.group_by(day_expression)
.order_by(day_expression)
).all()
- local_start = (start_dt + timedelta(hours=8)).date()
- local_end = (end_dt + timedelta(hours=8) - timedelta(days=1)).date()
+ local_start = to_business_naive(start_dt).date()
+ local_end = (to_business_naive(end_dt) - timedelta(days=1)).date()
daily_map = {str(day): (event_count, users) for day, event_count, users in daily_rows}
daily = []
cursor = local_start
@@ -160,47 +162,57 @@ class UserBehaviorService:
}
@staticmethod
- def users(db: Session, *, start: date | None, end: date | None, keyword: str, page: int, page_size: int) -> dict:
- start_dt, end_dt = _date_range(start, end)
- filters = [UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt]
- if keyword.strip():
- pattern = f"%{keyword.strip()}%"
- filters.append(or_(User.name.like(pattern), User.nickname.like(pattern), User.phone.like(pattern)))
- grouped = (
- select(
- User.id.label("user_id"),
- User.name,
- User.nickname,
- User.phone,
- func.count(UserBehaviorEvent.id).label("event_count"),
- func.max(UserBehaviorEvent.occurred_at).label("last_event_at"),
- )
- .join(UserBehaviorEvent, UserBehaviorEvent.user_id == User.id)
- .where(*filters)
- .group_by(User.id, User.name, User.nickname, User.phone)
+ def users(
+ db: Session,
+ *,
+ start: date | None,
+ end: date | None,
+ keyword: str,
+ page: int,
+ page_size: int,
+ sort_by: str = "lastEventAt",
+ sort_order: str = "desc",
+ ) -> dict:
+ grouped, order_expression = _user_summary_query(
+ start=start,
+ end=end,
+ keyword=keyword,
+ sort_by=sort_by,
+ sort_order=sort_order,
)
total = db.scalar(select(func.count()).select_from(grouped.subquery())) or 0
rows = db.execute(
- grouped.order_by(func.max(UserBehaviorEvent.occurred_at).desc(), User.id.desc())
+ grouped.order_by(order_expression, User.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
return {
- "items": [
- {
- "userId": row.user_id,
- "userName": row.nickname or row.name,
- "phone": row.phone,
- "eventCount": row.event_count,
- "lastEventAt": row.last_event_at,
- }
- for row in rows
- ],
+ "items": [_user_summary_dict(row) for row in rows],
"total": total,
"page": page,
"pageSize": page_size,
}
+ @staticmethod
+ def users_for_export(
+ db: Session,
+ *,
+ start: date | None,
+ end: date | None,
+ keyword: str,
+ sort_by: str,
+ sort_order: str,
+ ) -> list[dict]:
+ grouped, order_expression = _user_summary_query(
+ start=start,
+ end=end,
+ keyword=keyword,
+ sort_by=sort_by,
+ sort_order=sort_order,
+ )
+ rows = db.execute(grouped.order_by(order_expression, User.id.asc())).all()
+ return [_user_summary_dict(row) for row in rows]
+
@staticmethod
def timeline(db: Session, *, user_id: int, start: date | None, end: date | None, page: int, page_size: int) -> dict:
user = db.get(User, user_id)
@@ -249,18 +261,67 @@ class UserBehaviorService:
def _date_range(start: date | None, end: date | None) -> tuple[datetime, datetime]:
- today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
+ today = datetime.now(BUSINESS_TIMEZONE).date()
end_date = end or today
start_date = start or (end_date - timedelta(days=DEFAULT_RANGE_DAYS - 1))
if end_date < start_date:
start_date, end_date = end_date, start_date
if (end_date - start_date).days >= MAX_RANGE_DAYS:
start_date = end_date - timedelta(days=MAX_RANGE_DAYS - 1)
- # Admin date filters are Beijing calendar days; persisted timestamps are UTC-naive.
- return (
- datetime.combine(start_date, time.min) - timedelta(hours=8),
- datetime.combine(end_date + timedelta(days=1), time.min) - timedelta(hours=8),
+ return business_day_boundary(start_date), business_day_boundary(end_date, end_exclusive=True)
+
+
+def _user_summary_query(
+ *,
+ start: date | None,
+ end: date | None,
+ keyword: str,
+ sort_by: str,
+ sort_order: str,
+):
+ if sort_by not in USER_SORT_FIELDS:
+ raise ValueError(f"unsupported user behavior sort field: {sort_by}")
+ if sort_order not in USER_SORT_ORDERS:
+ raise ValueError(f"unsupported user behavior sort order: {sort_order}")
+ start_dt, end_dt = _date_range(start, end)
+ filters = [UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt]
+ if keyword.strip():
+ pattern = f"%{keyword.strip()}%"
+ filters.append(or_(User.name.like(pattern), User.nickname.like(pattern), User.phone.like(pattern)))
+ user_name = func.coalesce(func.nullif(User.nickname, ""), User.name)
+ event_count = func.count(UserBehaviorEvent.id)
+ last_event_at = func.max(UserBehaviorEvent.occurred_at)
+ grouped = (
+ select(
+ User.id.label("user_id"),
+ User.name,
+ User.nickname,
+ User.phone,
+ event_count.label("event_count"),
+ last_event_at.label("last_event_at"),
+ )
+ .join(UserBehaviorEvent, UserBehaviorEvent.user_id == User.id)
+ .where(*filters)
+ .group_by(User.id, User.name, User.nickname, User.phone)
)
+ sort_expressions = {
+ "userName": user_name,
+ "phone": User.phone,
+ "eventCount": event_count,
+ "lastEventAt": last_event_at,
+ }
+ expression = sort_expressions[sort_by]
+ return grouped, expression.asc() if sort_order == "asc" else expression.desc()
+
+
+def _user_summary_dict(row) -> dict:
+ return {
+ "userId": row.user_id,
+ "userName": row.nickname or row.name,
+ "phone": row.phone,
+ "eventCount": row.event_count,
+ "lastEventAt": row.last_event_at,
+ }
def _event_dict(item: UserBehaviorEvent) -> dict:
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py
index a82c2c9..2318e4c 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py
@@ -101,7 +101,14 @@ def test_chat_detail_messages_are_paginated():
])
db.commit()
- response = chat_detail(1, messagePage=2, messagePageSize=10, db=db, current_admin=object())
+ response = chat_detail(
+ 1,
+ messagePage=2,
+ messagePageSize=10,
+ focusMessageId=None,
+ db=db,
+ current_admin=object(),
+ )
data = response["data"]
assert data["session"]["messageCount"] == 25
@@ -111,6 +118,40 @@ def test_chat_detail_messages_are_paginated():
assert data["messages"][0]["content"] == "消息11"
+def test_chat_detail_focus_message_opens_its_page_in_chronological_order():
+ with _database() as db:
+ user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
+ session = ChatSession(id=1, user_id=1, title="长会话", message_count=25)
+ created_at = datetime(2026, 8, 25, 10, 0, 0)
+ db.add_all([user, session])
+ db.add_all([
+ ChatMessage(
+ id=index + 1,
+ session_id=1,
+ user_id=1,
+ role="user",
+ content=f"消息{index + 1}",
+ created_at=created_at,
+ )
+ for index in range(25)
+ ])
+ db.commit()
+
+ response = chat_detail(
+ 1,
+ messagePage=1,
+ messagePageSize=10,
+ focusMessageId=23,
+ db=db,
+ current_admin=object(),
+ )
+
+ data = response["data"]
+ assert data["messagesPage"]["page"] == 3
+ assert data["focusedMessageId"] == 23
+ assert [item["id"] for item in data["messages"]] == [21, 22, 23, 24, 25]
+
+
def test_chat_messages_endpoint_returns_only_message_page():
with _database() as db:
user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
@@ -149,11 +190,75 @@ def test_retrieval_and_attention_lists_are_paginated():
sessionId=None, knowledgeId=None, knowledgeCalled=None, statusValue="", attentionCreated=None,
page=3, pageSize=10, db=db, current_admin=object(),
)["data"]
- attention = attention_list(priority="", statusValue="", page=3, pageSize=10, db=db, current_admin=object())["data"]
+ attention = attention_list(
+ priority="",
+ statusValue="",
+ dateFrom=None,
+ dateTo=None,
+ page=3,
+ pageSize=10,
+ db=db,
+ current_admin=object(),
+ )["data"]
assert retrieval["total"] == attention["total"] == 21
assert len(retrieval["items"]) == len(attention["items"]) == 1
+def test_attention_list_filters_by_priority_and_trigger_time():
+ with _database() as db:
+ db.add_all([
+ HumanAttentionRecord(
+ session_id=1,
+ message_id=1,
+ user_id=1,
+ trigger_message="紧急问题",
+ problem_summary="紧急问题",
+ trigger_reason="测试",
+ priority="urgent",
+ status="pending",
+ created_at=datetime(2026, 8, 20, 9, 0, 0),
+ ),
+ HumanAttentionRecord(
+ session_id=2,
+ message_id=2,
+ user_id=2,
+ trigger_message="普通问题",
+ problem_summary="普通问题",
+ trigger_reason="测试",
+ priority="normal",
+ status="pending",
+ created_at=datetime(2026, 8, 21, 9, 0, 0),
+ ),
+ HumanAttentionRecord(
+ session_id=3,
+ message_id=3,
+ user_id=3,
+ trigger_message="过期紧急问题",
+ problem_summary="过期紧急问题",
+ trigger_reason="测试",
+ priority="urgent",
+ status="pending",
+ created_at=datetime(2026, 8, 10, 9, 0, 0),
+ ),
+ ])
+ db.commit()
+
+ data = attention_list(
+ priority="urgent",
+ statusValue="",
+ dateFrom=datetime(2026, 8, 20, 0, 0, 0),
+ dateTo=datetime(2026, 8, 22, 23, 59, 59),
+ page=1,
+ pageSize=10,
+ db=db,
+ current_admin=object(),
+ )["data"]
+
+ assert data["total"] == 1
+ assert data["items"][0]["messageId"] == 1
+ assert data["items"][0]["targetAvailable"] is False
+
+
def test_question_insights_clean_and_cluster_similar_user_questions():
with _database() as db:
user_a = User(id=1, phone="13800000000", name="学员A", daily_chat_limit=10)
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
index fa9b774..e322a4c 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
@@ -16,8 +16,9 @@ def test_super_admin_has_all_permissions() -> None:
assert permissions_for(admin) == ALL_PERMISSION_CODES
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
assert "prompt.batch" in ALL_PERMISSION_CODES
- assert "behavior.view" in ALL_PERMISSION_CODES
+ assert {"behavior.view", "behavior.export"} <= ALL_PERMISSION_CODES
assert {"attention.view", "attention.edit", "attention.config", "attention.preview"} <= ALL_PERMISSION_CODES
+ assert {"records.view", "records.export"} <= ALL_PERMISSION_CODES
def test_role_permissions_are_restricted_to_catalog() -> None:
@@ -81,6 +82,61 @@ def test_user_behavior_routes_require_behavior_permission() -> None:
enforce_admin_access(request, denied)
assert exc.value.status_code == 403
+ export_request = Request({"type": "http", "method": "GET", "path": "/api/admin/user-behavior/export", "headers": []})
+ with pytest.raises(HTTPException) as exc:
+ enforce_admin_access(export_request, allowed)
+ assert exc.value.status_code == 403
+
+ export_role = Role(code="behavior-exporter", name="行为报表导出", permissions=json.dumps(["behavior.export"]))
+ exporter = Admin(
+ id=6,
+ username="behavior-exporter",
+ password="hash",
+ name="行为报表导出",
+ status=1,
+ must_change_password=0,
+ is_super_admin=0,
+ role=export_role,
+ )
+ assert enforce_admin_access(export_request, exporter) is exporter
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/api/admin/chat/export",
+ "/api/admin/chat/14/export",
+ "/api/admin/question-insights/export",
+ ],
+)
+def test_record_export_routes_require_export_permission(path: str) -> None:
+ viewer = Admin(
+ id=7,
+ username="records-viewer",
+ password="hash",
+ name="记录查看员",
+ status=1,
+ must_change_password=0,
+ is_super_admin=0,
+ role=Role(code="records-view", name="记录查看", permissions=json.dumps(["records.view"])),
+ )
+ request = Request({"type": "http", "method": "GET", "path": path, "headers": []})
+ with pytest.raises(HTTPException) as exc:
+ enforce_admin_access(request, viewer)
+ assert exc.value.status_code == 403
+
+ exporter = Admin(
+ id=8,
+ username="records-exporter",
+ password="hash",
+ name="记录导出员",
+ status=1,
+ must_change_password=0,
+ is_super_admin=0,
+ role=Role(code="records-export", name="记录导出", permissions=json.dumps(["records.export"])),
+ )
+ assert enforce_admin_access(request, exporter) is exporter
+
@pytest.mark.parametrize(
("path", "method", "permission"),
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_chat_export.py b/ai_knowledge_base_v2/apps/backend/tests/test_chat_export.py
new file mode 100644
index 0000000..66e20b0
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_chat_export.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from openpyxl import load_workbook
+
+from app.core.ai_content_label import AI_GENERATED_NOTICE
+from app.models.chat import ChatMessage, ChatSession
+from app.models.user import User
+from app.services.chat_export_service import ChatExportService
+
+
+def test_chat_export_contains_all_messages_in_order_and_ai_notice() -> None:
+ session = ChatSession(
+ id=9,
+ user_id=3,
+ title="完整对话测试",
+ message_count=99,
+ last_message_at=datetime(2026, 8, 25, 8, 2),
+ updated_at=datetime(2026, 8, 25, 8, 3),
+ )
+ user = User(id=3, phone="13800000000", name="测试学员", daily_chat_limit=10)
+ messages = [
+ ChatMessage(
+ id=21,
+ session_id=9,
+ user_id=3,
+ role="user",
+ content="第一个问题",
+ message_status="FINISHED",
+ created_at=datetime(2026, 8, 25, 8),
+ ),
+ ChatMessage(
+ id=22,
+ session_id=9,
+ user_id=3,
+ role="assistant",
+ content="第二个回答",
+ message_status="FINISHED",
+ token_input=20,
+ token_output=10,
+ response_time_ms=800,
+ created_at=datetime(2026, 8, 25, 8, 1),
+ ),
+ ]
+
+ stream = ChatExportService.build(
+ session=session,
+ user=user,
+ source_name="千问千答直接访问",
+ messages=messages,
+ )
+ workbook = load_workbook(stream)
+
+ assert workbook.sheetnames == ["会话信息", "完整对话"]
+ assert workbook["会话信息"]["B8"].value == 2
+ sheet = workbook["完整对话"]
+ assert sheet.max_row == 3
+ assert sheet["B2"].value == 21
+ assert sheet["E2"].value == "第一个问题"
+ assert sheet["B3"].value == 22
+ assert AI_GENERATED_NOTICE in sheet["E3"].value
+ assert sheet["D2"].value == datetime(2026, 8, 25, 16)
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py b/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py
index 2da41ec..9964679 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from datetime import datetime
+from datetime import datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import create_engine
@@ -53,5 +53,5 @@ def test_feedback_export_workbook_is_formatted_and_formula_safe() -> None:
assert sheet["E2"].value.startswith("'=")
assert sheet["H1"].value == "对应AI回答(AI生成)"
assert "AI生成内容,请结合实际情况核对后使用。" in sheet["H2"].value
- assert sheet["I2"].value == created_at
+ assert sheet["I2"].value == created_at + timedelta(hours=8)
assert sheet.tables["FeedbackRecords"].ref == "A1:J2"
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_time_contract.py b/ai_knowledge_base_v2/apps/backend/tests/test_time_contract.py
new file mode 100644
index 0000000..875bdeb
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_time_contract.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+from zoneinfo import ZoneInfo
+
+from app.core.responses import api_success
+from app.core.time_utils import business_day_boundary, to_business_naive
+
+
+def test_api_success_serializes_naive_database_time_as_explicit_utc() -> None:
+ response = api_success({"createdAt": datetime(2026, 8, 25, 8, 30, 12, 123456)})
+
+ assert response["data"]["createdAt"] == "2026-08-25T08:30:12.123Z"
+
+
+def test_api_success_normalizes_aware_business_time_to_utc() -> None:
+ response = api_success(
+ {"createdAt": datetime(2026, 8, 25, 16, 30, tzinfo=ZoneInfo("Asia/Shanghai"))}
+ )
+
+ assert response["data"]["createdAt"] == "2026-08-25T08:30:00.000Z"
+
+
+def test_beijing_day_boundary_and_excel_display_conversion() -> None:
+ assert business_day_boundary(date(2026, 8, 25)) == datetime(2026, 8, 24, 16)
+ assert business_day_boundary(date(2026, 8, 25), end_exclusive=True) == datetime(2026, 8, 25, 16)
+ assert to_business_naive(datetime(2026, 8, 25, 8, 30)) == datetime(2026, 8, 25, 16, 30)
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_user_behavior_analytics.py b/ai_knowledge_base_v2/apps/backend/tests/test_user_behavior_analytics.py
index 3755b25..266e690 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_user_behavior_analytics.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_user_behavior_analytics.py
@@ -1,7 +1,9 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
+from io import BytesIO
+from openpyxl import load_workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
@@ -11,19 +13,27 @@ from app.models.behavior import UserBehaviorEvent
from app.models.user import User
from app.schemas.behavior import UserBehaviorEventCreate
from app.services.user_behavior_service import UserBehaviorService
+from app.services.user_behavior_export_service import UserBehaviorExportService
def _engine():
return create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
-def _event(event_id: str, code: str, *, target_type: str | None = None, target_id: int | None = None):
+def _event(
+ event_id: str,
+ code: str,
+ *,
+ target_type: str | None = None,
+ target_id: int | None = None,
+ occurred_at: datetime | None = None,
+):
return UserBehaviorEventCreate(
clientEventId=event_id,
eventCode=code,
targetType=target_type,
targetId=target_id,
- occurredAt=datetime.now(UTC),
+ occurredAt=occurred_at or datetime.now(UTC),
)
@@ -67,18 +77,19 @@ def test_behavior_overview_user_list_and_timeline() -> None:
]
db.add_all(users)
db.commit()
+ base_time = datetime.now(UTC) - timedelta(minutes=5)
UserBehaviorService.record_batch(
db,
user=users[0],
items=[
- _event("11111111-1111-1111-1111-111111111111", "app_open"),
- _event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3),
+ _event("11111111-1111-1111-1111-111111111111", "app_open", occurred_at=base_time),
+ _event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3, occurred_at=base_time + timedelta(minutes=1)),
],
)
UserBehaviorService.record_batch(
db,
user=users[1],
- items=[_event("33333333-3333-3333-3333-333333333333", "app_open")],
+ items=[_event("33333333-3333-3333-3333-333333333333", "app_open", occurred_at=base_time + timedelta(minutes=2))],
)
overview = UserBehaviorService.overview(db, start=None, end=None)
assert overview["totalEvents"] == 3
@@ -90,9 +101,46 @@ def test_behavior_overview_user_list_and_timeline() -> None:
listed = UserBehaviorService.users(db, start=None, end=None, keyword="甲", page=1, page_size=20)
assert listed["total"] == 1
assert listed["items"][0]["eventCount"] == 2
+ by_count = UserBehaviorService.users(
+ db,
+ start=None,
+ end=None,
+ keyword="",
+ page=1,
+ page_size=20,
+ sort_by="eventCount",
+ sort_order="asc",
+ )
+ assert [item["userName"] for item in by_count["items"]] == ["乙用户", "甲用户"]
+ by_last_time = UserBehaviorService.users(
+ db,
+ start=None,
+ end=None,
+ keyword="",
+ page=1,
+ page_size=20,
+ sort_by="lastEventAt",
+ sort_order="desc",
+ )
+ assert [item["userName"] for item in by_last_time["items"]] == ["乙用户", "甲用户"]
timeline = UserBehaviorService.timeline(db, user_id=1, start=None, end=None, page=1, page_size=50)
assert [item["eventName"] for item in timeline["items"]] == ["进入答疑页面", "发送问题"]
+ workbook_stream = UserBehaviorExportService.build_workbook(
+ db,
+ start=None,
+ end=None,
+ keyword="",
+ sort_by="eventCount",
+ sort_order="asc",
+ )
+ workbook = load_workbook(BytesIO(workbook_stream.getvalue()))
+ assert workbook.sheetnames == ["报表说明", "每日趋势", "功能使用频率", "用户汇总"]
+ assert workbook["报表说明"]["B11"].value == 3
+ assert workbook["用户汇总"]["B2"].value == "乙用户"
+ assert workbook["用户汇总"]["D2"].value == 1
+ assert workbook["用户汇总"]["C2"].number_format == "@"
+
def test_behavior_retention_deletes_only_expired_rows() -> None:
engine = _engine()
diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue b/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue
index 901cc33..8af5d3a 100644
--- a/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue
+++ b/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue
@@ -2,6 +2,7 @@
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it";
import { computed } from "vue";
+import { formatTime } from "../utils/dateTime";
const props = defineProps<{
messageId: string;
@@ -79,10 +80,7 @@ function splitReasoning(content: string) {
}
const displayTime = computed(() => {
- const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`;
- const date = new Date(normalized);
- if (Number.isNaN(date.getTime())) return "";
- return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
+ return formatTime(props.createdAt, "");
});
diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/PersonalCenterDialog.vue b/ai_knowledge_base_v2/apps/user-client/src/components/PersonalCenterDialog.vue
index 4f0a1b0..d818526 100644
--- a/ai_knowledge_base_v2/apps/user-client/src/components/PersonalCenterDialog.vue
+++ b/ai_knowledge_base_v2/apps/user-client/src/components/PersonalCenterDialog.vue
@@ -16,6 +16,7 @@ import {
import { computed, ref, watch } from "vue";
import type { PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "../types/api";
+import { formatDate } from "../utils/dateTime";
import AppDialog from "./AppDialog.vue";
type CenterSection = "overview" | "review" | "reports" | "records";
@@ -104,13 +105,6 @@ function usagePercent(used: number, limit: number | null) {
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)));
}
-function formatDate(value?: string | null) {
- if (!value) return "长期有效";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return value;
- return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
-}
-
function settlementStatusLabel(status: string) {
return {
pending: "等待整理",
@@ -189,7 +183,7 @@ function submitDeleteCard() {
有效期至
- {{ formatDate(entitlement?.expiredAt) }}
+ {{ formatDate(entitlement?.expiredAt, "长期有效") }}
diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/SessionDrawer.vue b/ai_knowledge_base_v2/apps/user-client/src/components/SessionDrawer.vue
index 10c47cb..4447069 100644
--- a/ai_knowledge_base_v2/apps/user-client/src/components/SessionDrawer.vue
+++ b/ai_knowledge_base_v2/apps/user-client/src/components/SessionDrawer.vue
@@ -4,6 +4,7 @@ import { computed, ref, watch } from "vue";
import type { ChatSession } from "../types/api";
import AppDialog from "./AppDialog.vue";
+import { formatSessionTime } from "../utils/dateTime";
const props = defineProps<{
open: boolean;
@@ -50,18 +51,6 @@ function submitDelete() {
});
}
-function formatTime(value: string) {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return "";
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
- const days = Math.round((today.getTime() - target.getTime()) / 86_400_000);
- const time = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
- if (days === 0) return time;
- if (days === 1) return `昨天 ${time}`;
- return `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${time}`;
-}