Sfoglia il codice sorgente

fix(frontend): 根治重复API调用——首页合并 family/member/list 与 membership/my、全页面 onLoad/onShow 防双发 + 请求层 3 秒去重 + getChildren 废弃委托

- 首页 index-home: loadFamilyMembers+loadChildren 合并为 loadFamilyData 单次请求,响应内过滤 effectiveRole=child
- 首页 index-home: checkMemberStatus 移入 loggedInInitDone 守卫,onLoad/onShow 用 _onLoadFired 防首次双发
- 首页 index-home: loadFamilyFeed 循环前过滤孩子成员,避免对家长发起 getTaskHistory
- 全页面: energy/hierarchy/share/survey-history/parent-index/join/notification/onboarding/address/cart/checkout/shop-index/daily-tasks/service-tasks/teacher-home/wealth-cf 统一 _onLoadFired 模式
- parent-index: dashboard 降级路径同步设置 familyMembersVisible,避免重复请求 getFamilyMemberList
- utils/api.js: 全局请求去重(同 URL+method+body 3 秒窗口复用 Promise),401 重放跳过去重
- utils/api.js: getChildren/getUserBadges/getGrowthReport/getTaskHistoryByCategory 等别名统一委托规范函数
- 新增 scripts/audit-duplicate-api-calls.js CI 审计脚本(0 error)
- AGENTS.md: 新增规则 1-4 + 4a/4b/4c(生命周期双发、同接口单封装、N+1 过滤、getChildren 废弃)
liaoxg 2 settimane fa
parent
commit
35ed064b94

+ 63 - 2
cfc-frontend/AGENTS.md

@@ -86,7 +86,68 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **地址选择:** 使用 `components/address-picker.vue` 四级联动组件
 - **角色切换:** 页面根据用户角色显示不同内容
 - **页面标题:** 所有页面的标题必须在 `pages.json` 的 `navigationBarTitleText` 中配置,禁止在 Vue 模板中手动写 `<text class="nav-title">`。仅动态标题页面(根据数据实时变化)可保留自定义 nav-bar
-- **API 调用去重:** 同一个接口在同一个页面生命周期内只调用一次。禁止在 onLoad/onShow/mounted 中多次调用同一 URL。如需依赖数据,在首次请求的 then 回调中处理后续逻辑
+- **API 调用去重(强制规范 · 违反者会被 CI 拦截):** 同一个后端 URL 在同一个页面首次加载时只允许发起一次网络请求。这是硬性规则,不是建议。违规会通过 `scripts/audit-duplicate-api-calls.js` 被检出(exit code 1)。
+
+  **为什么是根本性的:** 微信小程序生命周期在页面首次进入时 `onLoad` 和 `onShow` 都会触发,`utils/api.js` 的 3 秒去重是兜底,不是免死金牌——去重只能掩盖重复调用,不能消除它带来的延迟、内存和可维护性问题。
+
+  **规则 1 · 生命周期双发(最常见):** `onLoad` 和 `onShow` 不得同时无条件调用同一个加载方法。必须使用 `_onLoadFired` 标志位让首次 `onShow` 跳过,切 Tab 返回时刷新:
+  ```js
+  // 正确
+  onLoad(options) {
+    this.loadData(options)
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
+    } else {
+      this.loadData()              // 切 Tab 返回:刷新数据
+    }
+  },
+  ```
+  如果 `onShow` 的加载是"从子页面返回后的条件性刷新"(如地址编辑页返回),同样适用该模式——将条件判断放在 `else` 分支内。
+
+  **规则 2 · 同接口多封装(utils/api.js):** `utils/api.js` 中,同一个后端 URL 只能有**一个**包含 `request()` 调用的导出函数作为规范入口;语义相近的别名必须**委托**到规范函数,禁止复制 `request()` 调用:
+  ```js
+  // 规范入口
+  export const getFamilyMemberList = (params) => request('/api/family/member/list', 'POST', params || {})
+  // 别名(委托,不复制 request 调用)
+  export const getChildren = () => getFamilyMemberList({ memberType: 'child' })
+  ```
+  同 URL 不同 HTTP 方法 / 不同 `params` 的变体允许共存(如 `createPackage` POST data vs `getMyPackages` POST {}),但须在代码注释中标明差异。
+
+  **规则 3 · 一个方法只打一次接口:** 同一个方法内不得发起对同一 URL 的两次调用。常见反模式:`loadLoggedInData()` 中同时调用 `loadFamilyMembers()` 和 `loadChildren()`,二者封装了同一个 URL → 合并为一个请求,从响应中派生不同用途的数据:
+  ```js
+  // 错误:同一 URL 打两次
+  loadLoggedInData() { this.loadFamilyMembers(); this.loadChildren() }
+  // 正确:打一次,派生两类数据
+  loadFamilyData() {
+    getFamilyMemberList({ visibleOnly: true }).then(res => {
+      var arr = res.data
+      this.familyMembersVisible = arr
+      this.children = arr.filter(m => m.effectiveRole === 'child')
+    })
+  }
+  ```
+
+  **规则 4 · 禁止 N+1 循环请求:** 不得在 `.map()` / `.forEach()` / `for` 循环体内发起 API 请求。N 个元素 → N 次请求。应改为:(a) 后端提供批量接口一次返回;(b) 如果确实需要多元素多请求,须用 `Promise.all()` 并发执行(减少串行等待),同时确认后端能承受并发量;(c) 在循环前做去重/过滤,减少实际请求数。
+
+  **规则 4a · 循环前过滤成员类型(常见反模式):** 调用 `getTaskHistory` 等按成员 ID 轮询的接口时,**必须先过滤 `effectiveRole === 'child'`**,不得对家庭成员全量列表(含家长)发起请求。示例:
+  ```js
+  // 正确:过滤后轮询
+  var children = members.filter(m => m.effectiveRole === 'child')
+  Promise.all(children.map(c => getTaskHistory(c.id, 1, 3)))
+  // 错误:对全部成员(含家长)发起请求
+  Promise.all(members.map(m => getTaskHistory(m.id, 1, 3)))
+  ```
+
+  **规则 4b · getChildren 已废弃:** `getChildren()` 内部委托 `getFamilyMemberList({})`,二者调用同一接口。新代码直接使用 `getFamilyMemberList` 并在页面内按 `effectiveRole === 'child'` 过滤,不要同时导入 `getChildren` 和 `getFamilyMemberList`。
+
+  **规则 4c · onLoad/onShow 首次加载只走一次:** 小程序 TabBar 页面首次加载时 `onLoad` 和 `onShow` 均会触发。所有 API 调用必须确保首次只执行一次:在 `onLoad` 中设置 `_onLoadFired = true`,`onShow` 中检查该标志——首次为 `true` 则跳过(由 `onLoad` 负责),后续切 Tab 返回时标志已重置为 `false` 才刷新。`checkMemberStatus` 等高频方法同样适用。详见规则 1 代码示例。
+
+  **CI 门禁:** `scripts/audit-duplicate-api-calls.js` 是 CI 必跑脚本。运行方式:`node scripts/audit-duplicate-api-calls.js`,退出码 `0` = 通过,`1` = 存在未解决的 ERROR 级违规。每个新页面提交前必须通过审计。
+
+  **子组件接口调用:** 子组件不得重复调用父页面已调用的接口。父组件应通过 props 传递数据,而非让子组件各自请求。
 
 ## 时间显示规范
 
