فهرست منبع

refactor(cf): F-6 存量迁移 三账本合一(迁移417)

将 family_platform_balance / family_members.system_points / user_platform_balance 三套旧账本幂等迁入成员维度 cf_balance;迁移流水 ref_type 分别为 migration_family_pool / migration_system_points / migration_user_wallet,与 F-2 升级后的唯一键 uk_ref_type 对齐。
E2E Test Bot 2 هفته پیش
والد
کامیت
68f73f1d00

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.config;
 
 import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.CfLegacyLedgerMigrationService;
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -41,6 +42,9 @@ public class DatabaseInitializer implements CommandLineRunner {
     @Resource
     private ResourceLoader resourceLoader;
 
+    @Resource
+    private CfLegacyLedgerMigrationService cfLegacyLedgerMigrationService;
+
     @Value("${app.init-test-data:false}")
     private boolean initTestData;
 
@@ -3285,6 +3289,17 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 幂等:product_key 列/索引已存在则跳过;product_config 表不存在时数据并入为空跑
         addServiceConfigProductKeyColumn();
         migrateProductConfigToServiceConfig();
+
+        // 迁移417: 三套旧 CF 账本一次性迁入 cf_balance(F-6 存量迁移)
+        // 414/415/416 已由其他批次占用;幂等键分别为 migration_family_pool /
+        // migration_system_points / migration_user_wallet,重复启动不会重复入账。
+        try {
+            int migrated = cfLegacyLedgerMigrationService.migrate();
+            log.info("迁移417: 旧账本存量迁移完成,本次新增 {} 笔", migrated);
+        } catch (Exception e) {
+            // 事务已整体回滚;保留启动可用性,下次启动可安全重试。
+            log.warn("迁移417: 旧账本存量迁移失败,本批已回滚,下次启动重试: {}", e.getMessage());
+        }
     }
 
     private void migrateHealthDimensionsTable() {

+ 366 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfLegacyLedgerMigrationService.java

@@ -0,0 +1,366 @@
+package com.etotem.cfc.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+
+/**
+ * F-6:三套旧 CF 账本一次性迁入个人 {@code cf_balance}。
+ *
+ * <p>迁移编号 417。旧表 {@code family_platform_balance}、
+ * {@code family_members.system_points}、{@code user_platform_balance} 只读保留,
+ * 本服务不会回写或清零旧值。</p>
+ *
+ * <p>幂等策略:每一笔旧账本记录都先按
+ * {@code (member_id, ref_type, ref_id)} 检查 {@code cf_balance_log};已有流水即跳过。
+ * 余额变更与流水写入处于同一事务,失败会整体回滚,下次启动可安全重试。</p>
+ */
+@Slf4j
+@Service
+public class CfLegacyLedgerMigrationService {
+
+    public static final String REF_TYPE_FAMILY_POOL = "migration_family_pool";
+    public static final String REF_TYPE_SYSTEM_POINTS = "migration_system_points";
+    public static final String REF_TYPE_USER_WALLET = "migration_user_wallet";
+
+    private static final String LOG_TYPE_ADJUST = "adjust";
+
+    private static final String FAMILY_POOL_SQL =
+            "SELECT fpb.id AS source_id, fpb.family_id AS family_id, f.creator_id AS user_id, "
+                    + "fm.id AS member_id, COALESCE(fpb.total_earned, 0) AS total_earned, "
+                    + "COALESCE(fpb.available, 0) AS available, COALESCE(fpb.frozen, 0) AS frozen, "
+                    + "COALESCE(fpb.withdrawn, 0) AS withdrawn, COALESCE(fpb.exchanged, 0) AS exchanged "
+                    + "FROM family_platform_balance fpb "
+                    + "LEFT JOIN families f ON f.id = fpb.family_id "
+                    + "LEFT JOIN family_members fm ON fm.id = ("
+                    + "  SELECT fm2.id FROM family_members fm2 "
+                    + "  WHERE fm2.family_id = fpb.family_id AND fm2.user_id = f.creator_id "
+                    + "  ORDER BY CASE WHEN fm2.is_admin = 1 THEN 0 ELSE 1 END, fm2.id ASC LIMIT 1"
+                    + ")";
+
+    private static final String SYSTEM_POINTS_SQL =
+            "SELECT fm.id AS source_id, fm.id AS member_id, fm.family_id AS family_id, "
+                    + "fm.user_id AS user_id, COALESCE(fm.system_points, 0) AS amount "
+                    + "FROM family_members fm WHERE COALESCE(fm.system_points, 0) > 0";
+
+    private static final String USER_WALLET_SQL =
+            "SELECT upb.id AS source_id, fm.id AS member_id, fm.family_id AS family_id, "
+                    + "upb.user_id AS user_id, COALESCE(upb.total_earned, 0) AS total_earned, "
+                    + "COALESCE(upb.available, 0) AS available, COALESCE(upb.frozen, 0) AS frozen, "
+                    + "COALESCE(upb.withdrawn, 0) AS withdrawn, COALESCE(upb.exchanged, 0) AS exchanged "
+                    + "FROM user_platform_balance upb "
+                    + "LEFT JOIN users u ON u.id = upb.user_id "
+                    + "LEFT JOIN family_members fm ON fm.id = ("
+                    + "  SELECT fm2.id FROM family_members fm2 WHERE fm2.user_id = upb.user_id "
+                    + "  ORDER BY CASE WHEN upb.family_id IS NOT NULL AND fm2.family_id = upb.family_id THEN 0 "
+                    + "           WHEN u.family_id IS NOT NULL AND fm2.family_id = u.family_id THEN 1 ELSE 2 END, "
+                    + "           CASE WHEN fm2.is_admin = 1 THEN 0 ELSE 1 END, fm2.id ASC LIMIT 1"
+                    + ")";
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    /**
+     * 执行三类存量迁移。整个方法是一个事务:任何一笔失败都会回滚本批全部变更。
+     *
+     * @return 本次实际迁入的旧账本记录数(已迁移过的记录不计入)
+     */
+    @Transactional
+    public int migrate() {
+        if (!tableExists("cf_balance") || !tableExists("cf_balance_log")) {
+            log.warn("迁移417: cf_balance / cf_balance_log 不存在,跳过存量迁移");
+            return 0;
+        }
+
+        int migrated = 0;
+        migrated += migrateFamilyPlatformPools();
+        migrated += migrateMemberSystemPoints();
+        migrated += migrateUserWallets();
+        log.info("迁移417: 三套旧 CF 账本迁移完成,本次迁入 {} 笔", migrated);
+        return migrated;
+    }
+
+    int migrateFamilyPlatformPools() {
+        if (!tableExists("family_platform_balance") || !tableExists("families")
+                || !tableExists("family_members")) {
+            log.warn("迁移417: 家庭池或家庭成员表不存在,跳过 migration_family_pool");
+            return 0;
+        }
+
+        List<FamilyPoolRow> rows = jdbcTemplate.query(FAMILY_POOL_SQL,
+                (rs, rowNum) -> readFamilyPoolRow(rs));
+        int migrated = 0;
+        int unresolved = 0;
+        for (FamilyPoolRow row : rows) {
+            if (row.memberId == null) {
+                if (hasMigratableValue(row.totalEarned, row.available, row.frozen,
+                        row.withdrawn, row.exchanged)) {
+                    unresolved++;
+                    log.error("迁移417: 家庭池 familyId={} 找不到创建者对应的 family_member",
+                            row.familyId);
+                }
+                continue;
+            }
+            int availableDelta = mergeAvailable(row.available, row.frozen);
+            boolean applied = applyLedger(row.memberId, row.userId, row.familyId,
+                    0L, REF_TYPE_FAMILY_POOL, availableDelta, row.totalEarned,
+                    row.withdrawn, row.exchanged,
+                    "F-6 存量迁移:家庭池 available+frozen 合入 available");
+            if (applied) {
+                migrated++;
+            }
+        }
+        assertNoUnresolvedSource("migration_family_pool", unresolved);
+        return migrated;
+    }
+
+    int migrateMemberSystemPoints() {
+        if (!tableExists("family_members")) {
+            log.warn("迁移417: family_members 表不存在,跳过 migration_system_points");
+            return 0;
+        }
+
+        List<SystemPointsRow> rows = jdbcTemplate.query(SYSTEM_POINTS_SQL,
+                (rs, rowNum) -> readSystemPointsRow(rs));
+        int migrated = 0;
+        for (SystemPointsRow row : rows) {
+            int availableDelta = mergeAvailable(row.amount, 0);
+            boolean applied = applyLedger(row.memberId, row.userId, row.familyId,
+                    row.memberId, REF_TYPE_SYSTEM_POINTS, availableDelta, row.amount,
+                    0, 0,
+                    "F-6 存量迁移:family_members.system_points 迁入个人 CF 值");
+            if (applied) {
+                migrated++;
+            }
+        }
+        return migrated;
+    }
+
+    int migrateUserWallets() {
+        if (!tableExists("user_platform_balance") || !tableExists("users")
+                || !tableExists("family_members")) {
+            log.warn("迁移417: 用户钱包或家庭成员表不存在,跳过 migration_user_wallet");
+            return 0;
+        }
+
+        List<UserWalletRow> rows = jdbcTemplate.query(USER_WALLET_SQL,
+                (rs, rowNum) -> readUserWalletRow(rs));
+        int migrated = 0;
+        int unresolved = 0;
+        for (UserWalletRow row : rows) {
+            if (row.memberId == null) {
+                if (hasMigratableValue(row.totalEarned, row.available, row.frozen,
+                        row.withdrawn, row.exchanged)) {
+                    unresolved++;
+                    log.error("迁移417: 用户钱包 userId={} 找不到本人对应的 family_member",
+                            row.userId);
+                }
+                continue;
+            }
+            int availableDelta = mergeAvailable(row.available, row.frozen);
+            boolean applied = applyLedger(row.memberId, row.userId, row.familyId,
+                    row.sourceId, REF_TYPE_USER_WALLET, availableDelta, row.totalEarned,
+                    row.withdrawn, row.exchanged,
+                    "F-6 存量迁移:用户钱包 available+frozen 合入 available");
+            if (applied) {
+                migrated++;
+            }
+        }
+        assertNoUnresolvedSource("migration_user_wallet", unresolved);
+        return migrated;
+    }
+
+    /**
+     * 将一笔旧账本增量写入个人余额并留迁移流水。
+     *
+     * @return true 表示本次实际迁入;false 表示已有迁移流水,已幂等跳过
+     */
+    private boolean applyLedger(Long memberId, Long userId, Long familyId, Long refId,
+                                 String refType, int availableDelta, int totalEarnedDelta,
+                                 int withdrawnDelta, int exchangedDelta, String remark) {
+        // 幂等键对齐 F-2 升级后的唯一键 uk_ref_type (member_id, type, ref_type, ref_id)
+        Integer existingLogs = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM cf_balance_log "
+                        + "WHERE member_id = ? AND type = ? AND ref_type = ? AND ref_id = ?",
+                Integer.class, memberId, LOG_TYPE_ADJUST, refType, refId);
+        if (!shouldMigrateRow(existingLogs)) {
+            log.debug("迁移417: {} 已存在迁移流水 memberId={} refId={},跳过",
+                    refType, memberId, refId);
+            return false;
+        }
+
+        // frozen 按存量口径合入 available;目标表 frozen 保留 F-2 后续业务产生的冻结值。
+        jdbcTemplate.update(
+                "INSERT INTO cf_balance "
+                        + "(member_id, user_id, family_id, total_earned, available, frozen, withdrawn, exchanged, created_at, updated_at) "
+                        + "VALUES (?, ?, ?, ?, ?, 0, ?, ?, NOW(), NOW()) "
+                        + "ON DUPLICATE KEY UPDATE "
+                        + "user_id = COALESCE(cf_balance.user_id, VALUES(user_id)), "
+                        + "family_id = COALESCE(cf_balance.family_id, VALUES(family_id)), "
+                        + "total_earned = cf_balance.total_earned + VALUES(total_earned), "
+                        + "available = cf_balance.available + VALUES(available), "
+                        + "withdrawn = cf_balance.withdrawn + VALUES(withdrawn), "
+                        + "exchanged = cf_balance.exchanged + VALUES(exchanged), "
+                        + "updated_at = NOW()",
+                memberId, userId, familyId, totalEarnedDelta, availableDelta,
+                withdrawnDelta, exchangedDelta);
+
+        Integer balanceAfter = jdbcTemplate.queryForObject(
+                "SELECT available FROM cf_balance WHERE member_id = ?",
+                Integer.class, memberId);
+        if (balanceAfter == null) {
+            throw new IllegalStateException("迁移417: cf_balance 写入后未找到 memberId=" + memberId);
+        }
+
+        jdbcTemplate.update(
+                "INSERT INTO cf_balance_log "
+                        + "(member_id, user_id, family_id, type, ref_type, ref_id, amount, balance_after, remark, created_at) "
+                        + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())",
+                memberId, userId, familyId, LOG_TYPE_ADJUST, refType, refId,
+                availableDelta, balanceAfter, remark);
+        return true;
+    }
+
+    /** 旧账本可用值口径:available + frozen,合入新账本 available。 */
+    public static int mergeAvailable(Integer available, Integer frozen) {
+        return safeInt(available) + safeInt(frozen);
+    }
+
+    /** 幂等守卫:仅当该来源尚无迁移流水时才允许入账。 */
+    public static boolean shouldMigrateRow(Integer existingLogCount) {
+        return existingLogCount == null || existingLogCount <= 0;
+    }
+
+    /** 流动 CF 值守恒口径:迁移前后均按 available + frozen 计算。 */
+    public static long liquidValue(Integer available, Integer frozen) {
+        return (long) safeInt(available) + safeInt(frozen);
+    }
+
+    /** 历史消耗口径:已提现 + 已兑换。 */
+    public static long historicalValue(Integer withdrawn, Integer exchanged) {
+        return (long) safeInt(withdrawn) + safeInt(exchanged);
+    }
+
+    /**
+     * 校验单笔旧账本迁入后的守恒:
+     * 流动值 available+frozen 必须等于迁入 available;历史 withdrawn/exchanged 必须原样带入。
+     */
+    public static boolean isConserved(Integer oldAvailable, Integer oldFrozen,
+                                      Integer oldWithdrawn, Integer oldExchanged,
+                                      int migratedAvailable, int migratedWithdrawn,
+                                      int migratedExchanged) {
+        return liquidValue(oldAvailable, oldFrozen) == migratedAvailable
+                && historicalValue(oldWithdrawn, oldExchanged)
+                == (long) migratedWithdrawn + migratedExchanged;
+    }
+
+    private static int safeInt(Integer value) {
+        return value == null ? 0 : value;
+    }
+
+    private static boolean hasMigratableValue(int totalEarned, int available, int frozen,
+                                              int withdrawn, int exchanged) {
+        return totalEarned != 0 || available != 0 || frozen != 0
+                || withdrawn != 0 || exchanged != 0;
+    }
+
+    /** 有价值但找不到目标成员的旧账本必须让整批回滚,禁止静默丢失 CF 值。 */
+    private static void assertNoUnresolvedSource(String refType, int unresolved) {
+        if (unresolved > 0) {
+            throw new IllegalStateException(
+                    "迁移417: " + refType + " 有 " + unresolved + " 笔非零旧账本找不到目标成员");
+        }
+    }
+
+    private FamilyPoolRow readFamilyPoolRow(ResultSet rs) throws SQLException {
+        FamilyPoolRow row = new FamilyPoolRow();
+        row.sourceId = rs.getLong("source_id");
+        row.familyId = rs.getLong("family_id");
+        long userId = rs.getLong("user_id");
+        row.userId = rs.wasNull() ? null : userId;
+        long memberId = rs.getLong("member_id");
+        row.memberId = rs.wasNull() ? null : memberId;
+        row.totalEarned = rs.getInt("total_earned");
+        row.available = rs.getInt("available");
+        row.frozen = rs.getInt("frozen");
+        row.withdrawn = rs.getInt("withdrawn");
+        row.exchanged = rs.getInt("exchanged");
+        return row;
+    }
+
+    private SystemPointsRow readSystemPointsRow(ResultSet rs) throws SQLException {
+        SystemPointsRow row = new SystemPointsRow();
+        row.sourceId = rs.getLong("source_id");
+        row.memberId = rs.getLong("member_id");
+        row.familyId = rs.getLong("family_id");
+        long userId = rs.getLong("user_id");
+        row.userId = rs.wasNull() ? null : userId;
+        row.amount = rs.getInt("amount");
+        return row;
+    }
+
+    private UserWalletRow readUserWalletRow(ResultSet rs) throws SQLException {
+        UserWalletRow row = new UserWalletRow();
+        row.sourceId = rs.getLong("source_id");
+        row.memberId = rs.getLong("member_id");
+        row.familyId = rs.getLong("family_id");
+        long userId = rs.getLong("user_id");
+        row.userId = rs.wasNull() ? null : userId;
+        row.totalEarned = rs.getInt("total_earned");
+        row.available = rs.getInt("available");
+        row.frozen = rs.getInt("frozen");
+        row.withdrawn = rs.getInt("withdrawn");
+        row.exchanged = rs.getInt("exchanged");
+        return row;
+    }
+
+    private boolean tableExists(String tableName) {
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.TABLES "
+                            + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
+                    Integer.class, tableName);
+            return count != null && count > 0;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    static final class FamilyPoolRow {
+        Long sourceId;
+        Long familyId;
+        Long userId;
+        Long memberId;
+        int totalEarned;
+        int available;
+        int frozen;
+        int withdrawn;
+        int exchanged;
+    }
+
+    static final class SystemPointsRow {
+        Long sourceId;
+        Long memberId;
+        Long familyId;
+        Long userId;
+        int amount;
+    }
+
+    static final class UserWalletRow {
+        Long sourceId;
+        Long memberId;
+        Long familyId;
+        Long userId;
+        int totalEarned;
+        int available;
+        int frozen;
+        int withdrawn;
+        int exchanged;
+    }
+}

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

