feat: 完成权益到期与续期闭环

This commit is contained in:
2026-08-03 14:32:38 +08:00
parent 20ed4875b8
commit b2b56f43bb
18 changed files with 792 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 人批量续期,未到期从原到期日顺延,已到期从当前时间恢复,长期有效和未分配专属权益会明确拒绝。续期使用请求唯一键防止重复加天,并记录操作人、前后到期时间和备注;小时级维护任务分批归档已到期权益。用户端在临近到期及自动切换基础权益时使用柔性提示,并明确已有聊天、回顾和卡片不会丢失。
--- ---

View File

@@ -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 启动,所有容器健康 |