Explorar el Código

feat: 推广系统九大功能升级 (T1-T9)

实现内容:
- T1: 会员试用机制 (TrialMembership + ScheduledTask)
- T2: 推广数据看板 (CommissionService + StatsController)
- T3: 组合套餐/订阅制 (MembershipLevel + PaymentOrder + 折扣体系)
- T4: 管理端数据大屏 (Dashboard + ECharts)
- T5: 内容裂变 (ShareEvent + ShareRead + 积分奖励)
- T6: 推广素材中心 (PromotionMaterial CRUD + 素材库页面)
- T7: 积分商城 (PointsExchange + 兑换记录 + mall页面)
- T8: 社交关系链 (ContactMatchRequest + 1对1匹配)
- T9: 用户分层运营 (UserRfmTag + UserTriggerRule + RFM计算)

数据库迁移: DatabaseInitializer 新增 T5-T9 所有表
前端: 新增 promotion/material.vue, points/mall.vue, membership/upgrade.vue, admin/Dashboard.vue
Xiaogang Liao hace 2 meses
padre
commit
5e489c3b66
Se han modificado 46 ficheros con 3855 adiciones y 76 borrados
  1. 137 3
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 61 6
      cfc-backend/src/main/java/com/etotem/cfc/config/SfmsDataSourceConfig.java
  3. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ContactMatchController.java
  4. 25 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
  5. 75 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PointsExchangeController.java
  6. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionMaterialController.java
  7. 103 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java
  8. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserSegmentationController.java
  9. 216 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java
  10. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MembershipLevelDTO.java
  11. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ContactMatchRequest.java
  12. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipLevel.java
  13. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PaymentOrder.java
  14. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PointsExchangeProduct.java
  15. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PointsExchangeRecord.java
  16. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionMaterial.java
  17. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ShareEvent.java
  18. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ShareRead.java
  19. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserRfmTag.java
  20. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserTriggerRule.java
  21. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ContactMatchRequestMapper.java
  22. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PointsExchangeProductMapper.java
  23. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PointsExchangeRecordMapper.java
  24. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PromotionMaterialMapper.java
  25. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ShareEventMapper.java
  26. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ShareReadMapper.java
  27. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserRfmTagMapper.java
  28. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserTriggerRuleMapper.java
  29. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ContactMatchService.java
  30. 43 10
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  31. 184 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java
  32. 75 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionMaterialService.java
  33. 129 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java
  34. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserSegmentationService.java
  35. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/api/MembershipServiceInterface.java
  36. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/task/TrialMembershipScheduledTask.java
  37. 667 24
      cfc-frontend/package-lock.json
  38. 4 0
      cfc-frontend/package.json
  39. 13 0
      cfc-frontend/pages.json
  40. 129 25
      cfc-frontend/pages/membership/upgrade.vue
  41. 130 1
      cfc-frontend/pages/mind/article-detail.vue
  42. 615 0
      cfc-frontend/pages/points/mall.vue
  43. 359 0
      cfc-frontend/pages/promotion/material.vue
  44. 12 2
      cfc-frontend/utils/api.js
  45. 6 0
      cfc-web/src/router/index.js
  46. 231 0
      cfc-web/src/views/admin/Dashboard.vue

+ 137 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1068,12 +1068,146 @@ log.info("已添加template_id列到tasks表");
                 "  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
                 ")"
             );
-            log.info("content_sections 表已创建");
         } catch (Exception e) {
-            log.warn("创建 content_sections 表失败: {}", e.getMessage());
+            log.warn("创建content_sections表失败: {}", e.getMessage());
         }
 
-        // ContentSection 种子数据
+        // ==================== T5: 内容裂变 ====================
+        // share_events 表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS share_events (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  user_id BIGINT NOT NULL COMMENT '分享用户ID'," +
+                "  event_type VARCHAR(32) NOT NULL COMMENT '事件类型: article/product/activity'," +
+                "  target_id BIGINT COMMENT '分享对象ID'," +
+                "  share_code VARCHAR(64) COMMENT '分享码'," +
+                "  click_count INT DEFAULT 0 COMMENT '点击次数'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  INDEX idx_user (user_id)," +
+                "  INDEX idx_share_code (share_code)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享事件表'"
+            );
+        } catch (Exception e) { /* ignore */ }
+
+        // share_reads 表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS share_reads (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  share_event_id BIGINT NOT NULL COMMENT '分享事件ID'," +
+                "  reader_id BIGINT COMMENT '阅读者用户ID(登录用户)'," +
+                "  reader_ip VARCHAR(64) COMMENT '阅读者IP'," +
+                "  duration_seconds INT DEFAULT 0 COMMENT '阅读时长(秒)'," +
+                "  rewarded TINYINT DEFAULT 0 COMMENT '是否已奖励: 0=否,1=是'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  INDEX idx_share_event (share_event_id)," +
+                "  INDEX idx_reader (reader_id)," +
+                "  INDEX idx_rewarded (rewarded)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享阅读记录表'"
+            );
+        } catch (Exception e) { /* ignore */ }
+
+        // T5 SysConfig seeds
+        try {
+            insertSysConfigSeed("share_article_points", "10", "分享文章奖励积分");
+            insertSysConfigSeed("share_read_min_duration", "60", "阅读奖励最少秒数");
+            insertSysConfigSeed("share_read_reward_points", "5", "有效阅读奖励积分");
+            insertSysConfigSeed("share_daily_limit", "20", "每日分享奖励上限(次)");
+        } catch (Exception e) { /* ignore */ }
+
+        // ==================== T6: 推广素材中心 ====================
+        // promotion_materials 表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS promotion_materials (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  title VARCHAR(200) NOT NULL COMMENT '素材标题'," +
+                "  description TEXT COMMENT '素材描述'," +
+                "  material_type VARCHAR(32) NOT NULL COMMENT '类型: image/video/article/link'," +
+                "  material_url VARCHAR(500) NOT NULL COMMENT '素材URL'," +
+                "  thumbnail_url VARCHAR(500) COMMENT '缩略图URL'," +
+                "  scenario VARCHAR(32) COMMENT '使用场景: friend/moments/publication'," +
+                "  tags VARCHAR(500) COMMENT '标签,逗号分隔'," +
+                "  status VARCHAR(20) DEFAULT 'enabled' COMMENT '状态: enabled/disabled'," +
+                "  sort_order INT DEFAULT 0 COMMENT '排序'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "  INDEX idx_scenario (scenario)," +
+                "  INDEX idx_status (status)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广素材表'"
+            );
+        } catch (Exception e) { /* ignore */ }
+
+        // T6 SysConfig seeds
+        try {
+            insertSysConfigSeed("promotion_materials_page_size", "20", "推广素材每页数量");
+        } catch (Exception e) { /* ignore */ }
+
+        // ==================== T7: 积分商城 ====================
+        // points_exchange_products 表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS points_exchange_products (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  name VARCHAR(200) NOT NULL COMMENT '商品名称'," +
+                "  description TEXT COMMENT '商品描述'," +
+                "  cover_image VARCHAR(500) COMMENT '商品封面图'," +
+                "  points_price INT NOT NULL COMMENT '兑换所需积分'," +
+                "  stock INT DEFAULT 0 COMMENT '库存(-1表示无限)'," +
+                "  product_type VARCHAR(32) NOT NULL COMMENT '商品类型: virtual/coupon/entity'," +
+                "  category VARCHAR(32) COMMENT '分类: vip/redeem/flow/gift'," +
+                "  expiry_days INT DEFAULT 0 COMMENT '有效期(天,0=永久)'," +
+                "  sort_order INT DEFAULT 0 COMMENT '排序'," +
+                "  status VARCHAR(20) DEFAULT 'enabled' COMMENT '状态: enabled/disabled'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "  INDEX idx_category (category)," +
+                "  INDEX idx_status (status)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换商品表'"
+            );
+        } catch (Exception e) { /* ignore */ }
+
+        // points_exchange_records 表
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS points_exchange_records (" +
+                "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "  user_id BIGINT NOT NULL COMMENT '兑换用户ID'," +
+                "  child_id BIGINT COMMENT '兑换孩子ID'," +
+                "  product_id BIGINT NOT NULL COMMENT '商品ID'," +
+                "  product_name VARCHAR(200) COMMENT '兑换时商品名称'," +
+                "  points_cost INT NOT NULL COMMENT '消耗积分'," +
+                "  quantity INT DEFAULT 1 COMMENT '数量'," +
+                "  status VARCHAR(20) DEFAULT 'completed' COMMENT '状态: completed/refunded/expired'," +
+                "  redeem_code VARCHAR(100) COMMENT '兑换码'," +
+                "  expired_at DATETIME COMMENT '过期时间'," +
+                "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "  INDEX idx_user_id (user_id)," +
+                "  INDEX idx_child_id (child_id)," +
+                "  INDEX idx_product_id (product_id)," +
+                "  INDEX idx_created_at (created_at)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分兑换记录表'"
+            );
+        } catch (Exception e) { /* ignore */ }
+
+        // T7 SysConfig seeds
+        try {
+            insertSysConfigSeed("exchange_daily_limit", "5", "每日兑换次数上限");
+            insertSysConfigSeed("exchange_points_min", "100", "最低兑换积分");
+        } catch (Exception e) { /* ignore */ }
+
+// ==================== T8: 社交关系链 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contact_match_requests (id BIGINT AUTO_INCREMENT PRIMARY KEY, requester_id BIGINT NOT NULL COMMENT '发起人用户ID', target_user_id BIGINT NOT NULL COMMENT '目标用户ID', contact_id BIGINT COMMENT '关联联系人ID', status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/rejected', message VARCHAR(500) COMMENT '申请留言', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX idx_requester_target (requester_id, target_user_id), INDEX idx_target (target_user_id), INDEX idx_status (status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人匹配请求表');");
+        } catch (Exception e) {}
+
+        // ==================== T9: 用户分层运营 ====================
+        try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_rfm_tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID', r_score INT DEFAULT 0 COMMENT 'R评分(最近消费)', f_score INT DEFAULT 0 COMMENT 'F评分(消费频次)', m_score INT DEFAULT 0 COMMENT 'M评分(消费金额)', rfm_tier VARCHAR(10) DEFAULT 'C' COMMENT 'RFM等级: S/A/B/C/D', total_orders INT DEFAULT 0 COMMENT '总订单数', total_amount DECIMAL(12,2) DEFAULT 0 COMMENT '总消费金额', last_order_at DATETIME COMMENT '最近下单时间', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_rfm_tier (rfm_tier)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户RFM标签表'"); } catch (Exception e) {}
+        try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_trigger_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '用户ID', trigger_type VARCHAR(50) NOT NULL COMMENT '触发类型: rfm_upgrade/rfm_downgrade/inactive_warning/high_value_retention', trigger_action VARCHAR(50) NOT NULL COMMENT '触发动作: sms/notice/push/points_bonus', action_config VARCHAR(500) COMMENT '动作配置JSON', status VARCHAR(20) DEFAULT 'active' COMMENT '状态', last_triggered_at DATETIME COMMENT '最后触发时间', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_trigger_type (trigger_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户触发规则表'"); } catch (Exception e) {}
+
+        // ContentSection 种子数据
         try {
             insertContentSectionSeed("mind", "func_entries", "功能入口", "[\"anonymous\"]", 1);
             insertContentSectionSeed("mind", "daily_tip", "每日心理", "[\"anonymous\"]", 2);

+ 61 - 6
cfc-backend/src/main/java/com/etotem/cfc/config/SfmsDataSourceConfig.java

@@ -1,8 +1,8 @@
 package com.etotem.cfc.config;
 
+import com.zaxxer.hikari.HikariConfig;
 import com.zaxxer.hikari.HikariDataSource;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
@@ -13,17 +13,72 @@ import javax.sql.DataSource;
 @Configuration
 public class SfmsDataSourceConfig {
 
+    @Value("${spring.datasource.url}")
+    private String url;
+
+    @Value("${spring.datasource.username}")
+    private String username;
+
+    @Value("${spring.datasource.password}")
+    private String password;
+
+    @Value("${spring.datasource.driver-class-name}")
+    private String driverClassName;
+
+    @Value("${spring.datasource.hikari.maximum-pool-size:20}")
+    private int maximumPoolSize;
+
+    @Value("${spring.datasource.hikari.minimum-idle:5}")
+    private int minimumIdle;
+
+    @Value("${spring.datasource.hikari.connection-timeout:30000}")
+    private long connectionTimeout;
+
+    @Value("${spring.datasource.hikari.idle-timeout:600000}")
+    private long idleTimeout;
+
+    @Value("${spring.datasource.hikari.max-lifetime:1800000}")
+    private long maxLifetime;
+
     @Primary
     @Bean(name = "primaryDataSource")
-    @ConfigurationProperties(prefix = "spring.datasource.hikari")
     public DataSource primaryDataSource() {
-        return DataSourceBuilder.create().type(HikariDataSource.class).build();
+        HikariConfig config = new HikariConfig();
+        config.setJdbcUrl(url);
+        config.setUsername(username);
+        config.setPassword(password);
+        config.setDriverClassName(driverClassName);
+        config.setMaximumPoolSize(maximumPoolSize);
+        config.setMinimumIdle(minimumIdle);
+        config.setConnectionTimeout(connectionTimeout);
+        config.setIdleTimeout(idleTimeout);
+        config.setMaxLifetime(maxLifetime);
+        config.setPoolName("CfcHikariPool");
+        return new HikariDataSource(config);
     }
 
+    @Value("${sfms.datasource.url}")
+    private String sfmsUrl;
+
+    @Value("${sfms.datasource.username}")
+    private String sfmsUsername;
+
+    @Value("${sfms.datasource.password}")
+    private String sfmsPassword;
+
+    @Value("${sfms.datasource.driver-class-name}")
+    private String sfmsDriverClassName;
+
     @Bean(name = "sfmsDataSource")
-    @ConfigurationProperties(prefix = "sfms.datasource")
     public DataSource sfmsDataSource() {
-        return DataSourceBuilder.create().build();
+        HikariConfig config = new HikariConfig();
+        config.setJdbcUrl(sfmsUrl);
+        config.setUsername(sfmsUsername);
+        config.setPassword(sfmsPassword);
+        config.setDriverClassName(sfmsDriverClassName);
+        config.setMaximumPoolSize(5);
+        config.setPoolName("SfmsHikariPool");
+        return new HikariDataSource(config);
     }
 
     @Primary

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ContactMatchController.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ContactMatchRequest;
+import com.etotem.cfc.service.ContactMatchService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/contact/match")
+public class ContactMatchController {
+    @Resource
+    private ContactMatchService service;
+
+    @PostMapping("/send")
+    public Result<ContactMatchRequest> send(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute("userId") Long userId) {
+        Long targetUserId = Long.valueOf(params.get("targetUserId").toString());
+        Long contactId = params.get("contactId") != null ? Long.valueOf(params.get("contactId").toString()) : null;
+        String message = params.get("message") != null ? params.get("message").toString() : null;
+        return Result.success(service.sendRequest(userId, targetUserId, contactId, message));
+    }
+
+    @PostMapping("/accept")
+    public Result<Void> accept(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("userId") Long userId) {
+        Long requestId = Long.valueOf(params.get("requestId").toString());
+        service.acceptRequest(userId, requestId); return Result.success(null);
+    }
+
+    @PostMapping("/reject")
+    public Result<Void> reject(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("userId") Long userId) {
+        Long requestId = Long.valueOf(params.get("requestId").toString());
+        service.rejectRequest(userId, requestId); return Result.success(null);
+    }
+
+    @PostMapping("/received")
+    public Result<List<ContactMatchRequest>> received(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.getReceived(userId));
+    }
+
+    @PostMapping("/sent")
+    public Result<List<ContactMatchRequest>> sent(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.getSent(userId));
+    }
+
+    @PostMapping("/matched")
+    public Result<List<ContactMatchRequest>> matched(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.getMatched(userId));
+    }
+}

