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, ChatDetail,
ChatRecord, ChatRecord,
DashboardStats, DashboardStats,
EntitlementPlan,
KnowledgeItem, KnowledgeItem,
RetrievalLogItem, RetrievalLogItem,
AttentionItem, AttentionItem,
@@ -41,6 +42,8 @@ const dashboardFilters = reactive({
}); });
const users = ref<AdminUser[]>([]); const users = ref<AdminUser[]>([]);
const userKeyword = ref(""); const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]); const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]); const chats = ref<ChatRecord[]>([]);
@@ -88,6 +91,21 @@ const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null); const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | 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({ const knowledgeForm = reactive({
name: "", name: "",
feishuSpaceId: "", feishuSpaceId: "",
@@ -278,7 +296,8 @@ async function loadCurrentMenu() {
loading.value = true; loading.value = true;
try { try {
if (activeMenu.value === "dashboard") await loadDashboard(); 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 === "models") models.value = await api.models();
if (activeMenu.value === "configs") { if (activeMenu.value === "configs") {
configs.value = await api.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 }); 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) { async function loadRecordTab(tab = recordTab.value) {
loading.value = true; loading.value = true;
try { try {
@@ -866,6 +957,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="sidebar-title">大本营千问千答</div> <div class="sidebar-title">大本营千问千答</div>
<button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">数据看板</button> <button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">数据看板</button>
<button :class="{ active: activeMenu === 'users' }" @click="switchMenu('users')">用户管理</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 === 'knowledge' }" @click="switchMenu('knowledge')">知识库管理</button>
<button :class="{ active: activeMenu === 'prompt' }" @click="switchMenu('prompt')">Agent 管理</button> <button :class="{ active: activeMenu === 'prompt' }" @click="switchMenu('prompt')">Agent 管理</button>
<button :class="{ active: activeMenu === 'models' }" @click="switchMenu('models')">模型管理</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> <template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template>
</el-table-column> </el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" /> <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 prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"> <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" /> <AdminPagination :page="pagers.users.page" :page-size="pagers.users.pageSize" :total="pagers.users.total" @change="loadUsers" />
</template> </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'"> <template v-if="activeMenu === 'knowledge'">
<KnowledgeManagementView @preview="previewKnowledge" /> <KnowledgeManagementView @preview="previewKnowledge" />
</template> </template>

View File

@@ -12,6 +12,7 @@ import type {
ChatRecord, ChatRecord,
ChatRecordQuery, ChatRecordQuery,
DashboardStats, DashboardStats,
EntitlementPlan,
KnowledgeItem, KnowledgeItem,
KnowledgeContentSearchItem, KnowledgeContentSearchItem,
KnowledgeDetail, KnowledgeDetail,
@@ -25,6 +26,7 @@ import type {
ModelItem, ModelItem,
SystemConfigItem, SystemConfigItem,
UserImportResult, UserImportResult,
UserEntitlementSummary,
PageResult, PageResult,
PromptDetail, PromptDetail,
PromptHistoryItem, PromptHistoryItem,
@@ -136,6 +138,14 @@ export const api = {
updateUser: (id: number, payload: Record<string, unknown>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), 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)}`), knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"), 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 }) }), 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; 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 { .migration-overview {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));

View File

@@ -61,6 +61,42 @@ export interface AdminUser {
expiredAt?: string | null; expiredAt?: string | null;
lastLoginAt?: string | null; lastLoginAt?: string | null;
createdAt?: 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 { export interface UserImportFailure {
@@ -315,6 +351,7 @@ export interface ChatRecord {
export interface ChatMessageRecord { export interface ChatMessageRecord {
id: number; id: number;
sessionId: number; sessionId: number;
topicSessionId?: number | null;
userId: number; userId: number;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; 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 { return {
"id": message.id, "id": message.id,
"sessionId": message.session_id, "sessionId": message.session_id,
"topicSessionId": message.topic_session_id,
"userId": message.user_id, "userId": message.user_id,
"role": message.role, "role": message.role,
"content": message.content, "content": message.content,

View File

@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date, datetime from datetime import UTC, date, datetime
from io import BytesIO from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import func, select from sqlalchemy import extract, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db 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.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import SystemConfig 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.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService 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 from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@@ -40,7 +43,8 @@ def list_users(
query = query.where((User.phone.like(like)) | (User.name.like(like))) 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 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() 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") @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) OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit() db.commit()
db.refresh(user) 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") @router.post("/user/import")
@@ -240,10 +249,24 @@ def update_user(
if payload.expiredAt is not None: if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None) user.expired_at = payload.expiredAt.replace(tzinfo=None)
db.add(user) 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) OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit() db.commit()
db.refresh(user) 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}") @router.delete("/user/{user_id}")
@@ -269,7 +292,7 @@ def _get_user(db: Session, user_id: int) -> User:
return user return user
def _user_dict(user: User) -> dict: def _user_dict(user: User, entitlement: dict | None = None) -> dict:
return { return {
"id": user.id, "id": user.id,
"phone": user.phone, "phone": user.phone,
@@ -281,9 +304,64 @@ def _user_dict(user: User) -> dict:
"expiredAt": user.expired_at, "expiredAt": user.expired_at,
"lastLoginAt": user.last_login_at, "lastLoginAt": user.last_login_at,
"createdAt": user.created_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( def _apply_user_payload(
user: User, user: User,
payload: AdminUserCreateRequest | AdminUserImportItem, payload: AdminUserCreateRequest | AdminUserImportItem,

View File

@@ -6,6 +6,7 @@ from app.api import (
admin_auth, admin_auth,
admin_agent_records, admin_agent_records,
admin_dashboard, admin_dashboard,
admin_entitlements,
admin_knowledge, admin_knowledge,
admin_knowledge_lifecycle, admin_knowledge_lifecycle,
admin_records, 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_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_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_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_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.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"]) 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 __future__ import annotations
from fastapi import APIRouter, Depends 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.dependencies import get_current_user
from app.core.responses import api_success from app.core.responses import api_success
from app.models.user import User from app.models.user import User
from app.schemas.user import UserProfile 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 = APIRouter()
@router.get("/profile") @router.get("/profile")
def profile(current_user: User = Depends(get_current_user)) -> dict: def profile(
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json")) 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.admin import Admin, Role
from app.models.ai_config import ModelConfig, Prompt, SystemConfig from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base 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 ( from app.models.knowledge import (
HumanAttentionHistory, HumanAttentionHistory,
HumanAttentionRecord, HumanAttentionRecord,
@@ -28,6 +29,7 @@ __all__ = [
"Base", "Base",
"ChatMessage", "ChatMessage",
"ChatSession", "ChatSession",
"EntitlementPlan",
"Knowledge", "Knowledge",
"KnowledgeCard", "KnowledgeCard",
"KnowledgeChunk", "KnowledgeChunk",
@@ -46,9 +48,12 @@ __all__ = [
"OperationLog", "OperationLog",
"LogRetentionPolicy", "LogRetentionPolicy",
"StorageSnapshot", "StorageSnapshot",
"TopicSession",
"Prompt", "Prompt",
"Role", "Role",
"SystemConfig", "SystemConfig",
"User", "User",
"UserEntitlement",
"UserEntitlementLog",
"UserKnowledgePermission", "UserKnowledgePermission",
] ]

View File

@@ -29,6 +29,7 @@ class ChatMessage(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) 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) 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) user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False) role: Mapped[str] = mapped_column(String(20), nullable=False)
content: Mapped[str] = mapped_column(Text, 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) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages") 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) status: int | None = Field(default=None, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000) dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None 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): class AdminUserCreateRequest(BaseModel):
@@ -65,6 +68,28 @@ class AdminUserImportRequest(BaseModel):
students: list[AdminUserImportItem] = Field(default_factory=list) 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): class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
feishuSpaceId: 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): class ChatMessageRead(ORMModel):
id: int id: int
topic_session_id: int | None = None
role: str role: str
content: str content: str
message_status: str message_status: str

View File

@@ -18,3 +18,4 @@ class UserProfile(ORMModel):
effective_at: datetime | None = None effective_at: datetime | None = None
expired_at: datetime | None = None expired_at: datetime | None = None
last_login_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.chat import ChatMessage, ChatSession
from app.models.user import User from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService 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.external_errors import ExternalServiceError
from app.services.chat_context_service import ChatContextService from app.services.chat_context_service import ChatContextService
from app.services.model_service import ModelClientService from app.services.model_service import ModelClientService
from app.services.rag_service import RagService from app.services.rag_service import RagService
from app.services.topic_session_service import TopicSessionService
class ChatService: class ChatService:
@@ -75,11 +77,25 @@ class ChatService:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user) 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() now = _now()
normalized_question = question.strip() 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( user_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id,
user_id=user.id, user_id=user.id,
role="user", role="user",
content=normalized_question, content=normalized_question,
@@ -87,6 +103,7 @@ class ChatService:
created_at=now, created_at=now,
) )
db.add(user_message) db.add(user_message)
TopicSessionService.attach_user_message(user_message, topic)
db.flush() db.flush()
started_at = perf_counter() started_at = perf_counter()
@@ -141,6 +158,7 @@ class ChatService:
assistant_message = ChatMessage( assistant_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id,
user_id=user.id, user_id=user.id,
role="assistant", role="assistant",
content=completion.answer, content=completion.answer,
@@ -152,6 +170,12 @@ class ChatService:
created_at=now, created_at=now,
) )
db.add(assistant_message) db.add(assistant_message)
TopicSessionService.attach_assistant_message(
assistant_message,
topic,
token_input=completion.input_token,
token_output=completion.output_token,
)
db.flush() db.flush()
session.message_count += 2 session.message_count += 2
@@ -198,6 +222,18 @@ class ChatService:
if user.daily_chat_used >= user.daily_chat_limit: if user.daily_chat_used >= user.daily_chat_limit:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完") 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 @staticmethod
def prepare_daily_quota(db: Session, user: User) -> User: def prepare_daily_quota(db: Session, user: User) -> User:
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update()) 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.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService 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.external_errors import ExternalServiceError
from app.services.human_attention_service import HumanAttentionService from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService from app.services.model_stream_service import ModelStreamService
from app.services.rag_async_service import AsyncRagService from app.services.rag_async_service import AsyncRagService
from app.services.rag_service import RagService from app.services.rag_service import RagService
from app.services.topic_session_service import TopicSessionService
class ChatStreamService: class ChatStreamService:
@@ -29,11 +31,25 @@ class ChatStreamService:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user) 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() now = _now()
normalized_question = question.strip() 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( user_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id,
user_id=user.id, user_id=user.id,
role="user", role="user",
content=normalized_question, content=normalized_question,
@@ -41,6 +57,7 @@ class ChatStreamService:
created_at=now, created_at=now,
) )
db.add(user_message) db.add(user_message)
TopicSessionService.attach_user_message(user_message, topic)
db.flush() db.flush()
history = list( history = list(
@@ -122,6 +139,7 @@ class ChatStreamService:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
assistant_message = ChatMessage( assistant_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id,
user_id=user.id, user_id=user.id,
role="assistant", role="assistant",
content=answer, content=answer,
@@ -133,6 +151,12 @@ class ChatStreamService:
created_at=now, created_at=now,
) )
db.add(assistant_message) 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() db.flush()
session.message_count += 2 session.message_count += 2
@@ -163,11 +187,25 @@ class ChatStreamService:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user) 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() now = _now()
normalized_question = question.strip() 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( user_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id,
user_id=user.id, user_id=user.id,
role="user", role="user",
content=normalized_question, content=normalized_question,
@@ -175,6 +213,7 @@ class ChatStreamService:
created_at=now, created_at=now,
) )
db.add(user_message) db.add(user_message)
TopicSessionService.attach_user_message(user_message, topic)
db.flush() db.flush()
history = list( history = list(
@@ -262,6 +301,7 @@ class ChatStreamService:
model_response=model_response, model_response=model_response,
started_at=started_at, started_at=started_at,
now=now, now=now,
topic=topic,
) )
@@ -284,10 +324,12 @@ def _write_success(
model_response, model_response,
started_at: float, started_at: float,
now: datetime, now: datetime,
topic,
) -> None: ) -> None:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
assistant_message = ChatMessage( assistant_message = ChatMessage(
session_id=session.id, session_id=session.id,
topic_session_id=topic.id if topic is not None else None,
user_id=user.id, user_id=user.id,
role="assistant", role="assistant",
content=answer, content=answer,
@@ -299,6 +341,13 @@ def _write_success(
created_at=now, created_at=now,
) )
db.add(assistant_message) 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() db.flush()
session.message_count += 2 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"(上课|直播|带练|带领练习)"), "上课安排"),
(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" /> <LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else> <template v-else>
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" /> <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 <MessageList
ref="messageList" ref="messageList"
:messages="messages" :messages="messages"

View File

@@ -1,18 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { Layers3 } from "@lucide/vue"; import { Layers3 } from "@lucide/vue";
import { computed } from "vue";
defineProps<{ import type { UserEntitlementSummary } from "../types/api";
const props = defineProps<{
used: number; used: number;
limit: 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> </script>
<template> <template>
<section class="session-quota" :class="{ exhausted: limit > 0 && used >= limit }" aria-label="当前会话额度"> <section class="session-quota" :class="{ exhausted }" :aria-label="title">
<div> <div>
<Layers3 :size="18" aria-hidden="true" /> <Layers3 :size="18" aria-hidden="true" />
<span>当前会话额度</span> <span>{{ title }}</span>
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
</div> </div>
<strong>{{ used }}/{{ limit }}</strong> <strong>{{ displayUsed }}/{{ limitText }}</strong>
</section> </section>
</template> </template>

View File

@@ -1218,6 +1218,16 @@ textarea:focus-visible {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; 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, .session-quota svg,

View File

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

View File

@@ -0,0 +1,908 @@
# 千问千答产品 TODO 清单(按代码影响顺序与改动大小排序)
> 版本v0.1
> 日期2026-07-31
> 范围:仅针对“千问千答·五个月实修陪伴服务”本身,不包含慧能训练师培养、教务排班、师资认证、收入结算、老学员权益转轨等独立业务系统。
## 1. 产品边界
千问千答当前产品定位是:
> 面向大本营学员的 AI 实修陪伴工具,用来帮助学员查课程知识、梳理当下议题、找到功课方向、沉淀成长记录,并在必要时由用户主动生成给人工老师看的求助卡。
明确不做:
- 不做自动转人工;
- 不做老师派单;
- 不做工单流转;
- 不做老师处理状态;
- 不做师资认证、见习、督导、收入结算;
- 不做 108000 / 40000 老学员权益转轨账户;
- 不把慧能训练师体系强行塞进千问千答。
允许做:
- AI 在回复中提醒用户:“这个问题建议找人工老师确认”;
- 用户主动生成“老师求助卡”;
- 用户自行复制求助卡发给老师或班级群;
- 后台记录“用户生成过求助卡”这个事实,用于运营分析,但不形成派单。
## 2. 排序原则
本 TODO 按两个维度排序:
1. **代码影响顺序**先排会影响数据库模型、Agent 调用链、用户权益判断的底层改动,再排用户端和后台页面。
2. **改动大小**:优先识别大改动和依赖项,避免先做小功能后返工。
改动大小标记:
- **L**:大改动,涉及数据库迁移、核心服务、前后端多处联动;
- **M**:中改动,涉及新增接口、页面和部分业务逻辑;
- **S**:小改动,主要是 UI、配置、文案或局部功能增强。
## 3. 总体依赖顺序
```text
权益版本体系
主题会话机制
主题摘要 / 成长档案
Agent 调用链接入成长档案与权益
用户端实修能力:求助卡、分享稿、我的档案
后台运营能力:用户详情、成本、问题洞察二期
报告体系:周报、月报、五个月总结
```
不要先做周报、分享稿或漂亮页面。它们依赖主题会话和成长档案,提前做会变成“无数据可用的壳”。
## 4. TODO 清单
### 01. 权益版本体系
- 优先级P0
- 改动大小L
- 影响范围:数据库、用户管理、登录态/Profile、聊天额度校验、用户端权益展示、后台配置
- 建议先做:是
#### 当前现状
当前用户模型主要有:
- 启用状态;
- 每日聊天额度;
- 已用次数;
- 有效期。
这还不能表达文档中的:
- 大本营基础版;
- 五个月深度陪伴版;
- 高频加购包;
- 老师工作版。
#### 目标
建立真正的“产品权益”模型,而不是只用每日消息额度顶替。
#### 需要新增的数据
建议新增:
- `sys_entitlement_plan`:权益版本;
- `sys_user_entitlement`:用户当前权益;
- `sys_user_entitlement_log`:权益变更日志。
权益版本字段建议包含:
- 名称;
- 类型:基础版 / 深度陪伴版 / 高频加购包 / 老师工作版;
- 有效天数;
- 每月主题会话额度;
- 是否开启成长档案;
- 是否开启周报/月报;
- 是否允许生成老师求助卡;
- 是否允许生成班级分享稿;
- 是否参与普通额度扣减;
- 状态;
- 创建时间、更新时间。
#### 需要改的代码模块
- 后端:
- 用户模型;
- 用户管理接口;
- 用户登录/Profile 接口;
- 聊天前置校验;
- 系统配置或新增权益配置接口。
- 管理端:
- 用户管理;
- 用户新增/导入;
- 用户详情;
- 权益配置页面。
- 用户端:
- 个人权益展示;
- 额度提醒展示。
#### 验收标准
- 管理员可以创建/启用/停用权益版本;
- 管理员可以给用户分配权益;
- 用户登录后能看到自己的权益状态;
- 聊天接口按权益判断是否可用;
- 老师工作版不消耗普通学员额度;
- 所有权益变更有审计记录。
---
### 02. 主题会话机制
- 优先级P0
- 改动大小L
- 影响范围数据库、聊天服务、Agent 上下文、额度扣减、记录审计、用户端会话 UI
- 依赖:权益版本体系
#### 当前现状
当前系统有聊天会话和聊天消息,但没有“一个具体议题”的主题会话概念。
文档中明确说,千问千答不应该按一条消息计费,而应该按“一个主题会话”计算。
#### 目标
把“聊天消息”升级为“围绕一个议题的主题会话”,权益消耗按主题会话计算。
#### 需要新增的数据
建议新增:
- `sys_topic_session`:主题会话;
- `sys_topic_message_link`:主题与消息关联,或者在消息表增加 `topic_session_id`
主题会话字段建议包含:
- 用户 ID
- 普通聊天 session ID
- 主题标题;
- 核心问题;
- 状态:进行中 / 已完成 / 已归档;
- 起始时间;
- 结束时间;
- 消息数;
- Token 消耗;
- 是否已扣减权益;
- 推荐过的功课摘要;
- 是否生成过求助卡;
- 是否生成过分享稿。
#### 关键难点
要判断用户新发的问题是:
- 继续当前主题;
- 开启新主题;
- 对上一个回答追问;
- 只是闲聊或固定信息查询。
一期可以先做保守方案:
- 用户端默认当前聊天为一个主题;
- 超过一定间隔或用户点击“开启新主题”后创建新主题;
- AI 辅助生成主题标题;
- 先不要完全依赖模型自动判断切题。
#### 需要改的代码模块
- 后端:
- 聊天发送接口;
- 聊天历史接口;
- ChatService
- ChatStreamService
- Agent 调用链;
- 记录审计接口。
- 用户端:
- 新建主题;
- 当前主题状态;
- 历史主题列表。
- 管理端:
- 聊天详情展示主题;
- 用户详情展示主题统计。
#### 验收标准
- 用户可以围绕一个主题连续追问;
- 一个主题只扣一次主题额度;
- 管理后台能看到主题会话;
- 主题能关联原始聊天消息;
- 主题结束后可用于成长档案沉淀。
---
### 03. 主题摘要与成长档案
- 优先级P0
- 改动大小L
- 影响范围数据库、Agent 上下文、模型调用、用户端“我的档案”、后台用户详情
- 依赖:主题会话机制
#### 当前现状
当前已有会话摘要和上下文消息数,用于解决当前会话记忆问题。
但它不是产品层面的“五个月成长档案”,还不能表达:
- 反复议题;
- 做过的功课;
- 情绪和身体感受变化;
- 用户成长轨迹;
- 哪些功课有效。
#### 目标
把每个主题会话沉淀成主题摘要,再把多个主题摘要汇总成用户成长档案。
#### 需要新增的数据
建议新增:
- `sys_topic_summary`:主题摘要;
- `sys_user_growth_profile`:用户成长档案;
- `sys_growth_profile_revision`:成长档案版本记录。
主题摘要字段建议包含:
- 主题 ID
- 用户原始问题摘要;
- 主要事件;
- 主要情绪;
- 身体感受;
- 信念/程序;
- 推荐功课;
- 用户已经看见的内容;
- 下一步建议观察方向;
- 生成模型;
- 生成时间。
成长档案字段建议包含:
- 用户 ID
- 反复议题;
- 常见情绪;
- 常见身体反应;
- 常见关系模式;
- 常做功课;
- 有效功课;
- 最近进展;
- 最近更新时间。
#### 需要改的代码模块
- 后端:
- 主题结束逻辑;
- 摘要生成服务;
- 成长档案更新服务;
- Agent Prompt 构建逻辑;
- 模型服务。
- 用户端:
- 我的实修档案;
- 主题摘要展示。
- 管理端:
- 用户详情里的成长档案;
- 摘要重生成。
#### 验收标准
- 一个主题结束后可以生成摘要;
- 用户成长档案能跨主题更新;
- Agent 回答时能引用成长档案摘要;
- 不把全部历史原文塞进模型;
- 后台能查看档案更新记录。
---
### 04. Agent 调用链接入权益、主题与成长档案
- 优先级P0
- 改动大小L
- 影响范围Agent 主链路、调试预览、用户端流式输出、检索日志
- 依赖:权益版本体系、主题会话机制、成长档案
#### 当前现状
Agent 已经可以:
- 使用知识库;
- 使用运行参数;
- 使用上下文摘要;
- 记录检索日志;
- 支持后台预览。
但后续要让 Agent 区分:
- 当前用户是什么权益;
- 当前问题属于哪个主题;
- 是否可使用成长档案;
- 是否需要提醒找人工老师确认;
- 是否允许生成求助卡/分享稿。
#### 目标
让 Agent 不只是“回答问题”,而是运行在“权益 + 主题 + 成长档案 + 知识库”的产品上下文中。
#### 需要改的代码模块
- 后端:
- PromptService / RAGService / KnowledgeAgentService
- ChatStreamService
- AgentDebugService
- 检索日志;
- AI 请求日志。
- 管理端:
- Agent 调试支持模拟用户权益和主题上下文;
- 后台预览与用户端表现保持一致。
#### 验收标准
- 用户端和后台预览链路参数一致;
- Agent 能拿到当前主题摘要;
- 深度版用户可以使用成长档案;
- 基础版不会调用长期成长档案;
- 生成日志能看出本次用了哪些上下文。
---
### 05. 老师求助卡
- 优先级P0
- 改动大小M
- 影响范围:用户端、后端生成接口、记录审计、后台用户详情
- 依赖:主题会话机制、主题摘要
#### 产品边界
这不是转人工工单。
用户主动生成一张卡片,然后自行复制给老师。
系统不做:
- 自动派单;
- 老师接单;
- 老师处理状态;
- 后台工单流转。
#### 目标
把 AI 已经梳理过的信息整理成适合发给老师看的求助卡。
#### 求助卡内容建议
- 我遇到的问题;
- AI 已经帮我梳理出的重点;
- 我现在最明显的情绪/身体感受;
- 我已经尝试过的功课;
- 我仍然卡住的地方;
- 我想请老师确认的问题;
- 相关主题时间;
- 用户可自行删改的提示。
#### 需要新增的数据
建议新增:
- `sys_teacher_help_card`
字段建议包含:
- 用户 ID
- 主题 ID
- 内容;
- 生成来源;
- 是否被用户复制;
- 创建时间。
#### 需要改的代码模块
- 后端:
- 求助卡生成接口;
- 求助卡历史接口。
- 用户端:
- 生成按钮;
- 卡片预览;
- 复制按钮。
- 管理端:
- 用户详情展示生成历史;
- 记录审计可查看。
#### 验收标准
- 用户可以从当前主题生成求助卡;
- 求助卡内容可编辑、可复制;
- 不出现“已转人工”“等待老师处理”等文案;
- 后台只记录生成历史,不做派单。
---
### 06. 班级分享稿
- 优先级P1
- 改动大小M
- 影响范围:用户端、后端生成接口、主题记录
- 依赖:主题会话机制、主题摘要
#### 目标
帮助用户把一次实修看见整理成适合发班级群的分享稿。
#### 分享稿要求
- 不暴露过多隐私;
- 不替用户夸大成长;
- 不写成营销文;
- 重点表达:
- 我看见了什么;
- 我做了什么功课;
- 当下有什么变化;
- 我还在继续观察什么。
#### 需要新增的数据
建议新增:
- `sys_share_draft`
#### 需要改的代码模块
- 后端:
- 分享稿生成接口;
- 分享稿历史接口。
- 用户端:
- 生成分享稿;
- 编辑;
- 复制。
- 管理端:
- 用户详情可查看生成摘要。
#### 验收标准
- 用户可以生成分享稿;
- 分享稿可编辑、可复制;
- 分享稿不会自动发送到任何群;
- 后台可统计生成次数。
---
### 07. 知识库类型规则继续落地
- 优先级P1
- 改动大小M
- 影响范围知识库管理、检索策略、Agent Prompt 注入、检索日志
- 依赖:当前知识库体系
#### 当前现状
知识库已有类型:
- 课程知识库;
- 答疑知识库;
- 固定信息类知识库;
- 通用知识库。
但后续还要把类型规则更明确地用于 Agent 决策。
#### 目标
不同知识库类型在回答中的优先级不同。
#### 规则建议
- 固定信息类知识库优先级最高;
- 回答上课时间、带练安排、回放、音频、课程作业、会议链接、服务权益、助教联系方式时,优先使用固定信息类;
- 课程知识库用于解释课程内容和功课;
- 答疑知识库用于参考过往答疑;
- 如果固定信息类与其他知识冲突,以固定信息类为准;
- 不依赖管理员手写主提示词,应由系统规则注入。
#### 需要改的代码模块
- 后端:
- KnowledgeAgentService
- 检索排序;
- KnowledgeList 构建;
- 检索日志。
- 管理端:
- 知识库类型说明;
- 检索日志展示知识库类型。
#### 验收标准
- 固定信息问题优先召回固定信息类;
- 检索日志能看到知识库类型和优先级;
- 知识冲突时能说明采用固定信息类。
---
### 08. 柔性额度提醒
- 优先级P1
- 改动大小M
- 影响范围:聊天前置校验、用户端提示、权益体系
- 依赖:权益版本体系、主题会话机制
#### 目标
避免用户感觉自己在购买 Token 或单次问答。
#### 规则建议
- 不直接强调“还剩几次提问”;
- 展示为“本月主题使用情况”;
- 接近上限时提示:
- 本月深度主题使用较多;
- 建议先完成已有功课;
- 如需持续陪伴可联系运营升级;
- 超出后不要突然硬断所有能力,可以保留基础知识查询或提示联系运营确认。
#### 需要改的代码模块
- 后端:
- 权益校验服务;
- 聊天接口;
- 用户 Profile。
- 用户端:
- 权益提示;
- 聊天前提醒;
- 超额后的友好提示。
#### 验收标准
- 用户不会看到生硬的 Token/次数售卖提示;
- 权益不足时提示清楚;
- 基础版和深度版提示不同。
---
### 09. 成本统计与模型分流
- 优先级P1
- 改动大小L
- 影响范围模型配置、AI 请求日志、看板、Agent 路由
- 依赖:主题会话机制、知识库类型规则
#### 当前现状
系统已有 Token 记录和模型配置,但还没有完整成本核算和模型路由。
#### 目标
满足文档中“成本控制不能只靠限制次数”的要求。
#### 需要新增/修改
模型配置增加:
- 输入 Token 单价;
- 输出 Token 单价;
- 货币;
- 适用场景;
- 是否可用于摘要/报告/固定信息/深度梳理。
AI 日志增加:
- 估算成本;
- 路由原因;
- 问题类型;
- 是否命中知识库。
模型分流建议:
- 固定信息查询:低成本模型;
- 简单课程知识:低成本模型;
- 深度议题梳理:高能力模型;
- 周报/月报:异步模型;
- 管理后台问题洞察:批处理模型。
#### 需要改的代码模块
- 后端:
- ModelConfig
- ModelClientService
- Agent 路由服务;
- AI 日志;
- 数据看板。
- 管理端:
- 模型管理;
- 成本看板;
- 路由规则配置。
#### 验收标准
- 每次 AI 请求能计算成本;
- 看板能看到日/月成本;
- 不同问题类型可以走不同模型;
- 日志能解释为什么选这个模型。
---
### 10. 用户端“我的实修档案”
- 优先级P1
- 改动大小M
- 影响范围:用户端、成长档案接口、主题历史
- 依赖:主题会话机制、成长档案
#### 目标
让用户感受到“五个月成长被记录下来了”。
#### 用户端建议展示
- 最近主题;
- 已完成的功课记录;
- 主题摘要;
- 每周小结;
- 每月报告;
- 老师求助卡历史;
- 班级分享稿历史。
#### 注意
不要做成复杂 CRM也不要让用户有被监控感。表达应偏“我的实修记录”不是“后台画像”。
#### 验收标准
- 用户能查看自己的主题记录;
- 用户能查看成长档案摘要;
- 基础版和深度版展示边界不同。
---
### 11. 后台用户详情升级
- 优先级P1
- 改动大小M
- 影响范围:管理端用户详情、用户接口、统计接口
- 依赖:权益版本体系、主题会话机制、成长档案
#### 当前现状
用户管理主要是名单、额度、状态。
#### 目标
让后台能判断用户使用情况和产品价值。
#### 后台用户详情建议展示
- 当前权益;
- 权益有效期;
- 本月主题会话数;
- 使用频率;
- Token/成本;
- 最近主题;
- 成长档案摘要;
- 求助卡生成记录;
- 分享稿生成记录;
- 是否高频使用。
#### 验收标准
- 管理员可以从用户详情理解该用户是否真的在使用;
- 可以识别高频用户;
- 可以识别几乎未使用用户;
- 可以看到深度版价值是否被使用。
---
### 12. 周报、月报、五个月总结
- 优先级P2
- 改动大小L
- 影响范围:异步任务、模型调用、成长档案、用户端、后台
- 依赖:主题会话机制、成长档案、成本统计
#### 不建议过早做
报告依赖主题摘要和成长档案。如果先做,只能从原始聊天拼凑,成本高且质量不稳定。
#### 目标
体现深度陪伴版的连续价值。
#### 报告类型
- 每周实修小结;
- 每月成长报告;
- 五个月成长总结。
#### 报告内容建议
- 本周期主要议题;
- 做过的功课;
- 重复出现的情绪/模式;
- 有变化的地方;
- 仍需继续观察的方向;
- 可以带给老师确认的问题。
#### 需要改的代码模块
- 后端:
- 定时任务;
- 报告生成服务;
- 报告存储;
- 成本记录。
- 用户端:
- 报告列表;
- 报告详情。
- 管理端:
- 报告查看;
- 重新生成;
- 生成状态。
#### 验收标准
- 报告异步生成,不阻塞聊天;
- 用户端可查看;
- 后台可查看和重新生成;
- 生成失败有错误记录。
---
### 13. 问题洞察二期
- 优先级P2
- 改动大小M
- 影响范围:记录审计、问题统计、知识库补充流程
- 依赖:当前问题洞察一期
#### 当前现状
已有一期:
- 用户消息清洗;
- 多问题拆分;
- 同义词归一;
- 相似问题聚合;
- 后台问题洞察 Tab。
#### 二期目标
从“看高频问题”升级为“辅助运营和知识库改进”。
#### TODO
- 清洗结果持久化,避免每次重新扫原始消息;
- 增加问题分类:
- 课程知识类;
- 功课操作类;
- 情绪梳理类;
- 固定信息类;
- 服务权益类;
- 无知识命中类;
- 高频问题一键转“知识库补充建议”;
- 标记 AI 经常答不好的问题;
- 标记召回失败问题;
- 支持人工合并/拆分问题组。
#### 验收标准
- 能看到高频问题趋势;
- 能看到知识库缺口;
- 能把问题转成知识库补充任务;
- 不影响记录审计页面加载速度。
---
### 14. 老师工作版
- 优先级P2
- 改动大小M
- 影响范围:用户类型、权限、权益、知识库访问
- 依赖:权益版本体系、知识库类型规则
#### 产品边界
老师工作版属于千问千答的内部使用场景,但不是师资管理系统。
不做:
- 老师收入;
- 老师排班;
- 老师认证;
- 见习流程。
只做:
- 老师用 AI 查知识;
- 老师用 AI 生成答疑参考;
- 老师用 AI 检索固定信息和课程内容。
#### TODO
- 增加老师工作版权益;
- 老师账号不消耗普通学员额度;
- 老师可访问内部允许的知识库;
- 老师不能默认查看普通用户隐私;
- 可生成答疑参考,但需要提示“请老师自行判断后使用”。
#### 验收标准
- 老师账号可使用千问千答;
- 老师使用不影响学员额度;
- 权限边界清楚。
## 5. 建议实施批次
### 第一批:底层产品模型
目标:把千问千答从“聊天工具”变成“权益化实修陪伴产品”。
包含:
1. 权益版本体系;
2. 主题会话机制;
3. 主题摘要与成长档案;
4. Agent 调用链接入权益、主题与成长档案。
这一批改动最大,但越早做越少返工。
### 第二批:用户可感知价值
目标:让用户明显感受到深度陪伴版的差异。
包含:
1. 老师求助卡;
2. 班级分享稿;
3. 用户端“我的实修档案”;
4. 柔性额度提醒。
### 第三批:运营和成本验证
目标:支撑 980 / 1280 的商业模型是否成立。
包含:
1. 成本统计;
2. 模型分流;
3. 后台用户详情升级;
4. 问题洞察二期。
### 第四批:连续陪伴增强
目标:体现五个月周期服务价值。
包含:
1. 周报;
2. 月报;
3. 五个月总结;
4. 老师工作版。
## 6. 当前最推荐下一步
建议下一步不要先做报告、分享稿或老师工作版。
最推荐先做:
```text
权益版本体系 → 主题会话机制
```
原因:
- 这是收费模式的底座;
- 会影响用户表、聊天表、额度判断、用户端展示和后台统计;
- 后续成长档案、求助卡、分享稿、报告全部依赖它;
- 如果后做,会导致前面功能大面积返工。
如果要进一步降低第一批风险,可以拆成:
1. 先只建权益版本和用户权益,不立刻改完整扣费;
2. 再建主题会话,只做记录不做复杂自动判断;
3. 最后把主题会话接入权益扣减和成长档案。