Jelajahi Sumber

Merge: resolve conflicts on recommend system + service-role-apply APIs

E2E Test Bot 3 bulan lalu
induk
melakukan
da84dce15c

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

@@ -7171,5 +7171,59 @@ private void runMigration100() {
 		} catch (Exception e) {
 			// 列已存在,忽略错误
 		}
+
+		// 迁移114: 创建 periodic_services 表(周期性服务续费配置)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS periodic_services (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"product_id BIGINT NOT NULL COMMENT '关联商品ID', " +
+					"service_name VARCHAR(100) COMMENT '服务名称', " +
+					"cycle_days INT NOT NULL COMMENT '周期天数(如365=年度服务)', " +
+					"remind_before_days INT DEFAULT 30 COMMENT '提前提醒天数', " +
+					"auto_renew TINYINT DEFAULT 0 COMMENT '是否支持自动续费: 0=否 1=是', " +
+					"enabled TINYINT DEFAULT 1 COMMENT '是否启用: 0=禁用 1=启用', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+					"INDEX idx_product (product_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='周期性服务续费配置'");
+			log.info("已创建 periodic_services 表");
+		} catch (Exception e) {
+			log.warn("创建 periodic_services 表失败: {}", e.getMessage());
+		}
+
+		// 迁移115: 创建 recommendation_logs 表(推荐行为记录)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_logs (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"user_id BIGINT COMMENT '用户ID', " +
+					"family_id BIGINT COMMENT '家庭ID', " +
+					"child_id BIGINT COMMENT '孩子ID', " +
+					"content_type VARCHAR(20) NOT NULL COMMENT '内容类型: article/task/product/activity', " +
+					"content_id BIGINT NOT NULL COMMENT '内容ID', " +
+					"recommend_score INT COMMENT '推荐分值', " +
+					"action VARCHAR(20) COMMENT '行为: show/click/consume', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"INDEX idx_user_content (user_id, content_type, content_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐行为记录'");
+			log.info("已创建 recommendation_logs 表");
+		} catch (Exception e) {
+			log.warn("创建 recommendation_logs 表失败: {}", e.getMessage());
+		}
+
+		// 迁移116: products表添加family_scope列(推荐系统-家庭消费去重)
+		try {
+			ensureColumn("products", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除'");
+			log.info("已完成products.family_scope字段添加");
+		} catch (Exception e) {
+			log.warn("添加products.family_scope字段失败: {}", e.getMessage());
+		}
+
+		// 迁移117: activities表添加family_scope列(推荐系统-家庭参与去重)
+		try {
+			ensureColumn("activities", "family_scope", "TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除'");
+			log.info("已完成activities.family_scope字段添加");
+		} catch (Exception e) {
+			log.warn("添加activities.family_scope字段失败: {}", e.getMessage());
+		}
 	}
 }

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/RecommendController.java

