api.js 15 KB

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