@@ -121,7 +182,7 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 
 
 - **NEVER** 在页面模板中写 `<text class="nav-title">` 或自定义 nav-bar 标题。所有静态标题统一在 `pages.json` 的 `navigationBarTitleText` 中配置。需要运行时动态标题时才用自定义 nav-bar
-- **NEVER** 在同一个页面中多次调用相同的 API 接口。如果第一次请求的结果需要用于后续请求的参数,在第一次请求的 success/then 回调中发起第二次请求,而不是并行调用
+- **NEVER** 在同一个页面中多次调用相同的 API 接口。`utils/api.js` 的 `request()` 已内置 3 秒窗口去重(同 URL + method + body 自动复用 Promise),但这是安全网不是免死金牌——页面须从源头避免:`onLoad` 全量初始化 + `_initDone` 标志跳过 `onShow` 首次重复、降级路径同步设置所有 state 避免后续方法重复调用、子组件通过 props 接收数据而非各自请求
 
 ## UNIQUE FEATURES
 

+ 6 - 1
cfc-frontend/pages/energy/index.vue

@@ -249,9 +249,14 @@ export default {
   onLoad() {
     this.checkLogin()
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.isLoggedIn) this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      if (this.isLoggedIn) this.loadData()
+    }
   },
   methods: {
     checkLogin() {

+ 7 - 1
cfc-frontend/pages/guide/hierarchy/index.vue

@@ -92,9 +92,15 @@ export default {
   },
   onLoad() {
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    // 首次进入:onLoad 已加载,跳过避免双发;切 Tab 返回时刷新
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     async loadData() {

+ 6 - 1
cfc-frontend/pages/guide/invite/share.vue

@@ -120,9 +120,14 @@ export default {
   },
   onLoad() {
     this.loadInviteCodes()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadInviteCodes()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadInviteCodes()
+    }
   },
   methods: {
     async loadInviteCodes() {

+ 6 - 1
cfc-frontend/pages/health/challenge-manage.vue

@@ -252,9 +252,14 @@ export default {
     this.loadMembers()
     this.checkMembership()
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     async loadData() {

+ 5 - 1
cfc-frontend/pages/health/survey-history.vue

@@ -32,8 +32,12 @@ export default {
     var ui = uni.getStorageSync('userInfo') || {}
     this.memberId = options.memberId || uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
     this.loadData()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) { this._onLoadFired = false }
+    else if (this.memberId) this.loadData()
   },
-  onShow() { if (this.memberId) this.loadData() },
   methods: {
     getRecordKey(item) { return item && item.id ? 'r-' + item.id : 'r-' + Math.random() },
     getRecordTitle(item) { if (item.title) return item.title; if (this.templateMap[item.templateId]) return this.templateMap[item.templateId]; return '调研记录' },

+ 8 - 2
cfc-frontend/pages/home-pages/parent-index.vue

@@ -716,6 +716,7 @@ export default {
 
         // 优先使用仪表盘接口(包含孩子列表 + 统计数据)
         let dashboardLoaded = false
+        var _membersVisibleSet = false
         try {
           const dashRes = await getParentDashboard()
           const dashData = dashRes.data || {}
@@ -752,6 +753,9 @@ export default {
             const parents = members.filter(function(p) { return p.source === 'parent' && p.id && p.id !== currentUserId }).map(function(p) { return { ...p, role: 'parent' } })
             const children = members.filter(function(c) { return c.source !== 'parent' && c.id }).map(function(c) { return { ...c, role: 'child' } })
             this.familyMembers = [].concat(parents, children)
+            // 同步设置 familyMembersVisible,避免 loadFamilyMembersVisible() 重复调用 getFamilyMemberList
+            this.familyMembersVisible = members
+            _membersVisibleSet = true
             // 不自动选孩子:默认显示家长自己,等待用户从 FamilyMemberStrip 选择
             // 保存 familyId 用于后续请求
             if (data.familyId) {
@@ -900,8 +904,10 @@ export default {
         // 加载推荐商品
         this.loadRecommendedProducts()
 
-        // 加载家庭成员可见列表
-        this.loadFamilyMembersVisible()
+        // 加载家庭成员可见列表(降级路径已在上面设置,此处跳过避免重复请求)
+        if (!_membersVisibleSet) {
+          this.loadFamilyMembersVisible()
+        }
         this.loadSelfCheckStatus()
         // 加载健康预警(P1-2)
         this.loadAlerts()

+ 37 - 24
cfc-frontend/pages/index-home/index.vue

@@ -396,7 +396,7 @@ import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
-import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode } from '../../utils/api.js'
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
@@ -570,6 +570,7 @@ membershipDays: 0,
     }
   },
   onLoad(options) {
+    this._onLoadFired = true
     this._doInit(options)
   },
   onShow() {
@@ -591,8 +592,13 @@ membershipDays: 0,
       }, 5000)
     }
 
-    // 始终执行初始化,确保登录态变更后及时切换视图
-    this._doInit()
+    // 首次加载:onLoad 已触发 _doInit,此处跳过避免重复调用
+    // 后续 onShow(切 Tab 返回):刷新易变数据(会员状态、今日任务、挑战列表)
+    if (this._onLoadFired) {
+      this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
+    } else {
+      this._refreshOnShow()
+    }
     // 定期调研待办角标(内部自检登录态,未登录静默清空)
     this._loadSurveyBadge()
   },
@@ -624,15 +630,18 @@ membershipDays: 0,
 
     // ===== 登录态数据加载 =====
     loadLoggedInData: function() {
-      this.loadFamilyMembers()
-      this.loadChildren()
+      this.loadFamilyData()
       this.loadReferralCode()
     },
-    loadChildren: function() {
+    // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
+    loadFamilyData: function() {
       var self = this
-      getChildren().then(function(res) {
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res.code === 200 && res.data) {
-          self.children = res.data
+          var arr = Array.isArray(res.data) ? res.data : (res.data.list || [])
+          self.familyMembersVisible = arr
+          // 过滤出孩子成员(effectiveRole 为 'child' 的成员)
+          self.children = arr.filter(function(m) { return m.effectiveRole === 'child' })
           var savedChildId = uni.getStorageSync('currentChildId')
           if (savedChildId) {
             self.currentChildId = savedChildId
@@ -640,15 +649,22 @@ membershipDays: 0,
           self.loadDimensionData()
         }
         self.childrenChecked = true
+        self.membersChecked = true
         self.loadTodayTasks()
         self.loadFamilyFeed()
         self.loadNotices()
         self.loadNotifications()
       }).catch(function(e) {
-        console.log('获取孩子列表失败', e)
+        console.log('获取家庭成员失败', e)
+        self.familyMembersVisible = []
+        self.children = []
         self.childrenChecked = true
+        self.membersChecked = true
       })
     },
+    // loadChildren 已废弃:loadFamilyData 统一调用 getFamilyMemberList 一次,
+    // 从响应中过滤出 effectiveRole==='child' 的成员赋给 this.children。
+    // 删除了原 loadChildren 方法(调用 getChildren 会重复打 /api/family/member/list)。
     loadDimensionData: function() {
       this.loadSandboxData()
       this.loadEnergyOverview()
@@ -774,7 +790,8 @@ membershipDays: 0,
     },
     loadFamilyFeed: function() {
       var self = this
-      var children = self.children || []
+      // 仅对孩子成员(effectiveRole === 'child')拉取任务历史,避免对家长也发起请求
+      var children = (self.children || []).filter(function(m) { return m.effectiveRole === 'child' })
       if (children.length === 0) {
         self.familyFeed = []
         return
@@ -906,19 +923,7 @@ membershipDays: 0,
         uni.showToast({ title: '操作失败,请重试', icon: 'none' })
       })
     },
-    loadFamilyMembers: function() {
-      var self = this
-      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.familyMembersVisible = Array.isArray(res.data) ? res.data : (res.data.list || [])
-        }
-        self.membersChecked = true
-      }).catch(function(e) {
-        console.log('获取可见家庭成员失败', e)
-        self.familyMembersVisible = []
-        self.membersChecked = true
-      })
-    },
+    // loadFamilyMembers 已合并到 loadFamilyData(避免重复调用 /api/family/member/list)
     parseRelatedDimensions: function(val) {
       if (!val) return []
       if (Array.isArray(val)) return val
@@ -1045,12 +1050,20 @@ membershipDays: 0,
       }
       // 登录用户:直接在 TabBar 页面内联渲染,保持底签可见
       if (!this.loggedInInitDone) {
+        // 首次加载:全量初始化(数据 + 会员状态),onShow 不再重复
         this.loggedInInitDone = true
         this.loadLoggedInData()
+        this.checkMemberStatus()
       }
-      this.checkMemberStatus()
       this._watchPageReady()
     },
+    _refreshOnShow() {
+      // 切 Tab 返回时刷新易变数据(仅会员状态、今日任务、挑战列表),避免重复加载
+      if (!this.isLoggedIn()) return
+      this.checkMemberStatus()
+      this.loadTodayTasks()
+      this.loadChallenges()
+    },
     _watchPageReady() {
       if (this._watchPageReadyDone) return
       this._watchPageReadyDone = true

+ 6 - 0
cfc-frontend/pages/invite/join.vue

@@ -106,6 +106,7 @@ export default {
     }
   },
   onLoad(query) {
+    this._onLoadFired = true
     query = query || {}
     
     // 1. 解析家庭邀请码(单一参数,兼容 inviteCode/familyInviteCode)
@@ -153,6 +154,11 @@ export default {
     this.checkLoginStatus()
   },
   onShow() {
+    // 首次进入:onLoad 已处理,跳过避免双发;从登录页返回时重新检查
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+      return
+    }
     // 从登录页返回,重新检查
     if (this.familyInviteCode) {
       this.checkLoginStatus()

+ 6 - 1
cfc-frontend/pages/notification/index.vue

@@ -39,9 +39,14 @@ export default {
   },
   onLoad() {
     this.loadList()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadList()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadList()
+    }
   },
   onPullDownRefresh() {
     this.loadList()

+ 6 - 1
cfc-frontend/pages/profile-extra/onboarding.vue

@@ -80,9 +80,14 @@ export default {
   },
   onLoad() {
     this.loadProgress()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadProgress()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadProgress()
+    }
   },
   methods: {
     async loadProgress() {

+ 6 - 1
cfc-frontend/pages/shop/address/address.vue

@@ -69,9 +69,14 @@ export default {
       this.selectMode = options.selectMode === '1'
     }
     this.loadAddressList()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadAddressList()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadAddressList()
+    }
   },
   methods: {
     loadAddressList() {

+ 4 - 1
cfc-frontend/pages/shop/cart/cart.vue

@@ -126,9 +126,12 @@ export default {
     this.userId = uni.getStorageSync('userId')
     this.checkMemberStatus()
     this.loadCart()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.items.length > 0) {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else if (this.items.length > 0) {
       this.loadCart()
     }
   },

+ 7 - 2
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -534,10 +534,15 @@ export default {
     this.loadCfBalance()
     this.loadUserPoints()
     this.loadDeliveryConfig()
+    this._onLoadFired = true
   },
   onShow() {
-    // 从地址选择/编辑页返回后重新加载地址列表
-    this.loadDefaultAddress()
+    // 从地址选择/编辑页返回后重新加载地址列表(首次进入跳过,避免与 onLoad 双发)
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadDefaultAddress()
+    }
   },
   methods: {
     calcAmount() {

+ 8 - 3
cfc-frontend/pages/shop/index/index.vue

@@ -175,11 +175,16 @@ export default {
     this.loadProducts()
     this.loadRecommended()
     this.loadCartCount()
+    this._onLoadFired = true
   },
   onShow() {
-    this.checkMemberStatus()
-    this.isLoggedIn = !!uni.getStorageSync('token')
-    this.loadCartCount()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.checkMemberStatus()
+      this.isLoggedIn = !!uni.getStorageSync('token')
+      this.loadCartCount()
+    }
   },
   methods: {
     loadCategories() {

+ 6 - 1
cfc-frontend/pages/tasks/daily-tasks.vue

@@ -98,9 +98,14 @@ export default {
   },
   onLoad() {
     this.loadTasks()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadTasks()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadTasks()
+    }
   },
   methods: {
     switchTab(index) {

+ 6 - 1
cfc-frontend/pages/teacher/service-tasks.vue

@@ -81,9 +81,14 @@ export default {
   },
   onLoad() {
     this.loadTasks()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadTasks()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadTasks()
+    }
   },
   methods: {
     async loadTasks() {

+ 6 - 1
cfc-frontend/pages/teacher/teacher-home.vue

@@ -167,9 +167,14 @@ export default {
   },
   onLoad() {
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     loadData() {

+ 6 - 1
cfc-frontend/pages/wealth-cf/index.vue

@@ -114,9 +114,14 @@ export default {
     this.checkLogin()
     this.loadBalance()
     this.loadLogs()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.isLoggedIn) this.loadBalance()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      if (this.isLoggedIn) this.loadBalance()
+    }
   },
   methods: {
     checkLogin() {

+ 234 - 0
cfc-frontend/scripts/audit-duplicate-api-calls.js

@@ -0,0 +1,234 @@
+/**
+ * 重复 API 调用审计脚本
+ *
+ * 检测三类违规(违反 AGENTS.md「API 调用去重」规范):
+ *   A. 生命周期重复触发:同一个方法 / API 同时在 onLoad 和 onShow(或 onLoad+mounted 等
+ *      两个生命周期钩子)里被调用,首次进入页面时会双发。
+ *   B. 同接口多封装:utils/api.js 里多个导出函数指向同一个后端 URL(params 不同算
+ *      合法变体,不算违规;纯重复封装算违规)。
+ *   C. N+1 循环调用:在 .map / forEach / for 循环里调用 API 包装函数,N 个元素 → N 次请求。
+ *
+ * 用法:node scripts/audit-duplicate-api-calls.js [--pages-dir=pages]
+ * 退出码:0 = 无违规;1 = 发现违规(CI 可直接用作门禁)
+ */
+var fs = require('fs')
+var path = require('path')
+
+var root = path.resolve(__dirname, '..')
+var pagesDir = process.argv.slice(2).reduce(function (acc, a) {
+  var m = a.match(/^--pages-dir=(.+)$/)
+  return m ? m[1] : acc
+}, 'pages')
+var scanRoot = path.join(root, pagesDir)
+if (!fs.existsSync(scanRoot)) {
+  console.error('[audit] pages dir not found: ' + scanRoot)
+  process.exit(2)
+}
+
+// ---------- 工具:递归收集 .vue ----------
+function walk(dir, out) {
+  var entries = fs.readdirSync(dir)
+  for (var i = 0; i < entries.length; i++) {
+    var p = path.join(dir, entries[i])
+    var st = fs.statSync(p)
+    if (st.isDirectory()) walk(p, out)
+    else if (p.slice(-4) === '.vue') out.push(p)
+  }
+  return out
+}
+
+// ---------- 工具:取 <script> 块 ----------
+function extractScript(src) {
+  var m = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)
+  return m ? m[1] : ''
+}
+
+// ---------- 工具:按大括号深度截取某个方法体 ----------
+// 在 script 文本里定位 `name(args) {`,从 `{` 开始做大括号配对,返回方法体字符串
+function hookBody(script, hookName) {
+  // 匹配 onShow / onLoad / onShow(options) / onTabItemTap(e) 等,允许前面有空格/换行
+  var re = new RegExp('\\b' + hookName + '\\s*\\([^)]*\\)\\s*\\{', 'g')
+  var m
+  while ((m = re.exec(script)) !== null) {
+    var braceStart = script.indexOf('{', m.index)
+    if (braceStart < 0) return null
+    var depth = 0
+    var i = braceStart
+    var inStr = null // ' " `
+    var inLineComment = false
+    var inBlockComment = false
+    for (; i < script.length; i++) {
+      var c = script[i]
+      var next = script[i + 1]
+      if (inLineComment) { if (c === '\n') inLineComment = false; continue }
+      if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i++ }; continue }
+      if (inStr) {
+        if (c === '\\') { i++; continue }
+        if (c === inStr) inStr = null
+        continue
+      }
+      if (c === '/' && next === '/') { inLineComment = true; i++; continue }
+      if (c === '/' && next === '*') { inBlockComment = true; i++; continue }
+      if (c === '"' || c === "'" || c === '`') { inStr = c; continue }
+      if (c === '{') depth++
+      else if (c === '}') {
+        depth--
+        if (depth === 0) return script.slice(braceStart + 1, i)
+      }
+    }
+  }
+  return null
+}
+
+// ---------- 工具:提取方法调用名 ----------
+// this.foo(  -> foo ;以及裸函数调用 foo( 当作直接 API 调用候选
+function callsIn(body) {
+  var calls = []
+  if (!body) return calls
+  // this.xxx(  / that.xxx(  / self.xxx(
+  var reThis = /\b(?:this|that|self)\.([A-Za-z_$][\w$]*)\s*\(/g
+  var m
+  while ((m = reThis.exec(body)) !== null) calls.push(m[1])
+  return calls
+}
+
+// ---------- 规则 A:生命周期双发 ----------
+var HOOKS = ['onLoad', 'onShow', 'onTabItemTap', 'mounted', 'created', 'beforeMount']
+var violations = []
+
+var files = walk(scanRoot, [])
+for (var f = 0; f < files.length; f++) {
+  var file = files[f]
+  var rel = path.relative(root, file).replace(/\\/g, '/')
+  var src = fs.readFileSync(file, 'utf8')
+  var script = extractScript(src)
+  if (!script) continue
+
+  var hookCalls = {} // hookName -> Set(method)
+  HOOKS.forEach(function (h) {
+    var body = hookBody(script, h)
+    hookCalls[h] = callsIn(body)
+  })
+
+  // 任两个钩子共享同一方法名 → 双发风险
+  for (var i = 0; i < HOOKS.length; i++) {
+    for (var j = i + 1; j < HOOKS.length; j++) {
+      var a = HOOKS[i], b = HOOKS[j]
+      if (!hookCalls[a] || !hookCalls[b]) continue
+      var shared = hookCalls[a].filter(function (m) { return hookCalls[b].indexOf(m) !== -1 })
+      if (shared.length === 0) continue
+      // 看该方法体里有没有"已初始化"守卫标志(_initDone / _inited / _loaded 等)
+      // 如果有守卫,降级为 warning 而非 error
+      var guarded = false
+      shared.forEach(function (m) {
+        var mbody = hookBody(script, m)
+        if (mbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(mbody)) guarded = true
+      })
+      // 也检查生命周期钩子本身是否含守卫标志(_onLoadFired 通常写在 onLoad/onShow 里)
+      ;[a, b].forEach(function (h) {
+        var hbody = hookBody(script, h)
+        if (hbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(hbody)) guarded = true
+      })
+      violations.push({
+        type: 'LIFECYCLE_DOUBLE',
+        severity: guarded ? 'warn' : 'error',
+        file: rel,
+        hooks: a + ' + ' + b,
+        methods: shared.join(', '),
+        guarded: guarded
+      })
+    }
+  }
+
+  // 规则 C:N+1 —— methods 里某方法在 .map( / .forEach( / for(...) 里调用 API 包装
+  // 粗略检测:方法体里同时出现 \.(map|forEach)\s*\( 和某个形如 getXxx( 的调用
+  // 进一步限定:只看 methods 块。这里简化:在整个 script 里查 .map(/.forEach( 后续出现
+  // 的 API 包装函数调用(名称以 get/fetch/load/list 开头)。
+  var nplus = script.match(/\.map\s*\([^]*?\b(get|fetch|load|list)[A-Za-z]*\s*\(/)
+  if (nplus) {
+    // 更精确:逐方法体检查
+    var methodRe = /\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g
+    var mm
+    while ((mm = methodRe.exec(script)) !== null) {
+      // 跳过生命周期钩子
+      if (HOOKS.indexOf(mm[1]) !== -1) continue
+      var mbody = hookBody(script, mm[1])
+      if (!mbody) continue
+      if (/\.(map|forEach)\s*\(/.test(mbody) && /\b(get|fetch|load|list)[A-Za-z]*\s*\(/.test(mbody)) {
+        violations.push({
+          type: 'N_PLUS_1',
+          severity: 'warn',
+          file: rel,
+          hooks: 'method:' + mm[1],
+          methods: mm[1],
+          guarded: false
+        })
+      }
+    }
+  }
+}
+
+// ---------- 规则 B:同接口多封装(扫 utils/api.js)----------
+var apiFile = path.join(root, 'utils', 'api.js')
+if (fs.existsSync(apiFile)) {
+  var apiSrc = fs.readFileSync(apiFile, 'utf8')
+  // url -> [{ name, params }]  params = request() 第 3 个参数字符串
+  var urlMap = {}
+  var reWrap = /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*\{[^}]*?request\(\s*([^,)]+),\s*['"`]POST['"`]\s*(?:,\s*([^)]+?))?\s*\)/g
+  var mw
+  while ((mw = reWrap.exec(apiSrc)) !== null) {
+    var name = mw[1]
+    var urlRaw = mw[2].trim()
+    var url = urlRaw.replace(/['"`]/g, '')
+    if (/\$\{/.test(urlRaw)) url = url.replace(/\$\{[^}]+\}/g, '{id}')
+    var params = (mw[3] || '').trim()
+    if (!urlMap[url]) urlMap[url] = []
+    if (!urlMap[url].some(function (e) { return e.name === name })) urlMap[url].push({ name: name, params: params })
+  }
+  Object.keys(urlMap).forEach(function (url) {
+    var entries = urlMap[url]
+    if (entries.length <= 1) return
+    // 判断"真重复"(参数实质相同)vs"过滤变体/不同操作"(参数不同)
+    var isTrueDup = true
+    for (var i = 0; i < entries.length - 1; i++) {
+      var a = entries[i].params, b = entries[i + 1].params
+      var am = a.match(/_method:\s*['"`](\w+)['"`]/)
+      var bm = b.match(/_method:\s*['"`](\w+)['"`]/)
+      if (am && bm && am[1] !== bm[1]) { isTrueDup = false; break }
+      var aVar = /^[A-Za-z_$][\w$]*$/.test(a)
+      var bVar = /^[A-Za-z_$][\w$]*$/.test(b)
+      var aEmpty = a === '' || a === '{}' || /\|\|\s*\{\}/.test(a)
+      var bEmpty = b === '' || b === '{}' || /\|\|\s*\{\}/.test(b)
+      if ((aVar && bEmpty) || (bVar && aEmpty)) { isTrueDup = false; break }
+      if (a && b && a !== b && !/\|\|\s*\{\}/.test(a) && !/\|\|\s*\{\}/.test(b)) { isTrueDup = false; break }
+    }
+    violations.push({
+      type: 'DUPLICATE_WRAPPER',
+      severity: isTrueDup ? 'error' : 'warn',
+      file: 'utils/api.js',
+      hooks: url,
+      methods: entries.map(function (e) { return e.name }).join(', '),
+      guarded: !isTrueDup
+    })
+  })
+}
+
+// ---------- 输出 ----------
+var errors = violations.filter(function (v) { return v.severity === 'error' })
+var warns = violations.filter(function (v) { return v.severity === 'warn' })
+
+function print(list) {
+  list.forEach(function (v) {
+    var tag = v.severity === 'error' ? 'ERROR' : 'WARN '
+    var extra = v.guarded ? ' [guarded]' : ''
+    console.log('[' + tag + '] ' + v.type + ' ' + v.file + ' | ' + v.hooks + ' | ' + v.methods + extra)
+  })
+}
+
+console.log('=== Duplicate API Call Audit ===')
+console.log('Scanned ' + files.length + ' .vue files in ' + pagesDir + '/\n')
+if (errors.length) { console.log('--- ERRORS (' + errors.length + ') ---'); print(errors) }
+if (warns.length) { console.log('\n--- WARNINGS (' + warns.length + ') ---'); print(warns) }
+if (!errors.length && !warns.length) console.log('No violations found.')
+console.log('\nSummary: ' + errors.length + ' error(s), ' + warns.length + ' warning(s)')
+process.exit(errors.length > 0 ? 1 : 0)

+ 82 - 29
cfc-frontend/utils/api.js

@@ -5,6 +5,47 @@ const BASE_URL = config.API_BASE_URL
 var _redirectingToLogin = false
 var _reloginLock = false
 
+// === 请求去重(Dedup)全局安全网 ===
+// 同 URL + method + body 在 DEDUP_WINDOW_MS 窗口内复用同一个 Promise,
+// 防止 onLoad/onShow 双触发、组件嵌套、双击等场景导致的重复请求。
+// 401 续期重放请求传 options._skipDedup=true 跳过去重。
+var _dedupMap = {}
+var DEDUP_WINDOW_MS = 3000
+
+function _makeDedupKey(method, url, body) {
+  var bodyStr = ''
+  try { bodyStr = JSON.stringify(body || {}) } catch (e) { bodyStr = '' }
+  return method + '|' + url + '|' + bodyStr
+}
+
+function _tryDedup(method, fullUrl, body) {
+  var key = _makeDedupKey(method, fullUrl, body)
+  var cached = _dedupMap[key]
+  if (cached && Date.now() - cached.ts < DEDUP_WINDOW_MS) {
+    return cached.promise
+  }
+  return null
+}
+
+function _registerDedup(method, fullUrl, body, promise) {
+  var key = _makeDedupKey(method, fullUrl, body)
+  _dedupMap[key] = { promise: promise, ts: Date.now() }
+  var _cleanup = function() {
+    // 窗口到期后清理,确保缓存不常驻
+    setTimeout(function() {
+      if (_dedupMap[key] && _dedupMap[key].promise === promise) {
+        delete _dedupMap[key]
+      }
+    }, DEDUP_WINDOW_MS)
+  }
+  promise.then(_cleanup, _cleanup)
+}
+
+function _clearDedup() {
+  _dedupMap = {}
+}
+export { _clearDedup as clearApiDedup }
+
 function _clearAuthAndRedirect() {
   if (_redirectingToLogin) return
   _redirectingToLogin = true
@@ -73,7 +114,9 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
       console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
       _applyLoginData(res.data)
       // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
-      request(url, method, data, options).then(function(r) {
+      // 401 重放请求跳过去重(原请求已 reject,缓存中的 promise 已过期)
+      var _retryOpts = Object.assign({}, options, { _skipDedup: true })
+      request(url, method, data, _retryOpts).then(function(r) {
         _reloginLock = false
         resolve(r)
       }, function(e) {
@@ -121,8 +164,18 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
     }
     if (!hasFiltered) body = data
   }
-  
-  return new Promise((resolve, reject) => {
+
+  // === 请求去重:同 URL + method + body 在 3 秒窗口内复用 Promise ===
+  // 防止 onLoad/onShow 双触发、组件嵌套、双击等导致的重复请求
+  if (!options._skipDedup) {
+    var _cached = _tryDedup(method, fullUrl, body)
+    if (_cached) {
+      // console.log('[API] dedup hit ' + method + ' ' + fullUrl)
+      return _cached
+    }
+  }
+
+  var _promise = new Promise((resolve, reject) => {
     uni.request({
       url: fullUrl,
       method: method,
@@ -171,6 +224,13 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
       }
     })
   })
+
+  // 注册去重缓存,3 秒窗口内复用
+  if (!options._skipDedup) {
+    _registerDedup(method, fullUrl, body, _promise)
+  }
+
+  return _promise
 }
 
 // 登录模块
@@ -419,9 +479,10 @@ export const getAuthorizations = () => {
 	return request('/api/parent/authorizations', 'POST')
 }
 
-export const getChildren = () => {
-  return request('/api/family/member/list', 'POST', {})
-}
+// @deprecated 别名:统一使用 getFamilyMemberList,避免同 URL 多封装导致重复调用
+// @deprecated 使用 getFamilyMemberList 代替。getChildren 与 getFamilyMemberList 调用同一接口 /api/family/member/list,
+// 同一页面同时调用两者会导致重复请求。新代码请直接使用 getFamilyMemberList。
+export const getChildren = () => getFamilyMemberList({})
 
 export const getParentDashboard = () => {
   return request('/api/parent/dashboard', 'POST')
@@ -515,8 +576,10 @@ export const reviewTask = (taskId, data) => {
   return request(`/api/tasks/${taskId}/review`, 'POST', data)
 }
 
-export const getTaskHistory = (memberId, page = 1, size = 10) => {
-  return request('/api/tasks/history', 'POST', { memberId, page, size })
+export const getTaskHistory = (memberId, page = 1, size = 10, category) => {
+  var data = { memberId, page, size }
+  if (category) data.category = category
+  return request('/api/tasks/history', 'POST', data)
 }
 
 export const deleteTask = (taskId) => {
@@ -1332,18 +1395,11 @@ export const cancelMyJoinRequest = (requestId) => {
 }
 
 // ===== 家庭加入请求 API 别名(供组件统一引用)=====
-export const getPendingFamilyRequests = () => {
-  return request('/api/family/invite/pending-requests', 'POST', {})
-}
-export const approveFamilyJoinRequest = (requestId) => {
-  return request('/api/family/invite/approve-request', 'POST', { requestId: requestId })
-}
-export const rejectFamilyJoinRequest = (requestId) => {
-  return request('/api/family/invite/reject-request', 'POST', { requestId: requestId })
-}
-export const getMyPendingRequest = () => {
-  return request('/api/family/invite/my-request', 'POST', {})
-}
+// @deprecated 别名:委托到上面的规范函数,确保每个 URL 只有一处 request() 调用
+export const getPendingFamilyRequests = () => getPendingJoinRequests()
+export const approveFamilyJoinRequest = (requestId) => approveJoinRequest(requestId, '')
+export const rejectFamilyJoinRequest = (requestId) => rejectJoinRequest(requestId, '')
+export const getMyPendingRequest = () => getMyJoinRequest()
 
 // ===== AI 聊天 =====
 export const aiSendMessage = (data) => {
@@ -1761,15 +1817,13 @@ export const getWealthDetail = (memberId, memberType) => {
 }
 
 // ===== 成就徽章 =====
-export const getUserBadges = (memberId) => {
-  return request('/api/badges/child/list', 'POST', { memberId })
-}
+// @deprecated 别名:委托到 getChildBadges,确保 /api/badges/child/list 只有一处封装
+export const getUserBadges = (memberId) => getChildBadges(memberId)
 
 
 // ===== 成长报告 =====
-export const getGrowthReport = (memberId) => {
-  return request('/api/stats/child/overview', 'POST', { memberId })
-}
+// @deprecated 别名:委托到 getChildOverview,确保 /api/stats/child/overview 只有一处封装
+export const getGrowthReport = (memberId) => getChildOverview(memberId)
 
 // ===== 用户行动统计(我的页面) =====
 export const getUserActionStats = () => {
@@ -2023,9 +2077,8 @@ export const getTodayTasksByCategory = (memberId, category) => {
   return request('/api/tasks/today', 'POST', { memberId: memberId, category: category })
 
 }
-export const getTaskHistoryByCategory = (memberId, category, page, size) => {
-  return request('/api/tasks/history', 'POST', { memberId: memberId, category: category, page: page || 1, size: size || 10 })
-}
+// @deprecated 别名:委托到 getTaskHistory(已支持 category 参数),确保 /api/tasks/history 只有一处封装
+export const getTaskHistoryByCategory = (memberId, category, page, size) => getTaskHistory(memberId, page || 1, size || 10, category)
 
 // ===== 维度商品(domain 筛选,使用 productList) =====
 export const getProductsByDomain = (domain, page, size) => {