Przeglądaj źródła

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 1 tydzień temu
rodzic
commit
8fe5132eea

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

@@ -3382,6 +3382,59 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移429: 用户关注指标收藏表 indicator_favorite(T04 方案A:后端持久化,跨端同步)
         createIndicatorFavoriteTable();
+
+        // 迁移430: 商品成本价改为整型(分) —— P点口径:P点 = 实际成交额 - 成本价(分)
+        migrateProductsPurchasePriceToCents();
+        // 迁移431: 销售奖档位门槛 ×100(元→分)+ 档位名改为 新秀/小成/中富/大成/至富
+        migrateSalesAwardTierThresholds();
+    }
+
+    /**
+     * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
+     *
+     * <p>原 products.purchase_price DECIMAL(10,2),值与 price 同量级(分);改为 INT 存分。
+     * 仅改类型不做数值换算(幂等:已是 INT 则跳过)。</p>
+     */
+    private void migrateProductsPurchasePriceToCents() {
+        try {
+            String colType = jdbcTemplate.queryForObject(
+                    "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
+                            + "AND TABLE_NAME='products' AND COLUMN_NAME='purchase_price'", String.class);
+            if ("decimal".equalsIgnoreCase(colType)) {
+                // 值本身已是分(与 price 同量级、低于售价),只改类型不做数值换算
+                jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)'");
+                log.info("迁移430完成:products.purchase_price decimal→int(分)(值已是分,仅改类型)");
+            } else {
+                log.info("迁移430跳过:products.purchase_price 已是 {} 类型", colType);
+            }
+        } catch (Exception e) {
+            log.warn("迁移430失败(幂等容忍): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移431: 销售奖档位门槛由元改为分(×100),并按 id 重命名为新档位名(费率不变)。
+     *
+     * <p>档位:新秀(1w)/小成(5w)/中富(20w)/大成(50w)/至富(100w),单位分。</p>
+     */
+    private void migrateSalesAwardTierThresholds() {
+        try {
+            jdbcTemplate.execute(
+                    "UPDATE cf_direct_sales_tier SET min_annual_ppoints = CASE id "
+                            + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
+                            + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
+                            + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
+                            + "WHERE id BETWEEN 1 AND 5");
+            jdbcTemplate.execute(
+                    "UPDATE cf_indirect_sales_tier SET min_self_ppoints = CASE id "
+                            + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
+                            + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
+                            + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
+                            + "WHERE id BETWEEN 1 AND 5");
+            log.info("迁移431完成:销售奖档位门槛×100(分),档位名新秀/小成/中富/大成/至富");
+        } catch (Exception e) {
+            log.warn("迁移431失败(幂等容忍): {}", e.getMessage());
+        }
     }
 
     /**

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

@@ -56,8 +56,8 @@ public class CouponController {
     public Result<Map<String, Object>> exchange(@RequestAttribute("userId") Long userId,
                                                 @RequestBody Map<String, Object> params) {
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        Object memberId = params.get("memberId");
-        Long memberId = memberId != null ? Long.valueOf(memberId.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null;
         PointsExchangeRecord record = pointsExchangeService.exchangeCoupon(userId, memberId, couponId);
         return Result.success(java.util.Collections.singletonMap("recordId", record.getId()));
     }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -18,6 +18,7 @@ public class ProductDTO {
     private String coverImage;
     private String images;
     private Integer price;
+    private Integer purchasePrice;  // 成本价(分)
     private Integer memberPrice;
     private Integer stock;
     private Integer minQuantity;
@@ -140,6 +141,7 @@ public class ProductDTO {
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)
         d.price = p.getPrice();
+        d.purchasePrice = p.getPurchasePrice();
         if (d.price != null && d.price == 0) {
             d.priceLabel = "免费";
         } else {

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

@@ -27,7 +27,7 @@ public class Product implements Serializable {
     private String coverImage;
     private String images;
     private Integer price;       // 价格(分)
-    private java.math.BigDecimal purchasePrice; // 进价(分)
+    private Integer purchasePrice; // 成本价(分)
     private Integer stock;
     private Integer minStockAlert;
     private Integer salesCount;

+ 7 - 20
cfc-backend/src/main/java/com/etotem/cfc/entity/TongueRecord.java

@@ -5,37 +5,24 @@ import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import java.io.Serializable;
 import java.util.Date;
 
+@Data
 @TableName("tongue_records")
-public class TongueRecord {
+public class TongueRecord implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
-    @JsonProperty("childId")
-    private Long memberId;
+
+    /** 家庭成员ID */
     @TableField("child_id")
     @JsonProperty("childId")
     private Long memberId;
+
     private String imageUrl;
     private String overallAssessment;
     private String status;
     private Date createdAt;
     private Date updatedAt;
-
-    public Long getId() { return id; }
-    public void setId(Long id) { this.id = id; }
-    public Long getMemberId() { return memberId; }
-    public void setMemberId(Long memberId) { this.memberId = memberId; }
-    public Long getMemberId() { return memberId; }
-    public void setMemberId(Long memberId) { this.memberId = memberId; }
-    public String getImageUrl() { return imageUrl; }
-    public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
-    public String getOverallAssessment() { return overallAssessment; }
-    public void setOverallAssessment(String overallAssessment) { this.overallAssessment = overallAssessment; }
-    public String getStatus() { return status; }
-    public void setStatus(String status) { this.status = status; }
-    public Date getCreatedAt() { return createdAt; }
-    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
-    public Date getUpdatedAt() { return updatedAt; }
-    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
 }

+ 66 - 12
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -12,7 +12,9 @@ import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.PpointConsumptionLog;
+import com.etotem.cfc.entity.OrderItem;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
@@ -21,6 +23,8 @@ import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.OrderItemMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -92,6 +96,10 @@ public class CfCommissionService {
     @Resource
     private ProductMapper productMapper;
     @Resource
+    private OrderItemMapper orderItemMapper;
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+    @Resource
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
     @Resource
     private OctopusEffectRecordMapper octopusEffectRecordMapper;
@@ -133,7 +141,8 @@ public class CfCommissionService {
         // 当前消费人返 CF(F02 统一账本:1 元 = 100 CF,按实付金额)
         earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
         // 推荐人分润(两级销售奖)
-        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId,
+                orderAmountCent, paidAmountCent, productId);
     }
 
     /**
@@ -157,7 +166,8 @@ public class CfCommissionService {
     public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                    Integer orderAmountCent, Integer paidAmountCent) {
         earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
-        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId,
+                orderAmountCent, paidAmountCent, null);
     }
 
     /**
@@ -184,12 +194,12 @@ public class CfCommissionService {
      * </ul>
      */
     private void distributeToReferrers(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
-                                       Integer orderAmountCent, Long productId) {
+                                   Integer orderAmountCent, Integer paidAmountCent, Long productId) {
         if (orderAmountCent == null || orderAmountCent <= 0) return;
         int amountYuan = orderAmountCent / 100;
 
         // 本单消费产生的 P 点:F03a / F03b 的共同基数(§4.4)
-        int basePpoints = calcOrderPpoints(orderType, amountYuan, productId);
+        int basePpoints = calcOrderPpoints(orderId, orderType, orderAmountCent, paidAmountCent, productId);
         if (basePpoints <= 0) {
             log.info("本单 P 点基数为 0,跳过销售奖: orderType={}, orderId={}", orderType, orderId);
             return;
@@ -320,22 +330,66 @@ public class CfCommissionService {
     }
 
     /**
-     * 本单消费产生的 P 点(销售奖基数)。
-     * 商品:实付(元) × 有效 P 点 / 100;非商品:实付(元) × 服务费率(bps) / 10000。
+     * 本单消费产生的 P 点(销售奖基数,单位:分)。
+     *
+     * <p>口径:P 点 = 实际成交额 − 成本价({@code products.purchase_price},分)。</p>
+     * <ul>
+     *   <li>单商品订单:实付 − 成本价 × 数量;</li>
+     *   <li>多商品订单({@code order_items} 有明细):按各行 单价×数量 占比分摊实付,
+     *       再减各行成本价×数量,各行毛利(负数记 0)求和(方案A);</li>
+     *   <li>亏本销售不发:P 点 = max(0, ...);</li>
+     *   <li>非商品订单(会员/套餐/订阅/测评):沿用服务费率口径。</li>
+     * </ul>
      */
-    private int calcOrderPpoints(String orderType, int amountYuan, Long productId) {
-        if ("product".equals(orderType) && productId != null) {
+    private int calcOrderPpoints(Long orderId, String orderType, Integer amountCent, Integer paidAmountCent, Long productId) {
+        if ("product".equals(orderType)) {
             try {
+                int paid = paidAmountCent != null ? paidAmountCent : (amountCent != null ? amountCent : 0);
+                if (paid <= 0) return 0;
+                ProductOrder order = productOrderMapper.selectById(orderId);
+                if (order == null) return 0;
+
+                // 多商品订单:order_items 有明细 → 按行分摊实付 - 行成本(方案A)
+                List<OrderItem> items = orderItemMapper.findByOrderId(orderId);
+                if (items != null && !items.isEmpty()) {
+                    long totalBase = 0L;
+                    for (OrderItem it : items) {
+                        int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+                        long base = (long) Math.round((it.getPrice() != null ? it.getPrice().doubleValue() : 0d) * qty);
+                        if (base > 0) totalBase += base;
+                    }
+                    if (totalBase > 0) {
+                        int total = 0;
+                        for (OrderItem it : items) {
+                            int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+                            if (qty <= 0) continue;
+                            long base = (long) Math.round((it.getPrice() != null ? it.getPrice().doubleValue() : 0d) * qty);
+                            if (base <= 0) continue;
+                            long allocPaid = (long) ((double) paid * base / totalBase);
+                            Product p = productMapper.selectById(it.getProductId());
+                            int costPerUnit = (p != null && p.getPurchasePrice() != null) ? p.getPurchasePrice() : 0;
+                            long lineCost = (long) costPerUnit * qty;
+                            long linePpoint = allocPaid - lineCost;
+                            if (linePpoint > 0) total += (int) linePpoint;
+                        }
+                        return total;
+                    }
+                }
+
+                // 单商品订单:实付 − 成本 × 数量
+                if (productId == null) return 0;
                 Product p = productMapper.selectById(productId);
                 if (p == null) return 0;
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
-                if (effectivePpoint <= 0) return 0;
-                return amountYuan * effectivePpoint / 100;
+                int costPerUnit = p.getPurchasePrice() != null ? p.getPurchasePrice() : 0;
+                int qty = order.getQuantity() != null ? order.getQuantity() : 1;
+                int ppoints = paid - costPerUnit * qty;
+                return ppoints > 0 ? ppoints : 0;
             } catch (Exception e) {
-                log.warn("计算本单 P 点失败: productId={}, err={}", productId, e.getMessage());
+                log.warn("计算本单 P 点失败: orderId={}, err={}", orderId, e.getMessage());
                 return 0;
             }
         }
+        int amountYuan = (amountCent != null ? amountCent : 0) / 100;
         int serviceRateBps = configService.getIntConfig(CfConfigKeys.COMMISSION_SERVICE_RATE, 1000);
         if (serviceRateBps <= 0) return 0;
         return amountYuan * serviceRateBps / 10000;

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

@@ -285,7 +285,7 @@ public class NutritionProductService {
         nutritionProduct.setDescription(product.getDescription());
         nutritionProduct.setImageUrl(product.getCoverImage());
         nutritionProduct.setPrice(BigDecimal.valueOf(product.getPrice() != null ? product.getPrice() : 0));
-        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice().intValue() : 0);
+        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice() : 0);
         nutritionProduct.setSuitableFor("全家人");
         nutritionProduct.setCreateTime(new Date());
         nutritionProduct.setUpdateTime(new Date());

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -18,6 +18,7 @@ import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.entity.ProductOnlineSlot;
+import com.etotem.cfc.entity.OrderItem;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ProductPickupLocation;
 import com.etotem.cfc.entity.ProductPickupTimeSlot;
@@ -186,6 +187,9 @@ public class ProductOrderService {
     @Resource
     private com.etotem.cfc.mapper.ProductOnlineSlotMapper productOnlineSlotMapper;
 
+    @Resource
+    private com.etotem.cfc.mapper.OrderItemMapper orderItemMapper;
+
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
@@ -738,6 +742,7 @@ public class ProductOrderService {
         int totalQuantity = 0;
         int giftAmount = 0;
         OrderItemVO firstItem = dto.getItems().get(0);
+        java.util.List<OrderItem> orderItemRecords = new java.util.ArrayList<>();
         Product firstProduct = null;
         int firstUnitPrice = 0;
 
@@ -798,6 +803,17 @@ public class ProductOrderService {
                 firstProduct = product;
                 firstUnitPrice = unitPrice;
             }
+            // 订单明细行(多商品 P 点按行分摊的依据;单价/小计单位:分)
+            OrderItem itemRecord = new OrderItem();
+            itemRecord.setProductId(product.getId());
+            itemRecord.setSkuId(item.getSkuId());
+            itemRecord.setProductName(product.getName());
+            itemRecord.setProductImage(product.getCoverImage());
+            itemRecord.setSkuSpecs(item.getSpecOptionIds());
+            itemRecord.setPrice(java.math.BigDecimal.valueOf(unitPrice));
+            itemRecord.setQuantity(item.getQuantity());
+            itemRecord.setSubtotal(java.math.BigDecimal.valueOf((long) unitPrice * item.getQuantity()));
+            orderItemRecords.add(itemRecord);
         }
 
         User buyer = userMapper.selectById(buyerId);
@@ -842,6 +858,13 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
+        // 多商品订单明细分行落库(结算 P 点按行成本分摊,见 CfCommissionService.calcOrderPpoints)
+        if (!orderItemRecords.isEmpty()) {
+            for (OrderItem itemRecord : orderItemRecords) {
+                itemRecord.setOrderId(order.getId());
+                orderItemMapper.insert(itemRecord);
+            }
+        }
         // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
         if (appliedCouponId != null) {
             try {

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

@@ -1163,7 +1163,7 @@ CREATE TABLE IF NOT EXISTS products (
     cover_image VARCHAR(500) COMMENT '封面图片',
     images TEXT COMMENT '多张图片JSON',
     price INT NOT NULL DEFAULT 0 COMMENT '价格(分)',
-    purchase_price DECIMAL(10,2) DEFAULT 0.00 COMMENT '进价(分)',
+    purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)',
     stock INT NOT NULL DEFAULT 0 COMMENT '库存',
     min_stock_alert INT DEFAULT 0 COMMENT '库存预警阈值(低于此值预警)',
     sales_count INT NOT NULL DEFAULT 0 COMMENT '销量',

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c9f22f66fe2b9f210acdae1cd0a9562b6080fdfa
+000e10a892ae6c2aeb1366a0e8d6594d2e0cf9e2

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1853",
+  "version": "1.0.1854",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1853",
+      "version": "1.0.1854",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1854",
+  "version": "1.0.1855",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 94 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,100 @@
 
 ---
 
+## v1.0.1855 (2026-10-30)
+
+### Bug 修复
+- 修复 childId→memberId 规范化引入的重复定义
+- Dashboard 热力图「高」标签补样式 + 键盘可达性收口(tabindex/aria/space 激活)
+- 今日成长打卡判定优先业务日期,修复 iOS 上 createdAt 带毫秒 ISO 解析失败导致打卡不显示
+
+### 其他
+-   改为标准 @Data + @TableField("child_id") + @JsonProperty("childId") 单一字段
+- - CouponController: exchange 方法 memberId 重复声明(Object 与 Long 冲突),
+-   临时变量改名 memberIdObj
+- 
+- - cf_direct/indirect_sales_tier 门槛×100 + 档位名 新秀/小成/中富/大成/至富(迁移431)
+- - calcOrderPpoints 重写:单商品 实付-成本×数量;多商品按行单价×数量占比分摊实付再减行成本(负毛利记0);非商品沿用服务费率
+- - createMultiItem 落库 order_items 明细行(结算按行分摊前提)
+- - Product.purchasePrice 类型 BigDecimal→Integer(分)
+- - ProductEdit.vue 增加成本价(元)输入(落库转分)
+- - ProductDTO 输出 purchasePrice
+- 
+- 访问任意路径均返回白标 404,原 DOM 型 e2e 在远程 32 条全部超时失败。
+- 改为断言 /api/* 响应码与数据结构,不依赖前端。
+- - tests/playwright.config.api.js: 独立配置(无 globalSetup、独立 outputDir、
+-   testMatch 仅匹配 e2e/api/**、用例超时 180s)
+- - tests/e2e/helpers/api.js: registerRole(direct-register 自注册,不依赖种子库)、
+-   apiPost(60s 超时 + 非 JSON 响应回退 HTTP 码)、不回显任何敏感信息
+- - tests/e2e/api/*: 冒烟 4 例 + 家庭成员 13 例 + 能量 10 例 + 联系人 6 例 + 文章 7 例
+- - tests/defects/DEFECTS-api-remote-findings.md: CFC-EN-001(energy/grant 500)、
+-   CFC-FM-001(member/update nickname 不生效)、CFC-ENV-001(远程无 SPA)、
+-   CFC-ENV-002(member/add 冷启动 16s)
+- - 被真实缺陷阻塞的用例用 test.fixme 标记并配套特征化用例锁定缺陷行为
+- - .gitignore: 忽略 Playwright 失败诊断产物,保留 .last-run.json 入库
+- 
+-   数据库列名保持不变,未写迁移,零库变更
+- - 全量同步 Service/Controller/Mapper/Task/DTO/测试中的 getChildId/setChildId 调用点
+- - 加 @JsonProperty("childId") 保留 JSON 契约:接口出参键名仍为 childId,前端无需改动
+- - PointsLogVO:删除与 memberId 同值的重复 childId 字段,改为 memberId + getChildId() 别名
+-   getter,JSON 同时输出 childId 与 memberId(管理端表格 prop="childId" 依赖)
+- - ActivityFeedbackMapper.xml:result property="childId" -> "memberId"(column 仍为 child_id)
+- - 日志占位符 childId={} 同步改为 memberId={},避免日志文案与实际变量名不符
+- - 保留不动:数据库列 child_id、接口请求键 "childId"/"childIds"、MyBatis @Param("childId")
+- - 与前次 ChildBadge -> MemberBadge 规范保持一致,仅代码层,不动库表
+- 
+- 而后端 Jackson 输出的 createdAt 形如 2026-10-30T13:13:32.000+08:00(带毫秒+时区偏移),
+- iOS JavaScriptCore 只支持不带毫秒的 ISO(parseDate 注释记录),new Date() 返回 Invalid Date
+- → formatDateKey 返回空串 → 与 todayDate 不匹配 → 今日成长「饮食」显示未打卡、完成数为 0。
+- 修复:
+- 1. utils/format.js 新增 recordDateOf(record):业务日期字段优先
+-    (recordDate > startTime > sleepTime > checkinDate > createdAt),返回 yyyy-MM-dd;
+-    createdAt 仅为兜底且经 parseDate 归一化(去掉 .SSS 毫秒段,iOS 可解析)。
+- 2. parseDate 增加对带毫秒+时区偏移 ISO(Jackson 默认输出格式)的去毫秒处理。
+- 3. growth-main/index.vue 饮食/运动/睡眠/心情 4 处完成判定统一改用 recordDateOf。
+- 测试:__tests__/utils/format.test.js 新增 recordDateOf 5 用例(补打优先/兜底/运动睡眠心情/空值/毫秒ISO),
+- format.test.js 26 个全过;全量 jest 无新增失败(3 套件 4 用例为既有失败,与本改动无关)。
+- 
+- 全部缺失,本次按文档口径补齐 T01-T03 后端范围(数据层+服务层+接口层):
+- - 新增实体 IndicatorFavorite(@TableName indicator_favorite + @TableId AUTO)
+- - 新增 Mapper IndicatorFavoriteMapper(extends BaseMapper)
+- - 新增 Service 接口 IndicatorFavoriteService + 实现 IndicatorFavoriteServiceImpl
+-   (toggle 幂等「存在则删、不存在则插」、list 按 5 维隔离+id 升序、remove 幂等删除)
+- - 新增 Controller IndicatorFavoriteController:
+-   /api/indicator/favorite/toggle|list|remove 三个 POST,
+-   @RequestAttribute 取 userId/familyId 不信任前端传值,checkMemberBelongs 归属校验
+- - DatabaseInitializer 新增迁移429 createIndicatorFavoriteTable()
+-   (主线最大编号已为 428 coupon_gift_transfer,故取 428+1=429,幂等建表)
+- - JwtInterceptor.LOGIN_REQUIRED_PATHS 追加 /api/indicator/favorite(强制登录防匿名污染)
+- - schema.sql 追加 indicator_favorite 建表 DDL(与 DatabaseInitializer 一致)
+- - 单测 IndicatorFavoriteServiceTest(6) + IndicatorFavoriteControllerTest(10),
+-   JwtInterceptorLoginRequiredTest 增补收藏接口无 token 401 两条,共 21 例全绿
+- - 文档:API_REFERENCE.md 新增 §4.48.2、2026-10-25 设计稿补方案A落点、PROJECT-OVERVIEW 同步
+- 不涉及前端(F1-F5 需基线 dbf13c610,尚未合入 cfclub,按 §12.1 待主理人确认)。
+- 
+-   - ChildBadge -> MemberBadge(实体),字段 childId -> memberId(@TableField("child_id") 兼容旧列)
+-   - ChildBadgeMapper -> MemberBadgeMapper
+-   - BadgeService/Controller/UserController 方法:getChildBadges->getMemberBadges、getActiveChildBadges->getActiveMemberBadges、getChildBadgeStats->getMemberBadgeStats、getChildBadgeDetail->getMemberBadgeDetail;Controller 路径 /child/list->/member/list、/statistics/child->/statistics/member;返回字段 childBadge->memberBadge
+-   - 前端 api.js:getChildBadges->getMemberBadges、getChildBadgeStats->getMemberBadgeStats(getUserBadges 别名保留);achievement/wealth/rewards 页面同步;接口矩阵文档路径同步
+- - 成就页(pages/achievement/index.vue)重构:移除优惠券入口(资产类不属成就,财富中心已有)、移除"累计积分"目标;修正视角——调 getMemberBadges(null) 由后端 currentMemberId 查当前登录用户本人成就
+- - 约定:仅代码层规范,数据库表 child_badge / 列 child_id 保留,用 @TableField 兼容,未写迁移
+- 
+
+### 新功能
+- P点口径改为成本价核算 + 多商品明细分摊落库
+- T04 指标收藏后端化(方案A)— GAP-2026-10-30-indicator-fav
+
+### 测试
+- 新增 API 级 e2e 套件(远程后端实测 37 passed / 3 fixme)
+- 接口冒烟闭环 2026-10-30 15:17 — 通过率 45% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-30 15:07 — 通过率 45% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-30 14:55 — 通过率 45% (fix=0 reg=0)
+
+### 重构
+- 批量规范化 childId 历史字段为 memberId(仅代码层)
+- 规范 ChildBadge 命名为 MemberBadge,并收敛成就页为纯成就
+
+
 ## v1.0.1854 (2026-10-30)
 
 ### Bug 修复

+ 95 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1854
+> 当前版本: v1.0.1855
 
 ## 历史版本
 
@@ -8,6 +8,100 @@
 
 ---
 
+## v1.0.1855 (2026-10-30)
+
+### Bug 修复
+- 修复 childId→memberId 规范化引入的重复定义
+- Dashboard 热力图「高」标签补样式 + 键盘可达性收口(tabindex/aria/space 激活)
+- 今日成长打卡判定优先业务日期,修复 iOS 上 createdAt 带毫秒 ISO 解析失败导致打卡不显示
+
+### 其他
+-   改为标准 @Data + @TableField("child_id") + @JsonProperty("childId") 单一字段
+- - CouponController: exchange 方法 memberId 重复声明(Object 与 Long 冲突),
+-   临时变量改名 memberIdObj
+- 
+- - cf_direct/indirect_sales_tier 门槛×100 + 档位名 新秀/小成/中富/大成/至富(迁移431)
+- - calcOrderPpoints 重写:单商品 实付-成本×数量;多商品按行单价×数量占比分摊实付再减行成本(负毛利记0);非商品沿用服务费率
+- - createMultiItem 落库 order_items 明细行(结算按行分摊前提)
+- - Product.purchasePrice 类型 BigDecimal→Integer(分)
+- - ProductEdit.vue 增加成本价(元)输入(落库转分)
+- - ProductDTO 输出 purchasePrice
+- 
+- 访问任意路径均返回白标 404,原 DOM 型 e2e 在远程 32 条全部超时失败。
+- 改为断言 /api/* 响应码与数据结构,不依赖前端。
+- - tests/playwright.config.api.js: 独立配置(无 globalSetup、独立 outputDir、
+-   testMatch 仅匹配 e2e/api/**、用例超时 180s)
+- - tests/e2e/helpers/api.js: registerRole(direct-register 自注册,不依赖种子库)、
+-   apiPost(60s 超时 + 非 JSON 响应回退 HTTP 码)、不回显任何敏感信息
+- - tests/e2e/api/*: 冒烟 4 例 + 家庭成员 13 例 + 能量 10 例 + 联系人 6 例 + 文章 7 例
+- - tests/defects/DEFECTS-api-remote-findings.md: CFC-EN-001(energy/grant 500)、
+-   CFC-FM-001(member/update nickname 不生效)、CFC-ENV-001(远程无 SPA)、
+-   CFC-ENV-002(member/add 冷启动 16s)
+- - 被真实缺陷阻塞的用例用 test.fixme 标记并配套特征化用例锁定缺陷行为
+- - .gitignore: 忽略 Playwright 失败诊断产物,保留 .last-run.json 入库
+- 
+-   数据库列名保持不变,未写迁移,零库变更
+- - 全量同步 Service/Controller/Mapper/Task/DTO/测试中的 getChildId/setChildId 调用点
+- - 加 @JsonProperty("childId") 保留 JSON 契约:接口出参键名仍为 childId,前端无需改动
+- - PointsLogVO:删除与 memberId 同值的重复 childId 字段,改为 memberId + getChildId() 别名
+-   getter,JSON 同时输出 childId 与 memberId(管理端表格 prop="childId" 依赖)
+- - ActivityFeedbackMapper.xml:result property="childId" -> "memberId"(column 仍为 child_id)
+- - 日志占位符 childId={} 同步改为 memberId={},避免日志文案与实际变量名不符
+- - 保留不动:数据库列 child_id、接口请求键 "childId"/"childIds"、MyBatis @Param("childId")
+- - 与前次 ChildBadge -> MemberBadge 规范保持一致,仅代码层,不动库表
+- 
+- 而后端 Jackson 输出的 createdAt 形如 2026-10-30T13:13:32.000+08:00(带毫秒+时区偏移),
+- iOS JavaScriptCore 只支持不带毫秒的 ISO(parseDate 注释记录),new Date() 返回 Invalid Date
+- → formatDateKey 返回空串 → 与 todayDate 不匹配 → 今日成长「饮食」显示未打卡、完成数为 0。
+- 修复:
+- 1. utils/format.js 新增 recordDateOf(record):业务日期字段优先
+-    (recordDate > startTime > sleepTime > checkinDate > createdAt),返回 yyyy-MM-dd;
+-    createdAt 仅为兜底且经 parseDate 归一化(去掉 .SSS 毫秒段,iOS 可解析)。
+- 2. parseDate 增加对带毫秒+时区偏移 ISO(Jackson 默认输出格式)的去毫秒处理。
+- 3. growth-main/index.vue 饮食/运动/睡眠/心情 4 处完成判定统一改用 recordDateOf。
+- 测试:__tests__/utils/format.test.js 新增 recordDateOf 5 用例(补打优先/兜底/运动睡眠心情/空值/毫秒ISO),
+- format.test.js 26 个全过;全量 jest 无新增失败(3 套件 4 用例为既有失败,与本改动无关)。
+- 
+- 全部缺失,本次按文档口径补齐 T01-T03 后端范围(数据层+服务层+接口层):
+- - 新增实体 IndicatorFavorite(@TableName indicator_favorite + @TableId AUTO)
+- - 新增 Mapper IndicatorFavoriteMapper(extends BaseMapper)
+- - 新增 Service 接口 IndicatorFavoriteService + 实现 IndicatorFavoriteServiceImpl
+-   (toggle 幂等「存在则删、不存在则插」、list 按 5 维隔离+id 升序、remove 幂等删除)
+- - 新增 Controller IndicatorFavoriteController:
+-   /api/indicator/favorite/toggle|list|remove 三个 POST,
+-   @RequestAttribute 取 userId/familyId 不信任前端传值,checkMemberBelongs 归属校验
+- - DatabaseInitializer 新增迁移429 createIndicatorFavoriteTable()
+-   (主线最大编号已为 428 coupon_gift_transfer,故取 428+1=429,幂等建表)
+- - JwtInterceptor.LOGIN_REQUIRED_PATHS 追加 /api/indicator/favorite(强制登录防匿名污染)
+- - schema.sql 追加 indicator_favorite 建表 DDL(与 DatabaseInitializer 一致)
+- - 单测 IndicatorFavoriteServiceTest(6) + IndicatorFavoriteControllerTest(10),
+-   JwtInterceptorLoginRequiredTest 增补收藏接口无 token 401 两条,共 21 例全绿
+- - 文档:API_REFERENCE.md 新增 §4.48.2、2026-10-25 设计稿补方案A落点、PROJECT-OVERVIEW 同步
+- 不涉及前端(F1-F5 需基线 dbf13c610,尚未合入 cfclub,按 §12.1 待主理人确认)。
+- 
+-   - ChildBadge -> MemberBadge(实体),字段 childId -> memberId(@TableField("child_id") 兼容旧列)
+-   - ChildBadgeMapper -> MemberBadgeMapper
+-   - BadgeService/Controller/UserController 方法:getChildBadges->getMemberBadges、getActiveChildBadges->getActiveMemberBadges、getChildBadgeStats->getMemberBadgeStats、getChildBadgeDetail->getMemberBadgeDetail;Controller 路径 /child/list->/member/list、/statistics/child->/statistics/member;返回字段 childBadge->memberBadge
+-   - 前端 api.js:getChildBadges->getMemberBadges、getChildBadgeStats->getMemberBadgeStats(getUserBadges 别名保留);achievement/wealth/rewards 页面同步;接口矩阵文档路径同步
+- - 成就页(pages/achievement/index.vue)重构:移除优惠券入口(资产类不属成就,财富中心已有)、移除"累计积分"目标;修正视角——调 getMemberBadges(null) 由后端 currentMemberId 查当前登录用户本人成就
+- - 约定:仅代码层规范,数据库表 child_badge / 列 child_id 保留,用 @TableField 兼容,未写迁移
+- 
+
+### 新功能
+- P点口径改为成本价核算 + 多商品明细分摊落库
+- T04 指标收藏后端化(方案A)— GAP-2026-10-30-indicator-fav
+
+### 测试
+- 新增 API 级 e2e 套件(远程后端实测 37 passed / 3 fixme)
+- 接口冒烟闭环 2026-10-30 15:17 — 通过率 45% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-30 15:07 — 通过率 45% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-30 14:55 — 通过率 45% (fix=0 reg=0)
+
+### 重构
+- 批量规范化 childId 历史字段为 memberId(仅代码层)
+- 规范 ChildBadge 命名为 MemberBadge,并收敛成就页为纯成就
+
+
 ## v1.0.1854 (2026-10-30)
 
 ### Bug 修复

+ 21 - 2
cfc-web/src/views/Dashboard.vue

@@ -33,10 +33,12 @@
         <div
           class="kpi-card"
           :class="{ 'is-clickable': card.click }"
-          role="button"
-          tabindex="0"
+          :role="card.click ? 'button' : null"
+          :tabindex="card.click ? 0 : -1"
+          :aria-label="card.click ? card.label : null"
           @click="card.click && goPath(card.click)"
           @keyup.enter="card.click && goPath(card.click)"
+          @keydown.space.prevent="card.click && goPath(card.click)"
         >
           <div class="kpi-card__icon" :class="'kpi-icon--' + card.color">
             <i :class="card.icon"></i>
@@ -99,6 +101,7 @@
           >
             <span class="heatmap__hour">{{ h }}</span>
             <span class="heatmap__value">{{ fmtDuration(v) }}</span>
+            <span v-if="v > 0 && Number(v) >= Number(dwellByHourPeak) * 0.6" class="heatmap__flag" aria-hidden="true">高</span>
           </div>
         </div>
         <div class="heatmap__axis">
@@ -232,8 +235,10 @@
         :key="act.label"
         role="button"
         tabindex="0"
+        :aria-label="act.label"
         @click="goPath(act.path)"
         @keyup.enter="goPath(act.path)"
+        @keydown.space.prevent="goPath(act.path)"
       >
         <div class="action-icon" :class="act.iconClass"><i :class="act.icon"></i></div>
         <div class="action-label">{{ act.label }}</div>
@@ -1016,6 +1021,7 @@ export default {
 .heatmap { padding: 8px 4px; }
 .heatmap__row { display: flex; gap: 3px; }
 .heatmap__cell {
+  position: relative;
   flex: 1;
   min-width: 0;
   height: 48px;
@@ -1029,6 +1035,19 @@ export default {
   overflow: hidden;
 }
 .heatmap__cell:hover { opacity: 0.88; }
+.heatmap__flag {
+  position: absolute;
+  top: 2px;
+  right: 3px;
+  font-size: 9px;
+  font-weight: 700;
+  line-height: 1;
+  padding: 1px 3px;
+  border-radius: 4px;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.28);
+  letter-spacing: 0.02em;
+}
 .heatmap__hour {
   font-size: 11px;
   font-weight: 600;

+ 8 - 0
cfc-web/src/views/admin/ProductEdit.vue

@@ -181,6 +181,11 @@
               <el-input-number v-model="form.memberPrice" :min="0" :precision="2" :step="0.01" placeholder="留空等于普通价" class="full-width" controls />
             </el-form-item>
           </el-col>
+          <el-col :span="6">
+            <el-form-item label="成本价(元)">
+              <el-input-number v-model="form.purchasePrice" :min="0" :precision="2" :step="0.01" placeholder="用于P点=实付-成本" class="full-width" controls />
+            </el-form-item>
+          </el-col>
           <el-col :span="6">
             <el-form-item label="库存" prop="stock">
               <el-input-number v-model="form.stock" :min="0" :precision="0" placeholder="0" class="full-width" controls />
@@ -407,6 +412,7 @@ export default {
         productType: 'physical',
         price: null,
         memberPrice: null,
+        purchasePrice: null,
         stock: null,
         minQuantity: 1,
         vendorName: '',
@@ -707,6 +713,7 @@ export default {
             productType: p.productType || 'physical',
             price: (p.price != null) ? p.price / 100 : null,
             memberPrice: (p.memberPrice != null && p.memberPrice > 0) ? p.memberPrice / 100 : null,
+            purchasePrice: (p.purchasePrice != null) ? p.purchasePrice / 100 : null,
             stock: (p.stock != null) ? p.stock : null,
             minQuantity: p.minQuantity || null,
             vendorName: p.vendorName || '',
@@ -1092,6 +1099,7 @@ async loadGiftRule(productId) {
             ...this.form,
             price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
             memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
+            purchasePrice: (this.form.purchasePrice != null && this.form.purchasePrice > 0) ? Math.round(this.form.purchasePrice * 100) : 0,
             images: JSON.stringify(this.imageList)
           }
           // Extract dimensionWeights before sending to product API