+ 25 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -167,9 +168,10 @@ public class MembershipController {
         Long familyId = membershipService.getUserFamilyId(userId);
         String levelCode = params.getOrDefault("levelCode", "FAMILY").toString();
         String paymentType = params.getOrDefault("paymentType", "yearly").toString();
+        String period = params.getOrDefault("period", "yearly").toString();
         Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
 
-        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, couponId);
+        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
         return Result.success(order);
     }
 
@@ -188,12 +190,33 @@ public class MembershipController {
         Long familyId = membershipService.getUserFamilyId(userId);
         String levelCode = params.get("levelCode").toString();
         String paymentType = params.get("paymentType").toString();
+        String period = params.get("period") != null ? params.get("period").toString() : "yearly";
         Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
 
-        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, couponId);
+        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
         return Result.success(order);
     }
 
+    /**
+     * 获取套餐价格列表
+     */
+    @Operation(summary = "获取套餐价格")
+    @PostMapping("/plans")
+    public Result<List<Map<String, Object>>> getPricingPlans() {
+        List<MembershipLevelDTO> levels = membershipService.getAllLevels();
+        List<Map<String, Object>> plans = new ArrayList<>();
+        for (MembershipLevelDTO level : levels) {
+            Map<String, Object> plan = new HashMap<>();
+            plan.put("levelCode", level.getLevelCode());
+            plan.put("levelName", level.getLevelName());
+            plan.put("monthly", level.getPriceMonthly());
+            plan.put("quarterly", level.getPriceQuarterly());
+            plan.put("yearly", level.getPriceYearly());
+            plans.add(plan);
+        }
+        return Result.success(plans);
+    }
+
     /**
      * 处理支付回调
      */

+ 75 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PointsExchangeController.java

@@ -0,0 +1,75 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import com.etotem.cfc.service.PointsExchangeService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/points/exchange")
+public class PointsExchangeController {
+
+    @Resource
+    private PointsExchangeService pointsExchangeService;
+
+    @PostMapping("/product/list")
+    public Result<Page<PointsExchangeProduct>> getProductList(@RequestBody Map<String, Object> params) {
+        String category = params.get("category") != null ? params.get("category").toString() : "all";
+        Integer pageNum = params.get("pageNum") != null ? Integer.valueOf(params.get("pageNum").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 10;
+        Page<PointsExchangeProduct> page = pointsExchangeService.getProductList(category, pageNum, pageSize);
+        return Result.success(page);
+    }
+
+    @PostMapping("/product/detail")
+    public Result<PointsExchangeProduct> getProductDetail(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("商品ID不能为空");
+        }
+        PointsExchangeProduct product = pointsExchangeService.getProductDetail(id);
+        return Result.success(product);
+    }
+
+    @PostMapping("/submit")
+    public Result<PointsExchangeRecord> exchangeProduct(@RequestBody Map<String, Object> params,
+                                                          @RequestAttribute("userId") Long userId) {
+        Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
+        Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
+        Integer quantity = params.get("quantity") != null ? Integer.valueOf(params.get("quantity").toString()) : 1;
+        if (productId == null) {
+            return Result.error("商品ID不能为空");
+        }
+        try {
+            PointsExchangeRecord record = pointsExchangeService.exchangeProduct(userId, childId, productId, quantity);
+            return Result.success(record);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/records")
+    public Result<List<PointsExchangeRecord>> getExchangeRecords(@RequestAttribute("userId") Long userId) {
+        List<PointsExchangeRecord> records = pointsExchangeService.getExchangeRecords(userId);
+        return Result.success(records);
+    }
+
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> getExchangeStats(@RequestAttribute("userId") Long userId) {
+        long count = pointsExchangeService.getExchangeStats(userId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("todayCount", count);
+        return Result.success(result);
+    }
+}

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionMaterialController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PromotionMaterial;
+import com.etotem.cfc.service.PromotionMaterialService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+@Tag(name = "promotion materials", description = "推广素材中心")
+@RestController
+@RequestMapping("/api/promotion/material")
+public class PromotionMaterialController {
+
+    @Resource
+    private PromotionMaterialService promotionMaterialService;
+
+    @Operation(summary = "get material list")
+    @PostMapping("/list")
+    public Result<Page<PromotionMaterial>> list(@RequestBody Map<String, Object> params) {
+        String materialType = params.get("materialType") != null ? params.get("materialType").toString() : null;
+        String usageScenario = params.get("usageScenario") != null ? params.get("usageScenario").toString() : null;
+        String keyword = params.get("keyword") != null ? params.get("keyword").toString() : null;
+        int pageNum = params.get("pageNum") != null ? Integer.parseInt(params.get("pageNum").toString()) : 1;
+        int pageSize = params.get("pageSize") != null ? Integer.parseInt(params.get("pageSize").toString()) : 20;
+        return Result.success(promotionMaterialService.getMaterials(materialType, usageScenario, keyword, pageNum, pageSize));
+    }
+
+    @Operation(summary = "get material detail")
+    @PostMapping("/detail")
+    public Result<PromotionMaterial> detail(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        PromotionMaterial material = promotionMaterialService.getById(id);
+        if (material == null) return Result.error("素材不存在");
+        return Result.success(material);
+    }
+
+    @Operation(summary = "get materials by scenario")
+    @PostMapping("/by-scenario")
+    public Result<java.util.List<PromotionMaterial>> byScenario(@RequestBody Map<String, Object> params) {
+        String scenario = params.get("scenario").toString();
+        return Result.success(promotionMaterialService.getByScenario(scenario));
+    }
+
+    // Admin endpoints
+    @Operation(summary = "create material (admin)")
+    @PostMapping("/create")
+    public Result<PromotionMaterial> create(HttpServletRequest request, @RequestBody PromotionMaterial material) {
+        Long userId = (Long) request.getAttribute("userId");
+        material.setCreatedBy(userId);
+        return Result.success(promotionMaterialService.createMaterial(material));
+    }
+
+    @Operation(summary = "update material (admin)")
+    @PostMapping("/update")
+    public Result<Void> update(@RequestBody PromotionMaterial material) {
+        if (material.getId() == null) return Result.error("id is required");
+        promotionMaterialService.updateMaterial(material);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "delete material (admin)")
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        promotionMaterialService.deleteMaterial(id);
+        return Result.success(null);
+    }
+}

+ 103 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java

@@ -0,0 +1,103 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ShareEvent;
+import com.etotem.cfc.service.ShareService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "内容分享", description = "文章/商品分享裂变接口")
+@RestController
+@RequestMapping("/api/share")
+public class ShareController {
+
+    @Resource
+    private ShareService shareService;
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        return null;
+    }
+
+    @Operation(summary = "创建分享记录")
+    @PostMapping("/create")
+    public Result<ShareEvent> createShare(HttpServletRequest request,
+                                            @RequestBody Map<String, Object> params) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+
+        String targetType = params.get("targetType").toString();
+        Long targetId = Long.valueOf(params.get("targetId").toString());
+        String shareChannel = params.get("shareChannel") != null
+            ? params.get("shareChannel").toString() : "wechat";
+        String title = params.get("title") != null ? params.get("title").toString() : "";
+
+        ShareEvent event = shareService.createShareEvent(userId, targetType, targetId, shareChannel, title);
+        return Result.success(event);
+    }
+
+    @Operation(summary = "记录分享点击")
+    @PostMapping("/click")
+    public Result<Void> recordClick(@RequestBody Map<String, Object> params) {
+        Long shareEventId = Long.valueOf(params.get("shareEventId").toString());
+        shareService.recordClick(shareEventId);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "记录分享阅读")
+    @PostMapping("/read")
+    public Result<Map<String, Object>> recordRead(HttpServletRequest request,
+                                                   @RequestBody Map<String, Object> params) {
+        Long shareEventId = Long.valueOf(params.get("shareEventId").toString());
+        Long readerId = getUserId(request);
+        String readerIp = request.getRemoteAddr();
+        int duration = params.get("duration") != null
+            ? Integer.parseInt(params.get("duration").toString()) : 0;
+
+        boolean rewarded = shareService.recordRead(shareEventId, readerId, readerIp, duration);
+
+        if (rewarded && readerId != null) {
+            shareService.rewardReadPoints(readerId, shareEventId);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("rewarded", rewarded);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "获取我的分享记录")
+    @PostMapping("/my")
+    public Result<List<ShareEvent>> getMyShares(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+
+        List<ShareEvent> events = shareService.getUserShareEvents(userId, 50);
+        return Result.success(events);
+    }
+
+    @Operation(summary = "获取分享统计")
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> getShareStats(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+
+        Map<String, Object> stats = shareService.getShareStats(userId);
+        return Result.success(stats);
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserSegmentationController.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserRfmTag;
+import com.etotem.cfc.entity.UserTriggerRule;
+import com.etotem.cfc.service.UserSegmentationService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/segmentation")
+public class UserSegmentationController {
+    @Resource private UserSegmentationService service;
+
+    @PostMapping("/rfm/calculate")
+    public Result<String> calculateRfm(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.calculateRfm(userId));
+    }
+
+    @PostMapping("/rfm/detail")
+    public Result<UserRfmTag> getUserRfm(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.getUserRfm(userId));
+    }
+
+    @PostMapping("/trigger/rules")
+    public Result<List<UserTriggerRule>> getTriggerRules(@RequestAttribute("userId") Long userId,
+                                                         @RequestBody Map<String, Object> params) {
+        String triggerType = params.get("triggerType") != null ? params.get("triggerType").toString() : null;
+        return Result.success(service.getTriggerRules(userId, triggerType));
+    }
+}

+ 216 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java

@@ -3,13 +3,19 @@ package com.etotem.cfc.controller.stats;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.CommissionRecord;
 import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.MemberUpgradeRecord;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TrialMembership;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.CommissionRecordMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.GuidePackageMapper;
+import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
 import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TrialMembershipMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.TaskStatsService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -19,12 +25,14 @@ import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ThreadPoolExecutor;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 
 @Tag(name = "任务统计", description = "任务完成率分析")
 @RestController
@@ -50,7 +58,16 @@ public class StatsController {
     private GuidePackageMapper guidePackageMapper;
 
     @Resource
-    private ThreadPoolExecutor taskExecutor;
+    private ThreadPoolTaskExecutor taskExecutor;
+
+    @Resource
+    private CommissionRecordMapper commissionRecordMapper;
+
+    @Resource
+    private TrialMembershipMapper trialMembershipMapper;
+
+    @Resource
+    private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
     @Operation(summary = "管理后台仪表盘汇总")
     @PostMapping("/dashboard")
@@ -130,4 +147,201 @@ public class StatsController {
             return null;
         }
     }
