From 884dc765ad767c8c888d43b46b13be20ae43f4f4 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 15:22:11 +0800 Subject: [PATCH] feat: add entitlement plans and topic sessions --- .../apps/admin-web/src/App.vue | 204 +++- .../apps/admin-web/src/services/api.ts | 10 + .../apps/admin-web/src/styles.css | 91 ++ .../apps/admin-web/src/types/api.ts | 37 + .../versions/0014_entitlements_topics.py | 210 ++++ .../backend/app/api/admin_entitlements.py | 112 +++ .../apps/backend/app/api/admin_records.py | 1 + .../apps/backend/app/api/admin_users.py | 90 +- .../apps/backend/app/api/router.py | 2 + .../apps/backend/app/api/user.py | 18 +- .../apps/backend/app/models/__init__.py | 7 +- .../apps/backend/app/models/chat.py | 23 + .../apps/backend/app/models/entitlement.py | 55 ++ .../apps/backend/app/schemas/admin.py | 25 + .../apps/backend/app/schemas/chat.py | 1 + .../apps/backend/app/schemas/user.py | 1 + .../apps/backend/app/services/chat_service.py | 36 + .../app/services/chat_stream_service.py | 49 + .../app/services/entitlement_service.py | 237 +++++ .../app/services/question_insight_service.py | 1 + .../app/services/topic_session_service.py | 114 +++ .../backend/tests/test_entitlements_topics.py | 122 +++ .../apps/user-client/src/App.vue | 2 +- .../src/components/SessionQuota.vue | 20 +- .../apps/user-client/src/styles.css | 10 + .../apps/user-client/src/types/api.ts | 18 + .../docs/qianwen_product_todo.md | 908 ++++++++++++++++++ 27 files changed, 2389 insertions(+), 15 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0014_entitlements_topics.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/entitlement.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/topic_session_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_entitlements_topics.py create mode 100644 ai_knowledge_base_v2/docs/qianwen_product_todo.md diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index fc2ab18..1eceeae 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -16,6 +16,7 @@ import type { ChatDetail, ChatRecord, DashboardStats, + EntitlementPlan, KnowledgeItem, RetrievalLogItem, AttentionItem, @@ -41,6 +42,8 @@ const dashboardFilters = reactive({ }); const users = ref([]); const userKeyword = ref(""); +const entitlementPlans = ref([]); +const editingEntitlementPlanId = ref(null); const models = ref([]); const configs = ref([]); const chats = ref([]); @@ -88,6 +91,21 @@ const studentImportFile = ref(null); const studentImportInput = ref(null); const studentImportResult = ref(null); +const entitlementPlanForm = reactive({ + name: "", + planType: "basic" as "basic" | "deep" | "addon" | "teacher", + description: "", + validityDays: null as number | null, + monthlyTopicLimit: 30 as number | null, + enableGrowthProfile: 0, + enablePeriodicReports: 0, + allowHelpCard: 1, + allowShareDraft: 1, + deductQuota: 1, + status: 1, + sortOrder: 10, +}); + const knowledgeForm = reactive({ name: "", feishuSpaceId: "", @@ -278,7 +296,8 @@ async function loadCurrentMenu() { loading.value = true; try { if (activeMenu.value === "dashboard") await loadDashboard(); - if (activeMenu.value === "users") await loadUsers(pagers.users.page, pagers.users.pageSize); + if (activeMenu.value === "users") { await loadEntitlementPlans(); await loadUsers(pagers.users.page, pagers.users.pageSize); } + if (activeMenu.value === "entitlements") await loadEntitlementPlans(); if (activeMenu.value === "models") models.value = await api.models(); if (activeMenu.value === "configs") { configs.value = await api.configs(); @@ -298,6 +317,78 @@ async function loadUsers(page = 1, pageSize = pagers.users.pageSize) { Object.assign(pagers.users, { page: result.page, pageSize: result.pageSize, total: result.total }); } +async function loadEntitlementPlans() { + entitlementPlans.value = await api.entitlementPlans(true); +} + +function planTypeLabel(type: string) { + return { + basic: "基础版", + deep: "深度陪伴版", + addon: "高频加购包", + teacher: "老师工作版", + }[type] || type; +} + +async function saveEntitlementPlan() { + if (!entitlementPlanForm.name.trim()) { + ElMessage.warning("请填写权益版本名称"); + return; + } + const payload = { ...entitlementPlanForm }; + if (editingEntitlementPlanId.value) { + await api.updateEntitlementPlan(editingEntitlementPlanId.value, payload); + ElMessage.success("权益版本已更新"); + } else { + await api.createEntitlementPlan(payload); + ElMessage.success("权益版本已新增"); + } + resetEntitlementPlanForm(); + await loadEntitlementPlans(); +} + +function editEntitlementPlan(row: EntitlementPlan) { + editingEntitlementPlanId.value = row.id; + Object.assign(entitlementPlanForm, { + name: row.name, + planType: row.planType, + description: row.description ?? "", + validityDays: row.validityDays ?? null, + monthlyTopicLimit: row.monthlyTopicLimit ?? null, + enableGrowthProfile: row.enableGrowthProfile ? 1 : 0, + enablePeriodicReports: row.enablePeriodicReports ? 1 : 0, + allowHelpCard: row.allowHelpCard ? 1 : 0, + allowShareDraft: row.allowShareDraft ? 1 : 0, + deductQuota: row.deductQuota ? 1 : 0, + status: row.status, + sortOrder: row.sortOrder, + }); +} + +function resetEntitlementPlanForm() { + editingEntitlementPlanId.value = null; + Object.assign(entitlementPlanForm, { + name: "", + planType: "basic", + description: "", + validityDays: null, + monthlyTopicLimit: 30, + enableGrowthProfile: 0, + enablePeriodicReports: 0, + allowHelpCard: 1, + allowShareDraft: 1, + deductQuota: 1, + status: 1, + sortOrder: 10, + }); +} + +async function assignUserEntitlement(row: AdminUser, planId: number) { + const entitlement = await api.assignUserEntitlement(row.id, { planId }); + row.entitlement = entitlement; + ElMessage.success("用户权益已更新"); +} + async function loadRecordTab(tab = recordTab.value) { loading.value = true; try { @@ -866,6 +957,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { + @@ -984,6 +1076,30 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { + + + + + 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 3616007..4544cef 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 @@ -12,6 +12,7 @@ import type { ChatRecord, ChatRecordQuery, DashboardStats, + EntitlementPlan, KnowledgeItem, KnowledgeContentSearchItem, KnowledgeDetail, @@ -25,6 +26,7 @@ import type { ModelItem, SystemConfigItem, UserImportResult, + UserEntitlementSummary, PageResult, PromptDetail, PromptHistoryItem, @@ -136,6 +138,14 @@ export const api = { updateUser: (id: number, payload: Record) => request(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), deleteUser: (id: number) => request(`/admin/user/${id}`, { method: "DELETE" }), + entitlementPlans: (includeDisabled = true) => + request(`/admin/entitlement/plan/list${queryString({ includeDisabled })}`), + createEntitlementPlan: (payload: Record) => + request("/admin/entitlement/plan", { method: "POST", body: JSON.stringify(payload) }), + updateEntitlementPlan: (id: number, payload: Record) => + request(`/admin/entitlement/plan/${id}`, { method: "PUT", body: JSON.stringify(payload) }), + assignUserEntitlement: (userId: number, payload: { planId: number; effectiveAt?: string | null; expiredAt?: string | null; remark?: string | null }) => + request(`/admin/user/${userId}/entitlement`, { method: "POST", body: JSON.stringify(payload) }), knowledge: (query: Record = {}) => request>(`/admin/knowledge/list${queryString(query)}`), knowledgeOptions: () => request("/admin/knowledge/options"), resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }), diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 5caed4b..24f5037 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -635,6 +635,97 @@ textarea { flex: 0 0 auto; } +.user-entitlement-cell { + display: grid; + gap: 6px; + min-width: 0; +} + +.user-entitlement-cell .el-select { + width: 100%; +} + +.user-entitlement-cell small { + overflow: hidden; + color: #71817b; + font-size: 12px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entitlement-editor { + margin-bottom: 16px; + padding: 18px; + border: 1px solid #dfe8e5; + border-radius: 12px; + background: #ffffff; +} + +.entitlement-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.entitlement-editor-head h3, +.entitlement-editor-head p { + margin: 0; +} + +.entitlement-editor-head p { + margin-top: 6px; + color: #71817b; + font-size: 13px; + line-height: 1.6; +} + +.entitlement-form-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.entitlement-form-grid .el-input-number, +.entitlement-form-grid .el-select { + width: 100%; +} + +.entitlement-switch-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; +} + +.entitlement-switch-grid label { + min-width: 0; + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 1px solid #dfe8e5; + border-radius: 10px; + background: #f8fbfa; + color: #40524b; + font-size: 13px; +} + +.entitlement-switch-grid span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entitlement-capabilities { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .migration-overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); 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 167d681..7188708 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 @@ -61,6 +61,42 @@ export interface AdminUser { expiredAt?: string | null; lastLoginAt?: string | null; createdAt?: string | null; + entitlement?: UserEntitlementSummary | null; +} + +export interface EntitlementPlan { + id: number; + name: string; + planType: "basic" | "deep" | "addon" | "teacher"; + description?: string | null; + validityDays?: number | null; + monthlyTopicLimit?: number | null; + enableGrowthProfile: boolean; + enablePeriodicReports: boolean; + allowHelpCard: boolean; + allowShareDraft: boolean; + deductQuota: boolean; + status: number; + sortOrder: number; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface UserEntitlementSummary { + planId?: number | null; + name: string; + planType: string; + monthlyTopicLimit?: number | null; + monthlyTopicUsed: number; + monthlyTopicRemaining?: number | null; + enableGrowthProfile: boolean; + enablePeriodicReports: boolean; + allowHelpCard: boolean; + allowShareDraft: boolean; + deductQuota: boolean; + effectiveAt?: string | null; + expiredAt?: string | null; + source: string; } export interface UserImportFailure { @@ -315,6 +351,7 @@ export interface ChatRecord { export interface ChatMessageRecord { id: number; sessionId: number; + topicSessionId?: number | null; userId: number; role: "user" | "assistant"; content: string; diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0014_entitlements_topics.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0014_entitlements_topics.py new file mode 100644 index 0000000..0f5cc90 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0014_entitlements_topics.py @@ -0,0 +1,210 @@ +"""add entitlement plans and topic sessions + +Revision ID: 0014_entitlements_topics +Revises: 0013_question_insight_indexes +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0014_entitlements_topics" +down_revision = "0013_question_insight_indexes" +branch_labels = None +depends_on = None + +PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite") + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "sys_entitlement_plan" not in tables: + op.create_table( + "sys_entitlement_plan", + sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("plan_type", sa.String(30), nullable=False), + sa.Column("description", sa.String(255), nullable=True), + sa.Column("validity_days", sa.Integer(), nullable=True), + sa.Column("monthly_topic_limit", sa.Integer(), nullable=True), + sa.Column("enable_growth_profile", sa.Integer(), nullable=False, server_default="0"), + sa.Column("enable_periodic_reports", sa.Integer(), nullable=False, server_default="0"), + sa.Column("allow_help_card", sa.Integer(), nullable=False, server_default="1"), + sa.Column("allow_share_draft", sa.Integer(), nullable=False, server_default="1"), + sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"), + sa.Column("status", sa.Integer(), nullable=False, server_default="1"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_sys_entitlement_plan_plan_type", "sys_entitlement_plan", ["plan_type"]) + op.create_index("ix_sys_entitlement_plan_status", "sys_entitlement_plan", ["status"]) + _seed_default_plans() + + if "sys_user_entitlement" not in tables: + op.create_table( + "sys_user_entitlement", + sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False), + sa.Column("plan_id", sa.BigInteger(), sa.ForeignKey("sys_entitlement_plan.id"), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("effective_at", sa.DateTime(), nullable=True), + sa.Column("expired_at", sa.DateTime(), nullable=True), + sa.Column("assigned_by", sa.BigInteger(), nullable=True), + sa.Column("remark", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_sys_user_entitlement_user_id", "sys_user_entitlement", ["user_id"]) + op.create_index("ix_sys_user_entitlement_plan_id", "sys_user_entitlement", ["plan_id"]) + op.create_index("ix_sys_user_entitlement_status", "sys_user_entitlement", ["status"]) + + if "sys_user_entitlement_log" not in tables: + op.create_table( + "sys_user_entitlement_log", + sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("entitlement_id", sa.BigInteger(), nullable=True), + sa.Column("from_plan_id", sa.BigInteger(), nullable=True), + sa.Column("to_plan_id", sa.BigInteger(), nullable=True), + sa.Column("action", sa.String(30), nullable=False), + sa.Column("detail_json", sa.Text(), nullable=True), + sa.Column("operated_by", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_sys_user_entitlement_log_user_id", "sys_user_entitlement_log", ["user_id"]) + + if "sys_topic_session" not in tables: + op.create_table( + "sys_topic_session", + sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False), + sa.Column("chat_session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False), + sa.Column("title", sa.String(120), nullable=False), + sa.Column("core_question", sa.Text(), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("token_input", sa.Integer(), nullable=False, server_default="0"), + sa.Column("token_output", sa.Integer(), nullable=False, server_default="0"), + sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"), + sa.Column("recommended_homework", sa.Text(), nullable=True), + sa.Column("help_card_generated", sa.Integer(), nullable=False, server_default="0"), + sa.Column("share_draft_generated", sa.Integer(), nullable=False, server_default="0"), + sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_sys_topic_session_user_id", "sys_topic_session", ["user_id"]) + op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"]) + op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"]) + + columns = {column["name"] for column in inspector.get_columns("sys_chat_message")} + if "topic_session_id" not in columns: + op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True)) + op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"]) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + columns = {column["name"] for column in inspector.get_columns("sys_chat_message")} + if "topic_session_id" in columns: + op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message") + op.drop_column("sys_chat_message", "topic_session_id") + tables = set(inspector.get_table_names()) + if "sys_topic_session" in tables: + op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session") + op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session") + op.drop_index("ix_sys_topic_session_user_id", table_name="sys_topic_session") + op.drop_table("sys_topic_session") + if "sys_user_entitlement_log" in tables: + op.drop_index("ix_sys_user_entitlement_log_user_id", table_name="sys_user_entitlement_log") + op.drop_table("sys_user_entitlement_log") + if "sys_user_entitlement" in tables: + op.drop_index("ix_sys_user_entitlement_status", table_name="sys_user_entitlement") + op.drop_index("ix_sys_user_entitlement_plan_id", table_name="sys_user_entitlement") + op.drop_index("ix_sys_user_entitlement_user_id", table_name="sys_user_entitlement") + op.drop_table("sys_user_entitlement") + if "sys_entitlement_plan" in tables: + op.drop_index("ix_sys_entitlement_plan_status", table_name="sys_entitlement_plan") + op.drop_index("ix_sys_entitlement_plan_plan_type", table_name="sys_entitlement_plan") + op.drop_table("sys_entitlement_plan") + + +def _seed_default_plans() -> None: + op.bulk_insert( + sa.table( + "sys_entitlement_plan", + sa.column("name"), + sa.column("plan_type"), + sa.column("description"), + sa.column("validity_days"), + sa.column("monthly_topic_limit"), + sa.column("enable_growth_profile"), + sa.column("enable_periodic_reports"), + sa.column("allow_help_card"), + sa.column("allow_share_draft"), + sa.column("deduct_quota"), + sa.column("status"), + sa.column("sort_order"), + ), + [ + { + "name": "大本营基础版", + "plan_type": "basic", + "description": "随大本营提供,支持基础知识查询、功课方向和求助卡生成。", + "validity_days": None, + "monthly_topic_limit": 30, + "enable_growth_profile": 0, + "enable_periodic_reports": 0, + "allow_help_card": 1, + "allow_share_draft": 1, + "deduct_quota": 1, + "status": 1, + "sort_order": 10, + }, + { + "name": "五个月深度陪伴版", + "plan_type": "deep", + "description": "支持长期成长档案、阶段报告和更高主题会话额度。", + "validity_days": 150, + "monthly_topic_limit": 90, + "enable_growth_profile": 1, + "enable_periodic_reports": 1, + "allow_help_card": 1, + "allow_share_draft": 1, + "deduct_quota": 1, + "status": 1, + "sort_order": 20, + }, + { + "name": "高频加购包", + "plan_type": "addon", + "description": "用于少量高频用户补充主题会话额度。", + "validity_days": 31, + "monthly_topic_limit": 30, + "enable_growth_profile": 0, + "enable_periodic_reports": 0, + "allow_help_card": 1, + "allow_share_draft": 1, + "deduct_quota": 1, + "status": 1, + "sort_order": 30, + }, + { + "name": "老师工作版", + "plan_type": "teacher", + "description": "内部老师使用,不消耗普通学员权益额度。", + "validity_days": None, + "monthly_topic_limit": None, + "enable_growth_profile": 0, + "enable_periodic_reports": 0, + "allow_help_card": 1, + "allow_share_draft": 1, + "deduct_quota": 0, + "status": 1, + "sort_order": 40, + }, + ], + ) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py new file mode 100644 index 0000000..43081b0 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +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.models.entitlement import EntitlementPlan +from app.models.user import User +from app.schemas.admin import EntitlementPlanSaveRequest, UserEntitlementAssignRequest +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() + + +@router.get("/entitlement/plan/list") +def list_entitlement_plans( + includeDisabled: bool = Query(default=True), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + plans = EntitlementService.list_plans(db, include_disabled=includeDisabled) + return api_success([plan_dict(plan) for plan in plans]) + + +@router.post("/entitlement/plan") +def create_entitlement_plan( + payload: EntitlementPlanSaveRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + plan = EntitlementPlan() + _apply_plan_payload(plan, payload) + db.add(plan) + db.flush() + OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="create_plan", target_id=plan.id) + db.commit() + db.refresh(plan) + return api_success(plan_dict(plan)) + + +@router.put("/entitlement/plan/{plan_id}") +def update_entitlement_plan( + plan_id: int, + payload: EntitlementPlanSaveRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + plan = db.get(EntitlementPlan, plan_id) + if plan is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在") + _apply_plan_payload(plan, payload) + db.add(plan) + OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="update_plan", target_id=plan.id) + db.commit() + db.refresh(plan) + return api_success(plan_dict(plan)) + + +@router.post("/user/{user_id}/entitlement") +def assign_user_entitlement( + user_id: int, + payload: UserEntitlementAssignRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + user = db.get(User, user_id) + if user is None or user.is_deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + entitlement = EntitlementService.assign_user_plan( + db, + user=user, + plan_id=payload.planId, + operated_by=current_admin.id, + effective_at=payload.effectiveAt, + expired_at=payload.expiredAt, + remark=payload.remark, + ) + OperationLogService.write( + db, + admin_id=current_admin.id, + module="entitlement", + action="assign_user_plan", + target_id=user.id, + ) + db.commit() + db.refresh(entitlement) + view = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id), + ) + return api_success(entitlement_dict(view)) + + +def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None: + plan.name = payload.name.strip() + 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 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 3b08915..f75ddf6 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 @@ -304,6 +304,7 @@ def _message_dict(message: ChatMessage) -> dict: return { "id": message.id, "sessionId": message.session_id, + "topicSessionId": message.topic_session_id, "userId": message.user_id, "role": message.role, "content": message.content, 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 4c044f0..bcbb11e 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 @@ -1,14 +1,14 @@ from __future__ import annotations import re -from datetime import date, datetime +from datetime import UTC, date, datetime from io import BytesIO from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import StreamingResponse from openpyxl import Workbook, load_workbook from pydantic import ValidationError -from sqlalchemy import func, select +from sqlalchemy import extract, func, select from sqlalchemy.orm import Session from app.core.database import get_db @@ -16,9 +16,12 @@ from app.core.dependencies import get_current_admin from app.core.responses import api_success from app.models.admin import Admin from app.models.ai_config import SystemConfig +from app.models.entitlement import EntitlementPlan, UserEntitlement +from app.models.chat import TopicSession from app.models.user import User from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest from app.services.admin_service import OperationLogService +from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan from app.api.pagination import page_result router = APIRouter() @@ -40,7 +43,8 @@ def list_users( query = query.where((User.phone.like(like)) | (User.name.like(like))) total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all() - return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize)) + entitlements = _entitlement_views(db, users) + return api_success(page_result([_user_dict(user, entitlements.get(user.id)) for user in users], total=total, page=page, page_size=pageSize)) @router.post("/user") @@ -68,7 +72,12 @@ def create_user( OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id) db.commit() db.refresh(user) - return api_success(_user_dict(user)) + entitlement = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0), + ) + return api_success(_user_dict(user, entitlement_dict(entitlement))) @router.post("/user/import") @@ -240,10 +249,24 @@ def update_user( if payload.expiredAt is not None: user.expired_at = payload.expiredAt.replace(tzinfo=None) db.add(user) + if payload.entitlementPlanId is not None: + EntitlementService.assign_user_plan( + db, + user=user, + plan_id=payload.entitlementPlanId, + operated_by=current_admin.id, + expired_at=payload.entitlementExpiredAt, + remark=payload.entitlementRemark, + ) OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id) db.commit() db.refresh(user) - return api_success(_user_dict(user)) + entitlement = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0), + ) + return api_success(_user_dict(user, entitlement_dict(entitlement))) @router.delete("/user/{user_id}") @@ -269,7 +292,7 @@ def _get_user(db: Session, user_id: int) -> User: return user -def _user_dict(user: User) -> dict: +def _user_dict(user: User, entitlement: dict | None = None) -> dict: return { "id": user.id, "phone": user.phone, @@ -281,9 +304,64 @@ def _user_dict(user: User) -> dict: "expiredAt": user.expired_at, "lastLoginAt": user.last_login_at, "createdAt": user.created_at, + "entitlement": entitlement, } +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) + result: 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") + else: + view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0)) + result[user.id] = entitlement_dict(view) + return result + + +def _active_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tuple[UserEntitlement, EntitlementPlan]]: + now = datetime.now(UTC).replace(tzinfo=None) + rows = db.execute( + select(UserEntitlement, EntitlementPlan) + .join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id) + .where( + UserEntitlement.user_id.in_(user_ids), + UserEntitlement.status == "active", + EntitlementPlan.status == 1, + ) + .where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now)) + .where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now)) + .order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc()) + ).all() + result: dict[int, tuple[UserEntitlement, EntitlementPlan]] = {} + for entitlement, plan in rows: + result.setdefault(entitlement.user_id, (entitlement, plan)) + return result + + +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, diff --git a/ai_knowledge_base_v2/apps/backend/app/api/router.py b/ai_knowledge_base_v2/apps/backend/app/api/router.py index 3e4c7d0..a005202 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/router.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/router.py @@ -6,6 +6,7 @@ from app.api import ( admin_auth, admin_agent_records, admin_dashboard, + admin_entitlements, admin_knowledge, admin_knowledge_lifecycle, admin_records, @@ -25,6 +26,7 @@ api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"]) api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"]) +api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"]) api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"]) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/user.py b/ai_knowledge_base_v2/apps/backend/app/api/user.py index 92538c7..5e5018f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/user.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/user.py @@ -1,15 +1,29 @@ from __future__ import annotations from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.responses import api_success from app.models.user import User from app.schemas.user import UserProfile +from app.services.entitlement_service import EntitlementService, entitlement_dict +from app.services.topic_session_service import TopicSessionService router = APIRouter() @router.get("/profile") -def profile(current_user: User = Depends(get_current_user)) -> dict: - return api_success(UserProfile.model_validate(current_user).model_dump(mode="json")) +def profile( + db: Session = Depends(get_db), + 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), + ) + data["entitlement"] = entitlement_dict(view) + return api_success(data) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index 5cc3013..168421d 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -1,7 +1,8 @@ from app.models.admin import Admin, Role from app.models.ai_config import ModelConfig, Prompt, SystemConfig from app.models.base import Base -from app.models.chat import ChatMessage, ChatSession +from app.models.chat import ChatMessage, ChatSession, TopicSession +from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog from app.models.knowledge import ( HumanAttentionHistory, HumanAttentionRecord, @@ -28,6 +29,7 @@ __all__ = [ "Base", "ChatMessage", "ChatSession", + "EntitlementPlan", "Knowledge", "KnowledgeCard", "KnowledgeChunk", @@ -46,9 +48,12 @@ __all__ = [ "OperationLog", "LogRetentionPolicy", "StorageSnapshot", + "TopicSession", "Prompt", "Role", "SystemConfig", "User", + "UserEntitlement", + "UserEntitlementLog", "UserKnowledgePermission", ] diff --git a/ai_knowledge_base_v2/apps/backend/app/models/chat.py b/ai_knowledge_base_v2/apps/backend/app/models/chat.py index 16b8ea3..f04b39d 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/chat.py @@ -29,6 +29,7 @@ class ChatMessage(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False) + topic_session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False) role: Mapped[str] = mapped_column(String(20), nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False) @@ -40,3 +41,25 @@ class ChatMessage(Base): created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages") + + +class TopicSession(Base): + __tablename__ = "sys_topic_session" + + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False) + chat_session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False) + title: Mapped[str] = mapped_column(String(120), nullable=False) + core_question: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="active", index=True, nullable=False) + message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + token_input: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + token_output: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + quota_deducted: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + recommended_homework: Mapped[str | None] = mapped_column(Text, nullable=True) + help_card_generated: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + share_draft_generated: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/entitlement.py b/ai_knowledge_base_v2/apps/backend/app/models/entitlement.py new file mode 100644 index 0000000..1c07764 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/entitlement.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin + +PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite") + + +class EntitlementPlan(Base, TimestampMixin): + __tablename__ = "sys_entitlement_plan" + + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(80), nullable=False) + plan_type: Mapped[str] = mapped_column(String(30), index=True, nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + validity_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + monthly_topic_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) + enable_growth_profile: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + enable_periodic_reports: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + allow_help_card: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + allow_share_draft: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + deduct_quota: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + status: Mapped[int] = mapped_column(Integer, default=1, index=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + +class UserEntitlement(Base, TimestampMixin): + __tablename__ = "sys_user_entitlement" + + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False) + plan_id: Mapped[int] = mapped_column(ForeignKey("sys_entitlement_plan.id"), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="active", index=True, nullable=False) + effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + assigned_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + + +class UserEntitlementLog(Base): + __tablename__ = "sys_user_entitlement_log" + + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False) + entitlement_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + from_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + to_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + action: Mapped[str] = mapped_column(String(30), nullable=False) + detail_json: Mapped[str | None] = mapped_column(Text, nullable=True) + operated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index 9397c86..f6a2162 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -41,6 +41,9 @@ class AdminUserUpdateRequest(BaseModel): status: int | None = Field(default=None, ge=0, le=1) dailyChatLimit: int | None = Field(default=None, ge=0, le=100000) expiredAt: datetime | None = None + entitlementPlanId: int | None = Field(default=None, gt=0) + entitlementExpiredAt: datetime | None = None + entitlementRemark: str | None = Field(default=None, max_length=255) class AdminUserCreateRequest(BaseModel): @@ -65,6 +68,28 @@ class AdminUserImportRequest(BaseModel): students: list[AdminUserImportItem] = Field(default_factory=list) +class EntitlementPlanSaveRequest(BaseModel): + name: str = Field(min_length=1, max_length=80) + planType: Literal["basic", "deep", "addon", "teacher"] = "basic" + description: str | None = Field(default=None, max_length=255) + validityDays: int | None = Field(default=None, ge=1, le=3650) + monthlyTopicLimit: int | None = Field(default=None, ge=0, le=100000) + enableGrowthProfile: int = Field(default=0, ge=0, le=1) + enablePeriodicReports: int = Field(default=0, ge=0, le=1) + allowHelpCard: int = Field(default=1, ge=0, le=1) + allowShareDraft: int = Field(default=1, ge=0, le=1) + deductQuota: int = Field(default=1, ge=0, le=1) + status: int = Field(default=1, ge=0, le=1) + sortOrder: int = Field(default=0, ge=0, le=100000) + + +class UserEntitlementAssignRequest(BaseModel): + planId: int = Field(gt=0) + effectiveAt: datetime | None = None + expiredAt: datetime | None = None + remark: str | None = Field(default=None, max_length=255) + + class KnowledgeSaveRequest(BaseModel): name: str = Field(min_length=1, max_length=100) feishuSpaceId: str = Field(min_length=1, max_length=100) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py index 046cf8c..931220c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py @@ -22,6 +22,7 @@ class ChatSessionRead(ORMModel): class ChatMessageRead(ORMModel): id: int + topic_session_id: int | None = None role: str content: str message_status: str diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/user.py b/ai_knowledge_base_v2/apps/backend/app/schemas/user.py index de38e86..2fdbd8a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/user.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/user.py @@ -18,3 +18,4 @@ class UserProfile(ORMModel): effective_at: datetime | None = None expired_at: datetime | None = None last_login_at: datetime | None = None + entitlement: dict | None = None diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py index 41cea4b..3a3761c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py @@ -11,10 +11,12 @@ from sqlalchemy.orm import Session from app.models.chat import ChatMessage, ChatSession from app.models.user import User from app.services.ai_request_log_service import AiRequestLogService +from app.services.entitlement_service import EntitlementService from app.services.external_errors import ExternalServiceError from app.services.chat_context_service import ChatContextService from app.services.model_service import ModelClientService from app.services.rag_service import RagService +from app.services.topic_session_service import TopicSessionService class ChatService: @@ -75,11 +77,25 @@ class ChatService: user = ChatService.prepare_daily_quota(db, user) session = ChatService._get_user_session(db, user, session_id) ChatService._ensure_quota(user) + entitlement = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id), + ) + ChatService._ensure_topic_quota(db, user, session, entitlement) now = _now() normalized_question = question.strip() + topic = TopicSessionService.get_or_create_active( + db, + user=user, + session=session, + question=normalized_question, + deduct_quota=entitlement.deduct_quota, + ) user_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id, user_id=user.id, role="user", content=normalized_question, @@ -87,6 +103,7 @@ class ChatService: created_at=now, ) db.add(user_message) + TopicSessionService.attach_user_message(user_message, topic) db.flush() started_at = perf_counter() @@ -141,6 +158,7 @@ class ChatService: assistant_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id, user_id=user.id, role="assistant", content=completion.answer, @@ -152,6 +170,12 @@ class ChatService: created_at=now, ) db.add(assistant_message) + TopicSessionService.attach_assistant_message( + assistant_message, + topic, + token_input=completion.input_token, + token_output=completion.output_token, + ) db.flush() session.message_count += 2 @@ -198,6 +222,18 @@ class ChatService: if user.daily_chat_used >= user.daily_chat_limit: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完") + @staticmethod + def _ensure_topic_quota(db: Session, user: User, session: ChatSession, entitlement) -> None: + if not entitlement.deduct_quota or entitlement.monthly_topic_limit is None: + return + if TopicSessionService.active_for_session(db, user=user, session=session) is not None: + return + if entitlement.monthly_topic_used >= entitlement.monthly_topic_limit: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="本月深度主题使用较多,建议先完成已有功课;如需继续高频使用,可以联系运营老师确认权益。", + ) + @staticmethod def prepare_daily_quota(db: Session, user: User) -> User: locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update()) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py index 2e4c43c..c718e2b 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py @@ -16,11 +16,13 @@ from app.models.user import User from app.services.ai_request_log_service import AiRequestLogService from app.services.chat_service import ChatService, _title_from_question from app.services.chat_context_service import ChatContextService +from app.services.entitlement_service import EntitlementService from app.services.external_errors import ExternalServiceError from app.services.human_attention_service import HumanAttentionService from app.services.model_stream_service import ModelStreamService from app.services.rag_async_service import AsyncRagService from app.services.rag_service import RagService +from app.services.topic_session_service import TopicSessionService class ChatStreamService: @@ -29,11 +31,25 @@ class ChatStreamService: user = ChatService.prepare_daily_quota(db, user) session = ChatService._get_user_session(db, user, session_id) ChatService._ensure_quota(user) + entitlement = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id), + ) + ChatService._ensure_topic_quota(db, user, session, entitlement) now = _now() normalized_question = question.strip() + topic = TopicSessionService.get_or_create_active( + db, + user=user, + session=session, + question=normalized_question, + deduct_quota=entitlement.deduct_quota, + ) user_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id, user_id=user.id, role="user", content=normalized_question, @@ -41,6 +57,7 @@ class ChatStreamService: created_at=now, ) db.add(user_message) + TopicSessionService.attach_user_message(user_message, topic) db.flush() history = list( @@ -122,6 +139,7 @@ class ChatStreamService: cost_ms = int((perf_counter() - started_at) * 1000) assistant_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id, user_id=user.id, role="assistant", content=answer, @@ -133,6 +151,12 @@ class ChatStreamService: created_at=now, ) db.add(assistant_message) + TopicSessionService.attach_assistant_message( + assistant_message, + topic, + token_input=model_response.input_token if model_response is not None else None, + token_output=_rough_token_count(answer), + ) db.flush() session.message_count += 2 @@ -163,11 +187,25 @@ class ChatStreamService: user = ChatService.prepare_daily_quota(db, user) session = ChatService._get_user_session(db, user, session_id) ChatService._ensure_quota(user) + entitlement = EntitlementService.active_entitlement( + db, + user, + monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id), + ) + ChatService._ensure_topic_quota(db, user, session, entitlement) now = _now() normalized_question = question.strip() + topic = TopicSessionService.get_or_create_active( + db, + user=user, + session=session, + question=normalized_question, + deduct_quota=entitlement.deduct_quota, + ) user_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id, user_id=user.id, role="user", content=normalized_question, @@ -175,6 +213,7 @@ class ChatStreamService: created_at=now, ) db.add(user_message) + TopicSessionService.attach_user_message(user_message, topic) db.flush() history = list( @@ -262,6 +301,7 @@ class ChatStreamService: model_response=model_response, started_at=started_at, now=now, + topic=topic, ) @@ -284,10 +324,12 @@ def _write_success( model_response, started_at: float, now: datetime, + topic, ) -> None: cost_ms = int((perf_counter() - started_at) * 1000) assistant_message = ChatMessage( session_id=session.id, + topic_session_id=topic.id if topic is not None else None, user_id=user.id, role="assistant", content=answer, @@ -299,6 +341,13 @@ def _write_success( created_at=now, ) db.add(assistant_message) + if topic is not None: + TopicSessionService.attach_assistant_message( + assistant_message, + topic, + token_input=model_response.input_token if model_response is not None else None, + token_output=_rough_token_count(answer), + ) db.flush() session.message_count += 2 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 new file mode 100644 index 0000000..12bc2e8 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog +from app.models.user import User + + +DEFAULT_PLAN_TYPE = "basic" + + +@dataclass(frozen=True) +class EntitlementView: + plan_id: int | None + name: str + plan_type: str + monthly_topic_limit: int | None + monthly_topic_used: int + enable_growth_profile: bool + enable_periodic_reports: bool + allow_help_card: bool + allow_share_draft: bool + deduct_quota: bool + effective_at: datetime | None = None + expired_at: datetime | None = None + source: str = "legacy" + + @property + def monthly_topic_remaining(self) -> int | None: + if self.monthly_topic_limit is None: + return None + return max(0, self.monthly_topic_limit - self.monthly_topic_used) + + +class EntitlementService: + @staticmethod + def list_plans(db: Session, *, include_disabled: bool = False) -> list[EntitlementPlan]: + query = select(EntitlementPlan) + if not include_disabled: + query = query.where(EntitlementPlan.status == 1) + return list(db.scalars(query.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc()))) + + @staticmethod + def default_plan(db: Session) -> EntitlementPlan | None: + plan = db.scalar( + select(EntitlementPlan) + .where(EntitlementPlan.plan_type == DEFAULT_PLAN_TYPE, EntitlementPlan.status == 1) + .order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc()) + .limit(1) + ) + if plan is not None: + return plan + return db.scalar( + select(EntitlementPlan) + .where(EntitlementPlan.status == 1) + .order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc()) + .limit(1) + ) + + @staticmethod + def active_entitlement(db: Session, user: User, *, monthly_topic_used: int = 0) -> EntitlementView: + now = _now() + row = db.execute( + select(UserEntitlement, EntitlementPlan) + .join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id) + .where( + UserEntitlement.user_id == user.id, + UserEntitlement.status == "active", + EntitlementPlan.status == 1, + ) + .where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now)) + .where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now)) + .order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc()) + .limit(1) + ).first() + if row: + entitlement, plan = row + return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=entitlement, source="assigned") + + plan = EntitlementService.default_plan(db) + if plan is not None: + return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default") + + return EntitlementView( + plan_id=None, + name="旧版每日额度", + plan_type="legacy", + monthly_topic_limit=None, + monthly_topic_used=monthly_topic_used, + enable_growth_profile=False, + enable_periodic_reports=False, + allow_help_card=True, + allow_share_draft=True, + deduct_quota=True, + source="legacy", + ) + + @staticmethod + def assign_user_plan( + db: Session, + *, + user: User, + plan_id: int, + operated_by: int | None, + effective_at: datetime | None = None, + expired_at: datetime | None = None, + remark: str | None = None, + ) -> UserEntitlement: + plan = db.get(EntitlementPlan, plan_id) + if plan is None or plan.status != 1: + 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) + 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) + 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="权益到期时间不能早于生效时间") + + current = db.scalar( + select(UserEntitlement) + .where(UserEntitlement.user_id == user.id, UserEntitlement.status == "active") + .order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc()) + .limit(1) + ) + from_plan_id = current.plan_id if current else None + if current is not None: + current.status = "replaced" + db.add(current) + + entitlement = UserEntitlement( + user_id=user.id, + plan_id=plan.id, + status="active", + effective_at=effective_at, + expired_at=expired_at, + assigned_by=operated_by, + remark=remark, + ) + db.add(entitlement) + db.flush() + db.add( + UserEntitlementLog( + user_id=user.id, + entitlement_id=entitlement.id, + from_plan_id=from_plan_id, + to_plan_id=plan.id, + action="assign", + detail_json=json.dumps( + { + "effectiveAt": effective_at.isoformat() if effective_at else None, + "expiredAt": expired_at.isoformat() if expired_at else None, + "remark": remark, + }, + ensure_ascii=False, + ), + operated_by=operated_by, + created_at=now, + ) + ) + return entitlement + + +def plan_dict(plan: EntitlementPlan) -> dict: + return { + "id": plan.id, + "name": plan.name, + "planType": plan.plan_type, + "description": plan.description, + "validityDays": plan.validity_days, + "monthlyTopicLimit": plan.monthly_topic_limit, + "enableGrowthProfile": bool(plan.enable_growth_profile), + "enablePeriodicReports": bool(plan.enable_periodic_reports), + "allowHelpCard": bool(plan.allow_help_card), + "allowShareDraft": bool(plan.allow_share_draft), + "deductQuota": bool(plan.deduct_quota), + "status": plan.status, + "sortOrder": plan.sort_order, + "createdAt": plan.created_at, + "updatedAt": plan.updated_at, + } + + +def entitlement_dict(view: EntitlementView) -> dict: + return { + "planId": view.plan_id, + "name": view.name, + "planType": view.plan_type, + "monthlyTopicLimit": view.monthly_topic_limit, + "monthlyTopicUsed": view.monthly_topic_used, + "monthlyTopicRemaining": view.monthly_topic_remaining, + "enableGrowthProfile": view.enable_growth_profile, + "enablePeriodicReports": view.enable_periodic_reports, + "allowHelpCard": view.allow_help_card, + "allowShareDraft": view.allow_share_draft, + "deductQuota": view.deduct_quota, + "effectiveAt": view.effective_at, + "expiredAt": view.expired_at, + "source": view.source, + } + + +def view_from_plan( + plan: EntitlementPlan, + *, + monthly_topic_used: int, + entitlement: UserEntitlement | None, + source: str, +) -> EntitlementView: + return EntitlementView( + plan_id=plan.id, + name=plan.name, + plan_type=plan.plan_type, + monthly_topic_limit=plan.monthly_topic_limit, + monthly_topic_used=monthly_topic_used, + enable_growth_profile=bool(plan.enable_growth_profile), + enable_periodic_reports=bool(plan.enable_periodic_reports), + allow_help_card=bool(plan.allow_help_card), + allow_share_draft=bool(plan.allow_share_draft), + deduct_quota=bool(plan.deduct_quota), + effective_at=entitlement.effective_at if entitlement else None, + expired_at=entitlement.expired_at if entitlement else None, + source=source, + ) + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) 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 93f2e48..eec0a29 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 @@ -61,6 +61,7 @@ SYNONYM_RULES = ( (re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"), (re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"), (re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"), + (re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"), (re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"), (re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"), ) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/topic_session_service.py b/ai_knowledge_base_v2/apps/backend/app/services/topic_session_service.py new file mode 100644 index 0000000..f1f3c15 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/topic_session_service.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import extract, func, select +from sqlalchemy.orm import Session + +from app.models.chat import ChatMessage, ChatSession, TopicSession +from app.models.user import User + + +class TopicSessionService: + @staticmethod + def active_for_session(db: Session, *, user: User, session: ChatSession) -> TopicSession | None: + return db.scalar( + select(TopicSession) + .where( + TopicSession.user_id == user.id, + TopicSession.chat_session_id == session.id, + TopicSession.status == "active", + ) + .order_by(TopicSession.created_at.desc(), TopicSession.id.desc()) + .limit(1) + ) + + @staticmethod + def monthly_used_count(db: Session, user_id: int, *, at: datetime | None = None) -> int: + current = at or _now() + return int( + db.scalar( + select(func.count(TopicSession.id)).where( + TopicSession.user_id == user_id, + extract("year", TopicSession.started_at) == current.year, + extract("month", TopicSession.started_at) == current.month, + TopicSession.quota_deducted == 1, + ) + ) + or 0 + ) + + @staticmethod + def get_or_create_active( + db: Session, + *, + user: User, + session: ChatSession, + question: str, + deduct_quota: bool, + ) -> TopicSession: + topic = TopicSessionService.active_for_session(db, user=user, session=session) + if topic is not None: + return topic + topic = TopicSession( + user_id=user.id, + chat_session_id=session.id, + title=_title_from_question(question), + core_question=question.strip(), + status="active", + message_count=0, + token_input=0, + token_output=0, + quota_deducted=1 if deduct_quota else 0, + started_at=_now(), + ) + db.add(topic) + db.flush() + return topic + + @staticmethod + def attach_user_message(message: ChatMessage, topic: TopicSession) -> None: + message.topic_session_id = topic.id + topic.message_count += 1 + + @staticmethod + def attach_assistant_message( + message: ChatMessage, + topic: TopicSession, + *, + token_input: int | None, + token_output: int | None, + ) -> None: + message.topic_session_id = topic.id + topic.message_count += 1 + topic.token_input += int(token_input or 0) + topic.token_output += int(token_output or 0) + + @staticmethod + def topic_dict(topic: TopicSession) -> dict: + return { + "id": topic.id, + "userId": topic.user_id, + "chatSessionId": topic.chat_session_id, + "title": topic.title, + "coreQuestion": topic.core_question, + "status": topic.status, + "messageCount": topic.message_count, + "tokenInput": topic.token_input, + "tokenOutput": topic.token_output, + "quotaDeducted": bool(topic.quota_deducted), + "helpCardGenerated": bool(topic.help_card_generated), + "shareDraftGenerated": bool(topic.share_draft_generated), + "startedAt": topic.started_at, + "endedAt": topic.ended_at, + "updatedAt": topic.updated_at, + } + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +def _title_from_question(question: str) -> str: + title = question.strip().replace("\n", " ") + return title[:40] if title else "新主题" diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_entitlements_topics.py b/ai_knowledge_base_v2/apps/backend/tests/test_entitlements_topics.py new file mode 100644 index 0000000..2ec419f --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_entitlements_topics.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.models import Base +from app.models.chat import ChatSession, TopicSession +from app.models.entitlement import EntitlementPlan +from app.models.user import User +from app.services.chat_service import ChatService +from app.services.entitlement_service import EntitlementService +from app.services.topic_session_service import TopicSessionService + + +def _db() -> Session: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + return Session(engine) + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +def _seed_user_session(db: Session) -> tuple[User, ChatSession]: + user = User(id=1, phone="13800000001", name="测试用户", daily_chat_limit=100, daily_chat_used=0) + session = ChatSession(id=1, user_id=1, title="新聊天", message_count=0, last_message_at=_now(), is_deleted=0) + db.add_all([user, session]) + db.commit() + return user, session + + +def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment(): + with _db() as db: + user, _session = _seed_user_session(db) + db.add( + EntitlementPlan( + id=10, + name="大本营基础版", + plan_type="basic", + monthly_topic_limit=30, + status=1, + sort_order=10, + ) + ) + db.commit() + + view = EntitlementService.active_entitlement(db, user, monthly_topic_used=3) + + assert view.plan_id == 10 + assert view.name == "大本营基础版" + assert view.source == "default" + assert view.monthly_topic_remaining == 27 + + +def test_assign_user_plan_replaces_previous_active_plan(): + with _db() as db: + user, _session = _seed_user_session(db) + db.add_all( + [ + EntitlementPlan(id=10, name="基础版", plan_type="basic", monthly_topic_limit=30, status=1, sort_order=10), + EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1, sort_order=20), + ] + ) + db.commit() + + first = EntitlementService.assign_user_plan(db, user=user, plan_id=10, operated_by=99) + second = EntitlementService.assign_user_plan(db, user=user, plan_id=20, operated_by=99) + db.commit() + + db.refresh(first) + db.refresh(second) + view = EntitlementService.active_entitlement(db, user, monthly_topic_used=4) + + assert first.status == "replaced" + assert second.status == "active" + assert view.plan_id == 20 + assert view.source == "assigned" + assert view.monthly_topic_remaining == 86 + + +def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic(): + with _db() as db: + user, session = _seed_user_session(db) + db.add(EntitlementPlan(id=10, name="限额版", plan_type="basic", monthly_topic_limit=1, status=1, sort_order=10)) + db.add( + TopicSession( + id=100, + user_id=user.id, + chat_session_id=99, + title="旧主题", + core_question="旧主题", + status="active", + quota_deducted=1, + started_at=_now(), + created_at=_now(), + updated_at=_now(), + ) + ) + db.commit() + entitlement = EntitlementService.active_entitlement(db, user, monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id)) + + with pytest.raises(HTTPException) as exc: + ChatService._ensure_topic_quota(db, user, session, entitlement) + + assert exc.value.status_code == 403 + + TopicSessionService.get_or_create_active( + db, + user=user, + session=session, + question="当前主题", + deduct_quota=True, + ) + db.flush() + + ChatService._ensure_topic_quota(db, user, session, entitlement) diff --git a/ai_knowledge_base_v2/apps/user-client/src/App.vue b/ai_knowledge_base_v2/apps/user-client/src/App.vue index 0e96da2..d5a5a2c 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/App.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/App.vue @@ -304,7 +304,7 @@ function showToast(message: string) {