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

feat(api): 改版v1 C类接口 - 问题域/用户意图/通知/专项调研/套餐/物流 + 会员校验 + 报告钩子 + CF奖励

E2E Test Bot 2 месяцев назад
Родитель
Сommit
b67d757749

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PackageLogisticsController.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.PackageOrder;
+import com.etotem.cfc.mapper.PackageOrderMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+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 java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 套餐物流 — 改版v1(B19)
+ * <p>
+ * 注意:类名避开 shop 包的 LogisticsController(Bean 名冲突)。
+ */
+@Tag(name = "套餐物流", description = "套餐订单物流状态(管理端发货更新 / 用户查询)")
+@RestController
+@RequestMapping("/api/logistics")
+public class PackageLogisticsController {
+
+    @Resource
+    private PackageOrderMapper packageOrderMapper;
+
+    @Operation(summary = "管理端发货/更新物流(orderId + logisticsStatus + trackingNo)")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestAttribute("userId") Long userId,
+                                  @RequestAttribute(value = "role", required = false) String role,
+                                  @RequestBody Map<String, Object> params) {
+        Long orderId = params.get("orderId") != null
+                ? Long.valueOf(params.get("orderId").toString()) : null;
+        if (orderId == null) {
+            return Result.error("orderId 不能为空");
+        }
+        PackageOrder order = packageOrderMapper.selectById(orderId);
+        if (order == null) {
+            return Result.error("订单不存在");
+        }
+        String logisticsStatus = params.get("logisticsStatus") != null
+                ? params.get("logisticsStatus").toString() : null;
+        String trackingNo = params.get("trackingNo") != null
+                ? params.get("trackingNo").toString() : null;
+        if (logisticsStatus == null || logisticsStatus.isEmpty()) {
+            return Result.error("logisticsStatus 不能为空");
+        }
+        // 状态取值与 schema 一致: none/kit_shipped/kit_received/sample_shipped/sample_received
+        order.setLogisticsStatus(logisticsStatus);
+        if (trackingNo != null && !trackingNo.isEmpty()) {
+            order.setTrackingNo(trackingNo);
+        }
+        order.setUpdatedAt(new Date());
+        packageOrderMapper.updateById(order);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "用户查询我的套餐订单物流状态")
+    @PostMapping("/my")
+    public Result<List<PackageOrder>> my(@RequestAttribute("userId") Long userId) {
+        List<PackageOrder> orders = packageOrderMapper.selectList(
+                new LambdaQueryWrapper<PackageOrder>()
+                        .eq(PackageOrder::getUserId, userId)
+                        .orderByDesc(PackageOrder::getCreatedAt));
+        return Result.success(orders);
+    }
+}

+ 133 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemDomainController.java

@@ -0,0 +1,133 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProblemDomain;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.entity.TaskTemplatePackage;
+import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.mapper.TaskTemplatePackageMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 问题域 — 改版v1 需求驱动·逐步解锁(C类)
+ */
+@Tag(name = "问题域", description = "问题域列表(含问卷模板、推荐产品、任务模板)")
+@RestController
+@RequestMapping("/api/problem-domain")
+public class ProblemDomainController {
+
+    @Resource
+    private ProblemDomainMapper problemDomainMapper;
+
+    @Resource
+    private SurveyTemplateMapper surveyTemplateMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private TaskTemplatePackageMapper taskTemplatePackageMapper;
+
+    @Operation(summary = "启用中的问题域列表(含问卷模板 + 推荐产品 + 任务模板)")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list() {
+        List<ProblemDomain> domains = problemDomainMapper.selectList(
+                new LambdaQueryWrapper<ProblemDomain>()
+                        .eq(ProblemDomain::getStatus, 1)
+                        .orderByAsc(ProblemDomain::getSortOrder)
+                        .orderByAsc(ProblemDomain::getId));
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (ProblemDomain domain : domains) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", domain.getId());
+            item.put("code", domain.getCode());
+            item.put("name", domain.getName());
+            item.put("icon", domain.getIcon());
+            item.put("description", domain.getDescription());
+            item.put("flowIntroJson", domain.getFlowIntroJson());
+            item.put("contentTags", domain.getContentTags());
+            item.put("rewardCfValue", domain.getRewardCfValue());
+            item.put("sortOrder", domain.getSortOrder());
+
+            // 问卷模板
+            if (domain.getSurveyTemplateId() != null) {
+                SurveyTemplate template = surveyTemplateMapper.selectById(domain.getSurveyTemplateId());
+                if (template != null) {
+                    Map<String, Object> tm = new HashMap<>();
+                    tm.put("id", template.getId());
+                    tm.put("title", template.getTitle());
+                    tm.put("dimension", template.getDimension());
+                    tm.put("questionsJson", template.getQuestionsJson());
+                    item.put("surveyTemplate", tm);
+                }
+            }
+
+            // 推荐产品
+            List<Map<String, Object>> products = new ArrayList<>();
+            if (domain.getRecommendedProductIds() != null && !domain.getRecommendedProductIds().isEmpty()) {
+                for (String idStr : domain.getRecommendedProductIds().split(",")) {
+                    try {
+                        Long pid = Long.parseLong(idStr.trim());
+                        Product product = productMapper.selectById(pid);
+                        if (product == null || !"on".equals(product.getStatus())) {
+                            continue;
+                        }
+                        Map<String, Object> pm = new HashMap<>();
+                        pm.put("id", product.getId());
+                        pm.put("name", product.getName());
+                        pm.put("coverImage", product.getCoverImage());
+                        pm.put("price", product.getPrice());
+                        pm.put("memberPrice", product.getMemberPrice());
+                        pm.put("intro", product.getIntro());
+                        products.add(pm);
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+            item.put("products", products);
+
+            // 任务模板
+            List<Map<String, Object>> packages = new ArrayList<>();
+            if (domain.getTaskTemplatePackageIds() != null && !domain.getTaskTemplatePackageIds().isEmpty()) {
+                for (String idStr : domain.getTaskTemplatePackageIds().split(",")) {
+                    try {
+                        Long pid = Long.parseLong(idStr.trim());
+                        TaskTemplatePackage pkg = taskTemplatePackageMapper.selectById(pid);
+                        if (pkg == null) {
+                            continue;
+                        }
+                        Map<String, Object> pm = new HashMap<>();
+                        pm.put("id", pkg.getId());
+                        pm.put("name", pkg.getName());
+                        pm.put("description", pkg.getDescription());
+                        pm.put("cycleDays", pkg.getCycleDays());
+                        pm.put("category", pkg.getCategory());
+                        pm.put("price", pkg.getPrice());
+                        pm.put("coverImage", pkg.getCoverImage());
+                        packages.add(pm);
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+            item.put("packages", packages);
+
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.TaskTemplateItem;
+import com.etotem.cfc.entity.TaskTemplatePackage;
+import com.etotem.cfc.service.TaskTemplatePackageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 问题域套餐 — 改版v1(套餐详情:介绍/使用方式/包含检测/任务模板/奖励说明)
+ */
+@Tag(name = "问题域套餐", description = "套餐详情查询")
+@RestController
+@RequestMapping("/api/problem-package")
+public class ProblemPackageController {
+
+    @Resource
+    private TaskTemplatePackageService taskTemplatePackageService;
+
+    @Operation(summary = "套餐详情(名称/介绍/周期/包含任务模板/奖励说明)")
+    @PostMapping("/detail")
+    public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> params) {
+        Long packageId = params.get("packageId") != null
+                ? Long.valueOf(params.get("packageId").toString()) : null;
+        if (packageId == null) {
+            return Result.error("packageId 不能为空");
+        }
+        TaskTemplatePackage pkg = taskTemplatePackageService.getPackageById(packageId);
+        if (pkg == null) {
+            return Result.error("套餐不存在");
+        }
+        List<TaskTemplateItem> items = taskTemplatePackageService.getPackageItems(packageId);
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("id", pkg.getId());
+        data.put("name", pkg.getName());
+        data.put("description", pkg.getDescription());
+        data.put("cycleDays", pkg.getCycleDays());
+        data.put("category", pkg.getCategory());
+        data.put("tags", pkg.getTags());
+        data.put("price", pkg.getPrice());
+        data.put("platformFee", pkg.getPlatformFee());
+        data.put("coverImage", pkg.getCoverImage());
+        data.put("taskCount", items.size());
+        data.put("items", items);
+        return Result.success(data);
+    }
+}

+ 229 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemSurveyController.java

@@ -0,0 +1,229 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RecommendRequestDTO;
+import com.etotem.cfc.entity.ProblemDomain;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import com.etotem.cfc.service.ContentRecommendService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+import org.springframework.transaction.annotation.Transactional;
+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 java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 专项调研 — 改版v1(提交调研 → 汇总 resultTags → 推荐产品 → 存快照 → 推进旅程)
+ */
+@Tag(name = "专项调研", description = "问题域专项调研提交与结果推荐")
+@RestController
+@RequestMapping("/api/problem-survey")
+public class ProblemSurveyController {
+
+    @Resource
+    private SurveyTemplateMapper surveyTemplateMapper;
+
+    @Resource
+    private ProblemDomainMapper problemDomainMapper;
+
+    @Resource
+    private UserIntentMapper userIntentMapper;
+
+    @Resource
+    private ContentRecommendService contentRecommendService;
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Operation(summary = "提交专项调研:聚合 resultTags → 推荐产品 → 存快照 → 旅程推进")
+    @PostMapping("/submit")
+    @Transactional
+    public Result<Map<String, Object>> submit(@RequestAttribute("userId") Long userId,
+                                              @RequestAttribute(value = "familyId", required = false) Long familyId,
+                                              @RequestBody Map<String, Object> params) {
+        Long surveyTemplateId = params.get("surveyTemplateId") != null
+                ? Long.valueOf(params.get("surveyTemplateId").toString()) : null;
+        String problemDomainCode = params.get("problemDomainCode") != null
+                ? params.get("problemDomainCode").toString() : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        Object answersObj = params.get("answers");
+
+        SurveyTemplate template = surveyTemplateId != null ? surveyTemplateMapper.selectById(surveyTemplateId) : null;
+        if (template == null) {
+            return Result.error("问卷模板不存在");
+        }
+
+        // 1. 聚合 resultTags:从题目选项 tag/resultTag + 问题域 contentTags
+        Set<String> resultTags = new LinkedHashSet<>();
+        if (answersObj != null) {
+            try {
+                Map<String, Object> answers = OBJECT_MAPPER.convertValue(answersObj,
+                        new TypeReference<Map<String, Object>>() {});
+                resultTags.addAll(aggregateTagsFromAnswers(template.getQuestionsJson(), answers));
+            } catch (Exception ignored) {
+            }
+        }
+        ProblemDomain domain = null;
+        if (problemDomainCode != null && !problemDomainCode.isEmpty()) {
+            domain = problemDomainMapper.selectOne(
+                    new LambdaQueryWrapper<ProblemDomain>()
+                            .eq(ProblemDomain::getCode, problemDomainCode)
+                            .last("LIMIT 1"));
+            if (domain != null && domain.getContentTags() != null) {
+                for (String tag : domain.getContentTags().split(",")) {
+                    if (!tag.trim().isEmpty()) {
+                        resultTags.add(tag.trim());
+                    }
+                }
+            }
+        }
+
+        // 2. 推荐产品(按问卷维度 + 汇总标签)
+        Map<String, Object> recommendData = new HashMap<>();
+        RecommendRequestDTO recReq = new RecommendRequestDTO();
+        recReq.setType("product");
+        recReq.setDimensionCode(template.getDimension());
+        recReq.setChildId(memberId);
+        recReq.setSize(10);
+        try {
+            Map<String, Object> rec = contentRecommendService.recommend(recReq, userId, familyId);
+            recommendData = rec;
+        } catch (Exception e) {
+            // 推荐失败不阻断提交
+        }
+
+        // 3. 存快照到 user_intents(单用户唯一,up-sert)
+        UserIntent intent = userIntentMapper.selectOne(
+                new LambdaQueryWrapper<UserIntent>()
+                        .eq(UserIntent::getUserId, userId)
+                        .last("LIMIT 1"));
+        Date now = new Date();
+        Map<String, Object> snapshot = new HashMap<>();
+        snapshot.put("surveyTemplateId", template.getId());
+        snapshot.put("surveyTitle", template.getTitle());
+        snapshot.put("dimension", template.getDimension());
+        snapshot.put("resultTags", new ArrayList<>(resultTags));
+        snapshot.put("answers", answersObj);
+        snapshot.put("submittedAt", now);
+
+        if (intent == null) {
+            intent = new UserIntent();
+            intent.setUserId(userId);
+            intent.setIntentType(domain != null ? "C" : "B");
+            intent.setProblemDomainCode(problemDomainCode != null ? problemDomainCode : "");
+            intent.setJourneyStage(3);
+            intent.setStageUpdatedAt(now);
+            intent.setSurveyTemplateId(template.getId());
+            intent.setSurveySubmittedAt(now);
+            intent.setSurveyResultJson(toJson(snapshot));
+            intent.setCreatedAt(now);
+            intent.setUpdatedAt(now);
+            userIntentMapper.insert(intent);
+        } else {
+            intent.setSurveyTemplateId(template.getId());
+            intent.setSurveySubmittedAt(now);
+            intent.setSurveyResultJson(toJson(snapshot));
+            // 调研完成推进到阶段3
+            if (intent.getJourneyStage() == null || intent.getJourneyStage() < 3) {
+                intent.setJourneyStage(3);
+                intent.setStageUpdatedAt(now);
+            }
+            intent.setUpdatedAt(now);
+            userIntentMapper.updateById(intent);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("resultTags", new ArrayList<>(resultTags));
+        data.put("recommendations", recommendData.get("recommendations") != null
+                ? recommendData.get("recommendations") : new ArrayList<>());
+        data.put("snapshot", snapshot);
+        data.put("journeyStage", intent.getJourneyStage());
+        return Result.success(data);
+    }
+
+    /** 从题目选项聚合 resultTags(兼容 questionsJson 不同结构) */
+    private Set<String> aggregateTagsFromAnswers(String questionsJson, Map<String, Object> answers) {
+        Set<String> tags = new LinkedHashSet<>();
+        if (questionsJson == null || questionsJson.isEmpty()) {
+            return tags;
+        }
+        try {
+            List<Map<String, Object>> questions = new ArrayList<>();
+            Object parsed = OBJECT_MAPPER.readValue(questionsJson, Object.class);
+            if (parsed instanceof List) {
+                questions = OBJECT_MAPPER.convertValue(parsed,
+                        new TypeReference<List<Map<String, Object>>>() {});
+            } else if (parsed instanceof Map) {
+                Object qArr = ((Map<?, ?>) parsed).get("questions");
+                if (qArr instanceof List) {
+                    questions = OBJECT_MAPPER.convertValue(qArr,
+                            new TypeReference<List<Map<String, Object>>>() {});
+                }
+            }
+            for (Map<String, Object> question : questions) {
+                Object qId = question.get("id") != null ? question.get("id") : question.get("questionId");
+                Object answer = qId != null ? answers.get(String.valueOf(qId)) : null;
+                if (answer == null) {
+                    continue;
+                }
+                String answerStr = String.valueOf(answer);
+                // 多选数组答案
+                if (answer instanceof List) {
+                    for (Object a : (List<?>) answer) {
+                        collectOptionTag(question, String.valueOf(a), tags);
+                    }
+                } else {
+                    collectOptionTag(question, answerStr, tags);
+                }
+            }
+        } catch (Exception ignored) {
+        }
+        return tags;
+    }
+
+    private void collectOptionTag(Map<String, Object> question, String answerValue, Set<String> tags) {
+        Object optionsObj = question.get("options");
+        if (!(optionsObj instanceof List)) {
+            return;
+        }
+        for (Object o : (List<?>) optionsObj) {
+            if (!(o instanceof Map)) {
+                continue;
+            }
+            Map<?, ?> option = (Map<?, ?>) o;
+            Object optId = option.get("id") != null ? option.get("id") : option.get("value");
+            if (optId != null && String.valueOf(optId).equals(answerValue)) {
+                Object tag = option.get("tag") != null ? option.get("tag") : option.get("resultTag");
+                if (tag != null) {
+                    tags.add(String.valueOf(tag));
+                }
+            }
+        }
+    }
+
+    private String toJson(Object obj) {
+        try {
+            return OBJECT_MAPPER.writeValueAsString(obj);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+}

+ 106 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserIntentController.java

@@ -0,0 +1,106 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+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 org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 用户意图 — 改版v1 需求驱动·逐步解锁(C类)
+ */
+@Tag(name = "用户意图", description = "首次意图选择(A/B/C)与旅程推进")
+@RestController
+@RequestMapping("/api/user-intent")
+public class UserIntentController {
+
+    @Resource
+    private UserIntentMapper userIntentMapper;
+
+    @Operation(summary = "获取当前用户意图")
+    @PostMapping("/get")
+    public Result<UserIntent> get(@RequestAttribute("userId") Long userId) {
+        UserIntent intent = findIntent(userId);
+        return Result.success(intent);
+    }
+
+    @Operation(summary = "首次意图选择:A直达 / B问卷 / C问题域")
+    @PostMapping("/choose")
+    @Transactional
+    public Result<UserIntent> choose(@RequestAttribute("userId") Long userId,
+                                     @RequestBody Map<String, Object> params) {
+        String intentType = params.get("intentType") != null ? params.get("intentType").toString() : "";
+        if (!"A".equals(intentType) && !"B".equals(intentType) && !"C".equals(intentType)) {
+            return Result.error("intentType 必须为 A/B/C");
+        }
+        String problemDomainCode = params.get("problemDomainCode") != null
+                ? params.get("problemDomainCode").toString() : "";
+        if ("C".equals(intentType) && problemDomainCode.isEmpty()) {
+            return Result.error("C类意图必须携带 problemDomainCode");
+        }
+
+        UserIntent intent = findIntent(userId);
+        Date now = new Date();
+        if (intent == null) {
+            intent = new UserIntent();
+            intent.setUserId(userId);
+            intent.setIntentType(intentType);
+            intent.setProblemDomainCode(problemDomainCode);
+            intent.setJourneyStage(0);
+            intent.setStageUpdatedAt(now);
+            intent.setCreatedAt(now);
+            intent.setUpdatedAt(now);
+            userIntentMapper.insert(intent);
+        } else {
+            intent.setIntentType(intentType);
+            if (!problemDomainCode.isEmpty()) {
+                intent.setProblemDomainCode(problemDomainCode);
+            }
+            intent.setUpdatedAt(now);
+            userIntentMapper.updateById(intent);
+        }
+        return Result.success(intent);
+    }
+
+    @Operation(summary = "推进旅程阶段(0-7)")
+    @PostMapping("/update-stage")
+    public Result<UserIntent> updateStage(@RequestAttribute("userId") Long userId,
+                                          @RequestBody Map<String, Object> params) {
+        Integer stage = params.get("journeyStage") != null
+                ? Integer.valueOf(params.get("journeyStage").toString()) : null;
+        if (stage == null || stage < 0 || stage > 7) {
+            return Result.error("journeyStage 必须在 0-7 之间");
+        }
+        UserIntent intent = findIntent(userId);
+        if (intent == null) {
+            return Result.error("请先选择意图(/choose)");
+        }
+        // 只允许向前推进
+        if (intent.getJourneyStage() != null && intent.getJourneyStage() >= stage) {
+            return Result.success(intent);
+        }
+        intent.setJourneyStage(stage);
+        Date now = new Date();
+        intent.setStageUpdatedAt(now);
+        intent.setUpdatedAt(now);
+        userIntentMapper.updateById(intent);
+        return Result.success(intent);
+    }
+
+    private UserIntent findIntent(Long userId) {
+        return userIntentMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserIntent>()
+                        .eq(UserIntent::getUserId, userId)
+                        .last("LIMIT 1"));
+    }
+}

+ 76 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserNotificationController.java

@@ -0,0 +1,76 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+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 java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 用户通知 — 改版v1(报告完成、奖励到账、物流等站内通知)
+ */
+@Tag(name = "用户通知", description = "站内通知列表与已读")
+@RestController
+@RequestMapping("/api/notification")
+public class UserNotificationController {
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Operation(summary = "通知列表(未读优先,倒序)")
+    @PostMapping("/list")
+    public Result<List<UserNotification>> list(@RequestAttribute("userId") Long userId) {
+        List<UserNotification> list = userNotificationMapper.selectList(
+                new LambdaQueryWrapper<UserNotification>()
+                        .eq(UserNotification::getUserId, userId)
+                        .orderByAsc(UserNotification::getIsRead)
+                        .orderByDesc(UserNotification::getCreatedAt)
+                        .last("LIMIT 100"));
+        return Result.success(list);
+    }
+
+    @Operation(summary = "标记已读(id 或 ids 数组,缺省标记全部)")
+    @PostMapping("/read")
+    public Result<Boolean> read(@RequestAttribute("userId") Long userId,
+                                @RequestBody(required = false) Map<String, Object> params) {
+        Object idObj = params != null ? params.get("id") : null;
+        Object idsObj = params != null ? params.get("ids") : null;
+        if (idObj != null) {
+            UserNotification n = userNotificationMapper.selectById(Long.valueOf(idObj.toString()));
+            if (n != null && n.getUserId().equals(userId)) {
+                n.setIsRead(1);
+                userNotificationMapper.updateById(n);
+            }
+        } else if (idsObj != null) {
+            for (Object o : (List<?>) idsObj) {
+                UserNotification n = userNotificationMapper.selectById(Long.valueOf(o.toString()));
+                if (n != null && n.getUserId().equals(userId)) {
+                    n.setIsRead(1);
+                    userNotificationMapper.updateById(n);
+                }
+            }
+        } else {
+            // 全部标记已读
+            List<UserNotification> list = userNotificationMapper.selectList(
+                    new LambdaQueryWrapper<UserNotification>()
+                            .eq(UserNotification::getUserId, userId)
+                            .eq(UserNotification::getIsRead, 0));
+            for (UserNotification n : list) {
+                n.setIsRead(1);
+                userNotificationMapper.updateById(n);
+            }
+        }
+        return Result.success(true);
+    }
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -7,7 +7,10 @@ import com.etotem.cfc.dto.CreateTaskDTO;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.entity.MiniGame;
 import com.etotem.cfc.entity.MiniGame;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.MiniGameService;
 import com.etotem.cfc.service.MiniGameService;
+import com.etotem.cfc.service.ProblemCompletionService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +31,33 @@ public class TaskController {
     @Resource
     @Resource
     private MiniGameService miniGameService;
     private MiniGameService miniGameService;
 
 
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private ProblemCompletionService problemCompletionService;
+
+    /**
+     * 会员任务校验:task.memberOnly==1 且非会员 → 拒绝(提示开通会员)
+     */
+    private Result<?> checkMemberOnly(Long taskId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null) {
+            return Result.error("任务不存在");
+        }
+        if (task.getMemberOnly() != null && task.getMemberOnly() == 1) {
+            boolean isMember = membershipService.getCurrentLevel(task.getFamilyId()) != null
+                    && !"FREE".equals(membershipService.getCurrentLevel(task.getFamilyId()).getLevelCode());
+            if (!isMember) {
+                return Result.error("该任务是会员任务,请先开通会员后再操作");
+            }
+        }
+        return null;
+    }
+
     @Operation(summary = "获取可选择的小游戏列表")
     @Operation(summary = "获取可选择的小游戏列表")
     @PostMapping("/minigame-options")
     @PostMapping("/minigame-options")
     public Result<List<MiniGame>> getMinigameOptions() {
     public Result<List<MiniGame>> getMinigameOptions() {
@@ -64,8 +94,20 @@ public class TaskController {
     public Result<Map<String, Object>> completeTask(@PathVariable Long id,
     public Result<Map<String, Object>> completeTask(@PathVariable Long id,
                                                     @RequestBody CompleteTaskDTO dto,
                                                     @RequestBody CompleteTaskDTO dto,
                                                     @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
                                                     @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
         Long memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;
         Long memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;
         Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl());
         Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl());
+        if (result != null) {
+            // B17: 任务完成后检查问题域奖励
+            try {
+                problemCompletionService.checkAndRewardByTask(id);
+            } catch (Exception e) {
+                // 奖励发放失败不影响任务完成
+            }
+        }
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
@@ -112,7 +154,19 @@ public class TaskController {
     @PostMapping("/{id}/complete-parent")
     @PostMapping("/{id}/complete-parent")
     public Result<Map<String, Object>> completeParentTask(@PathVariable Long id,
     public Result<Map<String, Object>> completeParentTask(@PathVariable Long id,
                                                         @RequestAttribute("userId") Long userId) {
                                                         @RequestAttribute("userId") Long userId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
         Map<String, Object> result = taskService.completeParentTask(id, userId);
         Map<String, Object> result = taskService.completeParentTask(id, userId);
+        if (result != null) {
+            // B17: 任务完成后检查问题域奖励
+            try {
+                problemCompletionService.checkAndRewardByTask(id);
+            } catch (Exception e) {
+                // 奖励发放失败不影响任务完成
+            }
+        }
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
@@ -197,6 +251,10 @@ public class TaskController {
         if (memberId == null) {
         if (memberId == null) {
             return Result.error("memberId不能为空");
             return Result.error("memberId不能为空");
         }
         }
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
         boolean success = taskService.acceptTask(id, memberId);
         boolean success = taskService.acceptTask(id, memberId);
         return Result.success(success);
         return Result.success(success);
     }
     }

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PackageOrder.java

@@ -47,6 +47,9 @@ public class PackageOrder implements Serializable {
     private String contactPhone;    // 联系人手机号
     private String contactPhone;    // 联系人手机号
     private String contactAddress;  // 收件地址
     private String contactAddress;  // 收件地址
 
 
+    private String logisticsStatus; // 物流状态: pending/shipped/received/completed (B19)
+    private String trackingNo;      // 物流单号 (B19)
+
     private Date createdAt;
     private Date createdAt;
 
 
     private Date updatedAt;
     private Date updatedAt;
@@ -70,6 +73,8 @@ public class PackageOrder implements Serializable {
     public void setContactName(String contactName) { this.contactName = contactName; }
     public void setContactName(String contactName) { this.contactName = contactName; }
     public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
     public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
     public void setContactAddress(String contactAddress) { this.contactAddress = contactAddress; }
     public void setContactAddress(String contactAddress) { this.contactAddress = contactAddress; }
+    public void setLogisticsStatus(String logisticsStatus) { this.logisticsStatus = logisticsStatus; }
+    public void setTrackingNo(String trackingNo) { this.trackingNo = trackingNo; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
     public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
 }
 }

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -5,8 +5,12 @@ import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanReportUpload;
 import com.etotem.cfc.entity.DanReportUpload;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -55,6 +59,12 @@ public class DanReportUploadService {
     @Resource
     @Resource
     private ReportBlockAssembler reportBlockAssembler;
     private ReportBlockAssembler reportBlockAssembler;
 
 
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private UserIntentMapper userIntentMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
     // Phase 1: 上传并预览 PDF
     // Phase 1: 上传并预览 PDF
@@ -259,6 +269,37 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
                     upload.getFamilyMemberId(), result.getId(), upload.getDimension());
                     upload.getFamilyMemberId(), result.getId(), upload.getDimension());
         } catch (Exception ignored) {}
         } catch (Exception ignored) {}
 
 
+        // B16: 报告完成 → 写通知(report_ready) + 推进旅程 stage=5
+        try {
+            UserNotification notification = new UserNotification();
+            notification.setUserId(upload.getUploaderUserId());
+            notification.setType("report_ready");
+            notification.setTitle("健康报告已完成分析");
+            notification.setContent("您的健康报告已完成解析,可前往查看结果与成长方案。");
+            notification.setRefType("dan_report");
+            notification.setRefId(upload.getId());
+            notification.setIsRead(0);
+            notification.setCreatedAt(new Date());
+            userNotificationMapper.insert(notification);
+
+            // 推进 user_intents 旅程至 stage=5(报告完成)
+            UserIntent intent = userIntentMapper.selectOne(
+                    new LambdaQueryWrapper<UserIntent>()
+                            .eq(UserIntent::getUserId, upload.getUploaderUserId())
+                            .last("LIMIT 1"));
+            if (intent != null) {
+                if (intent.getJourneyStage() == null || intent.getJourneyStage() < 5) {
+                    intent.setJourneyStage(5);
+                    intent.setStageUpdatedAt(new Date());
+                    intent.setUpdatedAt(new Date());
+                    userIntentMapper.updateById(intent);
+                }
+            }
+            log.info("报告完成通知已发送: uploadId={}, userId={}", uploadId, upload.getUploaderUserId());
+        } catch (Exception e) {
+            log.warn("报告完成通知发送失败(不影响主流程): uploadId={}, error={}", uploadId, e.getMessage());
+        }
+
         return Result.success();
         return Result.success();
     }
     }
 
 

+ 164 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java

@@ -0,0 +1,164 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ProblemDomain;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 问题域完成奖励 — 改版v1(B17)
+ * <p>
+ * 任务全部完成后发放 CF 值(家庭维度),金额=问题域配置 reward_cf_value;
+ * 幂等:(familyId, problemDomainCode, 'completion') 查唯一发放记录(earn 内部幂等);
+ * 写通知(reward_granted) + 更新旅程 stage=7。
+ */
+@Slf4j
+@Service
+public class ProblemCompletionService {
+
+    @Resource
+    private ProblemDomainMapper problemDomainMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private TaskPlanInstanceMapper planInstanceMapper;
+
+    @Resource
+    private FamilyPlatformPointsService familyPlatformPointsService;
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private UserIntentMapper userIntentMapper;
+
+    /**
+     * 任务完成后的奖励检查入口:判断该家庭是否已把某问题域套餐内全部任务完成
+     * (任务 → familyId → 启用中的问题域 → 套餐 → 方案实例 → 全部任务 completed → 发放)
+     */
+    @Transactional
+    public void checkAndRewardByTask(Long taskId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null || task.getFamilyId() == null) {
+            return;
+        }
+        Long familyId = task.getFamilyId();
+
+        List<ProblemDomain> domains = problemDomainMapper.selectList(
+                new LambdaQueryWrapper<ProblemDomain>().eq(ProblemDomain::getStatus, 1));
+        for (ProblemDomain domain : domains) {
+            if (domain.getTaskTemplatePackageIds() == null
+                    || domain.getTaskTemplatePackageIds().trim().isEmpty()) {
+                continue;
+            }
+            List<Long> packageIds = new ArrayList<>();
+            for (String s : domain.getTaskTemplatePackageIds().split(",")) {
+                try {
+                    packageIds.add(Long.parseLong(s.trim()));
+                } catch (NumberFormatException ignored) {
+                }
+            }
+            if (packageIds.isEmpty()) {
+                continue;
+            }
+            rewardIfDomainCompleted(familyId, domain, packageIds);
+        }
+    }
+
+    private void rewardIfDomainCompleted(Long familyId, ProblemDomain domain, List<Long> packageIds) {
+        // 1. 找该家庭应用了这些套餐的方案实例
+        List<TaskPlanInstance> instances = planInstanceMapper.selectList(
+                new LambdaQueryWrapper<TaskPlanInstance>()
+                        .eq(TaskPlanInstance::getFamilyId, familyId)
+                        .in(TaskPlanInstance::getPackageId, packageIds)
+                        .ne(TaskPlanInstance::getStatus, "cancelled"));
+        if (instances.isEmpty()) {
+            return;
+        }
+
+        // 2. 找这些方案生成的计划任务(source_type='plan')
+        List<Long> planIds = new ArrayList<>();
+        for (TaskPlanInstance ins : instances) {
+            planIds.add(ins.getId());
+        }
+        List<Task> planTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                        .eq(Task::getFamilyId, familyId)
+                        .eq(Task::getSourceType, "plan")
+                        .in(Task::getSourceId, planIds));
+        if (planTasks.isEmpty()) {
+            return;
+        }
+
+        // 3. 存在未完成任务 → 不发放
+        for (Task t : planTasks) {
+            if (!"completed".equals(t.getStatus())) {
+                return;
+            }
+        }
+
+        // 4. 全部完成 → 发放 CF(earn 内部按 familyId+refType+refId 幂等)
+        int reward = domain.getRewardCfValue() != null ? domain.getRewardCfValue() : 0;
+        if (reward <= 0) {
+            return;
+        }
+        familyPlatformPointsService.earn(familyId, reward, "completion", domain.getId(),
+                "完成问题域「" + domain.getName() + "」全部任务奖励");
+
+        // 5. 写通知(防重复:type=reward_granted + refType=completion + refId=domainId)
+        List<UserIntent> intents = userIntentMapper.selectList(
+                new LambdaQueryWrapper<UserIntent>()
+                        .eq(UserIntent::getProblemDomainCode, domain.getCode()));
+        Long notified = userNotificationMapper.selectCount(
+                new LambdaQueryWrapper<UserNotification>()
+                        .eq(UserNotification::getType, "reward_granted")
+                        .eq(UserNotification::getRefType, "completion")
+                        .eq(UserNotification::getRefId, domain.getId()));
+        if (notified == null || notified == 0) {
+            // 通知发给选择了该问题域的用户
+            for (UserIntent intent : intents) {
+                UserNotification n = new UserNotification();
+                n.setUserId(intent.getUserId());
+                n.setType("reward_granted");
+                n.setTitle("问题域完成奖励已到账");
+                n.setContent("恭喜完成「" + domain.getName() + "」全部任务,奖励 "
+                        + reward + " CF 已发放至家庭账户。");
+                n.setRefType("completion");
+                n.setRefId(domain.getId());
+                n.setIsRead(0);
+                n.setCreatedAt(new Date());
+                userNotificationMapper.insert(n);
+            }
+        }
+
+        // 6. 推进该问题域用户旅程 stage=7
+        for (UserIntent intent : intents) {
+            if (intent.getJourneyStage() == null || intent.getJourneyStage() < 7) {
+                intent.setJourneyStage(7);
+                intent.setStageUpdatedAt(new Date());
+                intent.setUpdatedAt(new Date());
+                userIntentMapper.updateById(intent);
+            }
+        }
+
+        log.info("问题域完成奖励已发放: familyId={}, domainCode={}, reward={}",
+                familyId, domain.getCode(), reward);
+    }
+}