Forráskód Böngészése

fix(ai): 门禁交互审查修复(T-F1/T-F2)

- UpgradeGuideModal: hard/expired 允许点遮罩关闭,expired 增加「稍后再说」;
  chat 页为自定义导航栏,遮罩会盖住唯一返回入口,不可关闭会把用户困住,
  过期会员也就看不到已加载的历史会话(DoD 未达成)
- buildLegacyGate: daysRemaining/isActive 置空,避免降级路径给所有有效会员
  展示「剩余 0 天」假数据
- applyMemberGate: status 缺失(空 data / 5001 兜底)时 fail-closed,
  不再默认放行
- 状态条日期改用 utils/format.js 的 parseDate() 拼 yyyy-MM-dd(兼容时间戳/
  ISO/Java 格式,iOS 安全);剩余天数向上取整
- sendMessage catch: 门禁错误回填输入、用新对象同步 memberGate(不直接改写
  接口返回体,避免污染 3 秒去重缓存);非门禁错误补 toast 显示后端原话
- 补 T-F0 单测 4 条:aiGateStatus 命中 /api/ai/gate、_noToast 不弹 toast 且
  reject 带 code、无 _noToast 仍 toast(回归)、aiSendMessage 门禁不叠加 toast
Sisyphus Agent 1 hete
szülő
commit
1e2516e0c0

+ 42 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -1253,3 +1253,45 @@ describe('article related API', () => {
     expect(res.code).toBe(200)
   })
 })
+
+// --------------- AI 会员门禁(T-F0:aiGateStatus + _noToast) ---------------
+
+describe('AI member gate API', () => {
+  beforeEach(() => {
+    global.uni._storage.token = 'test-token'
+  })
+
+  test('aiGateStatus sends POST /api/ai/gate 且不把 _noToast 拼进 URL', async () => {
+    mockSuccess({ code: 200, data: { status: 'ALLOW', canUse: true } })
+    const res = await api.aiGateStatus()
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/ai\/gate$/)
+    expect(opts.url).not.toMatch(/_noToast/)
+    expect(opts.method).toBe('POST')
+    expect(res.data.status).toBe('ALLOW')
+  })
+
+  test('_noToast:非 200 不弹 toast,reject 仍带 code/message', async () => {
+    mockSuccess({ code: 46003, message: '该功能为会员专享,请先开通会员' })
+    await expect(api.aiGateStatus()).rejects.toMatchObject({
+      code: 46003,
+      message: '该功能为会员专享,请先开通会员'
+    })
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+  })
+
+  test('无 _noToast 时非 200 仍弹 toast(回归保护)', async () => {
+    mockSuccess({ code: 500, message: '服务异常' })
+    await expect(api.getMyMembership()).rejects.toMatchObject({ code: 500 })
+    expect(global.uni.showToast).toHaveBeenCalledTimes(1)
+    expect(global.uni.showToast.mock.calls[0][0].title).toBe('服务异常')
+  })
+
+  test('aiSendMessage 门禁错误不弹 toast(避免 toast + 弹窗叠加)', async () => {
+    mockSuccess({ code: 46004, message: '会员已过期,请续费后继续使用' })
+    await expect(api.aiSendMessage({ query: 'hi', conversationId: '' })).rejects.toMatchObject({
+      code: 46004
+    })
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+  })
+})

+ 37 - 11
cfc-frontend/pages/ai/chat.vue

@@ -175,6 +175,7 @@ import TaskCard from '@/pages/ai/components/AITaskCard.vue'
 import QuotaProgressBar from '@/pages/ai/components/QuotaProgressBar.vue'
 import UpgradeGuideModal from '@/pages/ai/components/UpgradeGuideModal.vue'
 import config from '@/config.js'
