diff --git a/backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql b/backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql new file mode 100644 index 00000000..7ae345c9 --- /dev/null +++ b/backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql @@ -0,0 +1,121 @@ +-- Unify the collation of EVERY aihr table (tables and all string columns). +-- +-- History: the 2026-07-18 release aligned the 18 newly added tables to the +-- collation of aihr_knowledge_info.tenant_id, and a same-day hotfix aligned +-- aihr_practice_assignment.ext_party_id. Both were partial: older tables kept +-- utf8mb4_0900_ai_ci columns (e.g. aihr_practice_session.ext_party_id), so any +-- join between the two families still raises error 1267 +-- (Illegal mix of collations). Seen in production on +-- /api/aihr/mobile/practice/assignments and /api/aihr/mobile/practice/mistakes. +-- +-- This migration converts ALL aihr tables to the canonical collation derived +-- at run time, ending the per-join whack-a-mole. CONVERT TO rewrites the table +-- default plus every character column, and is idempotent. + +SET @aihr_uni_collation := ( + SELECT collation_name + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'aihr_knowledge_info' + AND column_name = 'tenant_id' + LIMIT 1 +); +SET @aihr_uni_collation := COALESCE(@aihr_uni_collation, 'utf8mb4_0900_ai_ci'); + +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_candidate_employee_link` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_candidate_material` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_case_record` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_interview_result` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_answer_feedback` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_attach` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_fragment` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_gap` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_import_task` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_info` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_upload_item` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_model_config` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_model_provider` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_task` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_org_snapshot` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_position_responsibility` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_annotation` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_assignment` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_audio` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_audio_upload` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_calibration` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_rubric` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_rubric_dimension` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_scenario` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_session` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_prompt_template` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_qualification_gate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_sop_answer_review` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_sop_applicability` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_community_answer` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_community_question` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_incentive_rule` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_admin_audit` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_app` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_app_space` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_query_log` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_knowledge_space_grant` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_learning_question` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_exam` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_exam_answer` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_exam_attempt` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_exam_question` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_onboard_exam_target` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_points_ledger` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_practice_question_feedback` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_web_ai_query_audit` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; +SET @aihr_uni_ddl := CONCAT('ALTER TABLE `aihr_web_search_provider` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_uni_collation); +PREPARE aihr_uni_stmt FROM @aihr_uni_ddl; EXECUTE aihr_uni_stmt; DEALLOCATE PREPARE aihr_uni_stmt; + +SET @aihr_uni_ddl := NULL; +SET @aihr_uni_collation := NULL; diff --git a/mobile-uni/src/services/api.ts b/mobile-uni/src/services/api.ts index c2fe5ee9..01255a11 100644 --- a/mobile-uni/src/services/api.ts +++ b/mobile-uni/src/services/api.ts @@ -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 = (statusCode: number, data: unknown, clearAuthOnUna const payload = data as ApiPayload; 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 => { 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 => { 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('网络请求失败')) diff --git a/mobile-uni/src/services/auth.ts b/mobile-uni/src/services/auth.ts index a9ac1f58..c4d0c8e1 100644 --- a/mobile-uni/src/services/auth.ts +++ b/mobile-uni/src/services/auth.ts @@ -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);