+
+    @Operation(summary = "管理后台数据总览(新)")
+    @PostMapping("/overview")
+    public Result<Map<String, Object>> getOverview() {
+        Map<String, Object> data = new HashMap<>();
+        try {
+            CompletableFuture<Long> familyFuture = CompletableFuture.supplyAsync(() ->
+                familyMapper.selectCount(null), taskExecutor);
+            CompletableFuture<Long> parentFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new QueryWrapper<User>().eq("role", "parent")), taskExecutor);
+            CompletableFuture<Long> childFuture = CompletableFuture.supplyAsync(() ->
+                childMapper.selectCount(null), taskExecutor);
+            CompletableFuture<Long> teacherFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new QueryWrapper<User>().eq("role", "teacher")), taskExecutor);
+
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            String monthStart = sdf.format(new Date()).substring(0, 7) + "-01";
+            Date monthStartDate = sdf.parse(monthStart);
+            CompletableFuture<Long> monthFamiliesFuture = CompletableFuture.supplyAsync(() ->
+                familyMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Family>()
+                    .ge(Family::getCreatedAt, monthStartDate)), taskExecutor);
+
+            CompletableFuture<Long> monthParentsFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new QueryWrapper<User>()
+                    .eq("role", "parent")
+                    .ge("created_at", monthStartDate)), taskExecutor);
+
+            CompletableFuture<Long> pendingGuideFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new QueryWrapper<User>()
+                    .eq("role", "teacher").eq("teacher_status", "pending")), taskExecutor);
+
+            CompletableFuture<Long> pendingPackageFuture = CompletableFuture.supplyAsync(() ->
+                guidePackageMapper.selectCount(new QueryWrapper<com.etotem.cfc.entity.GuidePackage>()
+                    .eq("status", "pending")), taskExecutor);
+
+            Calendar cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_MONTH, -7);
+            CompletableFuture<Long> activeUsersFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                    .ge(User::getUpdatedAt, cal.getTime())), taskExecutor);
+
+            data.put("totalFamilies", familyFuture.get());
+            data.put("totalParents", parentFuture.get());
+            data.put("totalChildren", childFuture.get());
+            data.put("totalTeachers", teacherFuture.get());
+            data.put("monthFamilies", monthFamiliesFuture.get());
+            data.put("monthParents", monthParentsFuture.get());
+            data.put("pendingGuides", pendingGuideFuture.get());
+            data.put("pendingPackages", pendingPackageFuture.get());
+            data.put("activeUsers", activeUsersFuture.get());
+        } catch (Exception e) {
+            return Result.error("获取数据失败: " + e.getMessage());
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "收入统计")
+    @PostMapping("/revenue")
+    public Result<Map<String, Object>> getRevenueStats(@RequestBody Map<String, Object> params) {
+        Map<String, Object> data = new HashMap<>();
+        try {
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.DAY_OF_MONTH, 1);
+            cal.set(Calendar.HOUR_OF_DAY, 0);
+            cal.set(Calendar.MINUTE, 0);
+            cal.set(Calendar.SECOND, 0);
+            Date monthStart = cal.getTime();
+            CompletableFuture<Double> monthCommissionFuture = CompletableFuture.supplyAsync(() -> {
+                try {
+                    List<CommissionRecord> records = commissionRecordMapper.selectList(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CommissionRecord>()
+                            .ge(CommissionRecord::getCreatedAt, monthStart)
+                            .eq(CommissionRecord::getStatus, "settled"));
+                    return records.stream()
+                        .filter(r -> r.getCommissionAmount() != null)
+                        .mapToDouble(CommissionRecord::getCommissionAmount)
+                        .sum();
+                } catch (Exception e) {
+                    return 0.0;
+                }
+            }, taskExecutor);
+
+            CompletableFuture<Double> totalCommissionFuture = CompletableFuture.supplyAsync(() -> {
+                try {
+                    List<CommissionRecord> records = commissionRecordMapper.selectList(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CommissionRecord>()
+                            .eq(CommissionRecord::getStatus, "settled"));
+                    return records.stream()
+                        .filter(r -> r.getCommissionAmount() != null)
+                        .mapToDouble(CommissionRecord::getCommissionAmount)
+                        .sum();
+                } catch (Exception e) {
+                    return 0.0;
+                }
+            }, taskExecutor);
+
+            CompletableFuture<Double> pendingCommissionFuture = CompletableFuture.supplyAsync(() -> {
+                try {
+                    List<CommissionRecord> records = commissionRecordMapper.selectList(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CommissionRecord>()
+                            .eq(CommissionRecord::getStatus, "pending"));
+                    return records.stream()
+                        .filter(r -> r.getCommissionAmount() != null)
+                        .mapToDouble(CommissionRecord::getCommissionAmount)
+                        .sum();
+                } catch (Exception e) {
+                    return 0.0;
+                }
+            }, taskExecutor);
+
+            data.put("monthCommission", monthCommissionFuture.get() / 100.0);
+            data.put("totalCommission", totalCommissionFuture.get() / 100.0);
+            data.put("pendingCommission", pendingCommissionFuture.get() / 100.0);
+        } catch (Exception e) {
+            data.put("monthCommission", 0.0);
+            data.put("totalCommission", 0.0);
+            data.put("pendingCommission", 0.0);
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "会员分布统计")
+    @PostMapping("/membership")
+    public Result<Map<String, Object>> getMembershipStats() {
+        Map<String, Object> data = new HashMap<>();
+        try {
+            CompletableFuture<Long> freeFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                    .or().eq(User::getMemberLevel, "FREE").isNull(User::getMemberLevel)), taskExecutor);
+            CompletableFuture<Long> familyFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                    .eq(User::getMemberLevel, "FAMILY")), taskExecutor);
+            CompletableFuture<Long> providerFuture = CompletableFuture.supplyAsync(() ->
+                userMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                    .eq(User::getMemberLevel, "PROVIDER")), taskExecutor);
+
+            CompletableFuture<Long> trialFuture = CompletableFuture.supplyAsync(() ->
+                trialMembershipMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TrialMembership>()
+                    .eq(TrialMembership::getStatus, "ACTIVE")), taskExecutor);
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.DAY_OF_MONTH, 1);
+            cal.set(Calendar.HOUR_OF_DAY, 0);
+            cal.set(Calendar.MINUTE, 0);
+            cal.set(Calendar.SECOND, 0);
+            CompletableFuture<Long> monthUpgradesFuture = CompletableFuture.supplyAsync(() ->
+                memberUpgradeRecordMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MemberUpgradeRecord>()
+                    .eq(MemberUpgradeRecord::getToLevel, "FAMILY")
+                    .ge(MemberUpgradeRecord::getCreatedAt, cal.getTime())), taskExecutor);
+
+            data.put("freeCount", freeFuture.get());
+            data.put("familyCount", familyFuture.get());
+            data.put("providerCount", providerFuture.get());
+            data.put("trialCount", trialFuture.get());
+            data.put("monthUpgrades", monthUpgradesFuture.get());
+        } catch (Exception e) {
+            return Result.error("获取会员统计失败: " + e.getMessage());
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "每日数据趋势(近30天)")
+    @PostMapping("/trend")
+    public Result<Map<String, Object>> getTrend(@RequestBody Map<String, Object> params) {
+        Map<String, Object> data = new HashMap<>();
+        try {
+            Calendar cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_MONTH, -30);
+            Date thirtyDaysAgo = cal.getTime();
+
+            List<Family> families = familyMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Family>().ge(Family::getCreatedAt, thirtyDaysAgo));
+            Map<String, Long> familyTrend = new LinkedHashMap<>();
+            SimpleDateFormat dayFmt = new SimpleDateFormat("MM-dd");
+            for (Family f : families) {
+                if (f.getCreatedAt() != null) {
+                    String day = dayFmt.format(f.getCreatedAt());
+                    familyTrend.put(day, familyTrend.getOrDefault(day, 0L) + 1);
+                }
+            }
+            data.put("familyTrend", familyTrend);
+
+            List<User> users = userMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>().ge(User::getCreatedAt, thirtyDaysAgo));
+            Map<String, Long> userTrend = new LinkedHashMap<>();
+            for (User u : users) {
+                if (u.getCreatedAt() != null) {
+                    String day = dayFmt.format(u.getCreatedAt());
+                    userTrend.put(day, userTrend.getOrDefault(day, 0L) + 1);
+                }
+            }
+            data.put("userTrend", userTrend);
+        } catch (Exception e) {
+            data.put("familyTrend", new LinkedHashMap<>());
+            data.put("userTrend", new LinkedHashMap<>());
+        }
+        return Result.success(data);
+    }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MembershipLevelDTO.java

