Переглянути джерело

feat: restore BASIC 体验会员 9.9/月 + 饮食打卡分享 + 图片解析配额

## 变更内容

### 1. 恢复 BASIC 会员等级
- 迁移360 restoreBasicMembershipLevel() 重新插入 membership_levels 的 BASIC 行(level_order=2,月费990,max_children=3,max_tasks_per_day=3)和 membership_benefits 的 4 条 LITE 权益(AI_CHAT_LITE 3次/月,REPORT_READING_LITE 1次/月,GROWTH_PLAN_LITE,ASSESSMENT_QUOTA_LITE 1次)
- 修正 backfillMembershipLevelOrder 的 level_order 映射:FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6(BASIC 插回 FREE 与 FAMILY 之间)
- 同步 schema.sql:BASIC 种子数据 + level_order 与 quota_type 字段注释

### 2. 图片解析(食材识别)配额校验
- BasicQuotaService.QUOTA_LIMITS 增加 FOOD_RECOGNIZE=3
- BasicQuotaService 恢复 isActiveBasicMember / getExpireTime / refreshMonthlyQuota 真实实现
- 新增 consumeFoodRecognizeQuota(userId):FAMILY+ 不限次、BASIC 走月度配额、FREE 403 升级提示
- DietRecordController./recognize 注入 BasicQuotaService,取 userId 后校验配额,失败返回 Result.error(403, toast)

### 3. 饮食打卡分享(复用既有推荐链路)
- diet-checkin.vue / records.vue / record-detail.vue 接入 share-mixin(mixins + setShareInfo + onShareAppMessage)
- 分享链接自动带 refCode,他人点击注册后通过 App.vue / login.vue autoBindInviteCode 绑定推荐人
- 新用户即可自行饮食打卡

### 4. 设计文档
- 新增 docs/superpowers/specs/2026-10-11-restore-basic-and-diet-share-design.md

## 主文件变更
- DatabaseInitializer.java:新增迁移360 + backfill CASE 修正
- schema.sql:BASIC 种子 + 注释更新
- BasicQuotaService.java:恢复 3 方法 + FOOD_RECOGNIZE
- MembershipService.java:BASIC 分支 / 到期判断 / listActive / 激活配额初始化
- DietRecordController.java:/recognize 会员配额校验
- docs/superpowers/specs/2026-10-11-restore-basic-and-diet-share-design.md:设计说明

## 注意
- level_order 仅相对顺序调整(插入 BASIC),代码层 level_code 匹配逻辑不受影响
- 不恢复 consumeQuota 内原有的 isActiveBasicMember 前置校验,避免误伤 FREE/FAMILY 的 AI 对话
- 图片解析配额在控制器层校验,FAILED 返回 403 并提示升级
- 生产库历史 BASIC 用户已降级为 FREE,本迁移不自动回溯
Sisyphus Agent 2 днів тому
батько
коміт
6de3b3453d

+ 357 - 73
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2983,6 +2983,21 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         runSystemMigrations();
         registerCollectExpectedIndicators();
