memberNormalize.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. /**
  2. * 家庭成员去重规范化工具(T25 统一成员选择器 · 层3 前端防御)
  3. *
  4. * 背景:
  5. * family_members 存量数据中同一 (user_id, family_id) 可能存在多条重复行,
  6. * 导致同一用户在前端被渲染成两项("本人" + "本人所对应的成员")。
  7. *
  8. * 重要边界(见设计文档 §一.3):
  9. * 本工具只是**前端防御层**。重复的 family_members 行各自挂着不同的外键数据
  10. * (任务/打卡/健康等),单纯折叠会"隐藏"其中一行的数据。
  11. * 真正的根治是数据层合并(T24-C),需单独授权执行。
  12. * 后端 FamilyMemberService.listMembers 已按 user_id 做确定性折叠,
  13. * 本工具用于兜住仍返回重复行的历史接口/前端缓存数据。
  14. *
  15. * 折叠规则:
  16. * 1. 按 user_id 分组折叠(userId 为空的"不能登录用户"各自独立成组)
  17. * 2. canonical 行优先级:isSelf 行 → status='active' → 外键数据最多 → id 最小
  18. * 3. 每个真实用户至多输出一行,且全列表 isSelf 唯一
  19. * 4. 统一身份键为 familyMemberId(family_members.id),userId 仅作折叠依据
  20. */
  21. /**
  22. * 取成员的统一身份键(familyMemberId)。
  23. * 后端 FamilyMemberVO 已下发 familyMemberId;历史接口缺失时回退到 id。
  24. */
  25. export function memberIdOf(member) {
  26. if (!member) return null
  27. if (member.familyMemberId !== null && member.familyMemberId !== undefined && member.familyMemberId !== '') {
  28. return member.familyMemberId
  29. }
  30. if (member.id !== null && member.id !== undefined && member.id !== '') {
  31. return member.id
  32. }
  33. return null
  34. }
  35. /** 成员是否为有效状态(active 优先;未带 status 字段的历史数据视为 active) */
  36. function isActive(member) {
  37. if (!member || member.status === null || member.status === undefined || member.status === '') {
  38. return true
  39. }
  40. return member.status === 'active'
  41. }
  42. /**
  43. * 成员"数据量"权重(用于 canonical 选取的第三级 tie-break)。
  44. * 后端若下发 fkCount(外键引用数)则直接采用;否则用可见的数据丰度做近似。
  45. */
  46. function dataWeight(member) {
  47. if (!member) return 0
  48. if (typeof member.fkCount === 'number' && !isNaN(member.fkCount)) return member.fkCount
  49. if (typeof member.refCount === 'number' && !isNaN(member.refCount)) return member.refCount
  50. var n = 0
  51. if (member.totalPoints) n += 1
  52. if (member.streakDays) n += 1
  53. if (member.lastInteractionAt) n += 1
  54. if (member.trustScore !== null && member.trustScore !== undefined) n += 1
  55. if (member.intimacyScore !== null && member.intimacyScore !== undefined) n += 1
  56. if (member.communicationScore !== null && member.communicationScore !== undefined) n += 1
  57. return n
  58. }
  59. function numericId(member) {
  60. var id = memberIdOf(member)
  61. var n = Number(id)
  62. return isNaN(n) ? Number.MAX_SAFE_INTEGER : n
  63. }
  64. /**
  65. * canonical 行比较器:返回 <0 表示 a 更优先。
  66. * 优先级:isSelf → status='active' → 数据量最多 → id 最小。
  67. */
  68. function compareCanonical(a, b) {
  69. var aSelf = a && a.isSelf ? 1 : 0
  70. var bSelf = b && b.isSelf ? 1 : 0
  71. if (aSelf !== bSelf) return bSelf - aSelf
  72. var aActive = isActive(a) ? 1 : 0
  73. var bActive = isActive(b) ? 1 : 0
  74. if (aActive !== bActive) return bActive - aActive
  75. var aWeight = dataWeight(a)
  76. var bWeight = dataWeight(b)
  77. if (aWeight !== bWeight) return bWeight - aWeight
  78. return numericId(a) - numericId(b)
  79. }
  80. function hasLoginUser(member) {
  81. return !!member && member.userId !== null && member.userId !== undefined && member.userId !== '' && Number(member.userId) !== 0
  82. }
  83. /**
  84. * 规范化成员列表。
  85. *
  86. * @param {Array} rawMembers 原始成员数组(后端 VO 或其变体)
  87. * @param {Object} [viewer] 当前查看者,形如 { userId, memberId }(可只给其一)
  88. * @returns {Array} 每个真实用户至多一条、isSelf 唯一的成员数组
  89. */
  90. export function normalizeMembers(rawMembers, viewer) {
  91. var list = rawMembers instanceof Array ? rawMembers : []
  92. if (list.length === 0) return []
  93. var viewerUserId = viewer && viewer.userId !== null && viewer.userId !== undefined && viewer.userId !== ''
  94. ? String(viewer.userId) : ''
  95. var viewerMemberId = viewer && viewer.memberId !== null && viewer.memberId !== undefined && viewer.memberId !== ''
  96. ? String(viewer.memberId) : ''
  97. // 1) 按 user_id 分组(保留首次出现顺序)
  98. var groups = []
  99. var groupIndex = {}
  100. for (var i = 0; i < list.length; i++) {
  101. var row = list[i]
  102. if (!row || typeof row !== 'object') continue
  103. var key
  104. if (hasLoginUser(row)) {
  105. key = 'u:' + String(row.userId)
  106. } else {
  107. // 不能登录用户(无 userId):每行独立,按自身 id 去重
  108. key = 'm:' + String(row.id !== undefined && row.id !== null ? row.id : ('idx:' + i))
  109. }
  110. if (groupIndex[key] === undefined) {
  111. groupIndex[key] = groups.length
  112. groups.push([])
  113. }
  114. groups[groupIndex[key]].push(row)
  115. }
  116. // 2) 每组选 canonical 行(shallow-clone 输出,避免污染父组件共享的成员数组)
  117. var result = []
  118. for (var g = 0; g < groups.length; g++) {
  119. var group = groups[g]
  120. var winner = group[0]
  121. if (group.length > 1) {
  122. for (var k = 1; k < group.length; k++) {
  123. if (compareCanonical(group[k], winner) < 0) {
  124. winner = group[k]
  125. }
  126. }
  127. }
  128. var copy = {}
  129. for (var key in winner) {
  130. if (Object.prototype.hasOwnProperty.call(winner, key)) {
  131. copy[key] = winner[key]
  132. }
  133. }
  134. result.push(copy)
  135. }
  136. // 3) isSelf 唯一化:优先认 viewer,其次保留后端标记的那一条
  137. var selfIndex = -1
  138. if (viewerMemberId) {
  139. for (var j = 0; j < result.length; j++) {
  140. if (String(memberIdOf(result[j])) === viewerMemberId) {
  141. selfIndex = j
  142. break
  143. }
  144. }
  145. }
  146. if (selfIndex < 0 && viewerUserId) {
  147. for (var j2 = 0; j2 < result.length; j2++) {
  148. if (hasLoginUser(result[j2]) && String(result[j2].userId) === viewerUserId) {
  149. selfIndex = j2
  150. break
  151. }
  152. }
  153. }
  154. if (selfIndex < 0) {
  155. for (var j3 = 0; j3 < result.length; j3++) {
  156. if (result[j3] && result[j3].isSelf) {
  157. selfIndex = j3
  158. break
  159. }
  160. }
  161. }
  162. for (var j4 = 0; j4 < result.length; j4++) {
  163. result[j4].isSelf = (j4 === selfIndex)
  164. }
  165. // 4) 补齐统一身份键 + 渲染用唯一 key
  166. var usedKeys = {}
  167. for (var j5 = 0; j5 < result.length; j5++) {
  168. var m = result[j5]
  169. var mid = memberIdOf(m)
  170. m.familyMemberId = mid
  171. m.memberId = mid
  172. var base = (m.source === 'parent' ? 'parent:' : 'member:') + String(mid)
  173. if (usedKeys[base]) {
  174. var n = 2
  175. while (usedKeys[base + '#' + n]) n++
  176. base = base + '#' + n
  177. }
  178. usedKeys[base] = true
  179. m.selectorKey = base
  180. }
  181. return result
  182. }
  183. /** 从(已规范化或未规范化的)成员列表中找出"本人"一行 */
  184. export function findSelfMember(members) {
  185. if (!(members instanceof Array)) return null
  186. for (var i = 0; i < members.length; i++) {
  187. if (members[i] && members[i].isSelf) return members[i]
  188. }
  189. return null
  190. }
  191. /** 从成员列表中排除"本人"行(用于"个人 + 各成员"形态,避免同一人出现两项) */
  192. export function membersExceptSelf(members) {
  193. if (!(members instanceof Array)) return []
  194. var out = []
  195. for (var i = 0; i < members.length; i++) {
  196. if (members[i] && !members[i].isSelf) out.push(members[i])
  197. }
  198. return out
  199. }
  200. export default normalizeMembers