api.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. /**
  2. * 聊天 & 认证相关 API,与桌面版接口一致
  3. * 为保证真机 / 模拟器环境可直接访问后端,这里统一使用完整线上域名
  4. */
  5. import {
  6. writeTokenToEmbeddedBrowser,
  7. clearAllEmbeddedBrowserData
  8. } from './embeddedBrowserStorage'
  9. const BASE_URL = 'https://api.hnyunzhu.com/api/v1'
  10. const TOKEN_KEY = 'token'
  11. export function getToken() {
  12. try {
  13. return uni.getStorageSync(TOKEN_KEY) || ''
  14. } catch (e) {
  15. return ''
  16. }
  17. }
  18. /** 后端 JWT 校验失败返回 403(无 401);401/403 均视为需重新登录 */
  19. function isAuthFailureStatus(statusCode) {
  20. return statusCode === 401 || statusCode === 403
  21. }
  22. /** 未授权 / 凭证无效:清 token 并回登录页(网络错误勿调用) */
  23. function clearSessionAndRedirectToLogin(toastTitle = '请先登录') {
  24. setToken('')
  25. uni.showToast({ title: toastTitle, icon: 'none' })
  26. // 让 Toast 有机会展示再切页
  27. setTimeout(() => {
  28. uni.reLaunch({ url: '/pages/login/index' })
  29. }, 80)
  30. }
  31. export function setToken(token) {
  32. const v = token || ''
  33. try {
  34. uni.setStorageSync(TOKEN_KEY, v)
  35. } catch (e) {}
  36. if (v) {
  37. // 登录态写入 WebView 同源 localStorage(仅 App 端 + 内置浏览器偏好时真正执行)
  38. try {
  39. writeTokenToEmbeddedBrowser(v)
  40. } catch (e) {}
  41. } else {
  42. // 与 useWebSocket / chatStore 动态引用,避免 api ↔ composables 循环依赖
  43. import('../composables/useWebSocket.js')
  44. .then((m) => {
  45. if (typeof m.disconnectWebSocket === 'function') m.disconnectWebSocket()
  46. })
  47. .catch(() => {})
  48. import('../store/chat.js')
  49. .then((m) => {
  50. if (m.chatStore && typeof m.chatStore.reset === 'function') m.chatStore.reset()
  51. })
  52. .catch(() => {})
  53. // 退出/失效时清空 WebView 所有站点的存储(localStorage/IndexedDB/SessionStorage/WebSQL/Cookies)+ HTTP 缓存
  54. try {
  55. clearAllEmbeddedBrowserData()
  56. } catch (e) {}
  57. }
  58. }
  59. /**
  60. * 统一 request:带 Authorization,返回 Promise
  61. */
  62. function request(options) {
  63. const token = options && options.token !== undefined ? options.token : getToken()
  64. const url = (options.url && options.url.startsWith('http')) ? options.url : BASE_URL + (options.url || '')
  65. return new Promise((resolve, reject) => {
  66. uni.request({
  67. url,
  68. method: options.method || 'GET',
  69. data: options.data,
  70. header: {
  71. 'Content-Type': 'application/json',
  72. Authorization: token ? `Bearer ${token}` : '',
  73. ...options.header
  74. },
  75. success: (res) => {
  76. if (isAuthFailureStatus(res.statusCode)) {
  77. const skipRedirect = options && options.skipSessionRedirectOnAuthFailure === true
  78. if (!skipRedirect) {
  79. clearSessionAndRedirectToLogin()
  80. }
  81. const err = new Error('请先登录')
  82. err.authFailure = true
  83. reject(err)
  84. return
  85. }
  86. if (res.statusCode >= 400) {
  87. const d = res.data
  88. const msg = (d && (d.message || d.msg || d.detail || d.error)) || res.errMsg || `请求失败(${res.statusCode})`
  89. reject(new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)))
  90. return
  91. }
  92. resolve(res.data)
  93. },
  94. fail: (err) => {
  95. if (!(options && options.skipRequestFailToast)) {
  96. uni.showToast({ title: err.errMsg || '网络错误', icon: 'none' })
  97. }
  98. reject(err)
  99. }
  100. })
  101. })
  102. }
  103. /**
  104. * 获取聊天历史
  105. * GET /messages/history/{otherUserId}?skip=0&limit=50
  106. */
  107. export function getMessages(token, otherUserId, params = {}) {
  108. const skip = params.skip != null ? params.skip : 0
  109. const limit = params.limit != null ? params.limit : 50
  110. const url = `${BASE_URL}/messages/history/${otherUserId}?skip=${skip}&limit=${limit}`
  111. return new Promise((resolve, reject) => {
  112. uni.request({
  113. url,
  114. method: 'GET',
  115. header: {
  116. Authorization: token ? `Bearer ${token}` : ''
  117. },
  118. success: (res) => {
  119. if (isAuthFailureStatus(res.statusCode)) {
  120. clearSessionAndRedirectToLogin()
  121. reject(new Error('请先登录'))
  122. return
  123. }
  124. if (res.statusCode >= 400) {
  125. reject(new Error((res.data && res.data.message) || '请求失败'))
  126. return
  127. }
  128. resolve(res.data)
  129. },
  130. fail: (err) => reject(err)
  131. })
  132. })
  133. }
  134. /**
  135. * 发送消息(对接文档 Message_Integration_Guide.md 3.2)
  136. * POST /messages/ Body: { receiver_id, type, content_type, title?, content?, action_url?, action_text? }
  137. * 用户发私信:receiver_id 为对方用户 ID(数字),type: 'MESSAGE',建议带 title(如「私信」)
  138. * @param {object} [extras] - 可选,USER_NOTIFICATION 等可传 { action_url, action_text }
  139. */
  140. export function sendMessage(token, receiverId, content, contentType = 'TEXT', title, extras) {
  141. const id = receiverId != null ? String(receiverId).trim() : ''
  142. const num = id === '' ? NaN : Number(id)
  143. const defaultTitle = contentType === 'USER_NOTIFICATION' ? '通知' : '私信'
  144. const body = {
  145. receiver_id: (num !== num || !Number.isInteger(num)) ? (id || receiverId) : num,
  146. type: 'MESSAGE',
  147. content_type: contentType,
  148. title: title != null && title !== '' ? String(title) : defaultTitle,
  149. content: content != null ? String(content) : ''
  150. }
  151. if (extras && typeof extras === 'object') {
  152. if (extras.action_url != null && extras.action_url !== '') body.action_url = String(extras.action_url)
  153. if (extras.action_text != null && extras.action_text !== '') body.action_text = String(extras.action_text)
  154. }
  155. return request({
  156. token,
  157. url: '/messages/',
  158. method: 'POST',
  159. data: body
  160. })
  161. }
  162. /**
  163. * 上传文件
  164. * POST /messages/upload FormData: file
  165. * filePath: uni.chooseImage/chooseVideo/chooseFile 返回的 tempFilePath
  166. * 返回 { url, key, filename, content_type, size }
  167. */
  168. export function uploadFile(token, filePath, fileName, onProgress) {
  169. return new Promise((resolve, reject) => {
  170. const url = BASE_URL + '/messages/upload'
  171. const uploadTask = uni.uploadFile({
  172. url,
  173. filePath,
  174. name: 'file',
  175. header: {
  176. Authorization: token ? `Bearer ${token}` : ''
  177. },
  178. success: (res) => {
  179. if (isAuthFailureStatus(res.statusCode)) {
  180. clearSessionAndRedirectToLogin()
  181. reject(new Error('请先登录'))
  182. return
  183. }
  184. if (res.statusCode >= 400) {
  185. reject(new Error('上传失败'))
  186. return
  187. }
  188. try {
  189. const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
  190. resolve(data)
  191. } catch (e) {
  192. reject(e)
  193. }
  194. },
  195. fail: reject
  196. })
  197. if (onProgress && typeof uploadTask.onProgressUpdate === 'function') {
  198. uploadTask.onProgressUpdate((e) => {
  199. if (e.totalBytesSent && e.totalBytesExpectedToSend) {
  200. onProgress(e.totalBytesSent / e.totalBytesExpectedToSend)
  201. }
  202. })
  203. }
  204. })
  205. }
  206. /**
  207. * 获取会话列表
  208. * GET /messages/conversations
  209. */
  210. export function getContacts(token) {
  211. return request({
  212. token,
  213. url: '/messages/conversations',
  214. method: 'GET'
  215. })
  216. }
  217. /**
  218. * 未读消息总数(用于底部 Tab 角标)
  219. * GET /messages/unread-count — 响应为数字
  220. */
  221. export function getUnreadCount(token) {
  222. return request({
  223. token,
  224. url: '/messages/unread-count',
  225. method: 'GET'
  226. })
  227. }
  228. /**
  229. * 按会话标记当前用户在该会话内全部已读
  230. * PUT /messages/history/{other_user_id}/read-all
  231. */
  232. export function markHistoryReadAll(token, otherUserId) {
  233. const id = encodeURIComponent(String(otherUserId))
  234. return request({
  235. token,
  236. url: `/messages/history/${id}/read-all`,
  237. method: 'PUT'
  238. })
  239. }
  240. /**
  241. * 全局消息检索(后端 GET /messages/search;移动端搜索页当前用 chatStore 本地检索,可后续切换)
  242. * @param {string} token
  243. * @param {string} q - 关键词
  244. * @param {{ limit?: number }} [params]
  245. * @returns {Promise<object>} 一般为 { items: [...] },具体字段以后端为准
  246. */
  247. export function searchMessages(token, q, params = {}) {
  248. const limit = params.limit != null ? params.limit : 20
  249. const query = `q=${encodeURIComponent(String(q || '').trim())}&limit=${Number(limit) || 20}`
  250. return request({
  251. token,
  252. url: `/messages/search?${query}`,
  253. method: 'GET'
  254. })
  255. }
  256. /**
  257. * 获取通知消息回调链接
  258. * GET /messages/{messageId}/callback-url
  259. */
  260. export function getMessageCallbackUrl(token, messageId) {
  261. return request({
  262. token,
  263. url: `/messages/${messageId}/callback-url`,
  264. method: 'GET'
  265. })
  266. }
  267. /**
  268. * 登录:密码登录
  269. * POST /auth/login/json Body: { mobile, password, remember_me: true }
  270. */
  271. export function login(mobile, password) {
  272. return request({
  273. // 登录不带 token
  274. token: '',
  275. url: '/auth/login/json',
  276. method: 'POST',
  277. data: { mobile, password, remember_me: true }
  278. })
  279. }
  280. /**
  281. * 登录:验证码登录
  282. * POST /auth/sms/login Body: { mobile, code, platform: 'pc' }
  283. * 为了与桌面端保持一致,默认 platform 使用 'pc'
  284. */
  285. export function loginBySms(mobile, code, platform = 'pc') {
  286. return request({
  287. token: '',
  288. url: '/auth/sms/login',
  289. method: 'POST',
  290. data: { mobile, code, platform }
  291. })
  292. }
  293. /**
  294. * 发送短信验证码
  295. * POST /auth/sms/send-code Body: { mobile, platform: 'pc' }
  296. * 桌面端为 { mobile, platform: 'pc' },这里沿用相同约定
  297. */
  298. export function sendSmsCode(mobile, platform = 'pc') {
  299. return request({
  300. token: '',
  301. url: '/auth/sms/send-code',
  302. method: 'POST',
  303. data: { mobile, platform }
  304. })
  305. }
  306. /**
  307. * 获取当前用户信息(桌面端会尝试多个路径,这里也做同样 fallback)
  308. */
  309. export async function getCurrentUserInfo(token, requestOptions = {}) {
  310. const candidates = ['/users/me', '/auth/me', '/user/info', '/auth/user']
  311. let lastErr = null
  312. for (const path of candidates) {
  313. try {
  314. return await request({ token, url: path, method: 'GET', ...requestOptions })
  315. } catch (e) {
  316. if (e && e.authFailure) throw e
  317. lastErr = e
  318. }
  319. }
  320. throw lastErr || new Error('getCurrentUserInfo failed')
  321. }
  322. /**
  323. * 个人身份二维码密文(展示端)
  324. * GET /identity-qr/ 返回 { token, expires_at },二维码内容即为 token,客户端勿解密
  325. */
  326. export function getIdentityQrPayload(token) {
  327. return request({
  328. token,
  329. url: '/identity-qr/',
  330. method: 'GET'
  331. })
  332. }
  333. /**
  334. * 将 /users/me 或登录返回的用户对象规范为本地 current_user 结构(头像、姓名、账号名/英文名、企业等)
  335. */
  336. export function normalizeUserPayload(raw) {
  337. if (!raw || typeof raw !== 'object') return null
  338. const o =
  339. raw.user != null && typeof raw.user === 'object'
  340. ? raw.user
  341. : raw.data != null && typeof raw.data === 'object'
  342. ? raw.data
  343. : raw
  344. const id = o.id ?? o.user_id
  345. const accountName =
  346. o.alias ??
  347. o.english_name ??
  348. o.englishName ??
  349. o.username ??
  350. o.account_name ??
  351. o.account ??
  352. ''
  353. const org =
  354. o.org_name || o.orgName || o.organization_name || o.company_name || ''
  355. return {
  356. name:
  357. o.name ||
  358. o.nickname ||
  359. o.display_name ||
  360. o.displayName ||
  361. o.real_name ||
  362. o.realName ||
  363. o.full_name ||
  364. o.fullName ||
  365. '',
  366. id: id != null ? String(id) : '',
  367. avatar: o.avatar || o.avatar_url || o.avatarUrl || '',
  368. alias: accountName,
  369. org_name: org,
  370. orgName: org
  371. }
  372. }
  373. /**
  374. * 解析 JWT 获取 userId(与桌面端 getUserIdFromToken 类似)
  375. */
  376. export function getUserIdFromToken(accessToken) {
  377. try {
  378. if (!accessToken) return ''
  379. const parts = String(accessToken).split('.')
  380. if (parts.length < 2) return ''
  381. const payload = parts[1]
  382. // base64url -> base64
  383. const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
  384. // 补齐 padding
  385. const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
  386. const json = decodeURIComponent(
  387. atob(b64 + pad)
  388. .split('')
  389. .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
  390. .join('')
  391. )
  392. const obj = JSON.parse(json)
  393. return String(obj.user_id ?? obj.userId ?? obj.sub ?? obj.id ?? '')
  394. } catch (e) {
  395. return ''
  396. }
  397. }
  398. /**
  399. * 可选:联系人搜索(用于补全当前用户名)
  400. * 路径以桌面端 api.ts 为准;若后端不同请调整
  401. */
  402. export function searchContacts(token, keyword = '', limit = 100) {
  403. return request({
  404. token,
  405. url: `/contacts/search?keyword=${encodeURIComponent(keyword)}&limit=${limit}`,
  406. method: 'GET'
  407. })
  408. }
  409. /**
  410. * 用户搜索:GET /api/v1/users/search?q=关键词&limit=20
  411. * 搜索字段:手机号、姓名、英文名
  412. */
  413. export function searchUsers(token, q = '', limit = 20) {
  414. return request({
  415. token,
  416. url: `/users/search?q=${encodeURIComponent(q || '')}&limit=${limit}`,
  417. method: 'GET'
  418. })
  419. }
  420. /**
  421. * 用户列表分页检索:GET /api/v1/users/?skip=0&limit=20&keyword=xxx
  422. */
  423. export function listUsers(token, skip = 0, limit = 20, keyword = '') {
  424. return request({
  425. token,
  426. url: `/users/?skip=${skip}&limit=${limit}&keyword=${encodeURIComponent(keyword || '')}`,
  427. method: 'GET'
  428. })
  429. }
  430. /**
  431. * 应用中心(快捷导航)- 获取快捷导航应用列表
  432. * GET /api/v1/simple/me/launchpad-apps
  433. */
  434. export function getLaunchpadApps() {
  435. return request({
  436. url: '/simple/me/launchpad-apps',
  437. method: 'GET'
  438. })
  439. }
  440. /**
  441. * 应用中心(快捷导航)- 点击应用:SSO 登录获取 redirect_url
  442. * POST /api/v1/simple/sso-login
  443. */
  444. export function ssoLogin(appId, username = '', password = '') {
  445. return request({
  446. method: 'POST',
  447. url: '/simple/sso-login',
  448. data: {
  449. app_id: String(appId || ''),
  450. username: username == null ? '' : String(username),
  451. password: password == null ? '' : String(password)
  452. }
  453. })
  454. }
  455. /**
  456. * 图形验证码(开放接口)
  457. * GET /utils/captcha → { captcha_id, image, expire_seconds }
  458. */
  459. export function getCaptcha() {
  460. return request({
  461. token: '',
  462. url: '/utils/captcha',
  463. method: 'GET'
  464. })
  465. }
  466. /**
  467. * 开放:校验图形码后发送短信(忘记密码)
  468. * POST /open/sms/send
  469. */
  470. export function sendOpenSms(mobile, captcha_id, captcha_code) {
  471. return request({
  472. token: '',
  473. url: '/open/sms/send',
  474. method: 'POST',
  475. data: { mobile, captcha_id, captcha_code }
  476. })
  477. }
  478. /**
  479. * 开放:短信校验后重置密码
  480. * POST /open/pwd/reset
  481. */
  482. export function resetPasswordOpen(mobile, sms_code, new_password) {
  483. return request({
  484. token: '',
  485. url: '/open/pwd/reset',
  486. method: 'POST',
  487. data: { mobile, sms_code, new_password }
  488. })
  489. }
  490. /**
  491. * 已登录修改密码:旧密码方式(需 Bearer)
  492. * POST /simple/me/change-password
  493. * 移动端「修改密码」页已改为短信验证码 + /open/pwd/reset,不再调用本方法
  494. */
  495. export function changePasswordLoggedIn(old_password, new_password) {
  496. return request({
  497. url: '/simple/me/change-password',
  498. method: 'POST',
  499. data: { old_password, new_password }
  500. })
  501. }
  502. /**
  503. * 与后端约定一致:TEXT / IMAGE / VIDEO / FILE / USER_NOTIFICATION
  504. */
  505. export function normalizeMessageContentType(ct) {
  506. if (ct == null || ct === '') return 'TEXT'
  507. const upper = String(ct).trim().toUpperCase()
  508. if (['TEXT', 'IMAGE', 'VIDEO', 'FILE', 'USER_NOTIFICATION'].includes(upper)) return upper
  509. const lower = String(ct).toLowerCase()
  510. if (lower === 'image') return 'IMAGE'
  511. if (lower === 'video') return 'VIDEO'
  512. if (lower === 'file') return 'FILE'
  513. if (lower === 'text') return 'TEXT'
  514. return 'TEXT'
  515. }
  516. /**
  517. * 根据文件名/类型得到 content_type
  518. */
  519. export function getContentType(file) {
  520. if (!file) return 'FILE'
  521. const name = (file.name || file.path || '').toLowerCase()
  522. const type = (file.type || '').toLowerCase()
  523. if (type.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(name)) return 'IMAGE'
  524. if (type.startsWith('video/') || /\.(mp4|mov|avi|webm|mkv)(\?|$)/i.test(name)) return 'VIDEO'
  525. return 'FILE'
  526. }