Kaynağa Gözat

feat: 修复患者头像 ID 精度丢失问题,确保 ID 作为字符串处理以避免 JS Number 精度问题

mcbaiyun 3 hafta önce
ebeveyn
işleme
2bd10b4156

+ 158 - 0
docs/患者头像ID精度丢失修复总结.md

@@ -0,0 +1,158 @@
+# 患者头像 ID 精度丢失修复总结
+
+> 说明:本文件汇总来自 `医生首页` 患者动态(`patientActivities`)中,用户 ID 精度丢失导致头像请求错误的问题,以及解决过程、关键修改点、测试要点与后续建议。文档以中文撰写。
+
+## 一、问题简介 ✅
+
+在页面 `src/pages/doctor/index/index.vue` 中,患者动态数据原始接口返回如下(示例):
+
+```
+{"id":"1991836353626906625","userId":1988147181088956418,...}
+```
+
+但浏览器/小程序客户端请求头像的 URL 为:
+
+```
+https://wx.baiyun.work/user/avatar/1988147181088956400
+```
+
+可见 `userId` 发生了数值精度丢失,最后几位被截断或舍入,导致错误的头像 URL 发起请求,无法加载正确头像。
+
+## 二、根本原因(分析)🔎
+
+- JavaScript 的 Number 类型为 IEEE 64-bit 双精度浮点(double),整数精度仅保证到 Number.MAX_SAFE_INTEGER(2^53 - 1,约 9e15)。
+- 接口返回的 ID 属于大整数(大于 2^53),若前端把这些 ID 当作 Number 存储或解析(例如 JSON.parse 默认把数字解析为 Number),则会发生精度丢失。
+- 精度丢失会在后续使用该 ID 构造 URL、缓存 key 或其它字符串拼接中体现出来(例如拼接头像 URL),导致请求错误。
+
+## 三、修复要点(已实施的核心变更)🔧
+
+1. **在 API 层/前端解析层把 ID 强制转换为字符串(string)**
+   - 关键思路:无论后端返回时 ID 是字符串还是数字,前端都要统一按字符串处理。这样不会被 Number 精度限制所影响。
+   - 示例:在 `api` 的响应处理或 `userActivity` 的接口方法中,转换记录字段:
+
+```ts
+// src/api/userActivity.ts (示例)
+const resp = response.data as any
+if (resp && resp.code === 200 && resp.data) {
+  const safeRecords = resp.data.records.map((r: any) => ({
+    ...r,
+    id: r.id === null ? null : String(r.id),
+    userId: r.userId === null ? null : String(r.userId),
+    relatedEntityId: r.relatedEntityId === null ? null : String(r.relatedEntityId)
+  }))
+  // 继续处理 safeRecords...
+}
+```
+
+2. **修改 TypeScript 类型**
+   - 把 API 接口里所有表示主键/外键(如 `id`, `userId`, `relatedEntityId`)**类型从 `number` 改为 `string`**,或使用 `string | null`。
+
+3. **前端组件中使用字符串 ID**
+   - 在 `getPatientAvatar` 和任何缓存键、URL 拼接处确保使用字符串:`const idStr = String(userId)`(注意这是在 ID 还未丢失的情况下才有效)。
+   - 更好方案:在接口处理入口就把所有记录的 `userId` 等字段转为 `string`,前端组件直接使用。
+
+4. **缓存 key 的统一**
+   - 把 `avatarCache` 的 key 统一为字符串,避免使用 Number 当 key:
+
+```ts
+// avatarCache.set(String(userId), tempFilePath)
+// avatarCache.get(String(userId))
+```
+
+5. **避免在 JSON.parse 后直接使用数字**
+   - 如果从后端一端得到大整数并要使用 `JSON.parse`,可以用 `json-bigint` 之类的库或 reviver,将 ID 字段解析为字符串。示例:
+
+```ts
+// 使用 JSONBig(json-bigint 库)
+import JSONbig from 'json-bigint'
+const resp = JSONbig.parse(rawResponse)
+// JSONbig 能保留大整数字符串或 BigInt,视配置而定。
+```
+
+或者使用 reviver:
+
+```ts
+const parsed = JSON.parse(raw, (k, v) => {
+  if (['id', 'userId', 'relatedEntityId'].includes(k)) return v === null ? null : String(v)
+  return v
+})
+```
+
+## 四、代码片段(推荐)📄
+
+1) API 层响应转换(Node/TS 或前端 API 出片)
+
+```ts
+function normalizeIdsInRecord<T extends Record<string, any>>(record: T): T {
+  return {
+    ...record,
+    id: record.id === null ? null : String(record.id),
+    userId: record.userId === null ? null : String(record.userId),
+    relatedEntityId: record.relatedEntityId === null ? null : String(record.relatedEntityId)
+  }
+}
+
+// 使用:
+const safeRecords = resp.data.records.map(normalizeIdsInRecord)
+```
+
+2) 组件层防护(已在 `index.vue` 中使用,一并保证):
+
+```ts
+// 在 getPatientAvatar 中
+const idStr = String(userId) // 确保传入的是字符串
+// 拼接 URL
+const url = `https://wx.baiyun.work/user/avatar/${idStr}`
+```
+
+3) Typescript 类型建议(定义):
+
+```ts
+export interface ActivityRecord {
+  id: string | null
+  userId: string | null
+  relatedEntityId?: string | null
+  activityType: string
+  activityDescription?: string
+  createTime: string
+  // ... other fields
+}
+```
+
+## 五、验证(测试步骤)✅
+
+1. 使用原始接口返回样例(例如请求中包含大整数 ID)进行手动或自动测试:确保前端发出的头像请求 URL 中的 ID 与服务端返回的 ID 完全一致(逐字符匹配)。
+2. 检查 Network 面板或小程序调试器中 `uni.downloadFile` 请求 URL,确认使用字符串化的 ID。
+3. 检查头像缓存是否正确命中:
+   - 首次请求时,头像会下载并存入 `avatarCache`(键为字符串)。
+   - 再次打开页面或刷新时,`avatarCache.get` 能成功返回缓存路径。
+4. 编写小型自动化/单元测试来验证 `normalizeIdsInRecord` 或 API 层 transform 的行为,确保数字和字符串输入都能正确转换为字符串输出。
+
+## 六、回溯影响点(代码库中需要检查的位置)📌
+
+- `src/api` 下所有接口返回中含 `id`, `userId`, `relatedEntityId` 的地方
+- `src/pages/**` 中对 `userId` 进行拼接或作为缓存 key 的地方
+- `avatarCache` 的实现(键类型)
+- `uni.getStorageSync('user_info')` / `fetchUserInfo` 中是否把 `id` 等字段当作 number 使用
+- 任何使用 `JSON.parse` 直接处理接口原始返回值的地方
+
+## 七、后续建议(优先级排序)📋
+
+1. 后端约定:建议后端在接口里统一返回 `id`、`userId` 等主键为字符串类型(比如 JSON 的字符串 '123'),避免在前端做相应处理(最高优先级)。
+2. 在前端建立一个统一的 `normalizeId` 或 API 层统一解析方法,确保任何 API 响应里所有 ID 都统一为字符串(中优先级)。
+3. 更新 TypeScript 的类型定义(`api` 层与前端数据模型)以使用字符串 ID(中优先级)。
+4. 增加自动化测试 / 集成测试,确保大整数 ID 在前端流水线里不会被误解析为 Number(中优先级)。
+5. 添加额外的 PR 模板和检查项:在代码审查时特别关注 `id` 字段类型是否被误用为 Number(低优先级)。
+
+## 八、迁移步骤(如果后端改回字符串)🛠️
+
+- 升级后端返回 ID 为字符串(例如 `"1988147181088956418"`)后,前端可以把 `id` 定义为 `string` 类型,不需要额外的 `String()` 转换。
+- 若后端短时间内不能更改,则继续使用前端的 normalize 层作为兼容方案。
+
+## 九、恢复验证的检查点(能否被回归)🔁
+
+- 在修复后,打开患者动态页面,确认:
+  - network 请求中头像 URL 中的 ID 与接口原始 ID 一致
+  - 患者头像显示正常(不再请求到 1988147181088956400 的错误文件)
+  - 缓存正常命中(`avatarCache`)
+

+ 49 - 4
src/api/userActivity.ts

@@ -32,7 +32,7 @@ export const RELATED_ENTITY_TYPES = {
 
 // 查询患者动态的请求参数接口
 export interface PatientActivityQueryRequest {
-  patientUserId: number // 患者用户ID(医生查询时必填)
+  patientUserId: string // 患者用户ID(医生查询时必填),使用字符串保存以避免 JS Number 精度丢失
   activityTypes?: string[] // 活动类型列表(可选,用于过滤特定动态)
   startTime?: string // 开始时间
   endTime?: string // 结束时间
@@ -42,12 +42,12 @@ export interface PatientActivityQueryRequest {
 
 // 活动响应接口
 export interface UserActivityResponse {
-  id: string // 活动ID
-  userId: number // 操作用户ID
+  id: string // 活动ID(字符串)
+  userId: string // 操作用户ID(字符串)
   activityType: string // 活动类型
   activityDescription: string // 活动描述
   relatedEntityType: string // 相关实体类型
-  relatedEntityId?: number // 相关实体ID
+  relatedEntityId?: string // 相关实体ID(字符串,可选)
   metadata?: any // 元数据
   createTime: string // 操作时间
 }
@@ -73,6 +73,21 @@ export async function queryPatientActivities(params: PatientActivityQueryRequest
     header: { 'Content-Type': 'application/json' },
     data: params
   })
+  // 将 Snowflake 风格 ID 字段强制转为字符串,避免 JS Number 精度问题
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && Array.isArray(parsed.data.records)) {
+      parsed.data.records = parsed.data.records.map((r: any) => ({
+        ...r,
+        id: String(r.id),
+        userId: String(r.userId),
+        relatedEntityId: r.relatedEntityId == null ? undefined : String(r.relatedEntityId)
+      }))
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
   return res
 }
 
@@ -88,6 +103,21 @@ export async function queryBoundPatientsActivities(params?: Omit<PatientActivity
     header: { 'Content-Type': 'application/json' },
     data: params || {}
   })
+  // 将 Snowflake 风格 ID 字段强制转为字符串,避免 JS Number 精度问题
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && Array.isArray(parsed.data.records)) {
+      parsed.data.records = parsed.data.records.map((r: any) => ({
+        ...r,
+        id: String(r.id),
+        userId: String(r.userId),
+        relatedEntityId: r.relatedEntityId == null ? undefined : String(r.relatedEntityId)
+      }))
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
   return res
 }
 
@@ -103,6 +133,21 @@ export async function queryBoundFamiliesActivities(params?: Omit<PatientActivity
     header: { 'Content-Type': 'application/json' },
     data: params || {}
   })
+  // 将 Snowflake 风格 ID 字段强制转为字符串,避免 JS Number 精度问题
+  try {
+    const parsed = res?.data as any
+    if (parsed && parsed.code === 200 && parsed.data && Array.isArray(parsed.data.records)) {
+      parsed.data.records = parsed.data.records.map((r: any) => ({
+        ...r,
+        id: String(r.id),
+        userId: String(r.userId),
+        relatedEntityId: r.relatedEntityId == null ? undefined : String(r.relatedEntityId)
+      }))
+      res.data = parsed
+    }
+  } catch (e) {
+    // ignore
+  }
   return res
 }
 

+ 10 - 7
src/pages/doctor/index/index.vue

@@ -175,7 +175,8 @@ const fetchUserInfo = async () => {
     if (resp && resp.code === 200 && resp.data) {
       // 如果头像无效(不是有效的 http URL),则下载头像
       if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
-        const userId = resp.data.id || resp.data.userId
+        const userIdRaw = resp.data.id || resp.data.userId
+        const userId = userIdRaw ? String(userIdRaw) : ''
         if (userId) {
           // 检查是否有缓存的头像
           if (avatarCache.has(userId)) {
@@ -252,7 +253,8 @@ const fetchPatientActivities = async () => {
       const activitiesPromises = resp.data.records.map(async (activity: any) => ({
         desc: formatActivityDescription(activity),
         time: formatTime(activity.createTime),
-        patientAvatar: await getPatientAvatar(activity.userId)
+        // 强制把 userId 转为字符串,避免数值精度或类型不匹配引起的问题
+        patientAvatar: await getPatientAvatar(String(activity.userId))
       }))
       
       // 等待所有头像获取完成
@@ -392,19 +394,20 @@ function onMessageClick() {
 }
 
 // 获取患者头像
-const getPatientAvatar = async (userId: string): Promise<string> => {
+const getPatientAvatar = async (userId: string | number): Promise<string> => {
   try {
     const token = uni.getStorageSync('token')
     if (!token) return defaultAvatarUrl
     
     // 检查是否有缓存的头像
-    if (avatarCache.has(userId)) {
-      return avatarCache.get(userId)!
+    const idStr = String(userId)
+    if (avatarCache.has(idStr)) {
+      return avatarCache.get(idStr)!
     }
     
     // 尝试下载用户头像
     const downloadRes = await uni.downloadFile({
-      url: `https://wx.baiyun.work/user/avatar/${userId}`,
+      url: `https://wx.baiyun.work/user/avatar/${idStr}`,
       header: {
         Authorization: `Bearer ${token}`
       }
@@ -412,7 +415,7 @@ const getPatientAvatar = async (userId: string): Promise<string> => {
     
     if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
       // 缓存头像路径
-      avatarCache.set(userId, downloadRes.tempFilePath)
+      avatarCache.set(idStr, downloadRes.tempFilePath)
       return downloadRes.tempFilePath
     }
   } catch (e) {

+ 4 - 3
src/pages/doctor/index/my-patients.vue

@@ -104,14 +104,15 @@ const fetchPatients = async () => {
         try {
           if (patient.patientUserId) {
             // 检查是否有缓存的头像
-            if (avatarCache.has(patient.patientUserId)) {
-              patient.avatar = avatarCache.get(patient.patientUserId)
+            const patientIdStr = String(patient.patientUserId)
+            if (avatarCache.has(patientIdStr)) {
+              patient.avatar = avatarCache.get(patientIdStr)
             } else {
               const dlRes: any = await downloadAvatar(String(patient.patientUserId))
               if (dlRes && dlRes.statusCode === 200 && dlRes.tempFilePath) {
                 patient.avatar = dlRes.tempFilePath
                 // 缓存头像路径
-                avatarCache.set(patient.patientUserId, dlRes.tempFilePath)
+                avatarCache.set(patientIdStr, dlRes.tempFilePath)
               }
             }
           }

+ 5 - 4
src/pages/patient/profile/index.vue

@@ -104,18 +104,19 @@ const fetchUserInfo = async () => {
     if (resp && resp.code === 200 && resp.data) {
       // 如果头像无效(不是有效的 http URL),则下载头像
       if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
-        const userId = resp.data.id || resp.data.userId
+        const userIdRaw = resp.data.id || resp.data.userId
+        const userId = userIdRaw ? String(userIdRaw) : ''
         if (userId) {
           // 检查是否有缓存的头像
-          if (avatarCache.has(userId)) {
-            resp.data.avatar = avatarCache.get(userId)
+          if (avatarCache.has(String(userId))) {
+            resp.data.avatar = avatarCache.get(String(userId))
           } else {
             try {
               const downloadRes = await downloadAvatarApi(userId)
               if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
                 resp.data.avatar = downloadRes.tempFilePath
                 // 缓存头像路径
-                avatarCache.set(userId, downloadRes.tempFilePath)
+                avatarCache.set(String(userId), downloadRes.tempFilePath)
               }
             } catch (e) {
               console.error('Download avatar error:', e)

+ 7 - 1
src/utils/jsonBig.ts

@@ -1,9 +1,15 @@
+// 简单安全的 JSON 解析器,可在解析前把 Snowflake 类的长整数字段(如 id、userId、patientUserId、boundUserId、relatedEntityId)
+// 转换为字符串(通过在值外包裹引号)以避免 JS Number 导致的精度丢失。
+// 注意:这是一个保守实现,仅针对常见字段名做文本替换;如果你需要更强健的解决方案,考虑使用 json-bigint 等专用库。
 const jsonbig = JSON
 
 export function safeJsonParse(raw: string | object | undefined | null) {
   if (typeof raw === 'string') {
     try {
-      return jsonbig.parse(raw)
+      // 将长整数字段包装为字符串,避免 JSON.parse 将其转换为 Number 并引发精度丢失。
+      // 匹配示例: "userId":1988147181088956418 -> "userId":"1988147181088956418"
+      const preprocessed = raw.replace(/"(id|userId|patientUserId|boundUserId|relatedEntityId)"\s*:\s*([0-9]{10,})/g, '"$1":"$2"')
+      return jsonbig.parse(preprocessed)
     } catch (err) {
       return raw
     }