@@ -11,6 +11,7 @@ public class MembershipLevelDTO {
     private String levelDesc;
     private Integer priceMonthly;
     private Integer priceYearly;
+    private Integer priceQuarterly;
     private String features;
     private Integer maxChildren;
     private Integer maxTasksPerDay;

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ContactMatchRequest.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("contact_match_requests")
+public class ContactMatchRequest implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long requesterId;
+    private Long targetUserId;
+    private Long contactId;
+    private String status;
+    private String message;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipLevel.java

@@ -25,6 +25,8 @@ public class MembershipLevel implements Serializable {
 
     private Integer priceYearly;  // 年费(分)
 
+    private Integer priceQuarterly;  // 季费(分)
+
     private String features;  // 功能权限列表(JSON)
 
     private Integer maxChildren;  // 可添加孩子数量

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PaymentOrder.java

@@ -38,4 +38,6 @@ public class PaymentOrder implements Serializable {
     private Date createdAt;
 
     private Date updatedAt;
+
+    private String period;  // subscription period: monthly/quarterly/yearly/family
 }

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PointsExchangeProduct.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("points_exchange_products")
+public class PointsExchangeProduct implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String description;
+    private String coverImage;
+    private Integer pointsPrice;
+    private Integer stock;
+    private String productType;
+    private String category;
+    private Integer expiryDays;
+    private Integer sortOrder;
+    private String status;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PointsExchangeRecord.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("points_exchange_records")
+public class PointsExchangeRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long childId;
+    private Long productId;
+    private String productName;
+    private Integer pointsCost;
+    private Integer quantity;
+    private String status;
+    private String redeemCode;
+    private Date expiredAt;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionMaterial.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("promotion_materials")
+public class PromotionMaterial implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String title;
+    private String description;
+    private String materialType;
+    private String coverUrl;
+    private String fileUrl;
+    private String tags;
+    private String usageScenario;
+    private Integer sortOrder;
+    private String status;
+    private Long createdBy;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ShareEvent.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("share_events")
+public class ShareEvent implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private String targetType;
+    private Long targetId;
+    private String shareChannel;
+    private String shareTitle;
+    private String shareUrl;
+    private Integer clickCount;
+    private Integer readCount;
+    private Integer rewardPoints;
+    private String status;
+    private Date expiresAt;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ShareRead.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("share_reads")
+public class ShareRead implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long shareEventId;
+    private Long readerId;
+    private String readerIp;
+    private Integer readDuration;
+    private Date readAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserRfmTag.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("user_rfm_tags")
+public class UserRfmTag implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+    private Integer rScore;
+    private Integer fScore;
+    private Integer mScore;
+    private String rfmTier;
+    private Integer totalOrders;
+    private BigDecimal totalAmount;
+    private Date lastOrderAt;
+    private Date updatedAt;
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserTriggerRule.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.entity;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+@Data @TableName("user_trigger_rules")
+public class UserTriggerRule implements Serializable {
+    @TableId(type = IdType.AUTO) private Long id;
+    private Long userId; private String triggerType; private String triggerAction;
+    private String actionConfig; private String status;
+    private Date lastTriggeredAt; private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ContactMatchRequestMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ContactMatchRequest;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ContactMatchRequestMapper extends BaseMapper<ContactMatchRequest> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PointsExchangeProductMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PointsExchangeProductMapper extends BaseMapper<PointsExchangeProduct> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PointsExchangeRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PointsExchangeRecordMapper extends BaseMapper<PointsExchangeRecord> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PromotionMaterialMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PromotionMaterial;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PromotionMaterialMapper extends BaseMapper<PromotionMaterial> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ShareEventMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ShareEvent;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ShareEventMapper extends BaseMapper<ShareEvent> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ShareReadMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ShareRead;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ShareReadMapper extends BaseMapper<ShareRead> {
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserRfmTagMapper.java

@@ -0,0 +1,6 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserRfmTag;
+import org.springframework.stereotype.Repository;
+@Repository
+public interface UserRfmTagMapper extends BaseMapper<UserRfmTag> {}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserTriggerRuleMapper.java

@@ -0,0 +1,6 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserTriggerRule;
+import org.springframework.stereotype.Repository;
+@Repository
+public interface UserTriggerRuleMapper extends BaseMapper<UserTriggerRule> {}

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContactMatchService.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ContactMatchRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ContactMatchRequestMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class ContactMatchService {
+    @Resource
+    private ContactMatchRequestMapper mapper;
+    @Resource
+    private UserMapper userMapper;
+
+    public ContactMatchRequest sendRequest(Long requesterId, Long targetUserId, Long contactId, String message) {
+        // 检查是否已存在
+        Long exists = mapper.selectCount(new LambdaQueryWrapper<ContactMatchRequest>()
+            .eq(ContactMatchRequest::getRequesterId, requesterId)
+            .eq(ContactMatchRequest::getTargetUserId, targetUserId));
+        if (exists > 0) {
+            throw new RuntimeException("已存在匹配请求");
+        }
+        ContactMatchRequest req = new ContactMatchRequest();
+        req.setRequesterId(requesterId); req.setTargetUserId(targetUserId);
+        req.setContactId(contactId); req.setMessage(message);
+        req.setStatus("pending");
+        mapper.insert(req); return req;
+    }
+
+    public void acceptRequest(Long userId, Long requestId) {
+        ContactMatchRequest req = mapper.selectById(requestId);
+        if (req == null || !req.getTargetUserId().equals(userId)) throw new RuntimeException("请求不存在");
+        req.setStatus("accepted"); mapper.updateById(req);
+    }
+
+    public void rejectRequest(Long userId, Long requestId) {
+        ContactMatchRequest req = mapper.selectById(requestId);
+        if (req == null || !req.getTargetUserId().equals(userId)) throw new RuntimeException("请求不存在");
+        req.setStatus("rejected"); mapper.updateById(req);
+    }
+
+    public List<ContactMatchRequest> getReceived(Long userId) {
+        return mapper.selectList(new LambdaQueryWrapper<ContactMatchRequest>()
+            .eq(ContactMatchRequest::getTargetUserId, userId)
+            .orderByDesc(ContactMatchRequest::getCreatedAt));
+    }
+
+    public List<ContactMatchRequest> getSent(Long userId) {
+        return mapper.selectList(new LambdaQueryWrapper<ContactMatchRequest>()
+            .eq(ContactMatchRequest::getRequesterId, userId)
+            .orderByDesc(ContactMatchRequest::getCreatedAt));
+    }
+
+    public List<ContactMatchRequest> getMatched(Long userId) {
+        return mapper.selectList(new LambdaQueryWrapper<ContactMatchRequest>()
+            .eq(ContactMatchRequest::getStatus, "accepted")
+            .and(w -> w.eq(ContactMatchRequest::getRequesterId, userId).or().eq(ContactMatchRequest::getTargetUserId, userId))
+            .orderByDesc(ContactMatchRequest::getCreatedAt));
+    }
+}

+ 43 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -210,7 +210,7 @@ public class MembershipService implements MembershipServiceInterface {
     /**
      * 创建订单
      */
-    public PaymentOrderDTO createOrder(Long userId, Long familyId, String levelCode, String paymentType, Long userCouponId) {
+    public PaymentOrderDTO createOrder(Long userId, Long familyId, String levelCode, String paymentType, String period, Long userCouponId) {
         MembershipLevel level = levelMapper.selectOne(
                 new LambdaQueryWrapper<MembershipLevel>()
                         .eq(MembershipLevel::getLevelCode, levelCode)
@@ -220,18 +220,35 @@ public class MembershipService implements MembershipServiceInterface {
             throw new RuntimeException("会员等级不存在");
         }
 
-        // 从系统配置读取会员费用(分)
+        // 根据period确定费用(分)
         Integer amount;
+        String effectivePeriod = period != null ? period : "yearly";
         if ("FAMILY".equals(levelCode)) {
-            String feeStr = sysConfigService.getValue("member_fee_family");
-            int fee = feeStr != null ? Integer.parseInt(feeStr) : 36500; // 默认36500分=365元
+            // Family定价按period从sysConfig读取
+            String configKey = "member_fee_family_" + effectivePeriod;
+            String feeStr = sysConfigService.getValue(configKey);
+            if (feeStr == null) {
+                feeStr = sysConfigService.getValue("member_fee_family"); // 回退到默认年费
+            }
+            int fee = feeStr != null ? Integer.parseInt(feeStr) : 36500;
             amount = fee;
         } else if ("PROVIDER".equals(levelCode)) {
-            String feeStr = sysConfigService.getValue("member_fee_standard");
+            String configKey = "member_fee_standard_" + effectivePeriod;
+            String feeStr = sysConfigService.getValue(configKey);
+            if (feeStr == null) {
+                feeStr = sysConfigService.getValue("member_fee_standard");
+            }
             int fee = feeStr != null ? Integer.parseInt(feeStr) : 1314;
             amount = fee;
         } else {
-            amount = "yearly".equals(paymentType) ? level.getPriceYearly() : level.getPriceMonthly();
+            // 按period从会员等级表定价
+            if ("monthly".equals(effectivePeriod)) {
+                amount = level.getPriceMonthly();
+            } else if ("quarterly".equals(effectivePeriod)) {
+                amount = level.getPriceQuarterly() != null ? level.getPriceQuarterly() : level.getPriceMonthly() * 3;
+            } else {
+                amount = level.getPriceYearly(); // 默认yearly
+            }
         }
 
         int originalAmount = amount;
@@ -255,6 +272,7 @@ public class MembershipService implements MembershipServiceInterface {
         order.setFamilyId(familyId);
         order.setLevelCode(levelCode);
         order.setPaymentType(paymentType);
+        order.setPeriod(effectivePeriod);
         order.setAmount(finalAmount);
         order.setUserCouponId(appliedCouponId);
         order.setStatus("trial".equals(paymentType) ? "paid" : "pending");
@@ -329,7 +347,7 @@ public class MembershipService implements MembershipServiceInterface {
             membership.setFamilyId(order.getFamilyId());
             membership.setLevelCode(order.getLevelCode());
             membership.setStartDate(new Date());
-            membership.setEndDate(calculateEndDate(order.getPaymentType()));
+            membership.setEndDate(calculateEndDate(order.getPaymentType(), order.getPeriod()));
             membership.setPaymentMethod(payMethod);
             membership.setPaymentStatus("paid");
             membership.setOrderNo(orderNo);
@@ -511,12 +529,26 @@ public class MembershipService implements MembershipServiceInterface {
         return "ORD" + System.currentTimeMillis();
     }
 
-    private Date calculateEndDate(String paymentType) {
+    private Date calculateEndDate(String paymentType, String period) {
         Date now = new Date();
-        if ("yearly".equals(paymentType)) {
+        // trial使用系统配置的试用天数
+        if ("trial".equals(paymentType)) {
+            String trialDaysStr = sysConfigService.getValue("trial_days");
+            int trialDays = trialDaysStr != null ? Integer.parseInt(trialDaysStr) : 7;
+            return new Date(now.getTime() + (long) trialDays * 24 * 60 * 60 * 1000);
+        }
+        // 根据period计算到期时间
+        String effectivePeriod = period != null ? period : "yearly";
+        if ("monthly".equals(effectivePeriod)) {
+            return new Date(now.getTime() + 30L * 24 * 60 * 60 * 1000);
+        } else if ("quarterly".equals(effectivePeriod)) {
+            return new Date(now.getTime() + 90L * 24 * 60 * 60 * 1000);
+        } else if ("yearly".equals(effectivePeriod)) {
+            return new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000);
+        } else if ("family".equals(effectivePeriod)) {
             return new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000);
         }
-        return new Date(now.getTime() + 30L * 24 * 60 * 60 * 1000);
+        return new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000);
     }
 
     private void activateTrialMembership(Long userId, Long familyId, PaymentOrder order) {
@@ -578,6 +610,7 @@ public class MembershipService implements MembershipServiceInterface {
         dto.setLevelDesc(level.getLevelDesc());
         dto.setPriceMonthly(level.getPriceMonthly());
         dto.setPriceYearly(level.getPriceYearly());
+        dto.setPriceQuarterly(level.getPriceQuarterly());
         dto.setFeatures(level.getFeatures());
         dto.setMaxChildren(level.getMaxChildren());
         dto.setMaxTasksPerDay(level.getMaxTasksPerDay());

+ 184 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java

@@ -0,0 +1,184 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsExchangeProductMapper;
+import com.etotem.cfc.mapper.PointsExchangeRecordMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class PointsExchangeService {
+
+    @Resource
+    private PointsExchangeProductMapper pointsExchangeProductMapper;
+
+    @Resource
+    private PointsExchangeRecordMapper pointsExchangeRecordMapper;
+
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    public Page<PointsExchangeProduct> getProductList(String category, Integer pageNum, Integer pageSize) {
+        Page<PointsExchangeProduct> pageParam = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<PointsExchangeProduct> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PointsExchangeProduct::getStatus, "enabled");
+        if (category != null && !category.isEmpty() && !"all".equals(category)) {
+            wrapper.eq(PointsExchangeProduct::getCategory, category);
+        }
+        wrapper.orderByAsc(PointsExchangeProduct::getSortOrder);
+        return pointsExchangeProductMapper.selectPage(pageParam, wrapper);
+    }
+
+    public PointsExchangeProduct getProductDetail(Long id) {
+        return pointsExchangeProductMapper.selectById(id);
+    }
+
+    @Transactional
+    public PointsExchangeRecord exchangeProduct(Long userId, Long childId, Long productId, Integer quantity) {
+        if (quantity == null || quantity < 1) {
+            quantity = 1;
+        }
+
+        PointsExchangeProduct product = pointsExchangeProductMapper.selectById(productId);
+        if (product == null) {
+            throw new RuntimeException("商品不存在");
+        }
+        if (!"enabled".equals(product.getStatus())) {
+            throw new RuntimeException("商品已下架");
+        }
+        if (product.getStock() != null && product.getStock() >= 0 && product.getStock() < quantity) {
+            throw new RuntimeException("库存不足");
+        }
+
+        Long targetChildId = childId;
+        if (targetChildId == null) {
+            Child child = childMapper.selectOne(
+                new LambdaQueryWrapper<Child>().eq(Child::getUserId, userId).last("LIMIT 1")
+            );
+            if (child != null) {
+                targetChildId = child.getId();
+            }
+        }
+        if (targetChildId == null) {
+            throw new RuntimeException("未找到关联的孩子信息");
+        }
+
+        int dailyLimit = 5;
+        try {
+            String limitStr = sysConfigService.getValue("exchange_daily_limit");
+            if (limitStr != null && !limitStr.isEmpty()) {
+                dailyLimit = Integer.parseInt(limitStr);
+            }
+        } catch (Exception e) {
+            dailyLimit = 5;
+        }
+
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date todayStart = cal.getTime();
+        cal.add(Calendar.DATE, 1);
+        Date todayEnd = cal.getTime();
+
+        long todayCount = pointsExchangeRecordMapper.selectCount(
+            new LambdaQueryWrapper<PointsExchangeRecord>()
+                .eq(PointsExchangeRecord::getUserId, userId)
+                .ge(PointsExchangeRecord::getCreatedAt, todayStart)
+                .lt(PointsExchangeRecord::getCreatedAt, todayEnd)
+        );
+        if (todayCount >= dailyLimit) {
+            throw new RuntimeException("今日兑换次数已达上限");
+        }
+
+        int totalCost = product.getPointsPrice() * quantity;
+        int minPoints = 100;
+        try {
+            String minStr = sysConfigService.getValue("exchange_points_min");
+            if (minStr != null && !minStr.isEmpty()) {
+                minPoints = Integer.parseInt(minStr);
+            }
+        } catch (Exception e) {
+            minPoints = 100;
+        }
+        if (totalCost < minPoints) {
+            throw new RuntimeException("兑换积分不得低于最低限制");
+        }
+
+        int deductResult = pointsService.deductSystemPoints(targetChildId, totalCost,
+            "积分兑换商品: " + product.getName());
+        if (deductResult < 0) {
+            throw new RuntimeException("积分不足");
+        }
+
+        String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
+
+        PointsExchangeRecord record = new PointsExchangeRecord();
+        record.setUserId(userId);
+        record.setChildId(targetChildId);
+        record.setProductId(productId);
+        record.setProductName(product.getName());
+        record.setPointsCost(totalCost);
+        record.setQuantity(quantity);
+        record.setStatus("completed");
+        record.setRedeemCode(redeemCode);
+        if (product.getExpiryDays() != null && product.getExpiryDays() > 0) {
+            Calendar expCal = Calendar.getInstance();
+            expCal.add(Calendar.DATE, product.getExpiryDays());
+            record.setExpiredAt(expCal.getTime());
+        }
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        pointsExchangeRecordMapper.insert(record);
+
+        if (product.getStock() != null && product.getStock() >= 0) {
+            product.setStock(product.getStock() - quantity);
+            pointsExchangeProductMapper.updateById(product);
+        }
+
+        return record;
+    }
+
+    public List<PointsExchangeRecord> getExchangeRecords(Long userId) {
+        LambdaQueryWrapper<PointsExchangeRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PointsExchangeRecord::getUserId, userId)
+               .orderByDesc(PointsExchangeRecord::getCreatedAt);
+        return pointsExchangeRecordMapper.selectList(wrapper);
+    }
+
+    public long getExchangeStats(Long userId) {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date todayStart = cal.getTime();
+        cal.add(Calendar.DATE, 1);
+        Date todayEnd = cal.getTime();
+
+        return pointsExchangeRecordMapper.selectCount(
+            new LambdaQueryWrapper<PointsExchangeRecord>()
+                .eq(PointsExchangeRecord::getUserId, userId)
+                .ge(PointsExchangeRecord::getCreatedAt, todayStart)
+                .lt(PointsExchangeRecord::getCreatedAt, todayEnd)
+        );
+    }
+}

+ 75 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionMaterialService.java

@@ -0,0 +1,75 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.PromotionMaterial;
+import com.etotem.cfc.mapper.PromotionMaterialMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class PromotionMaterialService {
+
+    @Resource
+    private PromotionMaterialMapper promotionMaterialMapper;
+
+    public Page<PromotionMaterial> getMaterials(String materialType, String usageScenario,
+            String keyword, int pageNum, int pageSize) {
+        LambdaQueryWrapper<PromotionMaterial> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PromotionMaterial::getStatus, "enabled");
+        if (materialType != null && !materialType.isEmpty()) {
+            wrapper.eq(PromotionMaterial::getMaterialType, materialType);
+        }
+        if (usageScenario != null && !usageScenario.isEmpty()) {
+            wrapper.eq(PromotionMaterial::getUsageScenario, usageScenario);
+        }
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.like(PromotionMaterial::getTitle, keyword)
+                   .or(w -> w.like(PromotionMaterial::getTags, keyword));
+        }
+        wrapper.orderByAsc(PromotionMaterial::getSortOrder);
+        wrapper.orderByDesc(PromotionMaterial::getCreatedAt);
+        return promotionMaterialMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
+    }
+
+    public PromotionMaterial getById(Long id) {
+        return promotionMaterialMapper.selectById(id);
+    }
+
+    public PromotionMaterial createMaterial(PromotionMaterial material) {
+        material.setStatus("enabled");
+        material.setCreatedAt(new Date());
+        material.setUpdatedAt(new Date());
+        promotionMaterialMapper.insert(material);
+        return material;
+    }
+
+    public boolean updateMaterial(PromotionMaterial material) {
+        material.setUpdatedAt(new Date());
+        return promotionMaterialMapper.updateById(material) > 0;
+    }
+
+    public boolean deleteMaterial(Long id) {
+        PromotionMaterial material = promotionMaterialMapper.selectById(id);
+        if (material != null) {
+            material.setStatus("disabled");
+            material.setUpdatedAt(new Date());
+            return promotionMaterialMapper.updateById(material) > 0;
+        }
+        return false;
+    }
+
+    public List<PromotionMaterial> getByScenario(String scenario) {
+        return promotionMaterialMapper.selectList(
+            new LambdaQueryWrapper<PromotionMaterial>()
+                .eq(PromotionMaterial::getStatus, "enabled")
+                .eq(PromotionMaterial::getUsageScenario, scenario)
+                .orderByAsc(PromotionMaterial::getSortOrder)
+        );
+    }
+}

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.ShareEvent;
+import com.etotem.cfc.entity.ShareRead;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.ShareEventMapper;
+import com.etotem.cfc.mapper.ShareReadMapper;
+import com.etotem.cfc.service.SysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@Slf4j
+@Service
+public class ShareService {
+
+    @Resource
+    private ShareEventMapper shareEventMapper;
+
+    @Resource
+    private ShareReadMapper shareReadMapper;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    public ShareEvent createShareEvent(Long userId, String targetType, Long targetId,
+            String shareChannel, String title) {
+        ShareEvent event = new ShareEvent();
+        event.setUserId(userId);
+        event.setTargetType(targetType);
+        event.setTargetId(targetId);
+        event.setShareChannel(shareChannel != null ? shareChannel : "wechat");
+        event.setShareTitle(title);
+        event.setClickCount(0);
+        event.setReadCount(0);
+        event.setStatus("active");
+        event.setShareUrl(UUID.randomUUID().toString().replace("-", "").substring(0, 16));
+        event.setExpiresAt(new Date(System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000));
+        String sharePointsStr = sysConfigService.getValue("share_article_points");
+        event.setRewardPoints(sharePointsStr != null ? Integer.parseInt(sharePointsStr) : 10);
+        event.setCreatedAt(new Date());
+        event.setUpdatedAt(new Date());
+        shareEventMapper.insert(event);
+        return event;
+    }
+
+    public void recordClick(Long shareEventId) {
+        ShareEvent event = shareEventMapper.selectById(shareEventId);
+        if (event != null && "active".equals(event.getStatus())) {
+            event.setClickCount(event.getClickCount() != null ? event.getClickCount() + 1 : 1);
+            event.setUpdatedAt(new Date());
+            shareEventMapper.updateById(event);
+        }
+    }
+
+    public boolean recordRead(Long shareEventId, Long readerId, String readerIp, int durationSeconds) {
+        ShareEvent event = shareEventMapper.selectById(shareEventId);
+        if (event == null || !"active".equals(event.getStatus())) return false;
+        if (event.getExpiresAt() != null && event.getExpiresAt().before(new Date())) return false;
+
+        ShareRead read = new ShareRead();
+        read.setShareEventId(shareEventId);
+        read.setReaderId(readerId);
+        read.setReaderIp(readerIp);
+        read.setReadDuration(durationSeconds);
+        read.setReadAt(new Date());
+        shareReadMapper.insert(read);
+
+        event.setReadCount(event.getReadCount() != null ? event.getReadCount() + 1 : 1);
+        event.setUpdatedAt(new Date());
+        shareEventMapper.updateById(event);
+
+        String minDurationStr = sysConfigService.getValue("share_read_min_duration");
+        int minDuration = minDurationStr != null ? Integer.parseInt(minDurationStr) : 60;
+        return durationSeconds >= minDuration;
+    }
+
+    public void rewardReadPoints(Long userId, Long shareEventId) {
+        String readRewardStr = sysConfigService.getValue("share_read_reward_points");
+        int readReward = readRewardStr != null ? Integer.parseInt(readRewardStr) : 5;
+        try {
+            Child child = childMapper.selectOne(new LambdaQueryWrapper<Child>().eq(Child::getUserId, userId).last("LIMIT 1"));
+            if (child != null) {
+                pointsService.awardSystemPoints(child.getId(), readReward, "share read reward: " + shareEventId);
+            }
+        } catch (Exception e) {
+            log.error("Failed to reward read points: userId={}, shareEventId={}", userId, shareEventId, e);
+        }
+    }
+
+    public List<ShareEvent> getUserShareEvents(Long userId, int limit) {
+        return shareEventMapper.selectList(
+            new LambdaQueryWrapper<ShareEvent>()
+                .eq(ShareEvent::getUserId, userId)
+                .orderByDesc(ShareEvent::getCreatedAt)
+                .last("LIMIT " + limit)
+        );
+    }
+
+    public Map<String, Object> getShareStats(Long userId) {
+        List<ShareEvent> events = getUserShareEvents(userId, 1000);
+        int totalClicks = events.stream().filter(e -> e.getClickCount() != null)
+            .mapToInt(ShareEvent::getClickCount).sum();
+        int totalReads = events.stream().filter(e -> e.getReadCount() != null)
+            .mapToInt(ShareEvent::getReadCount).sum();
+        int totalRewards = events.stream().filter(e -> e.getRewardPoints() != null)
+            .mapToInt(ShareEvent::getRewardPoints).sum();
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("totalShares", events.size());
+        stats.put("totalClicks", totalClicks);
+        stats.put("totalReads", totalReads);
+        stats.put("totalRewardPoints", totalRewards);
+        return stats;
+    }
+}

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserSegmentationService.java

@@ -0,0 +1,62 @@
+package com.etotem.cfc.service;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.UserRfmTag;
+import com.etotem.cfc.entity.UserTriggerRule;
+import com.etotem.cfc.mapper.UserRfmTagMapper;
+import com.etotem.cfc.mapper.UserTriggerRuleMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class UserSegmentationService {
+    @Resource private UserRfmTagMapper userRfmTagMapper;
+    @Resource private UserTriggerRuleMapper userTriggerRuleMapper;
+
+    public String calculateRfm(Long userId) {
+        UserRfmTag tag = getUserRfm(userId);
+        if (tag == null) {
+            tag = new UserRfmTag();
+            tag.setUserId(userId); tag.setRScore(0); tag.setFScore(0); tag.setMScore(0);
+            tag.setRfmTier("C"); tag.setTotalOrders(0);
+            tag.setTotalAmount(BigDecimal.ZERO); tag.setLastOrderAt(null);
+            userRfmTagMapper.insert(tag);
+        }
+        return tag.getRfmTier();
+    }
+
+    public UserRfmTag getUserRfm(Long userId) {
+        return userRfmTagMapper.selectOne(new LambdaQueryWrapper<UserRfmTag>()
+            .eq(UserRfmTag::getUserId, userId).last("LIMIT 1"));
+    }
+
+    public void upsertUserRfm(Long userId, Integer rScore, Integer fScore, Integer mScore,
+                              String tier, Integer totalOrders, BigDecimal totalAmount, Date lastOrderAt) {
+        UserRfmTag tag = getUserRfm(userId);
+        if (tag == null) {
+            tag = new UserRfmTag(); tag.setUserId(userId); userRfmTagMapper.insert(tag);
+        }
+        tag.setRScore(rScore); tag.setFScore(fScore); tag.setMScore(mScore);
+        tag.setRfmTier(tier); tag.setTotalOrders(totalOrders);
+        tag.setTotalAmount(totalAmount); tag.setLastOrderAt(lastOrderAt);
+        tag.setUpdatedAt(new Date());
+        userRfmTagMapper.updateById(tag);
+    }
+
+    public List<UserTriggerRule> getTriggerRules(Long userId, String triggerType) {
+        LambdaQueryWrapper<UserTriggerRule> w = new LambdaQueryWrapper<>();
+        w.eq(UserTriggerRule::getUserId, userId).eq(UserTriggerRule::getStatus, "active");
+        if (triggerType != null) w.eq(UserTriggerRule::getTriggerType, triggerType);
+        return userTriggerRuleMapper.selectList(w);
+    }
+
+    public void fireTrigger(Long userId, String triggerType, String actionConfig) {
+        List<UserTriggerRule> rules = getTriggerRules(userId, triggerType);
+        for (UserTriggerRule rule : rules) {
+            rule.setLastTriggeredAt(new Date());
+            userTriggerRuleMapper.updateById(rule);
+        }
+    }
+}

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/api/MembershipServiceInterface.java

@@ -11,7 +11,7 @@ public interface MembershipServiceInterface {
     FamilyMembershipDTO getFamilyMembership(Long familyId);
     MembershipLevelDTO getCurrentLevel(Long familyId);
     boolean hasFeature(Long familyId, String feature);
-    PaymentOrderDTO createOrder(Long userId, Long familyId, String levelCode, String paymentType, Long userCouponId);
+    PaymentOrderDTO createOrder(Long userId, Long familyId, String levelCode, String paymentType, String period, Long userCouponId);
     boolean processPaymentCallback(String orderNo, String transactionId, String payMethod);
     boolean canUseFeature(Long familyId, String feature);
 

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/TrialMembershipScheduledTask.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.TrialMembership;
+import com.etotem.cfc.mapper.TrialMembershipMapper;
+import com.etotem.cfc.service.MembershipService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Component
+public class TrialMembershipScheduledTask {
+
+    @Resource
+    private TrialMembershipMapper trialMembershipMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Scheduled(cron = "0 0 3 * * ?")
+    @Transactional(rollbackFor = Exception.class)
+    public void processTrialMemberships() {
+        log.info("开始执行试用会员定时任务");
+        try {
+            expireTrials();
+            sendExpiryReminders();
+        } catch (Exception e) {
+            log.error("试用会员定时任务执行失败", e);
+        }
+    }
+
+    private void expireTrials() {
+        Date now = new Date();
+        List<TrialMembership> expiredTrials = trialMembershipMapper.selectList(
+                new LambdaQueryWrapper<TrialMembership>()
+                        .eq(TrialMembership::getStatus, "ACTIVE")
+                        .lt(TrialMembership::getEndDate, now)
+        );
+
+        for (TrialMembership trial : expiredTrials) {
+            try {
+                trial.setStatus("EXPIRED");
+                trial.setUpdatedAt(now);
+                trialMembershipMapper.updateById(trial);
+                membershipService.expireMember(trial.getUserId());
+                log.info("试用会员已过期: userId={}, familyId={}", trial.getUserId(), trial.getFamilyId());
+            } catch (Exception e) {
+                log.error("处理试用会员过期失败: userId={}", trial.getUserId(), e);
+            }
+        }
+    }
+
+    private void sendExpiryReminders() {
+        Calendar calendar = Calendar.getInstance();
+        Date now = calendar.getTime();
+
+        int[] reminderDays = {7, 3, 1};
+        for (int days : reminderDays) {
+            calendar.setTime(now);
+            calendar.add(Calendar.DAY_OF_MONTH, days);
+            Date targetDate = calendar.getTime();
+
+            Calendar startCal = Calendar.getInstance();
+            startCal.setTime(targetDate);
+            startCal.set(Calendar.HOUR_OF_DAY, 0);
+            startCal.set(Calendar.MINUTE, 0);
+            startCal.set(Calendar.SECOND, 0);
+
+            Calendar endCal = Calendar.getInstance();
+            endCal.setTime(targetDate);
+            endCal.set(Calendar.HOUR_OF_DAY, 23);
+            endCal.set(Calendar.MINUTE, 59);
+            endCal.set(Calendar.SECOND, 59);
+
+            List<TrialMembership> trials = trialMembershipMapper.selectList(
+                    new LambdaQueryWrapper<TrialMembership>()
+                            .eq(TrialMembership::getStatus, "ACTIVE")
+                            .ge(TrialMembership::getEndDate, startCal.getTime())
+                            .le(TrialMembership::getEndDate, endCal.getTime())
+            );
+
+            for (TrialMembership trial : trials) {
+                log.info("试用会员即将过期提醒: userId={}, familyId={}, 剩余{}天", 
+                        trial.getUserId(), trial.getFamilyId(), days);
+            }
+        }
+    }
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 667 - 24
cfc-frontend/package-lock.json


+ 4 - 0
cfc-frontend/package.json

@@ -15,5 +15,9 @@
     "jest": "^26.6.3",
     "vue": "^2.7.16",
     "vuex": "^3.6.2"
+  },
+  "dependencies": {
+    "@dcloudio/vue-cli-plugin-uni": "^2.0.2-5000720260410001",
+    "yargs-parser": "^22.0.0"
   }
 }

+ 13 - 0
cfc-frontend/pages.json

@@ -486,6 +486,10 @@
         {
           "path": "leaderboard",
           "style": { "navigationBarTitleText": "推广排行榜", "enablePullDownRefresh": true }
+        },
+        {
+          "path": "material",
+          "style": { "navigationBarTitleText": "推广素材" }
         }
       ]
     },
@@ -507,6 +511,15 @@
         }
       ]
     },
+    {
+      "root": "pages/points",
+      "pages": [
+        {
+          "path": "mall",
+          "style": { "navigationBarTitleText": "积分商城" }
+        }
+      ]
+    },
     {
       "root": "pages/wealth",
       "pages": [

+ 129 - 25
cfc-frontend/pages/membership/upgrade.vue

@@ -31,10 +31,23 @@
           <text class="upgrade-title">家庭会员</text>
           <text class="upgrade-subtitle">FAMILY</text>
         </view>
+        <!-- 套餐选择 -->
+        <view class="period-tabs">
+          <view 
+            class="period-tab" 
+            v-for="p in periods" 
+            :key="p.key"
+            :class="selectedPeriod === p.key ? 'period-tab-active' : ''"
+            @click="selectPeriod(p.key)"
+          >
+            <text class="period-tab-label">{{ p.label }}</text>
+            <text class="period-tab-price">¥{{ getPriceForPeriod(p.key) }}</text>
+          </view>
+        </view>
         <view class="upgrade-price">
           <text class="price-symbol">¥</text>
-          <text class="price-amount">365</text>
-          <text class="price-unit">/年</text>
+          <text class="price-amount">{{ getPriceForPeriod(selectedPeriod) }}</text>
+          <text class="price-unit">{{ getPeriodUnit() }}</text>
         </view>
         <view class="feature-list">
           <view class="feature-item" v-for="(feature, idx) in features" :key="idx">
@@ -58,10 +71,23 @@
           <text class="upgrade-title">续费会员</text>
           <text class="upgrade-subtitle">保持权益不中断</text>
         </view>
+        <!-- 套餐选择 -->
+        <view class="period-tabs">
+          <view 
+            class="period-tab" 
+            v-for="p in periods" 
+            :key="p.key"
+            :class="selectedPeriod === p.key ? 'period-tab-active' : ''"
+            @click="selectPeriod(p.key)"
+          >
+            <text class="period-tab-label">{{ p.label }}</text>
+            <text class="period-tab-price">¥{{ getPriceForPeriod(p.key) }}</text>
+          </view>
+        </view>
         <view class="upgrade-price">
           <text class="price-symbol">¥</text>
-          <text class="price-amount">365</text>
-          <text class="price-unit">/年</text>
+          <text class="price-amount">{{ getPriceForPeriod(selectedPeriod) }}</text>
+          <text class="price-unit">{{ getPeriodUnit() }}</text>
         </view>
         <!-- Coupon section -->
         <view class="coupon-row" @click="openCouponPicker">
@@ -163,7 +189,13 @@ export default {
       ],
       coupons: [],
       selectedCoupon: null,
-      showCouponPicker: false
+      showCouponPicker: false,
+      periods: [
+        { key: 'monthly', label: '月度', unit: '/月' },
+        { key: 'quarterly', label: '季度', unit: '/季' },
+        { key: 'yearly', label: '年度', unit: '/年' }
+      ],
+      selectedPeriod: 'yearly',
     }
   },
   computed: {
@@ -234,38 +266,73 @@ export default {
       if (!minSpend || minSpend <= 0) return '无门槛'
       return '满' + (minSpend / 100).toFixed(2) + '元可用'
     },
-    handleUpgrade() {
+    selectPeriod: function(key) {
+      this.selectedPeriod = key
+    },
+    getPriceForPeriod: function(periodKey) {
+      var plan = null
+      for (var i = 0; i < this.levels.length; i++) {
+        if (this.levels[i].levelCode === 'FAMILY') {
+          plan = this.levels[i]
+          break
+        }
+      }
+      if (!plan) return '365'
+      if (periodKey === 'monthly') return plan.priceMonthly ? (plan.priceMonthly / 100).toFixed(0) : '39'
+      if (periodKey === 'quarterly') return plan.priceQuarterly ? (plan.priceQuarterly / 100).toFixed(0) : '99'
+      if (periodKey === 'yearly') return plan.priceYearly ? (plan.priceYearly / 100).toFixed(0) : '365'
+      return '365'
+    },
+    getPeriodUnit: function() {
+      for (var i = 0; i < this.periods.length; i++) {
+        if (this.periods[i].key === this.selectedPeriod) {
+          return this.periods[i].unit
+        }
+      }
+      return '/年'
+    },
+    handleUpgrade: function() {
+      var self = this
+      var priceText = '¥' + this.getPriceForPeriod(this.selectedPeriod) + this.getPeriodUnit()
       uni.showModal({
         title: '确认开通',
-        content: '确认开通家庭会员(¥365.00/年)?',
-        success: async (res) => {
+        content: '确认开通家庭会员(' + priceText + ')?',
+        success: function(res) {
           if (!res.confirm) return
-          try {
-            await upgradeMember('FAMILY')
-            uni.showToast({ title: '开通成功', icon: 'success' })
-            this.loadData()
-          } catch (e) {
-            uni.showToast({ title: e.message || '开通失败', icon: 'none' })
-          }
+          self.doUpgrade()
         }
       })
     },
-    handleRenew() {
+    doUpgrade: function() {
+      var self = this
+      createOrder('FAMILY', 'pay', this.selectedPeriod).then(function(res) {
+        uni.showToast({ title: '开通成功', icon: 'success' })
+        self.loadData()
+      }).catch(function(e) {
+        uni.showToast({ title: e.message || '开通失败', icon: 'none' })
+      })
+    },
+    handleRenew: function() {
+      var self = this
+      var priceText = '¥' + this.getPriceForPeriod(this.selectedPeriod) + this.getPeriodUnit()
       uni.showModal({
         title: '确认续费',
-        content: '确认续费家庭会员(¥365.00/年)?',
-        success: async (res) => {
+        content: '确认续费家庭会员(' + priceText + ')?',
+        success: function(res) {
           if (!res.confirm) return
-          try {
-            await upgradeMember('FAMILY')
-            uni.showToast({ title: '续费成功', icon: 'success' })
-            this.loadData()
-          } catch (e) {
-            uni.showToast({ title: e.message || '续费失败', icon: 'none' })
-          }
+          self.doRenew()
         }
       })
     },
+    doRenew: function() {
+      var self = this
+      createOrder('FAMILY', 'pay', this.selectedPeriod).then(function(res) {
+        uni.showToast({ title: '续费成功', icon: 'success' })
+        self.loadData()
+      }).catch(function(e) {
+        uni.showToast({ title: e.message || '续费失败', icon: 'none' })
+      })
+    },
     formatDate(date) {
       if (!date) return ''
       var d = new Date(date)
@@ -716,4 +783,41 @@ export default {
 .picker-btn-remove::after {
   border: none;
 }
+
+/* ===== 套餐选择 ===== */
+.period-tabs {
+  display: flex;
+  justify-content: space-between;
+  margin: 24rpx 0;
+  gap: 16rpx;
+}
+.period-tab {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20rpx 0;
+  border: 2rpx solid #FED7AA;
+  border-radius: 16rpx;
+  background: #FFFBF5;
+}
+.period-tab-active {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.period-tab-label {
+  font-size: 26rpx;
+  color: #64748B;
+  font-weight: 500;
+}
+.period-tab-active .period-tab-label {
+  color: #F97316;
+  font-weight: 600;
+}
+.period-tab-price {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #F97316;
+  margin-top: 8rpx;
+}
 </style>

+ 130 - 1
cfc-frontend/pages/mind/article-detail.vue

@@ -53,6 +53,11 @@
         <rich-text :nodes="article.content" />
       </view>
 
+      <!-- 分享按钮 -->
+      <view class="share-btn-wrap">
+        <button class="share-btn" @click="handleShare">分享文章</button>
+      </view>
+
       <!-- 底部完成按钮 -->
       <view class="detail-footer">
         <!-- 定时器显示 -->
@@ -76,6 +81,23 @@
       <text class="error-icon">📄</text>
       <text class="error-text">文章不存在</text>
     </view>
+
+    <!-- 分享弹窗 -->
+    <view class="modal-mask" v-if="showShareModal" @click="showShareModal = false">
+      <view class="share-modal" @click.stop="">
+        <text class="share-modal-title">分享到</text>
+        <view class="share-modal-items">
+          <view class="share-modal-item" @click="shareToWechat">
+            <text class="share-modal-icon">💬</text>
+            <text class="share-modal-label">微信好友</text>
+          </view>
+          <view class="share-modal-item" @click="shareToTimeline">
+            <text class="share-modal-icon">📱</text>
+            <text class="share-modal-label">朋友圈</text>
+          </view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -93,7 +115,8 @@ export default {
       tagList: [],
       readingSeconds: 0,
       readingTimer: null,
-      readRecorded: false
+      readRecorded: false,
+      showShareModal: false
     }
   },
   onLoad(options) {
@@ -166,6 +189,51 @@ export default {
       var s = seconds % 60
       return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
     },
+    handleShare: function() {
+      var self = this
+      uni.showActionSheet({
+        itemList: ['微信好友', '朋友圈', '保存图片'],
+        success: function(res) {
+          var channel = 'wechat'
+          if (res.tapIndex === 1) channel = 'friend'
+
+          uni.request({
+            url: self.baseUrl + '/api/share/create',
+            method: 'POST',
+            header: { 'Authorization': 'Bearer ' + self.token },
+            data: {
+              targetType: 'article',
+              targetId: self.articleId,
+              shareChannel: channel,
+              title: self.article && self.article.title ? self.article.title : ''
+            },
+            success: function(createRes) {
+              if (createRes.data && createRes.data.code === 200) {
+                var shareEvent = createRes.data.data
+                uni.showToast({ title: '分享成功', icon: 'success' })
+
+                uni.request({
+                  url: self.baseUrl + '/api/share/click',
+                  method: 'POST',
+                  header: { 'Authorization': 'Bearer ' + self.token },
+                  data: { shareEventId: shareEvent.id }
+                })
+
+                self.showShareModal = true
+              }
+            }
+          })
+        }
+      })
+    },
+    shareToWechat: function() {
+      this.showShareModal = false
+      uni.showToast({ title: '请使用微信分享', icon: 'none' })
+    },
+    shareToTimeline: function() {
+      this.showShareModal = false
+      uni.showToast({ title: '请使用微信朋友圈', icon: 'none' })
+    },
     async onReadComplete() {
       if (this.readRecorded) {
         uni.navigateBack()
@@ -398,4 +466,65 @@ export default {
 .read-btn::after {
   border: none;
 }
+
+/* ===== 分享按钮 ===== */
+.share-btn-wrap {
+  padding: 24rpx 30rpx;
+  display: flex;
+  justify-content: flex-end;
+}
+.share-btn {
+  padding: 16rpx 32rpx;
+  background: #F97316;
+  color: #fff;
+  border-radius: 32rpx;
+  font-size: 26rpx;
+  border: none;
+}
+.share-btn::after {
+  border: none;
+}
+
+/* ===== 分享弹窗 ===== */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+  z-index: 1000;
+}
+.share-modal {
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  padding: 40rpx;
+}
+.share-modal-title {
+  display: block;
+  text-align: center;
+  font-size: 32rpx;
+  font-weight: bold;
+  margin-bottom: 32rpx;
+}
+.share-modal-items {
+  display: flex;
+  justify-content: space-around;
+}
+.share-modal-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.share-modal-icon {
+  font-size: 48rpx;
+  margin-bottom: 12rpx;
+}
+.share-modal-label {
+  font-size: 24rpx;
+  color: #64748B;
+}
 </style>

+ 615 - 0
cfc-frontend/pages/points/mall.vue

@@ -0,0 +1,615 @@
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">积分商城</text>
+      <view class="points-info">
+        <text class="points-label">我的积分</text>
+        <text class="points-value">{{ systemPoints }}</text>
+      </view>
+    </view>
+
+    <view class="category-tabs">
+      <view
+        v-for="tab in categories"
+        :key="tab.key"
+        class="tab-item"
+        :class="{ active: currentCategory === tab.key }"
+        @click="onCategoryChange(tab.key)"
+      >
+        <text class="tab-text">{{ tab.label }}</text>
+      </view>
+    </view>
+
+    <scroll-view
+      scroll-y
+      class="product-list"
+      @scrolltolower="onLoadMore"
+      refresher-enabled
+      :refresher-triggered="refreshing"
+      @refresherrefresh="onRefresh"
+    >
+      <view v-if="products.length > 0" class="product-grid">
+        <view
+          v-for="product in products"
+          :key="product.id"
+          class="product-card"
+          @click="onProductClick(product)"
+        >
+          <image
+            class="product-cover"
+            :src="product.coverImage || '/static/default-product.png'"
+            mode="aspectFill"
+          />
+          <view class="product-info">
+            <text class="product-name">{{ product.name }}</text>
+            <text class="product-points">{{ product.pointsPrice }} 积分</text>
+            <view class="exchange-btn" @click.stop="onExchangeClick(product)">
+              <text class="exchange-btn-text">兑换</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view v-else-if="!loading" class="empty-state">
+        <text class="empty-text">暂无商品</text>
+      </view>
+      <view v-if="loadingMore" class="loading-more">
+        <text class="loading-text">加载中...</text>
+      </view>
+    </scroll-view>
+
+    <view class="records-btn" @click="onRecordsClick">
+      <text class="records-btn-text">兑换记录</text>
+    </view>
+
+    <view v-if="showExchangeModal" class="modal-mask" @click="onCloseModal">
+      <view class="modal-content" @click.stop>
+        <text class="modal-title">确认兑换</text>
+        <view v-if="selectedProduct" class="modal-product">
+          <image
+            class="modal-cover"
+            :src="selectedProduct.coverImage || '/static/default-product.png'"
+            mode="aspectFill"
+          />
+          <view class="modal-info">
+            <text class="modal-name">{{ selectedProduct.name }}</text>
+            <text class="modal-points">{{ selectedProduct.pointsPrice }} 积分</text>
+          </view>
+        </view>
+        <view class="modal-actions">
+          <view class="modal-btn cancel" @click="onCloseModal">
+            <text class="modal-btn-text">取消</text>
+          </view>
+          <view class="modal-btn confirm" @click="onConfirmExchange">
+            <text class="modal-btn-text">确认兑换</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="showRecordsModal" class="modal-mask" @click="onCloseRecordsModal">
+      <view class="modal-content records-modal" @click.stop>
+        <text class="modal-title">兑换记录</text>
+        <scroll-view scroll-y class="records-list">
+          <view v-if="records.length > 0">
+            <view
+              v-for="record in records"
+              :key="record.id"
+              class="record-item"
+            >
+              <view class="record-header">
+                <text class="record-name">{{ record.productName }}</text>
+                <text class="record-points">-{{ record.pointsCost }}积分</-afficher>
+              </view>
+              <view class="record-meta">
+                <text class="record-code">兑换码: {{ record.redeemCode }}</text>
+                <text class="record-date">{{ formatDate(record.createdAt) }}</text>
+              </view>
+            </view>
+          </view>
+          <view v-else class="empty-state">
+            <text class="empty-text">暂无兑换记录</text>
+          </view>
+        </scroll-view>
+        <view class="modal-close" @click="onCloseRecordsModal">
+          <text class="modal-close-text">关闭</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  getPointsExchangeProducts,
+  submitPointsExchange,
+  getPointsExchangeRecords,
+  getSystemBalance
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      categories: [
+        { key: 'all', label: '全部' },
+        { key: 'vip', label: 'VIP' },
+        { key: 'redeem', label: '兑换' },
+        { key: 'flow', label: '流量' },
+        { key: 'gift', label: '礼品' }
+      ],
+      currentCategory: 'all',
+      products: [],
+      records: [],
+      systemPoints: 0,
+      pageNum: 1,
+      pageSize: 10,
+      loading: false,
+      loadingMore: false,
+      refreshing: false,
+      hasMore: true,
+      showExchangeModal: false,
+      showRecordsModal: false,
+      selectedProduct: null
+    }
+  },
+  onLoad() {
+    this.loadSystemPoints()
+    this.loadProducts()
+  },
+  onShow() {
+    this.loadSystemPoints()
+  },
+  methods: {
+    loadSystemPoints() {
+      var childId = uni.getStorageSync('currentChildId')
+      getSystemBalance(childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          this.systemPoints = res.data.systemPoints || 0
+        }
+      }.bind(this)).catch(function() {})
+    },
+    loadProducts() {
+      this.loading = true
+      getPointsExchangeProducts({
+        category: this.currentCategory,
+        pageNum: this.pageNum,
+        pageSize: this.pageSize
+      }).then(function(res) {
+        this.loading = false
+        if (res.code === 200 && res.data) {
+          var list = res.data.records || []
+          if (this.pageNum === 1) {
+            this.products = list
+          } else {
+            this.products = this.products.concat(list)
+          }
+          this.hasMore = list.length >= this.pageSize
+        }
+      }.bind(this)).catch(function() {
+        this.loading = false
+      }.bind(this))
+    },
+    onCategoryChange(category) {
+      this.currentCategory = category
+      this.pageNum = 1
+      this.products = []
+      this.loadProducts()
+    },
+    onRefresh() {
+      this.refreshing = true
+      this.pageNum = 1
+      getPointsExchangeProducts({
+        category: this.currentCategory,
+        pageNum: 1,
+        pageSize: this.pageSize
+      }).then(function(res) {
+        this.refreshing = false
+        if (res.code === 200 && res.data) {
+          this.products = res.data.records || []
+          this.hasMore = (res.data.records || []).length >= this.pageSize
+        }
+      }.bind(this)).catch(function() {
+        this.refreshing = false
+      }.bind(this))
+    },
+    onLoadMore() {
+      if (!this.hasMore && !this.loadingMore) {
+        this.loadingMore = true
+        this.pageNum = this.pageNum + 1
+        getPointsExchangeProducts({
+          category: this.currentCategory,
+          pageNum: this.pageNum,
+          pageSize: this.pageSize
+        }).then(function(res) {
+          this.loadingMore = false
+          if (res.code === 200 && res.data) {
+            var list = res.data.records || []
+            this.products = this.products.concat(list)
+            this.hasMore = list.length >= this.pageSize
+          }
+        }.bind(this)).catch(function() {
+          this.loadingMore = false
+        }.bind(this))
+      }
+    },
+    onProductClick(product) {
+      this.selectedProduct = product
+      this.showExchangeModal = true
+    },
+    onExchangeClick(product) {
+      this.selectedProduct = product
+      this.showExchangeModal = true
+    },
+    onCloseModal() {
+      this.showExchangeModal = false
+      this.selectedProduct = null
+    },
+    onConfirmExchange() {
+      if (!this.selectedProduct) {
+        return
+      }
+      var productId = this.selectedProduct.id
+      submitPointsExchange({ productId: productId, quantity: 1 }).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '兑换成功', icon: 'success' })
+          this.loadSystemPoints()
+          this.loadProducts()
+        }
+      }.bind(this)).catch(function(err) {
+        uni.showToast({ title: err && err.message ? err.message : '兑换失败', icon: 'none' })
+      }.bind(this))
+      this.onCloseModal()
+    },
+    onRecordsClick() {
+      this.loadRecords()
+      this.showRecordsModal = true
+    },
+    loadRecords() {
+      getPointsExchangeRecords().then(function(res) {
+        if (res.code === 200 && res.data) {
+          this.records = res.data || []
+        }
+      }.bind(this)).catch(function() {})
+    },
+    onCloseRecordsModal() {
+      this.showRecordsModal = false
+    },
+    formatDate(dateStr) {
+      if (!dateStr) {
+        return ''
+      }
+      var date = new Date(dateStr)
+      var year = date.getFullYear()
+      var month = date.getMonth() + 1
+      var day = date.getDate()
+      return year + '-' + (month < 10 ? '0' + month : month) + '-' + (day < 10 ? '0' + day : day)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-bottom: 120rpx;
+}
+
+.header {
+  background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
+  padding: 40rpx 30rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.title {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+
+.points-info {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+}
+
+.points-label {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+.points-value {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+
+.category-tabs {
+  display: flex;
+  background-color: #ffffff;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #eeeeee;
+  overflow-x: auto;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  margin: 0 10rpx;
+  border-radius: 30rpx;
+  background-color: #f5f5f5;
+}
+
+.tab-item.active {
+  background-color: #ff8c42;
+}
+
+.tab-text {
+  font-size: 28rpx;
+  color: #666666;
+}
+
+.tab-item.active .tab-text {
+  color: #ffffff;
+  font-weight: bold;
+}
+
+.product-list {
+  padding: 20rpx;
+}
+
+.product-grid {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.product-card {
+  width: 48%;
+  background-color: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
+}
+
+.product-cover {
+  width: 100%;
+  height: 240rpx;
+}
+
+.product-info {
+  padding: 20rpx;
+}
+
+.product-name {
+  font-size: 28rpx;
+  color: #333333;
+  font-weight: bold;
+  display: block;
+  margin-bottom: 10rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.product-points {
+  font-size: 32rpx;
+  color: #ff6b35;
+  font-weight: bold;
+  display: block;
+  margin-bottom: 16rpx;
+}
+
+.exchange-btn {
+  background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
+  border-radius: 30rpx;
+  padding: 16rpx 0;
+  text-align: center;
+}
+
+.exchange-btn-text {
+  font-size: 28rpx;
+  color: #ffffff;
+  font-weight: bold;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 100rpx 0;
+}
+
+.empty-text {
+  font-size: 28rpx;
+  color: #999999;
+}
+
+.loading-more {
+  text-align: center;
+  padding: 30rpx 0;
+}
+
+.loading-text {
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.records-btn {
+  position: fixed;
+  bottom: 40rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
+  border-radius: 40rpx;
+  padding: 24rpx 60rpx;
+  box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
+}
+
+.records-btn-text {
+  font-size: 30rpx;
+  color: #ffffff;
+  font-weight: bold;
+}
+
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+}
+
+.modal-content {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  width: 80%;
+  max-width: 600rpx;
+}
+
+.records-modal {
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.modal-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333333;
+  text-align: center;
+  display: block;
+  margin-bottom: 30rpx;
+}
+
+.modal-product {
+  display: flex;
+  align-items: center;
+  margin-bottom: 30rpx;
+  padding: 20rpx;
+  background-color: #f9f9f9;
+  border-radius: 12rpx;
+}
+
+.modal-cover {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 8rpx;
+  margin-right: 20rpx;
+}
+
+.modal-info {
+  flex: 1;
+}
+
+.modal-name {
+  font-size: 30rpx;
+  color: #333333;
+  font-weight: bold;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.modal-points {
+  font-size: 32rpx;
+  color: #ff6b35;
+  font-weight: bold;
+}
+
+.modal-actions {
+  display: flex;
+  justify-content: space-between;
+}
+
+.modal-btn {
+  flex: 1;
+  padding: 20rpx 0;
+  border-radius: 12rpx;
+  text-align: center;
+  margin: 0 10rpx;
+}
+
+.modal-btn.cancel {
+  background-color: #f5f5f5;
+}
+
+.modal-btn.confirm {
+  background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
+}
+
+.modal-btn-text {
+  font-size: 30rpx;
+  color: #666666;
+  font-weight: bold;
+}
+
+.modal-btn.confirm .modal-btn-text {
+  color: #ffffff;
+}
+
+.records-list {
+  flex: 1;
+  overflow-y: auto;
+  max-height: 50vh;
+}
+
+.record-item {
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #eeeeee;
+}
+
+.record-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.record-name {
+  font-size: 28rpx;
+  color: #333333;
+  font-weight: bold;
+}
+
+.record-points {
+  font-size: 28rpx;
+  color: #ff6b35;
+  font-weight: bold;
+}
+
+.record-meta {
+  display: flex;
+  justify-content: space-between;
+}
+
+.record-code {
+  font-size: 24rpx;
+  color: #666666;
+}
+
+.record-date {
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.modal-close {
+  margin-top: 20rpx;
+  padding: 20rpx 0;
+  background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
+  border-radius: 12rpx;
+  text-align: center;
+}
+
+.modal-close-text {
+  font-size: 30rpx;
+  color: #ffffff;
+  font-weight: bold;
+}
+</style>

+ 359 - 0
cfc-frontend/pages/promotion/material.vue

@@ -0,0 +1,359 @@
+<template>
+  <view class="container">
+    <!-- 搜索栏 -->
+    <view class="search-bar">
+      <view class="search-input-wrap">
+        <text class="search-icon">🔍</text>
+        <input
+          class="search-input"
+          type="text"
+          placeholder="搜索素材标题或标签"
+          v-model="searchKeyword"
+          confirm-type="search"
+          @confirm="onSearch"
+        />
+        <text class="search-clear" v-if="searchKeyword" @click="clearSearch">✕</text>
+      </view>
+    </view>
+
+    <!-- 类型标签栏 -->
+    <scroll-view class="type-tabs" scroll-x>
+      <view
+        class="type-tab"
+        :class="{ active: currentType === item.value }"
+        v-for="item in typeTabs"
+        :key="item.value"
+        @click="selectType(item.value)"
+      >
+        <text>{{ item.label }}</text>
+      </view>
+    </scroll-view>
+
+    <!-- 素材列表 -->
+    <scroll-view class="material-list" scroll-y @scrolltolower="loadMore">
+      <view class="material-grid">
+        <view
+          class="material-item"
+          v-for="item in materialList"
+          :key="item.id"
+          @click="goDetail(item)"
+        >
+          <image class="material-cover" :src="item.coverUrl || '/static/default-cover.png'" mode="aspectFill" />
+          <view class="material-info">
+            <text class="material-title">{{ item.title }}</text>
+            <text class="material-tags" v-if="item.tags">{{ item.tags }}</text>
+            <view class="material-actions">
+              <view class="action-btn share-btn" @click.stop="shareItem(item)">
+                <text>分享</text>
+              </view>
+              <view class="action-btn download-btn" @click.stop="downloadItem(item)">
+                <text>下载</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载状态 -->
+      <view class="loading-more" v-if="loading">
+        <text>加载中...</text>
+      </view>
+      <view class="no-more" v-if="noMore && materialList.length > 0">
+        <text>没有更多了</text>
+      </view>
+      <view class="empty-state" v-if="!loading && materialList.length === 0">
+        <text class="empty-text">暂无素材</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+var BASE_URL = config.API_BASE_URL
+
+function _request(url, method, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: BASE_URL + url,
+      method: method || 'POST',
+      data: data || {},
+      header: {
+        'Content-Type': 'application/json',
+        'X-User-Id': uni.getStorageSync('userId') || '',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        if (res.statusCode === 401 || (res.data && res.data.code === 401)) {
+          uni.removeStorageSync('token')
+          uni.removeStorageSync('userId')
+          uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
+          reject(res.data)
+          return
+        }
+        if (res.data && res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          uni.showToast({ title: (res.data && res.data.message) || '请求失败', icon: 'none' })
+          reject(res.data)
+        }
+      },
+      fail: function(err) {
+        uni.showToast({ title: '网络请求失败', icon: 'none' })
+        reject(err)
+      }
+    })
+  })
+}
+
+export default {
+  data() {
+    return {
+      searchKeyword: '',
+      currentType: 'all',
+      materialList: [],
+      pageNum: 1,
+      pageSize: 20,
+      loading: false,
+      noMore: false,
+      typeTabs: [
+        { label: '全部', value: 'all' },
+        { label: '图片', value: 'image' },
+        { label: '文章', value: 'article' },
+        { label: '视频', value: 'video' },
+        { label: '海报', value: 'poster' }
+      ]
+    }
+  },
+  onLoad() {
+    this.loadMaterials()
+  },
+  methods: {
+    loadMaterials() {
+      if (this.loading || this.noMore) return
+      this.loading = true
+      var self = this
+      var params = {
+        materialType: this.currentType === 'all' ? null : this.currentType,
+        keyword: this.searchKeyword || null,
+        pageNum: this.pageNum,
+        pageSize: this.pageSize
+      }
+      _request('/api/promotion/material/list', 'POST', params).then(function(res) {
+        if (res.code === 200 && res.data) {
+          var records = res.data.records || []
+          if (self.pageNum === 1) {
+            self.materialList = records
+          } else {
+            self.materialList = self.materialList.concat(records)
+          }
+          self.noMore = records.length < self.pageSize
+        }
+        self.loading = false
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    loadMore() {
+      if (this.noMore || this.loading) return
+      this.pageNum = this.pageNum + 1
+      this.loadMaterials()
+    },
+    selectType(type) {
+      this.currentType = type
+      this.pageNum = 1
+      this.noMore = false
+      this.materialList = []
+      this.loadMaterials()
+    },
+    onSearch() {
+      this.pageNum = 1
+      this.noMore = false
+      this.materialList = []
+      this.loadMaterials()
+    },
+    clearSearch() {
+      this.searchKeyword = ''
+      this.onSearch()
+    },
+    goDetail(item) {
+      uni.navigateTo({
+        url: '/pages/promotion/material-detail?id=' + item.id
+      })
+    },
+    shareItem(item) {
+      uni.showShareMenu({
+        withShareTicket: true
+      })
+      uni.showToast({ title: '分享功能开发中', icon: 'none' })
+    },
+    downloadItem(item) {
+      if (!item.fileUrl) {
+        uni.showToast({ title: '暂无下载链接', icon: 'none' })
+        return
+      }
+      uni.downloadFile({
+        url: item.fileUrl,
+        success: function(res) {
+          if (res.statusCode === 200) {
+            uni.saveImageToPhotosAlbum({
+              filePath: res.tempFilePath,
+              success: function() {
+                uni.showToast({ title: '已保存到相册', icon: 'success' })
+              },
+              fail: function() {
+                uni.showToast({ title: '保存失败', icon: 'none' })
+              }
+            })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '下载失败', icon: 'none' })
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F5F5F5;
+}
+
+/* 搜索栏 */
+.search-bar {
+  padding: 20rpx 30rpx;
+  background: #fff;
+}
+.search-input-wrap {
+  display: flex;
+  align-items: center;
+  background: #F5F5F5;
+  border-radius: 40rpx;
+  padding: 16rpx 24rpx;
+}
+.search-icon {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+  color: #999;
+}
+.search-input {
+  flex: 1;
+  font-size: 28rpx;
+  color: #333;
+}
+.search-clear {
+  font-size: 24rpx;
+  color: #999;
+  padding: 8rpx;
+}
+
+/* 类型标签 */
+.type-tabs {
+  white-space: nowrap;
+  background: #fff;
+  padding: 0 20rpx 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+.type-tab {
+  display: inline-block;
+  padding: 12rpx 28rpx;
+  margin-right: 16rpx;
+  border-radius: 32rpx;
+  background: #F5F5F5;
+  font-size: 26rpx;
+  color: #666;
+}
+.type-tab.active {
+  background: #F97316;
+  color: #fff;
+}
+
+/* 素材列表 */
+.material-list {
+  padding: 20rpx;
+}
+.material-grid {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.material-item {
+  width: 48%;
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.material-cover {
+  width: 100%;
+  height: 240rpx;
+  background: #f0f0f0;
+}
+.material-info {
+  padding: 16rpx;
+}
+.material-title {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 8rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-tags {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-actions {
+  display: flex;
+  justify-content: space-between;
+}
+.action-btn {
+  flex: 1;
+  text-align: center;
+  padding: 12rpx 0;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+  margin-right: 12rpx;
+}
+.action-btn:last-child {
+  margin-right: 0;
+}
+.share-btn {
+  background: #FFF3E0;
+  color: #F97316;
+}
+.download-btn {
+  background: #E3F2FD;
+  color: #2196F3;
+}
+
+/* 加载和空状态 */
+.loading-more, .no-more {
+  text-align: center;
+  padding: 24rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+.empty-state {
+  text-align: center;
+  padding: 120rpx 0;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

+ 12 - 2
cfc-frontend/utils/api.js

@@ -211,8 +211,12 @@ export const getCurrentLevel = () => {
 	return request('/api/membership/current', 'POST')
 }
 
-export const createOrder = (levelCode, paymentType) => {
-	return request('/api/membership/orders', 'POST', { levelCode, paymentType })
+export const createOrder = (levelCode, paymentType, period) => {
+	var data = { levelCode: levelCode, paymentType: paymentType }
+	if (period) {
+		data.period = period
+	}
+	return request('/api/membership/orders', 'POST', data)
 }
 
 export const getAuthorizations = () => {
@@ -1556,3 +1560,9 @@ export const confirmTongue = (data) => request('/api/health/report/confirm?type=
 export const discardTongue = (data) => request('/api/health/report/discard?type=tongue', 'POST', data)
 // 鎸囨爣瀹氫箟
 export const getIndicatorDefinitions = (data) => request('/api/admin/indicator/list', 'POST', data)
+
+export const getPointsExchangeProducts = (data) => request('/api/points/exchange/product/list', 'POST', data)
+export const getPointsExchangeProductDetail = (data) => request('/api/points/exchange/product/detail', 'POST', data)
+export const submitPointsExchange = (data) => request('/api/points/exchange/submit', 'POST', data)
+export const getPointsExchangeRecords = () => request('/api/points/exchange/records', 'POST', {})
+export const getPointsExchangeStats = () => request('/api/points/exchange/stats', 'POST', {})

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

@@ -29,6 +29,12 @@ const routes = [
         component: () => import('@/views/Dashboard.vue'),
         meta: { title: '首页', perm: 'dashboard' }
       },
+      {
+        path: 'admin/dashboard',
+        name: 'AdminDashboard',
+        component: () => import('@/views/admin/Dashboard'),
+        meta: { title: '数据大屏', roles: ['admin'] }
+      },
       // 家庭管理
       {
         path: 'families',

+ 231 - 0
cfc-web/src/views/admin/Dashboard.vue

@@ -0,0 +1,231 @@
+<template>
+  <div class="dashboard-container">
+    <!-- 顶部统计卡片 -->
+    <el-row :gutter="20" class="stat-row">
+      <el-col :span="6" v-for="(stat, idx) in statCards" :key="idx">
+        <el-card class="stat-card" :class="'stat-card-' + stat.type">
+          <div class="stat-icon">{{ stat.icon }}</div>
+          <div class="stat-content">
+            <div class="stat-value">{{ stat.value }}</div>
+            <div class="stat-label">{{ stat.label }}</div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 收入和会员概览 -->
+    <el-row :gutter="20" class="chart-row">
+      <el-col :span="16">
+        <el-card class="chart-card">
+          <div slot="header" class="card-header">
+            <span>用户与家庭增长趋势(近30天)</span>
+          </div>
+          <div ref="trendChart" class="echarts-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card class="chart-card">
+          <div slot="header" class="card-header">
+            <span>会员等级分布</span>
+          </div>
+          <div ref="memberPieChart" class="echarts-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 收入概览 -->
+    <el-row :gutter="20" class="chart-row">
+      <el-col :span="8">
+        <el-card class="chart-card">
+          <div slot="header" class="card-header">
+            <span>本月佣金</span>
+          </div>
+          <div class="revenue-value">¥{{ revenueData.monthCommission || 0 }}</div>
+          <div class="revenue-sub">待结算: ¥{{ revenueData.pendingCommission || 0 }}</div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card class="chart-card">
+          <div slot="header" class="card-header">
+            <span>累计佣金</span>
+          </div>
+          <div class="revenue-value">¥{{ revenueData.totalCommission || 0 }}</div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card class="chart-card">
+          <div slot="header" class="card-header">
+            <span>本月会员升级</span>
+          </div>
+          <div class="revenue-value">{{ membershipData.monthUpgrades || 0 }}</div>
+          <div class="revenue-sub">试用会员: {{ membershipData.trialCount || 0 }}</div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+
+export default {
+  name: 'AdminDashboard',
+  data() {
+    return {
+      statCards: [
+        { label: '家庭总数', value: 0, icon: '👨‍👩‍👧', type: 'orange' },
+        { label: '家长总数', value: 0, icon: '👤', type: 'blue' },
+        { label: '成长规划师', value: 0, icon: '🎓', type: 'green' },
+        { label: '待审核规划师', value: 0, icon: '⏳', type: 'red' }
+      ],
+      revenueData: {},
+      membershipData: {},
+      trendData: { familyTrend: {}, userTrend: {} }
+    }
+  },
+  mounted() {
+    this.loadOverview()
+    this.loadRevenue()
+    this.loadMembership()
+    this.loadTrend()
+  },
+  methods: {
+    async loadOverview() {
+      try {
+        const res = await this.$http.post('/api/stats/overview')
+        if (res.data.code === 200) {
+          const d = res.data.data
+          this.statCards[0].value = d.totalFamilies || 0
+          this.statCards[1].value = d.totalParents || 0
+          this.statCards[2].value = d.totalTeachers || 0
+          this.statCards[3].value = d.pendingGuides || 0
+        }
+      } catch (e) {
+        console.error('加载概览失败', e)
+      }
+    },
+    async loadRevenue() {
+      try {
+        const res = await this.$http.post('/api/stats/revenue')
+        if (res.data.code === 200) {
+          this.revenueData = res.data.data
+        }
+      } catch (e) {
+        console.error('加载收入统计失败', e)
+      }
+    },
+    async loadMembership() {
+      try {
+        const res = await this.$http.post('/api/stats/membership')
+        if (res.data.code === 200) {
+          this.membershipData = res.data.data
+          this.initMemberPieChart()
+        }
+      } catch (e) {
+        console.error('加载会员统计失败', e)
+      }
+    },
+    async loadTrend() {
+      try {
+        const res = await this.$http.post('/api/stats/trend')
+        if (res.data.code === 200) {
+          this.trendData = res.data.data
+          this.initTrendChart()
+        }
+      } catch (e) {
+        console.error('加载趋势失败', e)
+      }
+    },
+    initTrendChart() {
+      const chart = echarts.init(this.$refs.trendChart)
+      const familyData = Object.values(this.trendData.familyTrend || {})
+      const userData = Object.values(this.trendData.userTrend || {})
+      const xAxis = Object.keys(this.trendData.familyTrend || {})
+
+      chart.setOption({
+        tooltip: { trigger: 'axis' },
+        legend: { data: ['新增家庭', '新增用户'] },
+        xAxis: { type: 'category', data: xAxis },
+        yAxis: { type: 'value' },
+        series: [
+          { name: '新增家庭', type: 'bar', data: familyData, itemStyle: { color: '#F97316' } },
+          { name: '新增用户', type: 'line', data: userData, itemStyle: { color: '#0EA5E9' } }
+        ]
+      })
+    },
+    initMemberPieChart() {
+      const chart = echarts.init(this.$refs.memberPieChart)
+      chart.setOption({
+        tooltip: { trigger: 'item' },
+        legend: { orient: 'vertical', left: 'left' },
+        series: [{
+          type: 'pie',
+          radius: ['40%', '70%'],
+          data: [
+            { value: this.membershipData.freeCount || 0, name: '免费用户' },
+            { value: this.membershipData.familyCount || 0, name: '家庭会员' },
+            { value: this.membershipData.providerCount || 0, name: '服务商' }
+          ],
+          itemStyle: {
+            color: function(params) {
+              var colors = ['#94A3B8', '#F97316', '#10B981']
+              return colors[params.dataIndex]
+            }
+          }
+        }]
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dashboard-container {
+  padding: 20px;
+}
+.stat-row {
+  margin-bottom: 20px;
+}
+.stat-card {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+}
+.stat-icon {
+  font-size: 36px;
+  margin-right: 16px;
+}
+.stat-value {
+  font-size: 28px;
+  font-weight: bold;
+  color: #1E293B;
+}
+.stat-label {
+  font-size: 14px;
+  color: #64748B;
+  margin-top: 4px;
+}
+.chart-card {
+  min-height: 300px;
+}
+.echarts-container {
+  height: 260px;
+}
+.card-header {
+  font-weight: 600;
+  color: #1E293B;
+}
+.revenue-value {
+  font-size: 32px;
+  font-weight: bold;
+  color: #F97316;
+  text-align: center;
+  margin-top: 20px;
+}
+.revenue-sub {
+  font-size: 14px;
+  color: #64748B;
+  text-align: center;
+  margin-top: 8px;
+}
+</style>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio