Просмотр исходного кода

feat(onboarding): 引导浮层已读状态改为按账号记录,内容变化自动重弹

Xiaogang Liao 6 дней назад
Родитель
Сommit
30ae86d7d7

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3451,6 +3451,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateIndicatorDimensionCodeWidth();
         // 迁移450: 会员权益文案与成交口径对齐——付费档统一享「会员价」,无按等级递增的商品折扣
         alignMembershipBenefitMemberPriceCopy();
+
+        // 迁移451: 首页引导浮层已读状态按账号记录(新账号必弹、内容变化重弹)
+        migrateUserOnboardingGuide();
     }
 
     /**
@@ -3477,6 +3480,31 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移451: 首页引导浮层已读状态按账号记录。
+     *
+     * <p>原实现把 {@code _onboardingDone} 存在小程序本机 storage,换设备/换账号行为不可控,
+     * 且只能靠人工改版本号重弹。改为按 (user_id, position) 记录已读内容指纹:
+     * 新账号无记录必弹;同一账号在引导文案/步骤变化后(content_hash 不一致)重弹。</p>
+     */
+    private void migrateUserOnboardingGuide() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_onboarding_guide ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY,"
+                    + "user_id BIGINT NOT NULL,"
+                    + "position VARCHAR(20) NOT NULL,"
+                    + "version VARCHAR(32) NOT NULL DEFAULT '',"
+                    + "content_hash VARCHAR(64) NOT NULL DEFAULT '',"
+                    + "done_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
+                    + "UNIQUE KEY uk_uog_user_position (user_id, position)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页引导浮层已读状态'");
+            log.info("迁移451: user_onboarding_guide 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移451: user_onboarding_guide 创建失败: {}", e.getMessage());
+        }
+    }
+
     /**
      * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
      *

+ 32 - 21
cfc-backend/src/main/java/com/etotem/cfc/controller/config/GuideConfigController.java

@@ -1,11 +1,10 @@
 package com.etotem.cfc.controller.config;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.service.SysConfigService;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import com.etotem.cfc.service.GuideConfigService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -13,34 +12,46 @@ import javax.annotation.Resource;
 import java.util.Map;
 
 /**
- * 首页引导浮层配置读取(小程序端)
- * 存储于 sys_config 表,key = onboarding_guide_config,value 为 JSON 字符串
+ * 首页引导浮层配置读取(小程序端)。
+ * 配置存于 sys_config 表 key = onboarding_guide_config;
+ * 已读状态按账号存于 user_onboarding_guide。
  */
 @RestController
 @RequestMapping("/api/guide-config")
 public class GuideConfigController {
 
-    private static final String CONFIG_KEY = "onboarding_guide_config";
-
-    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
-
     @Resource
-    private SysConfigService sysConfigService;
+    private GuideConfigService guideConfigService;
 
     /**
-     * 获取引导配置(需登录;key 不存在时 data 为 null,前端使用默认值)
+     * 获取引导配置 + 当前用户是否应展示(需登录)。
+     * 请求体可选:{"position":"home","role":"parent"} / {"position":"child","role":"child"}。
      */
     @PostMapping("/current")
-    public Result<Map<String, Object>> current(@RequestAttribute("userId") Long userId) {
-        String value = sysConfigService.getValue(CONFIG_KEY);
-        if (value == null || value.isEmpty()) {
-            return Result.success(null);
+    public Result<Map<String, Object>> current(@RequestAttribute("userId") Long userId,
+                                               @RequestBody(required = false) Map<String, Object> params) {
+        return guideConfigService.current(userId, str(params, "position"), str(params, "role"));
+    }
+
+    /**
+     * 记录引导已完成(需登录)。
+     * 请求体:{"position":"home","role":"parent","version":"1.0","contentHash":"..."}。
+     */
+    @PostMapping("/ack")
+    public Result<String> ack(@RequestAttribute("userId") Long userId,
+                              @RequestBody Map<String, Object> params) {
+        if (params == null) {
+            return Result.error("参数不能为空");
         }
-        try {
-            Map<String, Object> config = OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
-            return Result.success(config);
-        } catch (Exception e) {
-            return Result.error("首页引导配置解析失败: " + e.getMessage());
+        guideConfigService.ack(userId, str(params, "position"), str(params, "role"),
+                str(params, "version"), str(params, "contentHash"));
+        return Result.success("已记录");
+    }
+
+    private static String str(Map<String, Object> params, String key) {
+        if (params == null || params.get(key) == null) {
+            return null;
         }
+        return String.valueOf(params.get(key));
     }
-}
+}

+ 178 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideConfigService.java

@@ -0,0 +1,178 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 首页引导浮层配置与已读状态。
+ *
+ * <p>配置存于 {@code sys_config.onboarding_guide_config};已读状态按账号存于
+ * {@code user_onboarding_guide}(user_id + position 唯一)。是否展示由服务端判定:
+ * 新账号无记录必弹;同一账号在引导内容(版本号 + 实际展示端步骤文案)变化后重弹。</p>
+ */
+@Service
+public class GuideConfigService {
+
+    private static final Logger logger = LoggerFactory.getLogger(GuideConfigService.class);
+
+    private static final String CONFIG_KEY = "onboarding_guide_config";
+    private static final String POSITION_HOME = "home";
+    private static final String POSITION_CHILD = "child";
+    private static final String ROLE_PARENT = "parent";
+    private static final String ROLE_CHILD = "child";
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    public Map<String, Object> getConfig() {
+        String value = sysConfigService.getValue(CONFIG_KEY);
+        if (value == null || value.isEmpty()) {
+            return null;
+        }
+        try {
+            return OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
+        } catch (Exception e) {
+            logger.warn("首页引导配置解析失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 小程序端读取:配置字段保持顶层(兼容旧客户端),额外返回 shouldShow / contentHash / version。
+     */
+    public Result<Map<String, Object>> current(Long userId, String position, String role) {
+        String pos = normalizePosition(position);
+        Map<String, Object> config = getConfig();
+        Map<String, Object> data = config == null ? new LinkedHashMap<>() : new LinkedHashMap<>(config);
+        String version = config == null ? "1.0" : String.valueOf(config.getOrDefault("version", "1.0"));
+        String hash = contentHash(config, pos, role);
+        data.put("version", version);
+        data.put("contentHash", hash);
+        data.put("shouldShow", shouldShow(userId, pos, config, hash));
+        return Result.success(data);
+    }
+
+    /**
+     * 是否展示:启用 + 位置匹配 + 该账号未读过当前内容指纹。
+     */
+    public boolean shouldShow(Long userId, String position, Map<String, Object> config, String hash) {
+        if (config != null && Boolean.FALSE.equals(config.get("enabled"))) {
+            return false;
+        }
+        if (config != null && config.get("positions") instanceof List) {
+            List<?> positions = (List<?>) config.get("positions");
+            if (!positions.contains(position)) {
+                return false;
+            }
+        }
+        if (userId == null) {
+            return false;
+        }
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(
+                "SELECT content_hash FROM user_onboarding_guide WHERE user_id=? AND position=?",
+                userId, position);
+        if (rows.isEmpty()) {
+            return true;
+        }
+        Object stored = rows.get(0).get("content_hash");
+        return stored == null || !hash.equals(String.valueOf(stored));
+    }
+
+    /**
+     * 记录已读。客户端传空指纹时按当前配置计算,避免组件配置拉取失败导致永久重弹。
+     */
+    public void ack(Long userId, String position, String role, String version, String contentHash) {
+        if (userId == null) {
+            return;
+        }
+        String pos = normalizePosition(position);
+        Map<String, Object> config = getConfig();
+        String ver = (version == null || version.isEmpty())
+                ? (config == null ? "1.0" : String.valueOf(config.getOrDefault("version", "1.0")))
+                : version;
+        String hash = (contentHash == null || contentHash.isEmpty())
+                ? contentHash(config, pos, role)
+                : contentHash;
+        jdbcTemplate.update(
+                "INSERT INTO user_onboarding_guide (user_id, position, version, content_hash, done_at) "
+                        + "VALUES (?, ?, ?, ?, NOW()) "
+                        + "ON DUPLICATE KEY UPDATE version=VALUES(version), content_hash=VALUES(content_hash), done_at=NOW()",
+                userId, pos, ver, hash);
+    }
+
+    /**
+     * 内容指纹:版本号 + 实际展示端每一步的 icon/title/desc/isIntro(顺序敏感)。
+     *
+     * <p>首页的展示角色可能不是家长(家长首页可切换为孩子视角),因此指纹按 role 取内容;
+     * role 缺省时按 position 推断。</p>
+     */
+    public String contentHash(Map<String, Object> config, String position, String role) {
+        String version = config == null ? "1.0" : String.valueOf(config.getOrDefault("version", "1.0"));
+        String end = resolveEnd(position, role);
+        Object rawSteps = config == null ? null : config.get(end);
+        StringBuilder sb = new StringBuilder();
+        sb.append("version=").append(version).append('|');
+        sb.append("end=").append(end).append('|');
+        if (rawSteps instanceof List) {
+            for (Object step : (List<?>) rawSteps) {
+                if (!(step instanceof Map)) {
+                    continue;
+                }
+                Map<?, ?> m = (Map<?, ?>) step;
+                sb.append(nullSafe(m.get("icon"))).append('\u0001')
+                        .append(nullSafe(m.get("title"))).append('\u0001')
+                        .append(nullSafe(m.get("desc"))).append('\u0001')
+                        .append(nullSafe(m.get("isIntro"))).append('\u0002');
+            }
+        }
+        return sha256Hex(sb.toString());
+    }
+
+    private String resolveEnd(String position, String role) {
+        if (ROLE_CHILD.equals(role)) {
+            return ROLE_CHILD;
+        }
+        if (ROLE_PARENT.equals(role)) {
+            return ROLE_PARENT;
+        }
+        return POSITION_CHILD.equals(position) ? ROLE_CHILD : ROLE_PARENT;
+    }
+
+    private String normalizePosition(String position) {
+        return POSITION_CHILD.equals(position) ? POSITION_CHILD : POSITION_HOME;
+    }
+
+    private static String nullSafe(Object v) {
+        return v == null ? "" : String.valueOf(v);
+    }
+
+    private static String sha256Hex(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digest.length * 2);
+            for (byte b : digest) {
+                hex.append(String.format("%02x", b));
+            }
+            return hex.toString();
+        } catch (Exception e) {
+            throw new IllegalStateException("SHA-256 不可用", e);
+        }
+    }
+}

+ 12 - 0
cfc-backend/src/main/resources/schema.sql

@@ -7052,3 +7052,15 @@ CREATE TABLE IF NOT EXISTS family_metric_agg_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_metric_key (metric_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置';
+
+-- 首页引导浮层已读状态(按账号记录,内容指纹变化后重弹)
+CREATE TABLE IF NOT EXISTS user_onboarding_guide (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    position VARCHAR(20) NOT NULL COMMENT '展示位置: home=家长首页 / child=孩子端首页',
+    version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '已读时的配置版本号',
+    content_hash VARCHAR(64) NOT NULL DEFAULT '' COMMENT '已读内容指纹(版本号+步骤文案)',
+    done_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '完成时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_uog_user_position (user_id, position)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页引导浮层已读状态';

+ 186 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/GuideConfigServiceTest.java

@@ -0,0 +1,186 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 首页引导浮层「按账号记录 + 新账号必弹 + 内容变化重弹」回归测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class GuideConfigServiceTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Mock
+    private SysConfigService sysConfigService;
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private GuideConfigService service;
+
+    private Map<String, Object> step(String title) {
+        Map<String, Object> s = new LinkedHashMap<>();
+        s.put("icon", "🔍");
+        s.put("title", title);
+        s.put("desc", "描述-" + title);
+        s.put("isIntro", true);
+        return s;
+    }
+
+    private Map<String, Object> config(String version, String parentTitle, String childTitle) {
+        Map<String, Object> cfg = new LinkedHashMap<>();
+        cfg.put("enabled", true);
+        cfg.put("version", version);
+        cfg.put("positions", Arrays.asList("home", "child"));
+        cfg.put("parent", Collections.singletonList(step(parentTitle)));
+        cfg.put("child", Collections.singletonList(step(childTitle)));
+        return cfg;
+    }
+
+    private void stubConfig(Map<String, Object> cfg) throws Exception {
+        when(sysConfigService.getValue("onboarding_guide_config")).thenReturn(MAPPER.writeValueAsString(cfg));
+    }
+
+    private void stubSeen(String hash) {
+        if (hash == null) {
+            when(jdbcTemplate.queryForList(anyString(), any(Long.class), any(String.class)))
+                    .thenReturn(Collections.emptyList());
+            return;
+        }
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(Collections.singletonMap("content_hash", hash));
+        when(jdbcTemplate.queryForList(anyString(), any(Long.class), any(String.class))).thenReturn(rows);
+    }
+
+    @Test
+    void 新账号无记录_必弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(null);
+
+        Result<Map<String, Object>> result = service.current(12L, "home", "parent");
+
+        assertEquals(Boolean.TRUE, result.getData().get("shouldShow"));
+        assertEquals("1.0", result.getData().get("version"));
+        assertEquals(cfg.get("parent"), result.getData().get("parent"));
+    }
+
+    @Test
+    void 已读过相同内容_不弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(service.contentHash(cfg, "home", "parent"));
+
+        assertEquals(Boolean.FALSE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 文案变化_同版本号也重弹() throws Exception {
+        Map<String, Object> before = config("1.0", "旧家长文案", "孩子版");
+        Map<String, Object> after = config("1.0", "新家长文案", "孩子版");
+        stubConfig(after);
+        stubSeen(service.contentHash(before, "home", "parent"));
+
+        assertEquals(Boolean.TRUE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 版本号变化_重弹() throws Exception {
+        Map<String, Object> before = config("1.0", "家长版", "孩子版");
+        Map<String, Object> after = config("1.1", "家长版", "孩子版");
+        stubConfig(after);
+        stubSeen(service.contentHash(before, "home", "parent"));
+
+        assertEquals(Boolean.TRUE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 配置禁用_不弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        cfg.put("enabled", false);
+        stubConfig(cfg);
+
+        assertEquals(Boolean.FALSE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 位置不匹配_不弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        cfg.put("positions", Collections.singletonList("home"));
+        stubConfig(cfg);
+
+        assertEquals(Boolean.FALSE, service.current(12L, "child", "child").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 同一账号家长与孩子位置互不影响() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(service.contentHash(cfg, "home", "parent"));
+
+        assertEquals(Boolean.FALSE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+        assertEquals(Boolean.TRUE, service.current(12L, "child", "child").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 家长内容变化不影响孩子指纹() throws Exception {
+        Map<String, Object> before = config("1.0", "旧家长文案", "孩子版");
+        Map<String, Object> after = config("1.0", "新家长文案", "孩子版");
+
+        assertNotEquals(service.contentHash(before, "home", "parent"), service.contentHash(after, "home", "parent"));
+        assertEquals(service.contentHash(before, "child", "child"), service.contentHash(after, "child", "child"));
+    }
+
+    @Test
+    void 同一位置按实际展示角色计算指纹() {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+
+        assertNotEquals(service.contentHash(cfg, "home", "parent"), service.contentHash(cfg, "home", "child"));
+    }
+
+    @Test
+    void 无配置时按默认内容指纹且新账号可弹() {
+        stubSeen(null);
+
+        Result<Map<String, Object>> result = service.current(12L, "home", "parent");
+
+        assertEquals(Boolean.TRUE, result.getData().get("shouldShow"));
+        assertEquals(64, String.valueOf(result.getData().get("contentHash")).length());
+    }
+
+    @Test
+    void ack_空指纹时按当前配置补齐() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+
+        service.ack(12L, "home", "parent", "", "");
+
+        verify(jdbcTemplate).update(anyString(), eq(12L), eq("home"), eq("1.0"),
+                eq(service.contentHash(cfg, "home", "parent")));
+    }
+}

+ 26 - 7
cfc-frontend/components/OnboardingGuide.vue

@@ -35,7 +35,7 @@
 </template>
 
 <script>
-import { getGuideConfig } from '../utils/api.js'
+import { getGuideConfig, ackGuideConfig } from '../utils/api.js'
 
 var PARENT_STEPS = [
   {
@@ -96,16 +96,20 @@ export default {
     return {
       currentStep: 0,
       _configSteps: null,
-      _configVersion: '1.0'
+      _configVersion: '1.0',
+      _configHash: ''
     }
   },
   created() {
     var self = this
-    getGuideConfig().then(function(res) {
+    getGuideConfig({ position: this.position, role: this.role }).then(function(res) {
       var cfg = (res && res.data) ? res.data : null
-      if (cfg && (cfg.parent || cfg.child)) {
-        self._configSteps = cfg
-        self._configVersion = cfg.version || '1.0'
+      if (cfg) {
+        if (cfg.parent || cfg.child) {
+          self._configSteps = cfg
+          self._configVersion = cfg.version || '1.0'
+        }
+        self._configHash = cfg.contentHash || ''
       }
     }).catch(function() {
       // 拉取配置失败,保持 null 走默认步骤
@@ -132,7 +136,22 @@ export default {
       if (this.currentStep < this.steps.length - 1) {
         this.currentStep++
       } else {
-        uni.setStorageSync('_onboardingDone', JSON.stringify({ version: this._configVersion || '1.0', doneAt: Date.now() }))
+        var doneVersion = this._configVersion || '1.0'
+        var payload = {
+          position: this.position,
+          role: this.role,
+          version: doneVersion,
+          contentHash: this._configHash || ''
+        }
+        // 本机兜底记录带 userId,避免同一设备多账号互相覆盖(服务端已读状态为准)
+        uni.setStorageSync('_onboardingDone', JSON.stringify({
+          version: doneVersion,
+          doneAt: Date.now(),
+          userId: uni.getStorageSync('userId') || ''
+        }))
+        ackGuideConfig(payload).catch(function() {
+          // 上报失败不阻塞关闭:本机已记录,下次联网仍可被服务端纠正
+        })
         this.$emit('close')
       }
     }

+ 17 - 16
cfc-frontend/pages/home-pages/child-index.vue

@@ -773,26 +773,27 @@ export default {
       var self = this
       // 1. 登录守卫:未登录时不尝试引导
       if (!this.isLoggedIn()) return
-      getGuideConfig().then(function(res) {
+      getGuideConfig({ position: 'child', role: 'child' }).then(function(res) {
         var cfg = (res && res.data) ? res.data : null
-        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
-        // 检查启用
-        if (cfg && cfg.enabled === false) return
-        // 检查位置
-        if (cfg && cfg.positions && cfg.positions.indexOf('child') === -1) return
-        // 比较版本
-        var stored = uni.getStorageSync('_onboardingDone')
-        var seenVersion = '0.0'
-        if (stored) {
-          try {
-            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
-            if (obj && obj.version) seenVersion = obj.version
-          } catch (e) {}
+        if (cfg && cfg.shouldShow === false) return
+        // 兼容旧后端:没有 shouldShow 字段时回退到本机版本号判断
+        if (!cfg || cfg.shouldShow === undefined) {
+          if (cfg && cfg.enabled === false) return
+          if (cfg && cfg.positions && cfg.positions.indexOf('child') === -1) return
+          var seenVersion = '0.0'
+          var stored = uni.getStorageSync('_onboardingDone')
+          if (stored) {
+            try {
+              var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+              if (obj && obj.version) seenVersion = obj.version
+            } catch (e) {}
+          }
+          var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+          if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
         }
-        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
         self.showOnboarding = true
       }).catch(function() {
-        // 接口失败降级:无配置则 version='1.0',已看过则不显示
+        // 接口失败降级:本机未看过则展示
         var stored = uni.getStorageSync('_onboardingDone')
         if (stored) {
           try {

+ 18 - 17
cfc-frontend/pages/index-home/index.vue

@@ -1805,7 +1805,7 @@ membershipDays: 0,
         if (newVal) that._tryHideSplash()
       })
     },
-    // 首次登录引导浮层:异步拉取配置并按版本号判断是否展示
+    // 首次登录引导浮层:是否展示由服务端按账号判定(新账号必弹、内容变化重弹)
     _maybeShowOnboardingGuide() {
       if (this.showOnboardingGuide) return
       // 未登录时不打标、不发请求;登录成功后 onSheetLoginSuccess 会再次触发本方法
@@ -1813,28 +1813,29 @@ membershipDays: 0,
       if (this._onboardingGuideAttempted) return
       this._onboardingGuideAttempted = true
       var self = this
-      getGuideConfig().then(function(res) {
+      getGuideConfig({ position: 'home', role: self.currentRole }).then(function(res) {
         var cfg = (res && res.data) ? res.data : null
-        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
-        // 检查启用
-        if (cfg && cfg.enabled === false) return
-        // 检查位置(index-home 对应 'home' 位置)
-        if (cfg && cfg.positions && cfg.positions.indexOf('home') === -1) return
-        // 比较版本
-        var stored = uni.getStorageSync('_onboardingDone')
-        var seenVersion = '0.0'
-        if (stored) {
-          try {
-            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
-            if (obj && obj.version) seenVersion = obj.version
-          } catch (e) {}
+        if (cfg && cfg.shouldShow === false) return
+        // 兼容旧后端:没有 shouldShow 字段时回退到本机版本号判断
+        if (!cfg || cfg.shouldShow === undefined) {
+          if (cfg && cfg.enabled === false) return
+          if (cfg && cfg.positions && cfg.positions.indexOf('home') === -1) return
+          var seenVersion = '0.0'
+          var stored = uni.getStorageSync('_onboardingDone')
+          if (stored) {
+            try {
+              var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+              if (obj && obj.version) seenVersion = obj.version
+            } catch (e) {}
+          }
+          var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+          if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
         }
-        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
         setTimeout(function() {
           self.showOnboardingGuide = true
         }, 600)
       }).catch(function() {
-        // 接口失败降级:无配置则 version='1.0',已看过则不显示
+        // 接口失败降级:本机未看过则展示
         var stored = uni.getStorageSync('_onboardingDone')
         if (stored) {
           try {

+ 8 - 3
cfc-frontend/utils/api.js

@@ -534,9 +534,14 @@ export const getFamilyMemberList = (params) => {
 	return request('/api/family/member/list', 'POST', params || {})
 }
 
-// 首页引导浮层配置(管理端可配置家长/孩子引导步骤,无配置时返回 null data)
-export const getGuideConfig = () => {
-	return request('/api/guide-config/current', 'POST', {})
+// 首页引导浮层配置(返回配置 + 当前账号 shouldShow/contentHash)
+export const getGuideConfig = (params) => {
+	return request('/api/guide-config/current', 'POST', params || {})
+}
+
+// 首页引导浮层已完成上报(按账号记录已读内容指纹)
+export const ackGuideConfig = (params) => {
+	return request('/api/guide-config/ack', 'POST', params || {})
 }
 
 // 引导弹窗配置(guide_popup 表,配置化管理):查询当前用户应展示的弹窗列表