@@ -0,0 +1,66 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RecommendRequestDTO;
+import com.etotem.cfc.entity.RecommendationLog;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.RecommendationLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ContentRecommendService;
+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 java.util.Date;
+import java.util.Map;
+
+@Tag(name = "推荐系统", description = "个性化内容推荐接口")
+@RestController
+@RequestMapping("/api/recommend")
+public class RecommendController {
+
+    @Resource
+    private ContentRecommendService contentRecommendService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private RecommendationLogMapper recommendationLogMapper;
+
+    @Operation(summary = "获取个性化推荐列表")
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody RecommendRequestDTO request,
+                                            @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long familyId = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+            }
+        }
+        Map<String, Object> data = contentRecommendService.recommend(request, userId, familyId);
+        return Result.success(data);
+    }
+
+    @Operation(summary = "记录推荐行为(点击/消费)")
+    @PostMapping("/track")
+    public Result<String> track(@RequestBody Map<String, Object> params,
+                                @RequestAttribute(value = "userId", required = false) Long userId) {
+        String contentType = (String) params.get("contentType");
+        Long contentId = params.get("contentId") != null ? Long.valueOf(params.get("contentId").toString()) : null;
+        String action = (String) params.get("action");
+        if (contentType == null || contentId == null || action == null) {
+            return Result.error("参数不完整");
+        }
+        RecommendationLog log = new RecommendationLog();
+        log.setUserId(userId);
+        log.setContentType(contentType);
+        log.setContentId(contentId);
+        log.setAction(action);
+        log.setCreatedAt(new Date());
+        recommendationLogMapper.insert(log);
+        return Result.success("ok");
+    }
+}

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPeriodicServiceController.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PeriodicService;
+import com.etotem.cfc.mapper.PeriodicServiceMapper;
+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 java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-周期性服务配置")
+@RestController
+@RequestMapping("/api/admin/periodic-service")
+public class AdminPeriodicServiceController {
+
+    @Resource
+    private PeriodicServiceMapper periodicServiceMapper;
+
+    @Operation(summary = "获取所有周期性服务配置")
+    @PostMapping("/list")
+    public Result<List<PeriodicService>> list() {
+        List<PeriodicService> list = periodicServiceMapper.selectList(
+                new LambdaQueryWrapper<PeriodicService>().orderByAsc(PeriodicService::getId));
+        return Result.success(list);
+    }
+
+    @Operation(summary = "获取单个周期性服务配置")
+    @PostMapping("/detail")
+    public Result<PeriodicService> detail(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        PeriodicService config = periodicServiceMapper.selectById(id);
+        if (config == null) return Result.error("配置不存在");
+        return Result.success(config);
+    }
+
+    @Operation(summary = "创建周期性服务配置")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody PeriodicService config) {
+        config.setCreatedAt(new Date());
+        config.setUpdatedAt(new Date());
+        if (config.getEnabled() == null) config.setEnabled(1);
+        periodicServiceMapper.insert(config);
+        return Result.success("创建成功");
+    }
+
+    @Operation(summary = "更新周期性服务配置")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody PeriodicService config) {
+        PeriodicService existing = periodicServiceMapper.selectById(config.getId());
+        if (existing == null) return Result.error("配置不存在");
+        config.setUpdatedAt(new Date());
+        periodicServiceMapper.updateById(config);
+        return Result.success("更新成功");
+    }
+
+    @Operation(summary = "删除周期性服务配置")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        periodicServiceMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    @Operation(summary = "切换启用/禁用状态")
+    @PostMapping("/toggle")
+    public Result<String> toggle(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        PeriodicService config = periodicServiceMapper.selectById(id);
+        if (config == null) return Result.error("配置不存在");
+        config.setEnabled(config.getEnabled() != null && config.getEnabled() == 1 ? 0 : 1);
+        config.setUpdatedAt(new Date());
+        periodicServiceMapper.updateById(config);
+        return Result.success(config.getEnabled() == 1 ? "已启用" : "已禁用");
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendItemVO.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.dto;
+
+import java.io.Serializable;
+
+public class RecommendItemVO implements Serializable {
+
+    private Long id;
+    private String title;
+    private String summary;
+    private String coverImage;
+    private String dimensionCode;
+    private Integer recommendScore;
+    private String recommendReason;
+    private String badge;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getTitle() { return title; }
+    public void setTitle(String title) { this.title = title; }
+    public String getSummary() { return summary; }
+    public void setSummary(String summary) { this.summary = summary; }
+    public String getCoverImage() { return coverImage; }
+    public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
+    public Integer getRecommendScore() { return recommendScore; }
+    public void setRecommendScore(Integer recommendScore) { this.recommendScore = recommendScore; }
+    public String getRecommendReason() { return recommendReason; }
+    public void setRecommendReason(String recommendReason) { this.recommendReason = recommendReason; }
+    public String getBadge() { return badge; }
+    public void setBadge(String badge) { this.badge = badge; }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendRequestDTO.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.dto;
+
+public class RecommendRequestDTO {
+
+    /** 推荐类型: article/task/product/activity */
+    private String type;
+
+    /** 当前维度: body/mind/wisdom/action/wealth */
+    private String dimensionCode;
+
+    /** 当前查看的孩子ID(文章/任务用) */
+    private Long childId;
+
+    /** 返回条数 */
+    private Integer size;
+
+    /** 前端已展示的ID列表,用于翻页去重 */
+    private String excludeIds;
+
+    public String getType() { return type; }
+    public void setType(String type) { this.type = type; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
+    public Long getChildId() { return childId; }
+    public void setChildId(Long childId) { this.childId = childId; }
+    public Integer getSize() { return size; }
+    public void setSize(Integer size) { this.size = size; }
+    public String getExcludeIds() { return excludeIds; }
+    public void setExcludeIds(String excludeIds) { this.excludeIds = excludeIds; }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -57,6 +57,9 @@ public class Activity implements Serializable {
     /** 会员价(分) */
     private Integer memberPrice;
 
+    /** 是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除 */
+    private Integer familyScope;
+
     /** 签到积分(0=使用系统默认) */
     private Integer checkinPoints;
 

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PeriodicService.java

@@ -0,0 +1,39 @@
+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("periodic_services")
+public class PeriodicService implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联商品ID */
+    private Long productId;
+
+    /** 服务名称 */
+    private String serviceName;
+
+    /** 周期天数(如365=年度服务) */
+    private Integer cycleDays;
+
+    /** 提前多少天提醒续费 */
+    private Integer remindBeforeDays;
+
+    /** 是否支持自动续费: 0=否 1=是 */
+    private Integer autoRenew;
+
+    /** 是否启用: 0=禁用 1=启用 */
+    private Integer enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -43,6 +43,9 @@ public class Product implements Serializable {
     private String externalId;
     private String externalData;
 
+    /** 是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除 */
+    private Integer familyScope;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RecommendationLog.java

@@ -0,0 +1,40 @@
+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("recommendation_logs")
+public class RecommendationLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 孩子ID */
+    private Long childId;
+
+    /** 内容类型: article/task/product/activity */
+    private String contentType;
+
+    /** 内容ID */
+    private Long contentId;
+
+    /** 推荐分值 */
+    private Integer recommendScore;
+
+    /** 行为: show/click/consume */
+    private String action;
+
+    private Date createdAt;
+}

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

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

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

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

+ 546 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java

@@ -0,0 +1,546 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.RecommendItemVO;
+import com.etotem.cfc.dto.RecommendRequestDTO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class ContentRecommendService {
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ArticleReadingRecordMapper articleReadingRecordMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private ActivityRegistrationMapper activityRegistrationMapper;
+
+    @Resource
+    private PeriodicServiceMapper periodicServiceMapper;
+
+    @Resource
+    private RecommendationLogMapper recommendationLogMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 通用推荐入口
+     */
+    public Map<String, Object> recommend(RecommendRequestDTO request, Long userId, Long familyId) {
+        String type = request.getType();
+        String dimensionCode = request.getDimensionCode();
+        Long childId = request.getChildId();
+        int size = request.getSize() != null ? request.getSize() : 10;
+
+        // 解析排除ID列表
+        Set<Long> excludeIds = new HashSet<>();
+        if (request.getExcludeIds() != null && !request.getExcludeIds().isEmpty()) {
+            for (String id : request.getExcludeIds().split(",")) {
+                try { excludeIds.add(Long.parseLong(id.trim())); } catch (NumberFormatException ignored) {}
+            }
+        }
+
+        List<RecommendItemVO> items;
+
+        switch (type) {
+            case "article":
+                items = recommendArticles(dimensionCode, userId, familyId, childId, excludeIds);
+                break;
+            case "task":
+                items = recommendTasks(dimensionCode, childId, excludeIds);
+                break;
+            case "product":
+                items = recommendProducts(dimensionCode, userId, familyId, excludeIds);
+                break;
+            case "activity":
+                items = recommendActivities(dimensionCode, userId, familyId, excludeIds);
+                break;
+            default:
+                items = new ArrayList<>();
+        }
+
+        // 按推荐分降序排列
+        items.sort((a, b) -> Integer.compare(
+                b.getRecommendScore() != null ? b.getRecommendScore() : 0,
+                a.getRecommendScore() != null ? a.getRecommendScore() : 0));
+
+        // 取前size条
+        List<RecommendItemVO> result = items.size() > size ? items.subList(0, size) : items;
+
+        // 记录推荐展现日志(异步风格,不阻塞)
+        try {
+            logRecommendations(userId, familyId, childId, type, result);
+        } catch (Exception e) {
+            log.warn("记录推荐日志失败: {}", e.getMessage());
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("recommendations", result);
+        data.put("hasMore", items.size() > size);
+        return data;
+    }
+
+    // ==================== 文章推荐 ====================
+
+    private List<RecommendItemVO> recommendArticles(String dimensionCode, Long userId,
+                                                     Long familyId, Long childId, Set<Long> excludeIds) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT 50");
+
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+            wrapper.and(w -> w
+                    .apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + dimensionCode.trim() + "'), 0) > 0")
+                    .or().like(Article::getRelatedDimensions, dimensionCode.trim()));
+        }
+
+        List<Article> allArticles = articleMapper.selectList(wrapper);
+        if (allArticles.isEmpty()) return new ArrayList<>();
+
+        // 获取孩子已读的文章ID
+        Set<Long> readArticleIds = new HashSet<>();
+        if (childId != null) {
+            List<ArticleReadingRecord> records = articleReadingRecordMapper.selectList(
+                    new LambdaQueryWrapper<ArticleReadingRecord>()
+                            .eq(ArticleReadingRecord::getChildId, childId)
+                            .select("DISTINCT article_id"));
+            for (ArticleReadingRecord r : records) {
+                if (r.getArticleId() != null) readArticleIds.add(r.getArticleId());
+            }
+        }
+
+        List<RecommendItemVO> items = new ArrayList<>();
+        for (Article a : allArticles) {
+            if (excludeIds.contains(a.getId())) continue;
+            if (readArticleIds.contains(a.getId())) continue;
+
+            int score = 0;
+            List<String> reasons = new ArrayList<>();
+
+            // 维度匹配 (0-25)
+            if (dimensionCode != null && hasDimensionMatch(a.getDimensionWeights(), dimensionCode)) {
+                score += 20;
+                reasons.add(dimensionLabel(dimensionCode) + "相关");
+            } else {
+                score += 5;
+            }
+
+            // 新鲜度 (0-15)
+            score += calcFreshnessScore(a.getPublishedAt(), 15);
+
+            // 热度 (0-10)
+            int viewCount = a.getViewCount() != null ? a.getViewCount() : 0;
+            score += Math.min(10, viewCount / 100);
+            if (viewCount >= 500) reasons.add("热门");
+
+            // 内容类型加权
+            String contentType = a.getContentType();
+            if ("course".equals(contentType)) score += 8;
+            else if ("knowledge".equals(contentType)) score += 6;
+            else if ("article".equals(contentType)) score += 4;
+
+            RecommendItemVO vo = new RecommendItemVO();
+            vo.setId(a.getId());
+            vo.setTitle(a.getTitle());
+            vo.setSummary(a.getSummary());
+            vo.setCoverImage(a.getCoverImage());
+            vo.setDimensionCode(dimensionCode);
+            vo.setRecommendScore(score);
+            vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
+            vo.setBadge(score >= 30 ? "hot" : "");
+            items.add(vo);
+        }
+        return items;
+    }
+
+    // ==================== 任务推荐 ====================
+
+    private List<RecommendItemVO> recommendTasks(String dimensionCode, Long childId, Set<Long> excludeIds) {
+        if (childId == null) return new ArrayList<>();
+
+        // 获取已存在的任务(用于去重)
+        Set<Long> articleSourceIds = new HashSet<>();
+        List<Task> existingTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                        .eq(Task::getChildId, childId)
+                        .in(Task::getStatus, "pending", "completed")
+                        .select("id", "source_type", "source_id"));
+        for (Task t : existingTasks) {
+            if ("article".equals(t.getSourceType()) && t.getSourceId() != null) {
+                articleSourceIds.add(t.getSourceId());
+            }
+        }
+
+        List<RecommendItemVO> items = new ArrayList<>();
+
+        // 从已发布文章中推荐可转化为任务的
+        LambdaQueryWrapper<Article> articleWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT 20");
+
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+            articleWrapper.and(w -> w
+                    .apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + dimensionCode.trim() + "'), 0) > 0")
+                    .or().like(Article::getRelatedDimensions, dimensionCode.trim()));
+        }
+
+        List<Article> articles = articleMapper.selectList(articleWrapper);
+        for (Article a : articles) {
+            if (excludeIds.contains(a.getId())) continue;
+            if (articleSourceIds.contains(a.getId())) continue;
+
+            int score = 20;
+            List<String> reasons = new ArrayList<>();
+            reasons.add("阅读任务");
+
+            if (dimensionCode != null && hasDimensionMatch(a.getDimensionWeights(), dimensionCode)) {
+                score += 15;
+            }
+
+            score += calcFreshnessScore(a.getPublishedAt(), 10);
+
+            RecommendItemVO vo = new RecommendItemVO();
+            vo.setId(a.getId());
+            vo.setTitle("阅读: " + a.getTitle());
+            vo.setSummary(a.getSummary());
+            vo.setCoverImage(a.getCoverImage());
+            vo.setDimensionCode(dimensionCode);
+            vo.setRecommendScore(score);
+            vo.setRecommendReason(String.join("·", reasons));
+            vo.setBadge("task");
+            items.add(vo);
+        }
+
+        return items;
+    }
+
+    // ==================== 商品推荐 ====================
+
+    private List<RecommendItemVO> recommendProducts(String dimensionCode, Long userId,
+                                                     Long familyId, Set<Long> excludeIds) {
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .orderByDesc(Product::getSalesCount)
+                .last("LIMIT 50");
+
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+            wrapper.eq(Product::getDomain, dimensionCode);
+        }
+
+        List<Product> allProducts = productMapper.selectList(wrapper);
+        if (allProducts.isEmpty()) return new ArrayList<>();
+
+        // 获取家庭已购商品ID
+        Set<Long> purchasedProductIds = getPurchasedProductIds(familyId, userId);
+
+        // 获取周期性服务配置
+        List<PeriodicService> periodicServices = periodicServiceMapper.selectList(
+                new LambdaQueryWrapper<PeriodicService>().eq(PeriodicService::getEnabled, 1));
+        Map<Long, PeriodicService> psMap = new HashMap<>();
+        for (PeriodicService ps : periodicServices) {
+            psMap.put(ps.getProductId(), ps);
+        }
+
+        // 获取用户的订单历史
+        List<ProductOrder> userOrders = new ArrayList<>();
+        if (userId != null) {
+            LambdaQueryWrapper<ProductOrder> orderWrapper = new LambdaQueryWrapper<ProductOrder>()
+                    .eq(ProductOrder::getStatus, "paid")
+                    .orderByDesc(ProductOrder::getCreatedAt);
+            if (familyId != null) {
+                orderWrapper.eq(ProductOrder::getFamilyId, familyId);
+            } else {
+                orderWrapper.eq(ProductOrder::getBuyerId, userId);
+            }
+            userOrders = productOrderMapper.selectList(orderWrapper);
+        }
+
+        List<RecommendItemVO> items = new ArrayList<>();
+        for (Product p : allProducts) {
+            if (excludeIds.contains(p.getId())) continue;
+
+            // 消费去重
+            boolean isFamilyScope = p.getFamilyScope() == null || p.getFamilyScope() == 1;
+            if (isFamilyScope && purchasedProductIds.contains(p.getId())) continue;
+            if (!isFamilyScope && userId != null) {
+                boolean personalPurchased = productOrderMapper.selectCount(
+                        new LambdaQueryWrapper<ProductOrder>()
+                                .eq(ProductOrder::getProductId, p.getId())
+                                .eq(ProductOrder::getBuyerId, userId)
+                                .eq(ProductOrder::getStatus, "paid")) > 0;
+                if (personalPurchased) continue;
+            }
+
+            int score = 0;
+            List<String> reasons = new ArrayList<>();
+
+            // 维度匹配 (0-25)
+            if (dimensionCode != null && dimensionCode.equals(p.getDomain())) {
+                score += 20;
+                reasons.add(dimensionLabel(dimensionCode) + "好物");
+            } else {
+                score += 5;
+            }
+
+            // 销量热度 (0-10)
+            int sales = p.getSalesCount() != null ? p.getSalesCount() : 0;
+            score += Math.min(10, sales / 10);
+            if (sales >= 50) reasons.add("热销");
+
+            // 周期性服务续费提示 (0-20)
+            PeriodicService ps = psMap.get(p.getId());
+            if (ps != null) {
+                int renewScore = calcRenewalScore(p.getId(), ps, userOrders);
+                if (renewScore > 0) {
+                    score += renewScore;
+                    reasons.add("续费提醒");
+                }
+            }
+
+            RecommendItemVO vo = new RecommendItemVO();
+            vo.setId(p.getId());
+            vo.setTitle(p.getName());
+            vo.setSummary(p.getIntro());
+            vo.setCoverImage(p.getCoverImage());
+            vo.setDimensionCode(p.getDomain());
+            vo.setRecommendScore(score);
+            vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
+            vo.setBadge(ps != null && calcRenewalScore(p.getId(), ps, userOrders) > 0 ? "renew" :
+                       score >= 25 ? "hot" : "");
+            items.add(vo);
+        }
+        return items;
+    }
+
+    // ==================== 活动推荐 ====================
+
+    private List<RecommendItemVO> recommendActivities(String dimensionCode, Long userId,
+                                                       Long familyId, Set<Long> excludeIds) {
+        LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
+                .eq(Activity::getStatus, "published")
+                .ge(Activity::getEndTime, new Date())
+                .orderByDesc(Activity::getStartTime)
+                .last("LIMIT 50");
+
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+            wrapper.eq(Activity::getDimensionCode, dimensionCode);
+        }
+
+        List<Activity> allActivities = activityMapper.selectList(wrapper);
+        if (allActivities.isEmpty()) return new ArrayList<>();
+
+        // 获取家庭已参与活动ID
+        Set<Long> registeredActivityIds = getRegisteredActivityIds(familyId, userId);
+
+        List<RecommendItemVO> items = new ArrayList<>();
+        for (Activity a : allActivities) {
+            if (excludeIds.contains(a.getId())) continue;
+
+            // 参与去重
+            boolean isFamilyScope = a.getFamilyScope() == null || a.getFamilyScope() == 1;
+            if (isFamilyScope && registeredActivityIds.contains(a.getId())) continue;
+            if (!isFamilyScope && userId != null) {
+                boolean personalRegistered = activityRegistrationMapper.selectCount(
+                        new LambdaQueryWrapper<ActivityRegistration>()
+                                .eq(ActivityRegistration::getActivityId, a.getId())
+                                .eq(ActivityRegistration::getUserId, userId)) > 0;
+                if (personalRegistered) continue;
+            }
+
+            int score = 0;
+            List<String> reasons = new ArrayList<>();
+
+            // 维度匹配 (0-25)
+            if (dimensionCode != null && dimensionCode.equals(a.getDimensionCode())) {
+                score += 20;
+                reasons.add(dimensionLabel(dimensionCode) + "活动");
+            } else {
+                score += 5;
+            }
+
+            // 新鲜度 (0-15)
+            score += calcFreshnessScore(a.getCreatedAt(), 15);
+
+            // 参与热度 (0-10)
+            int participants = a.getCurrentParticipants() != null ? a.getCurrentParticipants() : 0;
+            score += Math.min(10, participants);
+            if (participants >= 20) reasons.add("热门");
+
+            RecommendItemVO vo = new RecommendItemVO();
+            vo.setId(a.getId());
+            vo.setTitle(a.getTitle());
+            vo.setSummary(a.getDescription());
+            vo.setCoverImage(a.getCoverImage());
+            vo.setDimensionCode(a.getDimensionCode());
+            vo.setRecommendScore(score);
+            vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
+            vo.setBadge(score >= 30 ? "hot" : "");
+            items.add(vo);
+        }
+        return items;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    /** 检查dimension_weights JSON中是否包含指定维度的正权重 */
+    private boolean hasDimensionMatch(String dimensionWeightsJson, String dimCode) {
+        if (dimensionWeightsJson == null || dimensionWeightsJson.isEmpty()) return false;
+        try {
+            String key = "\"" + dimCode + "\"";
+            int idx = dimensionWeightsJson.indexOf(key);
+            if (idx < 0) return false;
+            int colonIdx = dimensionWeightsJson.indexOf(':', idx);
+            int commaIdx = dimensionWeightsJson.indexOf(',', colonIdx);
+            int braceIdx = dimensionWeightsJson.indexOf('}', colonIdx);
+            int endIdx = commaIdx > 0 && commaIdx < braceIdx ? commaIdx : braceIdx;
+            if (endIdx < 0) endIdx = dimensionWeightsJson.length();
+            String valStr = dimensionWeightsJson.substring(colonIdx + 1, endIdx).trim();
+            return Integer.parseInt(valStr) > 0;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /** 计算新鲜度分 */
+    private int calcFreshnessScore(Date date, int maxScore) {
+        if (date == null) return 0;
+        long diff = System.currentTimeMillis() - date.getTime();
+        long days = diff / (24 * 60 * 60 * 1000);
+        if (days <= 7) return maxScore;
+        if (days <= 30) return (int) (maxScore * 0.7);
+        if (days <= 90) return (int) (maxScore * 0.4);
+        if (days <= 180) return (int) (maxScore * 0.2);
+        return 0;
+    }
+
+    /** 计算续费提醒分 */
+    private int calcRenewalScore(Long productId, PeriodicService ps, List<ProductOrder> orders) {
+        for (ProductOrder order : orders) {
+            if (!productId.equals(order.getProductId())) continue;
+            Date paidAt = order.getPaidAt() != null ? order.getPaidAt() : order.getCreatedAt();
+            if (paidAt == null) continue;
+            long serviceEnd = paidAt.getTime() + ps.getCycleDays() * 24L * 60 * 60 * 1000;
+            long now = System.currentTimeMillis();
+            long daysLeft = (serviceEnd - now) / (24 * 60 * 60 * 1000);
+            int remindDays = ps.getRemindBeforeDays() != null ? ps.getRemindBeforeDays() : 30;
+            if (daysLeft <= remindDays && daysLeft >= 0) {
+                return 20;
+            }
+            if (daysLeft < 0 && daysLeft > -30) {
+                return 15;
+            }
+            return 0;
+        }
+        return 5;
+    }
+
+    /** 获取家庭已购商品ID */
+    private Set<Long> getPurchasedProductIds(Long familyId, Long userId) {
+        Set<Long> ids = new HashSet<>();
+        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
+                .eq(ProductOrder::getStatus, "paid");
+        if (familyId != null) {
+            wrapper.eq(ProductOrder::getFamilyId, familyId);
+        } else if (userId != null) {
+            wrapper.eq(ProductOrder::getBuyerId, userId);
+        } else {
+            return ids;
+        }
+        List<ProductOrder> orders = productOrderMapper.selectList(wrapper.select("distinct product_id"));
+        for (ProductOrder o : orders) {
+            ids.add(o.getProductId());
+        }
+        return ids;
+    }
+
+    /** 获取家庭已参与活动ID */
+    private Set<Long> getRegisteredActivityIds(Long familyId, Long userId) {
+        Set<Long> ids = new HashSet<>();
+        if (familyId != null) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+            Set<Long> memberUserIds = new HashSet<>();
+            for (FamilyMember m : members) {
+                if (m.getUserId() != null) memberUserIds.add(m.getUserId());
+            }
+            if (!memberUserIds.isEmpty()) {
+                List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
+                        new LambdaQueryWrapper<ActivityRegistration>()
+                                .in(ActivityRegistration::getUserId, memberUserIds)
+                                .select("distinct activity_id"));
+                for (ActivityRegistration r : regs) {
+                    ids.add(r.getActivityId());
+                }
+            }
+        } else if (userId != null) {
+            List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
+                    new LambdaQueryWrapper<ActivityRegistration>()
+                            .eq(ActivityRegistration::getUserId, userId)
+                            .select("distinct activity_id"));
+            for (ActivityRegistration r : regs) {
+                ids.add(r.getActivityId());
+            }
+        }
+        return ids;
+    }
+
+    /** 记录推荐日志 */
+    private void logRecommendations(Long userId, Long familyId, Long childId,
+                                     String type, List<RecommendItemVO> items) {
+        for (RecommendItemVO item : items) {
+            RecommendationLog log = new RecommendationLog();
+            log.setUserId(userId);
+            log.setFamilyId(familyId);
+            log.setChildId(childId);
+            log.setContentType(type);
+            log.setContentId(item.getId());
+            log.setRecommendScore(item.getRecommendScore());
+            log.setAction("show");
+            log.setCreatedAt(new Date());
+            recommendationLogMapper.insert(log);
+        }
+    }
+
+    /** 维度代码 → 中文标签 */
+    private String dimensionLabel(String code) {
+        if (code == null) return "";
+        switch (code) {
+            case "body": return "身体";
+            case "mind": return "心理";
+            case "wisdom": return "智慧";
+            case "action": return "行动";
+            case "wealth": return "财富";
+            default: return code;
+        }
+    }
+}

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

