Procházet zdrojové kódy

fix(onboarding): 老账号首次上线静默标记已读,避免全体重弹

Xiaogang Liao před 1 týdnem
rodič
revize
fc4822afdc

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3499,7 +3499,11 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
                     + "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 表已就绪");
+            jdbcTemplate.update("INSERT IGNORE INTO sys_config (config_key, config_value, description) "
+                            + "VALUES (?, CAST(UNIX_TIMESTAMP(NOW()) * 1000 AS CHAR), ?)",
+                    "onboarding_guide_baseline_at",
+                    "首页引导浮层按账号迁移基线(此时间前注册的账号视为已读,不重弹)");
+            log.info("迁移451: user_onboarding_guide 表与基线时间已就绪");
         } catch (Exception e) {
             log.warn("迁移451: user_onboarding_guide 创建失败: {}", e.getMessage());
         }

+ 53 - 12
cfc-backend/src/main/java/com/etotem/cfc/service/GuideConfigService.java

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
+import java.sql.Timestamp;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -21,6 +22,9 @@ import java.util.Map;
  * <p>配置存于 {@code sys_config.onboarding_guide_config};已读状态按账号存于
  * {@code user_onboarding_guide}(user_id + position 唯一)。是否展示由服务端判定:
  * 新账号无记录必弹;同一账号在引导内容(版本号 + 实际展示端步骤文案)变化后重弹。</p>
+ *
+ * <p>老账号兼容:首次上线本逻辑时,{@code onboarding_guide_baseline_at} 记录基线时间,
+ * 基线之前注册的账号视为已读并静默写入记录,避免全体老用户重弹;基线之后注册的新账号照常弹。</p>
  */
 @Service
 public class GuideConfigService {
@@ -28,6 +32,7 @@ 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 BASELINE_KEY = "onboarding_guide_baseline_at";
     private static final String POSITION_HOME = "home";
     private static final String POSITION_CHILD = "child";
     private static final String ROLE_PARENT = "parent";
@@ -60,7 +65,7 @@ public class GuideConfigService {
         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 version = versionOf(config);
         String hash = contentHash(config, pos, role);
         data.put("version", version);
         data.put("contentHash", hash);
@@ -88,7 +93,8 @@ public class GuideConfigService {
                 "SELECT content_hash FROM user_onboarding_guide WHERE user_id=? AND position=?",
                 userId, position);
         if (rows.isEmpty()) {
-            return true;
+            // 老账号基线迁移:静默记为已读,避免上线后全体老用户重弹
+            return !isLegacyAccount(userId, position, config, hash);
         }
         Object stored = rows.get(0).get("content_hash");
         return stored == null || !hash.equals(String.valueOf(stored));
@@ -103,17 +109,11 @@ public class GuideConfigService {
         }
         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 ver = (version == null || version.isEmpty()) ? versionOf(config) : 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);
+        upsertSeen(userId, pos, ver, hash);
     }
 
     /**
@@ -123,11 +123,10 @@ public class GuideConfigService {
      * 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("version=").append(versionOf(config)).append('|');
         sb.append("end=").append(end).append('|');
         if (rawSteps instanceof List) {
             for (Object step : (List<?>) rawSteps) {
@@ -144,6 +143,48 @@ public class GuideConfigService {
         return sha256Hex(sb.toString());
     }
 
+    /**
+     * 基线之前注册的账号:静默写入当前内容已读记录并返回 true。
+     * 基线未就绪或账号创建时间晚于基线时返回 false(按新账号处理)。
+     */
+    private boolean isLegacyAccount(Long userId, String position, Map<String, Object> config, String hash) {
+        String baselineValue = sysConfigService.getValue(BASELINE_KEY);
+        if (baselineValue == null || baselineValue.trim().isEmpty()) {
+            return false;
+        }
+        long baseline;
+        try {
+            baseline = Long.parseLong(baselineValue.trim());
+        } catch (NumberFormatException e) {
+            logger.warn("首页引导基线时间格式异常: {}", baselineValue);
+            return false;
+        }
+        Timestamp createdAt;
+        try {
+            createdAt = jdbcTemplate.queryForObject(
+                    "SELECT created_at FROM users WHERE id=?", Timestamp.class, userId);
+        } catch (Exception e) {
+            return false;
+        }
+        if (createdAt == null || createdAt.getTime() > baseline) {
+            return false;
+        }
+        upsertSeen(userId, position, versionOf(config), hash);
+        return true;
+    }
+
+    private void upsertSeen(Long userId, String position, String version, String hash) {
+        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, position, version, hash);
+    }
+
+    private static String versionOf(Map<String, Object> config) {
+        return config == null ? "1.0" : String.valueOf(config.getOrDefault("version", "1.0"));
+    }
+
     private String resolveEnd(String position, String role) {
         if (ROLE_CHILD.equals(role)) {
             return ROLE_CHILD;

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

@@ -15,6 +15,7 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.sql.Timestamp;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -183,4 +184,41 @@ class GuideConfigServiceTest {
         verify(jdbcTemplate).update(anyString(), eq(12L), eq("home"), eq("1.0"),
                 eq(service.contentHash(cfg, "home", "parent")));
     }
+
+    @Test
+    void 老账号_基线前注册_静默标记已读不重弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(null);
+        when(sysConfigService.getValue("onboarding_guide_baseline_at")).thenReturn("1000");
+        when(jdbcTemplate.queryForObject(anyString(), eq(Timestamp.class), any(Long.class)))
+                .thenReturn(new Timestamp(500L));
+
+        Result<Map<String, Object>> result = service.current(12L, "home", "parent");
+
+        assertEquals(Boolean.FALSE, result.getData().get("shouldShow"));
+        verify(jdbcTemplate).update(anyString(), eq(12L), eq("home"), eq("1.0"),
+                eq(service.contentHash(cfg, "home", "parent")));
+    }
+
+    @Test
+    void 新账号_基线后注册_必弹() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(null);
+        when(sysConfigService.getValue("onboarding_guide_baseline_at")).thenReturn("1000");
+        when(jdbcTemplate.queryForObject(anyString(), eq(Timestamp.class), any(Long.class)))
+                .thenReturn(new Timestamp(2000L));
+
+        assertEquals(Boolean.TRUE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
+
+    @Test
+    void 基线时间缺失_按新账号处理() throws Exception {
+        Map<String, Object> cfg = config("1.0", "家长版", "孩子版");
+        stubConfig(cfg);
+        stubSeen(null);
+
+        assertEquals(Boolean.TRUE, service.current(12L, "home", "parent").getData().get("shouldShow"));
+    }
 }