feat: add entitlement plans and topic sessions

This commit is contained in:
2026-07-31 15:22:11 +08:00
parent ad2161497f
commit 884dc765ad
27 changed files with 2389 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ import type {
ChatDetail,
ChatRecord,
DashboardStats,
EntitlementPlan,
KnowledgeItem,
RetrievalLogItem,
AttentionItem,
@@ -41,6 +42,8 @@ const dashboardFilters = reactive({
});
const users = ref<AdminUser[]>([]);
const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null);
const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]);
@@ -88,6 +91,21 @@ const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(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") {
<div class="sidebar-title">大本营千问千答</div>
<button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">数据看板</button>
<button :class="{ active: activeMenu === 'users' }" @click="switchMenu('users')">用户管理</button>
<button :class="{ active: activeMenu === 'entitlements' }" @click="switchMenu('entitlements')">权益管理</button>
<button :class="{ active: activeMenu === 'knowledge' }" @click="switchMenu('knowledge')">知识库管理</button>
<button :class="{ active: activeMenu === 'prompt' }" @click="switchMenu('prompt')">Agent 管理</button>
<button :class="{ active: activeMenu === 'models' }" @click="switchMenu('models')">模型管理</button>
@@ -984,6 +1076,30 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template>
</el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="权益版本" min-width="260">
<template #default="{ row }">
<div class="user-entitlement-cell">
<el-select
:model-value="row.entitlement?.planId"
placeholder="默认基础版"
size="small"
@change="(planId: number) => assignUserEntitlement(row, planId)"
>
<el-option
v-for="plan in entitlementPlans.filter((item) => item.status === 1)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<small>
{{ row.entitlement?.name || '默认基础版' }}
· 本月主题
{{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}
</small>
</div>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
@@ -995,6 +1111,92 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<AdminPagination :page="pagers.users.page" :page-size="pagers.users.pageSize" :total="pagers.users.total" @change="loadUsers" />
</template>
<template v-if="activeMenu === 'entitlements'">
<div class="page-head">
<h2>权益管理</h2>
<p>配置不同服务版本的本月主题额度和可用能力学员可在用户管理里直接分配</p>
</div>
<section class="entitlement-editor">
<div class="entitlement-editor-head">
<div>
<h3>{{ editingEntitlementPlanId ? '编辑权益版本' : '新增权益版本' }}</h3>
<p>一期先接入主题额度成长档案/周期报告开关和转人工卡片/分享草稿能力位</p>
</div>
<el-button @click="resetEntitlementPlanForm">清空</el-button>
</div>
<el-form label-position="top" :model="entitlementPlanForm">
<div class="entitlement-form-grid">
<el-form-item label="版本名称">
<el-input v-model="entitlementPlanForm.name" placeholder="例如:五个月深度陪伴版" />
</el-form-item>
<el-form-item label="版本类型">
<el-select v-model="entitlementPlanForm.planType">
<el-option label="基础版" value="basic" />
<el-option label="深度陪伴版" value="deep" />
<el-option label="高频加购包" value="addon" />
<el-option label="老师工作版" value="teacher" />
</el-select>
</el-form-item>
<el-form-item label="有效天数">
<el-input-number v-model="entitlementPlanForm.validityDays" :min="1" :max="3650" placeholder="留空长期有效" />
</el-form-item>
<el-form-item label="本月主题额度">
<el-input-number v-model="entitlementPlanForm.monthlyTopicLimit" :min="0" :max="99999" placeholder="留空不限" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="entitlementPlanForm.sortOrder" :min="0" :max="9999" />
</el-form-item>
</div>
<el-form-item label="说明">
<el-input v-model="entitlementPlanForm.description" type="textarea" :rows="2" placeholder="给后台管理员看的说明,不展示给学员" />
</el-form-item>
<div class="entitlement-switch-grid">
<label><span>成长档案</span><el-switch v-model="entitlementPlanForm.enableGrowthProfile" :active-value="1" :inactive-value="0" /></label>
<label><span>周期报告</span><el-switch v-model="entitlementPlanForm.enablePeriodicReports" :active-value="1" :inactive-value="0" /></label>
<label><span>允许求助卡片</span><el-switch v-model="entitlementPlanForm.allowHelpCard" :active-value="1" :inactive-value="0" /></label>
<label><span>允许分享草稿</span><el-switch v-model="entitlementPlanForm.allowShareDraft" :active-value="1" :inactive-value="0" /></label>
<label><span>占用主题额度</span><el-switch v-model="entitlementPlanForm.deductQuota" :active-value="1" :inactive-value="0" /></label>
<label><span>启用版本</span><el-switch v-model="entitlementPlanForm.status" :active-value="1" :inactive-value="0" /></label>
</div>
<div class="actions">
<el-button type="primary" @click="saveEntitlementPlan">{{ editingEntitlementPlanId ? '保存权益版本' : '新增权益版本' }}</el-button>
<el-button @click="resetEntitlementPlanForm">取消编辑</el-button>
</div>
</el-form>
</section>
<el-table :data="entitlementPlans" stripe>
<el-table-column prop="name" label="版本名称" min-width="180" />
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ planTypeLabel(row.planType) }}</template>
</el-table-column>
<el-table-column label="本月主题额度" width="130">
<template #default="{ row }">{{ row.monthlyTopicLimit ?? '不限' }}</template>
</el-table-column>
<el-table-column label="有效期" width="110">
<template #default="{ row }">{{ row.validityDays ? `${row.validityDays}` : '长期' }}</template>
</el-table-column>
<el-table-column label="能力" min-width="260">
<template #default="{ row }">
<div class="entitlement-capabilities">
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">成长档案</el-tag>
<el-tag v-if="row.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
<el-tag :type="row.deductQuota ? 'warning' : 'info'" effect="plain">{{ row.deductQuota ? '计入额度' : '不计额度' }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" @click="editEntitlementPlan(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'knowledge'">
<KnowledgeManagementView @preview="previewKnowledge" />
</template>

View File

@@ -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<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
entitlementPlans: (includeDisabled = true) =>
request<EntitlementPlan[]>(`/admin/entitlement/plan/list${queryString({ includeDisabled })}`),
createEntitlementPlan: (payload: Record<string, unknown>) =>
request<EntitlementPlan>("/admin/entitlement/plan", { method: "POST", body: JSON.stringify(payload) }),
updateEntitlementPlan: (id: number, payload: Record<string, unknown>) =>
request<EntitlementPlan>(`/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<UserEntitlementSummary>(`/admin/user/${userId}/entitlement`, { method: "POST", body: JSON.stringify(payload) }),
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/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 }) }),

View File

@@ -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));

View File

@@ -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;

View File

@@ -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,
},
],
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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)

View File

@@ -61,6 +61,7 @@ SYNONYM_RULES = (
(re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"),
(re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"),
(re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"),
(re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"),
(re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"),
(re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
)

View File

@@ -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 "新主题"

View File

@@ -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)

View File

@@ -304,7 +304,7 @@ function showToast(message: string) {
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else>
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" />
<SessionQuota :used="user.todayUsed" :limit="user.dailyLimit" />
<SessionQuota :used="user.todayUsed" :limit="user.dailyLimit" :entitlement="user.entitlement" />
<MessageList
ref="messageList"
:messages="messages"

View File

@@ -1,18 +1,30 @@
<script setup lang="ts">
import { Layers3 } from "@lucide/vue";
import { computed } from "vue";
defineProps<{
import type { UserEntitlementSummary } from "../types/api";
const props = defineProps<{
used: number;
limit: number;
entitlement?: UserEntitlementSummary | null;
}>();
const hasEntitlement = computed(() => Boolean(props.entitlement));
const displayUsed = computed(() => props.entitlement?.monthlyTopicUsed ?? props.used);
const displayLimit = computed(() => props.entitlement?.monthlyTopicLimit ?? props.limit);
const exhausted = computed(() => displayLimit.value !== null && displayLimit.value > 0 && displayUsed.value >= displayLimit.value);
const title = computed(() => props.entitlement ? "本月主题额度" : "当前会话额度");
const limitText = computed(() => displayLimit.value === null ? "不限" : String(displayLimit.value));
</script>
<template>
<section class="session-quota" :class="{ exhausted: limit > 0 && used >= limit }" aria-label="当前会话额度">
<section class="session-quota" :class="{ exhausted }" :aria-label="title">
<div>
<Layers3 :size="18" aria-hidden="true" />
<span>当前会话额度</span>
<span>{{ title }}</span>
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
</div>
<strong>{{ used }}/{{ limit }}</strong>
<strong>{{ displayUsed }}/{{ limitText }}</strong>
</section>
</template>

View File

@@ -1218,6 +1218,16 @@ textarea:focus-visible {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.session-quota small {
overflow: hidden;
max-width: 150px;
color: #6c8078;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-quota svg,

View File

@@ -12,6 +12,24 @@ export interface UserProfile {
dailyLimit: number;
todayUsed: number;
status: number;
entitlement?: UserEntitlementSummary | 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 LoginResult {