|
|
@@ -3257,6 +3257,29 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
|
|
|
|
|
|
// 迁移406: activity_poster_templates 增加 layout_config 列(海报模板元素位置/样式可配置,需求:活动详情海报预览重设计)
|
|
|
addActivityPosterTemplateLayoutConfigColumn();
|
|
|
+
|
|
|
+ // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度;F-1)
|
|
|
+ createCfBalanceTables();
|
|
|
+
|
|
|
+ // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history + 24 行规则种子(F02–F24;F-1)
|
|
|
+ createCfEarnRuleTables();
|
|
|
+ seedCfEarnRules();
|
|
|
+
|
|
|
+ // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位;F-1)
|
|
|
+ createCfSalesTierTables();
|
|
|
+
|
|
|
+ // 迁移410: 创建 cf_earn_daily_limit + 会员等级日限种子(N11;F-1)
|
|
|
+ createCfEarnDailyLimitTable();
|
|
|
+ seedCfEarnDailyLimits();
|
|
|
+
|
|
|
+ // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3;F-1)
|
|
|
+ addCouponFamilyIdColumns();
|
|
|
+
|
|
|
+ // 迁移412: 创建 service_config + 散落参数迁入 + CF 通用运营参数种子(W0.1)
|
|
|
+ // 顺序:先迁旧位置实际值,再补种子默认值(避免默认值覆盖 sys_config 里的真实配置)
|
|
|
+ createServiceConfigTable();
|
|
|
+ migrateScatteredCfConfigs();
|
|
|
+ seedCfServiceConfig();
|
|
|
}
|
|
|
|
|
|
private void migrateHealthDimensionsTable() {
|
|
|
@@ -13106,4 +13129,600 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
|
|
|
log.warn("迁移406: 添加 activity_poster_templates.layout_config 列失败: {}", e.getMessage());
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // ==================================================================================
|
|
|
+ // F-1 数据层(CF 值体系统一):cf_balance / cf_balance_log / cf_earn_rule /
|
|
|
+ // cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit
|
|
|
+ // 口径:docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §五 + §3.6
|
|
|
+ // 仅建表 + 种子,不含任何发放/扣减业务逻辑(F-2 批)
|
|
|
+ // ==================================================================================
|
|
|
+
|
|
|
+ // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度)
|
|
|
+ private void createCfBalanceTables() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', "
|
|
|
+ + "`user_id` BIGINT NULL COMMENT '关联登录用户ID(可为空,不能登录成员无账号)', "
|
|
|
+ + "`family_id` BIGINT NULL COMMENT '所属家庭ID(冗余,便于聚合)', "
|
|
|
+ + "`total_earned` INT NOT NULL DEFAULT 0 COMMENT '累计获得', "
|
|
|
+ + "`available` INT NOT NULL DEFAULT 0 COMMENT '可用', "
|
|
|
+ + "`frozen` INT NOT NULL DEFAULT 0 COMMENT '提现冻结', "
|
|
|
+ + "`withdrawn` INT NOT NULL DEFAULT 0 COMMENT '已提现', "
|
|
|
+ + "`exchanged` INT NOT NULL DEFAULT 0 COMMENT '已兑换消费', "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY `uk_member` (`member_id`), "
|
|
|
+ + "KEY `idx_family` (`family_id`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值余额(家庭成员维度)'");
|
|
|
+ log.info("迁移407: 已创建 cf_balance 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移407: 创建 cf_balance 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_balance_log` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`member_id` BIGINT NOT NULL, "
|
|
|
+ + "`user_id` BIGINT NULL, "
|
|
|
+ + "`family_id` BIGINT NULL, "
|
|
|
+ + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust', "
|
|
|
+ + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', "
|
|
|
+ + "`ref_id` BIGINT NULL, "
|
|
|
+ + "`amount` INT NOT NULL COMMENT '变动数量(正负)', "
|
|
|
+ + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', "
|
|
|
+ + "`remark` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "KEY `idx_member` (`member_id`), "
|
|
|
+ + "KEY `idx_family` (`family_id`), "
|
|
|
+ + "UNIQUE KEY `uk_ref` (`member_id`, `ref_type`, `ref_id`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水'");
|
|
|
+ log.info("迁移407: 已创建 cf_balance_log 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移407: 创建 cf_balance_log 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history(CF 值获取规则 + 变更审计)
|
|
|
+ private void createCfEarnRuleTables() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`code` VARCHAR(20) NOT NULL COMMENT '规则编码 F02~F24(F03 拆 F03a/F03b)', "
|
|
|
+ + "`name` VARCHAR(100) NOT NULL COMMENT '途径名称', "
|
|
|
+ + "`category` VARCHAR(20) NOT NULL COMMENT 'consume消费返佣/behavior成长行为/admin管理员', "
|
|
|
+ + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用(随时开关)', "
|
|
|
+ + "`rule_type` VARCHAR(20) NOT NULL COMMENT 'fixed固定/formula公式/per_item逐人发放', "
|
|
|
+ + "`amount` INT NULL COMMENT '固定数量(rule_type=fixed)', "
|
|
|
+ + "`formula` VARCHAR(500) NULL COMMENT '计算公式(rule_type=formula)', "
|
|
|
+ + "`params_json` TEXT NULL COMMENT '公式参数(bps/倍数/档位表等,JSON)', "
|
|
|
+ + "`daily_limit_per_member` INT NULL COMMENT '单成员每日上限,空=不限', "
|
|
|
+ + "`total_limit_per_member` INT NULL COMMENT '单成员累计上限,空=不限', "
|
|
|
+ + "`cooldown_seconds` INT NULL COMMENT '同一成员冷却秒数,空=不限', "
|
|
|
+ + "`dedup_ref_type` VARCHAR(50) NOT NULL COMMENT '幂等refType,写入cf_balance_log', "
|
|
|
+ + "`effective_from` DATETIME NULL COMMENT '生效起,空=立即', "
|
|
|
+ + "`effective_to` DATETIME NULL COMMENT '生效止,空=长期', "
|
|
|
+ + "`sort_order` INT NOT NULL DEFAULT 0, "
|
|
|
+ + "`remark` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_by` BIGINT NULL COMMENT '最后修改人', "
|
|
|
+ + "UNIQUE KEY `uk_code` (`code`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值获取规则(运营可配置)'");
|
|
|
+ log.info("迁移408: 已创建 cf_earn_rule 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移408: 创建 cf_earn_rule 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_rule_history` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`rule_code` VARCHAR(20) NOT NULL, "
|
|
|
+ + "`old_json` TEXT NULL COMMENT '变更前整行快照', "
|
|
|
+ + "`new_json` TEXT NULL COMMENT '变更后整行快照', "
|
|
|
+ + "`changed_by` BIGINT NOT NULL, "
|
|
|
+ + "`change_reason` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "KEY `idx_rule` (`rule_code`, `created_at`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则变更历史'");
|
|
|
+ log.info("迁移408: 已创建 cf_earn_rule_history 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移408: 创建 cf_earn_rule_history 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 24 行规则种子(F02–F24,F03 拆 F03a/F03b),列序:
|
|
|
+ * code, name, category, ruleType, amount, formula, paramsJson, dailyLimit, totalLimit,
|
|
|
+ * cooldownSeconds, dedupRefType, sortOrder, remark
|
|
|
+ *
|
|
|
+ * 数值口径:§三 各条「参数来源」+ §九 修正后数值(F06=1990、F18 与 F02 同口径)。
|
|
|
+ * 注意:§三 公式写作 floor(实付(分)/100)×100,与 §九「19.9 元=1990 CF」不自洽;
|
|
|
+ * 按 §九 修正口径执行 —— 1 元=100 CF 即 实付(分)→CF 1:1,公式记作 floor(amt)。
|
|
|
+ */
|
|
|
+ private static final String[][] CF_EARN_RULE_SEEDS = {
|
|
|
+ {"F02", "消费返CF(所有订单)", "consume", "formula", "",
|
|
|
+ "floor(amt)",
|
|
|
+ "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"1元=100CF,实付分→CF 1:1\"}",
|
|
|
+ "", "", "", "cf_consume_order", "10",
|
|
|
+ "所有消费订单(商品/套餐/测评/会员/订阅/调研)均返"},
|
|
|
+ {"F03a", "直接销售奖", "consume", "formula", "",
|
|
|
+ "floor(base_ppoints * rate_percent / 100)",
|
|
|
+ "{\"base\":\"team_annual_ppoints\",\"tier_table\":\"cf_direct_sales_tier\",\"window\":\"1y_rolling\",\"team_depth\":8,\"max_rate_percent\":70,\"downgrade\":true}",
|
|
|
+ "", "", "", "cf_direct_sales", "20",
|
|
|
+ "基数=直接推荐人消费P点;档位按团队8层内年度累计P点(cf_direct_sales_tier)"},
|
|
|
+ {"F03b", "间接销售奖", "consume", "formula", "",
|
|
|
+ "floor(base_ppoints * rate_percent / 100)",
|
|
|
+ "{\"base\":\"self_annual_ppoints\",\"tier_table\":\"cf_indirect_sales_tier\",\"window\":\"1y_rolling\",\"max_rate_percent\":20,\"downgrade\":true}",
|
|
|
+ "", "", "", "cf_indirect_sales", "30",
|
|
|
+ "基数=间接推荐人消费P点;档位按自身年度累计消费P点(cf_indirect_sales_tier)"},
|
|
|
+ {"F04", "商品赠品CF", "consume", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"product_order_gift.cf_value\"}",
|
|
|
+ "", "", "", "product_order_gift", "40",
|
|
|
+ "赠品规则管理端配置"},
|
|
|
+ {"F05", "活动签到CF", "consume", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"activities.platform_points\"}",
|
|
|
+ "", "", "", "activity_checkin", "50",
|
|
|
+ "活动配置数量"},
|
|
|
+ {"F06", "菌群健康调研打卡返还", "consume", "formula", "",
|
|
|
+ "floor(amt)",
|
|
|
+ "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"fallback\":\"gut_health_project.cf_refund_amount\",\"example\":\"19.9元=1990CF\"}",
|
|
|
+ "7", "1", "", "gut_health_refund", "60",
|
|
|
+ "cf_refund_amount 为空时按项目价×100(19.9元=1990CF,§九修正值);一周项目只发一次"},
|
|
|
+ {"F07", "文章答题奖励", "behavior", "formula", "",
|
|
|
+ "score + (all_correct ? 3 : 0)",
|
|
|
+ "{\"bonus_all_correct\":3,\"dedup\":\"article\"}",
|
|
|
+ "5", "", "", "article_answer", "70",
|
|
|
+ "答对题数+全对3;日限5次(N9)"},
|
|
|
+ {"F08", "AI阅读答题奖励", "behavior", "fixed", "5",
|
|
|
+ "",
|
|
|
+ "{\"require_all_correct\":true}",
|
|
|
+ "3", "", "", "ai_reading_answer", "80",
|
|
|
+ "仅全部答对才发(原硬编码已外置)"},
|
|
|
+ {"F09", "完成测评建档", "behavior", "formula", "",
|
|
|
+ "first_report ? 50 : 10",
|
|
|
+ "{\"first_amount\":50,\"repeat_amount\":10,\"dedup_key\":\"report_hash\"}",
|
|
|
+ "3", "", "", "assessment_report", "90",
|
|
|
+ "第1份50/后续每份10,重复报告不计(N10)"},
|
|
|
+ {"F10", "家庭挑战完成", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"family_challenge.reward_points\",\"pending\":\"Q1 家庭合计 or 按人各发\"}",
|
|
|
+ "1", "1", "", "family_challenge", "100",
|
|
|
+ "按挑战配置数量(按人各发 or 家庭合计待 Q1 确认)"},
|
|
|
+ {"F11", "邀请里程碑", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"invite_milestone.reward_points\"}",
|
|
|
+ "", "", "", "invite_milestone", "110",
|
|
|
+ "按里程碑配置"},
|
|
|
+ {"F12", "成长任务", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"growth_task.reward_points\"}",
|
|
|
+ "", "", "", "growth_task", "120",
|
|
|
+ "按任务模板配置"},
|
|
|
+ {"F13", "分享阅读奖励", "behavior", "fixed", "5",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"cf.rate.share_read_reward_points\"}",
|
|
|
+ "5", "", "", "share_read", "130",
|
|
|
+ "默认5CF;日限5次"},
|
|
|
+ {"F14", "喝水打卡", "behavior", "fixed", "5",
|
|
|
+ "",
|
|
|
+ "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
|
|
|
+ "", "", "", "health_water", "140",
|
|
|
+ "5CF/次;日限按会员等级(N11)"},
|
|
|
+ {"F15", "饮食打卡", "behavior", "fixed", "5",
|
|
|
+ "",
|
|
|
+ "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
|
|
|
+ "", "", "", "health_meal", "150",
|
|
|
+ "5CF/次;日限按会员等级(N11)"},
|
|
|
+ {"F16", "每日健康打卡", "behavior", "fixed", "10",
|
|
|
+ "",
|
|
|
+ "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
|
|
|
+ "", "", "", "daily_health_checkin", "160",
|
|
|
+ "10CF/次;日限按会员等级(N11)"},
|
|
|
+ {"F17", "挑战赛子任务完成", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"challenge_task.reward_cf\"}",
|
|
|
+ "", "", "", "challenge_sub_task", "170",
|
|
|
+ "按挑战赛任务配置"},
|
|
|
+ {"F18", "健康启航计划付费", "consume", "formula", "",
|
|
|
+ "floor(amt)",
|
|
|
+ "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"note\":\"与F02同口径\"}",
|
|
|
+ "1", "1", "", "health_start_plan", "180",
|
|
|
+ "实付分→CF 1:1(§九修正口径,原硬编码已外置)"},
|
|
|
+ {"F19", "管理员手动调整", "admin", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"require_parent_password\":true,\"allow_negative\":true}",
|
|
|
+ "", "", "", "admin_adjust", "190",
|
|
|
+ "正负任意,需家长密码校验,必须留痕(Q9)"},
|
|
|
+ {"F20", "问题域全部任务完成", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"problem_domain.reward_cf_value\"}",
|
|
|
+ "", "", "", "problem_domain", "200",
|
|
|
+ "按管理端配置"},
|
|
|
+ {"F21", "关卡通关奖励", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"unlock_gate.reward_points\"}",
|
|
|
+ "", "", "", "unlock_gate", "210",
|
|
|
+ "按管理端配置"},
|
|
|
+ {"F22", "任务完成CF", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"source\":\"task.cf_amount\",\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
|
|
|
+ "", "", "", "task_complete", "220",
|
|
|
+ "保留个人发放,取消家庭池那条;日限按会员等级(N11)"},
|
|
|
+ {"F23", "成员间转让(收款方)", "behavior", "per_item", "",
|
|
|
+ "",
|
|
|
+ "{\"direction\":\"transfer_in\",\"pair_type\":\"transfer_out\"}",
|
|
|
+ "", "", "", "cf_transfer", "230",
|
|
|
+ "转入+X / 转出−X,同一事务"},
|
|
|
+ {"F24", "分享奖励(推荐注册)", "behavior", "fixed", "20",
|
|
|
+ "",
|
|
|
+ "{\"trigger\":\"referral_register\"}",
|
|
|
+ "10", "", "", "referral_register", "240",
|
|
|
+ "N8 新增:推荐他人注册即得,建议20CF/人,日限10次"}
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 幂等写入 24 行规则种子(已存在则跳过,不覆盖运营改过的配置) */
|
|
|
+ private void seedCfEarnRules() {
|
|
|
+ int inserted = 0;
|
|
|
+ for (String[] r : CF_EARN_RULE_SEEDS) {
|
|
|
+ try {
|
|
|
+ Integer cnt = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM cf_earn_rule WHERE code = ?", Integer.class, r[0]);
|
|
|
+ if (cnt != null && cnt > 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ jdbcTemplate.update("INSERT INTO cf_earn_rule (code, name, category, enabled, rule_type, amount, "
|
|
|
+ + "formula, params_json, daily_limit_per_member, total_limit_per_member, cooldown_seconds, "
|
|
|
+ + "dedup_ref_type, sort_order, remark, created_at, updated_at) "
|
|
|
+ + "VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())",
|
|
|
+ r[0], r[1], r[2], r[3], cfIntOrNull(r[4]), cfTextOrNull(r[5]), cfTextOrNull(r[6]),
|
|
|
+ cfIntOrNull(r[7]), cfIntOrNull(r[8]), cfIntOrNull(r[9]), r[10],
|
|
|
+ cfIntOrNull(r[11]) != null ? cfIntOrNull(r[11]) : 0, cfTextOrNull(r[12]));
|
|
|
+ inserted++;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移408: 写入规则种子 {} 失败: {}", r[0], e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (inserted > 0) {
|
|
|
+ log.info("迁移408: cf_earn_rule 规则种子新增 {} 行(共 {} 条定义)", inserted, CF_EARN_RULE_SEEDS.length);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移409: 创建 cf_direct_sales_tier / cf_indirect_sales_tier(两级销售奖档位,数值由运营配置)
|
|
|
+ private void createCfSalesTierTables() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_direct_sales_tier` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`tier_name` VARCHAR(50) NOT NULL COMMENT '档位名称', "
|
|
|
+ + "`min_annual_ppoints` INT NOT NULL COMMENT '团队8层内年度累计P点下限', "
|
|
|
+ + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', "
|
|
|
+ + "`sort_order` INT NOT NULL DEFAULT 0, "
|
|
|
+ + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', "
|
|
|
+ + "`remark` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY `uk_min` (`min_annual_ppoints`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直接销售奖档位(按团队8层内年度P点累计)'");
|
|
|
+ log.info("迁移409: 已创建 cf_direct_sales_tier 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移409: 创建 cf_direct_sales_tier 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_indirect_sales_tier` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`tier_name` VARCHAR(50) NOT NULL, "
|
|
|
+ + "`min_self_ppoints` INT NOT NULL COMMENT '自身年度累计消费P点下限', "
|
|
|
+ + "`rate_percent` INT NOT NULL COMMENT '分润比例(%)', "
|
|
|
+ + "`sort_order` INT NOT NULL DEFAULT 0, "
|
|
|
+ + "`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', "
|
|
|
+ + "`remark` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY `uk_min` (`min_self_ppoints`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='间接销售奖档位(按自身年度消费P点累计)'");
|
|
|
+ log.info("迁移409: 已创建 cf_indirect_sales_tier 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移409: 创建 cf_indirect_sales_tier 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移410: 创建 cf_earn_daily_limit(规则 × 会员等级 每日次数上限,N11)
|
|
|
+ private void createCfEarnDailyLimitTable() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `cf_earn_daily_limit` ("
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "`rule_code` VARCHAR(20) NOT NULL COMMENT '规则编码(cf_earn_rule.code)', "
|
|
|
+ + "`level_code` VARCHAR(32) NOT NULL COMMENT '会员等级编码 FREE/BASIC/FAMILY/LIFETIME/PREMIUM', "
|
|
|
+ + "`daily_limit` INT NOT NULL COMMENT '每日次数上限', "
|
|
|
+ + "`remark` VARCHAR(255) NULL, "
|
|
|
+ + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "UNIQUE KEY `uk_rule_level` (`rule_code`, `level_code`) "
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则×会员等级每日次数上限(N11)'");
|
|
|
+ log.info("迁移410: 已创建 cf_earn_daily_limit 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移410: 创建 cf_earn_daily_limit 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** N11 会员等级日限:FREE=1(建议) / BASIC=3 / FAMILY=10 / LIFETIME=10 / PREMIUM=20,PROVIDER 待定不落行 */
|
|
|
+ private static final String[][] CF_DAILY_LIMIT_LEVELS = {
|
|
|
+ {"FREE", "1", "免费会员(建议值,Q11)"},
|
|
|
+ {"BASIC", "3", "9.9体验会员"},
|
|
|
+ {"FAMILY", "10", "家庭会员"},
|
|
|
+ {"LIFETIME", "10", "终身会员"},
|
|
|
+ {"PREMIUM", "20", "高级会员"}
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 按会员等级限次的规则(任务/打卡类):F14 喝水 / F15 饮食 / F16 每日健康 / F22 任务完成 */
|
|
|
+ private static final String[] CF_DAILY_LIMIT_RULES = {"F14", "F15", "F16", "F22"};
|
|
|
+
|
|
|
+ private void seedCfEarnDailyLimits() {
|
|
|
+ int inserted = 0;
|
|
|
+ for (String ruleCode : CF_DAILY_LIMIT_RULES) {
|
|
|
+ for (String[] lv : CF_DAILY_LIMIT_LEVELS) {
|
|
|
+ try {
|
|
|
+ Integer cnt = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM cf_earn_daily_limit WHERE rule_code = ? AND level_code = ?",
|
|
|
+ Integer.class, ruleCode, lv[0]);
|
|
|
+ if (cnt != null && cnt > 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ jdbcTemplate.update("INSERT INTO cf_earn_daily_limit (rule_code, level_code, daily_limit, remark, created_at, updated_at) "
|
|
|
+ + "VALUES (?, ?, ?, ?, NOW(), NOW())", ruleCode, lv[0], Integer.valueOf(lv[1]), lv[2]);
|
|
|
+ inserted++;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移410: 写入日限种子 {}/{} 失败: {}", ruleCode, lv[0], e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (inserted > 0) {
|
|
|
+ log.info("迁移410: cf_earn_daily_limit 日限种子新增 {} 行", inserted);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移411: coupon / user_coupon 补 family_id(优惠券家庭化 N3)
|
|
|
+ private void addCouponFamilyIdColumns() {
|
|
|
+ if (!tableExists("coupon") || !tableExists("user_coupon")) {
|
|
|
+ log.warn("迁移411: coupon / user_coupon 表不存在,跳过 family_id 迁移");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ ensureColumn("coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '模板块绑定家庭ID(NULL=全平台模板)'");
|
|
|
+ ensureColumn("user_coupon", "family_id", "BIGINT DEFAULT NULL COMMENT '券实例归属家庭ID(家庭内任意成员可用)'");
|
|
|
+ try {
|
|
|
+ if (!indexExists("user_coupon", "idx_family")) {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE user_coupon ADD INDEX idx_family (family_id)");
|
|
|
+ log.info("迁移411: 已添加 user_coupon.idx_family 索引");
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移411: 添加 user_coupon.idx_family 失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ if (!indexExists("coupon", "idx_family")) {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE coupon ADD INDEX idx_family (family_id)");
|
|
|
+ log.info("迁移411: 已添加 coupon.idx_family 索引");
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移411: 添加 coupon.idx_family 失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移412: 创建 service_config(L2 配置中心;W0.1)
|
|
|
+ private void createServiceConfigTable() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("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)', "
|
|
|
+ + "`config_value` TEXT NULL COMMENT '配置值(统一字符串存储,按 value_type 解析)', "
|
|
|
+ + "`value_type` VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT 'string/int/double/bool/json', "
|
|
|
+ + "`description` VARCHAR(255) NULL, "
|
|
|
+ + "`editable` TINYINT NOT NULL DEFAULT 1 COMMENT '1允许后台编辑 0系统托管', "
|
|
|
+ + "`updated_by` BIGINT NULL, "
|
|
|
+ + "`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通用运营参数归口)'");
|
|
|
+ log.info("迁移412: 已创建 service_config 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: 创建 service_config 表失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** W0.1 §3.6.6(2) 10 项 CF 通用运营参数 + cf.version 版本号 */
|
|
|
+ private void seedCfServiceConfig() {
|
|
|
+ ensureServiceConfig("cf.rate.points_multiplier", "1.0", "double",
|
|
|
+ "积分倍数(消费→CF 换算);原散落 products.points_multiplier / sys_config", 1);
|
|
|
+ ensureServiceConfig("cf.rate.repeat_rate_default", "1.0", "double",
|
|
|
+ "复购率默认系数(未匹配规则时不加权)", 1);
|
|
|
+ ensureServiceConfig("cf.commission.settle_days", "7", "int",
|
|
|
+ "佣金结算周期(天),财务口径常用", 1);
|
|
|
+ ensureServiceConfig("cf.invite.valid_days", "7", "int",
|
|
|
+ "邀请码有效期(天)", 1);
|
|
|
+ ensureServiceConfig("cf.price.default_factor", "1.0", "double",
|
|
|
+ "默认价格系数(未定价时原价)", 1);
|
|
|
+ ensureServiceConfig("cf.stock.deduction_mode", "immediate", "string",
|
|
|
+ "库存扣减策略 immediate/on_pay(Phase 4 定稿)", 1);
|
|
|
+ ensureServiceConfig("cf.rate.share_read_reward_points", "5", "int",
|
|
|
+ "分享阅读奖励 CF;原散落 sys_config.share_read_reward_points", 1);
|
|
|
+ ensureServiceConfig("cf.commission.service_rate", "1000", "int",
|
|
|
+ "佣金服务费率(bps);原散落 sys_config.commission_service_rate", 1);
|
|
|
+ ensureServiceConfig("cf.product.gut_health_price", "1990", "int",
|
|
|
+ "肠道健康项目定价(分);原散落 GutHealthProjectService.DEFAULT_PRICE / sys_config", 1);
|
|
|
+ ensureServiceConfig("cf.product.platform_points_share", "1000", "int",
|
|
|
+ "产品平台积分分成比例(bps);原散落 sys_config.product_platform_points_share", 1);
|
|
|
+ ensureServiceConfig("cf.version", "1", "int",
|
|
|
+ "CF 配置版本号(单调递增,前端据此局部刷新提示)", 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 迁移散落在 sys_config / products 的 CF 参数到 service_config:
|
|
|
+ * 优先取旧位置的实际值(存在则沿用),旧位置仅做标记清理,不删值(F-2/F-3 收口前保持兼容)。
|
|
|
+ * 幂等:新 key 已存在则跳过;旧位置标记带唯一前缀,重复执行不重复追加。
|
|
|
+ */
|
|
|
+ private void migrateScatteredCfConfigs() {
|
|
|
+ migrateSysConfigToServiceConfig("commission_service_rate", "cf.commission.service_rate", "1000");
|
|
|
+ migrateSysConfigToServiceConfig("product_platform_points_share", "cf.product.platform_points_share", "1000");
|
|
|
+ migrateSysConfigToServiceConfig("share_read_reward_points", "cf.rate.share_read_reward_points", "5");
|
|
|
+ migrateSysConfigToServiceConfig("gut_health_project_price", "cf.product.gut_health_price", "1990");
|
|
|
+
|
|
|
+ // products.points_multiplier(列)→ cf.rate.points_multiplier,并标记列注释
|
|
|
+ try {
|
|
|
+ if (!serviceConfigExists("cf.rate.points_multiplier")) {
|
|
|
+ String val = null;
|
|
|
+ try {
|
|
|
+ // 仅当所有商品倍数一致时才沿用该值,否则回落到 1.0(避免把某个商品的特殊倍数当成全局口径)
|
|
|
+ Integer distinct = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(DISTINCT points_multiplier) FROM products WHERE points_multiplier IS NOT NULL",
|
|
|
+ Integer.class);
|
|
|
+ if (distinct != null && distinct == 1) {
|
|
|
+ val = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT CAST(points_multiplier AS CHAR) FROM products WHERE points_multiplier IS NOT NULL LIMIT 1",
|
|
|
+ String.class);
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 列不存在或表为空
|
|
|
+ }
|
|
|
+ ensureServiceConfig("cf.rate.points_multiplier", val != null ? val : "1.0", "double",
|
|
|
+ "积分倍数(消费→CF 换算);原散落 products.points_multiplier", 1);
|
|
|
+ }
|
|
|
+ markProductsPointsMultiplierMigrated();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: products.points_multiplier 迁移失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void migrateSysConfigToServiceConfig(String oldKey, String newKey, String defaultVal) {
|
|
|
+ try {
|
|
|
+ if (serviceConfigExists(newKey)) {
|
|
|
+ markSysConfigMigrated(oldKey, newKey);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String val = null;
|
|
|
+ try {
|
|
|
+ val = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT config_value FROM sys_config WHERE config_key = ? LIMIT 1", String.class, oldKey);
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 旧配置不存在,使用默认值
|
|
|
+ }
|
|
|
+ ensureServiceConfig(newKey, val != null && !val.trim().isEmpty() ? val : defaultVal,
|
|
|
+ cfValueTypeOf(defaultVal), "由 sys_config." + oldKey + " 迁入(W0.1)", 1);
|
|
|
+ markSysConfigMigrated(oldKey, newKey);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: {} → {} 迁移失败: {}", oldKey, newKey, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 旧 sys_config 行标记为已迁移(保留值做过渡兼容,不物理删除) */
|
|
|
+ private void markSysConfigMigrated(String oldKey, String newKey) {
|
|
|
+ try {
|
|
|
+ int updated = jdbcTemplate.update(
|
|
|
+ "UPDATE sys_config SET description = CONCAT(COALESCE(description, ''), ?) "
|
|
|
+ + "WHERE config_key = ? AND (description IS NULL OR description NOT LIKE '%【已迁移→service_config%')",
|
|
|
+ " 【已迁移→service_config " + newKey + ",本行仅过渡期兼容】", oldKey);
|
|
|
+ if (updated > 0) {
|
|
|
+ log.info("迁移412: 已标记 sys_config.{} 迁往 service_config.{}", oldKey, newKey);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: 标记 sys_config.{} 失败: {}", oldKey, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** products.points_multiplier 列注释标记(列保留兼容,不再作为权威来源) */
|
|
|
+ private void markProductsPointsMultiplierMigrated() {
|
|
|
+ try {
|
|
|
+ String comment = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COLUMN_COMMENT FROM information_schema.COLUMNS "
|
|
|
+ + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'products' AND COLUMN_NAME = 'points_multiplier'",
|
|
|
+ String.class);
|
|
|
+ if (comment != null && comment.contains("已迁移")) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 "
|
|
|
+ + "COMMENT '积分倍数【已迁移→service_config cf.rate.points_multiplier,本列仅过渡期兼容】'");
|
|
|
+ log.info("迁移412: 已标记 products.points_multiplier 迁往 service_config.cf.rate.points_multiplier");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: 标记 products.points_multiplier 失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean serviceConfigExists(String key) {
|
|
|
+ try {
|
|
|
+ Integer cnt = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM service_config WHERE config_key = ?", Integer.class, key);
|
|
|
+ return cnt != null && cnt > 0;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 幂等写入 service_config(已存在则跳过,不覆盖运营改过的值) */
|
|
|
+ private void ensureServiceConfig(String key, String value, String valueType, String description, int editable) {
|
|
|
+ try {
|
|
|
+ if (serviceConfigExists(key)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ jdbcTemplate.update("INSERT INTO service_config (group_key, config_key, config_value, value_type, description, editable, created_at, updated_at) "
|
|
|
+ + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())",
|
|
|
+ groupKeyOfConfig(key), key, value, valueType, description, editable);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移412: 写入 service_config.{} 失败: {}", key, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** cf.rate.points_multiplier → cf.rate */
|
|
|
+ private String groupKeyOfConfig(String key) {
|
|
|
+ if (key == null) {
|
|
|
+ return "general";
|
|
|
+ }
|
|
|
+ int idx = key.lastIndexOf('.');
|
|
|
+ return idx > 0 ? key.substring(0, idx) : "general";
|
|
|
+ }
|
|
|
+
|
|
|
+ private String cfValueTypeOf(String value) {
|
|
|
+ if (value == null) {
|
|
|
+ return "string";
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Integer.parseInt(value.trim());
|
|
|
+ return "int";
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ // 非整数
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Double.parseDouble(value.trim());
|
|
|
+ return "double";
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ // 非数值
|
|
|
+ }
|
|
|
+ return "string";
|
|
|
+ }
|
|
|
+
|
|
|
+ private Integer cfIntOrNull(String v) {
|
|
|
+ if (v == null || v.trim().isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return Integer.valueOf(v.trim());
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String cfTextOrNull(String v) {
|
|
|
+ return (v == null || v.trim().isEmpty()) ? null : v;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean indexExists(String table, String indexName) {
|
|
|
+ try {
|
|
|
+ Integer cnt = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() "
|
|
|
+ + "AND TABLE_NAME = ? AND INDEX_NAME = ?",
|
|
|
+ Integer.class, table, indexName);
|
|
|
+ return cnt != null && cnt > 0;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|