feat: isolate external application conversations
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
AiLogRecord,
|
||||
ChatRecord,
|
||||
QuestionInsightSummary,
|
||||
SsoClientItem,
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
|
||||
@@ -15,6 +16,7 @@ type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
|
||||
const loading = ref(false);
|
||||
const activeTab = ref<RecordTab>("chats");
|
||||
const chats = ref<ChatRecord[]>([]);
|
||||
const ssoClients = ref<SsoClientItem[]>([]);
|
||||
const aiLogs = ref<AiLogRecord[]>([]);
|
||||
const operationLogs = ref<Record<string, unknown>[]>([]);
|
||||
const insights = ref<QuestionInsightSummary | null>(null);
|
||||
@@ -32,6 +34,8 @@ const chatFilters = reactive({
|
||||
keyword: "",
|
||||
userId: undefined as number | undefined,
|
||||
status: "",
|
||||
sourceType: "",
|
||||
sourceClientId: undefined as number | undefined,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
@@ -42,7 +46,9 @@ const insightFilters = reactive({
|
||||
maxMessages: 5000,
|
||||
});
|
||||
|
||||
onMounted(() => loadTab("chats"));
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTab("chats"), loadSsoClients()]);
|
||||
});
|
||||
watch(activeTab, (tab) => loadTab(tab));
|
||||
|
||||
async function loadTab(tab: RecordTab = activeTab.value) {
|
||||
@@ -91,6 +97,13 @@ async function loadTab(tab: RecordTab = activeTab.value) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function loadSsoClients() {
|
||||
try {
|
||||
ssoClients.value = await api.ssoClients();
|
||||
} catch {
|
||||
ssoClients.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(tab: RecordTab, page: number, pageSize: number) {
|
||||
Object.assign(pagers[tab], { page, pageSize });
|
||||
@@ -123,6 +136,8 @@ async function resetChatFilters() {
|
||||
keyword: "",
|
||||
userId: undefined,
|
||||
status: "",
|
||||
sourceType: "",
|
||||
sourceClientId: undefined,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
@@ -173,6 +188,8 @@ function buildChatQuery() {
|
||||
keyword: chatFilters.keyword,
|
||||
userId: chatFilters.userId,
|
||||
status: chatFilters.status,
|
||||
sourceType: chatFilters.sourceType,
|
||||
sourceClientId: chatFilters.sourceClientId,
|
||||
dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
|
||||
dateTo: formatDateTime(chatFilters.dateTo, "end"),
|
||||
};
|
||||
@@ -220,6 +237,13 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
label="已停止"
|
||||
value="STOPPED" /><el-option label="失败" value="FAILED"
|
||||
/></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="时间范围">
|
||||
<label class="record-date-field"
|
||||
><span>开始时间</span
|
||||
@@ -257,6 +281,11 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
prop="userName"
|
||||
label="用户"
|
||||
width="120"
|
||||
/><el-table-column
|
||||
prop="sourceName"
|
||||
label="会话来源"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column
|
||||
prop="title"
|
||||
label="会话标题"
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
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";
|
||||
|
||||
const loading = ref(false);
|
||||
const savingUrl = ref(false);
|
||||
const clients = ref<SsoClientItem[]>([]);
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
const userClientUrl = ref("");
|
||||
const activeTab = ref("applications");
|
||||
const clientDialogOpen = ref(false);
|
||||
@@ -35,6 +36,8 @@ const clientForm = reactive({
|
||||
appId: "",
|
||||
name: "",
|
||||
redirectUrisText: "",
|
||||
allowAutoRegister: false,
|
||||
defaultEntitlementPlanId: undefined as number | undefined,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
@@ -45,9 +48,14 @@ onMounted(loadOverview);
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
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;
|
||||
clients.value = clientList;
|
||||
entitlementPlans.value = plans;
|
||||
} catch (error) {
|
||||
showError(error, "应用接入配置加载失败");
|
||||
} finally {
|
||||
@@ -70,7 +78,14 @@ async function savePublicUrl() {
|
||||
|
||||
function openCreateDialog() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -80,6 +95,8 @@ function openEditDialog(client: SsoClientItem) {
|
||||
appId: client.appId,
|
||||
name: client.name,
|
||||
redirectUrisText: client.redirectUris.join("\n"),
|
||||
allowAutoRegister: client.allowAutoRegister,
|
||||
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? undefined,
|
||||
status: client.status,
|
||||
});
|
||||
clientDialogOpen.value = true;
|
||||
@@ -92,16 +109,33 @@ async function saveClient() {
|
||||
ElMessage.warning("请填写应用ID和应用名称");
|
||||
return;
|
||||
}
|
||||
if (clientForm.allowAutoRegister && !clientForm.defaultEntitlementPlanId) {
|
||||
ElMessage.warning("允许自动注册时请选择默认权益版本");
|
||||
return;
|
||||
}
|
||||
const redirectUris = clientForm.redirectUrisText
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
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("接入应用已更新");
|
||||
} 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 || "";
|
||||
revealedAppId.value = result.appId;
|
||||
secretDialogOpen.value = true;
|
||||
@@ -119,6 +153,8 @@ async function toggleClient(client: SsoClientItem) {
|
||||
await api.updateSsoClient(client.id, {
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
allowAutoRegister: client.allowAutoRegister,
|
||||
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? null,
|
||||
status: targetStatus,
|
||||
});
|
||||
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
|
||||
@@ -272,6 +308,12 @@ function showError(error: unknown, fallback: string) {
|
||||
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template #default="{ row }">{{ formatTime(row.lastUsedAt) }}</template>
|
||||
</el-table-column>
|
||||
@@ -353,6 +395,18 @@ function showError(error: unknown, fallback: string) {
|
||||
<el-form-item label="允许回跳地址">
|
||||
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" />
|
||||
</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>
|
||||
<template #footer>
|
||||
@@ -384,6 +438,8 @@ function showError(error: unknown, fallback: string) {
|
||||
.sso-filters .el-select { width: 190px; }
|
||||
.sso-filters .el-input { max-width: 360px; }
|
||||
.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 label { color: #6c7d79; }
|
||||
.sso-secret-block code { padding: 10px 12px; overflow-wrap: anywhere; border-radius: 8px; background: #f3f7f5; color: #173d34; }
|
||||
|
||||
@@ -369,6 +369,12 @@ async function deleteUser(row: AdminUser) {
|
||||
width="150"
|
||||
/><el-table-column prop="name" 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"
|
||||
><template #default="{ row }"
|
||||
><el-switch
|
||||
|
||||
@@ -273,9 +273,9 @@ export const api = {
|
||||
body: JSON.stringify({ userClientUrl }),
|
||||
}),
|
||||
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) }),
|
||||
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) }),
|
||||
rotateSsoClientSecret: (id: number) =>
|
||||
request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, {
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface SsoClientItem {
|
||||
appId: string;
|
||||
name: string;
|
||||
redirectUris: string[];
|
||||
allowAutoRegister: boolean;
|
||||
defaultEntitlementPlanId?: number | null;
|
||||
defaultEntitlementPlanName?: string | null;
|
||||
status: number;
|
||||
identityCount: number;
|
||||
lastUsedAt?: string | null;
|
||||
@@ -143,6 +146,8 @@ export interface AdminUser {
|
||||
phone: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
registrationSource: "direct" | "sso" | string;
|
||||
registrationClientId?: number | null;
|
||||
status: number;
|
||||
dailyChatLimit: number;
|
||||
dailyChatUsed: number;
|
||||
@@ -484,6 +489,9 @@ export interface ChatRecord {
|
||||
userId: number;
|
||||
userPhone: string;
|
||||
userName: string;
|
||||
sourceType: "direct" | "sso";
|
||||
sourceClientId?: number | null;
|
||||
sourceName: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
lastMessageAt?: string | null;
|
||||
@@ -715,6 +723,8 @@ export interface ChatRecordQuery {
|
||||
keyword?: string;
|
||||
userId?: number | null;
|
||||
status?: string;
|
||||
sourceType?: string;
|
||||
sourceClientId?: number | null;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -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")
|
||||
@@ -18,6 +18,7 @@ from app.models.admin import Admin
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
|
||||
from app.models.logs import AiRequestLog, OperationLog
|
||||
from app.models.sso import SsoClient
|
||||
from app.models.user import User
|
||||
from app.api.pagination import page_result
|
||||
from app.services.admin_service import OperationLogService
|
||||
@@ -34,6 +35,8 @@ def chat_list(
|
||||
keyword: str = Query(default=""),
|
||||
userId: int | None = Query(default=None),
|
||||
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),
|
||||
dateTo: datetime | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
@@ -41,10 +44,19 @@ def chat_list(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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
|
||||
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")
|
||||
@@ -52,26 +64,37 @@ def export_chats(
|
||||
keyword: str = Query(default=""),
|
||||
userId: int | None = Query(default=None),
|
||||
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),
|
||||
dateTo: datetime | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> Response:
|
||||
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())
|
||||
.limit(1000)
|
||||
).all()
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
|
||||
for session, user in rows:
|
||||
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "来源", "标题", "消息数", "最后消息时间", "更新时间"])
|
||||
for session, user, source_client in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
session.id,
|
||||
session.user_id,
|
||||
user.phone if user else "",
|
||||
user.name if user else "",
|
||||
source_client.name if source_client else "千问千答直接访问",
|
||||
session.title,
|
||||
session.message_count,
|
||||
session.last_message_at,
|
||||
@@ -117,8 +140,9 @@ def chat_detail(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
session_row = db.execute(
|
||||
select(ChatSession, User)
|
||||
select(ChatSession, User, SsoClient)
|
||||
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
|
||||
.where(ChatSession.id == session_id)
|
||||
).first()
|
||||
if session_row is None:
|
||||
@@ -126,7 +150,7 @@ def chat_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_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
|
||||
messages = db.scalars(
|
||||
@@ -161,7 +185,7 @@ def chat_detail(
|
||||
)
|
||||
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],
|
||||
"messagesPage": page_result(
|
||||
[_message_dict(item) for item in messages],
|
||||
@@ -305,16 +329,23 @@ def _chat_query(
|
||||
keyword: str,
|
||||
user_id: int | None,
|
||||
status: str,
|
||||
source_type: str,
|
||||
source_client_id: int | None,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
):
|
||||
query = (
|
||||
select(ChatSession, User)
|
||||
select(ChatSession, User, SsoClient)
|
||||
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
|
||||
.where(ChatSession.is_deleted == 0)
|
||||
)
|
||||
if user_id is not None:
|
||||
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:
|
||||
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
|
||||
if date_to is not None:
|
||||
@@ -340,12 +371,15 @@ def _chat_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 {
|
||||
"id": session.id,
|
||||
"userId": session.user_id,
|
||||
"userPhone": user.phone 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,
|
||||
"messageCount": session.message_count,
|
||||
"lastMessageAt": session.last_message_at,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.entitlement import EntitlementPlan
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
|
||||
@@ -80,11 +81,12 @@ def list_sso_clients(
|
||||
.subquery()
|
||||
)
|
||||
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(EntitlementPlan, EntitlementPlan.id == SsoClient.default_entitlement_plan_id)
|
||||
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
|
||||
).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")
|
||||
@@ -96,12 +98,15 @@ def create_sso_client(
|
||||
app_id = payload.appId.strip()
|
||||
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已存在")
|
||||
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
|
||||
plaintext_secret = secrets.token_urlsafe(32)
|
||||
client = SsoClient(
|
||||
app_id=app_id,
|
||||
name=payload.name.strip(),
|
||||
client_secret=SecretService.encrypt(plaintext_secret),
|
||||
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,
|
||||
created_by=current_admin.id,
|
||||
)
|
||||
@@ -127,8 +132,11 @@ def update_sso_client(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
client = _require_client(db, client_id)
|
||||
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
|
||||
client.name = payload.name.strip()
|
||||
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
|
||||
OperationLogService.write(
|
||||
db,
|
||||
@@ -141,7 +149,8 @@ def update_sso_client(
|
||||
identity_count = db.scalar(
|
||||
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
|
||||
) 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")
|
||||
@@ -260,12 +269,15 @@ def _require_client(db: Session, client_id: int) -> SsoClient:
|
||||
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 {
|
||||
"id": client.id,
|
||||
"appId": client.app_id,
|
||||
"name": client.name,
|
||||
"redirectUris": _json_list(client.redirect_uris),
|
||||
"allowAutoRegister": bool(client.allow_auto_register),
|
||||
"defaultEntitlementPlanId": client.default_entitlement_plan_id,
|
||||
"defaultEntitlementPlanName": plan_name,
|
||||
"status": client.status,
|
||||
"identityCount": identity_count,
|
||||
"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:
|
||||
return {
|
||||
"id": identity.id,
|
||||
|
||||
@@ -490,6 +490,8 @@ def _user_dict(user: User, entitlement: dict | None = None) -> dict:
|
||||
"phone": user.phone,
|
||||
"name": user.name,
|
||||
"nickname": user.nickname,
|
||||
"registrationSource": user.registration_source,
|
||||
"registrationClientId": user.registration_client_id,
|
||||
"status": user.status,
|
||||
"dailyChatLimit": user.daily_chat_limit,
|
||||
"dailyChatUsed": user.daily_chat_used,
|
||||
|
||||
@@ -7,12 +7,15 @@ from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.models.user import User
|
||||
from app.models.chat import TopicSession
|
||||
from app.models.growth import TopicSummary
|
||||
from app.schemas.chat import (
|
||||
ChatCompletionRequest,
|
||||
ChatMessageRead,
|
||||
@@ -40,14 +43,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||
session = ChatService.create_session(db, current_user)
|
||||
def create_session(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
|
||||
session = ChatService.create_session(db, current.user, current.chat_scope)
|
||||
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
|
||||
|
||||
|
||||
@router.get("/session/list")
|
||||
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||
sessions = ChatService.list_sessions(db, current_user)
|
||||
def list_sessions(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
|
||||
sessions = ChatService.list_sessions(db, current.user, current.chat_scope)
|
||||
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(
|
||||
sessionId: int = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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)
|
||||
result = []
|
||||
for message in messages:
|
||||
@@ -72,9 +75,9 @@ def history(
|
||||
def update_title(
|
||||
payload: UpdateSessionTitleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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())
|
||||
|
||||
|
||||
@@ -82,9 +85,9 @@ def update_title(
|
||||
def delete_session(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> dict:
|
||||
ChatService.delete_session(db, current_user, session_id)
|
||||
ChatService.delete_session(db, current.user, session_id, current.chat_scope)
|
||||
return api_success()
|
||||
|
||||
|
||||
@@ -92,29 +95,37 @@ def delete_session(
|
||||
def finish_topic(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> dict:
|
||||
session = ChatService._get_user_session(db, current_user, session_id)
|
||||
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
|
||||
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))
|
||||
|
||||
|
||||
@router.get("/topic/settlement/{summary_id}")
|
||||
def topic_settlement(
|
||||
summary_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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")
|
||||
def generate_help_card(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> dict:
|
||||
session = ChatService._get_user_session(db, current_user, session_id)
|
||||
card = HelpCardService.generate_for_session(db, user=current_user, session=session)
|
||||
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
|
||||
card = HelpCardService.generate_for_session(db, user=current.user, session=session)
|
||||
return api_success(help_card_dict(card))
|
||||
|
||||
|
||||
@@ -122,27 +133,33 @@ def generate_help_card(
|
||||
def list_help_cards(
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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")
|
||||
def mark_help_card_copied(
|
||||
card_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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}")
|
||||
def delete_help_card(
|
||||
card_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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()
|
||||
|
||||
|
||||
@@ -150,10 +167,10 @@ def delete_help_card(
|
||||
def generate_share_draft(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> dict:
|
||||
session = ChatService._get_user_session(db, current_user, session_id)
|
||||
draft = ShareDraftService.generate_for_session(db, user=current_user, session=session)
|
||||
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
|
||||
draft = ShareDraftService.generate_for_session(db, user=current.user, session=session)
|
||||
return api_success(share_draft_dict(draft))
|
||||
|
||||
|
||||
@@ -161,27 +178,33 @@ def generate_share_draft(
|
||||
def list_share_drafts(
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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")
|
||||
def mark_share_draft_copied(
|
||||
draft_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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}")
|
||||
def delete_share_draft(
|
||||
draft_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> 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()
|
||||
|
||||
|
||||
@@ -189,10 +212,10 @@ def delete_share_draft(
|
||||
def completions(
|
||||
payload: ChatCompletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_chat_stream(payload, db, current_user),
|
||||
_chat_stream(payload, db, current),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
@@ -206,13 +229,17 @@ def completions(
|
||||
def stop(
|
||||
payload: StopChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current: UserAuthContext = Depends(get_current_user_context),
|
||||
) -> dict:
|
||||
ChatService.stop_generation(db, current_user, payload.sessionId)
|
||||
ChatService.stop_generation(db, current.user, payload.sessionId, current.chat_scope)
|
||||
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)
|
||||
queue_lease = await request_chat_slot(config)
|
||||
queue_request = queue_lease.request
|
||||
@@ -274,6 +301,7 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
|
||||
payload.sessionId,
|
||||
payload.message,
|
||||
retry_failed_question=payload.retry,
|
||||
scope=chat_scope,
|
||||
)
|
||||
async for segment in ReasoningPolicyService.iter_segments(chunks):
|
||||
if segment.kind == "content":
|
||||
|
||||
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal file
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal 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
|
||||
@@ -7,8 +7,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.models.admin import Admin
|
||||
from app.models.sso import SsoClient
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
@@ -22,10 +24,10 @@ def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depend
|
||||
return payload
|
||||
|
||||
|
||||
def get_current_user(
|
||||
def get_current_user_context(
|
||||
payload: dict = Depends(get_current_token_payload),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
) -> UserAuthContext:
|
||||
if payload.get("type") != "user":
|
||||
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="账号尚未生效")
|
||||
if user.expired_at and user.expired_at < now:
|
||||
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(
|
||||
|
||||
@@ -12,7 +12,12 @@ from fastapi import HTTPException, status
|
||||
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()
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
@@ -22,6 +27,11 @@ def create_access_token(subject: str, token_type: str = "user") -> tuple[str, da
|
||||
"exp": expires_at,
|
||||
"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)
|
||||
return token, expires_at
|
||||
|
||||
|
||||
@@ -2,17 +2,32 @@ from __future__ import annotations
|
||||
|
||||
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 app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class ChatSession(Base, TimestampMixin):
|
||||
__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)
|
||||
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)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
@@ -20,6 +20,8 @@ class SsoClient(Base):
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
client_secret: Mapped[str] = mapped_column(Text, 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)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@@ -8,13 +8,18 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__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)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
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)
|
||||
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
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._-]+$")
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
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)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@@ -36,10 +38,18 @@ class SsoClientSaveRequest(BaseModel):
|
||||
cleaned.append(value)
|
||||
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):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
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)
|
||||
|
||||
@field_validator("redirectUris")
|
||||
@@ -47,6 +57,10 @@ class SsoClientUpdateRequest(BaseModel):
|
||||
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
|
||||
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):
|
||||
userClientUrl: str = Field(default="", max_length=1000)
|
||||
@@ -65,6 +79,9 @@ class SsoClientItem(BaseModel):
|
||||
appId: str
|
||||
name: str
|
||||
redirectUris: list[str]
|
||||
allowAutoRegister: bool
|
||||
defaultEntitlementPlanId: int | None = None
|
||||
defaultEntitlementPlanName: str | None = None
|
||||
status: int
|
||||
identityCount: int
|
||||
lastUsedAt: datetime | None = None
|
||||
|
||||
@@ -73,7 +73,11 @@ class AuthService:
|
||||
db.commit()
|
||||
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}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
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.entitlement_service import EntitlementService, entitlement_prompt_context
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
@@ -23,10 +24,17 @@ from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
class ChatService:
|
||||
@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()
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
source_type=scope.source_type,
|
||||
source_client_id=scope.source_client_id,
|
||||
title="新聊天",
|
||||
message_count=0,
|
||||
last_message_at=now,
|
||||
@@ -38,18 +46,32 @@ class ChatService:
|
||||
return session
|
||||
|
||||
@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(
|
||||
db.scalars(
|
||||
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())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
def get_history(
|
||||
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(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
@@ -59,8 +81,14 @@ class ChatService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
def update_title(
|
||||
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()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
@@ -68,16 +96,27 @@ class ChatService:
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def delete_session(db: Session, user: User, session_id: int) -> None:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
def delete_session(
|
||||
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
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
@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)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session = ChatService._get_user_session(db, user, session_id, scope)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
@@ -253,13 +292,31 @@ class ChatService:
|
||||
return completion.answer
|
||||
|
||||
@staticmethod
|
||||
def stop_generation(db: Session, user: User, session_id: int) -> None:
|
||||
ChatService._get_user_session(db, user, session_id)
|
||||
def stop_generation(
|
||||
db: Session,
|
||||
user: User,
|
||||
session_id: int,
|
||||
scope: ChatAccessScope | None = None,
|
||||
) -> None:
|
||||
ChatService._get_user_session(db, user, session_id, scope)
|
||||
|
||||
@staticmethod
|
||||
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession:
|
||||
session = db.get(ChatSession, session_id)
|
||||
if session is None or session.user_id != user.id or session.is_deleted:
|
||||
def _get_user_session(
|
||||
db: Session,
|
||||
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="会话不存在")
|
||||
return session
|
||||
|
||||
@@ -309,3 +366,15 @@ def _now() -> datetime:
|
||||
def _title_from_question(question: str) -> str:
|
||||
title = question.strip().replace("\n", " ")
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app.models.chat import ChatMessage, TopicSession
|
||||
from app.models.knowledge import KnowledgeRetrievalLog
|
||||
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.chat_service import ChatService, _title_from_question
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
@@ -29,9 +30,15 @@ from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
class ChatStreamService:
|
||||
@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)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session = ChatService._get_user_session(db, user, session_id, scope)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
@@ -219,9 +226,10 @@ class ChatStreamService:
|
||||
question: str,
|
||||
*,
|
||||
retry_failed_question: bool = False,
|
||||
scope: ChatAccessScope | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
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)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
|
||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
||||
from app.models.chat import ChatSession, TopicSession
|
||||
from app.models.growth import TeacherHelpCard, TopicSummary
|
||||
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.chat_service import chat_scope_filters
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
|
||||
|
||||
@@ -46,19 +48,34 @@ class HelpCardService:
|
||||
return card
|
||||
|
||||
@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(
|
||||
db.scalars(
|
||||
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())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_copied(db: Session, *, user: User, card_id: int) -> TeacherHelpCard:
|
||||
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
|
||||
def mark_copied(
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
|
||||
card.copied = 1
|
||||
@@ -69,8 +86,14 @@ class HelpCardService:
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, *, user: User, card_id: int) -> None:
|
||||
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
|
||||
def delete(
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
|
||||
db.delete(card)
|
||||
@@ -122,3 +145,23 @@ def _format_time(value: datetime | None) -> str:
|
||||
|
||||
def _now() -> datetime:
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
||||
from app.models.chat import ChatSession, TopicSession
|
||||
from app.models.growth import ShareDraft, TopicSummary
|
||||
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.chat_service import chat_scope_filters
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
|
||||
|
||||
@@ -46,19 +48,34 @@ class ShareDraftService:
|
||||
return draft
|
||||
|
||||
@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(
|
||||
db.scalars(
|
||||
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())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_copied(db: Session, *, user: User, draft_id: int) -> ShareDraft:
|
||||
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id))
|
||||
def mark_copied(
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
|
||||
draft.copied = 1
|
||||
@@ -69,8 +86,14 @@ class ShareDraftService:
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, *, user: User, draft_id: int) -> None:
|
||||
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id))
|
||||
def delete(
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
|
||||
db.delete(draft)
|
||||
@@ -113,3 +136,23 @@ def _share_draft_values(*, topic: TopicSession, summary: TopicSummary) -> dict[s
|
||||
|
||||
def _now() -> datetime:
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,14 +10,17 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.entitlement import EntitlementPlan
|
||||
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.sso import SsoTicketRequest
|
||||
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.security_state_service import SecurityStateService
|
||||
from app.services.secret_service import SecretService
|
||||
@@ -166,7 +169,11 @@ class SsoService:
|
||||
)
|
||||
db.commit()
|
||||
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 {
|
||||
"token": token,
|
||||
"expiredAt": expired_at,
|
||||
@@ -271,11 +278,47 @@ class SsoService:
|
||||
|
||||
if not payload.verifiedPhone:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
|
||||
user = db.scalar(
|
||||
select(User).where(User.phone == payload.verifiedPhone, User.is_deleted == 0)
|
||||
)
|
||||
phone = payload.verifiedPhone.strip()
|
||||
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0).with_for_update())
|
||||
created = False
|
||||
if user is None:
|
||||
if not client.allow_auto_register:
|
||||
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(
|
||||
select(UserExternalIdentity).where(
|
||||
UserExternalIdentity.client_id == client.id,
|
||||
@@ -385,3 +428,18 @@ def _int_value(value) -> int:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -15,6 +15,8 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.models import Base
|
||||
from app.core.config import get_settings
|
||||
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.user import User
|
||||
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()))
|
||||
assert audit is not None
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
2. 新增接入应用;
|
||||
3. 保存系统只展示一次的应用密钥;
|
||||
4. 如需学员从千问千答返回来源应用,配置完整的回跳地址白名单。
|
||||
5. 选择是否允许自动注册新学员;开启时必须同时选择默认权益版本。
|
||||
|
||||
首次免登录时,接入应用必须提供学员在本应用内的稳定用户 ID 和已经由本应用验证过的手机号。手机号仅用于首次匹配千问千答学员,后续以账号绑定为准。
|
||||
|
||||
@@ -93,13 +94,22 @@ signature = hmac.new(APP_SECRET.encode(), canonical, hashlib.sha256).hexdigest()
|
||||
|
||||
- 已经绑定:按“应用 ID + 外部用户 ID”直接找到千问千答学员;
|
||||
- 首次进入:使用已验证手机号匹配现有学员并建立绑定;
|
||||
- 手机号不在学员名单:拒绝进入,不自动创建无权益账号;
|
||||
- 手机号不在学员名单且应用未开启自动注册:拒绝进入;
|
||||
- 手机号不在学员名单且应用已开启自动注册:必须同时提供姓名,系统创建学员、分配该应用的默认权益,再建立外部账号绑定;
|
||||
- 学员已禁用、尚未生效或权益已过期:拒绝进入;
|
||||
- 同一应用中的一个千问千答学员只能绑定一个外部用户 ID。
|
||||
|
||||
绑定错误时,管理员可以在“应用接入 → 账号绑定”中解除绑定,学员下次进入时重新核对手机号。
|
||||
|
||||
## 5. 安全规则
|
||||
## 5. 账号与会话数据规则
|
||||
|
||||
- 同一手机号只对应一个千问千答学员账号,不会因接入多个应用重复注册;
|
||||
- 学员的会话、消息、AI 请求和衍生的求助卡/分享稿仍保存在千问千答服务器;
|
||||
- 会话按登录来源强制隔离:直接登录只能看到直接会话,从应用 A 进入只能看到应用 A 的会话,不能访问应用 B 或直接登录会话;
|
||||
- 隔离规则由后端根据签名登录态强制执行,不依赖前端传入来源参数;
|
||||
- 管理员可在“记录审计”按直接访问、第三方应用和具体应用筛选会话。
|
||||
|
||||
## 6. 安全规则
|
||||
|
||||
- 所有生产接口必须使用 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 各自只能查看、修改和删除本来源会话。
|
||||
|
||||
Reference in New Issue
Block a user