feat: 增强实时会议gRPC服务和会话状态管理

- 在 `MeetingCommandServiceImpl` 中更新 `saveRealtimeTranscriptSnapshot` 方法,仅保存最终结果
- 在 `GrpcServerLifecycle` 中添加 `GrpcExceptionLoggingInterceptor`
- 在 `RealtimeMeetingSessionStateServiceImpl` 中添加终端状态处理逻辑
- 在 `RealtimeMeetingGrpcService` 中增强错误处理和流关闭逻辑
- 添加 `saveRealtimeTranscriptSnapshotShouldIgnoreNonFinalTranscript` 测试用例
- 在 `MeetingAuthorizationServiceImpl` 中添加匿名访问支持
- 在 `RealtimeMeetingGrpcSessionServiceImpl` 中添加异常处理和清理逻辑
dev_na
chenhao 2026-04-08 19:45:50 +08:00
parent b2e2f2c46a
commit 1c82365e97
7 changed files with 219 additions and 37 deletions

View File

@ -6,7 +6,109 @@ const http = axios.create({
timeout: 15000
});
http.interceptors.request.use((config) => {
const refreshClient = axios.create({
baseURL: "/",
timeout: 15000
});
const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"];
const API_SUCCESS_CODE = "200";
const REFRESH_AHEAD_MS = 60 * 1000;
let refreshPromise: Promise<string | null> | null = null;
function isApiSuccessCode(code: unknown): boolean {
return String(code) === API_SUCCESS_CODE;
}
function getTokenPayload(token: string): Record<string, any> | null {
try {
const payload = token.split(".")[1];
if (!payload) {
return null;
}
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(decodeURIComponent(escape(window.atob(normalized))));
} catch {
return null;
}
}
function getTokenExpireAt(token: string): number | null {
const payload = getTokenPayload(token);
if (!payload || typeof payload.exp !== "number") {
return null;
}
return payload.exp * 1000;
}
function isTokenExpiringSoon(token: string): boolean {
const expireAt = getTokenExpireAt(token);
if (!expireAt) {
return false;
}
return expireAt - Date.now() <= REFRESH_AHEAD_MS;
}
function clearAuthStorage() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
}
function persistTokens(data: { accessToken: string; refreshToken: string }) {
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
const payload = getTokenPayload(data.accessToken);
if (payload && payload.tenantId !== undefined && payload.tenantId !== null) {
localStorage.setItem("activeTenantId", String(payload.tenantId));
}
}
function isAuthWhitelistRequest(url?: string) {
return AUTH_WHITELIST.some((path) => (url || "").includes(path));
}
async function refreshAccessToken(): Promise<string | null> {
if (refreshPromise) {
return refreshPromise;
}
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
return null;
}
refreshPromise = refreshClient
.post("/sys/auth/refresh", { refreshToken })
.then((resp) => {
const body = resp.data;
if (!body || !isApiSuccessCode(body.code) || !body.data?.accessToken || !body.data?.refreshToken) {
throw new Error(body?.msg || "刷新登录态失败");
}
persistTokens(body.data);
return body.data.accessToken as string;
})
.catch(() => {
clearAuthStorage();
return null;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
http.interceptors.request.use(async (config) => {
if (!isAuthWhitelistRequest(config.url)) {
const currentToken = localStorage.getItem("accessToken");
if (currentToken && isTokenExpiringSoon(currentToken)) {
await refreshAccessToken();
}
}
const token = localStorage.getItem("accessToken");
if (token) {
config.headers = config.headers || {};
@ -18,10 +120,9 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use(
(resp) => {
const body = resp.data;
// 如果返回的 code 不是 0表示业务错误
if (body && body.code !== "0") {
if (body && !isApiSuccessCode(body.code)) {
const errorMsg = body.msg || "请求失败";
message.error(errorMsg); // 自动展示后端错误消息
message.error(errorMsg);
const err = new Error(errorMsg);
(err as any).code = body.code;
(err as any).msg = body.msg;
@ -29,27 +130,37 @@ http.interceptors.response.use(
}
return resp;
},
(error) => {
// 处理 HTTP 状态码错误 (4xx, 5xx)
async (error) => {
const originalRequest = error.config || {};
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!isAuthWhitelistRequest(originalRequest.url)
) {
originalRequest._retry = true;
const newToken = await refreshAccessToken();
if (newToken) {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest);
}
}
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
clearAuthStorage();
window.location.href = "/login?timeout=1";
return Promise.reject(error);
}
const body = error.response?.data;
const errorMsg = body?.msg || error.message || "网络异常";
// 防止重复弹出相同的提示(可选逻辑,根据需要调整)
message.error(errorMsg);
if (body && body.msg) {
const err = new Error(body.msg);
(err as any).code = body.code;
(err as any).msg = body.msg;
return Promise.reject(err);
const err = new Error(body.msg);
(err as any).code = body.code;
(err as any).msg = body.msg;
return Promise.reject(err);
}
return Promise.reject(error);

View File

@ -55,7 +55,11 @@ function resolveMenuIcon(icon?: string): ReactNode {
type PermissionMenuNode = SysPermission & {
children?: PermissionMenuNode[];
};
type CachedUserProfile = { displayName?: string; username?: string; avatar_url?: string };
function getAvatarUrl(profile?: CachedUserProfile | null) {
return profile?.avatar_url?.trim() || "";
}
export default function AppLayout() {
const { message } = App.useApp();
const { t, i18n } = useTranslation();
@ -79,7 +83,7 @@ export default function AppLayout() {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr) as { displayName?: string; username?: string };
const profile = JSON.parse(profileStr) as CachedUserProfile;
return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
}
} catch {
@ -88,7 +92,14 @@ export default function AppLayout() {
return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
}, [t]);
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? getAvatarUrl(JSON.parse(profileStr) as CachedUserProfile) : "";
} catch {
return "";
}
});
const fetchInitialData = useCallback(async () => {
try {
const storedTenants = localStorage.getItem("availableTenants");
@ -116,7 +127,20 @@ export default function AppLayout() {
useEffect(() => {
fetchInitialData();
}, [fetchInitialData]);
useEffect(() => {
const syncUserProfile = () => {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) return;
const profile = JSON.parse(profileStr) as CachedUserProfile;
setCurrentUserAvatarUrl(getAvatarUrl(profile));
} catch {
}
};
window.addEventListener("user-profile-updated", syncUserProfile);
return () => window.removeEventListener("user-profile-updated", syncUserProfile);
}, []);
useEffect(() => {
const syncPlatformConfig = () => {
const configStr = sessionStorage.getItem("platformConfig");
@ -299,7 +323,7 @@ export default function AppLayout() {
<BellOutlined style={{ fontSize: "18px", color: "var(--app-text-main)", cursor: "pointer" }} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
<Avatar size="small" src={currentUserAvatarUrl || undefined} icon={currentUserAvatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
<span style={{ fontWeight: 500 }}>{currentUserDisplayName}</span>
</Space>
</Dropdown>
@ -404,9 +428,9 @@ export default function AppLayout() {
mode="horizontal"
selectedKeys={[location.pathname]}
items={menuItems}
style={{
borderBottom: 0,
lineHeight: '62px',
style={{
borderBottom: 0,
lineHeight: '62px',
background: 'transparent',
color: 'var(--app-text-main)'
}}

View File

@ -265,6 +265,9 @@
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
"botBindStatus": "Binding Status",
"avatarUrl": "Avatar URL",
"avatarUrlPlaceholder": "Enter avatar image URL",
"uploadAvatar": "Upload Avatar",
"botBound": "Bound",
"botUnbound": "Not Generated",
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",
@ -441,4 +444,4 @@
"layoutSide": "Side Menu",
"layoutTop": "Top Menu"
}
}
}

View File

@ -265,6 +265,10 @@
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"avatarUrl": "\u5934\u50cf URL",
"avatarUrlPlaceholder": "\u8bf7\u8f93\u5165\u5934\u50cf\u56fe\u7247\u5730\u5740",
"uploadAvatar": "\u4e0a\u4f20\u5934\u50cf",
"botBound": "已绑定",
"botUnbound": "未生成",
"botSecretHidden": "已隐藏。如需查看新的 Secret请重新生成。",
@ -441,4 +445,4 @@
"layoutSide": "左侧菜单",
"layoutTop": "顶部菜单"
}
}
}

View File

@ -1,9 +1,9 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, App } from 'antd';
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message,App } from "antd";
import type { DefaultOptionType } from "antd/es/select";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser } from "@/api";
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser, uploadPlatformAsset } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
@ -68,6 +68,7 @@ export default function Users() {
const { items: statusDict } = useDict("sys_common_status");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [data, setData] = useState<SysUser[]>([]);
const [roles, setRoles] = useState<SysRole[]>([]);
const [tenants, setTenants] = useState<SysTenant[]>([]);
@ -229,6 +230,18 @@ export default function Users() {
loadUsersData();
};
const handleAvatarUpload = async (file: File) => {
try {
setAvatarUploading(true);
const url = await uploadPlatformAsset(file);
form.setFieldValue("avatar_url", url);
message.success(t("common.success"));
} finally {
setAvatarUploading(false);
}
return false;
};
const submit = async () => {
const values = await form.validateFields();
setSaving(true);
@ -238,6 +251,7 @@ export default function Users() {
displayName: values.displayName,
email: values.email,
phone: values.phone,
avatar_url: values.avatar_url,
status: values.status,
isPlatformAdmin: values.isPlatformAdmin
};
@ -280,9 +294,7 @@ export default function Users() {
key: "user",
render: (_: any, record: SysUser) => (
<Space>
<div className="user-avatar-placeholder">
<UserOutlined />
</div>
<Avatar className="user-avatar-placeholder" size={40} src={record.avatar_url || undefined} icon={record.avatar_url ? undefined : <UserOutlined />} />
<div>
<Space size={4}>
<div className="user-display-name">{record.displayName}</div>
@ -403,6 +415,10 @@ export default function Users() {
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
</Row>
<Form.Item label={t("profile.avatarUrl")} name="avatar_url"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
</Upload>
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}><Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} /></Form.Item>
<Form.Item label={t("users.roles")} name="roleIds" rules={[{ required: true, message: t("users.roles") }]}><Select mode="multiple" placeholder={t("users.roles")} options={roleOptions} optionFilterProp={isPlatformMode ? "searchText" : "label"} /></Form.Item>
{!isPlatformMode && <Form.Item label={t("users.orgNode")} name="orgId"><TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData} /></Form.Item>}

View File

@ -43,14 +43,14 @@ const fallbackRecentCards: RecentCard[] = [
},
{
id: "sample-2",
title: "【示例】开会用通义听悟,高效又省心",
title: "【示例】开会用iMeeting,高效又省心",
duration: "02:14",
time: "2026-03-24 11:04",
tags: ["会议日程", "笔记", "发言人", "协同", "纪要"]
},
{
id: "sample-3",
title: "【示例】上课用通义听悟,学习效率 UPUP",
title: "【示例】上课用iMeeting,学习效率 UPUP",
duration: "02:01",
time: "2026-03-23 11:04",
tags: ["转写", "笔记", "学习", "教学音频", "课程音频"]

View File

@ -1,20 +1,20 @@
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, App } from 'antd';
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons";
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, Upload, message } from "antd";
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile } from "@/api";
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import type { BotCredential, UserProfile } from "@/types";
const { Paragraph, Title, Text } = Typography;
export default function Profile() {
const { message } = App.useApp();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialSaving, setCredentialSaving] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [user, setUser] = useState<UserProfile | null>(null);
const [credential, setCredential] = useState<BotCredential | null>(null);
const [profileForm] = Form.useForm();
@ -25,6 +25,8 @@ export default function Profile() {
try {
const data = await getCurrentUser();
setUser(data);
sessionStorage.setItem("userProfile", JSON.stringify(data));
window.dispatchEvent(new Event("user-profile-updated"));
profileForm.setFieldsValue(data);
} finally {
setLoading(false);
@ -58,6 +60,18 @@ export default function Profile() {
}
};
const handleAvatarUpload = async (file: File) => {
try {
setAvatarUploading(true);
const url = await uploadPlatformAsset(file);
profileForm.setFieldValue("avatar_url", url);
message.success(t("common.success"));
} finally {
setAvatarUploading(false);
}
return false;
};
const handleUpdatePassword = async () => {
try {
const values = await pwdForm.validateFields();
@ -82,6 +96,8 @@ export default function Profile() {
};
const renderValue = (value?: string) => value || "-";
const avatarUrlValue = Form.useWatch("avatar_url", profileForm) as string | undefined;
const avatarUrl = avatarUrlValue?.trim() || undefined;
return (
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
@ -90,7 +106,7 @@ export default function Profile() {
<Row gutter={24}>
<Col xs={24} lg={8}>
<Card className="app-page__content-card text-center" loading={loading}>
<Avatar size={80} icon={<UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
<Text type="secondary">@{user?.username}</Text>
<div className="mt-4">
@ -121,6 +137,14 @@ export default function Profile() {
<Form.Item label={t("users.phone")} name="phone">
<Input />
</Form.Item>
<Form.Item label={t("profile.avatarUrl")} name="avatar_url">
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
</Form.Item>
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>
{t("profile.uploadAvatar")}
</Button>
</Upload>
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
{t("profile.saveChanges")}