@@ -3638,3 +3638,41 @@ CREATE TABLE IF NOT EXISTS `service_role_applications` (
 -- 用户表新增字段(服务角色申请状态)
 -- ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected';
 -- ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected';
+
+-- ============================================================
+-- 周期性服务续费配置
+-- ============================================================
+CREATE TABLE IF NOT EXISTS periodic_services (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '关联商品ID',
+    service_name VARCHAR(100) COMMENT '服务名称',
+    cycle_days INT NOT NULL COMMENT '周期天数(如365=年度服务)',
+    remind_before_days INT DEFAULT 30 COMMENT '提前提醒天数',
+    auto_renew TINYINT DEFAULT 0 COMMENT '是否支持自动续费: 0=否 1=是',
+    enabled TINYINT DEFAULT 1 COMMENT '是否启用: 0=禁用 1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='周期性服务续费配置';
+
+-- ============================================================
+-- 推荐行为记录
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recommendation_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    family_id BIGINT COMMENT '家庭ID',
+    child_id BIGINT COMMENT '孩子ID',
+    content_type VARCHAR(20) NOT NULL COMMENT '内容类型: article/task/product/activity',
+    content_id BIGINT NOT NULL COMMENT '内容ID',
+    recommend_score INT COMMENT '推荐分值',
+    action VARCHAR(20) COMMENT '行为: show/click/consume',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_user_content (user_id, content_type, content_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐行为记录';
+
+-- products表添加family_scope列(推荐系统-家庭消费去重)
+-- ALTER TABLE products ADD COLUMN family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除';
+
+-- activities表添加family_scope列(推荐系统-家庭参与去重)
+-- ALTER TABLE activities ADD COLUMN family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除';

+ 169 - 0
cfc-frontend/components/RecommendedSection.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="recommended-section" v-if="items && items.length > 0">
+    <view class="section-header">
+      <text class="section-icon">✨</text>
+      <text class="section-title">为你推荐</text>
+      <text class="section-more" v-if="hasMore" @click="$emit('more')">更多 ›</text>
+    </view>
+    <scroll-view class="scroll-x" scroll-x show-scrollbar="false">
+      <view class="card-list">
+        <view
+          class="card-item"
+          v-for="(item, idx) in items"
+          :key="idx"
+          @click="onItemClick(item)"
+          hover-class="card-hover"
+        >
+          <image class="card-img" :src="item.coverImage || '/static/default.png'" mode="aspectFill" />
+          <view class="card-body">
+            <text class="card-title line-clamp-2">{{ item.title }}</text>
+            <view class="card-footer">
+              <text class="card-reason line-clamp-1">{{ item.recommendReason }}</text>
+              <text class="card-badge" v-if="item.badge" :class="'badge-' + item.badge">{{ badgeLabel(item.badge) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    items: {
+      type: Array,
+      default: function() { return [] }
+    },
+    hasMore: {
+      type: Boolean,
+      default: false
+    }
+  },
+  methods: {
+    onItemClick(item) {
+      this.$emit('itemClick', item)
+    },
+    badgeLabel(badge) {
+      var map = {
+        hot: '热门',
+        new: '最新',
+        renew: '续费',
+        task: '任务'
+      }
+      return map[badge] || badge
+    }
+  }
+}
+</script>
+
+<style scoped>
+.recommended-section {
+  margin: 20rpx 30rpx;
+}
+.section-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.section-icon {
+  font-size: 28rpx;
+  margin-right: 8rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #333;
+  flex: 1;
+}
+.section-more {
+  font-size: 24rpx;
+  color: #999;
+}
+.scroll-x {
+  white-space: nowrap;
+  overflow: hidden;
+}
+.card-list {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+.card-item {
+  display: flex;
+  flex-direction: column;
+  width: 240rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  flex-shrink: 0;
+}
+.card-hover {
+  opacity: 0.85;
+}
+.card-img {
+  width: 240rpx;
+  height: 160rpx;
+  background: #f5f5f5;
+}
+.card-body {
+  padding: 12rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+.card-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #333;
+  line-height: 1.4;
+  height: 72rpx;
+}
+.card-footer {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+}
+.card-reason {
+  font-size: 20rpx;
+  color: #999;
+  flex: 1;
+  margin-right: 8rpx;
+}
+.card-badge {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+}
+.badge-hot {
+  background: #fff1f0;
+  color: #f56c6c;
+}
+.badge-new {
+  background: #e6f7ff;
+  color: #1890ff;
+}
+.badge-renew {
+  background: #fff7e6;
+  color: #fa8c16;
+}
+.badge-task {
+  background: #f6ffed;
+  color: #52c41a;
+}
+.line-clamp-1 {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.line-clamp-2 {
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+</style>

+ 5 - 1
cfc-frontend/utils/api.js

@@ -2209,4 +2209,8 @@ export const uploadServiceRoleImage = (filePath) => {
             fail: reject
         })
     })
-}
+}
+
+// 推荐系统
+export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
+export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)