+
+        // 减脂专题表创建(远程 fatloss 功能)
+        createFatWeightRecordTable();
+        createFatDietRecordTable();
+        createFatExerciseRecordTable();
+        createFatGoalTable();
+
+        // 指标统一层迁移(356~362)
+        migrateIndicatorDefinitionsColumns();
+        migrateIndicatorValuesColumns();
+        addIndicatorValuesKeys();
+        createIndicatorMappingRulesTable();
+        createIndicatorCorrelationsTable();
+        ensureIndicatorCollectionDate();
+        seedDimensionDefinitions();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -6038,7 +6053,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_levels (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/BASIC/PROFESSIONAL/ENTERPRISE', " +
+                    "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE', " +
                     "level_name VARCHAR(100) NOT NULL COMMENT '等级名称', " +
                     "level_desc TEXT COMMENT '等级描述', " +
                     "price_monthly INT COMMENT '月费(分)', " +
@@ -8120,7 +8135,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_benefits (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/BASIC/PROFESSIONAL/ENTERPRISE', " +
+                    "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE', " +
                     "benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等', " +
                     "benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称', " +
                     "benefit_desc VARCHAR(500) COMMENT '权益描述', " +
@@ -10935,6 +10950,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         backfillTemplateInstanceFamilyMemberId();
         createReportTransferRequestsTable();
         addProductMinDiscountLevel();
+        restoreBasicMembershipLevel();
         backfillMembershipLevelOrder();
         removeReportUploadQuota();
 
@@ -10945,15 +10961,8 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         addAbTestColumnsToRecommendationLog();
         createTaskReminderConfigTable();
         addMissingColumnsToRecommendationLog();
-
-        // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
-        try {
-            jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
-                    "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
-            log.info("迁移341: 已为 BASIC 体验会员补充 referral_commission 功能");
-        } catch (Exception e) {
-            log.warn("迁移341: 更新 BASIC features 失败: {}", e.getMessage());
-        }
+        createDietFamilySelectedIngredientsTable();
+        createSsoTicketTable();
 
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
@@ -11020,55 +11029,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移314: meal_configs 添加索引 idx_config_date 失败(可能已存在): {}", e.getMessage());
         }
 
-// 迁移315: membership_levels 新增 BASIC 体验会员等级(9.9元/月)
-        try {
-            jdbcTemplate.execute(
-                "INSERT IGNORE INTO membership_levels " +
-                "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " +
-                "VALUES ('BASIC', '体验会员', " +
-                "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " +
-                "990, " +
-                "'[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\"]', " +
-                "3, 3)");
-            log.info("迁移315: 已初始化BASIC体验会员等级");
-        } catch (Exception e) {
-            // 等级已存在,忽略
-        }
-
-        // 迁移316: membership_benefits 新增 BASIC 会员权益
-        try {
-            jdbcTemplate.execute(
-                "INSERT IGNORE INTO membership_benefits " +
-                "(level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
-                "('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1), " +
-                "('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " +
-                "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " +
-                "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8)");
-            log.info("迁移316: 已初始化BASIC会员权益列表");
-        } catch (Exception e) {
-            // 已存在,忽略
-        }
-
-        // 迁移317: 创建 basic_quota_usage 表(BASIC会员月度配额使用记录)
-        try {
-            jdbcTemplate.execute(
-                "CREATE TABLE IF NOT EXISTS basic_quota_usage (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                "user_id BIGINT NOT NULL COMMENT '家长用户ID', " +
-                "quota_type VARCHAR(30) NOT NULL COMMENT '配额类型', " +
-                "used_count INT DEFAULT 0 COMMENT '已用次数', " +
-                "total_count INT NOT NULL COMMENT '月度总配额', " +
-                "period_start DATE NOT NULL COMMENT '周期开始日期', " +
-                "period_end DATE NOT NULL COMMENT '周期结束日期', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-                "UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start), " +
-                "INDEX idx_user_period (user_id, period_start) " +
-                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录'");
-            log.info("迁移317: 已创建basic_quota_usage表");
-        } catch (Exception e) {
-            // 表已存在,忽略
-        }
 
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" +
@@ -11194,15 +11154,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
 
         // 迁移321: 商品/分类可见性由「角色」改为「按会员级别」控制
-        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6
+        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5
         // - products.min_visible_level: 最低可见会员级别编码(NULL=所有用户含未登录可见)
         // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=所有用户含未登录可见)
         ensureColumn("membership_levels", "level_order",
-                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6'");
+                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5'");
         try {
             jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
-                    "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
-                    "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
+                    "WHEN 'FREE' THEN 1 WHEN 'FAMILY' THEN 2 WHEN 'PROVIDER' THEN 3 " +
+                    "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END");
             log.info("迁移321: 已回填 membership_levels.level_order");
         } catch (Exception e) {
             log.warn("迁移321: 回填 membership_levels.level_order 失败: {}", e.getMessage());
@@ -11793,7 +11753,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addProductMinDiscountLevel() {
         try {
             jdbcTemplate.execute("ALTER TABLE products ADD COLUMN min_discount_level VARCHAR(50) DEFAULT NULL " +
-                    "COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
+                    "COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
             log.info("迁移350: 已添加 products.min_discount_level 列");
         } catch (Exception e) {
             // 列已存在,忽略
@@ -11808,18 +11768,57 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
                     "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
                     "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
-            log.info("迁移351: 已顺延 membership_levels.level_order({} 行),BASIC=2", updated);
+            log.info("迁移351: 已回填 membership_levels.level_order({} 行):FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6", updated);
+        } catch (Exception e) {
+            log.warn("迁移351: 回填 membership_levels.level_order 失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移360: 恢复 9.9 元/月 BASIC 体验会员等级与权益(幂等)。
+     * 反向还原提交 f86dd164b 删除的内容;level_order 由 backfillMembershipLevelOrder 统一回填。
+     */
+    private void restoreBasicMembershipLevel() {
+        try {
+            jdbcTemplate.execute(
+                    "INSERT IGNORE INTO membership_levels " +
+                            "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " +
+                            "VALUES ('BASIC', '体验会员', " +
+                            "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " +
+                            "990, " +
+                            "'[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]', " +
+                            "3, 3)");
+            log.info("迁移360: 已恢复 BASIC 体验会员等级");
+        } catch (Exception e) {
+            log.warn("迁移360: 恢复 BASIC 会员等级失败: {}", e.getMessage());
+        }
+        // 兼容历史数据:早期 BASIC features 缺少 referral_commission,补齐
+        try {
+            jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
+                    "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
         } catch (Exception e) {
-            log.warn("迁移351: 顺延 membership_levels.level_order 失败: {}", e.getMessage());
+            log.warn("迁移360: 补齐 BASIC features 失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute(
+                    "INSERT IGNORE INTO membership_benefits " +
+                            "(level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
+                            "('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1), " +
+                            "('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " +
+                            "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " +
+                            "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测试', '📅', '#E0E7FF', 8)");
+            log.info("迁移360: 已恢复 BASIC 会员权益");
+        } catch (Exception e) {
+            log.warn("迁移360: 恢复 BASIC 会员权益失败: {}", e.getMessage());
         }
     }
 
-    // 迁移352: 健康报告上传改为对所有登录用户免费无限,移除 REPORT_UPLOAD 配额与 BASIC 权益文案
+    // 迁移352: 健康报告上传改为对所有登录用户免费无限
     private void removeReportUploadQuota() {
         try {
             int deletedBenefits = jdbcTemplate.update(
                     "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'REPORT_UPLOAD_LITE'");
-            log.info("迁移352: 已移除 BASIC 权益 REPORT_UPLOAD_LITE({} 行)", deletedBenefits);
+            log.info("迁移352: 已清理遗留权益记录({} 行)", deletedBenefits);
         } catch (Exception e) {
             log.warn("迁移352: 移除 REPORT_UPLOAD_LITE 权益失败: {}", e.getMessage());
         }
@@ -11852,6 +11851,29 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登)
+    // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。
+    private void createSsoTicketTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS sso_ticket (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)'," +
+                    "source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train'," +
+                    "target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train'," +
+                    "source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)'," +
+                    "cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据'," +
+                    "used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用'," +
+                    "expire_at DATETIME NOT NULL COMMENT '过期时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "UNIQUE KEY uk_ticket (ticket)," +
+                    "INDEX idx_expire (expire_at)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据'");
+            log.info("迁移359: 已创建 sso_ticket 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 sso_ticket 表失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移355: product_recommendation_log 补齐缺失列
     // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列),
     // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at,
@@ -11993,14 +12015,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    /** 写入类型「应采集指标项」定义(每次启动覆盖,保证与代码一致) */
-    private void seedExpectedIndicator(String typeId, String expectedJson) {
+    /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */
+    void seedExpectedIndicator(String typeId, String expectedJson) {
         try {
             int updated = jdbcTemplate.update(
-                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() WHERE type_id = ?",
+                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " +
+                    "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')",
                     expectedJson, typeId);
             if (updated == 0) {
-                log.warn("迁移354: 类型 {} 不存在,跳过指标项定义", typeId);
+                log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId);
             } else {
                 log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
             }
@@ -12020,4 +12043,265 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         return sb.append(']').toString();
     }
+
+    // 迁移359: 家庭共餐选中食材表(持久化共餐场景食材选择)
+    private void createDietFamilySelectedIngredientsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "food_id BIGINT NOT NULL COMMENT '食材ID'," +
+                    "food_name VARCHAR(100) COMMENT '食材名称'," +
+                    "score INT COMMENT '推荐分'," +
+                    "reason VARCHAR(255) COMMENT '推荐理由'," +
+                    "category VARCHAR(50) COMMENT '分类'," +
+                    "calories_per_100g INT COMMENT '每100克卡路里'," +
+                    "cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)'," +
+                    "nutrition_score INT COMMENT '营养密度分(0-100)'," +
+                    "balance_reason VARCHAR(255) COMMENT '均衡化推荐理由'," +
+                    "sort_order INT DEFAULT 0 COMMENT '排序'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "UNIQUE KEY uk_family_food (family_id, food_id)," +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材'");
+            log.info("迁移359: 已创建 diet_family_selected_ingredients 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
+    private void migrateIndicatorDefinitionsColumns() {
+        ensureColumn("indicator_definitions", "source_kind",
+                "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
+        ensureColumn("indicator_definitions", "report_type_scope",
+                "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
+        ensureColumn("indicator_definitions", "value_type",
+                "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
+        ensureColumn("indicator_definitions", "good_direction",
+                "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
+        ensureColumn("indicator_definitions", "dimension_code",
+                "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
+        log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
+    }
+
+    // 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
+    private void migrateIndicatorValuesColumns() {
+        ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
+        ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
+        ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
+        ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
+        ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
+        ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
+        log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
+    }
+
+    // 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
+    private void addIndicatorValuesKeys() {
+        try {
+            // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
+            jdbcTemplate.execute(
+                    "DELETE v FROM indicator_values v " +
+                    "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
+                    "      FROM indicator_values " +
+                    "      GROUP BY source_type, source_id, definition_id " +
+                    "      HAVING COUNT(*) > 1) d " +
+                    "  ON v.source_type = d.source_type AND v.source_id = d.source_id " +
+                    " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
+            log.info("迁移358: 已清理 indicator_values 重复行(如有)");
+        } catch (Exception e) {
+            log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
+            log.info("迁移358: 已添加 uk_source_definition 唯一键");
+        } catch (Exception e) {
+            log.info("迁移358: uk_source_definition 已存在,跳过");
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
+            log.info("迁移358: 已添加 idx_subject_def_date 索引");
+        } catch (Exception e) {
+            log.info("迁移358: idx_subject_def_date 已存在,跳过");
+        }
+    }
+
+    // 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
+    private void createIndicatorMappingRulesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
+                    "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
+                    "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
+                    "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
+                    "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
+                    "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
+                    "status TINYINT DEFAULT 1," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
+                    "INDEX idx_def (definition_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
+            log.info("迁移359: 已创建 indicator_mapping_rules 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
+    private void createIndicatorCorrelationsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
+                    "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
+                    "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
+                    "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
+                    "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
+                    "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
+                    "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
+                    "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
+                    "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
+                    "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
+                    "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
+                    "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
+                    "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
+                    "computed_at DATETIME NULL COMMENT '最后校准时间'," +
+                    "created_by BIGINT NULL COMMENT '创建者'," +
+                    "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
+                    "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
+                    "INDEX idx_def_a (definition_id_a)," +
+                    "INDEX idx_def_b (definition_id_b)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
+            log.info("迁移360: 已创建 indicator_correlations 表");
+        } catch (Exception e) {
+            log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
+    private void ensureIndicatorCollectionDate() {
+        ensureColumn("health_indicators", "collection_date",
+                "DATETIME COMMENT '指标采集日期(报告检测日期)'");
+        log.info("迁移361: health_indicators.collection_date 已确认存在");
+    }
+
+    // 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
+    private void seedDimensionDefinitions() {
+        Object[][] dims = {
+            {"dim.body",     "身", "身体",  "higher_is_better", "higher_is_better"},
+            {"dim.mind",     "心", "心理",  "higher_is_better", "higher_is_better"},
+            {"dim.wisdom",   "智", "智慧",  "higher_is_better", "higher_is_better"},
+            {"dim.action",   "行", "行动",  "higher_is_better", "higher_is_better"},
+            {"dim.wealth",   "富", "财富",  "higher_is_better", "higher_is_better"},
+        };
+        for (Object[] d : dims) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.update(
+                            "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
+                            "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
+                            "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
+                            d[1], d[0], d[2], d[4], d[3]);
+                    log.info("迁移362: 已预置维度定义 {}", d[0]);
+                }
+            } catch (Exception e) {
+                log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
+            }
+        }
+    }
+
+    // 迁移355: 创建减脂专题体重记录表
+    private void createFatWeightRecordTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL, " +
+                    "weight DECIMAL(5,2) NOT NULL, " +
+                    "body_fat_percent DECIMAL(4,2) DEFAULT NULL, " +
+                    "waist_circumference DECIMAL(4,2) DEFAULT NULL, " +
+                    "measure_date DATE NOT NULL, " +
+                    "remark VARCHAR(255) DEFAULT NULL, " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_fat_weight_member (member_id, measure_date)) " +
+                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录'");
+            log.info("迁移355: 已创建fat_weight_record表");
+        } catch (Exception e) {
+            log.warn("迁移355: 创建fat_weight_record表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移356: 创建减脂专题饮食记录表
+    private void createFatDietRecordTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL, " +
+                    "meal_type VARCHAR(20) NOT NULL, " +
+                    "food_name VARCHAR(255) NOT NULL, " +
+                    "food_weight DECIMAL(6,2) DEFAULT NULL, " +
+                    "calories INT DEFAULT 0, " +
+                    "protein DECIMAL(6,2) DEFAULT 0, " +
+                    "fat DECIMAL(6,2) DEFAULT 0, " +
+                    "carbs DECIMAL(6,2) DEFAULT 0, " +
+                    "eat_date DATE NOT NULL, " +
+                    "remark VARCHAR(255) DEFAULT NULL, " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_fat_diet_member (member_id, eat_date)) " +
+                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录'");
+            log.info("迁移356: 已创建fat_diet_record表");
+        } catch (Exception e) {
+            log.warn("迁移356: 创建fat_diet_record表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移357: 创建减脂专题运动记录表
+    private void createFatExerciseRecordTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL, " +
+                    "exercise_type VARCHAR(50) NOT NULL, " +
+                    "duration_min INT DEFAULT 0, " +
+                    "distance_km DECIMAL(5,2) DEFAULT NULL, " +
+                    "calories_burned INT DEFAULT 0, " +
+                    "exercise_date DATE NOT NULL, " +
+                    "remark VARCHAR(255) DEFAULT NULL, " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_fat_exercise_member (member_id, exercise_date)) " +
+                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录'");
+            log.info("迁移357: 已创建fat_exercise_record表");
+        } catch (Exception e) {
+            log.warn("迁移357: 创建fat_exercise_record表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移358: 创建减脂专题目标表
+    private void createFatGoalTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL, " +
+                    "target_weight DECIMAL(5,2) NOT NULL, " +
+                    "start_weight DECIMAL(5,2) NOT NULL, " +
+                    "target_body_fat DECIMAL(4,2) DEFAULT NULL, " +
+                    "target_date DATE DEFAULT NULL, " +
+                    "status VARCHAR(20) DEFAULT 'active', " +
+                    "start_date DATE NOT NULL, " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_fat_goal_member (member_id)) " +
+                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标'");
+            log.info("迁移358: 已创建fat_goal表");
+        } catch (Exception e) {
+            log.warn("迁移358: 创建fat_goal表失败: {}", e.getMessage());
+        }
+    }
 }

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecordController.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.controller.diet;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.QuotaConsumeResult;
+import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.DietRecordService;
 import org.springframework.web.bind.annotation.*;
 
@@ -14,8 +16,17 @@ public class DietRecordController {
     @Resource
     private DietRecordService dietRecordService;
 
+    @Resource
+    private BasicQuotaService basicQuotaService;
+
     @PostMapping("/recognize")
-    public Result<com.etotem.cfc.dto.RecognizeFoodResult> recognizeFood(@RequestBody Map<String, String> request) {
+    public Result<com.etotem.cfc.dto.RecognizeFoodResult> recognizeFood(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, String> request) {
+        QuotaConsumeResult quota = basicQuotaService.consumeFoodRecognizeQuota(userId);
+        if (!quota.isSuccess()) {
+            return Result.error(403, quota.getToastMsg());
+        }
         return Result.success(dietRecordService.recognizeFood(request.get("image_url")));
     }
 

+ 33 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.entity.BasicQuotaUsage;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.BasicQuotaUsageMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -32,6 +33,7 @@ public class BasicQuotaService {
         QUOTA_LIMITS.put("AI_CHAT", 3);
         QUOTA_LIMITS.put("REPORT_READING", 1);
         QUOTA_LIMITS.put("ASSESSMENT_QUOTA", 1);
+        QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
     }
 
     /**
@@ -73,12 +75,6 @@ public class BasicQuotaService {
     public QuotaConsumeResult consumeQuota(Long userId, String quotaType) {
         QuotaConsumeResult result = new QuotaConsumeResult();
 
-        if (!isActiveBasicMember(userId)) {
-            result.setSuccess(false);
-            result.setToastMsg("该功能仅限体验会员使用");
-            return result;
-        }
-
         Date now = new Date();
         Date periodStart = getMonthStart(now);
         Date periodEnd = getMonthEnd(now);
@@ -126,7 +122,7 @@ public class BasicQuotaService {
     }
 
     /**
-     * 批量刷新所有 BASIC 用户当月配额(每月1号调用)
+     * 批量刷新所有有效 BASIC 用户当月配额(每月1号调用)
      */
     public void refreshMonthlyQuota() {
         Date now = new Date();
@@ -175,7 +171,7 @@ public class BasicQuotaService {
                 }
             }
         }
-        log.info("BASIC配额刷新完成, 处理用户数: {}", userIds.size());
+        log.info("BASIC配额刷新完成, 处理用户数={}", userIds.size());
     }
 
     /**
@@ -197,7 +193,7 @@ public class BasicQuotaService {
     }
 
     /**
-     * 获取 BASIC 用户到期时间
+     * 获取会员到期时间
      */
     public Date getExpireTime(Long userId) {
         User user = userMapper.selectById(userId);
@@ -207,6 +203,34 @@ public class BasicQuotaService {
         return null;
     }
 
+    /**
+     * 图片解析(食材识别)配额校验:
+     * - FAMILY 及以上(level_order &gt; BASIC):不限次放行;
+     * - BASIC:走 FOOD_RECOGNIZE 月度配额;
+     * - FREE:拒绝并提示升级。
+     */
+    public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
+        QuotaConsumeResult result = new QuotaConsumeResult();
+        if (userId == null || userId == 0L) {
+            result.setSuccess(true);
+            return result;
+        }
+        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
+        int basicOrder = membershipService.getLevelOrder("BASIC");
+        if (userOrder < basicOrder) {
+            result.setSuccess(false);
+            result.setRemaining(0);
+            result.setToastMsg("图片解析为体验会员及以上专属权益,升级后即可使用");
+            result.setActionUrl("/pages/membership/upgrade");
+            return result;
+        }
+        if (userOrder > basicOrder) {
+            result.setSuccess(true);
+            return result;
+        }
+        return consumeQuota(userId, "FOOD_RECOGNIZE");
+    }
+
     private Date getMonthStart(Date date) {
         Calendar cal = Calendar.getInstance();
         cal.setTime(date);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -243,6 +243,7 @@ public class MembershipService implements MembershipServiceInterface {
                     || "referral_commission".equals(feature);
         }
 
+
         // BASIC 体验会员可用功能(9.9元/月,限次配额型)
         if ("BASIC".equals(level)) {
             return "free_activities".equals(feature)
@@ -258,7 +259,7 @@ public class MembershipService implements MembershipServiceInterface {
                     || "referral_commission".equals(feature);
         }
 
-        // FAMILY用户可用功能
+
         if ("FAMILY".equals(level)) {
             return "free_activities".equals(feature)
                     || "free_courses".equals(feature)

+ 191 - 11
cfc-backend/src/main/resources/schema.sql

@@ -1189,7 +1189,7 @@ CREATE TABLE IF NOT EXISTS products (
     family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除',
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
-    min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
+    min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -2508,6 +2508,11 @@ CREATE TABLE IF NOT EXISTS indicator_definitions (
     options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
     unit VARCHAR(50) COMMENT '单位',
     ref_range VARCHAR(200) COMMENT '参考范围',
+    source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
+    report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
+    value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
+    good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
+    dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
     sort_order INT DEFAULT 0 COMMENT '排序',
     status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -2523,9 +2528,17 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     value VARCHAR(200) COMMENT '值',
     numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
     remark TEXT COMMENT '备注',
+    subject_id BIGINT COMMENT '观测对象(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
+    report_id BIGINT COMMENT '来源报告(health_reports.id)',
+    report_date DATE COMMENT '真实报告日期(时序查询键)',
+    unit VARCHAR(32) COMMENT '观测单位',
+    status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
     recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
     INDEX idx_source (source_type, source_id),
-    INDEX idx_definition (definition_id)
+    INDEX idx_definition (definition_id),
+    INDEX idx_subject_def_date (subject_id, definition_id, report_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
 
 -- ========== 舌诊主记录表 ==========
@@ -4080,6 +4093,27 @@ CREATE TABLE IF NOT EXISTS report_food_suitability (
     INDEX idx_food (food_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告食材适宜度';
 
+-- ============================================================
+-- 家庭共餐选中食材表(持久化共餐场景食材选择)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    food_id BIGINT NOT NULL COMMENT '食材ID',
+    food_name VARCHAR(100) COMMENT '食材名称',
+    score INT COMMENT '推荐分',
+    reason VARCHAR(255) COMMENT '推荐理由',
+    category VARCHAR(50) COMMENT '分类',
+    calories_per_100g INT COMMENT '每100克卡路里',
+    cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)',
+    nutrition_score INT COMMENT '营养密度分(0-100)',
+    balance_reason VARCHAR(255) COMMENT '均衡化推荐理由',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_food (family_id, food_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材';
 
 -- ============================================================
 -- 积分兑换商品表(迁移124)
@@ -5686,14 +5720,14 @@ CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒';
 
 -- ============================================================
--- BASIC 体验会员(迁移311-313)
--- =====================================================
+-- BASIC 体验会员(迁移360 恢复,对应提交 f86dd164b 的反向还原)
+-- ============================================================
 
-INSERT IGNORE INTO membership_levels 
-(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) 
+INSERT IGNORE INTO membership_levels
+(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day)
 VALUES (
-    'BASIC', 
-    '体验会员', 
+    'BASIC',
+    '体验会员',
     '9.9元/月体验版,解锁核心功能,限次使用AI与报告解读',
     990,
     '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite","referral_commission"]',
@@ -5705,12 +5739,14 @@ INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name,
 ('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1),
 ('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4),
 ('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3),
-('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8);
+('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测试', '📅', '#E0E7FF', 8);
+
+-- ============================================================
 
 CREATE TABLE IF NOT EXISTS basic_quota_usage (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id BIGINT NOT NULL COMMENT '家长用户ID(非family_id)',
-    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/REPORT_READING/ASSESSMENT_QUOTA',
+    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/REPORT_READING/ASSESSMENT_QUOTA/FOOD_RECOGNIZE',
     used_count INT DEFAULT 0 COMMENT '本月已用次数',
     total_count INT NOT NULL COMMENT '月度总配额',
     period_start DATE NOT NULL COMMENT '周期开始日期(每月1号)',
@@ -5719,7 +5755,34 @@ CREATE TABLE IF NOT EXISTS basic_quota_usage (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start),
     INDEX idx_user_period (user_id, period_start)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度配额使用记录';
+
+-- 菌群健康调研项目(单次付费问卷)
+CREATE TABLE IF NOT EXISTS gut_health_projects (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '购买用户ID',
+    family_id BIGINT COMMENT '关联家庭ID',
+    member_id BIGINT COMMENT '测评对象(家庭成员ID)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paused/completed',
+    questions JSON COMMENT '本次问卷题目+答案JSON',
+    health_recommendations JSON COMMENT 'AI生成的健康建议',
+    risk_scores JSON COMMENT '各维度风险评分(0-100)',
+    microbe_test_recommended TINYINT DEFAULT 0 COMMENT '是否推荐菌群检测',
+    paid_at DATETIME COMMENT '付费时间',
+    completed_at DATETIME COMMENT '完成时间',
+    -- 新增字段:问卷完成后的奖励资格
+    member_discount_qualified TINYINT DEFAULT 0 COMMENT '是否获得会员价购买菌群检测资格',
+    family_membership_qualified TINYINT DEFAULT 0 COMMENT '是否获得一年家庭会员资格',
+    family_membership_activated TINYINT DEFAULT 0 COMMENT '是否已激活家庭会员',
+    -- 新增字段:打卡任务系统
+    cf_refund_amount INT DEFAULT 0 COMMENT 'CF值返还金额(分),19.9元=1990分',
+    cf_refunded TINYINT DEFAULT 0 COMMENT 'CF值是否已返还',
+    completed_tasks_count INT DEFAULT 0 COMMENT '已完成任务数/总任务数',
+    action_tasks JSON COMMENT 'AI生成的一周行动任务列表',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_user_created (user_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群健康调研项目';
 
 -- DOA 个人目标-周计划-周复盘(迁移318,家庭打卡个人目标模块)
 -- ============================================================
@@ -6092,3 +6155,120 @@ CREATE TABLE IF NOT EXISTS task_reminder_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_user (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表';
+
+-- 迁移355: 减脂专题 - 体重记录表
+CREATE TABLE IF NOT EXISTS fat_weight_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    weight DECIMAL(5,2) NOT NULL COMMENT '体重(kg)',
+    body_fat_percent DECIMAL(4,2) DEFAULT NULL COMMENT '体脂率(%)',
+    waist_circumference DECIMAL(4,2) DEFAULT NULL COMMENT '腰围(cm)',
+    measure_date DATE NOT NULL COMMENT '测量日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_weight_member (member_id, measure_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录';
+
+-- 迁移356: 减脂专题 - 饮食记录表
+CREATE TABLE IF NOT EXISTS fat_diet_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    meal_type VARCHAR(20) NOT NULL COMMENT 'meal_type: breakfast/lunch/dinner/snack',
+    food_name VARCHAR(255) NOT NULL COMMENT '食物名称',
+    food_weight DECIMAL(6,2) DEFAULT NULL COMMENT '食物重量(克)',
+    calories INT DEFAULT 0 COMMENT '热量(kcal)',
+    protein DECIMAL(6,2) DEFAULT 0 COMMENT '蛋白质(g)',
+    fat DECIMAL(6,2) DEFAULT 0 COMMENT '脂肪(g)',
+    carbs DECIMAL(6,2) DEFAULT 0 COMMENT '碳水(g)',
+    eat_date DATE NOT NULL COMMENT '进餐日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_diet_member (member_id, eat_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录';
+
+-- 迁移357: 减脂专题 - 运动记录表
+CREATE TABLE IF NOT EXISTS fat_exercise_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    exercise_type VARCHAR(50) NOT NULL COMMENT '运动类型',
+    duration_min INT DEFAULT 0 COMMENT '运动时长(分钟)',
+    distance_km DECIMAL(5,2) DEFAULT NULL COMMENT '距离(km)',
+    calories_burned INT DEFAULT 0 COMMENT '消耗热量(kcal)',
+    exercise_date DATE NOT NULL COMMENT '运动日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_exercise_member (member_id, exercise_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录';
+
+-- 迁移358: 减脂专题 - 减脂目标表
+CREATE TABLE IF NOT EXISTS fat_goal (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    target_weight DECIMAL(5,2) NOT NULL COMMENT '目标体重(kg)',
+    start_weight DECIMAL(5,2) NOT NULL COMMENT '初始体重(kg)',
+    target_body_fat DECIMAL(4,2) DEFAULT NULL COMMENT '目标体脂率(%)',
+    target_date DATE DEFAULT NULL COMMENT '目标达成日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/completed/abandoned',
+    start_date DATE NOT NULL COMMENT '开始日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_fat_goal_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标';
+-- 迁移359: 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)
+-- cfc 与 train 共用本库(zxyj)。签发方写入 ticket,目标方原子占用(used 0→1)后按 cfc_user_id 建立登录态。
+CREATE TABLE IF NOT EXISTS sso_ticket (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)',
+    source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train',
+    target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train',
+    source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)',
+    cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据',
+    used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用',
+    expire_at DATETIME NOT NULL COMMENT '过期时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_ticket (ticket),
+    INDEX idx_expire (expire_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';
+
+-- 迁移359: 指标归一化映射规则(连接各来源原始名与统一 indicator_definitions)
+CREATE TABLE IF NOT EXISTS indicator_mapping_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension',
+    raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)',
+    alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配',
+    definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id',
+    match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created',
+    priority INT DEFAULT 0 COMMENT '多规则冲突时取最高',
+    status TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_raw (source_type, raw_name),
+    INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则';
+
+-- 迁移360: 指标关联关系(人工定义 + 数据校准)
+CREATE TABLE IF NOT EXISTS indicator_correlations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标',
+    definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标',
+    relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source',
+    direction VARCHAR(10) NOT NULL COMMENT 'positive|negative',
+    manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1',
+    computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)',
+    method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman',
+    sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数',
+    confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)',
+    effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual',
+    manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖',
+    cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending',
+    computed_at DATETIME NULL COMMENT '最后校准时间',
+    created_by BIGINT NULL COMMENT '创建者',
+    description VARCHAR(500) NULL COMMENT '给用户看的解释',
+    source_note VARCHAR(255) NULL COMMENT '依据/出处',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_pair (definition_id_a, definition_id_b),
+    INDEX idx_def_a (definition_id_a),
+    INDEX idx_def_b (definition_id_b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';

+ 3 - 0
cfc-frontend/pages/diet/record-detail.vue

@@ -74,8 +74,10 @@
 
 <script>
 import { getDietDailyRecords } from '@/utils/api.js'
+import shareMixin from '@/components/share-mixin.js'
 
 export default {
+  mixins: [shareMixin],
   data() {
     return {
       recordId: null,
@@ -106,6 +108,7 @@ export default {
             self.record = record
             self.parseAIFoods(record.aiRecognizedFoods)
             self.parseConfirmedFoods(record.userConfirmedFoods)
+            self.setShareInfo('饮食打卡记录', '/pages/diet/record-detail?id=' + self.recordId)
           }
         }
       })

+ 3 - 0
cfc-frontend/pages/diet/records.vue

@@ -177,8 +177,10 @@
 <script>
 import { getMealList, getMealDaily, getMealWeekly, getMealAlerts, getCautionFamilyMembers, createMealRecord } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
+import shareMixin from '../../components/share-mixin.js'
 
 export default {
+  mixins: [shareMixin],
   data() {
     return {
       memberId: null,
@@ -202,6 +204,7 @@ export default {
     this.loadWeekly()
     this.loadAlerts()
     this.loadDayRecords()
+    this.setShareInfo('孩子的每日饮食打卡记录', '/pages/diet/records')
   },
   methods: {
     goBack: function() { uni.navigateBack() },

+ 139 - 5
cfc-frontend/pages/growth/diet-checkin.vue

@@ -131,15 +131,41 @@
       <!-- 底部留白 -->
       <view class="bottom-spacer"></view>
     </scroll-view>
+
+    <!-- 图片识别结果确认弹窗 -->
+    <view class="recognize-overlay" v-if="showRecognizeModal" @tap="cancelRecognize">
+      <view class="recognize-modal" @tap.stop>
+        <view class="recognize-header">
+          <text class="recognize-title">🍽️ 识别结果</text>
+          <text class="recognize-count">{{ recognizedFoods.length }} 种食材</text>
+        </view>
+        <view class="recognize-body">
+          <view class="food-chip" v-for="(f, idx) in recognizedFoods" :key="getFoodsKey(f, idx)">
+            <text class="food-chip-name">{{ f.name }}</text>
+            <text class="food-chip-cat" v-if="f.category && f.category !== 'unknown'">{{ f.category }}</text>
+            <text class="food-chip-del" @tap="removeRecognizedFood(idx)">×</text>
+          </view>
+          <view class="recognize-hint">
+            <text>可手动删除不需要的食材,确认后自动填入食物清单</text>
+          </view>
+        </view>
+        <view class="recognize-footer">
+          <view class="btn-cancel" @tap="cancelRecognize"><text>取消</text></view>
+          <view class="btn-confirm" @tap="confirmRecognize"><text>✓ 确认填入</text></view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
 import config from '../../config'
 import { parseDate } from '../../utils/format.js'
-import { sttTranscribe } from '../../utils/api.js'
+import { sttTranscribe, recognizeDietFood } from '../../utils/api.js'
+import shareMixin from '../../components/share-mixin.js'
 
 export default {
+  mixins: [shareMixin],
   data() {
     return {
       memberId: null,
@@ -151,6 +177,8 @@ export default {
       recorderManager: null,
       history: [],
       todayText: '',
+      showRecognizeModal: false,
+      recognizedFoods: [],
       mealTypes: [
         { label: '早餐', value: 'breakfast' },
         { label: '午餐', value: 'lunch' },
@@ -179,6 +207,7 @@ export default {
   },
     onLoad: function(options) {
     this.todayText = this.getToday()
+    this.setShareInfo('我在用浠艾福记录孩子的每日饮食,一起来打卡吧', '/pages/growth/diet-checkin')
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     if (role !== 'child') {
       this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
@@ -205,6 +234,10 @@ export default {
       uni.showLoading({ title: '语音识别中...', mask: true })
       sttTranscribe(res.tempFilePath).then(function(text) {
         self.form.voiceText = text
+        // 语音转写成功:若食物清单为空则自动填入
+        if (!self.form.foodItems || !self.form.foodItems.trim()) {
+          self.form.foodItems = text.trim()
+        }
         uni.hideLoading()
         // 保存时停止录音 → 语音已就绪,继续提交
         if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
@@ -265,10 +298,36 @@ export default {
         count: 9 - self.form.photos.length,
         sizeType: ['compressed'],
         sourceType: ['album', 'camera'],
-        success: function(res) {
-          res.tempFilePaths.forEach(function(path) {
-            if (self.form.photos.length < 9) self.form.photos.push(path)
-          })
+        success: async function(res) {
+          for (var i = 0; i < res.tempFilePaths.length; i++) {
+            if (self.form.photos.length >= 9) break
+            var path = res.tempFilePaths[i]
+            self.form.photos.push(path)
+            try {
+              var url = await self.uploadFile(path)
+              if (url) {
+                var recognizeRes = await new Promise(function(resolve) {
+                  uni.request({
+                    url: config.API_BASE_URL + '/api/diet/record/recognize',
+                    method: 'POST',
+                    data: { image_url: url },
+                    header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+                    success: function(r) { resolve(r) },
+                    fail: function() { resolve({ data: { code: 500 } }) }
+                  })
+                })
+                if (recognizeRes.data && recognizeRes.data.code === 200 && recognizeRes.data.data && recognizeRes.data.data.foods) {
+                  var foods = recognizeRes.data.data.foods
+                  for (var f = 0; f < foods.length; f++) {
+                    self.recognizedFoods.push({ name: foods[f].name, category: foods[f].category })
+                  }
+                }
+              }
+            } catch (e) { /* ignore */ }
+          }
+          if (self.recognizedFoods.length > 0) {
+            self.showRecognizeResult()
+          }
         }
       })
     },
@@ -299,6 +358,38 @@ export default {
     deleteVoice: function() {
       this.form.voicePath = ''
     },
+    showRecognizeResult: function() {
+      this.showRecognizeModal = true
+    },
+    confirmRecognize: function() {
+      var self = this
+      var names = []
+      for (var i = 0; i < self.recognizedFoods.length; i++) {
+        if (self.recognizedFoods[i].name && self.recognizedFoods[i].name.trim()) {
+          names.push(self.recognizedFoods[i].name.trim())
+        }
+      }
+      if (names.length > 0) {
+        var newText = names.join('、')
+        if (self.form.foodItems && self.form.foodItems.trim()) {
+          self.form.foodItems = self.form.foodItems + '、' + newText
+        } else {
+          self.form.foodItems = newText
+        }
+      }
+      self.showRecognizeModal = false
+      self.recognizedFoods = []
+    },
+    cancelRecognize: function() {
+      this.showRecognizeModal = false
+      this.recognizedFoods = []
+    },
+    removeRecognizedFood: function(idx) {
+      this.recognizedFoods.splice(idx, 1)
+    },
+    getFoodsKey: function(item, idx) {
+      return item.name || ('food' + idx)
+    },
     uploadFile: function(filePath) {
       var self = this
       return new Promise(function(resolve, reject) {
@@ -477,4 +568,47 @@ success: function(res) {
 
 /* ===== 底部留白 ===== */
 .bottom-spacer { height: 40rpx; }
+
+/* ===== 识别结果弹窗 ===== */
+.recognize-overlay {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.45); z-index: 999;
+  display: flex; align-items: center; justify-content: center;
+}
+.recognize-modal {
+  width: 600rpx; background: #fff; border-radius: 28rpx;
+  overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(0,0,0,0.18);
+}
+.recognize-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 28rpx 32rpx 20rpx; border-bottom: 1rpx solid #f0f0f0;
+}
+.recognize-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.recognize-count { font-size: 24rpx; color: #F97316; font-weight: 500; }
+.recognize-body { padding: 24rpx 32rpx; max-height: 420rpx; overflow-y: auto; }
+.food-chip {
+  display: inline-flex; align-items: center; gap: 8rpx;
+  background: #FFF3E0; border: 1rpx solid #FFE0B2; border-radius: 24rpx;
+  padding: 8rpx 20rpx; margin: 6rpx 8rpx 6rpx 0;
+}
+.food-chip-name { font-size: 26rpx; color: #E65100; font-weight: 500; }
+.food-chip-cat { font-size: 20rpx; color: #999; }
+.food-chip-del {
+  width: 32rpx; height: 32rpx; display: flex; align-items: center; justify-content: center;
+  background: #eee; border-radius: 50%; color: #999; font-size: 24rpx;
+}
+.recognize-hint {
+  margin-top: 16rpx; padding: 12rpx 16rpx; background: #FAFAFA;
+  border-radius: 12rpx;
+}
+.recognize-hint text { font-size: 22rpx; color: #999; }
+.recognize-footer {
+  display: flex; padding: 20rpx 32rpx 32rpx; gap: 20rpx;
+}
+.btn-cancel, .btn-confirm {
+  flex: 1; text-align: center; padding: 18rpx 0; border-radius: 14rpx;
+  font-size: 28rpx; font-weight: 600;
+}
+.btn-cancel { background: #f5f5f5; color: #666; }
+.btn-confirm { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25); }
 </style>

+ 55 - 0
docs/superpowers/specs/2026-10-11-restore-basic-and-diet-share-design.md

@@ -0,0 +1,55 @@
+# 恢复 BASIC 体验会员 + 饮食打卡分享 + 图片解析配额
+
+日期:2026-10-11
+状态:实现中
+
+## 背景
+
+- 提交 `f86dd164b`(2026-10-09)「remove BASIC trial membership」移除了 9.9 元/月的 BASIC 体验会员等级:
+  - 删除 `membership_levels` 的 BASIC 行、`membership_benefits` 的 4 条 BASIC 权益;
+  - 将存量 `users.member_level='BASIC'` 迁移为 `FREE`;
+  - `BasicQuotaService.consumeQuota` 去掉 `isActiveBasicMember` 前置校验;
+  - `MembershipService.listActiveBasicMemberUserIds()` 改为返回空列表;
+  - `backfillMembershipLevelOrder()`(迁移 351)level_order 改为 FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5。
+- 需求:**恢复 9.9 元/月 BASIC 体验会员级别**;**饮食打卡分享**(他人点击分享注册后自动绑定推荐人);**图片解析(食材识别)改为需 BASIC 会员并做配额校验**。
+
+## 恢复范围(依据 `f86dd164b` 反向还原)
+
+### 1. BASIC 会员等级(数据库)
+- `membership_levels` 重新插入 BASIC:
+  - `level_name='体验会员'`,`level_desc='9.9元/月体验版,解锁核心功能,限次使用AI与报告解读'`,`price_monthly=990`,
+    `features='["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite","referral_commission"]'`,
+    `max_children=3`,`max_tasks_per_day=3`,`level_order=2`。
+- `membership_benefits` 重新插入 4 条:
+  - `AI_CHAT_LITE`(限3次/月)、`REPORT_READING_LITE`(限1次/月)、`GROWTH_PLAN_LITE`、`ASSESSMENT_QUOTA_LITE`(限1次)。
+- `level_order` 还原为 **FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6**(相对顺序不变,仅在 FREE 与 FAMILY 之间插入 BASIC)。
+
+### 2. 服务层还原
+- `BasicQuotaService`:恢复 `isActiveBasicMember(userId)`、`getExpireTime(userId)`、`refreshMonthlyQuota()` 完整实现。
+- `MembershipService`:
+  - `canUseFeature` 恢复 BASIC 分支;
+  - `getMemberLevel` 个人会员到期判断恢复 `FAMILY || BASIC`;
+  - `listActiveBasicMemberUserIds()` 恢复真实查询(供到期提醒定时任务使用);
+  - `activateMembership` 开通 BASIC 时调用 `activationQuotaService.activateBasicQuota(userId, familyId)` 初始化当月配额。
+- `ActivationQuotaService.activateBasicQuota` 仍存在,无需改动。
+
+### 3. 图片解析配额(新需求)
+- 新增配额类型 `FOOD_RECOGNIZE`,BASIC 每月 3 次。
+- 规则(`BasicQuotaService.consumeFoodRecognizeQuota(userId)`):
+  - `level_order > BASIC`(FAMILY/PROVIDER/PREMIUM/LIFETIME)→ 不限次放行;
+  - `level_order == BASIC` → 走 `consumeQuota(userId, "FOOD_RECOGNIZE")`;
+  - `level_order < BASIC`(FREE)→ 拒绝并提示升级。
+- `DietRecordController./recognize` 注入 `BasicQuotaService`,取 `@RequestAttribute("userId")` 后调用 `consumeFoodRecognizeQuota(userId)`;失败返回 `Result.error(403, toast)`(`AiGateway`/`DietRecordService` 签名不变)。
+
+## 饮食打卡分享
+- 复用既有分享链路:`share-mixin.js`(`setShareInfo` + `loadReferralCode`)、`App.vue.captureReferralParams/handleScene`、`login.vue.handleReferralBind`、`getShareQrCode`。
+- 在 `pages/growth/diet-checkin.vue`、`pages/diet/records.vue`、`pages/diet/record-detail.vue` 接入 `shareMixin` 并调用 `onShareAppMessage` / 生成带 `refCode` 的分享链接,使新用户点击后注册自动绑定推荐人。
+
+## 迁移编号
+- 现有最新迁移:`迁移359`(sso_ticket)。
+- 本次新增:`迁移360 restoreBasicMembershipLevel()`(幂等:INSERT IGNORE + 幂等 UPDATE level_order)。
+
+## 风险
+- `level_order` 全局位移会影响商品/分类可见性、会员折扣判定、有效等级解析。相对顺序保持不变,仅插入 BASIC,按代码(level_code)匹配的逻辑不受影响。
+- 不恢复 `consumeQuota` 内原有的 `isActiveBasicMember` 前置校验(避免误伤 FREE/FAMILY 的 AI 对话),图片解析的会员门槛改由 `consumeFoodRecognizeQuota` 显式控制。
+- 生产库需确认是否存在历史 BASIC 用户,本迁移不自动回填(用户已迁移为 FREE,需运营确认是否回溯)。