feat: 完成权益到期与续期闭环
This commit is contained in:
@@ -8,6 +8,7 @@ import AgentManagementView from "./components/AgentManagementView.vue";
|
|||||||
import EntitlementManagementView from "./components/EntitlementManagementView.vue";
|
import EntitlementManagementView from "./components/EntitlementManagementView.vue";
|
||||||
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
|
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
|
||||||
import TableRowActions from "./components/TableRowActions.vue";
|
import TableRowActions from "./components/TableRowActions.vue";
|
||||||
|
import UserEntitlementRenewDialog from "./components/UserEntitlementRenewDialog.vue";
|
||||||
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
|
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
|
||||||
import { api, clearToken, getToken, saveToken } from "./services/api";
|
import { api, clearToken, getToken, saveToken } from "./services/api";
|
||||||
import type {
|
import type {
|
||||||
@@ -49,6 +50,11 @@ const userDetailLoading = ref(false);
|
|||||||
const userReportGenerating = ref("");
|
const userReportGenerating = ref("");
|
||||||
const userSettlementRetrying = ref<number | null>(null);
|
const userSettlementRetrying = ref<number | null>(null);
|
||||||
const userKeyword = ref("");
|
const userKeyword = ref("");
|
||||||
|
const userEntitlementFilters = reactive({ planId: undefined as number | undefined, status: "all" });
|
||||||
|
const selectedUsers = ref<AdminUser[]>([]);
|
||||||
|
const renewDialogOpen = ref(false);
|
||||||
|
const renewTargets = ref<AdminUser[]>([]);
|
||||||
|
const renewSubmitting = ref(false);
|
||||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||||
const models = ref<ModelItem[]>([]);
|
const models = ref<ModelItem[]>([]);
|
||||||
const configs = ref<SystemConfigItem[]>([]);
|
const configs = ref<SystemConfigItem[]>([]);
|
||||||
@@ -326,11 +332,91 @@ async function loadCurrentMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
|
async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
|
||||||
const result = await api.users({ keyword: userKeyword.value, page, pageSize });
|
const result = await api.users({
|
||||||
|
keyword: userKeyword.value,
|
||||||
|
planId: userEntitlementFilters.planId,
|
||||||
|
entitlementStatus: userEntitlementFilters.status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
users.value = result.items;
|
users.value = result.items;
|
||||||
|
selectedUsers.value = [];
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetUserFilters() {
|
||||||
|
userKeyword.value = "";
|
||||||
|
userEntitlementFilters.planId = undefined;
|
||||||
|
userEntitlementFilters.status = "all";
|
||||||
|
void loadUsers(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserSelectionChange(rows: AdminUser[]) {
|
||||||
|
selectedUsers.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenewDialog(targets: AdminUser[]) {
|
||||||
|
if (!targets.length) return ElMessage.warning("请先选择需要续期的用户");
|
||||||
|
renewTargets.value = targets;
|
||||||
|
renewDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEntitlementRenew(payload: { extensionDays: number; remark: string }) {
|
||||||
|
if (!renewTargets.value.length || renewSubmitting.value) return;
|
||||||
|
renewSubmitting.value = true;
|
||||||
|
const idempotencyKey = typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `renew-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
try {
|
||||||
|
if (renewTargets.value.length === 1) {
|
||||||
|
await api.renewUserEntitlement(renewTargets.value[0].id, { ...payload, idempotencyKey });
|
||||||
|
ElMessage.success("权益续期成功");
|
||||||
|
} else {
|
||||||
|
const result = await api.batchRenewUserEntitlement({
|
||||||
|
userIds: renewTargets.value.map((user) => user.id),
|
||||||
|
...payload,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
if (result.failureCount) {
|
||||||
|
const firstFailure = result.failed[0];
|
||||||
|
ElMessage.warning(`成功 ${result.successCount} 人,失败 ${result.failureCount} 人${firstFailure ? `;${firstFailure.name || firstFailure.userId}:${firstFailure.reason}` : ""}`);
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已为 ${result.successCount} 位用户续期`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renewDialogOpen.value = false;
|
||||||
|
await loadUsers(pagers.users.page, pagers.users.pageSize);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : "权益续期失败");
|
||||||
|
} finally {
|
||||||
|
renewSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitlementStatusLabel(row: AdminUser) {
|
||||||
|
const status = row.entitlement?.lifecycleStatus;
|
||||||
|
if (status === "expired_fallback") return "已到期,已降级";
|
||||||
|
if (status === "expiring_7") return "7 天内到期";
|
||||||
|
if (status === "expiring_30") return "30 天内到期";
|
||||||
|
if (status === "long_term") return "长期有效";
|
||||||
|
if (status === "default") return "默认权益";
|
||||||
|
return "生效中";
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitlementStatusType(row: AdminUser) {
|
||||||
|
const status = row.entitlement?.lifecycleStatus;
|
||||||
|
if (status === "expired_fallback") return "danger";
|
||||||
|
if (status === "expiring_7" || status === "expiring_30") return "warning";
|
||||||
|
if (status === "active" || status === "long_term") return "success";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntitlementDate(value?: string | null) {
|
||||||
|
if (!value) return "长期";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEntitlementPlans() {
|
async function loadEntitlementPlans() {
|
||||||
entitlementPlans.value = await api.entitlementPlans(true);
|
entitlementPlans.value = await api.entitlementPlans(true);
|
||||||
}
|
}
|
||||||
@@ -1098,7 +1184,6 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<template v-if="activeMenu === 'users'">
|
<template v-if="activeMenu === 'users'">
|
||||||
<div class="page-head inline">
|
<div class="page-head inline">
|
||||||
<div><h2>用户管理</h2><p>维护学员名单;只有名单内启用学员可以登录用户端。</p></div>
|
<div><h2>用户管理</h2><p>维护学员名单;只有名单内启用学员可以登录用户端。</p></div>
|
||||||
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadUsers(1)" />
|
|
||||||
</div>
|
</div>
|
||||||
<section class="student-tools">
|
<section class="student-tools">
|
||||||
<div class="student-tool-panel">
|
<div class="student-tool-panel">
|
||||||
@@ -1154,7 +1239,27 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<el-table :data="users" stripe>
|
<section class="user-list-toolbar">
|
||||||
|
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @keyup.enter="loadUsers(1)" />
|
||||||
|
<el-select v-model="userEntitlementFilters.planId" placeholder="全部权益版本" clearable>
|
||||||
|
<el-option v-for="plan in entitlementPlans.filter((item) => item.status === 1)" :key="plan.id" :label="plan.name" :value="plan.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="userEntitlementFilters.status" aria-label="权益状态">
|
||||||
|
<el-option label="全部权益状态" value="all" />
|
||||||
|
<el-option label="生效中" value="active" />
|
||||||
|
<el-option label="7 天内到期" value="expiring7" />
|
||||||
|
<el-option label="8-30 天内到期" value="expiring30" />
|
||||||
|
<el-option label="已到期" value="expired" />
|
||||||
|
<el-option label="使用默认权益" value="default" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadUsers(1)">筛选</el-button>
|
||||||
|
<el-button @click="resetUserFilters">重置</el-button>
|
||||||
|
<el-button class="batch-renew-button" :disabled="!selectedUsers.length" @click="openRenewDialog(selectedUsers)">
|
||||||
|
批量续期{{ selectedUsers.length ? `(${selectedUsers.length})` : '' }}
|
||||||
|
</el-button>
|
||||||
|
</section>
|
||||||
|
<el-table :data="users" stripe @selection-change="handleUserSelectionChange">
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="phone" label="手机号" width="150" />
|
<el-table-column prop="phone" label="手机号" width="150" />
|
||||||
<el-table-column prop="name" label="姓名" width="140" />
|
<el-table-column prop="name" label="姓名" width="140" />
|
||||||
@@ -1166,7 +1271,7 @@ 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">
|
<el-table-column label="权益版本" min-width="300">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="user-entitlement-cell">
|
<div class="user-entitlement-cell">
|
||||||
<el-select
|
<el-select
|
||||||
@@ -1184,9 +1289,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</el-select>
|
</el-select>
|
||||||
<small>
|
<small>
|
||||||
{{ row.entitlement?.name || '默认基础版' }}
|
{{ row.entitlement?.name || '默认基础版' }}
|
||||||
· 本月主题
|
· {{ row.entitlement?.lifecycleStatus === 'expired_fallback' ? `原 ${row.entitlement?.previousPlanName || '权益'} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期` : `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}` }}
|
||||||
{{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}
|
|
||||||
</small>
|
</small>
|
||||||
|
<div class="entitlement-meta-row">
|
||||||
|
<el-tag size="small" :type="entitlementStatusType(row)">{{ entitlementStatusLabel(row) }}</el-tag>
|
||||||
|
<span>本月主题 {{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -1200,6 +1308,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<el-button size="small">更多</el-button>
|
<el-button size="small">更多</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="openRenewDialog([row])">权益续期</el-dropdown-item>
|
||||||
<el-dropdown-item @click="deleteUser(row)">删除用户</el-dropdown-item>
|
<el-dropdown-item @click="deleteUser(row)">删除用户</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -1209,6 +1318,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<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" />
|
||||||
|
<UserEntitlementRenewDialog
|
||||||
|
v-model="renewDialogOpen"
|
||||||
|
:users="renewTargets"
|
||||||
|
:submitting="renewSubmitting"
|
||||||
|
@submit="submitEntitlementRenew"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="activeMenu === 'entitlements'">
|
<template v-if="activeMenu === 'entitlements'">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
|
||||||
|
import type { AdminUser } from "../types/api";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
users: AdminUser[];
|
||||||
|
submitting: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean];
|
||||||
|
submit: [payload: { extensionDays: number; remark: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = reactive({ extensionDays: 30, remark: "" });
|
||||||
|
const title = computed(() => props.users.length > 1 ? `批量续期(${props.users.length} 人)` : "用户权益续期");
|
||||||
|
const userNames = computed(() => props.users.map((user) => user.name || user.phone).join("、"));
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) Object.assign(form, { extensionDays: 30, remark: "" });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="title"
|
||||||
|
width="min(520px, calc(100vw - 32px))"
|
||||||
|
destroy-on-close
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="renew-summary">
|
||||||
|
<span>续期对象</span>
|
||||||
|
<strong>{{ userNames }}</strong>
|
||||||
|
<small>未到期权益从原到期日顺延;已到期权益从当前时间重新生效。长期有效或未分配专属权益的用户不会被误续期。</small>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="延长天数">
|
||||||
|
<el-input-number v-model="form.extensionDays" :min="1" :max="3650" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="续期备注(可选)">
|
||||||
|
<el-input v-model="form.remark" maxlength="255" show-word-limit placeholder="例如:线下续费确认、活动补偿" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="$emit('update:modelValue', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="emit('submit', form)">确认续期</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.renew-summary { display: grid; gap: 6px; margin-bottom: 18px; padding: 14px; border-radius: 10px; background: #f4f8f6; }
|
||||||
|
.renew-summary span, .renew-summary small { color: #6d7e78; font-size: 12px; }
|
||||||
|
.renew-summary strong { color: #25483e; line-height: 1.55; }
|
||||||
|
.renew-summary small { line-height: 1.6; }
|
||||||
|
.el-input-number { width: 100%; }
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ChatRecordQuery,
|
ChatRecordQuery,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
EntitlementPlan,
|
EntitlementPlan,
|
||||||
|
EntitlementBatchRenewResult,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeContentSearchItem,
|
KnowledgeContentSearchItem,
|
||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
@@ -129,7 +130,7 @@ export const api = {
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||||
},
|
},
|
||||||
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||||
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
||||||
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<PageResult<TopicSessionRecord>>(`/admin/user/${id}/topics${queryString(query)}`),
|
request<PageResult<TopicSessionRecord>>(`/admin/user/${id}/topics${queryString(query)}`),
|
||||||
@@ -159,6 +160,10 @@ export const api = {
|
|||||||
request<EntitlementPlan>(`/admin/entitlement/plan/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
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 }) =>
|
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) }),
|
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
renewUserEntitlement: (userId: number, payload: { extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
|
||||||
|
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement/renew`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
batchRenewUserEntitlement: (payload: { userIds: number[]; extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
|
||||||
|
request<EntitlementBatchRenewResult>("/admin/user/entitlement/batch-renew", { 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 }) }),
|
||||||
|
|||||||
@@ -658,6 +658,42 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-list-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) 220px 190px auto auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #dfe8e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-toolbar .el-input,
|
||||||
|
.user-list-toolbar .el-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-renew-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entitlement-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entitlement-meta-row span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #71817b;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.migration-overview {
|
.migration-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
@@ -2298,11 +2334,17 @@ textarea {
|
|||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.student-tools,
|
.student-tools,
|
||||||
|
.user-list-toolbar,
|
||||||
.agent-workbench,
|
.agent-workbench,
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-renew-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.audit-detail-grid {
|
.audit-detail-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ export interface UserEntitlementSummary {
|
|||||||
effectiveAt?: string | null;
|
effectiveAt?: string | null;
|
||||||
expiredAt?: string | null;
|
expiredAt?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
lifecycleStatus: "active" | "long_term" | "expiring_7" | "expiring_30" | "expired_fallback" | "default" | "legacy" | string;
|
||||||
|
daysUntilExpiry?: number | null;
|
||||||
|
previousPlanName?: string | null;
|
||||||
|
previousExpiredAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntitlementBatchRenewResult {
|
||||||
|
succeeded: Array<{ userId: number; name: string }>;
|
||||||
|
failed: Array<{ userId: number; name?: string; reason: string }>;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserImportFailure {
|
export interface UserImportFailure {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add entitlement renewal idempotency and expiry index
|
||||||
|
|
||||||
|
Revision ID: 0026_entitlement_renewal
|
||||||
|
Revises: 0025_recent_practice_review
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import context, op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0026_entitlement_renewal"
|
||||||
|
down_revision = "0025_recent_practice_review"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||||
|
log_columns = set() if inspector is None else {
|
||||||
|
column["name"] for column in inspector.get_columns("sys_user_entitlement_log")
|
||||||
|
}
|
||||||
|
if "request_key" not in log_columns:
|
||||||
|
op.add_column("sys_user_entitlement_log", sa.Column("request_key", sa.String(160), nullable=True))
|
||||||
|
|
||||||
|
log_indexes = set() if inspector is None else {
|
||||||
|
index["name"] for index in inspector.get_indexes("sys_user_entitlement_log")
|
||||||
|
}
|
||||||
|
if "ux_entitlement_log_request_key" not in log_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ux_entitlement_log_request_key",
|
||||||
|
"sys_user_entitlement_log",
|
||||||
|
["request_key"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
entitlement_indexes = set() if inspector is None else {
|
||||||
|
index["name"] for index in inspector.get_indexes("sys_user_entitlement")
|
||||||
|
}
|
||||||
|
if "ix_entitlement_status_expiry" not in entitlement_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_entitlement_status_expiry",
|
||||||
|
"sys_user_entitlement",
|
||||||
|
["status", "expired_at", "user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_entitlement_status_expiry", table_name="sys_user_entitlement")
|
||||||
|
op.drop_index("ux_entitlement_log_request_key", table_name="sys_user_entitlement_log")
|
||||||
|
op.drop_column("sys_user_entitlement_log", "request_key")
|
||||||
@@ -9,7 +9,12 @@ from app.core.responses import api_success
|
|||||||
from app.models.admin import Admin
|
from app.models.admin import Admin
|
||||||
from app.models.entitlement import EntitlementPlan
|
from app.models.entitlement import EntitlementPlan
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.admin import EntitlementPlanSaveRequest, UserEntitlementAssignRequest
|
from app.schemas.admin import (
|
||||||
|
EntitlementPlanSaveRequest,
|
||||||
|
UserEntitlementAssignRequest,
|
||||||
|
UserEntitlementBatchRenewRequest,
|
||||||
|
UserEntitlementRenewRequest,
|
||||||
|
)
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
|
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
|
||||||
from app.services.topic_session_service import TopicSessionService
|
from app.services.topic_session_service import TopicSessionService
|
||||||
@@ -97,6 +102,79 @@ def assign_user_entitlement(
|
|||||||
return api_success(entitlement_dict(view))
|
return api_success(entitlement_dict(view))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/{user_id}/entitlement/renew")
|
||||||
|
def renew_user_entitlement(
|
||||||
|
user_id: int,
|
||||||
|
payload: UserEntitlementRenewRequest,
|
||||||
|
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="用户不存在")
|
||||||
|
EntitlementService.renew_user_plan(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
extension_days=payload.extensionDays,
|
||||||
|
request_key=f"{payload.idempotencyKey}:{user.id}",
|
||||||
|
operated_by=current_admin.id,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="entitlement",
|
||||||
|
action="renew_user_plan",
|
||||||
|
target_id=user.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
view = EntitlementService.active_entitlement(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||||
|
)
|
||||||
|
return api_success(entitlement_dict(view))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/entitlement/batch-renew")
|
||||||
|
def batch_renew_user_entitlement(
|
||||||
|
payload: UserEntitlementBatchRenewRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
succeeded: list[dict] = []
|
||||||
|
failed: list[dict] = []
|
||||||
|
for user_id in dict.fromkeys(payload.userIds):
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None or user.is_deleted:
|
||||||
|
failed.append({"userId": user_id, "reason": "用户不存在"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
EntitlementService.renew_user_plan(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
extension_days=payload.extensionDays,
|
||||||
|
request_key=f"{payload.idempotencyKey}:{user.id}",
|
||||||
|
operated_by=current_admin.id,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
succeeded.append({"userId": user.id, "name": user.name})
|
||||||
|
except HTTPException as exc:
|
||||||
|
failed.append({"userId": user.id, "name": user.name, "reason": str(exc.detail)})
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="entitlement",
|
||||||
|
action="batch_renew_user_plan",
|
||||||
|
target_id=None,
|
||||||
|
result="SUCCESS" if not failed else "PARTIAL",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return api_success(
|
||||||
|
{"succeeded": succeeded, "failed": failed, "successCount": len(succeeded), "failureCount": len(failed)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None:
|
def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None:
|
||||||
plan.name = payload.name.strip()
|
plan.name = payload.name.strip()
|
||||||
plan.plan_type = payload.planType
|
plan.plan_type = payload.planType
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
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
|
||||||
@@ -45,6 +47,8 @@ class AdminGenerateReportRequest(BaseModel):
|
|||||||
@router.get("/user/list")
|
@router.get("/user/list")
|
||||||
def list_users(
|
def list_users(
|
||||||
keyword: str = Query(default=""),
|
keyword: str = Query(default=""),
|
||||||
|
planId: Annotated[int | None, Query(gt=0)] = None,
|
||||||
|
entitlementStatus: Annotated[str, Query(pattern="^(all|active|expiring7|expiring30|expired|default)$")] = "all",
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
pageSize: int = Query(default=20, ge=10, le=100),
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -54,6 +58,51 @@ def list_users(
|
|||||||
if keyword:
|
if keyword:
|
||||||
like = f"%{keyword}%"
|
like = f"%{keyword}%"
|
||||||
query = query.where((User.phone.like(like)) | (User.name.like(like)))
|
query = query.where((User.phone.like(like)) | (User.name.like(like)))
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
active_user_ids = select(UserEntitlement.user_id).join(
|
||||||
|
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
|
||||||
|
).where(
|
||||||
|
UserEntitlement.status == "active",
|
||||||
|
EntitlementPlan.status == 1,
|
||||||
|
EntitlementPlan.plan_type != "teacher",
|
||||||
|
(UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now),
|
||||||
|
(UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now),
|
||||||
|
)
|
||||||
|
if planId is not None:
|
||||||
|
query = query.where(User.id.in_(active_user_ids.where(UserEntitlement.plan_id == planId)))
|
||||||
|
if entitlementStatus == "active":
|
||||||
|
query = query.where(User.id.in_(active_user_ids))
|
||||||
|
elif entitlementStatus == "expiring7":
|
||||||
|
query = query.where(
|
||||||
|
User.id.in_(
|
||||||
|
active_user_ids.where(
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at <= now + timedelta(days=7),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif entitlementStatus == "expiring30":
|
||||||
|
query = query.where(
|
||||||
|
User.id.in_(
|
||||||
|
active_user_ids.where(
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at > now + timedelta(days=7),
|
||||||
|
UserEntitlement.expired_at <= now + timedelta(days=30),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif entitlementStatus == "default":
|
||||||
|
query = query.where(User.id.not_in(active_user_ids))
|
||||||
|
elif entitlementStatus == "expired":
|
||||||
|
expired_user_ids = select(UserEntitlement.user_id).join(
|
||||||
|
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
|
||||||
|
).where(
|
||||||
|
UserEntitlement.status.in_(("active", "expired")),
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at < now,
|
||||||
|
EntitlementPlan.plan_type != "teacher",
|
||||||
|
)
|
||||||
|
query = query.where(User.id.not_in(active_user_ids), User.id.in_(expired_user_ids))
|
||||||
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()
|
||||||
entitlements = _entitlement_views(db, users)
|
entitlements = _entitlement_views(db, users)
|
||||||
@@ -457,13 +506,32 @@ def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
|
|||||||
return {}
|
return {}
|
||||||
counts = _monthly_topic_counts(db, user_ids)
|
counts = _monthly_topic_counts(db, user_ids)
|
||||||
explicit = _active_entitlement_rows(db, user_ids)
|
explicit = _active_entitlement_rows(db, user_ids)
|
||||||
|
previous_expired = _latest_expired_entitlement_rows(db, user_ids)
|
||||||
|
default_plan = EntitlementService.default_plan(db)
|
||||||
result: dict[int, dict] = {}
|
result: dict[int, dict] = {}
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.id in explicit:
|
if user.id in explicit:
|
||||||
entitlement, plan = explicit[user.id]
|
entitlement, plan = explicit[user.id]
|
||||||
view = view_from_plan(plan, monthly_topic_used=counts.get(user.id, 0), entitlement=entitlement, source="assigned")
|
view = view_from_plan(plan, monthly_topic_used=counts.get(user.id, 0), entitlement=entitlement, source="assigned")
|
||||||
else:
|
else:
|
||||||
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0))
|
if default_plan is None:
|
||||||
|
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0))
|
||||||
|
else:
|
||||||
|
view = view_from_plan(
|
||||||
|
default_plan,
|
||||||
|
monthly_topic_used=counts.get(user.id, 0),
|
||||||
|
entitlement=None,
|
||||||
|
source="default",
|
||||||
|
)
|
||||||
|
if user.id in previous_expired:
|
||||||
|
expired_entitlement, expired_plan = previous_expired[user.id]
|
||||||
|
view = replace(
|
||||||
|
view,
|
||||||
|
source="default_after_expiry",
|
||||||
|
lifecycle_status="expired_fallback",
|
||||||
|
previous_plan_name=expired_plan.name,
|
||||||
|
previous_expired_at=expired_entitlement.expired_at,
|
||||||
|
)
|
||||||
result[user.id] = entitlement_dict(view)
|
result[user.id] = entitlement_dict(view)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -488,6 +556,26 @@ def _active_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tupl
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_expired_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.in_(("active", "expired")),
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at < now,
|
||||||
|
EntitlementPlan.plan_type != "teacher",
|
||||||
|
)
|
||||||
|
.order_by(UserEntitlement.expired_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 _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -> dict:
|
def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -> dict:
|
||||||
now = datetime.now(UTC).replace(tzinfo=None)
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
@@ -30,6 +30,7 @@ class EntitlementPlan(Base, TimestampMixin):
|
|||||||
|
|
||||||
class UserEntitlement(Base, TimestampMixin):
|
class UserEntitlement(Base, TimestampMixin):
|
||||||
__tablename__ = "sys_user_entitlement"
|
__tablename__ = "sys_user_entitlement"
|
||||||
|
__table_args__ = (Index("ix_entitlement_status_expiry", "status", "expired_at", "user_id"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
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)
|
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||||
@@ -50,6 +51,7 @@ class UserEntitlementLog(Base):
|
|||||||
from_plan_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)
|
to_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
request_key: Mapped[str | None] = mapped_column(String(160), unique=True, nullable=True)
|
||||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
operated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
operated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ class UserEntitlementAssignRequest(BaseModel):
|
|||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class UserEntitlementRenewRequest(BaseModel):
|
||||||
|
extensionDays: int = Field(ge=1, le=3650)
|
||||||
|
idempotencyKey: str = Field(min_length=8, max_length=100)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class UserEntitlementBatchRenewRequest(UserEntitlementRenewRequest):
|
||||||
|
userIds: list[int] = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
import math
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -32,6 +33,10 @@ class EntitlementView:
|
|||||||
effective_at: datetime | None = None
|
effective_at: datetime | None = None
|
||||||
expired_at: datetime | None = None
|
expired_at: datetime | None = None
|
||||||
source: str = "legacy"
|
source: str = "legacy"
|
||||||
|
lifecycle_status: str = "legacy"
|
||||||
|
days_until_expiry: int | None = None
|
||||||
|
previous_plan_name: str | None = None
|
||||||
|
previous_expired_at: datetime | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def monthly_topic_remaining(self) -> int | None:
|
def monthly_topic_remaining(self) -> int | None:
|
||||||
@@ -92,7 +97,30 @@ class EntitlementService:
|
|||||||
|
|
||||||
plan = EntitlementService.default_plan(db)
|
plan = EntitlementService.default_plan(db)
|
||||||
if plan is not None:
|
if plan is not None:
|
||||||
return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default")
|
previous = db.execute(
|
||||||
|
select(UserEntitlement, EntitlementPlan)
|
||||||
|
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
|
||||||
|
.where(
|
||||||
|
UserEntitlement.user_id == user.id,
|
||||||
|
UserEntitlement.status.in_(("active", "expired")),
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at < now,
|
||||||
|
EntitlementPlan.plan_type != "teacher",
|
||||||
|
)
|
||||||
|
.order_by(UserEntitlement.expired_at.desc(), UserEntitlement.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
view = view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default")
|
||||||
|
if previous:
|
||||||
|
expired_entitlement, expired_plan = previous
|
||||||
|
return replace(
|
||||||
|
view,
|
||||||
|
source="default_after_expiry",
|
||||||
|
lifecycle_status="expired_fallback",
|
||||||
|
previous_plan_name=expired_plan.name,
|
||||||
|
previous_expired_at=expired_entitlement.expired_at,
|
||||||
|
)
|
||||||
|
return view
|
||||||
|
|
||||||
return EntitlementView(
|
return EntitlementView(
|
||||||
plan_id=None,
|
plan_id=None,
|
||||||
@@ -108,8 +136,127 @@ class EntitlementService:
|
|||||||
allow_share_draft=True,
|
allow_share_draft=True,
|
||||||
deduct_quota=True,
|
deduct_quota=True,
|
||||||
source="legacy",
|
source="legacy",
|
||||||
|
lifecycle_status="legacy",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expire_due_entitlements(db: Session, *, batch_size: int = 500, now: datetime | None = None) -> int:
|
||||||
|
current_time = now or _now()
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
due = list(
|
||||||
|
db.scalars(
|
||||||
|
select(UserEntitlement)
|
||||||
|
.where(
|
||||||
|
UserEntitlement.status == "active",
|
||||||
|
UserEntitlement.expired_at.is_not(None),
|
||||||
|
UserEntitlement.expired_at < current_time,
|
||||||
|
)
|
||||||
|
.order_by(UserEntitlement.id.asc())
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not due:
|
||||||
|
return total
|
||||||
|
for entitlement in due:
|
||||||
|
entitlement.status = "expired"
|
||||||
|
db.add(entitlement)
|
||||||
|
db.add(
|
||||||
|
UserEntitlementLog(
|
||||||
|
user_id=entitlement.user_id,
|
||||||
|
entitlement_id=entitlement.id,
|
||||||
|
from_plan_id=entitlement.plan_id,
|
||||||
|
to_plan_id=None,
|
||||||
|
action="expire",
|
||||||
|
detail_json=json.dumps(
|
||||||
|
{"expiredAt": entitlement.expired_at.isoformat() if entitlement.expired_at else None},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
operated_by=None,
|
||||||
|
created_at=current_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
total += len(due)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def renew_user_plan(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
extension_days: int,
|
||||||
|
request_key: str,
|
||||||
|
operated_by: int | None,
|
||||||
|
remark: str | None = None,
|
||||||
|
) -> UserEntitlement:
|
||||||
|
existing_log = db.scalar(select(UserEntitlementLog).where(UserEntitlementLog.request_key == request_key))
|
||||||
|
if existing_log is not None and existing_log.entitlement_id is not None:
|
||||||
|
existing = db.get(UserEntitlement, existing_log.entitlement_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
db.scalar(select(User.id).where(User.id == user.id).with_for_update())
|
||||||
|
existing_log = db.scalar(select(UserEntitlementLog).where(UserEntitlementLog.request_key == request_key))
|
||||||
|
if existing_log is not None and existing_log.entitlement_id is not None:
|
||||||
|
existing = db.get(UserEntitlement, existing_log.entitlement_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
current = db.scalar(
|
||||||
|
select(UserEntitlement)
|
||||||
|
.where(UserEntitlement.user_id == user.id, UserEntitlement.status.in_(("active", "expired")))
|
||||||
|
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该用户尚未分配专属权益,请先选择权益版本")
|
||||||
|
plan = db.get(EntitlementPlan, current.plan_id)
|
||||||
|
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="原权益版本已停用,无法续期,请重新分配权益")
|
||||||
|
if current.status == "active" and current.expired_at is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前权益为长期有效,无需续期")
|
||||||
|
|
||||||
|
previous_expired_at = current.expired_at
|
||||||
|
base_time = max(now, previous_expired_at) if previous_expired_at is not None else now
|
||||||
|
renewed_expired_at = base_time + timedelta(days=extension_days)
|
||||||
|
current.status = "replaced"
|
||||||
|
db.add(current)
|
||||||
|
renewed = UserEntitlement(
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=plan.id,
|
||||||
|
status="active",
|
||||||
|
effective_at=now,
|
||||||
|
expired_at=renewed_expired_at,
|
||||||
|
assigned_by=operated_by,
|
||||||
|
remark=remark,
|
||||||
|
)
|
||||||
|
db.add(renewed)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
UserEntitlementLog(
|
||||||
|
user_id=user.id,
|
||||||
|
entitlement_id=renewed.id,
|
||||||
|
from_plan_id=plan.id,
|
||||||
|
to_plan_id=plan.id,
|
||||||
|
action="renew",
|
||||||
|
request_key=request_key,
|
||||||
|
detail_json=json.dumps(
|
||||||
|
{
|
||||||
|
"extensionDays": extension_days,
|
||||||
|
"previousExpiredAt": previous_expired_at.isoformat() if previous_expired_at else None,
|
||||||
|
"renewedExpiredAt": renewed_expired_at.isoformat(),
|
||||||
|
"sourceEntitlementId": current.id,
|
||||||
|
"remark": remark,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
operated_by=operated_by,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return renewed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def assign_user_plan(
|
def assign_user_plan(
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -217,6 +364,10 @@ def entitlement_dict(view: EntitlementView) -> dict:
|
|||||||
"effectiveAt": view.effective_at,
|
"effectiveAt": view.effective_at,
|
||||||
"expiredAt": view.expired_at,
|
"expiredAt": view.expired_at,
|
||||||
"source": view.source,
|
"source": view.source,
|
||||||
|
"lifecycleStatus": view.lifecycle_status,
|
||||||
|
"daysUntilExpiry": view.days_until_expiry,
|
||||||
|
"previousPlanName": view.previous_plan_name,
|
||||||
|
"previousExpiredAt": view.previous_expired_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +391,17 @@ def view_from_plan(
|
|||||||
entitlement: UserEntitlement | None,
|
entitlement: UserEntitlement | None,
|
||||||
source: str,
|
source: str,
|
||||||
) -> EntitlementView:
|
) -> EntitlementView:
|
||||||
|
days_until_expiry = _days_until_expiry(entitlement.expired_at) if entitlement else None
|
||||||
|
lifecycle_status = "default"
|
||||||
|
if entitlement is not None:
|
||||||
|
if entitlement.expired_at is None:
|
||||||
|
lifecycle_status = "long_term"
|
||||||
|
elif days_until_expiry is not None and days_until_expiry <= 7:
|
||||||
|
lifecycle_status = "expiring_7"
|
||||||
|
elif days_until_expiry is not None and days_until_expiry <= 30:
|
||||||
|
lifecycle_status = "expiring_30"
|
||||||
|
else:
|
||||||
|
lifecycle_status = "active"
|
||||||
return EntitlementView(
|
return EntitlementView(
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
name=plan.name,
|
name=plan.name,
|
||||||
@@ -256,8 +418,16 @@ def view_from_plan(
|
|||||||
effective_at=entitlement.effective_at if entitlement else None,
|
effective_at=entitlement.effective_at if entitlement else None,
|
||||||
expired_at=entitlement.expired_at if entitlement else None,
|
expired_at=entitlement.expired_at if entitlement else None,
|
||||||
source=source,
|
source=source,
|
||||||
|
lifecycle_status=lifecycle_status,
|
||||||
|
days_until_expiry=days_until_expiry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _days_until_expiry(expired_at: datetime | None) -> int | None:
|
||||||
|
if expired_at is None:
|
||||||
|
return None
|
||||||
|
return max(0, math.ceil((expired_at - _now()).total_seconds() / 86400))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.core.database import SessionLocal
|
|||||||
from app.models.knowledge import KnowledgeRetrievalCandidate, KnowledgeRetrievalLog
|
from app.models.knowledge import KnowledgeRetrievalCandidate, KnowledgeRetrievalLog
|
||||||
from app.models.logs import LogRetentionPolicy
|
from app.models.logs import LogRetentionPolicy
|
||||||
from app.services.redis_client import get_sync_redis_client
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
from app.services.entitlement_service import EntitlementService
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceService:
|
class MaintenanceService:
|
||||||
@@ -31,6 +32,8 @@ class MaintenanceService:
|
|||||||
if not lock_acquired:
|
if not lock_acquired:
|
||||||
return
|
return
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
|
EntitlementService.expire_due_entitlements(db)
|
||||||
|
db.commit()
|
||||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||||
if not policy or not policy.enabled or not policy.retention_days:
|
if not policy or not policy.enabled or not policy.retention_days:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||||
from app.models.entitlement import EntitlementPlan
|
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.chat_service import ChatService
|
from app.services.chat_service import ChatService
|
||||||
from app.services.entitlement_service import EntitlementService, entitlement_dict
|
from app.services.entitlement_service import EntitlementService, entitlement_dict
|
||||||
@@ -113,6 +113,112 @@ def test_assign_user_plan_replaces_previous_active_plan():
|
|||||||
assert view.monthly_topic_remaining == 86
|
assert view.monthly_topic_remaining == 86
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_entitlement_falls_back_and_keeps_previous_plan_context():
|
||||||
|
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),
|
||||||
|
UserEntitlement(
|
||||||
|
id=100,
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=20,
|
||||||
|
status="active",
|
||||||
|
effective_at=_now() - timedelta(days=40),
|
||||||
|
expired_at=_now() - timedelta(days=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
view = EntitlementService.active_entitlement(db, user)
|
||||||
|
expired_count = EntitlementService.expire_due_entitlements(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert view.plan_id == 10
|
||||||
|
assert view.lifecycle_status == "expired_fallback"
|
||||||
|
assert view.previous_plan_name == "深度陪伴版"
|
||||||
|
assert view.previous_expired_at is not None
|
||||||
|
assert expired_count == 1
|
||||||
|
assert db.get(UserEntitlement, 100).status == "expired"
|
||||||
|
assert db.scalar(select(UserEntitlementLog).where(UserEntitlementLog.action == "expire")) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_renew_user_plan_extends_from_current_expiry_and_is_idempotent():
|
||||||
|
with _db() as db:
|
||||||
|
user, _session = _seed_user_session(db)
|
||||||
|
current_expiry = _now() + timedelta(days=5)
|
||||||
|
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1))
|
||||||
|
db.add(
|
||||||
|
UserEntitlement(
|
||||||
|
id=100,
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=20,
|
||||||
|
status="active",
|
||||||
|
effective_at=_now() - timedelta(days=20),
|
||||||
|
expired_at=current_expiry,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
renewed = EntitlementService.renew_user_plan(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
extension_days=30,
|
||||||
|
request_key="renew-test-key:1",
|
||||||
|
operated_by=99,
|
||||||
|
remark="测试续期",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
duplicate = EntitlementService.renew_user_plan(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
extension_days=30,
|
||||||
|
request_key="renew-test-key:1",
|
||||||
|
operated_by=99,
|
||||||
|
remark="重复请求",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert renewed.id == duplicate.id
|
||||||
|
assert renewed.expired_at == current_expiry + timedelta(days=30)
|
||||||
|
assert db.get(UserEntitlement, 100).status == "replaced"
|
||||||
|
assert db.scalar(select(func.count(UserEntitlementLog.id)).where(UserEntitlementLog.action == "renew")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_renew_expired_user_plan_restores_same_plan_from_now():
|
||||||
|
with _db() as db:
|
||||||
|
user, _session = _seed_user_session(db)
|
||||||
|
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1))
|
||||||
|
db.add(
|
||||||
|
UserEntitlement(
|
||||||
|
id=100,
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=20,
|
||||||
|
status="expired",
|
||||||
|
effective_at=_now() - timedelta(days=40),
|
||||||
|
expired_at=_now() - timedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
renewed = EntitlementService.renew_user_plan(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
extension_days=30,
|
||||||
|
request_key="restore-test-key:1",
|
||||||
|
operated_by=99,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
view = EntitlementService.active_entitlement(db, user)
|
||||||
|
|
||||||
|
assert renewed.expired_at is not None
|
||||||
|
assert timedelta(days=29) < renewed.expired_at - _now() <= timedelta(days=30)
|
||||||
|
assert view.plan_id == 20
|
||||||
|
assert view.source == "assigned"
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic():
|
def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic():
|
||||||
with _db() as db:
|
with _db() as db:
|
||||||
user, session = _seed_user_session(db)
|
user, session = _seed_user_session(db)
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ function settlementStatusLabel(status: string) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div v-if="activeSection === 'overview'" class="center-section">
|
<div v-if="activeSection === 'overview'" class="center-section">
|
||||||
|
<aside v-if="entitlement?.lifecycleStatus === 'expired_fallback'" class="entitlement-notice expired">
|
||||||
|
<strong>权益已平稳切换</strong>
|
||||||
|
<span>原“{{ entitlement.previousPlanName || '专属权益' }}”已于 {{ formatDate(entitlement.previousExpiredAt) }} 到期,当前已自动切换为“{{ entitlement.name }}”。已有聊天、实修回顾和卡片记录仍会保留。</span>
|
||||||
|
</aside>
|
||||||
|
<aside v-else-if="entitlement?.lifecycleStatus === 'expiring_7' || entitlement?.lifecycleStatus === 'expiring_30'" class="entitlement-notice">
|
||||||
|
<strong>权益即将到期</strong>
|
||||||
|
<span>当前权益有效至 {{ formatDate(entitlement.expiredAt) }}。如需继续使用,可提前联系运营老师确认续期。</span>
|
||||||
|
</aside>
|
||||||
<article class="plan-card">
|
<article class="plan-card">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<PackageCheck :size="18" aria-hidden="true" />
|
<PackageCheck :size="18" aria-hidden="true" />
|
||||||
@@ -224,6 +232,10 @@ function settlementStatusLabel(status: string) {
|
|||||||
.center-tabs button { min-height: 36px; padding: 0 8px; border: 0; border-radius: 10px; background: transparent; color: var(--chat-muted); font-size: 13px; font-weight: 650; white-space: nowrap; }
|
.center-tabs button { min-height: 36px; padding: 0 8px; border: 0; border-radius: 10px; background: transparent; color: var(--chat-muted); font-size: 13px; font-weight: 650; white-space: nowrap; }
|
||||||
.center-tabs button[aria-selected="true"] { background: #fff; color: var(--chat-brand-dark); box-shadow: 0 3px 12px rgba(27, 68, 55, 0.08); }
|
.center-tabs button[aria-selected="true"] { background: #fff; color: var(--chat-brand-dark); box-shadow: 0 3px 12px rgba(27, 68, 55, 0.08); }
|
||||||
.center-section { display: grid; gap: 15px; }
|
.center-section { display: grid; gap: 15px; }
|
||||||
|
.entitlement-notice { display: grid; gap: 4px; padding: 11px 13px; border: 1px solid #e7dcb9; border-radius: 12px; background: #fffaf0; color: #735f30; }
|
||||||
|
.entitlement-notice.expired { border-color: #e6d1cc; background: #fff7f5; color: #76504a; }
|
||||||
|
.entitlement-notice strong { font-size: 12px; }
|
||||||
|
.entitlement-notice span { font-size: 11px; line-height: 1.6; }
|
||||||
.plan-card, .review-card, .usage-card, .compact-record, .report-record { border: 1px solid var(--chat-border); border-radius: 14px; background: #fff; }
|
.plan-card, .review-card, .usage-card, .compact-record, .report-record { border: 1px solid var(--chat-border); border-radius: 14px; background: #fff; }
|
||||||
.plan-card { padding: 14px; background: linear-gradient(145deg, #f2f8f5, #fff); }
|
.plan-card { padding: 14px; background: linear-gradient(145deg, #f2f8f5, #fff); }
|
||||||
.section-heading { display: flex; align-items: center; gap: 9px; color: var(--chat-brand-dark); }
|
.section-heading { display: flex; align-items: center; gap: 9px; color: var(--chat-brand-dark); }
|
||||||
|
|||||||
@@ -31,7 +31,18 @@ const usageText = computed(() => {
|
|||||||
if (props.entitlement) return `已使用 ${displayUsed.value}/${displayLimit.value} 个主题`;
|
if (props.entitlement) return `已使用 ${displayUsed.value}/${displayLimit.value} 个主题`;
|
||||||
return `今日 ${displayUsed.value}/${displayLimit.value}`;
|
return `今日 ${displayUsed.value}/${displayLimit.value}`;
|
||||||
});
|
});
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
const guidanceText = computed(() => {
|
const guidanceText = computed(() => {
|
||||||
|
if (props.entitlement?.lifecycleStatus === "expired_fallback") {
|
||||||
|
return `原${props.entitlement.previousPlanName ? `“${props.entitlement.previousPlanName}”` : "权益"}已于 ${formatDate(props.entitlement.previousExpiredAt)} 到期,当前已自动切换为${props.entitlement.name},已有对话与回顾仍会保留。`;
|
||||||
|
}
|
||||||
|
if (props.entitlement?.lifecycleStatus === "expiring_7" || props.entitlement?.lifecycleStatus === "expiring_30") {
|
||||||
|
return `当前权益将于 ${formatDate(props.entitlement.expiredAt)} 到期;如需继续使用,可提前联系运营老师确认续期。`;
|
||||||
|
}
|
||||||
if (exhausted.value) return "本月深度主题使用较多,建议先完成已有功课;如需继续高频陪伴,可联系运营老师确认权益。";
|
if (exhausted.value) return "本月深度主题使用较多,建议先完成已有功课;如需继续高频陪伴,可联系运营老师确认权益。";
|
||||||
if (nearLimit.value) return "本月深度主题接近上限,可以先沉淀已有主题,再继续新的议题。";
|
if (nearLimit.value) return "本月深度主题接近上限,可以先沉淀已有主题,再继续新的议题。";
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface UserEntitlementSummary {
|
|||||||
effectiveAt?: string | null;
|
effectiveAt?: string | null;
|
||||||
expiredAt?: string | null;
|
expiredAt?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
lifecycleStatus: "active" | "long_term" | "expiring_7" | "expiring_30" | "expired_fallback" | "default" | "legacy" | string;
|
||||||
|
daysUntilExpiry?: number | null;
|
||||||
|
previousPlanName?: string | null;
|
||||||
|
previousExpiredAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopicSummary {
|
export interface TopicSummary {
|
||||||
|
|||||||
@@ -681,6 +681,7 @@ AI 日志增加:
|
|||||||
- 2026-07-31:用户端原“我的档案”弹窗已扩展为实修记录视图,可查看实修回顾、最近主题沉淀、最近老师求助卡和最近班级分享稿;仍保持“我的实修记录”表达,不做后台画像式展示。
|
- 2026-07-31:用户端原“我的档案”弹窗已扩展为实修记录视图,可查看实修回顾、最近主题沉淀、最近老师求助卡和最近班级分享稿;仍保持“我的实修记录”表达,不做后台画像式展示。
|
||||||
- 2026-08-03:升级为 V2“近期实修回顾”。移除常见情绪、身体/关系模式、做过或有效功课、近期变化等字段;旧版数据不再展示或注入 Agent,下一次主题沉淀时按最近30天、最多10个新版主题懒重建。
|
- 2026-08-03:升级为 V2“近期实修回顾”。移除常见情绪、身体/关系模式、做过或有效功课、近期变化等字段;旧版数据不再展示或注入 Agent,下一次主题沉淀时按最近30天、最多10个新版主题懒重建。
|
||||||
- 2026-08-03:新增用户端“个人中心”,顶部头像入口统一展示账号、当前套餐、有效期、本月主题和今日问答用量、剩余额度及能力清单;近期回顾、周期回顾、求助卡和分享稿按栏目查看。基础版会明确提示未包含的能力,不做成长评分、打卡天数或结果排名。
|
- 2026-08-03:新增用户端“个人中心”,顶部头像入口统一展示账号、当前套餐、有效期、本月主题和今日问答用量、剩余额度及能力清单;近期回顾、周期回顾、求助卡和分享稿按栏目查看。基础版会明确提示未包含的能力,不做成长评分、打卡天数或结果排名。
|
||||||
|
- 2026-08-03:完成权益到期与续期闭环。后台用户列表支持按权益版本、生效中、7 天内到期、8-30 天内到期、已到期和默认权益筛选;支持单人及最多 200 人批量续期,未到期从原到期日顺延,已到期从当前时间恢复,长期有效和未分配专属权益会明确拒绝。续期使用请求唯一键防止重复加天,并记录操作人、前后到期时间和备注;小时级维护任务分批归档已到期权益。用户端在临近到期及自动切换基础权益时使用柔性提示,并明确已有聊天、回顾和卡片不会丢失。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,18 @@
|
|||||||
|
|
||||||
2026-08-03 已完成实修记录 V2 改造:不再生成、展示或注入常见情绪、身体/关系模式、做过或有效功课、近期变化等长期画像字段;仅保留最近 30 天、最多 10 个新版主题形成的近期回顾、近期关注和可以继续留意的问题。旧版记录保留在数据库中用于兼容和回滚,但不会进入用户端或 Agent 上下文。
|
2026-08-03 已完成实修记录 V2 改造:不再生成、展示或注入常见情绪、身体/关系模式、做过或有效功课、近期变化等长期画像字段;仅保留最近 30 天、最多 10 个新版主题形成的近期回顾、近期关注和可以继续留意的问题。旧版记录保留在数据库中用于兼容和回滚,但不会进入用户端或 Agent 上下文。
|
||||||
|
|
||||||
|
2026-08-03 已完成权益到期与续期闭环:后台可筛选临期、到期和默认权益用户,支持单人及批量续期;续期按请求唯一键幂等执行并保留审计明细,小时级维护任务自动归档到期权益。用户端会柔性提示临期或已降级状态,并明确历史内容仍然保留。
|
||||||
|
|
||||||
## 3. 功能验收结果
|
## 3. 功能验收结果
|
||||||
|
|
||||||
| 验收项 | 结果 | 实际结果 |
|
| 验收项 | 结果 | 实际结果 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 权益管理页面 | 通过 | 编辑区按基础配置、能力权限拆分;桌面端和窄屏自适应;按钮、开关和表格不再拥挤错位 |
|
| 权益管理页面 | 通过 | 编辑区按基础配置、能力权限拆分;用户列表可筛选权益版本和到期状态,并支持单人/批量续期;桌面端和窄屏自适应 |
|
||||||
| 权益版本编辑 | 通过 | 编辑已有权益、退出编辑、启用状态和能力开关均可正常操作 |
|
| 权益版本编辑 | 通过 | 编辑已有权益、退出编辑、启用状态和能力开关均可正常操作 |
|
||||||
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
|
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
|
||||||
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
|
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
|
||||||
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
|
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
|
||||||
| 用户端个人中心 | 通过 | 顶部统一入口展示账号、套餐说明、有效期、本月主题、今日问答、剩余额度和能力清单;近期回顾与生成记录分栏展示 |
|
| 用户端个人中心 | 通过 | 顶部统一入口展示账号、套餐说明、有效期、本月主题、今日问答、剩余额度和能力清单;近期回顾与生成记录分栏展示;临期或降级时显示柔性说明 |
|
||||||
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和近期实修回顾 |
|
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和近期实修回顾 |
|
||||||
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
|
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
|
||||||
| 主题沉淀 | 通过 | 结束主题快速入队;主题摘要与近期实修回顾后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
|
| 主题沉淀 | 通过 | 结束主题快速入队;主题摘要与近期实修回顾后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
|
||||||
@@ -39,12 +41,12 @@
|
|||||||
|
|
||||||
| 验收项 | 结果 | 实际结果 |
|
| 验收项 | 结果 | 实际结果 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 后端自动化测试 | 通过 | 114 项测试全部通过,包含 V2 数据隔离、异步入队、幂等、重试、重启恢复及推理内容过滤 |
|
| 后端自动化测试 | 通过 | 117 项测试全部通过,包含 V2 数据隔离、异步入队、权益续期幂等、到期归档、重试、重启恢复及推理内容过滤 |
|
||||||
| OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
|
| OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
|
||||||
| 管理后台构建 | 通过 | 生产构建成功 |
|
| 管理后台构建 | 通过 | 生产构建成功 |
|
||||||
| 用户端构建 | 通过 | 生产构建成功 |
|
| 用户端构建 | 通过 | 生产构建成功 |
|
||||||
| Alembic 离线 SQL | 通过 | 成功生成 1154 行迁移 SQL,并覆盖到 `0025_recent_practice_review` |
|
| Alembic 离线 SQL | 通过 | 成功生成迁移 SQL,并覆盖到 `0026_entitlement_renewal` |
|
||||||
| 现有数据库迁移 | 通过 | 开发 MySQL 已升级至 `0025_recent_practice_review`,数据库与 Redis 就绪 |
|
| 现有数据库迁移 | 通过 | 开发 MySQL 已升级至 `0026_entitlement_renewal`,到期复合索引和续期请求唯一索引均已核对 |
|
||||||
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0025_recent_practice_review`,V2 字段与默认值均已生成,验收库随后清理 |
|
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0025_recent_practice_review`,V2 字段与默认值均已生成,验收库随后清理 |
|
||||||
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
|
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
|
||||||
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
|
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
|
||||||
|
|||||||
Reference in New Issue
Block a user