ソースを参照

fix: 修复能量沙盘解锁与健康方案付费墙及手签名

问题1 能量沙盘解锁: UnlockGateService.hasFamilySelfCheck 与 isGateUnlocked(SELF_CHECK) 增加对 survey_records 定期调研记录的检查(按 family_members.id), 完成定期调研即解锁沙盘。

问题2 家庭会员(FAMILY)及以上生成方案误弹199健康启航计划: MembershipService.getFamilyMembership 只取已支付且未过期的有效会员记录, 避免 pending/refunded/过期记录按 endDate 冒顶导致误判 FREE; HealthPlanController.generate 付费墙改用 getMemberLevel(userId)(个人+家庭取较高有效级别并校验有效期), 已开通会员不再被拦截; health-plan-summary.vue showPaywallSheet 仅免费用户展示购买健康启航计划入口。

问题3 在线手签名不好用: hand-signature.vue canvasToTempFilePath 补传组件实例 this(微信小程序组件内 canvas 必须传实例, 否则导出必然失败), 处理 H5 直接返回 base64 data URL 场景并防护 getFileSystemManager 缺失, loadRect 改用 .in(this) 限定组件作用域(与全站组件一致)。
Agnes Agent 2 週間 前
親
コミット
1c8de9e748

+ 5 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -34,12 +34,11 @@ public class HealthPlanController {
     public Result<Object> generate(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("userId") Long userId,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId) {
-        // 付费墙拦截:家庭会员 或 健康启航计划 任一有效即可生成
-        boolean isMember = false;
-        if (familyId != null) {
-            com.etotem.cfc.dto.MembershipLevelDTO level = membershipService.getCurrentLevel(familyId);
-            isMember = level != null && !"FREE".equals(level.getLevelCode());
-        }
+        // 付费墙拦截:用户有效会员(个人 users.member_level 或 家庭 family_memberships 任一维度,
+        // getMemberLevel 已取二者较高级别并校验有效期)或 健康启航计划 任一有效即可生成。
+        // 改用 getMemberLevel 而非 getCurrentLevel(familyId):后者只看 family_memberships 且按 endDate 取最新,
+        // 会被 pending/refunded/过期记录冒顶误判 FREE,导致已开通家庭会员的用户仍被拦截并误弹 199 健康启航计划。
+        boolean isMember = !"FREE".equals(membershipService.getMemberLevel(userId));
         java.util.Map<String, Object> purchaseCheck = purchaseService.checkPurchase(userId);
         if (!isMember && !Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
             return Result.error(4001, "请升级为家庭会员或购买健康启航计划");

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -134,9 +134,13 @@ public class MembershipService implements MembershipServiceInterface {
      * 获取家庭的会员信息
      */
     public FamilyMembershipDTO getFamilyMembership(Long familyId) {
+        // 只取「已支付且未过期」的有效会员记录:避免 pending/refunded 或已过期记录按 endDate 冒顶,
+        // 遮蔽真正的有效会员记录,导致 getCurrentLevel 误判 FREE(健康方案等付费墙误弹)。
         FamilyMembership membership = membershipMapper.selectOne(
                 new LambdaQueryWrapper<FamilyMembership>()
                         .eq(FamilyMembership::getFamilyId, familyId)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, new Date())
                         .orderByDesc(FamilyMembership::getEndDate)
                         .last("LIMIT 1")
         );

+ 14 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java

@@ -45,6 +45,9 @@ public class UnlockGateService {
     @Resource
     private HealthStatusMapper healthStatusMapper;
 
+    @Resource
+    private SurveyRecordMapper surveyRecordMapper;
+
     @Resource
     private ProductOrderMapper productOrderMapper;
 
@@ -77,14 +80,18 @@ public class UnlockGateService {
                     new LambdaQueryWrapper<FiveDimensionSelfCheck>().in(FiveDimensionSelfCheck::getUserId, userIds));
             // 家庭成员健康档案(health_status) 同样视为完成自检
             long healthCount = 0L;
+            // 家庭成员定期调研记录(survey_records) 同样视为完成自检
+            long surveyCount = 0L;
             List<FamilyMember> familyMembers = familyMemberMapper.selectList(
                     new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
             if (!familyMembers.isEmpty()) {
                 List<Long> memberIds = familyMembers.stream().map(FamilyMember::getId).collect(Collectors.toList());
                 healthCount = healthStatusMapper.selectCount(
                         new LambdaQueryWrapper<HealthStatus>().in(HealthStatus::getMemberId, memberIds));
+                surveyCount = surveyRecordMapper.selectCount(
+                        new LambdaQueryWrapper<SurveyRecord>().in(SurveyRecord::getMemberId, memberIds));
             }
-            long count = selfCheckCount + healthCount;
+            long count = selfCheckCount + healthCount + surveyCount;
             int minCount = 1;
             if (gate.getConditionParams() != null && !gate.getConditionParams().isEmpty()) {
                 // parse JSON for minCount
@@ -160,7 +167,7 @@ public class UnlockGateService {
      */
     public boolean hasFamilySelfCheck(Long familyId) {
         if (familyId == null) return false;
-        // 原有:任一家庭成员有健康档案(health_status)
+        // 原有:任一家庭成员有健康档案(health_status) 或 定期调研记录(survey_records)
         List<FamilyMember> familyMembers = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
         if (!familyMembers.isEmpty()) {
@@ -168,8 +175,12 @@ public class UnlockGateService {
             long healthCount = healthStatusMapper.selectCount(
                     new LambdaQueryWrapper<HealthStatus>().in(HealthStatus::getMemberId, memberIds));
             if (healthCount >= 1) return true;
+            // 新增:任一家庭成员完成定期调研(survey_records) 也视为通过
+            long surveyCount = surveyRecordMapper.selectCount(
+                    new LambdaQueryWrapper<SurveyRecord>().in(SurveyRecord::getMemberId, memberIds));
+            if (surveyCount >= 1) return true;
         }
-        // 新增:任一家庭成员完成五维自检(five_dimension_self_check) 也视为通过(与 isGateUnlocked(SELF_CHECK) 保持一致)
+        // 任一家庭成员完成五维自检(five_dimension_self_check) 也视为通过(与 isGateUnlocked(SELF_CHECK) 保持一致)
         List<User> familyUsers = userMapper.selectList(
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId));
         if (!familyUsers.isEmpty()) {

+ 31 - 15
cfc-frontend/components/hand-signature/hand-signature.vue

@@ -124,8 +124,8 @@ export default {
     loadRect: function() {
       var self = this
       try {
-        var query = uni.createSelectorQuery()
-        if (self.$scope) query = query.in(self.$scope)
+        // 与全站组件一致:用 .in(this) 将选择器限定到本组件作用域(组件内 canvas 必须带组件实例)
+        var query = uni.createSelectorQuery().in(this)
         query.select('.signature-canvas').boundingClientRect(function(rect) {
           if (rect) {
             self.rect = rect
@@ -201,6 +201,8 @@ export default {
       var rect = this.rect || {}
       var w = rect.width || 300
       var h = rect.height || this.height
+      // 注意第二个参数 this:canvas 位于自定义组件内,必须传入组件实例,
+      // 否则微信小程序按页面级 canvas-id 查找不到组件内 canvas,导出必然失败(签名「不好用」的根因之一)。
       uni.canvasToTempFilePath({
         canvasId: 'hsCanvas',
         x: 0,
@@ -212,25 +214,32 @@ export default {
         fileType: 'png',
         quality: 1,
         success: function(res) {
+          var tmp = res && res.tempFilePath ? res.tempFilePath : ''
+          // H5:canvasToTempFilePath 的 tempFilePath 直接就是 base64 data URL
+          if (String(tmp).indexOf('data:image') === 0) {
+            self._emitSignature(tmp)
+            return
+          }
           // 小程序:临时文件路径转 base64 data URL 提交后端
-          var fs = uni.getFileSystemManager()
+          var fs = (typeof uni.getFileSystemManager === 'function') ? uni.getFileSystemManager() : null
+          if (!fs) {
+            // 环境不支持文件系统(个别平台变体),降级为临时路径
+            self._emitSignature(tmp)
+            return
+          }
           fs.readFile({
-            filePath: res.tempFilePath,
+            filePath: tmp,
             encoding: 'base64',
             success: function(fileRes) {
-              self.currentDataUrl = 'data:image/png;base64,' + (fileRes.data || '')
-              self.hasContent = true
-              self.$emit('confirm', self.currentDataUrl)
-              self.savingLive = false
+              self._emitSignature('data:image/png;base64,' + (fileRes.data || ''))
             },
             fail: function() {
-              // 降级:直接返回临时路径(H5 环境 tempFilePath 即为 base64 data URL)
-              if (String(res.tempFilePath || '').indexOf('data:image') === 0) {
-                self.currentDataUrl = res.tempFilePath
-                self.hasContent = true
-                self.$emit('confirm', self.currentDataUrl)
+              // 降级:H5 的 tempFilePath 本身即为 base64 data URL;其余情况保持静默允许重试
+              if (String(tmp).indexOf('data:image') === 0) {
+                self._emitSignature(tmp)
+              } else {
+                self.savingLive = false
               }
-              self.savingLive = false
             }
           })
         },
@@ -239,7 +248,14 @@ export default {
           uni.showToast({ title: '签名导出失败,请重试', icon: 'none' })
           self.savingLive = false
         }
-      })
+      }, this)
+    },
+    // 统一回写签名并通知父组件(小程序已转 base64,H5 直接透传 data URL)
+    _emitSignature: function(dataUrl) {
+      this.currentDataUrl = dataUrl || ''
+      this.hasContent = true
+      this.$emit('confirm', this.currentDataUrl)
+      this.savingLive = false
     },
     handleClear: function() {
       var self = this

+ 19 - 2
cfc-frontend/pages/health/health-plan-summary.vue

@@ -1036,10 +1036,27 @@ selectedMembers: function() {
         })
       }).catch(function() {})
     },
-    // 付费墙引导:三选一(购买家庭会员 / 购买健康启航计划 / 取消)
+    // 付费墙引导:仅免费用户展示「购买健康启航计划」入口;
+    // 已是会员(FAMILY 及以上)的用户即使兜底走到 4001,也绝不提示购买 199 元健康启航计划
     showPaywallSheet: function() {
+      var self = this
+      getMyMembership().then(function(res) {
+        var m = (res && res.data) || {}
+        var level = m.memberLevel || m.levelCode || ''
+        var items = ['购买家庭会员']
+        if (!level || level === 'FREE') {
+          items.push('购买健康启航计划')
+        }
+        self._showPaywallActions(items)
+      }).catch(function() {
+        // 会员查询失败:按免费口径展示全部入口(与原行为一致)
+        self._showPaywallActions(['购买家庭会员', '购买健康启航计划'])
+      })
+    },
+    // 弹出付费墙操作面板(itemList 推断当前可用入口)
+    _showPaywallActions: function(itemList) {
       uni.showActionSheet({
-        itemList: ['购买家庭会员', '购买健康启航计划'],
+        itemList: itemList,
         success: function(sheetRes) {
           if (sheetRes.tapIndex === 0) {
             uni.setStorageSync('PLAN_RESUME_AFTER_PURCHASE', '1')