feat: isolate external application conversations

This commit is contained in:
2026-08-03 18:57:19 +08:00
parent 26109648af
commit cd3c875106
26 changed files with 843 additions and 110 deletions

View File

@@ -6,6 +6,7 @@ import type {
AiLogRecord, AiLogRecord,
ChatRecord, ChatRecord,
QuestionInsightSummary, QuestionInsightSummary,
SsoClientItem,
} from "../types/api"; } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue"; import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
@@ -15,6 +16,7 @@ type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false); const loading = ref(false);
const activeTab = ref<RecordTab>("chats"); const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]); const chats = ref<ChatRecord[]>([]);
const ssoClients = ref<SsoClientItem[]>([]);
const aiLogs = ref<AiLogRecord[]>([]); const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]); const operationLogs = ref<Record<string, unknown>[]>([]);
const insights = ref<QuestionInsightSummary | null>(null); const insights = ref<QuestionInsightSummary | null>(null);
@@ -32,6 +34,8 @@ const chatFilters = reactive({
keyword: "", keyword: "",
userId: undefined as number | undefined, userId: undefined as number | undefined,
status: "", status: "",
sourceType: "",
sourceClientId: undefined as number | undefined,
dateFrom: "", dateFrom: "",
dateTo: "", dateTo: "",
}); });
@@ -42,7 +46,9 @@ const insightFilters = reactive({
maxMessages: 5000, maxMessages: 5000,
}); });
onMounted(() => loadTab("chats")); onMounted(async () => {
await Promise.all([loadTab("chats"), loadSsoClients()]);
});
watch(activeTab, (tab) => loadTab(tab)); watch(activeTab, (tab) => loadTab(tab));
async function loadTab(tab: RecordTab = activeTab.value) { async function loadTab(tab: RecordTab = activeTab.value) {
@@ -91,6 +97,13 @@ async function loadTab(tab: RecordTab = activeTab.value) {
loading.value = false; loading.value = false;
} }
} }
async function loadSsoClients() {
try {
ssoClients.value = await api.ssoClients();
} catch {
ssoClients.value = [];
}
}
async function changePage(tab: RecordTab, page: number, pageSize: number) { async function changePage(tab: RecordTab, page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize }); Object.assign(pagers[tab], { page, pageSize });
@@ -123,6 +136,8 @@ async function resetChatFilters() {
keyword: "", keyword: "",
userId: undefined, userId: undefined,
status: "", status: "",
sourceType: "",
sourceClientId: undefined,
dateFrom: "", dateFrom: "",
dateTo: "", dateTo: "",
}); });
@@ -173,6 +188,8 @@ function buildChatQuery() {
keyword: chatFilters.keyword, keyword: chatFilters.keyword,
userId: chatFilters.userId, userId: chatFilters.userId,
status: chatFilters.status, status: chatFilters.status,
sourceType: chatFilters.sourceType,
sourceClientId: chatFilters.sourceClientId,
dateFrom: formatDateTime(chatFilters.dateFrom, "start"), dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
dateTo: formatDateTime(chatFilters.dateTo, "end"), dateTo: formatDateTime(chatFilters.dateTo, "end"),
}; };
@@ -220,6 +237,13 @@ function formatMoney(value?: number | null, currency = "CNY") {
label="已停止" label="已停止"
value="STOPPED" /><el-option label="失败" value="FAILED" value="STOPPED" /><el-option label="失败" value="FAILED"
/></el-select> /></el-select>
<el-select v-model="chatFilters.sourceType" placeholder="会话来源" clearable @change="chatFilters.sourceClientId = undefined">
<el-option label="直接访问" value="direct" />
<el-option label="第三方应用" value="sso" />
</el-select>
<el-select v-if="chatFilters.sourceType === 'sso'" v-model="chatFilters.sourceClientId" placeholder="来源应用" clearable>
<el-option v-for="client in ssoClients" :key="client.id" :label="client.name" :value="client.id" />
</el-select>
<div class="record-date-range" aria-label="时间范围"> <div class="record-date-range" aria-label="时间范围">
<label class="record-date-field" <label class="record-date-field"
><span>开始时间</span ><span>开始时间</span
@@ -257,6 +281,11 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="userName" prop="userName"
label="用户" label="用户"
width="120" width="120"
/><el-table-column
prop="sourceName"
label="会话来源"
min-width="160"
show-overflow-tooltip
/><el-table-column /><el-table-column
prop="title" prop="title"
label="会话标题" label="会话标题"

View File

@@ -3,12 +3,13 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api"; import type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
const loading = ref(false); const loading = ref(false);
const savingUrl = ref(false); const savingUrl = ref(false);
const clients = ref<SsoClientItem[]>([]); const clients = ref<SsoClientItem[]>([]);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const userClientUrl = ref(""); const userClientUrl = ref("");
const activeTab = ref("applications"); const activeTab = ref("applications");
const clientDialogOpen = ref(false); const clientDialogOpen = ref(false);
@@ -35,6 +36,8 @@ const clientForm = reactive({
appId: "", appId: "",
name: "", name: "",
redirectUrisText: "", redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined as number | undefined,
status: 1, status: 1,
}); });
@@ -45,9 +48,14 @@ onMounted(loadOverview);
async function loadOverview() { async function loadOverview() {
loading.value = true; loading.value = true;
try { try {
const [config, clientList] = await Promise.all([api.ssoConfig(), api.ssoClients()]); const [config, clientList, plans] = await Promise.all([
api.ssoConfig(),
api.ssoClients(),
api.entitlementPlans(false),
]);
userClientUrl.value = config.userClientUrl; userClientUrl.value = config.userClientUrl;
clients.value = clientList; clients.value = clientList;
entitlementPlans.value = plans;
} catch (error) { } catch (error) {
showError(error, "应用接入配置加载失败"); showError(error, "应用接入配置加载失败");
} finally { } finally {
@@ -70,7 +78,14 @@ async function savePublicUrl() {
function openCreateDialog() { function openCreateDialog() {
editingClientId.value = null; editingClientId.value = null;
Object.assign(clientForm, { appId: "", name: "", redirectUrisText: "", status: 1 }); Object.assign(clientForm, {
appId: "",
name: "",
redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined,
status: 1,
});
clientDialogOpen.value = true; clientDialogOpen.value = true;
} }
@@ -80,6 +95,8 @@ function openEditDialog(client: SsoClientItem) {
appId: client.appId, appId: client.appId,
name: client.name, name: client.name,
redirectUrisText: client.redirectUris.join("\n"), redirectUrisText: client.redirectUris.join("\n"),
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? undefined,
status: client.status, status: client.status,
}); });
clientDialogOpen.value = true; clientDialogOpen.value = true;
@@ -92,16 +109,33 @@ async function saveClient() {
ElMessage.warning("请填写应用ID和应用名称"); ElMessage.warning("请填写应用ID和应用名称");
return; return;
} }
if (clientForm.allowAutoRegister && !clientForm.defaultEntitlementPlanId) {
ElMessage.warning("允许自动注册时请选择默认权益版本");
return;
}
const redirectUris = clientForm.redirectUrisText const redirectUris = clientForm.redirectUrisText
.split(/\r?\n/) .split(/\r?\n/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
try { try {
if (editingClientId.value) { if (editingClientId.value) {
await api.updateSsoClient(editingClientId.value, { name, redirectUris, status: clientForm.status }); await api.updateSsoClient(editingClientId.value, {
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
ElMessage.success("接入应用已更新"); ElMessage.success("接入应用已更新");
} else { } else {
const result = await api.createSsoClient({ appId, name, redirectUris, status: clientForm.status }); const result = await api.createSsoClient({
appId,
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
revealedSecret.value = result.clientSecret || ""; revealedSecret.value = result.clientSecret || "";
revealedAppId.value = result.appId; revealedAppId.value = result.appId;
secretDialogOpen.value = true; secretDialogOpen.value = true;
@@ -119,6 +153,8 @@ async function toggleClient(client: SsoClientItem) {
await api.updateSsoClient(client.id, { await api.updateSsoClient(client.id, {
name: client.name, name: client.name,
redirectUris: client.redirectUris, redirectUris: client.redirectUris,
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? null,
status: targetStatus, status: targetStatus,
}); });
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用"); ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
@@ -272,6 +308,12 @@ function showError(error: unknown, fallback: string) {
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template> <template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="identityCount" label="绑定用户" width="100" align="center" /> <el-table-column prop="identityCount" label="绑定用户" width="100" align="center" />
<el-table-column label="新用户策略" min-width="190">
<template #default="{ row }">
<span v-if="row.allowAutoRegister">自动注册 · {{ row.defaultEntitlementPlanName || "未配置权益" }}</span>
<span v-else>仅已有学员</span>
</template>
</el-table-column>
<el-table-column label="最近使用" width="180"> <el-table-column label="最近使用" width="180">
<template #default="{ row }">{{ formatTime(row.lastUsedAt) }}</template> <template #default="{ row }">{{ formatTime(row.lastUsedAt) }}</template>
</el-table-column> </el-table-column>
@@ -353,6 +395,18 @@ function showError(error: unknown, fallback: string) {
<el-form-item label="允许回跳地址"> <el-form-item label="允许回跳地址">
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" /> <el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" />
</el-form-item> </el-form-item>
<div class="sso-auto-register">
<div>
<strong>自动注册新学员</strong>
<p>可信应用传入本地尚不存在的已验证手机号和姓名时自动创建学员并分配默认权益</p>
</div>
<el-switch v-model="clientForm.allowAutoRegister" />
</div>
<el-form-item v-if="clientForm.allowAutoRegister" label="默认权益版本">
<el-select v-model="clientForm.defaultEntitlementPlanId" placeholder="请选择自动注册学员的权益" style="width: 100%">
<el-option v-for="plan in entitlementPlans" :key="plan.id" :label="plan.name" :value="plan.id" />
</el-select>
</el-form-item>
<el-form-item label="启用应用"><el-switch v-model="clientForm.status" :active-value="1" :inactive-value="0" /></el-form-item> <el-form-item label="启用应用"><el-switch v-model="clientForm.status" :active-value="1" :inactive-value="0" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -384,6 +438,8 @@ function showError(error: unknown, fallback: string) {
.sso-filters .el-select { width: 190px; } .sso-filters .el-select { width: 190px; }
.sso-filters .el-input { max-width: 360px; } .sso-filters .el-input { max-width: 360px; }
.sso-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .sso-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.sso-auto-register { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding: 16px; border: 1px solid #dce8e4; border-radius: 12px; background: #f8fbfa; }
.sso-auto-register p { margin: 6px 0 0; color: #71817d; font-size: 13px; line-height: 1.6; }
.sso-secret-block { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px 16px; align-items: start; margin-top: 20px; } .sso-secret-block { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px 16px; align-items: start; margin-top: 20px; }
.sso-secret-block label { color: #6c7d79; } .sso-secret-block label { color: #6c7d79; }
.sso-secret-block code { padding: 10px 12px; overflow-wrap: anywhere; border-radius: 8px; background: #f3f7f5; color: #173d34; } .sso-secret-block code { padding: 10px 12px; overflow-wrap: anywhere; border-radius: 8px; background: #f3f7f5; color: #173d34; }

View File

@@ -369,6 +369,12 @@ async function deleteUser(row: AdminUser) {
width="150" width="150"
/><el-table-column prop="name" label="姓名" width="140" /> /><el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="nickname" label="昵称" width="140" /> <el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="注册来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.registrationSource === 'sso'" type="info">第三方接入</el-tag>
<el-tag v-else>直接学员</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="130" <el-table-column label="状态" width="130"
><template #default="{ row }" ><template #default="{ row }"
><el-switch ><el-switch

View File

@@ -273,9 +273,9 @@ export const api = {
body: JSON.stringify({ userClientUrl }), body: JSON.stringify({ userClientUrl }),
}), }),
ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"), ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"),
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; status: number }) => createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; status: number }) =>
request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }), request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }),
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; status: number }) => updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; status: number }) =>
request<SsoClientItem>(`/admin/sso/client/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<SsoClientItem>(`/admin/sso/client/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
rotateSsoClientSecret: (id: number) => rotateSsoClientSecret: (id: number) =>
request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, { request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, {

View File

@@ -23,6 +23,9 @@ export interface SsoClientItem {
appId: string; appId: string;
name: string; name: string;
redirectUris: string[]; redirectUris: string[];
allowAutoRegister: boolean;
defaultEntitlementPlanId?: number | null;
defaultEntitlementPlanName?: string | null;
status: number; status: number;
identityCount: number; identityCount: number;
lastUsedAt?: string | null; lastUsedAt?: string | null;
@@ -143,6 +146,8 @@ export interface AdminUser {
phone: string; phone: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
registrationSource: "direct" | "sso" | string;
registrationClientId?: number | null;
status: number; status: number;
dailyChatLimit: number; dailyChatLimit: number;
dailyChatUsed: number; dailyChatUsed: number;
@@ -484,6 +489,9 @@ export interface ChatRecord {
userId: number; userId: number;
userPhone: string; userPhone: string;
userName: string; userName: string;
sourceType: "direct" | "sso";
sourceClientId?: number | null;
sourceName: string;
title: string; title: string;
messageCount: number; messageCount: number;
lastMessageAt?: string | null; lastMessageAt?: string | null;
@@ -715,6 +723,8 @@ export interface ChatRecordQuery {
keyword?: string; keyword?: string;
userId?: number | null; userId?: number | null;
status?: string; status?: string;
sourceType?: string;
sourceClientId?: number | null;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
page?: number; page?: number;

View File

@@ -0,0 +1,56 @@
"""add sso auto registration and chat source isolation
Revision ID: 0030_sso_chat_source
Revises: 0029_sso_integration
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0030_sso_chat_source"
down_revision = "0029_sso_integration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_sso_client",
sa.Column("allow_auto_register", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_sso_client",
sa.Column("default_entitlement_plan_id", sa.BigInteger(), nullable=True),
)
op.add_column(
"sys_user",
sa.Column("registration_source", sa.String(length=30), nullable=False, server_default="direct"),
)
op.add_column("sys_user", sa.Column("registration_client_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_user_registration_client_id", "sys_user", ["registration_client_id"])
op.add_column(
"sys_chat_session",
sa.Column("source_type", sa.String(length=20), nullable=False, server_default="direct"),
)
op.add_column("sys_chat_session", sa.Column("source_client_id", sa.BigInteger(), nullable=True))
op.create_index(
"ix_chat_session_user_source_active_updated",
"sys_chat_session",
["user_id", "source_type", "source_client_id", "is_deleted", "updated_at"],
)
def downgrade() -> None:
op.drop_index("ix_chat_session_user_source_active_updated", table_name="sys_chat_session")
op.drop_column("sys_chat_session", "source_client_id")
op.drop_column("sys_chat_session", "source_type")
op.drop_index("ix_sys_user_registration_client_id", table_name="sys_user")
op.drop_column("sys_user", "registration_client_id")
op.drop_column("sys_user", "registration_source")
op.drop_column("sys_sso_client", "default_entitlement_plan_id")
op.drop_column("sys_sso_client", "allow_auto_register")

View File

@@ -18,6 +18,7 @@ from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, OperationLog
from app.models.sso import SsoClient
from app.models.user import User from app.models.user import User
from app.api.pagination import page_result from app.api.pagination import page_result
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
@@ -34,6 +35,8 @@ def chat_list(
keyword: str = Query(default=""), keyword: str = Query(default=""),
userId: int | None = Query(default=None), userId: int | None = Query(default=None),
status: str = Query(default=""), status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None), dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None), dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
@@ -41,10 +44,19 @@ def chat_list(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo) query = _chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
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
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all() rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize)) items = [_chat_row_dict(session, user, source_client) for session, user, source_client in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/chat/export") @router.get("/chat/export")
@@ -52,26 +64,37 @@ def export_chats(
keyword: str = Query(default=""), keyword: str = Query(default=""),
userId: int | None = Query(default=None), userId: int | None = Query(default=None),
status: str = Query(default=""), status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None), dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None), dateTo: datetime | None = Query(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> Response: ) -> Response:
rows = db.execute( rows = db.execute(
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo) _chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
.order_by(ChatSession.updated_at.desc()) .order_by(ChatSession.updated_at.desc())
.limit(1000) .limit(1000)
).all() ).all()
output = StringIO() output = StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"]) writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "来源", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user in rows: for session, user, source_client in rows:
writer.writerow( writer.writerow(
[ [
session.id, session.id,
session.user_id, session.user_id,
user.phone if user else "", user.phone if user else "",
user.name if user else "", user.name if user else "",
source_client.name if source_client else "千问千答直接访问",
session.title, session.title,
session.message_count, session.message_count,
session.last_message_at, session.last_message_at,
@@ -117,8 +140,9 @@ def chat_detail(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
session_row = db.execute( session_row = db.execute(
select(ChatSession, User) select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True) .join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id) .where(ChatSession.id == session_id)
).first() ).first()
if session_row is None: if session_row is None:
@@ -126,7 +150,7 @@ def chat_detail(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user = session_row session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id) message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0 message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
messages = db.scalars( messages = db.scalars(
@@ -161,7 +185,7 @@ def chat_detail(
) )
return api_success( return api_success(
{ {
"session": _chat_row_dict(session, user), "session": _chat_row_dict(session, user, source_client),
"messages": [_message_dict(item) for item in messages], "messages": [_message_dict(item) for item in messages],
"messagesPage": page_result( "messagesPage": page_result(
[_message_dict(item) for item in messages], [_message_dict(item) for item in messages],
@@ -305,16 +329,23 @@ def _chat_query(
keyword: str, keyword: str,
user_id: int | None, user_id: int | None,
status: str, status: str,
source_type: str,
source_client_id: int | None,
date_from: datetime | None, date_from: datetime | None,
date_to: datetime | None, date_to: datetime | None,
): ):
query = ( query = (
select(ChatSession, User) select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True) .join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.is_deleted == 0) .where(ChatSession.is_deleted == 0)
) )
if user_id is not None: if user_id is not None:
query = query.where(ChatSession.user_id == user_id) query = query.where(ChatSession.user_id == user_id)
if source_type:
query = query.where(ChatSession.source_type == source_type)
if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None: if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None)) query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
if date_to is not None: if date_to is not None:
@@ -340,12 +371,15 @@ def _chat_query(
return query return query
def _chat_row_dict(session: ChatSession, user: User | None) -> dict: def _chat_row_dict(session: ChatSession, user: User | None, source_client: SsoClient | None = None) -> dict:
return { return {
"id": session.id, "id": session.id,
"userId": session.user_id, "userId": session.user_id,
"userPhone": user.phone if user else "", "userPhone": user.phone if user else "",
"userName": user.name if user else "", "userName": user.name if user else "",
"sourceType": session.source_type,
"sourceClientId": session.source_client_id,
"sourceName": source_client.name if source_client else "千问千答直接访问",
"title": session.title, "title": session.title,
"messageCount": session.message_count, "messageCount": session.message_count,
"lastMessageAt": session.last_message_at, "lastMessageAt": session.last_message_at,

View File

@@ -13,6 +13,7 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User from app.models.user import User
from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
@@ -80,11 +81,12 @@ def list_sso_clients(
.subquery() .subquery()
) )
rows = db.execute( rows = db.execute(
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0)) select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0), EntitlementPlan.name)
.outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id) .outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id)
.outerjoin(EntitlementPlan, EntitlementPlan.id == SsoClient.default_entitlement_plan_id)
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc()) .order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
).all() ).all()
return api_success([_client_item(client, int(identity_count)) for client, identity_count in rows]) return api_success([_client_item(client, int(identity_count), plan_name) for client, identity_count, plan_name in rows])
@router.post("/sso/client") @router.post("/sso/client")
@@ -96,12 +98,15 @@ def create_sso_client(
app_id = payload.appId.strip() app_id = payload.appId.strip()
if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None: if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在")
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
plaintext_secret = secrets.token_urlsafe(32) plaintext_secret = secrets.token_urlsafe(32)
client = SsoClient( client = SsoClient(
app_id=app_id, app_id=app_id,
name=payload.name.strip(), name=payload.name.strip(),
client_secret=SecretService.encrypt(plaintext_secret), client_secret=SecretService.encrypt(plaintext_secret),
redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False), redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False),
allow_auto_register=1 if payload.allowAutoRegister else 0,
default_entitlement_plan_id=payload.defaultEntitlementPlanId,
status=payload.status, status=payload.status,
created_by=current_admin.id, created_by=current_admin.id,
) )
@@ -127,8 +132,11 @@ def update_sso_client(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
client = _require_client(db, client_id) client = _require_client(db, client_id)
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
client.name = payload.name.strip() client.name = payload.name.strip()
client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False) client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False)
client.allow_auto_register = 1 if payload.allowAutoRegister else 0
client.default_entitlement_plan_id = payload.defaultEntitlementPlanId
client.status = payload.status client.status = payload.status
OperationLogService.write( OperationLogService.write(
db, db,
@@ -141,7 +149,8 @@ def update_sso_client(
identity_count = db.scalar( identity_count = db.scalar(
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id) select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
) or 0 ) or 0
return api_success(_client_item(client, identity_count)) plan_name = db.scalar(select(EntitlementPlan.name).where(EntitlementPlan.id == client.default_entitlement_plan_id))
return api_success(_client_item(client, identity_count, plan_name))
@router.post("/sso/client/{client_id}/secret/rotate") @router.post("/sso/client/{client_id}/secret/rotate")
@@ -260,12 +269,15 @@ def _require_client(db: Session, client_id: int) -> SsoClient:
return client return client
def _client_item(client: SsoClient, identity_count: int) -> dict: def _client_item(client: SsoClient, identity_count: int, plan_name: str | None = None) -> dict:
return { return {
"id": client.id, "id": client.id,
"appId": client.app_id, "appId": client.app_id,
"name": client.name, "name": client.name,
"redirectUris": _json_list(client.redirect_uris), "redirectUris": _json_list(client.redirect_uris),
"allowAutoRegister": bool(client.allow_auto_register),
"defaultEntitlementPlanId": client.default_entitlement_plan_id,
"defaultEntitlementPlanName": plan_name,
"status": client.status, "status": client.status,
"identityCount": identity_count, "identityCount": identity_count,
"lastUsedAt": client.last_used_at, "lastUsedAt": client.last_used_at,
@@ -274,6 +286,14 @@ def _client_item(client: SsoClient, identity_count: int) -> dict:
} }
def _validate_default_plan(db: Session, allow_auto_register: bool, plan_id: int | None) -> None:
if not allow_auto_register:
return
plan = db.get(EntitlementPlan, plan_id) if plan_id else None
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认权益版本不存在或已停用")
def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict: def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict:
return { return {
"id": identity.id, "id": identity.id,

View File

@@ -490,6 +490,8 @@ def _user_dict(user: User, entitlement: dict | None = None) -> dict:
"phone": user.phone, "phone": user.phone,
"name": user.name, "name": user.name,
"nickname": user.nickname, "nickname": user.nickname,
"registrationSource": user.registration_source,
"registrationClientId": user.registration_client_id,
"status": user.status, "status": user.status,
"dailyChatLimit": user.daily_chat_limit, "dailyChatLimit": user.daily_chat_limit,
"dailyChatUsed": user.daily_chat_used, "dailyChatUsed": user.daily_chat_used,

View File

@@ -7,12 +7,15 @@ from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_user from app.core.auth_context import ChatAccessScope, UserAuthContext
from app.core.dependencies import get_current_user_context
from app.core.responses import api_success from app.core.responses import api_success
from app.models.user import User from app.models.chat import TopicSession
from app.models.growth import TopicSummary
from app.schemas.chat import ( from app.schemas.chat import (
ChatCompletionRequest, ChatCompletionRequest,
ChatMessageRead, ChatMessageRead,
@@ -40,14 +43,14 @@ logger = logging.getLogger(__name__)
@router.post("/session") @router.post("/session")
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict: def create_session(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
session = ChatService.create_session(db, current_user) session = ChatService.create_session(db, current.user, current.chat_scope)
return api_success(CreateSessionResponse(sessionId=session.id).model_dump()) return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
@router.get("/session/list") @router.get("/session/list")
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict: def list_sessions(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
sessions = ChatService.list_sessions(db, current_user) sessions = ChatService.list_sessions(db, current.user, current.chat_scope)
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions]) return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
@@ -55,9 +58,9 @@ def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(ge
def history( def history(
sessionId: int = Query(...), sessionId: int = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
messages = ChatService.get_history(db, current_user, sessionId) messages = ChatService.get_history(db, current.user, sessionId, current.chat_scope)
reasoning_visible = ReasoningPolicyService.is_visible(db) reasoning_visible = ReasoningPolicyService.is_visible(db)
result = [] result = []
for message in messages: for message in messages:
@@ -72,9 +75,9 @@ def history(
def update_title( def update_title(
payload: UpdateSessionTitleRequest, payload: UpdateSessionTitleRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title) session = ChatService.update_title(db, current.user, payload.sessionId, payload.title, current.chat_scope)
return api_success(ChatSessionRead.model_validate(session).model_dump()) return api_success(ChatSessionRead.model_validate(session).model_dump())
@@ -82,9 +85,9 @@ def update_title(
def delete_session( def delete_session(
session_id: int, session_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
ChatService.delete_session(db, current_user, session_id) ChatService.delete_session(db, current.user, session_id, current.chat_scope)
return api_success() return api_success()
@@ -92,29 +95,37 @@ def delete_session(
def finish_topic( def finish_topic(
session_id: int, session_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
session = ChatService._get_user_session(db, current_user, session_id) session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session)) return api_success(GrowthProfileService.finish_active_topic(db, user=current.user, session=session))
@router.get("/topic/settlement/{summary_id}") @router.get("/topic/settlement/{summary_id}")
def topic_settlement( def topic_settlement(
summary_id: int, summary_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
return api_success(GrowthProfileService.topic_settlement_result(db, user=current_user, summary_id=summary_id)) topic_session_id = db.scalar(
select(TopicSession.chat_session_id)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id)
.where(TopicSummary.id == summary_id, TopicSummary.user_id == current.user.id)
)
if topic_session_id is None:
raise HTTPException(status_code=404, detail="主题沉淀任务不存在")
ChatService._get_user_session(db, current.user, topic_session_id, current.chat_scope)
return api_success(GrowthProfileService.topic_settlement_result(db, user=current.user, summary_id=summary_id))
@router.post("/session/{session_id}/help-card") @router.post("/session/{session_id}/help-card")
def generate_help_card( def generate_help_card(
session_id: int, session_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
session = ChatService._get_user_session(db, current_user, session_id) session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
card = HelpCardService.generate_for_session(db, user=current_user, session=session) card = HelpCardService.generate_for_session(db, user=current.user, session=session)
return api_success(help_card_dict(card)) return api_success(help_card_dict(card))
@@ -122,27 +133,33 @@ def generate_help_card(
def list_help_cards( def list_help_cards(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
return api_success([help_card_dict(card) for card in HelpCardService.list_user_cards(db, user=current_user, limit=limit)]) cards = HelpCardService.list_user_cards(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([help_card_dict(card) for card in cards])
@router.post("/help-card/{card_id}/copied") @router.post("/help-card/{card_id}/copied")
def mark_help_card_copied( def mark_help_card_copied(
card_id: int, card_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id))) return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current.user, scope=current.chat_scope, card_id=card_id)))
@router.delete("/help-card/{card_id}") @router.delete("/help-card/{card_id}")
def delete_help_card( def delete_help_card(
card_id: int, card_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
HelpCardService.delete(db, user=current_user, card_id=card_id) HelpCardService.delete(db, user=current.user, scope=current.chat_scope, card_id=card_id)
return api_success() return api_success()
@@ -150,10 +167,10 @@ def delete_help_card(
def generate_share_draft( def generate_share_draft(
session_id: int, session_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
session = ChatService._get_user_session(db, current_user, session_id) session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
draft = ShareDraftService.generate_for_session(db, user=current_user, session=session) draft = ShareDraftService.generate_for_session(db, user=current.user, session=session)
return api_success(share_draft_dict(draft)) return api_success(share_draft_dict(draft))
@@ -161,27 +178,33 @@ def generate_share_draft(
def list_share_drafts( def list_share_drafts(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
return api_success([share_draft_dict(draft) for draft in ShareDraftService.list_user_drafts(db, user=current_user, limit=limit)]) drafts = ShareDraftService.list_user_drafts(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([share_draft_dict(draft) for draft in drafts])
@router.post("/share-draft/{draft_id}/copied") @router.post("/share-draft/{draft_id}/copied")
def mark_share_draft_copied( def mark_share_draft_copied(
draft_id: int, draft_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current_user, draft_id=draft_id))) return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)))
@router.delete("/share-draft/{draft_id}") @router.delete("/share-draft/{draft_id}")
def delete_share_draft( def delete_share_draft(
draft_id: int, draft_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
ShareDraftService.delete(db, user=current_user, draft_id=draft_id) ShareDraftService.delete(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)
return api_success() return api_success()
@@ -189,10 +212,10 @@ def delete_share_draft(
def completions( def completions(
payload: ChatCompletionRequest, payload: ChatCompletionRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> StreamingResponse: ) -> StreamingResponse:
return StreamingResponse( return StreamingResponse(
_chat_stream(payload, db, current_user), _chat_stream(payload, db, current),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
@@ -206,13 +229,17 @@ def completions(
def stop( def stop(
payload: StopChatRequest, payload: StopChatRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current: UserAuthContext = Depends(get_current_user_context),
) -> dict: ) -> dict:
ChatService.stop_generation(db, current_user, payload.sessionId) ChatService.stop_generation(db, current.user, payload.sessionId, current.chat_scope)
return api_success() return api_success()
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]: async def _chat_stream(payload: ChatCompletionRequest, db: Session, current: UserAuthContext) -> AsyncIterator[str]:
# Keep the internal stream helper compatible with direct service/test callers;
# HTTP requests always pass a fully validated UserAuthContext.
current_user = getattr(current, "user", current)
chat_scope = getattr(current, "chat_scope", ChatAccessScope.direct())
config = load_chat_queue_config(db) config = load_chat_queue_config(db)
queue_lease = await request_chat_slot(config) queue_lease = await request_chat_slot(config)
queue_request = queue_lease.request queue_request = queue_lease.request
@@ -274,6 +301,7 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
payload.sessionId, payload.sessionId,
payload.message, payload.message,
retry_failed_question=payload.retry, retry_failed_question=payload.retry,
scope=chat_scope,
) )
async for segment in ReasoningPolicyService.iter_segments(chunks): async for segment in ReasoningPolicyService.iter_segments(chunks):
if segment.kind == "content": if segment.kind == "content":

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from app.models.user import User
@dataclass(frozen=True)
class ChatAccessScope:
source_type: str = "direct"
source_client_id: int | None = None
@classmethod
def direct(cls) -> "ChatAccessScope":
return cls()
@dataclass(frozen=True)
class UserAuthContext:
user: User
chat_scope: ChatAccessScope

View File

@@ -7,8 +7,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.auth_context import ChatAccessScope, UserAuthContext
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models.admin import Admin from app.models.admin import Admin
from app.models.sso import SsoClient
from app.models.user import User from app.models.user import User
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
@@ -22,10 +24,10 @@ def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depend
return payload return payload
def get_current_user( def get_current_user_context(
payload: dict = Depends(get_current_token_payload), payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> UserAuthContext:
if payload.get("type") != "user": if payload.get("type") != "user":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
@@ -40,7 +42,25 @@ def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
if user.expired_at and user.expired_at < now: if user.expired_at and user.expired_at < now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
return user auth_source = str(payload.get("auth_source") or "direct")
if auth_source == "direct":
scope = ChatAccessScope.direct()
elif auth_source == "sso":
try:
client_id = int(payload.get("sso_client_id"))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用登录态无效") from exc
client = db.get(SsoClient, client_id)
if client is None or client.status != 1:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用已停用")
scope = ChatAccessScope(source_type="sso", source_client_id=client_id)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录来源无效")
return UserAuthContext(user=user, chat_scope=scope)
def get_current_user(context: UserAuthContext = Depends(get_current_user_context)) -> User:
return context.user
def get_current_admin( def get_current_admin(

View File

@@ -12,7 +12,12 @@ from fastapi import HTTPException, status
from app.core.config import get_settings from app.core.config import get_settings
def create_access_token(subject: str, token_type: str = "user") -> tuple[str, datetime]: def create_access_token(
subject: str,
token_type: str = "user",
*,
extra_claims: dict | None = None,
) -> tuple[str, datetime]:
settings = get_settings() settings = get_settings()
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes) expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = { payload = {
@@ -22,6 +27,11 @@ def create_access_token(subject: str, token_type: str = "user") -> tuple[str, da
"exp": expires_at, "exp": expires_at,
"iat": datetime.now(UTC), "iat": datetime.now(UTC),
} }
if extra_claims:
protected_claims = {"sub", "type", "jti", "exp", "iat"}
if protected_claims.intersection(extra_claims):
raise ValueError("extra_claims cannot override protected token claims")
payload.update(extra_claims)
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return token, expires_at return token, expires_at

View File

@@ -2,17 +2,32 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class ChatSession(Base, TimestampMixin): class ChatSession(Base, TimestampMixin):
__tablename__ = "sys_chat_session" __tablename__ = "sys_chat_session"
__table_args__ = (
Index(
"ix_chat_session_user_source_active_updated",
"user_id",
"source_type",
"source_client_id",
"is_deleted",
"updated_at",
),
)
id: Mapped[int] = mapped_column(BigInteger, 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)
source_type: Mapped[str] = mapped_column(String(20), default="direct", nullable=False)
source_client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
title: Mapped[str] = mapped_column(String(100), nullable=False) title: Mapped[str] = mapped_column(String(100), nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True) summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)

View File

@@ -20,6 +20,8 @@ class SsoClient(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
client_secret: Mapped[str] = mapped_column(Text, nullable=False) client_secret: Mapped[str] = mapped_column(Text, nullable=False)
redirect_uris: Mapped[str] = mapped_column(Text, default="[]", nullable=False) redirect_uris: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
allow_auto_register: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
default_entitlement_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
status: Mapped[int] = mapped_column(Integer, default=1, nullable=False) status: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -8,13 +8,18 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class User(Base, TimestampMixin, SoftDeleteMixin): class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "sys_user" __tablename__ = "sys_user"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(50), nullable=False)
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) nickname: Mapped[str | None] = mapped_column(String(50), nullable=True)
registration_source: Mapped[str] = mapped_column(String(30), default="direct", nullable=False)
registration_client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
status: Mapped[int] = mapped_column(default=1, nullable=False) status: Mapped[int] = mapped_column(default=1, nullable=False)
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False) daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False) daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
class SsoTicketRequest(BaseModel): class SsoTicketRequest(BaseModel):
@@ -20,6 +20,8 @@ class SsoClientSaveRequest(BaseModel):
appId: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]+$") appId: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]+$")
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
redirectUris: list[str] = Field(default_factory=list, max_length=20) redirectUris: list[str] = Field(default_factory=list, max_length=20)
allowAutoRegister: bool = False
defaultEntitlementPlanId: int | None = Field(default=None, gt=0)
status: int = Field(default=1, ge=0, le=1) status: int = Field(default=1, ge=0, le=1)
@field_validator("redirectUris") @field_validator("redirectUris")
@@ -36,10 +38,18 @@ class SsoClientSaveRequest(BaseModel):
cleaned.append(value) cleaned.append(value)
return cleaned return cleaned
@model_validator(mode="after")
def validate_auto_register_plan(self):
if self.allowAutoRegister and self.defaultEntitlementPlanId is None:
raise ValueError("允许自动注册时必须选择默认权益版本")
return self
class SsoClientUpdateRequest(BaseModel): class SsoClientUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
redirectUris: list[str] = Field(default_factory=list, max_length=20) redirectUris: list[str] = Field(default_factory=list, max_length=20)
allowAutoRegister: bool = False
defaultEntitlementPlanId: int | None = Field(default=None, gt=0)
status: int = Field(default=1, ge=0, le=1) status: int = Field(default=1, ge=0, le=1)
@field_validator("redirectUris") @field_validator("redirectUris")
@@ -47,6 +57,10 @@ class SsoClientUpdateRequest(BaseModel):
def validate_redirect_uris(cls, values: list[str]) -> list[str]: def validate_redirect_uris(cls, values: list[str]) -> list[str]:
return SsoClientSaveRequest.validate_redirect_uris(values) return SsoClientSaveRequest.validate_redirect_uris(values)
@model_validator(mode="after")
def validate_auto_register_plan(self):
return SsoClientSaveRequest.validate_auto_register_plan(self)
class SsoPublicConfigRequest(BaseModel): class SsoPublicConfigRequest(BaseModel):
userClientUrl: str = Field(default="", max_length=1000) userClientUrl: str = Field(default="", max_length=1000)
@@ -65,6 +79,9 @@ class SsoClientItem(BaseModel):
appId: str appId: str
name: str name: str
redirectUris: list[str] redirectUris: list[str]
allowAutoRegister: bool
defaultEntitlementPlanId: int | None = None
defaultEntitlementPlanName: str | None = None
status: int status: int
identityCount: int identityCount: int
lastUsedAt: datetime | None = None lastUsedAt: datetime | None = None

View File

@@ -73,7 +73,11 @@ class AuthService:
db.commit() db.commit()
db.refresh(user) db.refresh(user)
token, expired_at = create_access_token(str(user.id), "user") token, expired_at = create_access_token(
str(user.id),
"user",
extra_claims={"auth_source": "direct"},
)
return {"token": token, "expiredAt": expired_at, "user": user} return {"token": token, "expiredAt": expired_at, "user": user}
@classmethod @classmethod

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.user import User from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.ai_request_log_service import AiRequestLogService from app.services.ai_request_log_service import AiRequestLogService
from app.services.entitlement_service import EntitlementService, entitlement_prompt_context from app.services.entitlement_service import EntitlementService, entitlement_prompt_context
from app.services.external_errors import ExternalServiceError from app.services.external_errors import ExternalServiceError
@@ -23,10 +24,17 @@ from app.services.topic_session_service import TopicSessionService
class ChatService: class ChatService:
@staticmethod @staticmethod
def create_session(db: Session, user: User) -> ChatSession: def create_session(
db: Session,
user: User,
scope: ChatAccessScope | None = None,
) -> ChatSession:
scope = scope or ChatAccessScope.direct()
now = _now() now = _now()
session = ChatSession( session = ChatSession(
user_id=user.id, user_id=user.id,
source_type=scope.source_type,
source_client_id=scope.source_client_id,
title="新聊天", title="新聊天",
message_count=0, message_count=0,
last_message_at=now, last_message_at=now,
@@ -38,18 +46,32 @@ class ChatService:
return session return session
@staticmethod @staticmethod
def list_sessions(db: Session, user: User) -> list[ChatSession]: def list_sessions(
db: Session,
user: User,
scope: ChatAccessScope | None = None,
) -> list[ChatSession]:
scope = scope or ChatAccessScope.direct()
return list( return list(
db.scalars( db.scalars(
select(ChatSession) select(ChatSession)
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0) .where(
ChatSession.user_id == user.id,
ChatSession.is_deleted == 0,
*chat_scope_filters(scope),
)
.order_by(ChatSession.updated_at.desc()) .order_by(ChatSession.updated_at.desc())
) )
) )
@staticmethod @staticmethod
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]: def get_history(
session = ChatService._get_user_session(db, user, session_id) db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> list[ChatMessage]:
session = ChatService._get_user_session(db, user, session_id, scope)
return list( return list(
db.scalars( db.scalars(
select(ChatMessage) select(ChatMessage)
@@ -59,8 +81,14 @@ class ChatService:
) )
@staticmethod @staticmethod
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession: def update_title(
session = ChatService._get_user_session(db, user, session_id) db: Session,
user: User,
session_id: int,
title: str,
scope: ChatAccessScope | None = None,
) -> ChatSession:
session = ChatService._get_user_session(db, user, session_id, scope)
session.title = title.strip() session.title = title.strip()
db.add(session) db.add(session)
db.commit() db.commit()
@@ -68,16 +96,27 @@ class ChatService:
return session return session
@staticmethod @staticmethod
def delete_session(db: Session, user: User, session_id: int) -> None: def delete_session(
session = ChatService._get_user_session(db, user, session_id) db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> None:
session = ChatService._get_user_session(db, user, session_id, scope)
session.is_deleted = 1 session.is_deleted = 1
db.add(session) db.add(session)
db.commit() db.commit()
@staticmethod @staticmethod
def create_answer(db: Session, user: User, session_id: int, question: str) -> str: def create_answer(
db: Session,
user: User,
session_id: int,
question: str,
scope: ChatAccessScope | None = None,
) -> str:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user) ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement( entitlement = EntitlementService.active_entitlement(
db, db,
@@ -253,13 +292,31 @@ class ChatService:
return completion.answer return completion.answer
@staticmethod @staticmethod
def stop_generation(db: Session, user: User, session_id: int) -> None: def stop_generation(
ChatService._get_user_session(db, user, session_id) db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> None:
ChatService._get_user_session(db, user, session_id, scope)
@staticmethod @staticmethod
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession: def _get_user_session(
session = db.get(ChatSession, session_id) db: Session,
if session is None or session.user_id != user.id or session.is_deleted: user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> ChatSession:
scope = scope or ChatAccessScope.direct()
session = db.scalar(
select(ChatSession).where(
ChatSession.id == session_id,
ChatSession.user_id == user.id,
ChatSession.is_deleted == 0,
*chat_scope_filters(scope),
)
)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
return session return session
@@ -309,3 +366,15 @@ def _now() -> datetime:
def _title_from_question(question: str) -> str: def _title_from_question(question: str) -> str:
title = question.strip().replace("\n", " ") title = question.strip().replace("\n", " ")
return title[:20] if title else "新聊天" return title[:20] if title else "新聊天"
def chat_scope_filters(scope: ChatAccessScope) -> tuple:
if scope.source_type == "direct":
return (
ChatSession.source_type == "direct",
ChatSession.source_client_id.is_(None),
)
return (
ChatSession.source_type == "sso",
ChatSession.source_client_id == scope.source_client_id,
)

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, TopicSession from app.models.chat import ChatMessage, TopicSession
from app.models.knowledge import KnowledgeRetrievalLog from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.ai_request_log_service import AiRequestLogService from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService from app.services.chat_context_service import ChatContextService
@@ -29,9 +30,15 @@ from app.services.topic_session_service import TopicSessionService
class ChatStreamService: class ChatStreamService:
@staticmethod @staticmethod
def stream_answer(db: Session, user: User, session_id: int, question: str) -> Iterator[str]: def stream_answer(
db: Session,
user: User,
session_id: int,
question: str,
scope: ChatAccessScope | None = None,
) -> Iterator[str]:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user) ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement( entitlement = EntitlementService.active_entitlement(
db, db,
@@ -219,9 +226,10 @@ class ChatStreamService:
question: str, question: str,
*, *,
retry_failed_question: bool = False, retry_failed_question: bool = False,
scope: ChatAccessScope | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user) ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement( entitlement = EntitlementService.active_entitlement(
db, db,

View File

@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession from app.models.chat import ChatSession, TopicSession
from app.models.growth import TeacherHelpCard, TopicSummary from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.user import User from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.content_generation_config_service import ContentGenerationConfigService from app.services.content_generation_config_service import ContentGenerationConfigService
from app.services.chat_service import chat_scope_filters
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
@@ -46,19 +48,34 @@ class HelpCardService:
return card return card
@staticmethod @staticmethod
def list_user_cards(db: Session, *, user: User, limit: int = 20) -> list[TeacherHelpCard]: def list_user_cards(
db: Session,
*,
user: User,
scope: ChatAccessScope | None = None,
limit: int = 20,
) -> list[TeacherHelpCard]:
scope = scope or ChatAccessScope.direct()
return list( return list(
db.scalars( db.scalars(
select(TeacherHelpCard) select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user.id) .join(TopicSession, TopicSession.id == TeacherHelpCard.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(TeacherHelpCard.user_id == user.id, *chat_scope_filters(scope))
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc()) .order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit) .limit(limit)
) )
) )
@staticmethod @staticmethod
def mark_copied(db: Session, *, user: User, card_id: int) -> TeacherHelpCard: def mark_copied(
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id)) db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None = None,
) -> TeacherHelpCard:
card = _user_card(db, user=user, card_id=card_id, scope=scope)
if card is None: if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
card.copied = 1 card.copied = 1
@@ -69,8 +86,14 @@ class HelpCardService:
return card return card
@staticmethod @staticmethod
def delete(db: Session, *, user: User, card_id: int) -> None: def delete(
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id)) db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None = None,
) -> None:
card = _user_card(db, user=user, card_id=card_id, scope=scope)
if card is None: if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
db.delete(card) db.delete(card)
@@ -122,3 +145,23 @@ def _format_time(value: datetime | None) -> str:
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
def _user_card(
db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None,
) -> TeacherHelpCard | None:
scope = scope or ChatAccessScope.direct()
return db.scalar(
select(TeacherHelpCard)
.join(TopicSession, TopicSession.id == TeacherHelpCard.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(
TeacherHelpCard.id == card_id,
TeacherHelpCard.user_id == user.id,
*chat_scope_filters(scope),
)
)

View File

@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession from app.models.chat import ChatSession, TopicSession
from app.models.growth import ShareDraft, TopicSummary from app.models.growth import ShareDraft, TopicSummary
from app.models.user import User from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.content_generation_config_service import ContentGenerationConfigService from app.services.content_generation_config_service import ContentGenerationConfigService
from app.services.chat_service import chat_scope_filters
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
@@ -46,19 +48,34 @@ class ShareDraftService:
return draft return draft
@staticmethod @staticmethod
def list_user_drafts(db: Session, *, user: User, limit: int = 20) -> list[ShareDraft]: def list_user_drafts(
db: Session,
*,
user: User,
scope: ChatAccessScope | None = None,
limit: int = 20,
) -> list[ShareDraft]:
scope = scope or ChatAccessScope.direct()
return list( return list(
db.scalars( db.scalars(
select(ShareDraft) select(ShareDraft)
.where(ShareDraft.user_id == user.id) .join(TopicSession, TopicSession.id == ShareDraft.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(ShareDraft.user_id == user.id, *chat_scope_filters(scope))
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc()) .order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
.limit(limit) .limit(limit)
) )
) )
@staticmethod @staticmethod
def mark_copied(db: Session, *, user: User, draft_id: int) -> ShareDraft: def mark_copied(
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id)) db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None = None,
) -> ShareDraft:
draft = _user_draft(db, user=user, draft_id=draft_id, scope=scope)
if draft is None: if draft is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
draft.copied = 1 draft.copied = 1
@@ -69,8 +86,14 @@ class ShareDraftService:
return draft return draft
@staticmethod @staticmethod
def delete(db: Session, *, user: User, draft_id: int) -> None: def delete(
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id)) db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None = None,
) -> None:
draft = _user_draft(db, user=user, draft_id=draft_id, scope=scope)
if draft is None: if draft is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
db.delete(draft) db.delete(draft)
@@ -113,3 +136,23 @@ def _share_draft_values(*, topic: TopicSession, summary: TopicSummary) -> dict[s
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
def _user_draft(
db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None,
) -> ShareDraft | None:
scope = scope or ChatAccessScope.direct()
return db.scalar(
select(ShareDraft)
.join(TopicSession, TopicSession.id == ShareDraft.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(
ShareDraft.id == draft_id,
ShareDraft.user_id == user.id,
*chat_scope_filters(scope),
)
)

View File

@@ -10,14 +10,17 @@ from urllib.parse import quote
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.security import create_access_token from app.core.security import create_access_token
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User from app.models.user import User
from app.schemas.sso import SsoTicketRequest from app.schemas.sso import SsoTicketRequest
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.entitlement_service import EntitlementService
from app.services.redis_client import get_sync_redis_client from app.services.redis_client import get_sync_redis_client
from app.services.security_state_service import SecurityStateService from app.services.security_state_service import SecurityStateService
from app.services.secret_service import SecretService from app.services.secret_service import SecretService
@@ -166,7 +169,11 @@ class SsoService:
) )
db.commit() db.commit()
db.refresh(user) db.refresh(user)
token, expired_at = create_access_token(str(user.id), "user") token, expired_at = create_access_token(
str(user.id),
"user",
extra_claims={"auth_source": "sso", "sso_client_id": client.id},
)
return { return {
"token": token, "token": token,
"expiredAt": expired_at, "expiredAt": expired_at,
@@ -271,11 +278,47 @@ class SsoService:
if not payload.verifiedPhone: if not payload.verifiedPhone:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
user = db.scalar( phone = payload.verifiedPhone.strip()
select(User).where(User.phone == payload.verifiedPhone, User.is_deleted == 0) user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0).with_for_update())
) created = False
if user is None: if user is None:
if not client.allow_auto_register:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
display_name = (payload.displayName or "").strip()
if not display_name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="首次自动注册必须提供姓名")
plan = db.get(EntitlementPlan, client.default_entitlement_plan_id)
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="接入应用未配置可用的默认权益")
candidate = User(
phone=phone,
name=display_name[:50],
nickname=display_name[:50],
registration_source="sso",
registration_client_id=client.id,
status=1,
daily_chat_limit=_default_daily_limit(db),
daily_chat_used=0,
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
user = candidate
created = True
except IntegrityError:
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
if user is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="学员账号创建冲突,请重试")
if created:
EntitlementService.assign_user_plan(
db,
user=user,
plan_id=plan.id,
operated_by=None,
remark=f"接入应用 {client.name} 自动注册分配",
)
existing_user_binding = db.scalar( existing_user_binding = db.scalar(
select(UserExternalIdentity).where( select(UserExternalIdentity).where(
UserExternalIdentity.client_id == client.id, UserExternalIdentity.client_id == client.id,
@@ -385,3 +428,18 @@ def _int_value(value) -> int:
return int(value) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 return 0
def _default_daily_limit(db: Session) -> int:
value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "daily_chat_limit"))
if value and value.strip():
try:
return max(0, min(100000, int(float(value.strip()))))
except ValueError:
pass
try:
from app.core.config import get_settings
return get_settings().default_daily_chat_limit
except Exception:
return 100

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from fastapi import HTTPException
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.core.auth_context import ChatAccessScope
from app.core.dependencies import get_current_user_context
from app.models import Base
from app.models.sso import SsoClient
from app.models.user import User
from app.services.chat_service import ChatService
def _db() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
return Session(engine)
def test_chat_sessions_are_isolated_by_login_source_and_application():
with _db() as db:
user = User(
phone="13800138000",
name="测试学员",
status=1,
daily_chat_limit=100,
daily_chat_used=0,
is_deleted=0,
)
db.add(user)
db.commit()
db.refresh(user)
direct = ChatService.create_session(db, user)
app_a_scope = ChatAccessScope(source_type="sso", source_client_id=101)
app_b_scope = ChatAccessScope(source_type="sso", source_client_id=202)
app_a = ChatService.create_session(db, user, app_a_scope)
app_b = ChatService.create_session(db, user, app_b_scope)
assert [item.id for item in ChatService.list_sessions(db, user)] == [direct.id]
assert [item.id for item in ChatService.list_sessions(db, user, app_a_scope)] == [app_a.id]
assert [item.id for item in ChatService.list_sessions(db, user, app_b_scope)] == [app_b.id]
with pytest.raises(HTTPException) as cross_source:
ChatService.get_history(db, user, app_a.id)
assert cross_source.value.status_code == 404
with pytest.raises(HTTPException) as cross_application:
ChatService.get_history(db, user, app_b.id, app_a_scope)
assert cross_application.value.status_code == 404
def test_auth_context_defaults_old_tokens_to_direct_and_rejects_disabled_sso_client():
with _db() as db:
user = User(
phone="13700137000",
name="登录来源验收",
status=1,
daily_chat_limit=100,
daily_chat_used=0,
is_deleted=0,
)
client = SsoClient(
app_id="context-test-app",
name="鉴权验收应用",
client_secret="encrypted-placeholder",
redirect_uris="[]",
status=1,
)
db.add_all([user, client])
db.commit()
direct = get_current_user_context({"sub": str(user.id), "type": "user"}, db)
assert direct.chat_scope == ChatAccessScope.direct()
sso = get_current_user_context(
{
"sub": str(user.id),
"type": "user",
"auth_source": "sso",
"sso_client_id": client.id,
},
db,
)
assert sso.chat_scope == ChatAccessScope(source_type="sso", source_client_id=client.id)
client.status = 0
db.commit()
with pytest.raises(HTTPException) as disabled:
get_current_user_context(
{
"sub": str(user.id),
"type": "user",
"auth_source": "sso",
"sso_client_id": client.id,
},
db,
)
assert disabled.value.status_code == 401

View File

@@ -15,6 +15,8 @@ from sqlalchemy.pool import StaticPool
from app.models import Base from app.models import Base
from app.core.config import get_settings from app.core.config import get_settings
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.core.security import decode_access_token
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User from app.models.user import User
from app.schemas.sso import SsoTicketRequest from app.schemas.sso import SsoTicketRequest
@@ -211,3 +213,59 @@ def test_sso_first_login_requires_verified_existing_phone(monkeypatch: pytest.Mo
audit = db.scalar(select(SsoLoginAudit).order_by(SsoLoginAudit.id.desc())) audit = db.scalar(select(SsoLoginAudit).order_by(SsoLoginAudit.id.desc()))
assert audit is not None assert audit is not None
assert audit.status == "FAILED" assert audit.status == "FAILED"
def test_sso_can_auto_register_and_assign_configured_entitlement(monkeypatch: pytest.MonkeyPatch):
redis = FakeRedis()
monkeypatch.setattr("app.services.sso_service.get_sync_redis_client", lambda: redis)
monkeypatch.setattr(get_settings(), "jwt_secret_key", "test-sso-jwt-secret-key-32-bytes-long")
with _db() as db:
_user, client, secret = _seed(db)
plan = EntitlementPlan(
name="第三方默认权益",
plan_type="basic",
validity_days=90,
status=1,
sort_order=1,
)
db.add(plan)
db.flush()
client.allow_auto_register = 1
client.default_entitlement_plan_id = plan.id
db.commit()
raw_body = json.dumps(
{
"externalUserId": "external-new-user",
"verifiedPhone": "13900139000",
"displayName": "新接入学员",
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
timestamp = str(int(time.time()))
nonce = "auto-register-nonce-123"
ticket = SsoService.issue_ticket(
db,
app_id=client.app_id,
timestamp=timestamp,
nonce=nonce,
signature=_signature(secret, timestamp, nonce, raw_body),
raw_body=raw_body,
payload=SsoTicketRequest.model_validate_json(raw_body),
ip="127.0.0.1",
)
user = db.scalar(select(User).where(User.phone == "13900139000"))
assert user is not None
assert user.name == "新接入学员"
assert user.registration_source == "sso"
assert user.registration_client_id == client.id
entitlement = db.scalar(select(UserEntitlement).where(UserEntitlement.user_id == user.id))
assert entitlement is not None
assert entitlement.plan_id == plan.id
login = SsoService.exchange(db, code=ticket["code"], ip="127.0.0.1")
claims = decode_access_token(login["token"])
assert claims["auth_source"] == "sso"
assert claims["sso_client_id"] == client.id

View File

@@ -14,6 +14,7 @@
2. 新增接入应用; 2. 新增接入应用;
3. 保存系统只展示一次的应用密钥; 3. 保存系统只展示一次的应用密钥;
4. 如需学员从千问千答返回来源应用,配置完整的回跳地址白名单。 4. 如需学员从千问千答返回来源应用,配置完整的回跳地址白名单。
5. 选择是否允许自动注册新学员;开启时必须同时选择默认权益版本。
首次免登录时,接入应用必须提供学员在本应用内的稳定用户 ID 和已经由本应用验证过的手机号。手机号仅用于首次匹配千问千答学员,后续以账号绑定为准。 首次免登录时,接入应用必须提供学员在本应用内的稳定用户 ID 和已经由本应用验证过的手机号。手机号仅用于首次匹配千问千答学员,后续以账号绑定为准。
@@ -93,13 +94,22 @@ signature = hmac.new(APP_SECRET.encode(), canonical, hashlib.sha256).hexdigest()
- 已经绑定:按“应用 ID + 外部用户 ID”直接找到千问千答学员 - 已经绑定:按“应用 ID + 外部用户 ID”直接找到千问千答学员
- 首次进入:使用已验证手机号匹配现有学员并建立绑定; - 首次进入:使用已验证手机号匹配现有学员并建立绑定;
- 手机号不在学员名单:拒绝进入,不自动创建无权益账号 - 手机号不在学员名单且应用未开启自动注册:拒绝进入;
- 手机号不在学员名单且应用已开启自动注册:必须同时提供姓名,系统创建学员、分配该应用的默认权益,再建立外部账号绑定;
- 学员已禁用、尚未生效或权益已过期:拒绝进入; - 学员已禁用、尚未生效或权益已过期:拒绝进入;
- 同一应用中的一个千问千答学员只能绑定一个外部用户 ID。 - 同一应用中的一个千问千答学员只能绑定一个外部用户 ID。
绑定错误时,管理员可以在“应用接入 → 账号绑定”中解除绑定,学员下次进入时重新核对手机号。 绑定错误时,管理员可以在“应用接入 → 账号绑定”中解除绑定,学员下次进入时重新核对手机号。
## 5. 安全规则 ## 5. 账号与会话数据规则
- 同一手机号只对应一个千问千答学员账号,不会因接入多个应用重复注册;
- 学员的会话、消息、AI 请求和衍生的求助卡/分享稿仍保存在千问千答服务器;
- 会话按登录来源强制隔离:直接登录只能看到直接会话,从应用 A 进入只能看到应用 A 的会话,不能访问应用 B 或直接登录会话;
- 隔离规则由后端根据签名登录态强制执行,不依赖前端传入来源参数;
- 管理员可在“记录审计”按直接访问、第三方应用和具体应用筛选会话。
## 6. 安全规则
- 所有生产接口必须使用 HTTPS - 所有生产接口必须使用 HTTPS
- 应用密钥只保存在接入应用服务端; - 应用密钥只保存在接入应用服务端;
@@ -111,10 +121,12 @@ signature = hmac.new(APP_SECRET.encode(), canonical, hashlib.sha256).hexdigest()
- 停用接入应用后,该应用不能继续申请或兑换登录态; - 停用接入应用后,该应用不能继续申请或兑换登录态;
- 登录成功和失败都会写入登录审计。 - 登录成功和失败都会写入登录审计。
## 6. 验收清单 ## 7. 验收清单
- 已登录来源应用的学员点击后直接进入千问千答; - 已登录来源应用的学员点击后直接进入千问千答;
- 首次进入能按已验证手机号正确绑定; - 首次进入能按已验证手机号正确绑定;
- 开启自动注册时,新手机号能创建学员并获得指定的默认权益;
- 未开启自动注册时,新手机号仍被拒绝;
- 后续进入不再依赖手机号; - 后续进入不再依赖手机号;
- 同一个授权码第二次兑换失败; - 同一个授权码第二次兑换失败;
- 错误签名、过期时间戳、重复随机数均被拒绝; - 错误签名、过期时间戳、重复随机数均被拒绝;
@@ -122,3 +134,4 @@ signature = hmac.new(APP_SECRET.encode(), canonical, hashlib.sha256).hexdigest()
- 禁用或过期学员不能进入; - 禁用或过期学员不能进入;
- 停用应用和更新密钥立即生效; - 停用应用和更新密钥立即生效;
- 手机号验证码登录仍可正常使用。 - 手机号验证码登录仍可正常使用。
- 直接登录、应用 A 和应用 B 各自只能查看、修改和删除本来源会话。