ticketAuth.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. /**
  2. * 票据认证工具
  3. * 处理从门户传递过来的票据,获取访问令牌
  4. */
  5. // 票据处理接口(直接使用完整URL)测试环境
  6. const TICKET_PROCESS_API = 'https://aqai.shudaodsj.com:22001/api/ticket/process'
  7. // ===== 关键修复:在模块加载时立即保存原始 URL =====
  8. // 防止其他请求(如 axios 拦截器)在认证完成前跳转导致票据丢失
  9. let originalUrl = null
  10. let originalSearch = null
  11. let originalHash = null
  12. // 立即保存原始 URL(在任何其他代码执行之前)
  13. try {
  14. originalUrl = window.location.href
  15. originalSearch = window.location.search
  16. originalHash = window.location.hash
  17. console.log('💾 已保存原始 URL:', originalUrl)
  18. console.log('💾 已保存原始 Search:', originalSearch)
  19. console.log('💾 已保存原始 Hash:', originalHash)
  20. } catch (e) {
  21. console.warn('⚠️ 保存原始 URL 失败:', e)
  22. }
  23. // 导出标志,供 axios 拦截器使用
  24. export let isAuthenticating = false
  25. /**
  26. * 从 URL 获取票据参数
  27. */
  28. export function getTicketFromUrl() {
  29. console.log('🔍 === 开始获取票据参数 ===')
  30. console.log('📍 当前完整 URL:', window.location.href)
  31. console.log('📍 当前 location.search:', window.location.search)
  32. console.log('📍 当前 location.hash:', window.location.hash)
  33. console.log('📍 保存的原始 URL:', originalUrl)
  34. console.log('📍 保存的原始 Search:', originalSearch)
  35. console.log('📍 保存的原始 Hash:', originalHash)
  36. // 新的 URL 格式: http://域名/?iamcaspticket=xxx
  37. // 优先从保存的原始 URL 中获取(防止页面跳转导致票据丢失)
  38. // 如果原始 URL 中没有,再从当前 URL 获取
  39. let search = ''
  40. // ===== 优先级1: 从保存的原始 URL 中获取 =====
  41. if (originalSearch) {
  42. search = originalSearch
  43. console.log('✅ 从保存的原始 Search 中提取到查询参数:', search.substring(0, 100))
  44. } else if (originalHash && originalHash.includes('?')) {
  45. const hashParts = originalHash.split('?')
  46. if (hashParts.length > 1) {
  47. search = '?' + hashParts.slice(1).join('?')
  48. console.log('✅ 从保存的原始 Hash 中提取到查询参数:', search.substring(0, 100))
  49. }
  50. }
  51. // ===== 优先级2: 如果原始 URL 中没有,从当前 URL 获取 =====
  52. if (!search) {
  53. if (window.location.search) {
  54. search = window.location.search
  55. console.log('✅ 从当前 Search 中提取到查询参数:', search.substring(0, 100))
  56. }
  57. if (!search && window.location.hash && window.location.hash.includes('?')) {
  58. const hashParts = window.location.hash.split('?')
  59. if (hashParts.length > 1) {
  60. search = '?' + hashParts.slice(1).join('?')
  61. console.log('✅ 从当前 Hash 中提取到查询参数(兼容模式):', search.substring(0, 100))
  62. }
  63. }
  64. }
  65. if (!search) {
  66. console.log('❌ 未找到查询参数')
  67. console.log('🔍 === 票据获取结束 ===')
  68. return null
  69. }
  70. console.log('📍 原始查询字符串:', search)
  71. console.log('📍 查询字符串长度:', search.length)
  72. console.log('📍 查询字符串前10个字符:', search.substring(0, 10))
  73. // 处理 ?& 格式异常(多余的 & 符号)
  74. if (search.startsWith('?&')) {
  75. console.log('⚠️ 检测到 URL 格式异常: ?& 开头,正在修正...')
  76. search = '?' + search.substring(2) // 去掉 ?& 中的 &,保留 ?
  77. console.log('✅ 修正后的查询字符串:', search.substring(0, 100))
  78. }
  79. // 尝试多种方式解析票据
  80. let ticket = null
  81. // 方式1: 使用 URLSearchParams
  82. const urlParams = new URLSearchParams(search)
  83. ticket = urlParams.get('iamcaspticket')
  84. console.log('📋 方式1 (URLSearchParams) 结果:', ticket ? ticket.substring(0, 50) + '...' : 'null')
  85. // 方式2: 如果方式1失败,尝试手动解析(处理特殊情况)
  86. if (!ticket && search) {
  87. console.log('⚠️ URLSearchParams 解析失败,尝试手动解析...')
  88. // 移除开头的 ? 或 ?&
  89. let cleanSearch = search
  90. if (cleanSearch.startsWith('?&')) {
  91. cleanSearch = cleanSearch.substring(2)
  92. } else if (cleanSearch.startsWith('?')) {
  93. cleanSearch = cleanSearch.substring(1)
  94. }
  95. console.log('📋 清理后的字符串:', cleanSearch.substring(0, 50) + '...')
  96. // 按 & 分割参数
  97. const params = cleanSearch.split('&')
  98. console.log('📋 分割后的参数数量:', params.length)
  99. for (const param of params) {
  100. // 只分割第一个 = 号,避免票据内容中的 = 被分割
  101. const equalIndex = param.indexOf('=')
  102. if (equalIndex === -1) continue
  103. const key = param.substring(0, equalIndex)
  104. const value = param.substring(equalIndex + 1)
  105. console.log('📋 检查参数:', key)
  106. if (key === 'iamcaspticket' && value) {
  107. ticket = decodeURIComponent(value)
  108. console.log('✅ 方式2 (手动解析) 找到票据:', ticket.substring(0, 50) + '...')
  109. break
  110. }
  111. }
  112. }
  113. if (ticket) {
  114. console.log('🎫 成功获取到票据!')
  115. console.log('🎫 票据长度:', ticket.length)
  116. console.log('🎫 票据前50个字符:', ticket.substring(0, 50) + '...')
  117. // 票据可能已经解码,检查是否需要再次解码
  118. try {
  119. // 如果票据中包含 %,说明可能是 URL 编码的
  120. if (ticket.includes('%')) {
  121. const decoded = decodeURIComponent(ticket)
  122. console.log('🔄 票据已解码')
  123. ticket = decoded
  124. }
  125. } catch (e) {
  126. console.log('⚠️ 票据解码失败,使用原始值:', e.message)
  127. }
  128. console.log('🔍 === 票据获取成功 ===')
  129. return ticket
  130. }
  131. console.log('❌ 未找到票据参数 iamcaspticket')
  132. console.log('📍 当前 URL:', window.location.href)
  133. console.log('📍 查询字符串:', search)
  134. console.log('🔍 === 票据获取结束 ===')
  135. return null
  136. }
  137. /**
  138. * 清理 URL 中的票据参数
  139. * 票据只能使用一次,处理成功后应该清理,避免刷新时重复使用
  140. */
  141. export function clearTicketFromUrl() {
  142. try {
  143. console.log('='.repeat(60))
  144. console.log('🧹 开始清理 URL 中的票据参数...')
  145. console.log('📍 调用栈:', new Error().stack)
  146. console.log('📍 当前 URL:', window.location.href)
  147. // 获取当前 URL
  148. const currentUrl = window.location.href
  149. const url = new URL(currentUrl)
  150. const ticketParamsToRemove = ['iamcaspticket', 'iamtarget', 'ip']
  151. let hasRemovedParams = false
  152. // 优先从普通查询参数中清理(新的标准格式)
  153. for (const param of ticketParamsToRemove) {
  154. if (url.searchParams.has(param)) {
  155. console.log(`✅ 从查询参数中删除: ${param}`)
  156. url.searchParams.delete(param)
  157. hasRemovedParams = true
  158. }
  159. }
  160. if (hasRemovedParams) {
  161. window.history.replaceState(null, '', url.toString())
  162. console.log('✅ URL 已更新(普通模式)')
  163. console.log('📍 新 URL:', window.location.href)
  164. return
  165. }
  166. // 如果普通查询参数中没有,尝试从 hash 中清理(兼容旧格式 Hash 路由)
  167. if (url.hash && url.hash.includes('?')) {
  168. const hashParts = url.hash.split('?')
  169. const hashPath = hashParts[0]
  170. let hashSearch = hashParts.slice(1).join('?')
  171. console.log('📍 Hash 路径:', hashPath)
  172. console.log('📍 Hash 原始查询:', hashSearch)
  173. if (hashSearch.startsWith('&')) {
  174. console.log('⚠️ 检测到 Hash 查询参数以 & 开头,正在修正...')
  175. hashSearch = hashSearch.substring(1)
  176. console.log('✅ 修正后的 Hash 查询:', hashSearch)
  177. }
  178. const hashParams = new URLSearchParams(hashSearch)
  179. console.log('📋 Hash 查询参数列表:')
  180. for (const [key, value] of hashParams.entries()) {
  181. console.log(` - ${key}: ${value.substring(0, 50)}${value.length > 50 ? '...' : ''}`)
  182. }
  183. let hasRemovedHashParams = false
  184. for (const param of ticketParamsToRemove) {
  185. if (hashParams.has(param)) {
  186. console.log(`✅ 从 Hash 中删除参数(兼容模式): ${param}`)
  187. hashParams.delete(param)
  188. hasRemovedHashParams = true
  189. }
  190. }
  191. if (hasRemovedHashParams) {
  192. const newHashSearch = hashParams.toString()
  193. const newHash = newHashSearch ? `${hashPath}?${newHashSearch}` : hashPath
  194. console.log('📍 新的 Hash:', newHash)
  195. console.log('📍 当前 Hash:', window.location.hash)
  196. try {
  197. console.log('🔄 开始更新地址栏...')
  198. window.location.hash = newHash
  199. console.log('✅ 地址栏已更新(兼容模式)')
  200. console.log('📍 新的 Hash:', window.location.hash)
  201. console.log('📍 完整 URL:', window.location.href)
  202. } catch (e) {
  203. console.error('❌ 更新地址栏失败:', e)
  204. const newUrl = url.origin + url.pathname + newHash
  205. window.history.replaceState(null, '', newUrl)
  206. console.log('✅ 使用 replaceState 更新(降级方案)')
  207. }
  208. return
  209. }
  210. }
  211. console.log('ℹ️ URL 中没有票据参数,无需清理')
  212. } catch (error) {
  213. console.error('❌ 清理票据参数失败:', error)
  214. console.error('❌ 错误详情:', error.message)
  215. console.error('❌ 错误堆栈:', error.stack)
  216. // 清理失败不影响主流程
  217. }
  218. }
  219. /**
  220. * 处理票据,获取访问令牌
  221. */
  222. export async function processTicket(ticketData) {
  223. try {
  224. console.log('🔍 正在处理票据...')
  225. console.log('📡 请求接口:', TICKET_PROCESS_API)
  226. console.log('📦 票据数据长度:', ticketData.length)
  227. console.log('📦 票据前100字符:', ticketData.substring(0, 100))
  228. const requestBody = {
  229. ticket_data: ticketData
  230. }
  231. console.log('📤 发送请求...')
  232. console.log('📤 请求体:', JSON.stringify(requestBody).substring(0, 150))
  233. const response = await fetch(TICKET_PROCESS_API, {
  234. method: 'POST',
  235. headers: {
  236. 'Content-Type': 'application/json',
  237. // 票据处理接口不需要 token,这是获取 token 的第一步
  238. // 如果后端要求,可以添加一个特殊标识
  239. 'X-Auth-Type': 'ticket'
  240. },
  241. body: JSON.stringify(requestBody),
  242. // 添加 credentials 以确保跨域请求携带cookie
  243. credentials: 'include'
  244. })
  245. console.log('📥 收到响应,状态码:', response.status)
  246. if (!response.ok) {
  247. const errorText = await response.text()
  248. console.error('❌ HTTP错误响应:', errorText)
  249. throw new Error(`HTTP错误: ${response.status} - ${errorText}`)
  250. }
  251. const data = await response.json()
  252. console.log('📋 响应数据:', data)
  253. // 检查是否有 refresh_token
  254. if (data.refresh_token && data.token_type) {
  255. console.log('✅ 票据处理成功')
  256. console.log('🎫 Token类型:', data.token_type)
  257. console.log('🔑 Refresh Token:', data.refresh_token.substring(0, 50) + '...')
  258. // 如果返回了 username,也一起返回
  259. if (data.username) {
  260. console.log('👤 用户名:', data.username)
  261. }
  262. return {
  263. refreshToken: data.refresh_token,
  264. tokenType: data.token_type,
  265. username: data.username || null
  266. }
  267. } else {
  268. console.error('❌ 响应数据格式错误,缺少必要字段')
  269. throw new Error('票据处理失败: 响应数据不完整')
  270. }
  271. } catch (error) {
  272. console.error('❌ 票据处理失败:', error)
  273. console.error('❌ 错误详情:', error.message)
  274. throw error
  275. }
  276. }
  277. /**
  278. * 保存令牌到 localStorage
  279. */
  280. export function saveToken(refreshToken, tokenType, username = null) {
  281. try {
  282. console.log('💾 开始保存令牌...')
  283. console.log('🎫 Token类型:', tokenType)
  284. console.log('🔑 Refresh Token:', refreshToken.substring(0, 50) + '...')
  285. // 保存 refresh_token 和 token_type
  286. localStorage.setItem('shudao_refresh_token', refreshToken)
  287. localStorage.setItem('shudao_token_type', tokenType)
  288. // 如果有 username,也保存
  289. if (username) {
  290. localStorage.setItem('shudao_username', username)
  291. console.log('👤 用户名:', username)
  292. }
  293. console.log('=' .repeat(60))
  294. console.log('✅ 令牌保存完成!')
  295. console.log(' - shudao_refresh_token:', localStorage.getItem('shudao_refresh_token')?.substring(0, 50) + '...')
  296. console.log(' - shudao_token_type:', localStorage.getItem('shudao_token_type'))
  297. if (username) {
  298. console.log(' - shudao_username:', localStorage.getItem('shudao_username'))
  299. }
  300. console.log('=' .repeat(60))
  301. return true
  302. } catch (error) {
  303. console.error('❌ 保存令牌失败:', error)
  304. throw error
  305. }
  306. }
  307. // ===== 已废弃:模拟用户生成功能 =====
  308. // 现在完全依赖票据认证,不再使用降级方案
  309. /**
  310. * 生成模拟用户信息(降级方案 - 已废弃)
  311. * 当票据解析失败或没有票据时使用
  312. */
  313. // export function generateMockUserInfo() {
  314. // console.log('=' .repeat(60))
  315. // console.log('🎭 【降级方案】开始生成模拟用户信息...')
  316. // console.log('⚠️ 这是模拟数据,不是真实用户!')
  317. // console.log('=' .repeat(60))
  318. //
  319. // // 生成随机用户ID (1-1000)
  320. // const mockUserId = Math.floor(Math.random() * 1000) + 1
  321. //
  322. // // 生成模拟账号ID
  323. // const mockAccountId = `mock_user_${mockUserId}`
  324. //
  325. // // 生成模拟用户信息
  326. // const mockUserInfo = {
  327. // accountID: mockAccountId,
  328. // name: `测试用户${mockUserId}`,
  329. // empNo: `TEST${String(mockUserId).padStart(6, '0')}`,
  330. // phone: `1380000${String(mockUserId).padStart(4, '0')}`,
  331. // mobile: `1380000${String(mockUserId).padStart(4, '0')}`,
  332. // username: `测试用户${mockUserId}`
  333. // }
  334. //
  335. // // 保存到 localStorage
  336. // localStorage.setItem('shudao_user_id', mockUserId.toString())
  337. // localStorage.setItem('shudao_user_info', JSON.stringify(mockUserInfo))
  338. //
  339. // // 生成模拟 token
  340. // const mockToken = `mock_token_${Date.now()}_${mockUserId}`
  341. // localStorage.setItem('shudao_access_token', mockToken)
  342. //
  343. // console.log('✅ 模拟用户信息已生成并保存:')
  344. // console.log(' - User ID:', mockUserId)
  345. // console.log(' - Account ID:', mockAccountId)
  346. // console.log(' - Name:', mockUserInfo.name)
  347. // console.log(' - User Info:', mockUserInfo)
  348. // console.log('=' .repeat(60))
  349. // console.log('⚠️ 当前使用【模拟用户】,请检查是否有有效票据!')
  350. // console.log('=' .repeat(60))
  351. //
  352. // return mockUserInfo
  353. // }
  354. /**
  355. * 检查本地是否已有令牌
  356. * @returns {boolean} 是否有有效的本地令牌
  357. */
  358. function hasLocalToken() {
  359. try {
  360. const refreshToken = localStorage.getItem('shudao_refresh_token')
  361. const tokenType = localStorage.getItem('shudao_token_type')
  362. const hasData = !!(refreshToken && tokenType)
  363. if (hasData) {
  364. console.log('✅ 检测到本地已有令牌:')
  365. console.log(' - Refresh Token:', refreshToken.substring(0, 50) + '...')
  366. console.log(' - Token Type:', tokenType)
  367. } else {
  368. console.log('⚠️ 本地没有完整的令牌')
  369. console.log(' - Refresh Token:', refreshToken ? '有' : '无')
  370. console.log(' - Token Type:', tokenType ? '有' : '无')
  371. }
  372. return hasData
  373. } catch (error) {
  374. console.error('❌ 检查本地令牌失败:', error)
  375. return false
  376. }
  377. }
  378. /**
  379. * 获取本地令牌
  380. * @returns {object|null} 本地令牌对象,如果不存在返回null
  381. */
  382. function getLocalToken() {
  383. try {
  384. const refreshToken = localStorage.getItem('shudao_refresh_token')
  385. const tokenType = localStorage.getItem('shudao_token_type')
  386. if (!refreshToken || !tokenType) {
  387. return null
  388. }
  389. return {
  390. refreshToken,
  391. tokenType
  392. }
  393. } catch (error) {
  394. console.error('❌ 获取本地令牌失败:', error)
  395. return null
  396. }
  397. }
  398. // 调试日志收集器
  399. const debugLogs = []
  400. function addDebugLog(level, message) {
  401. const log = {
  402. level,
  403. time: new Date().toLocaleTimeString(),
  404. message
  405. }
  406. debugLogs.push(log)
  407. console.log(`[${level.toUpperCase()}] ${message}`)
  408. // 保存到 sessionStorage
  409. try {
  410. sessionStorage.setItem('auth_debug_logs', JSON.stringify(debugLogs))
  411. } catch (e) {
  412. console.warn('无法保存调试日志:', e)
  413. }
  414. }
  415. /**
  416. * 处理票据认证流程
  417. * 1. 从 URL 获取票据
  418. * 2. 调用票据处理接口
  419. * 3. 保存令牌
  420. * 4. 认证策略:
  421. * - 有票据 + 处理成功 → 正常进入
  422. * - 有票据 + 处理失败 → 跳转404
  423. * - 无票据 + 有本地令牌 → 使用本地令牌进入
  424. * - 无票据 + 无本地令牌 → 跳转404
  425. *
  426. * @returns {object} 认证结果对象 { success: boolean, token: object, fromTicket/fromCache: boolean }
  427. * @throws {Error} 票据验证失败或无有效认证信息时抛出错误
  428. */
  429. export async function handleTicketAuth() {
  430. // 设置认证标志,防止 axios 拦截器在认证期间跳转
  431. isAuthenticating = true
  432. // 清空之前的日志
  433. debugLogs.length = 0
  434. addDebugLog('info', '🚀 开始票据认证流程')
  435. addDebugLog('info', `当前 URL: ${window.location.href}`)
  436. addDebugLog('info', `原始 URL: ${originalUrl}`)
  437. addDebugLog('info', `User Agent: ${navigator.userAgent.substring(0, 100)}...`)
  438. addDebugLog('info', `是否移动端: ${/Mobile|Android|iPhone|iPad/i.test(navigator.userAgent)}`)
  439. try {
  440. // 1. 获取票据
  441. addDebugLog('info', '步骤1: 获取票据')
  442. const ticket = getTicketFromUrl()
  443. if (!ticket) {
  444. addDebugLog('warning', '⚠️ 未找到票据')
  445. // 检查本地是否已有令牌
  446. if (hasLocalToken()) {
  447. isAuthenticating = false
  448. addDebugLog('success', '✅ 本地已有令牌,使用本地令牌')
  449. const tokenData = getLocalToken()
  450. return { success: true, token: tokenData, fromCache: true }
  451. } else {
  452. // 未找到票据且本地无令牌,抛出错误
  453. addDebugLog('error', '❌ 未找到票据且本地无令牌')
  454. throw new Error('TICKET_NOT_FOUND')
  455. }
  456. }
  457. addDebugLog('success', `✅ 成功获取票据 (长度: ${ticket.length})`)
  458. // 2. 处理票据
  459. addDebugLog('info', '步骤2: 调用后端处理票据')
  460. const { refreshToken, tokenType, username } = await processTicket(ticket)
  461. addDebugLog('success', `✅ 票据处理成功,获得 token`)
  462. // 3. 保存令牌和用户名
  463. addDebugLog('info', '步骤3: 保存令牌到本地')
  464. saveToken(refreshToken, tokenType, username)
  465. addDebugLog('success', `✅ 令牌已保存 (用户: ${username || '未知'})`)
  466. addDebugLog('success', '🎉 票据认证流程完成!')
  467. // 认证完成,清除标志
  468. isAuthenticating = false
  469. return {
  470. success: true,
  471. token: { refreshToken, tokenType, username },
  472. fromTicket: true
  473. }
  474. } catch (error) {
  475. addDebugLog('error', `❌ 认证失败: ${error.message}`)
  476. addDebugLog('error', `错误堆栈: ${error.stack?.substring(0, 200)}...`)
  477. // 认证失败,清除标志
  478. isAuthenticating = false
  479. // 票据验证失败,直接抛出错误
  480. throw error
  481. }
  482. }
  483. /**
  484. * 清理 URL 中的票据参数
  485. */
  486. function cleanUrlParams() {
  487. try {
  488. const url = new URL(window.location.href)
  489. url.searchParams.delete('iamcaspticket')
  490. // 使用 replaceState 更新 URL,不刷新页面
  491. window.history.replaceState({}, document.title, url.toString())
  492. console.log('🧹 已清理 URL 中的票据参数')
  493. } catch (error) {
  494. console.error('清理 URL 参数失败:', error)
  495. }
  496. }