+ 49 - 0
cfc-web/src/api/periodic-service.js

@@ -0,0 +1,49 @@
+import request from '../utils/request'
+
+export function getPeriodicServiceList(data) {
+  return request({
+    url: '/admin/periodic-service/list',
+    method: 'post',
+    data
+  })
+}
+
+export function getPeriodicServiceDetail(data) {
+  return request({
+    url: '/admin/periodic-service/detail',
+    method: 'post',
+    data
+  })
+}
+
+export function createPeriodicService(data) {
+  return request({
+    url: '/admin/periodic-service/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updatePeriodicService(data) {
+  return request({
+    url: '/admin/periodic-service/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deletePeriodicService(data) {
+  return request({
+    url: '/admin/periodic-service/delete',
+    method: 'post',
+    data
+  })
+}
+
+export function togglePeriodicService(data) {
+  return request({
+    url: '/admin/periodic-service/toggle',
+    method: 'post',
+    data
+  })
+}

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

@@ -319,6 +319,12 @@ const routes = [
         component: () => import('@/views/admin/CommentReview.vue'),
         meta: { title: '评论审核', perm: 'articles:manage' }
       },
+      {
+        path: 'periodic-service-config',
+        name: 'PeriodicServiceConfig',
+        component: () => import('@/views/admin/PeriodicServiceConfig.vue'),
+        meta: { title: '周期性服务配置', perm: 'admin' }
+      },
       {
         path: 'knowledge-tags',
         name: 'KnowledgeTag',

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

@@ -236,6 +236,7 @@ export default {
             { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'energy' },
             { path: '/energy-rule', label: '能量规则管理', icon: 'el-icon-setting', perm: 'energy' },
             { path: '/energy-behavior-config', label: '能量行为配置', icon: 'el-icon-edit-outline', perm: 'energy' },
+            { path: '/periodic-service-config', label: '周期性服务配置', icon: 'el-icon-refresh', perm: 'energy' },
             { path: '/foods', label: '食材管理', icon: 'el-icon-apple', perm: 'diet:foods' },
             { path: '/recipes', label: '食谱管理', icon: 'el-icon-dish', perm: 'diet:recipes' },
             { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-sunny', perm: 'diet:seasonal' },

+ 197 - 0
cfc-web/src/views/admin/PeriodicServiceConfig.vue

@@ -0,0 +1,197 @@
+<template>
+  <div class="periodic-service-config">
+    <div class="header">
+      <h2>周期性服务续费配置</h2>
+      <el-button type="primary" icon="el-icon-plus" @click="openDialog()">新增配置</el-button>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width: 100%">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="productId" label="商品ID" width="80" />
+      <el-table-column prop="serviceName" label="服务名称" min-width="160" />
+      <el-table-column prop="cycleDays" label="周期天数" width="100" />
+      <el-table-column prop="remindBeforeDays" label="提前提醒(天)" width="120" />
+      <el-table-column label="自动续费" width="100">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.autoRenew === 1 ? 'success' : 'info'" size="small">
+            {{ scope.row.autoRenew === 1 ? '支持' : '不支持' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="80">
+        <template slot-scope="scope">
+          <el-switch v-model="scope.row.enabled" :active-value="1" :inactive-value="0"
+            @change="toggle(scope.row)" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="160" fixed="right">
+        <template slot-scope="scope">
+          <el-button type="text" size="small" @click="openDialog(scope.row)">编辑</el-button>
+          <el-button type="text" size="small" style="color: #f56c6c"
+            @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @closed="resetForm">
+      <el-form :model="form" label-width="120px" size="small">
+        <el-form-item label="商品ID" required>
+          <el-input-number v-model="form.productId" :min="1" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="服务名称">
+          <el-input v-model="form.serviceName" placeholder="可选,留空则使用商品名称" />
+        </el-form-item>
+        <el-form-item label="周期天数" required>
+          <el-input-number v-model="form.cycleDays" :min="1" :max="3650" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="提前提醒天数">
+          <el-input-number v-model="form.remindBeforeDays" :min="1" :max="180" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="自动续费">
+          <el-switch v-model="form.autoRenew" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="save">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getPeriodicServiceList, createPeriodicService, updatePeriodicService, deletePeriodicService, togglePeriodicService } from '@/api/periodic-service'
+
+export default {
+  data() {
+    return {
+      loading: false,
+      list: [],
+      dialogVisible: false,
+      dialogTitle: '',
+      form: {
+        productId: null,
+        serviceName: '',
+        cycleDays: 365,
+        remindBeforeDays: 30,
+        autoRenew: 0,
+        enabled: 1
+      },
+      editingId: null
+    }
+  },
+  mounted() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getPeriodicServiceList()
+        if (res.data && res.data.code === 200) {
+          this.list = res.data.data || []
+        }
+      } catch (e) {
+        console.error('获取列表失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    openDialog(row) {
+      if (row && row.id) {
+        this.editingId = row.id
+        this.dialogTitle = '编辑周期性服务配置'
+        this.form = {
+          productId: row.productId,
+          serviceName: row.serviceName || '',
+          cycleDays: row.cycleDays,
+          remindBeforeDays: row.remindBeforeDays,
+          autoRenew: row.autoRenew,
+          enabled: row.enabled
+        }
+      } else {
+        this.editingId = null
+        this.dialogTitle = '新增周期性服务配置'
+        this.resetForm()
+      }
+      this.dialogVisible = true
+    },
+    resetForm() {
+      this.form = {
+        productId: null,
+        serviceName: '',
+        cycleDays: 365,
+        remindBeforeDays: 30,
+        autoRenew: 0,
+        enabled: 1
+      }
+    },
+    async save() {
+      if (!this.form.productId) {
+        this.$message.error('请输入商品ID')
+        return
+      }
+      if (!this.form.cycleDays || this.form.cycleDays < 1) {
+        this.$message.error('请输入有效的周期天数')
+        return
+      }
+      try {
+        let res
+        if (this.editingId) {
+          res = await updatePeriodicService({ id: this.editingId, ...this.form })
+        } else {
+          res = await createPeriodicService(this.form)
+        }
+        if (res.data && res.data.code === 200) {
+          this.$message.success(this.editingId ? '更新成功' : '创建成功')
+          this.dialogVisible = false
+          this.fetchList()
+        } else {
+          this.$message.error(res.data?.message || '操作失败')
+        }
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
+    async toggle(row) {
+      try {
+        const res = await togglePeriodicService({ id: row.id })
+        if (res.data && res.data.code === 200) {
+          this.$message.success(res.data.message || '操作成功')
+        }
+      } catch (e) {
+        this.$message.error('操作失败')
+        row.enabled = row.enabled === 1 ? 0 : 1
+      }
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该配置吗?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          const res = await deletePeriodicService({ id: row.id })
+          if (res.data && res.data.code === 200) {
+            this.$message.success('删除成功')
+            this.fetchList()
+          }
+        } catch (e) {
+          this.$message.error('删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.periodic-service-config {
+  padding: 20px;
+}
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+</style>