fix(channels): resolve issue #19 — non-admin member errors on channel page

Three bugs surfaced when a non-admin workspace member opened the channel
admin page:

- vue-i18n "Invalid linked format" when '@' appeared in message strings
  without the linked-format escape. Replaced literal '@' with vue-i18n v9
  literal interpolation {'@'} in both zh-CN.ts and en-US.ts (6 strings:
  QQ guide step3, accessControl requireMention/Tooltip).

- 403 from WorkspaceAccessInterceptor was being treated as 401 by the
  axios interceptor and the chat SSE handler, clearing the token and
  redirecting to /login. Split the two:
    * 401 = authentication failure  -> handleAuthFailure (logout)
    * 403 = authorization failure   -> keep session, surface to caller
  Now a member who lacks workspace permission sees a toast instead of
  being silently logged out.

- Two backend exception sites threw with the default code=500 for what
  is semantically an auth/authz event, contradicting the codes returned
  elsewhere for the same business event:
    * AuthService.login() bad credentials  500 -> 401
    * WorkspaceService.requirePermission() 500 -> 403
  This aligns service-layer denials with SecurityConfig (401 for missing
  JWT) and WorkspaceAccessInterceptor (403 for permission denied), so
  the same business event always produces the same code.
This commit is contained in:
matevip 2026-04-27 19:31:46 +08:00
parent 2f93d53737
commit 03c8584910
6 changed files with 15 additions and 12 deletions

View File

@ -51,7 +51,7 @@ public class AuthService {
.eq(UserEntity::getEnabled, true));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials", "用户名或密码错误");
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
}
String token = generateToken(user);

View File

@ -209,7 +209,7 @@ public class WorkspaceService {
*/
public void requirePermission(Long workspaceId, Long userId, String minRole) {
if (!hasPermission(workspaceId, userId, minRole)) {
throw new MateClawException("err.workspace.insufficient_permission", "权限不足:需要 " + minRole + " 或更高角色");
throw new MateClawException("err.workspace.insufficient_permission", 403, "权限不足:需要 " + minRole + " 或更高角色");
}
}

View File

@ -30,7 +30,9 @@ http.interceptors.response.use(
// 后端统一响应格式 R<T>: { code: number, msg: string, data: T }
if (data && typeof data === 'object' && 'code' in data) {
if (data.code === 200) return data
if (data.code === 401 || data.code === 403) {
// 401 = authentication failure → log out
// 403 = authorization failure (e.g. workspace permission denied) → keep session, surface error to caller
if (data.code === 401) {
handleAuthFailure()
return Promise.reject(new Error(data.msg || 'Unauthorized'))
}
@ -39,7 +41,7 @@ http.interceptors.response.use(
return data
},
(err) => {
if (err.response?.status === 401 || err.response?.status === 403) {
if (err.response?.status === 401) {
handleAuthFailure()
}
return Promise.reject(err.response?.data?.msg || err.message)

View File

@ -240,8 +240,9 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
errorInfo.requestId = response.headers.get('X-Request-Id')
|| errorBody?.requestId
|| errorInfo.requestId
// 401/403 时清除 token 并跳转登录页(与 http.ts 保持一致)
if (response.status === 401 || response.status === 403) {
// 401 = authentication failure → log out (consistent with http.ts).
// 403 = authorization failure (e.g. workspace permission denied) → keep session.
if (response.status === 401) {
handleAuthFailure()
}
throw Object.assign(new Error(errorMsg), { errorInfo })

View File

@ -1561,8 +1561,8 @@ export default {
allowFromPlaceholder: 'Comma-separated user IDs (empty = allow all)',
denyMessage: 'Deny Message',
denyMessagePlaceholder: 'Sorry, you do not have permission',
requireMention: 'Require @mention',
requireMentionTooltip: 'Whether the bot requires @mention in group chats',
requireMention: "Require {'@'}mention",
requireMentionTooltip: "Whether the bot requires {'@'}mention in group chats",
},
messageFilter: {
title: 'Message Filter',
@ -1639,7 +1639,7 @@ export default {
qq: {
step1: 'Go to <a href="https://q.qq.com/" target="_blank" rel="noopener">QQ Open Platform</a> and create a bot application',
step2: 'Get <b>AppID</b> and <b>AppSecret</b> from the app management page, fill in below',
step3: 'Enable required message types in "Feature Config → Message Subscription" (C2C, Group @, Channel messages, etc.)',
step3: "Enable required message types in \"Feature Config → Message Subscription\" (C2C, Group {'@'}, Channel messages, etc.)",
step4: 'After starting the channel, auto-receives messages via <b>WebSocket long connection</b>, <b>no public IP or callback URL required</b>',
},
},

View File

@ -1571,8 +1571,8 @@ export default {
allowFromPlaceholder: '逗号分隔用户 ID留空 = 全部允许)',
denyMessage: '拒绝提示',
denyMessagePlaceholder: '抱歉,您没有使用权限',
requireMention: '需要 @提及',
requireMentionTooltip: '群聊中是否需要 @机器人才响应',
requireMention: "需要 {'@'}提及",
requireMentionTooltip: "群聊中是否需要 {'@'}机器人才响应",
},
messageFilter: {
title: '消息过滤',
@ -1649,7 +1649,7 @@ export default {
qq: {
step1: '前往 <a href="https://q.qq.com/" target="_blank" rel="noopener">QQ 开放平台</a> 创建机器人应用',
step2: '在应用管理页面获取 <b>AppID</b> 和 <b>AppSecret</b>,填入下方配置',
step3: '在「功能配置 → 消息订阅」中开启需要的消息类型C2C 消息、群聊 @消息、频道消息等)',
step3: "在「功能配置 → 消息订阅」中开启需要的消息类型C2C 消息、群聊 {'@'}消息、频道消息等)",
step4: '启动渠道后通过 <b>WebSocket 长连接</b>自动接收消息,<b>无需公网 IP 和回调 URL</b>',
},
},