fix: unify all aihr collations, stop silent logout on biz 403, enable knowledge apps
线上现象:「问」页语音提问报 401(请求不带 token),用户观察为
「点了几个页面后登录状态失效」。
根因链(逐环生产验证):
1. 问师傅 /api/knowledge/query 对真实登录返回业务 403
「当前登录端未启用知识问答应用」——生产 aihr_knowledge_app 为空表,
多租户知识平台的应用注册/空间绑定/角色授权从未在生产配置(P1 遗留)。
2. 前端 readPayload 把 403 也视为认证失败并 clearAuth() —— 业务 403
触发静默登出,localStorage token 被清。
3. 之后语音 ASR 请求裸奔 -> 401「未能读取到有效 token」。
同期回归:凌晨对 aihr_practice_assignment.ext_party_id 的单列 collation
热修复,使其与 aihr_practice_session(仍 0900_ai_ci)的 JOIN 反向失配,
/practice/mistakes 500。
修复:
- backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql:
47 张 aihr 表全部 CONVERT 到运行时基准 collation(生产=general_ci),
终结 collation 打地鼠;本地验证后已在生产执行
(schema 备份 /opt/wygj/backups/aihr-schema-before-unify-20260718041407.sql),
13 项移动端接口回归全 200(含 mistakes)。
- api.ts/auth.ts:本地登录态仅在真正 401(HTTP 或业务码)时清除,并经
redirectToLoginForAuthLoss 记住当前页自动回登录(2s 防抖,登录页内不跳);
业务 403(权限拒绝,如录音归属/知识空间)只报错不登出;
authenticatedFileUrl 同步收紧(403 不再清登录态)。
- 生产数据配置(幂等 INSERT,业务范围保守):
· aihr_knowledge_app 注册 yc_admin(pc)/yc_mobile(app) SESSION 应用
· yc_mobile 绑定 SOP 域 5 库(1001/1002/1003/1201/1202),
employee/supervisor ROLE READ;yc_admin 绑定全部 13 库,
hr_operator/superadmin MANAGE
· 财务/销售/行政等大库(1004-1010)暂仅管理端,待业务确认再放开
生产验证:
- 问师傅 query 200,真实回答+引用「投诉处理 SOP v1.0」
- E2E:登录后全 tab 浏览+语音+文字提问,token 全程稳定(storage 打桩零触发)
- 无效 token 点语音提问 -> 自动回登录页(原为死胡同 toast)
- H5 已发布(备份 h5-before-20260718042238)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { clearAuth, getAuth } from './auth';
|
||||
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
||||
|
||||
@@ -48,8 +48,11 @@ export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUna
|
||||
const payload = data as ApiPayload<T>;
|
||||
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
||||
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
|
||||
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) {
|
||||
// Only a real login failure (401) drops the local session; business 403
|
||||
// (permission denied) must surface as a message, never a silent logout.
|
||||
if (clearAuthOnUnauthorized && (statusCode === 401 || code === 401)) {
|
||||
clearAuth();
|
||||
redirectToLoginForAuthLoss();
|
||||
}
|
||||
throw new ApiError(payload?.msg || `HTTP ${statusCode}`, statusCode, code);
|
||||
}
|
||||
@@ -61,7 +64,10 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
||||
try {
|
||||
return JSON.parse(text || '{}');
|
||||
} catch (error) {
|
||||
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
|
||||
if (clearAuthOnUnauthorized && statusCode === 401) {
|
||||
clearAuth();
|
||||
redirectToLoginForAuthLoss();
|
||||
}
|
||||
throw error instanceof SyntaxError
|
||||
? new ApiError(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败', statusCode)
|
||||
: error;
|
||||
@@ -138,7 +144,10 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
||||
resolve(URL.createObjectURL(xhr.response));
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 || xhr.status === 403) clearAuth();
|
||||
if (xhr.status === 401) {
|
||||
clearAuth();
|
||||
redirectToLoginForAuthLoss();
|
||||
}
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络请求失败'));
|
||||
@@ -155,7 +164,10 @@ export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
||||
resolve(response.tempFilePath);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode === 401 || response.statusCode === 403) clearAuth();
|
||||
if (response.statusCode === 401) {
|
||||
clearAuth();
|
||||
redirectToLoginForAuthLoss();
|
||||
}
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
},
|
||||
fail: () => reject(new Error('网络请求失败'))
|
||||
|
||||
@@ -160,6 +160,29 @@ export const rememberLoginRedirect = (url: string) => {
|
||||
uni.setStorageSync(loginRedirectKey, url);
|
||||
};
|
||||
|
||||
let lastAuthLossRedirectAt = 0;
|
||||
|
||||
/**
|
||||
* Central recovery for a lost login (HTTP/biz 401): remember the current page,
|
||||
* then relaunch to login. Debounced because parallel requests can 401 together.
|
||||
* Business 403 must NOT end up here — permission denials are not logouts.
|
||||
*/
|
||||
export const redirectToLoginForAuthLoss = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastAuthLossRedirectAt < 2000) return;
|
||||
lastAuthLossRedirectAt = now;
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1] as { route?: string; $page?: { fullPath?: string } } | undefined;
|
||||
const path = current?.$page?.fullPath || (current?.route ? `/${current.route}` : '');
|
||||
if (path.startsWith('/pages/auth/')) return;
|
||||
if (path.startsWith('/pages/')) uni.setStorageSync(loginRedirectKey, path);
|
||||
} catch {
|
||||
// Best effort only; still return to login below.
|
||||
}
|
||||
uni.reLaunch({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
export const consumeLoginRedirect = () => {
|
||||
const url = uni.getStorageSync(loginRedirectKey) || '';
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
|
||||
Reference in New Issue
Block a user