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

refactor(config): W0.2 双配置中心合并,service_config 收编 product_config

- DatabaseInitializer 迁移413(幂等):service_config 新增 product_key 列 + idx_product_key 索引;
  若目标环境存在 product_config 表则按 product.{productKey}.{key} 规则并入(product_key→product_code→product_id→group_key→default 标识规则),表缺失时幂等空跑;同步 schema.sql
- ServiceConfigItem 新增 productKey 字段;ConfigService 支持按 productKey 过滤列表明细 + product 侧配置 get/set(读 service_config WHERE product_key=?)
- AdminServiceConfigController /list、/save 支持 productKey 维度
- 新增 AdminProductConfigController:product_config 写接口 410 Gone(30 天只读回滚窗口,2026-11-23 可删表)
- cfc-web 配置中心统一入口(ConfigCenter.vue + 路由 + 菜单),product_key 可筛选;out/ 备份脚本

注:本基线(origin/cfclub)与测试库不存在 product_config 表/实体,迁移数据并入为空跑(0→0),
ProductConfigServiceImpl 亦不存在,step4 以 ConfigService 同等语义实现。
E2E Test Bot 2 недель назад
Родитель
Сommit
90cccc0365

+ 155 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/ConfigService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.config;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.constant.ProductConfigKeys;
 import com.etotem.cfc.entity.ServiceConfigItem;
 import com.etotem.cfc.mapper.ServiceConfigItemMapper;
 import org.slf4j.Logger;