+import { parseDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -257,13 +258,14 @@ export default {
       if (!this.memberGate) return ''
       if (this.gateStatus === 'MEMBER_EXPIRED') {
         var end = this.memberGate.memberExpireTime || this.memberGate.endDate
-        var day = end ? String(end).slice(0, 10) : ''
+        var day = this.formatGateDate(end)
         return day ? ('会员已于 ' + day + ' 到期') : '会员已到期'
       }
       if (this.gateStatus === 'ALLOW' && this.memberGate.isActive) {
         var remain = this.memberGate.daysRemaining
         if (typeof remain === 'number' && remain >= 0 && remain <= this.nearExpiryDays) {
-          return '会员即将到期(剩余 ' + remain + ' 天)'
+          // 后端可能返回小数(按毫秒折算),展示向上取整,避免「剩余 0 天」却仍可用
+          return '会员即将到期(剩余 ' + Math.ceil(remain) + ' 天)'
         }
       }
       return ''
@@ -319,6 +321,8 @@ export default {
     },
 
     // /api/ai/gate 不可用时的旧会员接口降级映射
+    // 注意:旧接口不区分「从未付费 / 曾付费已过期」,也不能给出剩余天数,
+    // 因此 daysRemaining / isActive 置空(状态条据此跳过临期提醒),避免展示假数据。
     buildLegacyGate(membership) {
       var level = membership.memberLevel || 'FREE'
       var allowed = level !== 'FREE'
@@ -328,15 +332,22 @@ export default {
         canUse: allowed,
         canViewHistory: false,
         memberLevel: level,
-        isActive: allowed,
+        isActive: null,
         memberExpireTime: membership.memberExpireTime || null,
         endDate: membership.endDate || membership.memberExpireTime || null,
-        daysRemaining: 0
+        daysRemaining: null
       }
     },
 
     applyMemberGate(gate, autoPrompt) {
-      var status = gate.status || (gate.canUse === false ? 'UPGRADE_REQUIRED' : 'ALLOW')
+      // status 缺失(gate 接口返回空 data / 5001 无家庭兜底)时 fail-closed:
+      // 不默认放行,沿用上一次已知状态;首次遇到则按未开通处理(不主动弹窗,避免误伤)。
+      var status = gate.status
+      if (!status) {
+        if (gate.canUse === true) status = 'ALLOW'
+        else if (gate.canUse === false) status = 'UPGRADE_REQUIRED'
+        else status = this.gateStatus || 'UPGRADE_REQUIRED'
+      }
       this.memberGate = gate
       this.gateStatus = status
       this.gateBlocked = status !== 'ALLOW'
@@ -394,6 +405,15 @@ export default {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
 
+    // 门禁日期展示:yyyy-MM-dd(iOS 安全解析走 parseDate,兼容时间戳/ISO/Java 格式)
+    formatGateDate(value) {
+      var d = parseDate(value)
+      if (!d) return ''
+      var m = d.getMonth() + 1
+      var day = d.getDate()
+      return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
+    },
+
     startVoiceRecord() {
       if (this.isRecording) return
       if (this.gateBlocked) {
@@ -568,27 +588,33 @@ export default {
       } catch (e) {
         var errCode = e && e.code
         if (errCode === 46003 || errCode === 46004) {
-          // 会员门禁:撤掉未落库的乐观消息,改为弹专用引导弹窗(不显示通用失败文案)
+          // 会员门禁:撤掉未落库的乐观消息,把输入还给用户,改为弹专用引导弹窗
           const lastMsg = this.messages[this.messages.length - 1]
           if (lastMsg && String(lastMsg.id).indexOf('temp_') === 0) {
             this.messages.pop()
           }
+          if (!this.inputText) this.inputText = text
           if (errCode === 46004) {
             this.gateStatus = 'MEMBER_EXPIRED'
-            this.gateBlocked = true
-            this.showGateModal('expired')
           } else {
             this.gateStatus = 'UPGRADE_REQUIRED'
-            this.gateBlocked = true
-            this.showGateModal('hard')
           }
+          this.gateBlocked = true
+          // 保证状态条依赖的 memberGate 不为空(会话中途降级时也能显示续费入口)
+          // 用新对象覆盖,避免直接改写接口返回体(可能被 3 秒去重缓存共享)
+          this.memberGate = Object.assign({}, this.memberGate || {}, {
+            status: this.gateStatus,
+            canUse: false
+          })
+          this.showGateModal(errCode === 46004 ? 'expired' : 'hard')
           return
         }
-        // 显示错误
+        // 显示错误(发送类走 _noToast,这里补回后端原话,避免只剩通用兜底)
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
           lastMsg.answer = '抱歉,我暂时无法回答,请稍后再试。'
         }
+        uni.showToast({ title: (e && e.message) || '发送失败,请稍后再试', icon: 'none' })
       } finally {
         this.sending = false
         this.$nextTick(() => {

+ 6 - 5
cfc-frontend/pages/ai/components/UpgradeGuideModal.vue

@@ -9,6 +9,7 @@
         <view v-if="type === 'soft'" class="btn-learn" @click="handleLearn">了解详情</view>
         <view v-if="type === 'hard'" class="btn-upgrade" @click="handleUpgrade">升级 FAMILY</view>
         <view v-if="type === 'expired'" class="btn-upgrade" @click="handleUpgrade">去续费</view>
+        <view v-if="type === 'expired'" class="btn-later" @click="handleKnow">稍后再说</view>
       </view>
     </view>
   </view>
@@ -69,9 +70,9 @@ export default {
       this.$emit('upgrade')
     },
     handleMaskClick() {
-      if (this.type === 'soft') {
-        this.hide()
-      }
+      // 三种类型都允许点遮罩关闭:chat 页使用自定义导航栏,遮罩会盖住唯一的返回入口,
+      // 若 hard/expired 不可关闭会把用户困在页面里(过期会员也就看不到已加载的历史会话)。
+      this.hide()
     }
   }
 }
@@ -115,13 +116,13 @@ export default {
   flex-direction: column;
   gap: 16rpx;
 }
-.btn-know, .btn-learn, .btn-upgrade {
+.btn-know, .btn-learn, .btn-upgrade, .btn-later {
   height: 80rpx;
   line-height: 80rpx;
   border-radius: 24rpx;
   font-size: calc(28rpx * var(--font-scale, 1));
 }
-.btn-know {
+.btn-know, .btn-later {
   background: $facade-fill-2;
   color: $facade-text-body;
 }