Sfoglia il codice sorgente

报告类型→商城商品链接配置(规格 2026-10-30-report-type-product-link-design)

- 迁移432: report_type_registry 添加 linked_product_id 列(BIGINT NULL,幂等 ensureColumn)+ schema.sql 同步
- ReportTypeRegistry 实体 +linkedProductId + 瞬态 productName
- 后台 /types 返回 linkedProductId+productName;/types/save 支持保存与清空(NULL)关联商品
- ReportParserController 新增 POST /api/report-parser/types/links(is_active=1 且已配置链接的类型,含商品名)
- 小程序 report-upload.vue 菌群/DAN 项叠加「查看商品」入口跳转商品详情;api.js 新增 getReportTypeLinks
- 后台 ReportTypeManagement.vue 编辑对话框加关联商品下拉、列表加关联商品列
Agnes Agent 1 settimana fa
parent
commit
911f114792

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

@@ -3389,6 +3389,10 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateSalesAwardTierThresholds();
         // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
         migrateFamilyNameBackfill();
+
+        // 迁移432: report_type_registry 添加 linked_product_id 列(报告类型→商城商品链接配置,规格 2026-10-30-report-type-product-link-design)
+        ensureColumn("report_type_registry", "linked_product_id",
+                "BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接'");
     }
 
     /**

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java

@@ -1,7 +1,12 @@
 package com.etotem.cfc.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
 import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportFingerprintService;
@@ -12,8 +17,13 @@ import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
 import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 
 @RestController
@@ -34,6 +44,53 @@ public class ReportParserController {
     @Resource
     private com.etotem.cfc.service.StorageService storageService;
 
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 小程序公开:返回已激活且配置了商城商品链接的报告类型(供「查看商品」入口展示)。
+     * 仅返回 is_active=1 AND linked_product_id IS NOT NULL 的记录;未配置链接的类型不出现在结果中。
+     */
+    @PostMapping("/types/links")
+    public Result<List<Map<String, Object>>> listTypeLinks() {
+        List<ReportTypeRegistry> types = typeRegistryMapper.selectList(
+                new LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getIsActive, true)
+                        .isNotNull(ReportTypeRegistry::getLinkedProductId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (types == null || types.isEmpty()) {
+            return Result.success(result);
+        }
+        // 批量回填商品名,避免 N+1
+        Set<Long> productIds = new HashSet<>();
+        for (ReportTypeRegistry t : types) {
+            if (t.getLinkedProductId() != null) productIds.add(t.getLinkedProductId());
+        }
+        Map<Long, String> nameMap = new HashMap<>();
+        try {
+            List<Product> products = productMapper.selectBatchIds(productIds);
+            if (products != null) {
+                for (Product p : products) {
+                    if (p != null && p.getId() != null) nameMap.put(p.getId(), p.getName());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询商品名失败(不影响链接返回): {}", e.getMessage());
+        }
+        for (ReportTypeRegistry t : types) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("typeId", t.getTypeId());
+            item.put("displayName", t.getDisplayName());
+            item.put("linkedProductId", t.getLinkedProductId());
+            item.put("productName", nameMap.get(t.getLinkedProductId()));
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
     @PostMapping("/upload")
     public Result<Map<String, Object>> uploadAndParse(
             @RequestParam("file") MultipartFile file,

+ 39 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
@@ -54,6 +55,9 @@ public class ReportParserAdminController {
     @Resource
     private ReportTemplateMapper reportTemplateMapper;
 
+    @Resource
+    private com.etotem.cfc.mapper.ProductMapper productMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     // ==================== 报告类型管理 ====================
@@ -72,7 +76,33 @@ public class ReportParserAdminController {
             wrapper.orderByDesc(ReportTypeRegistry::getId);
         }
         SortUtil.applySort(wrapper, sortSpecs, null);
-        return Result.success(typeRegistryMapper.selectPage(pageParam, wrapper));
+        Page<ReportTypeRegistry> result = typeRegistryMapper.selectPage(pageParam, wrapper);
+        fillProductNames(result.getRecords());
+        return Result.success(result);
+    }
+
+    /** 为报告类型批量回填关联商品名称(linkedProductId 非空者),连同 linkedProductId 一起返回前端 */
+    private void fillProductNames(List<ReportTypeRegistry> types) {
+        if (types == null || types.isEmpty()) return;
+        java.util.Set<Long> productIds = new java.util.HashSet<>();
+        for (ReportTypeRegistry t : types) {
+            if (t.getLinkedProductId() != null) productIds.add(t.getLinkedProductId());
+        }
+        if (productIds.isEmpty()) return;
+        java.util.Map<Long, String> nameMap = new HashMap<>();
+        try {
+            List<com.etotem.cfc.entity.Product> products = productMapper.selectBatchIds(productIds);
+            if (products != null) {
+                for (com.etotem.cfc.entity.Product p : products) {
+                    if (p != null && p.getId() != null) nameMap.put(p.getId(), p.getName());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("填充报告类型商品名失败: {}", e.getMessage());
+        }
+        for (ReportTypeRegistry t : types) {
+            if (t.getLinkedProductId() != null) t.setProductName(nameMap.get(t.getLinkedProductId()));
+        }
     }
 
     @PostMapping("/types/save")
@@ -90,6 +120,14 @@ public class ReportParserAdminController {
         if (type.getId() != null) {
             type.setUpdatedAt(new Date());
             typeRegistryMapper.updateById(type);
+            // MyBatis-Plus updateById 默认忽略 null 字段,linkedProductId 允许清空(=NULL 表示不可链接),需显式置空
+            if (type.getLinkedProductId() == null) {
+                typeRegistryMapper.update(null,
+                        new LambdaUpdateWrapper<ReportTypeRegistry>()
+                                .eq(ReportTypeRegistry::getId, type.getId())
+                                .set(ReportTypeRegistry::getLinkedProductId, null)
+                                .set(ReportTypeRegistry::getUpdatedAt, type.getUpdatedAt()));
+            }
         } else {
             Long exists = typeRegistryMapper.selectCount(
                     new LambdaQueryWrapper<ReportTypeRegistry>()

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -31,6 +32,13 @@ public class ReportTypeRegistry implements Serializable {
     /** 该报告类型应采集的指标项定义(JSON 数组字符串),用于数据采集完整性校验 */
     private String expectedIndicators;
 
+    /** 关联商城商品 products.id,NULL=不可链接 */
+    private Long linkedProductId;
+
+    /** 商品名称(非数据库字段,用于前端展示) */
+    @TableField(exist = false)
+    private String productName;
+
     private Date createdAt;
     private Date updatedAt;
 

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

@@ -4946,6 +4946,7 @@ CREATE TABLE IF NOT EXISTS report_type_registry (
     created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
     script_status VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed',
     expected_indicators TEXT DEFAULT NULL COMMENT '该报告类型应采集的指标项定义(JSON数组),用于数据采集完整性校验',
+    linked_product_id BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_type_id (type_id)

+ 67 - 2
cfc-frontend/pages/health/report-upload.vue

@@ -94,6 +94,11 @@
           <view class="guide-info">
             <text class="guide-name">菌群检测报告</text>
             <text class="guide-desc">肠道菌群 PDF,后台采集解析菌群指标</text>
+            <view class="guide-link" v-if="getTypeLink('gut_flora')" @click="goProductDetail('gut_flora')">
+              <text class="guide-link-label">查看商品</text>
+              <text class="guide-link-name">{{ getTypeLink('gut_flora').productName }}</text>
+              <text class="guide-link-arrow">›</text>
+            </view>
           </view>
         </view>
         <view class="guide-item">
@@ -101,6 +106,11 @@
           <view class="guide-info">
             <text class="guide-name">素质测评 A2</text>
             <text class="guide-desc">核心素养 PDF,解析认知、情绪、大五人格等</text>
+            <view class="guide-link" v-if="getTypeLink('dan_a2')" @click="goProductDetail('dan_a2')">
+              <text class="guide-link-label">查看商品</text>
+              <text class="guide-link-name">{{ getTypeLink('dan_a2').productName }}</text>
+              <text class="guide-link-arrow">›</text>
+            </view>
           </view>
         </view>
         <view class="guide-item">
@@ -138,7 +148,7 @@
 
 <script>
 import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
-import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList, requestReportTransfer } from '../../utils/api.js'
+import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList, requestReportTransfer, getReportTypeLinks } from '../../utils/api.js'
 import { pickImage } from '../../utils/image-picker.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
@@ -158,7 +168,8 @@ export default {
       collecting: false,
       collectFileName: '',
       collectError: '',
-      collectPollCount: 0
+      collectPollCount: 0,
+      reportTypeLinks: [] // 报告类型→商城商品链接配置(后台可配置),供「查看商品」入口展示
     }
   },
   computed: {
@@ -177,6 +188,7 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
       this.uploadType = 'tongue'
     }
     this.loadFamilyMembers()
+    this.loadReportTypeLinks()
   },
   onUnload: function() {
     this.stopCollectPolling()
@@ -232,6 +244,30 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
         self.familyMembers = []
       })
     },
+    // 拉取「报告类型→商城商品」链接配置;接口失败时静默回退纯展示(不阻断页面)
+    loadReportTypeLinks: function() {
+      var self = this
+      getReportTypeLinks().then(function(res) {
+        var list = (res && res.data) || []
+        self.reportTypeLinks = list instanceof Array ? list : []
+      }).catch(function() {
+        self.reportTypeLinks = []
+      })
+    },
+    // 根据 typeId 查找已配置的链接信息(未配置/接口失败返回 null,前端回退为纯展示)
+    getTypeLink: function(typeId) {
+      var list = this.reportTypeLinks || []
+      for (var i = 0; i < list.length; i++) {
+        if (list[i] && list[i].typeId === typeId) return list[i]
+      }
+      return null
+    },
+    // 点击「查看商品」跳转到配置的商城商品详情页
+    goProductDetail: function(typeId) {
+      var link = this.getTypeLink(typeId)
+      if (!link || !link.linkedProductId) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + link.linkedProductId })
+    },
     ensureMember: function() {
       if (this.memberId) return true
       uni.showToast({ title: '请先选择报告所属成员', icon: 'none' })
@@ -846,6 +882,35 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
   color: $app-text-subtle;
   line-height: 1.5;
 }
+.guide-link {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 10rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 10rpx;
+  background: $facade-brand-tint-2;
+  align-self: flex-start;
+}
+.guide-link-label {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-violet;
+  font-weight: 600;
+  margin-right: 8rpx;
+}
+.guide-link-name {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  max-width: 320rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.guide-link-arrow {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-violet;
+  margin-left: 6rpx;
+}
 .guide-tips {
   margin-top: 8rpx;
   padding-top: 16rpx;

+ 4 - 0
cfc-frontend/utils/api.js

@@ -2491,6 +2491,10 @@ export const confirmReportPreview = (data) => request('/api/health/report/confir
 // 查询异步采集状态(数据采集中→完成后自动入库)
 export const getReportCollectStatus = (draftId) => request('/api/health/report/collect/status', 'POST', { draftId })
 
+// 小程序上传页「支持的报告类型」→商城商品链接配置(后台可配置;返回 is_active=1 且已配置链接的类型)
+// 返回 [{ typeId, displayName, linkedProductId, productName }],未配置链接的类型不出现在结果中
+export const getReportTypeLinks = () => request('/api/report-parser/types/links', 'POST', {})
+
 // 查询当前用户所有"采集中"的草稿
 export const getCollectingReports = () => request('/api/health/report/collecting-list', 'POST', {})
 

+ 33 - 4
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -48,6 +48,12 @@
             <StatusTag :type="row.isActive ? 'success' : 'info'" :text="row.isActive ? '启用' : '停用'" />
           </template>
         </el-table-column>
+        <el-table-column label="关联商品" width="160">
+          <template slot-scope="{ row }">
+            <span v-if="row.linkedProductId">{{ row.productName || ('商品#' + row.linkedProductId) }}</span>
+            <span v-else class="text-faint">— 未链接</span>
+          </template>
+        </el-table-column>
         <el-table-column label="脚本状态" width="110">
           <template slot-scope="{ row }">
             <StatusTag v-if="row.scriptStatus === 'generating'" type="warning" size="small" text="生成中" />
@@ -104,6 +110,18 @@
         <el-form-item label="识别阈值">
           <el-input-number v-model="form.minConfidence" :min="1" :max="100" />
         </el-form-item>
+        <el-form-item label="关联商品">
+          <el-select
+            v-model="form.linkedProductId"
+            placeholder="选择关联商品(清空=不可链接)"
+            clearable
+            filterable
+            style="width: 100%"
+          >
+            <el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
+          </el-select>
+          <div class="el-form-item-msg">配置后小程序报告上传页该类型展示「查看商品」入口;留空=不可链接</div>
+        </el-form-item>
         <el-form-item label="采集指标">
           <el-tag
             v-for="(tag, idx) in indicators"
@@ -184,6 +202,7 @@
 <script>
 import { getReportTypes, saveReportType, toggleReportType, deleteReportType,
          getFingerprints, saveFingerprint, deleteFingerprint } from '@/api/reportParser'
+import { getProductList } from '@/api/admin'
 import SortMixin from '@/mixins/SortMixin'
 
 export default {
@@ -195,8 +214,9 @@ export default {
       types: [], keyword: '',
       sortableColumns: { id: 'ID', typeId: '类型标识', displayName: '显示名称', family: '家族', parserPath: '解析器路径', minConfidence: '阈值', source: '来源' },
       dialogVisible: false, isEdit: false,
-      form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 },
+      form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10, linkedProductId: null },
       indicators: [], indicatorInput: '', indicatorInputVisible: false,
+      products: [],
       fpDialogVisible: false, currentType: null,
       fingerprints: [], fpFormVisible: false,
       fpForm: { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 },
@@ -212,11 +232,20 @@ export default {
         (t.family || '').toLowerCase().includes(kw))
     }
   },
-  mounted() { this.fetchTypes() },
+  mounted() { this.fetchTypes(); this.loadProducts() },
   methods: {
     loadList() {
       this.fetchTypes()
     },
+    async loadProducts() {
+      try {
+        const res = await getProductList({ page: 1, size: 100 })
+        const data = res.data || {}
+        this.products = (data.records || []).map(p => ({ id: p.id, name: p.name }))
+      } catch (e) {
+        this.products = []
+      }
+    },
     async fetchTypes() {
       this.loading = true
       try {
@@ -248,13 +277,13 @@ export default {
     removeIndicator(idx) { this.indicators.splice(idx, 1) },
     handleCreate() {
       this.isEdit = false
-      this.form = { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 }
+      this.form = { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10, linkedProductId: null }
       this.indicators = []
       this.dialogVisible = true
     },
     handleEdit(row) {
       this.isEdit = true
-      this.form = { ...row }
+      this.form = { ...row, linkedProductId: row.linkedProductId != null ? row.linkedProductId : null }
       this.indicators = this.safeParseIndicators(row.expectedIndicators)
       this.dialogVisible = true
     },