feat: add OpenAPI share page with device-bound tokens and thought/answer separation [AC-IDMP-SHARE]
This commit is contained in:
parent
9c40509225
commit
382f91ce83
|
|
@ -112,7 +112,7 @@
|
|||
<el-card shadow="never" class="shares-card" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>分享链接</span>
|
||||
<span>历史分享(旧)</span>
|
||||
<el-button size="small" text @click="loadShares">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -139,34 +139,25 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="shareDialogVisible" title="分享对话" width="500px">
|
||||
<el-dialog v-model="shareDialogVisible" title="分享对话(安全链接)" width="500px">
|
||||
<el-form :model="shareForm" label-width="120px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="shareForm.title" placeholder="可选,分享标题" maxlength="255" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="shareForm.description" type="textarea" :rows="2" placeholder="可选,分享描述" maxlength="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期">
|
||||
<el-select v-model="shareForm.expires_in_days" style="width: 100%">
|
||||
<el-option label="1 天" :value="1" />
|
||||
<el-option label="7 天" :value="7" />
|
||||
<el-option label="30 天" :value="30" />
|
||||
<el-option label="90 天" :value="90" />
|
||||
<el-select v-model="shareForm.expires_in_minutes" style="width: 100%">
|
||||
<el-option label="15 分钟" :value="15" />
|
||||
<el-option label="1 小时" :value="60" />
|
||||
<el-option label="6 小时" :value="360" />
|
||||
<el-option label="24 小时" :value="1440" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大在线人数">
|
||||
<el-input-number v-model="shareForm.max_concurrent_users" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="shareDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creatingShare" @click="handleCreateShare">创建分享</el-button>
|
||||
<el-button type="primary" :loading="creatingShare" @click="handleCreateShare">创建安全分享</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="shareResultVisible" title="分享成功" width="500px">
|
||||
<el-result icon="success" title="分享链接已创建" :sub-title="shareResult?.share_url">
|
||||
<el-result icon="success" title="安全分享链接已创建" :sub-title="shareResult?.share_url">
|
||||
<template #extra>
|
||||
<el-input :model-value="shareResult?.share_url" readonly>
|
||||
<template #append>
|
||||
|
|
@ -186,13 +177,13 @@ import {
|
|||
respondDialogue,
|
||||
switchSessionMode,
|
||||
reportMessages,
|
||||
createShare,
|
||||
createPublicShareToken,
|
||||
listShares,
|
||||
deleteShare,
|
||||
type DialogueMessage,
|
||||
type DialogueResponse,
|
||||
type SessionMode,
|
||||
type ShareResponse,
|
||||
type CreatePublicShareTokenResponse,
|
||||
type ShareListItem
|
||||
} from '@/api/mid-platform'
|
||||
|
||||
|
|
@ -223,12 +214,9 @@ const shares = ref<ShareListItem[]>([])
|
|||
const shareDialogVisible = ref(false)
|
||||
const shareResultVisible = ref(false)
|
||||
const creatingShare = ref(false)
|
||||
const shareResult = ref<ShareResponse | null>(null)
|
||||
const shareResult = ref<CreatePublicShareTokenResponse | null>(null)
|
||||
const shareForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
expires_in_days: 7,
|
||||
max_concurrent_users: 10
|
||||
expires_in_minutes: 60
|
||||
})
|
||||
|
||||
const now = () => new Date().toLocaleTimeString()
|
||||
|
|
@ -334,10 +322,7 @@ const clearConversation = () => {
|
|||
|
||||
const showShareDialog = () => {
|
||||
shareForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
expires_in_days: 7,
|
||||
max_concurrent_users: 10
|
||||
expires_in_minutes: 60
|
||||
}
|
||||
shareDialogVisible.value = true
|
||||
}
|
||||
|
|
@ -345,14 +330,17 @@ const showShareDialog = () => {
|
|||
const handleCreateShare = async () => {
|
||||
creatingShare.value = true
|
||||
try {
|
||||
const result = await createShare(sessionId.value, shareForm.value)
|
||||
const result = await createPublicShareToken({
|
||||
session_id: sessionId.value,
|
||||
user_id: userId.value,
|
||||
expires_in_minutes: shareForm.value.expires_in_minutes
|
||||
})
|
||||
shareResult.value = result
|
||||
shareDialogVisible.value = false
|
||||
shareResultVisible.value = true
|
||||
await loadShares()
|
||||
ElMessage.success('分享链接创建成功')
|
||||
ElMessage.success('安全分享链接创建成功')
|
||||
} catch {
|
||||
ElMessage.error('创建分享链接失败')
|
||||
ElMessage.error('创建安全分享链接失败')
|
||||
} finally {
|
||||
creatingShare.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,44 @@
|
|||
<template>
|
||||
<div class="share-page">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-icon class="is-loading" :size="48">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p>加载中...</p>
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">正在加载...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<el-result :icon="errorIcon" :title="errorTitle" :sub-title="errorMessage">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
<div class="error-icon">{{ errorEmoji }}</div>
|
||||
<h2 class="error-title">{{ errorTitle }}</h2>
|
||||
<p class="error-message">{{ errorMessage }}</p>
|
||||
<button class="back-btn" @click="goHome">返回首页</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="sessionInfo">
|
||||
<el-card shadow="never" class="header-card">
|
||||
<div class="session-header">
|
||||
<div class="session-info">
|
||||
<h2>{{ sessionInfo.title || '共享对话' }}</h2>
|
||||
<p v-if="sessionInfo.description" class="description">{{ sessionInfo.description }}</p>
|
||||
<div class="meta-info">
|
||||
<el-tag size="small" type="info">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ sessionInfo.current_users }} / {{ sessionInfo.max_concurrent_users }} 在线
|
||||
</el-tag>
|
||||
<el-tag size="small" type="warning">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ formatExpiresAt(sessionInfo.expires_at) }}
|
||||
</el-tag>
|
||||
<div class="page-header">
|
||||
<div class="header-icon">💬</div>
|
||||
<h1 class="page-title">{{ sessionInfo.title || '对话记录' }}</h1>
|
||||
<p v-if="sessionInfo.description" class="page-desc">{{ sessionInfo.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="chat-card">
|
||||
<template #header>
|
||||
<div class="chat-header">
|
||||
<span>对话记录</span>
|
||||
<el-tag size="small" type="info">{{ sessionInfo.history.length }} 条消息</el-tag>
|
||||
<div class="chat-container">
|
||||
<div class="chat-list" ref="chatListRef">
|
||||
<template v-for="(msg, idx) in sessionInfo.history" :key="idx">
|
||||
<div class="chat-bubble" :class="`bubble-${msg.role}`">
|
||||
<div class="bubble-avatar">
|
||||
<span v-if="msg.role === 'user' || msg.role === 'human'">👤</span>
|
||||
<span v-else>🤖</span>
|
||||
</div>
|
||||
<div class="bubble-content">
|
||||
<div class="bubble-text">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="chat-list" ref="chatListRef">
|
||||
<div
|
||||
v-for="(msg, idx) in sessionInfo.history"
|
||||
:key="idx"
|
||||
class="chat-item"
|
||||
:class="`role-${msg.role}`"
|
||||
>
|
||||
<div class="meta">
|
||||
<el-tag size="small" :type="getTagType(msg.role)">
|
||||
{{ getRoleLabel(msg.role) }}
|
||||
</el-tag>
|
||||
<div v-if="!sessionInfo.history.length" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>暂无对话内容</p>
|
||||
</div>
|
||||
<div class="content">{{ msg.content }}</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!sessionInfo.history.length" description="暂无对话记录" :image-size="80" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -68,10 +46,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading, User, Clock } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getSharedSession,
|
||||
joinSharedSession,
|
||||
leaveSharedSession,
|
||||
type SharedSessionInfo
|
||||
|
|
@ -87,43 +62,13 @@ const errorMessage = ref('')
|
|||
const sessionInfo = ref<SharedSessionInfo | null>(null)
|
||||
const chatListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const errorIcon = computed(() => {
|
||||
if (error.value === 'expired') return 'error'
|
||||
if (error.value === 'not_found') return 'error'
|
||||
if (error.value === 'too_many_users') return 'warning'
|
||||
return 'error'
|
||||
const errorEmoji = computed(() => {
|
||||
if (error.value === 'expired') return '⏰'
|
||||
if (error.value === 'not_found') return '🔍'
|
||||
if (error.value === 'too_many_users') return '👥'
|
||||
return '😕'
|
||||
})
|
||||
|
||||
const getTagType = (role: string) => {
|
||||
if (role === 'user') return 'info'
|
||||
if (role === 'assistant') return 'success'
|
||||
if (role === 'human') return 'warning'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
if (role === 'user') return '用户'
|
||||
if (role === 'assistant') return '助手'
|
||||
if (role === 'human') return '人工'
|
||||
return role
|
||||
}
|
||||
|
||||
const formatExpiresAt = (expiresAt: string) => {
|
||||
const expires = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} 天 ${diffHours} 小时后过期`
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} 小时后过期`
|
||||
} else {
|
||||
return '即将过期'
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
|
@ -133,8 +78,8 @@ const loadSession = async () => {
|
|||
|
||||
if (!shareToken) {
|
||||
error.value = 'not_found'
|
||||
errorTitle.value = '无效链接'
|
||||
errorMessage.value = '分享链接无效,请检查链接是否正确'
|
||||
errorTitle.value = '链接无效'
|
||||
errorMessage.value = '这个链接好像不对哦,请检查一下'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
|
@ -146,20 +91,20 @@ const loadSession = async () => {
|
|||
sessionInfo.value = await joinSharedSession(shareToken)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
const detail = err?.response?.data?.detail || '未知错误'
|
||||
const detail = err?.response?.data?.detail || '出了点问题'
|
||||
|
||||
if (status === 404) {
|
||||
error.value = 'not_found'
|
||||
errorTitle.value = '分享不存在'
|
||||
errorMessage.value = '该分享链接不存在或已被删除'
|
||||
errorTitle.value = '找不到内容'
|
||||
errorMessage.value = '这个分享链接不存在或已被删除'
|
||||
} else if (status === 410) {
|
||||
error.value = 'expired'
|
||||
errorTitle.value = '分享已过期'
|
||||
errorMessage.value = detail
|
||||
errorTitle.value = '链接已失效'
|
||||
errorMessage.value = '这个分享链接已经过期了'
|
||||
} else if (status === 429) {
|
||||
error.value = 'too_many_users'
|
||||
errorTitle.value = '访问人数过多'
|
||||
errorMessage.value = '当前在线人数已达上限,请稍后再试'
|
||||
errorTitle.value = '访问人数太多'
|
||||
errorMessage.value = '当前查看的人太多了,请稍后再试'
|
||||
} else {
|
||||
error.value = 'error'
|
||||
errorTitle.value = '加载失败'
|
||||
|
|
@ -176,14 +121,12 @@ const handleLeave = async () => {
|
|||
try {
|
||||
await leaveSharedSession(shareToken)
|
||||
} catch {
|
||||
// ignore leave errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSession()
|
||||
|
||||
window.addEventListener('beforeunload', handleLeave)
|
||||
})
|
||||
|
||||
|
|
@ -196,9 +139,9 @@ onUnmounted(() => {
|
|||
<style scoped>
|
||||
.share-page {
|
||||
min-height: 100vh;
|
||||
background: var(--el-bg-color-page);
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
background: #f5f5f5;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
|
@ -208,91 +151,199 @@ onUnmounted(() => {
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
margin-top: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
.loading-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: #1677ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.header-card,
|
||||
.chat-card {
|
||||
margin-bottom: 16px;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-info .description {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
.loading-text {
|
||||
margin-top: 14px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-info .el-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0 0 18px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
.back-btn {
|
||||
padding: 10px 24px;
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
.back-btn:hover {
|
||||
background: #4096ff;
|
||||
}
|
||||
|
||||
.chat-item.role-user {
|
||||
border-left: 4px solid var(--el-color-info);
|
||||
.page-header {
|
||||
background: white;
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.chat-item.role-assistant {
|
||||
border-left: 4px solid var(--el-color-success);
|
||||
}
|
||||
|
||||
.chat-item.role-human {
|
||||
border-left: 4px solid var(--el-color-warning);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.header-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 16px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bubble-user,
|
||||
.bubble-human {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.bubble-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.bubble-text {
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubble-user .bubble-text {
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bubble-human .bubble-text {
|
||||
background: #fa8c16;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bubble-assistant .bubble-text {
|
||||
background: white;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.bubble-text {
|
||||
font-size: 14px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
"""OpenAPI-facing routers for third-party integrations."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .dialogue import router as dialogue_router
|
||||
from .share_page import router as share_page_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(dialogue_router)
|
||||
router.include_router(share_page_router)
|
||||
|
||||
__all__ = ["router", "dialogue_router", "share_page_router"]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""OpenAPI dialogue router placeholder."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/openapi/v1/dialogue", tags=["OpenAPI Dialogue"])
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"""Simple shareable chat page and tokenized public chat APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.mid.dialogue import (
|
||||
get_default_kb_tool_runner,
|
||||
get_feature_flag_service,
|
||||
get_high_risk_handler,
|
||||
get_interrupt_context_enricher,
|
||||
get_metrics_collector,
|
||||
get_output_guardrail_executor,
|
||||
get_policy_router,
|
||||
get_runtime_observer,
|
||||
get_segment_humanizer,
|
||||
get_timeout_governor,
|
||||
get_trace_logger,
|
||||
respond_dialogue,
|
||||
)
|
||||
from app.core.database import get_session
|
||||
from app.core.tenant import clear_tenant_context, set_tenant_context
|
||||
from app.models.mid.schemas import DialogueRequest, HistoryMessage
|
||||
from app.services.openapi.share_token_service import get_share_token_service
|
||||
|
||||
router = APIRouter(prefix="/openapi/v1/share", tags=["OpenAPI Share Page"])
|
||||
|
||||
SHARE_DEVICE_COOKIE = "share_device_id"
|
||||
|
||||
|
||||
class CreatePublicShareTokenRequest(BaseModel):
|
||||
tenant_id: str | None = Field(default=None, description="Tenant id (optional, fallback to X-Tenant-Id)")
|
||||
api_key: str | None = Field(default=None, description="API key (optional, fallback to X-API-Key)")
|
||||
session_id: str = Field(..., description="Shared session id")
|
||||
user_id: str | None = Field(default=None, description="Optional default user id")
|
||||
expires_in_minutes: int = Field(default=60, ge=1, le=1440, description="Token ttl in minutes")
|
||||
|
||||
|
||||
class CreatePublicShareTokenResponse(BaseModel):
|
||||
share_token: str
|
||||
share_url: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class PublicShareChatRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
history: list[HistoryMessage] = Field(default_factory=list)
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
def _get_or_create_device_id(request: Request) -> tuple[str, bool]:
|
||||
existing = request.cookies.get(SHARE_DEVICE_COOKIE)
|
||||
if existing:
|
||||
return existing, False
|
||||
return secrets.token_urlsafe(16), True
|
||||
|
||||
|
||||
@router.post("/token", response_model=CreatePublicShareTokenResponse, summary="Create a public one-time share token")
|
||||
async def create_public_share_token(request: Request, body: CreatePublicShareTokenRequest) -> CreatePublicShareTokenResponse:
|
||||
tenant_id = body.tenant_id or request.headers.get("X-Tenant-Id")
|
||||
api_key = body.api_key or request.headers.get("X-API-Key")
|
||||
|
||||
if not tenant_id or not api_key:
|
||||
raise HTTPException(status_code=400, detail="tenant_id/api_key missing")
|
||||
|
||||
service = get_share_token_service()
|
||||
token, expires_at = await service.create_token(
|
||||
tenant_id=tenant_id,
|
||||
api_key=api_key,
|
||||
session_id=body.session_id,
|
||||
user_id=body.user_id,
|
||||
expires_in_minutes=body.expires_in_minutes,
|
||||
)
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
share_url = f"{base_url}/openapi/v1/share/chat?token={token}"
|
||||
return CreatePublicShareTokenResponse(
|
||||
share_token=token,
|
||||
share_url=share_url,
|
||||
expires_at=expires_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat/{chat_token}", summary="Public chat via consumed share token")
|
||||
async def public_chat_via_share_token(
|
||||
chat_token: str,
|
||||
body: PublicShareChatRequest,
|
||||
request: Request,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
service = get_share_token_service()
|
||||
device_id, _ = _get_or_create_device_id(request)
|
||||
grant = await service.get_chat_grant_for_device(chat_token, device_id)
|
||||
if not grant:
|
||||
raise HTTPException(status_code=403, detail="This share link is bound to another device or expired")
|
||||
|
||||
set_tenant_context(grant.tenant_id)
|
||||
request.state.tenant_id = grant.tenant_id
|
||||
|
||||
try:
|
||||
mid_request = DialogueRequest(
|
||||
session_id=grant.session_id,
|
||||
user_id=body.user_id or grant.user_id,
|
||||
user_message=body.message,
|
||||
history=body.history,
|
||||
)
|
||||
|
||||
result = await respond_dialogue(
|
||||
request=request,
|
||||
dialogue_request=mid_request,
|
||||
session=session,
|
||||
policy_router=get_policy_router(),
|
||||
high_risk_handler=get_high_risk_handler(),
|
||||
timeout_governor=get_timeout_governor(),
|
||||
feature_flag_service=get_feature_flag_service(),
|
||||
trace_logger=get_trace_logger(),
|
||||
metrics_collector=get_metrics_collector(),
|
||||
output_guardrail_executor=get_output_guardrail_executor(),
|
||||
interrupt_context_enricher=get_interrupt_context_enricher(),
|
||||
default_kb_tool_runner=get_default_kb_tool_runner(),
|
||||
segment_humanizer=get_segment_humanizer(),
|
||||
runtime_observer=get_runtime_observer(),
|
||||
)
|
||||
finally:
|
||||
clear_tenant_context()
|
||||
|
||||
merged_reply = "\n\n".join([s.text for s in result.segments if s.text])
|
||||
return {
|
||||
"request_id": result.trace.request_id,
|
||||
"reply": merged_reply,
|
||||
"segments": [s.model_dump() for s in result.segments],
|
||||
"trace": result.trace.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/chat", response_class=HTMLResponse, summary="Shareable chat page")
|
||||
async def share_chat_page(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Query(description="One-time share token")] = None,
|
||||
) -> HTMLResponse:
|
||||
service = get_share_token_service()
|
||||
device_id, is_new_cookie = _get_or_create_device_id(request)
|
||||
|
||||
chat_token = ""
|
||||
token_error = ""
|
||||
|
||||
if token:
|
||||
claim = await service.claim_or_reuse(token, device_id)
|
||||
if claim.ok and claim.grant:
|
||||
chat_token = claim.grant.chat_token
|
||||
elif claim.status in {"invalid", "expired"}:
|
||||
token_error = "分享链接已失效"
|
||||
elif claim.status == "forbidden":
|
||||
token_error = "该链接已绑定到其他设备,无法访问"
|
||||
else:
|
||||
token_error = "分享链接不可用"
|
||||
else:
|
||||
token_error = "缺少分享 token"
|
||||
|
||||
html = f"""
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>对话分享</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
.welcome-screen {{
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}}
|
||||
.welcome-screen.hidden {{ display: none; }}
|
||||
.welcome-input-wrapper {{
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}}
|
||||
.welcome-textarea, .input-textarea {{
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
background: #fafafa;
|
||||
transition: all 0.2s;
|
||||
}}
|
||||
.welcome-textarea:focus, .input-textarea:focus {{
|
||||
border-color: #1677ff;
|
||||
background: white;
|
||||
}}
|
||||
.chat-screen {{ flex: 1; display: none; flex-direction: column; }}
|
||||
.chat-screen.active {{ display: flex; }}
|
||||
.chat-list {{
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
.bubble {{ display: flex; gap: 10px; align-items: flex-start; }}
|
||||
.bubble.user {{ flex-direction: row-reverse; }}
|
||||
.avatar {{
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: white; display: flex; align-items: center; justify-content: center;
|
||||
}}
|
||||
.bubble-content {{ max-width: 75%; }}
|
||||
.bubble-text {{
|
||||
padding: 12px 16px; border-radius: 16px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 14px; line-height: 1.6;
|
||||
}}
|
||||
.bubble.user .bubble-text {{ background: #1677ff; color: white; }}
|
||||
.bubble.bot .bubble-text {{ background: white; color: #333; }}
|
||||
.bubble.error .bubble-text {{ background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }}
|
||||
.thought-block {{
|
||||
background: #f5f5f5;
|
||||
color: #888;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid #ddd;
|
||||
}}
|
||||
.thought-label {{
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.final-answer-block {{
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.final-answer-label {{
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.input-area {{ background: white; padding: 16px 20px 20px; border-top: 1px solid #eee; }}
|
||||
.input-wrapper {{ max-width: 800px; margin: 0 auto; display: flex; gap: 12px; align-items: flex-end; }}
|
||||
.send-btn, .welcome-send {{
|
||||
width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: #1677ff; color: white;
|
||||
}}
|
||||
.status {{ text-align: center; padding: 8px; font-size: 12px; color: #999; }}
|
||||
.status.error {{ color: #ff4d4f; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<h1>今天有什么可以帮到你?</h1>
|
||||
<div class="welcome-input-wrapper">
|
||||
<textarea class="welcome-textarea" id="welcomeInput" placeholder="输入消息,按 Enter 发送" rows="1"></textarea>
|
||||
<button class="welcome-send" id="welcomeSendBtn">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-screen" id="chatScreen">
|
||||
<div class="chat-list" id="chatList"></div>
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea class="input-textarea" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
|
||||
<button class="send-btn" id="chatSendBtn">➤</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (window.location.search.includes('token=')) {{
|
||||
window.history.replaceState(null, '', '/openapi/v1/share/chat');
|
||||
}}
|
||||
const CHAT_TOKEN = {chat_token!r};
|
||||
const TOKEN_ERROR = {token_error!r};
|
||||
|
||||
const welcomeScreen = document.getElementById('welcomeScreen');
|
||||
const chatScreen = document.getElementById('chatScreen');
|
||||
const welcomeInput = document.getElementById('welcomeInput');
|
||||
const welcomeSendBtn = document.getElementById('welcomeSendBtn');
|
||||
const chatInput = document.getElementById('chatInput');
|
||||
const chatSendBtn = document.getElementById('chatSendBtn');
|
||||
const chatList = document.getElementById('chatList');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
const chatHistory = [];
|
||||
let sending = false;
|
||||
let started = false;
|
||||
|
||||
function setStatus(text, type) {{
|
||||
statusEl.textContent = text || '';
|
||||
statusEl.className = 'status ' + (type || '');
|
||||
}}
|
||||
|
||||
function formatBotMessage(text) {{
|
||||
const thoughtKey = 'Thought:';
|
||||
const finalKey = 'Final Answer:';
|
||||
const thoughtIdx = text.indexOf(thoughtKey);
|
||||
const finalIdx = text.indexOf(finalKey);
|
||||
|
||||
if (thoughtIdx === -1 && finalIdx === -1) {{
|
||||
return '<div class="bubble-text">' + text + '</div>';
|
||||
}}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (thoughtIdx !== -1) {{
|
||||
const thoughtStart = thoughtIdx + thoughtKey.length;
|
||||
const thoughtEnd = finalIdx !== -1 ? finalIdx : text.length;
|
||||
const thoughtContent = text.slice(thoughtStart, thoughtEnd).trim().split('\\n').join('<br>');
|
||||
if (thoughtContent) {{
|
||||
html += '<div class="thought-block"><div class="thought-label">💭 思考过程</div><div>' + thoughtContent + '</div></div>';
|
||||
}}
|
||||
}}
|
||||
|
||||
if (finalIdx !== -1) {{
|
||||
const answerStart = finalIdx + finalKey.length;
|
||||
const answerContent = text.slice(answerStart).trim().split('\\n').join('<br>');
|
||||
if (answerContent) {{
|
||||
html += '<div class="final-answer-block"><div class="final-answer-label">✨ 回答</div><div>' + answerContent + '</div></div>';
|
||||
}}
|
||||
}}
|
||||
|
||||
return html || '<div class="bubble-text">' + text + '</div>';
|
||||
}}
|
||||
|
||||
function addMessage(role, text) {{
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bubble ' + role;
|
||||
const avatar = role === 'user' ? '👤' : (role === 'bot' ? '🤖' : '⚠️');
|
||||
|
||||
let contentHtml;
|
||||
if (role === 'bot') {{
|
||||
contentHtml = formatBotMessage(text);
|
||||
}} else {{
|
||||
contentHtml = '<div class="bubble-text">' + text + '</div>';
|
||||
}}
|
||||
|
||||
div.innerHTML = '<div class="avatar">' + avatar + '</div><div class="bubble-content">' + contentHtml + '</div>';
|
||||
chatList.appendChild(div);
|
||||
chatList.scrollTop = chatList.scrollHeight;
|
||||
}}
|
||||
|
||||
function switchToChat() {{
|
||||
if (!started) {{
|
||||
welcomeScreen.classList.add('hidden');
|
||||
chatScreen.classList.add('active');
|
||||
started = true;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function sendMessage(fromWelcome) {{
|
||||
if (sending) return;
|
||||
const input = fromWelcome ? welcomeInput : chatInput;
|
||||
const message = (input.value || '').trim();
|
||||
if (!message) return;
|
||||
if (!CHAT_TOKEN) {{
|
||||
setStatus(TOKEN_ERROR || '链接无效', 'error');
|
||||
return;
|
||||
}}
|
||||
|
||||
switchToChat();
|
||||
addMessage('user', message);
|
||||
chatHistory.push({{ role: 'user', content: message }});
|
||||
input.value = '';
|
||||
|
||||
sending = true;
|
||||
chatSendBtn.disabled = true;
|
||||
welcomeSendBtn.disabled = true;
|
||||
setStatus('发送中...');
|
||||
|
||||
try {{
|
||||
const resp = await fetch('/openapi/v1/share/chat/' + encodeURIComponent(CHAT_TOKEN), {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{ message, history: chatHistory }})
|
||||
}});
|
||||
const data = await resp.json().catch(() => ({{}}));
|
||||
|
||||
if (!resp.ok) {{
|
||||
const err = data?.detail || data?.message || '请求失败';
|
||||
addMessage('error', '发送失败:' + err);
|
||||
setStatus('发送失败', 'error');
|
||||
return;
|
||||
}}
|
||||
|
||||
const reply = data.reply || '(无回复)';
|
||||
addMessage('bot', reply);
|
||||
chatHistory.push({{ role: 'assistant', content: reply }});
|
||||
setStatus('');
|
||||
}} catch (e) {{
|
||||
addMessage('error', '网络异常,请稍后重试');
|
||||
setStatus('网络异常', 'error');
|
||||
}} finally {{
|
||||
sending = false;
|
||||
chatSendBtn.disabled = false;
|
||||
welcomeSendBtn.disabled = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
welcomeSendBtn.addEventListener('click', () => sendMessage(true));
|
||||
chatSendBtn.addEventListener('click', () => sendMessage(false));
|
||||
welcomeInput.addEventListener('keydown', (e) => {{ if (e.key === 'Enter' && !e.shiftKey) {{ e.preventDefault(); sendMessage(true); }} }});
|
||||
chatInput.addEventListener('keydown', (e) => {{ if (e.key === 'Enter' && !e.shiftKey) {{ e.preventDefault(); sendMessage(false); }} }});
|
||||
|
||||
if (!CHAT_TOKEN) {{
|
||||
welcomeInput.disabled = true;
|
||||
welcomeSendBtn.disabled = true;
|
||||
setStatus(TOKEN_ERROR || '链接无效', 'error');
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
response = HTMLResponse(
|
||||
content=html,
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
if is_new_cookie:
|
||||
response.set_cookie(
|
||||
key=SHARE_DEVICE_COOKIE,
|
||||
value=device_id,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=30 * 24 * 3600,
|
||||
)
|
||||
return response
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
"""Redis-backed share token service for secure share links with device binding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatGrantRecord:
|
||||
chat_token: str
|
||||
tenant_id: str
|
||||
api_key: str
|
||||
session_id: str
|
||||
user_id: str | None
|
||||
bound_device_id: str
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaimResult:
|
||||
ok: bool
|
||||
status: Literal["claimed", "reused", "invalid", "expired", "forbidden"]
|
||||
grant: ChatGrantRecord | None = None
|
||||
|
||||
|
||||
class ShareTokenService:
|
||||
"""Manage temporary share tokens and device-bound chat grants in Redis."""
|
||||
|
||||
def __init__(self, redis_client: redis.Redis | None = None) -> None:
|
||||
self._settings = get_settings()
|
||||
self._redis = redis_client
|
||||
|
||||
async def _get_client(self) -> redis.Redis:
|
||||
if self._redis is None:
|
||||
self._redis = redis.from_url(
|
||||
self._settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return self._redis
|
||||
|
||||
@staticmethod
|
||||
def _token_key(token: str) -> str:
|
||||
return f"share:token:{token}"
|
||||
|
||||
@staticmethod
|
||||
def _grant_key(chat_token: str) -> str:
|
||||
return f"share:grant:{chat_token}"
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
tenant_id: str,
|
||||
api_key: str,
|
||||
session_id: str,
|
||||
user_id: str | None,
|
||||
expires_in_minutes: int,
|
||||
) -> tuple[str, datetime]:
|
||||
client = await self._get_client()
|
||||
now = datetime.utcnow()
|
||||
expires_at = now + timedelta(minutes=expires_in_minutes)
|
||||
ttl_seconds = max(1, int((expires_at - now).total_seconds()))
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"api_key": api_key,
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"bound_device_id": None,
|
||||
"chat_token": None,
|
||||
}
|
||||
await client.setex(self._token_key(token), ttl_seconds, json.dumps(payload))
|
||||
return token, expires_at
|
||||
|
||||
async def claim_or_reuse(self, token: str, device_id: str) -> ClaimResult:
|
||||
client = await self._get_client()
|
||||
raw = await client.get(self._token_key(token))
|
||||
if not raw:
|
||||
return ClaimResult(ok=False, status="invalid")
|
||||
|
||||
data = json.loads(raw)
|
||||
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||
if datetime.utcnow() > expires_at:
|
||||
await client.delete(self._token_key(token))
|
||||
return ClaimResult(ok=False, status="expired")
|
||||
|
||||
if not data.get("chat_token"):
|
||||
chat_token = secrets.token_urlsafe(24)
|
||||
now = datetime.utcnow()
|
||||
ttl_seconds = max(1, int((expires_at - now).total_seconds()))
|
||||
|
||||
grant_data = {
|
||||
"chat_token": chat_token,
|
||||
"tenant_id": data["tenant_id"],
|
||||
"api_key": data["api_key"],
|
||||
"session_id": data["session_id"],
|
||||
"user_id": data.get("user_id"),
|
||||
"bound_device_id": device_id,
|
||||
"expires_at": data["expires_at"],
|
||||
"created_at": now.isoformat(),
|
||||
"last_seen_at": now.isoformat(),
|
||||
}
|
||||
data["bound_device_id"] = device_id
|
||||
data["chat_token"] = chat_token
|
||||
|
||||
pipe = client.pipeline()
|
||||
pipe.setex(self._grant_key(chat_token), ttl_seconds, json.dumps(grant_data))
|
||||
pipe.setex(self._token_key(token), ttl_seconds, json.dumps(data))
|
||||
await pipe.execute()
|
||||
|
||||
return ClaimResult(ok=True, status="claimed", grant=self._to_grant(grant_data))
|
||||
|
||||
if data.get("bound_device_id") != device_id:
|
||||
return ClaimResult(ok=False, status="forbidden")
|
||||
|
||||
grant_raw = await client.get(self._grant_key(data["chat_token"]))
|
||||
if not grant_raw:
|
||||
return ClaimResult(ok=False, status="invalid")
|
||||
|
||||
grant_data = json.loads(grant_raw)
|
||||
grant_data["last_seen_at"] = datetime.utcnow().isoformat()
|
||||
ttl = await client.ttl(self._grant_key(data["chat_token"]))
|
||||
if ttl and ttl > 0:
|
||||
await client.setex(self._grant_key(data["chat_token"]), ttl, json.dumps(grant_data))
|
||||
|
||||
return ClaimResult(ok=True, status="reused", grant=self._to_grant(grant_data))
|
||||
|
||||
async def get_chat_grant_for_device(self, chat_token: str, device_id: str) -> ChatGrantRecord | None:
|
||||
client = await self._get_client()
|
||||
raw = await client.get(self._grant_key(chat_token))
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
data = json.loads(raw)
|
||||
if data.get("bound_device_id") != device_id:
|
||||
return None
|
||||
|
||||
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||
if datetime.utcnow() > expires_at:
|
||||
await client.delete(self._grant_key(chat_token))
|
||||
return None
|
||||
|
||||
data["last_seen_at"] = datetime.utcnow().isoformat()
|
||||
ttl = await client.ttl(self._grant_key(chat_token))
|
||||
if ttl and ttl > 0:
|
||||
await client.setex(self._grant_key(chat_token), ttl, json.dumps(data))
|
||||
return self._to_grant(data)
|
||||
|
||||
@staticmethod
|
||||
def _to_grant(data: dict) -> ChatGrantRecord:
|
||||
return ChatGrantRecord(
|
||||
chat_token=data["chat_token"],
|
||||
tenant_id=data["tenant_id"],
|
||||
api_key=data["api_key"],
|
||||
session_id=data["session_id"],
|
||||
user_id=data.get("user_id"),
|
||||
bound_device_id=data["bound_device_id"],
|
||||
expires_at=datetime.fromisoformat(data["expires_at"]),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
last_seen_at=datetime.fromisoformat(data["last_seen_at"]),
|
||||
)
|
||||
|
||||
|
||||
_share_token_service: ShareTokenService | None = None
|
||||
|
||||
|
||||
def get_share_token_service() -> ShareTokenService:
|
||||
global _share_token_service
|
||||
if _share_token_service is None:
|
||||
_share_token_service = ShareTokenService()
|
||||
return _share_token_service
|
||||
Loading…
Reference in New Issue