feat: add entitlement plans and topic sessions
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 }) }),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
112
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal file
112
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
55
ai_knowledge_base_v2/apps/backend/app/models/entitlement.py
Normal file
55
ai_knowledge_base_v2/apps/backend/app/models/entitlement.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 "新主题"
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
908
ai_knowledge_base_v2/docs/qianwen_product_todo.md
Normal file
908
ai_knowledge_base_v2/docs/qianwen_product_todo.md
Normal 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. 最后把主题会话接入权益扣减和成长档案。
|
||||||
|
|
||||||
Reference in New Issue
Block a user