@@ -116,10 +117,23 @@ public class ConfigService {
 
     /** 列出配置(groupKey 为空时返回全部) */
     public List<ServiceConfigItem> listConfigs(String groupKey) {
+        return listConfigs(groupKey, null);
+    }
+
+    /**
+     * 列出配置(groupKey / productKey 均可为空)。
+     *
+     * productKey 非空时只返回该产品的配置行(W0.2:product 侧配置并入 service_config 后,
+     * product_key 即为唯一的产品维度筛选条件)。
+     */
+    public List<ServiceConfigItem> listConfigs(String groupKey, String productKey) {
         LambdaQueryWrapper<ServiceConfigItem> wrapper = new LambdaQueryWrapper<>();
         if (groupKey != null && !groupKey.trim().isEmpty()) {
             wrapper.eq(ServiceConfigItem::getGroupKey, groupKey);
         }
+        if (productKey != null && !productKey.trim().isEmpty()) {
+            wrapper.eq(ServiceConfigItem::getProductKey, ProductConfigKeys.normalizeProductKey(productKey));
+        }
         wrapper.orderByAsc(ServiceConfigItem::getGroupKey).orderByAsc(ServiceConfigItem::getConfigKey);
         return serviceConfigItemMapper.selectList(wrapper);
     }
@@ -164,6 +178,147 @@ public class ConfigService {
         CfConfigCache.invalidate();
     }
 
+    // ==================== W0.2:product 侧配置(service_config WHERE product_key = ?) ====================
+
+    /**
+     * 读取 product 侧配置值(W0.2 双配置中心合并)。
+     *
+     * 读取链路:缓存(key 为 product.{productKey}.{configKey},TTL 60s)→
+     * service_config WHERE product_key = ? AND config_key = ?。
+     *
+     * @param productKey 产品标识(为空时按 {@link ProductConfigKeys#DEFAULT_PRODUCT_KEY} 兜底)
+     * @param configKey  原配置键(自动加 product.{productKey}. 前缀)
+     * @return 配置值;未登记返回 null
+     */
+    public String getProductConfig(String productKey, String configKey) {
+        if (configKey == null || configKey.trim().isEmpty()) {
+            return null;
+        }
+        String scoped = ProductConfigKeys.scopedKey(productKey, configKey);
+        String cached = CfConfigCache.get(scoped);
+        if (cached != null) {
+            return cached;
+        }
+        String value = null;
+        try {
+            ServiceConfigItem item = serviceConfigItemMapper.selectOne(
+                    new LambdaQueryWrapper<ServiceConfigItem>()
+                            .eq(ServiceConfigItem::getConfigKey, scoped)
+                            .eq(ServiceConfigItem::getProductKey, ProductConfigKeys.normalizeProductKey(productKey))
+                            .last("LIMIT 1"));
+            if (item != null) {
+                value = item.getConfigValue();
+            }
+        } catch (Exception e) {
+            log.warn("读取 product 侧配置失败 productKey={} key={},回退兜底值:{}", productKey, configKey, e.getMessage());
+        }
+        if (value != null) {
+            CfConfigCache.put(scoped, value);
+        }
+        return value;
+    }
+
+    /** 读取 product 侧配置,缺失时返回指定默认值 */
+    public String getProductConfig(String productKey, String configKey, String defaultValue) {
+        String v = getProductConfig(productKey, configKey);
+        return v != null ? v : defaultValue;
+    }
+
+    /** 读取 product 侧 int 配置 */
+    public int getProductIntConfig(String productKey, String configKey, int defaultValue) {
+        String v = getProductConfig(productKey, configKey);
+        if (v == null) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(v.trim());
+        } catch (NumberFormatException e) {
+            log.warn("product 配置 {}.{} 非整数:{},使用默认值 {}", productKey, configKey, v, defaultValue);
+            return defaultValue;
+        }
+    }
+
+    /** 读取 product 侧 double 配置 */
+    public double getProductDoubleConfig(String productKey, String configKey, double defaultValue) {
+        String v = getProductConfig(productKey, configKey);
+        if (v == null) {
+            return defaultValue;
+        }
+        try {
+            return Double.parseDouble(v.trim());
+        } catch (NumberFormatException e) {
+            log.warn("product 配置 {}.{} 非小数:{},使用默认值 {}", productKey, configKey, v, defaultValue);
+            return defaultValue;
+        }
+    }
+
+    /** 读取 product 侧 boolean 配置(true/1/on/yes 为真) */
+    public boolean getProductBooleanConfig(String productKey, String configKey, boolean defaultValue) {
+        String v = getProductConfig(productKey, configKey);
+        if (v == null) {
+            return defaultValue;
+        }
+        String s = v.trim().toLowerCase();
+        if ("true".equals(s) || "1".equals(s) || "on".equals(s) || "yes".equals(s)) {
+            return true;
+        }
+        if ("false".equals(s) || "0".equals(s) || "off".equals(s) || "no".equals(s)) {
+            return false;
+        }
+        return defaultValue;
+    }
+
+    /**
+     * 保存 product 侧配置(upsert):写库后立即失效本地缓存,并递增 cf.version 供前端感知。
+     *
+     * @param productKey 产品标识(为空时按 default 兜底)
+     * @param configKey  原配置键
+     * @param value      配置值(字符串原值)
+     * @param description 配置说明(更新时为空则不覆盖原说明)
+     * @param updatedBy  操作人 userId,可为空
+     */
+    public void setProductConfig(String productKey, String configKey, String value, String description, Long updatedBy) {
+        if (configKey == null || configKey.trim().isEmpty()) {
+            throw new IllegalArgumentException("config key 不能为空");
+        }
+        String pk = ProductConfigKeys.normalizeProductKey(productKey);
+        String scoped = ProductConfigKeys.scopedKey(productKey, configKey);
+        ServiceConfigItem item = serviceConfigItemMapper.selectOne(
+                new LambdaQueryWrapper<ServiceConfigItem>()
+                        .eq(ServiceConfigItem::getConfigKey, scoped)
+                        .last("LIMIT 1"));
+        Date now = new Date();
+        if (item == null) {
+            item = new ServiceConfigItem();
+            item.setConfigKey(scoped);
+            item.setGroupKey(ProductConfigKeys.PRODUCT_GROUP_KEY);
+            item.setProductKey(pk);
+            item.setConfigValue(value);
+            item.setValueType(guessValueType(value));
+            item.setDescription(description != null && !description.trim().isEmpty()
+                    ? description : "product 侧配置(W0.2 并入 service_config)");
+            item.setEditable(1);
+            item.setUpdatedBy(updatedBy);
+            item.setCreatedAt(now);
+            item.setUpdatedAt(now);
+            serviceConfigItemMapper.insert(item);
+        } else {
+            item.setProductKey(pk);
+            item.setConfigValue(value);
+            if (item.getValueType() == null) {
+                item.setValueType(guessValueType(value));
+            }
+            if (description != null && !description.trim().isEmpty()) {
+                item.setDescription(description);
+            }
+            item.setUpdatedBy(updatedBy);
+            item.setUpdatedAt(now);
+            serviceConfigItemMapper.updateById(item);
+        }
+        bumpVersion(updatedBy);
+        CfConfigCache.invalidate();
+    }
+
     /** 配置版本号 +1(前端据此局部刷新) */
     private void bumpVersion(Long updatedBy) {
         try {

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

@@ -3280,6 +3280,11 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createServiceConfigTable();
         migrateScatteredCfConfigs();
         seedCfServiceConfig();
+
+        // 迁移413: service_config 收编 product_config(W0.2 双配置中心合并)
+        // 幂等:product_key 列/索引已存在则跳过;product_config 表不存在时数据并入为空跑
+        addServiceConfigProductKeyColumn();
+        migrateProductConfigToServiceConfig();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -13680,6 +13685,135 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         return idx > 0 ? key.substring(0, idx) : "general";
     }
 
+    // ==================== 迁移413:W0.2 双配置中心合并(service_config 收编 product_config) ====================
+
+    /**
+     * 迁移413-1:service_config 增加 product_key 列(product 侧配置关联键;NULL = 全局/CF 通用参数)。
+     *
+     * 幂等:service_config 不存在 / 列已存在 / 索引已存在均跳过,可重复执行。
+     */
+    private void addServiceConfigProductKeyColumn() {
+        try {
+            if (!tableExists("service_config")) {
+                log.warn("迁移413: service_config 表不存在(迁移412 未执行?),跳过 product_key 列/索引");
+                return;
+            }
+            if (columnExists("service_config", "product_key")) {
+                log.info("迁移413: service_config.product_key 已存在,跳过加列");
+            } else {
+                ensureColumn("service_config", "product_key",
+                        "VARCHAR(64) DEFAULT NULL COMMENT '关联产品标识(product 侧配置;NULL=全局/CF 通用参数)'");
+                log.info("迁移413: 已为 service_config 添加 product_key 列");
+            }
+            if (!indexExists("service_config", "idx_product_key")) {
+                jdbcTemplate.execute("ALTER TABLE service_config ADD KEY idx_product_key (product_key)");
+                log.info("迁移413: 已创建 service_config.idx_product_key 索引");
+            }
+        } catch (Exception e) {
+            log.warn("迁移413: 添加 product_key 列/索引失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移413-2:把 product_config 表数据并入 service_config(product_key 填对应产品标识)。
+     *
+     * <p>product_key 标识规则(按 product_config 实际列优先级取第一个存在的列):</p>
+     * <ol>
+     *   <li>{@code product_key}(字符串产品标识)</li>
+     *   <li>{@code product_code}(产品编码)</li>
+     *   <li>{@code product_id}(产品主键,CAST 为字符串;唯一索引冲突风险由 idempotentKey 拼接规避)</li>
+     *   <li>group_key(计划文档 §3.6.6 中唯一保证存在的关联字段,作为兜底产品标识)</li>
+     *   <li>以上皆缺 → {@code default}</li>
+     * </ol>
+     *
+     * <p>config_key 写入规则:product 侧键统一加 {@code product.{productKey}.} 前缀
+     * (见 {@link com.etotem.cfc.constant.ProductConfigKeys}),保证 config_key 全局唯一
+     * (service_config 唯一键 uk_config_key 保留不动,W0.2 不改 W0.1 种子与索引)。</p>
+     *
+     * <p>幂等:本仓库基线(origin/cfclub / 测试库)不存在 product_config 表,迁移为空跑;
+     * 若目标环境存在该表,按「service_config 中不存在同 key 才插入」幂等,INSERT IGNORE 兜底唯一键冲突。</p>
+     */
+    private void migrateProductConfigToServiceConfig() {
+        if (!tableExists("product_config")) {
+            log.info("迁移413: product_config 表不存在(本基线无该表),跳过数据并入(幂等空跑)");
+            return;
+        }
+        try {
+            String groupCol = firstExistingColumn("product_config", "group_key", "groupKey");
+            String keyCol = firstExistingColumn("product_config", "config_key", "configKey");
+            String valueCol = firstExistingColumn("product_config", "config_value", "configValue");
+            String typeCol = firstExistingColumn("product_config", "value_type", "valueType");
+            String descCol = firstExistingColumn("product_config", "description");
+            String editableCol = firstExistingColumn("product_config", "editable");
+            String upByCol = firstExistingColumn("product_config", "updated_by", "updatedBy");
+            String createdAtCol = firstExistingColumn("product_config", "created_at", "createdAt");
+            String updatedAtCol = firstExistingColumn("product_config", "updated_at", "updatedAt");
+            if (keyCol == null) {
+                log.warn("迁移413: product_config 缺少配置键列(config_key/configKey),无法并入,跳过");
+                return;
+            }
+
+            // 产品标识表达式(优先级:product_key → product_code → product_id → group_key → 'default')
+            // 统一加 p. 前缀限定,避免与 service_config 同名列歧义
+            String productKeyExpr = null;
+            for (String c : new String[]{"product_key", "product_code"}) {
+                if (columnExists("product_config", c)) {
+                    productKeyExpr = "NULLIF(TRIM(p.`" + c + "`), '')";
+                    break;
+                }
+            }
+            if (productKeyExpr == null && columnExists("product_config", "product_id")) {
+                productKeyExpr = "CAST(p.`product_id` AS CHAR)";
+            }
+            if (productKeyExpr == null && groupCol != null) {
+                productKeyExpr = "NULLIF(TRIM(p.`" + groupCol + "`), '')";
+            }
+            if (productKeyExpr == null) {
+                productKeyExpr = "'" + com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY + "'";
+            }
+
+            StringBuilder sql = new StringBuilder("INSERT IGNORE INTO service_config ")
+                    .append("(group_key, config_key, config_value, value_type, description, editable, product_key, updated_by, created_at, updated_at) ")
+                    .append("SELECT ");
+            sql.append("'").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_GROUP_KEY).append("', ");
+            // config_key = product.{productKey}.{原key}(保证全局唯一;截断到 128 防超长)
+            sql.append("LEFT(CONCAT('").append(com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX)
+                    .append("', COALESCE(").append(productKeyExpr).append(", '")
+                    .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`")
+                    .append(keyCol).append("`), 128), ");
+            sql.append(valueCol != null ? "p.`" + valueCol + "`, " : "NULL, ");
+            sql.append(typeCol != null ? "COALESCE(NULLIF(TRIM(p.`" + typeCol + "`), ''), 'string'), " : "'string', ");
+            sql.append(descCol != null ? "LEFT(p.`" + descCol + "`, 255), " : "NULL, ");
+            sql.append(editableCol != null ? "COALESCE(p.`" + editableCol + "`, 1), " : "1, ");
+            sql.append("LEFT(COALESCE(").append(productKeyExpr).append(", '")
+                    .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), 64), ");
+            sql.append(upByCol != null ? "p.`" + upByCol + "`, " : "NULL, ");
+            sql.append(createdAtCol != null ? "COALESCE(p.`" + createdAtCol + "`, NOW()), " : "NOW(), ");
+            sql.append(updatedAtCol != null ? "COALESCE(p.`" + updatedAtCol + "`, NOW()) " : "NOW() ");
+            sql.append("FROM product_config p WHERE NOT EXISTS "
+                    + "(SELECT 1 FROM service_config s WHERE s.config_key = CONCAT('"
+                    + com.etotem.cfc.constant.ProductConfigKeys.PRODUCT_KEY_PREFIX
+                    + "', COALESCE(").append(productKeyExpr).append(", '")
+                    .append(com.etotem.cfc.constant.ProductConfigKeys.DEFAULT_PRODUCT_KEY).append("'), '.', p.`")
+                    .append(keyCol).append("`))");
+
+            int rows = jdbcTemplate.update(sql.toString());
+            log.info("迁移413: product_config → service_config 并入完成,新增 {} 行", rows);
+        } catch (Exception e) {
+            log.warn("迁移413: product_config 数据并入失败(跳过,可重试):{}", e.getMessage());
+        }
+    }
+
+    /** 返回 product_config 中第一个实际存在的列名(蛇形优先,兼容驼峰),全部缺失返回 null */
+    private String firstExistingColumn(String table, String... candidates) {
+        for (String c : candidates) {
+            if (columnExists(table, c)) {
+                return c;
+            }
+        }
+        return null;
+    }
+
     private String cfValueTypeOf(String value) {
         if (value == null) {
             return "string";

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/ProductConfigKeys.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.constant;
+
+/**
+ * product 侧配置键规则(W0.2 双配置中心合并:service_config 收编 product_config)。
+ *
+ * <p>背景见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.6 / §3.7 W0.2:
+ * product 侧配置并入唯一的 service_config 表,product 侧只保留 {@code product_key} 关联键。</p>
+ *
+ * <p>键命名规则:</p>
+ * <ul>
+ *   <li>全局(CF 通用运营参数,W0.1 种子):{@code cf.{module}.{key}},{@code product_key = NULL}</li>
+ *   <li>product 侧配置:{@code product.{productKey}.{原配置键}},{@code product_key = {productKey}}</li>
+ * </ul>
+ *
+ * <p>之所以加 {@code product.} 前缀:service_config 上 W0.1 已落地 {@code UNIQUE KEY uk_config_key(config_key)}
+ * (config_key 全局唯一),W0.2 不改动该唯一索引与 W0.1 种子数据,仅新增 product_key 列做关联维度,
+ * 用前缀保证 product 侧 config_key 仍然全局唯一,避免同键多产品时触发唯一键冲突。</p>
+ */
+public final class ProductConfigKeys {
+
+    /** product 侧配置键前缀 */
+    public static final String PRODUCT_KEY_PREFIX = "product.";
+
+    /** product 侧配置分组(写入 service_config.group_key) */
+    public static final String PRODUCT_GROUP_KEY = "product";
+
+    /** 未能识别出产品标识时的兜底产品标识 */
+    public static final String DEFAULT_PRODUCT_KEY = "default";
+
+    /** product_key 列最大长度(与 service_config.product_key VARCHAR(64) 一致) */
+    public static final int MAX_PRODUCT_KEY_LENGTH = 64;
+
+    private ProductConfigKeys() {
+    }
+
+    /**
+     * 生成 product 侧配置键:{@code product.{productKey}.{configKey}}。
+     *
+     * @param productKey 产品标识
+     * @param configKey  原配置键
+     * @return 写入 service_config.config_key 的完整键
+     */
+    public static String scopedKey(String productKey, String configKey) {
+        String pk = normalizeProductKey(productKey);
+        String ck = configKey == null ? "" : configKey.trim();
+        return PRODUCT_KEY_PREFIX + pk + "." + ck;
+    }
+
+    /**
+     * 规范化产品标识:去空白 + 截断到 64 字符 + 空值兜底为 {@link #DEFAULT_PRODUCT_KEY}。
+     */
+    public static String normalizeProductKey(String productKey) {
+        if (productKey == null || productKey.trim().isEmpty()) {
+            return DEFAULT_PRODUCT_KEY;
+        }
+        String pk = productKey.trim();
+        if (pk.length() > MAX_PRODUCT_KEY_LENGTH) {
+            pk = pk.substring(0, MAX_PRODUCT_KEY_LENGTH);
+        }
+        return pk;
+    }
+
+    /** 是否是 product 侧配置键(product.{productKey}.{key}) */
+    public static boolean isProductScoped(String configKey) {
+        return configKey != null && configKey.startsWith(PRODUCT_KEY_PREFIX);
+    }
+}

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductConfigController.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 【已废弃 410 Gone】product_config 旧配置中心管理端接口。
+ *
+ * <p>W0.2 双配置中心合并:product_config 表的数据已并入 service_config(唯一配置中心),
+ * product 侧配置以 service_config.product_key 做关联键;原 product_config 写接口一律返回 410 Gone,
+ * 不再提供写能力。</p>
+ *
+ * <p>下线时间线:</p>
+ * <ul>
+ *   <li>2026-10-24 起:写接口 410 Gone;product_config 表只读保留 30 天(回滚用)</li>
+ *   <li>2026-11-23 起:确认无回滚需求后由运维删除 product_config 表,本控制器同步删除</li>
+ * </ul>
+ *
+ * <p>替代入口:{@link AdminServiceConfigController}(/api/admin/service-config/list、/save,
+ * 保存时传 productKey 即写入 product 侧配置)。</p>
+ *
+ * @deprecated product_config 已并入 service_config,统一走 /api/admin/service-config/*
+ */
+@Deprecated
+@Tag(name = "管理端-产品配置中心(已废弃410)")
+@RestController
+@RequestMapping("/api/admin/product-config")
+public class AdminProductConfigController {
+
+    private static final String GONE_MESSAGE =
+            "product_config 已并入统一配置中心 service_config(product_key 关联产品),本接口已下线(410 Gone);"
+                    + "请改用 /api/admin/service-config/list、/api/admin/service-config/save(保存时传 productKey)";
+
+    @Operation(summary = "【已废弃410】产品配置列表(请改用 /api/admin/service-config/list)")
+    @PostMapping("/list")
+    @ResponseStatus(HttpStatus.GONE)
+    public Result<List<Map<String, Object>>> list() {
+        return Result.error(HttpStatus.GONE.value(), GONE_MESSAGE);
+    }
+
+    @Operation(summary = "【已废弃410】产品配置保存(请改用 /api/admin/service-config/save)")
+    @PostMapping("/save")
+    @ResponseStatus(HttpStatus.GONE)
+    public Result<String> save(@RequestBody(required = false) Map<String, Object> params) {
+        return Result.error(HttpStatus.GONE.value(), GONE_MESSAGE);
+    }
+}

+ 17 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminServiceConfigController.java

@@ -18,8 +18,12 @@ import java.util.Map;
 /**
  * L2 配置中心(service_config)管理端接口。
  *
- * CF 通用运营参数(key 前缀 cf.)的唯一维护入口,保存后立即失效本地缓存。
- * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.6 / W0.1
+ * CF 通用运营参数(key 前缀 cf.)+ product 侧配置(product_key 非空)的唯一维护入口,
+ * 保存后立即失效本地缓存。
+ *
+ * W0.2 双配置中心合并:product_config 已并入本表(新增 product_key 列做产品维度),
+ * 原 product_config 写路径见 {@link AdminProductConfigController}(410 Gone,保留 30 天只读窗口)。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.6 / W0.1 / W0.2
  */
 @Tag(name = "管理端-服务配置中心(CF 参数)")
 @RestController
@@ -29,14 +33,15 @@ public class AdminServiceConfigController {
     @Resource
     private ConfigService configService;
 
-    @Operation(summary = "配置列表(可按 groupKey 过滤,默认返回全部)")
+    @Operation(summary = "配置列表(可按 groupKey / productKey 过滤,均缺省返回全部)")
     @PostMapping("/list")
     public Result<List<ServiceConfigItem>> list(@RequestBody(required = false) Map<String, Object> params) {
         String groupKey = params == null ? null : (String) params.get("groupKey");
-        return Result.success(configService.listConfigs(groupKey));
+        String productKey = params == null ? null : (String) params.get("productKey");
+        return Result.success(configService.listConfigs(groupKey, productKey));
     }
 
-    @Operation(summary = "保存配置(upsert,保存后主动失效 CF 配置缓存)")
+    @Operation(summary = "保存配置(upsert;productKey 为空=全局 CF 参数,非空=product 侧配置;保存后主动失效缓存)")
     @PostMapping("/save")
     public Result<String> save(@RequestBody Map<String, Object> params) {
         String key = params == null ? null : (String) params.get("configKey");
@@ -49,6 +54,13 @@ public class AdminServiceConfigController {
         }
         Long updatedBy = params.get("updatedBy") == null ? null
                 : Long.valueOf(String.valueOf(params.get("updatedBy")));
+        String productKey = params == null ? null : (String) params.get("productKey");
+        if (productKey != null && !productKey.trim().isEmpty()) {
+            // W0.2:product 侧配置写入 service_config(product_key = ?)
+            configService.setProductConfig(productKey, key, String.valueOf(rawValue),
+                    (String) params.get("description"), updatedBy);
+            return Result.success("保存成功");
+        }
         configService.setConfig(key, String.valueOf(rawValue), updatedBy);
         return Result.success("保存成功");
     }

+ 9 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceConfigItem.java

@@ -20,9 +20,17 @@ public class ServiceConfigItem implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
-    /** 配置分组(如 cf.rate / cf.commission) */
+    /** 配置分组(如 cf.rate / cf.commission / product) */
     private String groupKey;
 
+    /**
+     * 关联产品标识(product 侧配置;NULL = 全局/CF 通用运营参数)。
+     *
+     * W0.2 双配置中心合并(service_config 收编 product_config)新增字段,
+     * 见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.6 / §3.7 W0.2。
+     */
+    private String productKey;
+
     /** 配置键(如 cf.rate.points_multiplier),全局唯一 */
     private String configKey;
 

+ 6 - 4
cfc-backend/src/main/resources/schema.sql

@@ -6783,8 +6783,9 @@ CREATE TABLE IF NOT EXISTS cf_earn_daily_limit (
 -- 表 服务配置中心(L2,CF通用运营参数归口)
 CREATE TABLE IF NOT EXISTS service_config (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    group_key VARCHAR(64) NOT NULL DEFAULT 'general' COMMENT '配置分组(如 cf.rate / cf.commission)',
-    config_key VARCHAR(128) NOT NULL COMMENT '配置键(如 cf.rate.points_multiplier)',
+    group_key VARCHAR(64) NOT NULL DEFAULT 'general' COMMENT '配置分组(如 cf.rate / cf.commission / product)',
+    product_key VARCHAR(64) DEFAULT NULL COMMENT '关联产品标识(product 侧配置;NULL=全局/CF 通用运营参数,W0.2)',
+    config_key VARCHAR(128) NOT NULL COMMENT '配置键(如 cf.rate.points_multiplier / product.{productKey}.{key})',
     config_value TEXT COMMENT '配置值(统一字符串存储,按 value_type 解析)',
     value_type VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT 'string/int/double/bool/json',
     description VARCHAR(255),
@@ -6793,5 +6794,6 @@ CREATE TABLE IF NOT EXISTS service_config (
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_config_key (config_key),
-    KEY idx_group_key (group_key)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口)';
+    KEY idx_group_key (group_key),
+    KEY idx_product_key (product_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口 + product 侧配置并入,W0.2)';

+ 18 - 0
cfc-web/src/api/serviceConfig.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+// 配置中心(service_config,唯一配置中心)列表
+// 可按 groupKey / productKey 过滤;productKey 即 product 侧配置的产品维度
+export function getServiceConfigList(data) {
+  return request({ url: '/api/admin/service-config/list', method: 'post', data })
+}
+
+// 保存配置(upsert)
+// productKey 为空 = 全局 CF 通用运营参数;productKey 非空 = product 侧配置
+export function saveServiceConfig(data) {
+  return request({ url: '/api/admin/service-config/save', method: 'post', data })
+}
+
+// 手动失效配置本地缓存(排障用)
+export function invalidateServiceConfig() {
+  return request({ url: '/api/admin/service-config/invalidate', method: 'post' })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -774,6 +774,12 @@ const routes = [
         component: () => import('@/views/admin/dimension'),
         meta: { title: '维度配置', perm: 'config:dimension' }
       },
+      {
+        path: 'config-center',
+        name: 'ConfigCenter',
+        component: () => import('@/views/admin/ConfigCenter.vue'),
+        meta: { title: '配置中心', perm: 'config:dimension' }
+      },
       {
         path: 'knowledge-base',
         name: 'KnowledgeBase',

+ 2 - 1
cfc-web/src/views/Layout.vue

@@ -315,9 +315,10 @@ export default {
                 { path: '/system-prompt', label: '系统提示词', icon: 'el-icon-chat-line-square', perm: 'system:base' },
                 { path: '/file-manage', label: '文件管理', icon: 'el-icon-files', perm: 'system:base' },
               ]},
-            // --- 配置中心 ---
+            // --- 配置中心(统一配置中心 service_config;W0.2 双配置中心合并后单一入口) ---
             { title: '配置中心', icon: 'el-icon-setting', perm: 'config:dimension',
               children: [
+                { path: '/config-center', label: '配置中心', icon: 'el-icon-setting', perm: 'config:dimension' },
                 { path: '/dimension-config', label: '维度配置', icon: 'el-icon-data-line', perm: 'config:dimension' },
               ]},
             // --- 供应商体系 ---

+ 248 - 0
cfc-web/src/views/admin/ConfigCenter.vue

@@ -0,0 +1,248 @@
+<template>
+  <div class="config-center admin-page">
+    <el-card>
+      <PageHeader slot="header" title="配置中心"
+        subtitle="统一配置中心(service_config):productKey 为空 = 全局 CF 通用运营参数;productKey 非空 = product 侧配置(product.{productKey}.{key}),W0.2 已收编 product_config">
+        <el-input v-model="filterGroupKey" placeholder="分组(如 cf.rate / product)" size="small" clearable class="fg-filter-input" @keyup.enter.native="loadData" />
+        <el-input v-model="filterProductKey" placeholder="产品标识(留空=全部)" size="small" clearable class="fg-filter-input" @keyup.enter.native="loadData" />
+        <el-input v-model="filterConfigKey" placeholder="配置键关键字" size="small" clearable class="fg-filter-input" @keyup.enter.native="loadData" />
+        <el-button type="primary" size="small" @click="loadData">查询</el-button>
+        <el-button size="small" @click="resetFilters">重置</el-button>
+        <el-button size="small" @click="handleInvalidate">失效缓存</el-button>
+        <el-button type="success" size="small" @click="handleCreate">+ 新增配置</el-button>
+      </PageHeader>
+      <div class="adm-table-wrap">
+        <el-table height="100%" :data="filteredList" v-loading="loading" border stripe>
+          <el-table-column prop="id" label="ID" width="80" />
+          <el-table-column label="产品标识" width="130">
+            <template slot-scope="{ row }">
+              <el-tag v-if="row.productKey" size="mini" type="warning">{{ row.productKey }}</el-tag>
+              <span v-else class="fg-global-tag">全局</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="groupKey" label="分组" width="150" />
+          <el-table-column prop="configKey" label="配置键" min-width="220" show-overflow-tooltip />
+          <el-table-column prop="configValue" label="配置值" min-width="160" show-overflow-tooltip />
+          <el-table-column prop="valueType" label="类型" width="80" />
+          <el-table-column label="可编辑" width="80">
+            <template slot-scope="{ row }">
+              <el-tag :type="row.editable === 1 ? 'success' : 'info'" size="mini">{{ row.editable === 1 ? '是' : '否' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="description" label="说明" min-width="180" show-overflow-tooltip />
+          <el-table-column label="更新时间" width="170">
+            <template slot-scope="{ row }">{{ formatTime(row.updatedAt) }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="110" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑配置' : '新增配置'" width="560px" :close-on-click-modal="false">
+      <el-form :model="form" label-width="110px">
+        <el-form-item label="配置键">
+          <el-input v-model="form.configKey" :disabled="isEdit" placeholder="如 cf.rate.points_multiplier" />
+        </el-form-item>
+        <el-form-item label="产品标识">
+          <el-input v-model="form.productKey" placeholder="留空=全局参数;填写=product 侧配置" class="fg-filter-input-260" />
+          <div class="fg-center-hint">product 侧配置保存后键自动变为 product.{产品标识}.{配置键},并写入 product_key 关联键</div>
+        </el-form-item>
+        <el-form-item label="配置值">
+          <el-input v-model="form.configValue" type="textarea" :rows="2" placeholder="配置值(字符串存储,按类型解析)" />
+        </el-form-item>
+        <el-form-item label="值类型">
+          <el-select v-model="form.valueType" class="fg-filter-input-180">
+            <el-option label="字符串 string" value="string" />
+            <el-option label="整数 int" value="int" />
+            <el-option label="小数 double" value="double" />
+            <el-option label="布尔 bool" value="bool" />
+            <el-option label="JSON json" value="json" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="说明">
+          <el-input v-model="form.description" type="textarea" :rows="2" placeholder="配置说明" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getServiceConfigList, saveServiceConfig, invalidateServiceConfig } from '@/api/serviceConfig.js'
+
+export default {
+  name: 'ConfigCenter',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      list: [],
+      filterGroupKey: '',
+      filterProductKey: '',
+      filterConfigKey: '',
+      dialogVisible: false,
+      isEdit: false,
+      form: {
+        id: null,
+        configKey: '',
+        productKey: '',
+        configValue: '',
+        valueType: 'string',
+        description: ''
+      }
+    }
+  },
+  computed: {
+    filteredList() {
+      var self = this
+      var kw = (this.filterConfigKey || '').trim()
+      if (!kw) return this.list
+      var lower = kw.toLowerCase()
+      return this.list.filter(function(item) {
+        return (item.configKey && item.configKey.toLowerCase().indexOf(lower) >= 0) ||
+          (item.configValue && item.configValue.toLowerCase().indexOf(lower) >= 0)
+      })
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    formatTime(t) {
+      if (!t) return '-'
+      if (typeof t === 'number') {
+        var d = new Date(t)
+        var pad = function(n) { return n < 10 ? '0' + n : '' + n }
+        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
+          ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+      }
+      return String(t).replace('T', ' ').substring(0, 19)
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        var params = {}
+        if (this.filterGroupKey) params.groupKey = this.filterGroupKey
+        if (this.filterProductKey) params.productKey = this.filterProductKey
+        var res = await getServiceConfigList(params)
+        if (res.data) {
+          this.list = res.data
+        }
+      } catch (e) {
+        this.$message.error('加载配置列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    resetFilters() {
+      this.filterGroupKey = ''
+      this.filterProductKey = ''
+      this.filterConfigKey = ''
+      this.loadData()
+    },
+    handleInvalidate() {
+      var self = this
+      this.$confirm('确定手动失效 CF 配置本地缓存吗?(排障用)', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async function() {
+        try {
+          await invalidateServiceConfig()
+          self.$message.success('缓存已失效')
+        } catch (e) {
+          self.$message.error(e.message || '失效失败')
+        }
+      }).catch(function() {})
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = { id: null, configKey: '', productKey: '', configValue: '', valueType: 'string', description: '' }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      var configKey = row.configKey
+      if (row.productKey && configKey && configKey.indexOf('product.') === 0) {
+        var prefix = 'product.' + row.productKey + '.'
+        if (configKey.indexOf(prefix) === 0) {
+          configKey = configKey.substring(prefix.length)
+        }
+      }
+      this.form = {
+        id: row.id,
+        configKey: configKey,
+        productKey: row.productKey || '',
+        configValue: row.configValue || '',
+        valueType: row.valueType || 'string',
+        description: row.description || ''
+      }
+      this.dialogVisible = true
+    },
+    async handleSubmit() {
+      if (!this.form.configKey || !this.form.configKey.trim()) {
+        this.$message.warning('请输入配置键')
+        return
+      }
+      var payload = {
+        configKey: this.form.configKey.trim(),
+        configValue: this.form.configValue,
+        valueType: this.form.valueType,
+        description: this.form.description
+      }
+      if (this.form.productKey && this.form.productKey.trim()) {
+        payload.productKey = this.form.productKey.trim()
+      }
+      this.submitting = true
+      try {
+        await saveServiceConfig(payload)
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.loadData()
+      } catch (e) {
+        this.$message.error(e.message || '保存失败')
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.config-center {
+  padding: 20px;
+}
+
+.fg-filter-input {
+  width: 180px;
+}
+
+.fg-filter-input-260 {
+  width: 260px;
+}
+
+.fg-filter-input-180 {
+  width: 180px;
+}
+
+.fg-center-hint {
+  width: 100%;
+  color: var(--adm-text-45, #909399);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.fg-global-tag {
+  color: var(--adm-text-45, #909399);
+  font-size: 12px;
+}
+</style>

+ 16 - 5
docs/superpowers/api/API_REFERENCE.md

@@ -2059,12 +2059,12 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 
 ### 6.1 服务配置中心(`/api/admin/service-config`)
 
-CF 通用运营参数唯一维护入口(`service_config` 表,key 前缀 `cf.`)。保存后自动 `CfConfigCache.invalidate()`(本地缓存 TTL 60s)+ `cf.version` 自增。
+唯一配置中心(`service_config` 表):CF 通用运营参数(key 前缀 `cf.`,`product_key` 为空)+ product 侧配置(`product_key` 非空,key 形如 `product.{productKey}.{key}`,W0.2 由 `product_config` 并入)。保存后自动 `CfConfigCache.invalidate()`(本地缓存 TTL 60s)+ `cf.version` 自增。
 
 | 路径 | 说明 |
 |------|------|
-| `POST /api/admin/service-config/list` | 配置列表,body 可选 `{"groupKey":"cf.rate"}`,不传返回全部 |
-| `POST /api/admin/service-config/save` | 保存配置(upsert),body `{"configKey":"cf.rate.points_multiplier","configValue":"1.0","updatedBy":1}` |
+| `POST /api/admin/service-config/list` | 配置列表,body 可选 `{"groupKey":"cf.rate","productKey":"gut_health"}`,不传返回全部(`productKey` 为产品维度筛选,W0.2 新增) |
+| `POST /api/admin/service-config/save` | 保存配置(upsert),body `{"configKey":"cf.rate.points_multiplier","configValue":"1.0","updatedBy":1}`;传 `productKey` 则写入 product 侧配置(自动加 `product.{productKey}.` 前缀并回填 `product_key`,W0.2 新增),可另传 `description` |
 | `POST /api/admin/service-config/invalidate` | 手动失效 CF 配置缓存(排障用) |
 
 已登记配置项:`cf.rate.points_multiplier`、`cf.rate.repeat_rate_default`、`cf.commission.settle_days`、`cf.invite.valid_days`、`cf.price.default_factor`、`cf.stock.deduction_mode`、`cf.rate.share_read_reward_points`、`cf.commission.service_rate`、`cf.product.gut_health_price`、`cf.product.platform_points_share`、`cf.version`。
@@ -2080,6 +2080,17 @@ CF 通用运营参数唯一维护入口(`service_config` 表,key 前缀 `cf.
 | `POST /api/admin/cf-earn-rule/toggle` | 启停,body `{"code":"F07","enabled":0,"changedBy":1,"changeReason":"..."}` |
 | `POST /api/admin/cf-earn-rule/history` | 变更历史,body 可选 `{"code":"F07","limit":100}` |
 
-> 读取封装:`ConfigService.getConfig(key)`(缓存 60s → DB → 兜底常量);规则读取:`CfEarnRuleService.getByCode(code)`。
+> 读取封装:`ConfigService.getConfig(key)`(缓存 60s → DB → 兜底常量);product 侧配置读取:`ConfigService.getProductConfig(productKey, key)`(W0.2);规则读取:`CfEarnRuleService.getByCode(code)`。
 
-*文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口)*
+### 6.3 产品配置中心(`/api/admin/product-config`,**已废弃 410 Gone**)
+
+W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯一配置中心),product 侧配置通过 `product_key` 关联产品;本控制器写接口统一返回 410 Gone(HTTP 410 + body `code=410`),保留 30 天只读窗口后由运维删表。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/product-config/list` | 【410 Gone】已废弃,改用 `POST /api/admin/service-config/list`(body 传 `productKey` 筛选) |
+| `POST /api/admin/product-config/save` | 【410 Gone】已废弃,改用 `POST /api/admin/service-config/save`(body 传 `productKey`) |
+
+时间线: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 + 配置中心统一入口)*

+ 51 - 0
out/w02_product_config_backup.sql

@@ -0,0 +1,51 @@
+-- =====================================================================
+-- W0.2 双配置中心合并:product_config 全表备份脚本(含审计日志)
+-- 计划文档: docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.6 步骤1 / §3.7 W0.2
+-- 职责: 运维在迁移前对 product_config 全表数据做备份(含与 product_config 相关的审计日志)
+-- 实际状态: 本仓库基线(origin/cfclub @ f8b145d3a)与测试库(192.168.16.251:3306/zxyj)
+--          均不存在 product_config 表/实体(经 information_schema 与全仓 grep 验证),
+--          故本脚本在目标环境存在 product_config 时执行,测试库执行为 0 行空跑(幂等)。
+-- 执行前提: 在存在 product_config 表的数据库(如生产)中执行。
+-- =====================================================================
+
+-- 0) 行数基线(记录迁移前数据量,用于验收对比)
+SELECT 'product_config' AS tbl, COUNT(*) AS rows_before FROM product_config
+UNION ALL
+SELECT 'product_config_audit', COUNT(*) FROM product_config_audit;
+
+-- 1) 全表结构备份(DDL 快照),执行后另存为 product_config.ddl.sql
+SHOW CREATE TABLE product_config;
+
+-- 2) 全表数据备份(完整 INSERT 语句,可离线回放),执行后另存为 product_config.data.sql
+--    输出到本地文件: mysql -u<user> -p <db> < product_config_backup.sql > product_config.data.sql
+SELECT CONCAT(
+  'INSERT INTO product_config (',
+  GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION),
+  ') VALUES (',
+  GROUP_CONCAT(CONCAT('''', REPLACE(IFNULL(`', COLUMN_NAME, '`,'''')), '''')),
+  ');'
+) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_config'
+GROUP BY TABLE_NAME;
+
+-- 3) 结构化导出(CSV,便于对比/导入)
+--    SELECT * FROM product_config INTO OUTFILE '/tmp/product_config_backup.csv'
+--    FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n';
+
+-- 4) 审计日志备份(若存在 product_config_audit / product_config_log 等审计表)
+SELECT 'product_config 相关审计表:'
+UNION ALL
+SELECT TABLE_NAME FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND (TABLE_NAME LIKE 'product_config%audit%' OR TABLE_NAME LIKE 'product_config%log%');
+
+-- 5) 迁移后对比(由 运维/开发 执行)
+--    SELECT COUNT(*) FROM product_config;                          -- 迁移前(应等于 rows_before)
+--    SELECT COUNT(*) FROM service_config WHERE product_key IS NOT NULL;  -- 迁移后并入的行数
+--    两者应相等(product_key 为空兜底为 'default' 的按 1:1 计入)
+
+-- =====================================================================
+-- 迁移 413 说明(DatabaseInitializer 已实现,幂等):
+--   ALTER TABLE service_config ADD COLUMN product_key VARCHAR(64);
+--   product_config → service_config 并入,config_key 加前缀 product.{productKey}.
+--   product_key 标识规则: product_key → product_code → product_id → group_key → 'default'
+-- =====================================================================