@@ -6703,6 +6703,11 @@ CREATE TABLE IF NOT EXISTS cf_balance_log (
     UNIQUE KEY uk_ref (member_id, ref_type, ref_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水';
 
+-- 迁移417(F-6 存量迁移):本批仅迁移数据,不新增 DDL。
+-- family_platform_balance / family_members.system_points / user_platform_balance
+-- 三套旧账本保留为只读历史;迁移流水分别使用
+-- migration_family_pool / migration_system_points / migration_user_wallet 幂等守卫。
+
 -- 表 CF值获取规则(运营可配置,24 行种子 F02–F24)
 CREATE TABLE IF NOT EXISTS cf_earn_rule (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 140 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfLegacyLedgerMigrationServiceTest.java

@@ -0,0 +1,140 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 CfLegacyLedgerMigrationServiceTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private CfLegacyLedgerMigrationService cfLegacyLedgerMigrationService;
+
+    @Test
+    void migrationMath_mergesFrozenIntoAvailableAndGuardsExistingLogs() {
+        assertEquals(35, CfLegacyLedgerMigrationService.mergeAvailable(30, 5));
+        assertEquals(0, CfLegacyLedgerMigrationService.mergeAvailable(null, null));
+        assertTrue(CfLegacyLedgerMigrationService.shouldMigrateRow(0));
+        assertTrue(CfLegacyLedgerMigrationService.shouldMigrateRow(null));
+        assertFalse(CfLegacyLedgerMigrationService.shouldMigrateRow(1));
+
+        assertTrue(CfLegacyLedgerMigrationService.isConserved(
+                20, 5, 7, 3, 25, 7, 3));
+        assertFalse(CfLegacyLedgerMigrationService.isConserved(
+                20, 5, 7, 3, 24, 7, 3));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void migrateMemberSystemPoints_repeatsSkipsBalanceAndLogInserts() {
+        CfLegacyLedgerMigrationService.SystemPointsRow row =
+                new CfLegacyLedgerMigrationService.SystemPointsRow();
+        row.sourceId = 11L;
+        row.memberId = 11L;
+        row.familyId = 7L;
+        row.userId = 9L;
+        row.amount = 20;
+
+        when(jdbcTemplate.queryForObject(
+                ArgumentMatchers.startsWith("SELECT COUNT(*) FROM information_schema.TABLES"),
+                eq(Integer.class), anyString()))
+                .thenReturn(1);
+        when(jdbcTemplate.query(
+                anyString(),
+                ArgumentMatchers.<RowMapper<CfLegacyLedgerMigrationService.SystemPointsRow>>any()))
+                .thenReturn(Collections.singletonList(row));
+        when(jdbcTemplate.queryForObject(
+                eq("SELECT COUNT(*) FROM cf_balance_log "
+                        + "WHERE member_id = ? AND type = ? AND ref_type = ? AND ref_id = ?"),
+                eq(Integer.class),
+                eq(11L),
+                eq("adjust"),
+                eq(CfLegacyLedgerMigrationService.REF_TYPE_SYSTEM_POINTS),
+                eq(11L)))
+                .thenReturn(0, 1);
+        when(jdbcTemplate.queryForObject(
+                eq("SELECT available FROM cf_balance WHERE member_id = ?"),
+                eq(Integer.class),
+                eq(11L)))
+                .thenReturn(30);
+
+        assertEquals(1, cfLegacyLedgerMigrationService.migrateMemberSystemPoints());
+        assertEquals(0, cfLegacyLedgerMigrationService.migrateMemberSystemPoints());
+
+        verify(jdbcTemplate).update(
+                ArgumentMatchers.contains("INSERT INTO cf_balance ("),
+                eq(11L), eq(9L), eq(7L), eq(20), eq(20), eq(0), eq(0));
+        verify(jdbcTemplate).update(
+                ArgumentMatchers.contains("INSERT INTO cf_balance_log"),
+                eq(11L), eq(9L), eq(7L), eq("adjust"),
+                eq(CfLegacyLedgerMigrationService.REF_TYPE_SYSTEM_POINTS), eq(11L),
+                eq(20), eq(30), eq("F-6 存量迁移:family_members.system_points 迁入个人 CF 值"));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void migrateFamilyPlatformPools_usesZeroRefIdAndMergesFrozen() {
+        CfLegacyLedgerMigrationService.FamilyPoolRow row =
+                new CfLegacyLedgerMigrationService.FamilyPoolRow();
+        row.sourceId = 80L;
+        row.familyId = 7L;
+        row.userId = 9L;
+        row.memberId = 11L;
+        row.totalEarned = 100;
+        row.available = 30;
+        row.frozen = 5;
+        row.withdrawn = 2;
+        row.exchanged = 3;
+
+        when(jdbcTemplate.queryForObject(
+                ArgumentMatchers.startsWith("SELECT COUNT(*) FROM information_schema.TABLES"),
+                eq(Integer.class), anyString()))
+                .thenReturn(1);
+        when(jdbcTemplate.query(
+                anyString(),
+                ArgumentMatchers.<RowMapper<CfLegacyLedgerMigrationService.FamilyPoolRow>>any()))
+                .thenReturn(Collections.singletonList(row));
+        when(jdbcTemplate.queryForObject(
+                eq("SELECT COUNT(*) FROM cf_balance_log "
+                        + "WHERE member_id = ? AND type = ? AND ref_type = ? AND ref_id = ?"),
+                eq(Integer.class),
+                eq(11L),
+                eq("adjust"),
+                eq(CfLegacyLedgerMigrationService.REF_TYPE_FAMILY_POOL),
+                eq(0L)))
+                .thenReturn(0);
+        when(jdbcTemplate.queryForObject(
+                eq("SELECT available FROM cf_balance WHERE member_id = ?"),
+                eq(Integer.class),
+                eq(11L)))
+                .thenReturn(35);
+
+        assertEquals(1, cfLegacyLedgerMigrationService.migrateFamilyPlatformPools());
+
+        verify(jdbcTemplate).update(
+                ArgumentMatchers.contains("INSERT INTO cf_balance_log"),
+                eq(11L), eq(9L), eq(7L), eq("adjust"),
+                eq(CfLegacyLedgerMigrationService.REF_TYPE_FAMILY_POOL), eq(0L),
+                eq(35), eq(35),
+                eq("F-6 存量迁移:家庭池 available+frozen 合入 available"));
+    }
+}

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -226,6 +226,7 @@
 | **AI健康教练人格分化与管家自助选择**(L2 自助绑管/换绑 + 浠宝/福宝人格话术 + 管理端分配管理与停接开关) ✅ 已完成 | `specs/2026-08-23-coach-butler-design.md` + `plans/2026-08-23-coach-butler.md` |
 | **BASIC 体验会员 + 权益对比表动态化 + PROVIDER 退出会员权益**(2026-10-18):迁移 381/351/321 统一 level_order 语义(越大越高,PROVIDER=1 与 FREE 同级)+ `getMemberLevel` 归一 PROVIDER→FREE + `canUseFeature` 移除全权限兜底 + 活动价判定去 PROVIDER + 新接口 `POST /api/membership/compare` 聚合各档实际权限(折扣 bps / max_children / max_tasks_per_day / BasicQuotaService 配额 / features 开关),upgrade.vue 与 index.vue 对比表改动态渲染 | `api/API_REFERENCE.md` 4.50 |
 | **CF 值体系统一 · 数据层与配置归口(F-1 + W0.1)**(2026-10-24):8 张新表(cf_balance / cf_balance_log / cf_earn_rule + 24 行种子 / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit + service_config)+ coupon/user_coupon 补 family_id + 迁移 407-412 + `ConfigService.getConfig`(缓存 TTL 60s + `CfConfigCache.invalidate`) | `plans/2026-10-24-cf-value-unification-plan.md` + `api/API_REFERENCE.md` 六 |
+| **CF 值体系统一 · F-6 存量迁移**(2026-10-24):迁移417 将 `family_platform_balance`、`family_members.system_points`、`user_platform_balance` 一次性幂等迁入成员维度 `cf_balance`;三类 `migration_*` 流水守卫重复执行,旧表保留只读历史 | `plans/2026-10-24-cf-value-unification-plan.md` §六 + `api/API_REFERENCE.md` 6.4 |
 
 ---
 

+ 13 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -2093,4 +2093,16 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 
 时间线:2026-10-24 起写接口下线(product_config 只读保留 30 天回滚用);2026-11-23 起确认无回滚需求后删表并移除本控制器。
 
-*文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口);同日补登 W0.2 双配置中心合并(service_config 收编 product_config:迁移413 + product_key 维度 + product-config 接口 410 Gone + 配置中心统一入口)*
+### 6.4 F-6 存量账本迁移(迁移417,无新增接口)
+
+启动时由 `DatabaseInitializer` → `CfLegacyLedgerMigrationService` 一次性执行,无 REST API。迁移只读旧表、不回写旧表:
+
+| 来源 | 目标成员 | `cf_balance_log.ref_type` | `ref_id` | 口径 |
+|------|----------|---------------------------|----------|------|
+| `family_platform_balance` | 家庭 `families.creator_id` 对应的 `family_members` | `migration_family_pool` | `0` | `available + frozen` 合入新 `available`,`withdrawn`/`exchanged` 原样带入 |
+| `family_members.system_points > 0` | 当前成员 | `migration_system_points` | `family_members.id` | `system_points` 合入新 `available` |
+| `user_platform_balance` | 该用户本人对应的 `family_members` | `migration_user_wallet` | `user_platform_balance.id` | `available + frozen` 合入新 `available`,`withdrawn`/`exchanged` 原样带入 |
+
+幂等键为 `(member_id, type='adjust', ref_type, ref_id)`(对齐 F-2 迁移416 升级后的唯一键 `uk_ref_type`);重复启动按三类 `ref_type` 逐笔跳过,余额与流水同事务提交。三套旧账本保留为只读历史,写接口 410 Gone 由 F-8 收口。
+
+*文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口);同日补登 W0.2 双配置中心合并(service_config 收编 product_config:迁移413 + product_key 维度 + product-config 接口 410 Gone + 配置中心统一入口);同日补登 F-6 存量迁移(迁移417 + 三套旧账本幂等迁入 cf_balance)*