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

feat(activity): A2 活动适用人群 + 费用模型重构

按 docs/superpowers/plans/2026-10-26-activity-create-page-ux-and-audience-design.md §二 2.1-2.5 落地:

后端
- 迁移421: activities 新增 audience_scope(all/member_only) / audience_type
  (adult/include_child/include_elder/include_child_elder) 两列,
  information_schema 存在性检查 + AFTER visible_to 列定位,幂等;schema.sql 同步。
- Activity/ActivityDTO 新增 audienceScope/audienceType 字段与映射。
- ActivityDTO 新增 basePrice(原始普通价):price 已被 resolveMemberPrice
  替换成会员价,编辑表单回填必须用原始值,否则会员编辑会把会员价写成普通价。
- ActivityOrderService 结算按参与者身份分类:
  self(未关联成员)/child(age<18)/elder(age>=60)/adult(其他成年家庭成员),
  废弃「memberId 非空即孩子」旧语义;批量预取年龄避免 N+1。
- 成人档 price>0 ? price : parentFee;会员档区分 memberPrice NULL(存量别名)
  vs 0(新模型同普通价),parentMemberFee=0 的存量免费语义原样保留。
- audience_scope=member_only 后端硬门禁:非登录引导登录、非会员拒绝,
  范式对齐 TaskController.checkMemberOnly。

前端(create.vue 仅【费用与适用人群区块】)
- 参加范围单选 + 参与人群多选(成人恒选,含儿童/含长者)
- 价格/会员价恒显输入;儿童两档仅在勾选「含儿童」时显示
- 不再提交 parentFee/parentMemberFee(历史别名);存量收费不误清零
- 保留 A1 已提交的 multiSelector 时间选择器代码,未改动

测试:ActivityOrderServiceTest(15) + ActivityDTOTest(3),18 例全绿
zxyj-web 2 недель назад
Родитель
Сommit
d64617c840

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

@@ -3323,6 +3323,50 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移420: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4)
         // 注:迁移419 已被 membership_levels.max_daily_analyze 占用,本表顺延为 420。
         createPpointConsumptionLogTable();
+
+        // 迁移421: activities 表新增适用人群字段 audience_scope / audience_type(A2 活动人群与费用模型重构)
+        // 说明:沿用 visible_scope 迁移的 information_schema.COLUMNS 存在性检查范式,
+        //      以便用 AFTER visible_to 精确定位列顺序(ensureColumn 不支持列定位)。
+        addActivityAudienceColumns();
+    }
+
+    /**
+     * 迁移421: activities 表新增 audience_scope / audience_type 两列(A2)
+     * - audience_scope: all(默认,全部用户可参加) / member_only(仅会员可参加,后端硬门禁)
+     * - audience_type : adult(默认,仅成人) / include_child / include_elder / include_child_elder
+     * 长者档不新增独立价格列(决策D1),长者复用成人价,audience_type 仅作展示与筛选标签。
+     * 幂等:information_schema 检查列是否存在,存在则跳过。
+     */
+    private void addActivityAudienceColumns() {
+        // 迁移421-1: activities表添加audience_scope列
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_scope'",
+                    Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_scope VARCHAR(20) DEFAULT 'all' COMMENT '参与人群范围: all(全部用户)/member_only(仅会员可参加)' AFTER visible_to");
+                log.info("已添加audience_scope列到activities表");
+            } else {
+                log.info("activities表audience_scope列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加activities表audience_scope列失败: {}", e.getMessage());
+        }
+
+        // 迁移421-2: activities表添加audience_type列
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'audience_type'",
+                    Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN audience_type VARCHAR(20) DEFAULT 'adult' COMMENT '参与人群构成: adult(仅成人)/include_child/include_elder/include_child_elder' AFTER audience_scope");
+                log.info("已添加audience_type列到activities表");
+            } else {
+                log.info("activities表audience_type列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加activities表audience_type列失败: {}", e.getMessage());
+        }
     }
 
     private void migrateHealthDimensionsTable() {

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -28,6 +28,8 @@ public class ActivityDTO {
     /** 签到人数(checkin 时 +1) */
     private Integer signedCount;
     private Integer price;
+    /** 成人档普通价(分) 原始值(未做会员价解析),供编辑表单回填;price 为展示值(会员看会员价) */
+    private Integer basePrice;
     private String vendorName;
     private String priceLabel;
 
@@ -76,6 +78,12 @@ public class ActivityDTO {
     /** 创建时选择的海报模板ID(小程序端海报模板选择) */
     private Long posterTemplateId;
 
+    /** 参与人群范围: all(全部用户,默认)/member_only(仅会员可参加) */
+    private String audienceScope;
+
+    /** 参与人群构成: adult/include_child/include_elder/include_child_elder(空=历史活动,按 adult 处理) */
+    private String audienceType;
+
     public static ActivityDTO from(Activity activity) {
         return from(activity, false, null);
     }
@@ -116,6 +124,11 @@ public class ActivityDTO {
         dto.setCreatorId(activity.getCreatorId());
         dto.setFeedbackDimensions(activity.getFeedbackDimensions());
         dto.setPosterTemplateId(activity.getPosterTemplateId());
+        dto.setAudienceScope(activity.getAudienceScope());
+        dto.setAudienceType(activity.getAudienceType());
+        // basePrice 保留原始普通价:price 会被 resolveMemberPrice 替换成会员价,
+        // 编辑表单需要原始值回填,否则会员编辑活动会把会员价写成普通价
+        dto.setBasePrice(activity.getPrice());
         // Member-aware pricing: resolve final display price based on membership level
         // Guests (no memberLevel) see base price; members see memberPrice if applicable
         Integer resolvedPrice = resolveMemberPrice(activity.getPrice(), activity.getMemberPrice(), memberLevel);

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

@@ -117,6 +117,12 @@ public class Activity implements Serializable {
     /** 可见对象标识: 家庭ID/角色名/用户ID 的JSON数组, 如 "["family_1","family_2"]" */
     private String visibleTo;
 
+    /** 参与人群范围: all(全部用户,默认)/member_only(仅会员可参加,下单后端硬门禁) */
+    private String audienceScope;
+
+    /** 参与人群构成: adult(仅成人,默认)/include_child/include_elder/include_child_elder;长者复用成人价,仅作展示与筛选标签 */
+    private String audienceType;
+
     private Long vendorId;
 
     private String vendorName;

+ 136 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java

@@ -7,10 +7,12 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.ActivityOrder;
 import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.mapper.ActivityOrderMapper;
 import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -38,6 +40,9 @@ public class ActivityOrderService {
     @Resource
     private ActivityRegistrationMapper registrationMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private UserMapper userMapper;
 
@@ -67,8 +72,13 @@ public class ActivityOrderService {
         try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
         boolean isMember = "FAMILY".equals(memberLevel) || "PREMIUM".equals(memberLevel);
 
+        // A2 §2.5 仅会员参加硬门禁:audience_scope=member_only 时,非会员拒绝、非登录用户引导登录(后端硬门禁,前端提示仅作体验优化)
+        String gateMessage = memberOnlyGateMessage(activity, userId, isMember);
+        if (gateMessage != null) return Result.error(gateMessage);
+
         int totalAmount = 0;
         List<Map<String, Object>> snapshot = new ArrayList<>();
+        Map<Long, Integer> memberAgeMap = loadMemberAges(registrants);
         for (Map<String, Object> reg : registrants) {
             String name = (String) reg.get("name");
             if (name == null || name.trim().isEmpty()) return Result.error("报名人姓名不能为空");
@@ -78,17 +88,16 @@ public class ActivityOrderService {
             if (memberId != null && isRegistered(activityId, memberId)) {
                 return Result.error("已报名: " + name);
             }
-            boolean isChild = memberId != null;
-            Integer fee = isChild
-                    ? (isMember ? activity.getChildMemberFee() : activity.getChildFee())
-                    : (isMember ? activity.getParentMemberFee() : activity.getParentFee());
-            if (fee == null) fee = 0;
+            // A2 §2.4 按参与者身份分类计费:self(本人)/child(age<18)/elder(age>=60)/adult(其他成年家庭成员),
+            // 不再用「memberId 非空即孩子」的旧语义;age 为空一律回落 adult,绝不按 child 处理(否则会多收费)
+            String participantType = classifyParticipant(memberId, memberAgeMap.get(memberId));
+            Integer fee = resolveFee(activity, participantType, isMember);
             totalAmount += fee;
             Map<String, Object> item = new HashMap<>();
             item.put("name", name.trim());
             item.put("phone", phone != null ? phone.trim() : null);
             item.put("memberId", memberId);
-            item.put("type", isChild ? "child" : "parent");
+            item.put("type", participantType);
             item.put("fee", fee);
             snapshot.add(item);
         }
@@ -121,6 +130,127 @@ public class ActivityOrderService {
         return Result.success(result);
     }
 
+    /** 报名人身份分类(写入订单快照 type 字段,与 A2 §2.4 一致) */
+    static final String TYPE_SELF = "self";
+    static final String TYPE_CHILD = "child";
+    static final String TYPE_ELDER = "elder";
+    static final String TYPE_ADULT = "adult";
+
+    /** 儿童年龄上限(不含):age < 18 视为儿童 */
+    static final int CHILD_AGE_MAX_EXCLUSIVE = 18;
+    /** 长者年龄下限(含):age >= 60 视为长者 */
+    static final int ELDER_AGE_MIN = 60;
+
+    /** 批量预取报名人年龄,避免逐人查库(N+1) */
+    private Map<Long, Integer> loadMemberAges(List<Map<String, Object>> registrants) {
+        Map<Long, Integer> ageMap = new HashMap<>();
+        List<Long> memberIds = new ArrayList<>();
+        for (Map<String, Object> reg : registrants) {
+            Object memberIdObj = reg.get("memberId");
+            if (memberIdObj == null) continue;
+            Long memberId;
+            try {
+                memberId = Long.valueOf(memberIdObj.toString());
+            } catch (NumberFormatException e) {
+                continue;
+            }
+            if (memberId != null && !memberIds.contains(memberId)) {
+                memberIds.add(memberId);
+            }
+        }
+        if (memberIds.isEmpty()) return ageMap;
+        List<FamilyMember> members = familyMemberMapper.selectBatchIds(memberIds);
+        if (members == null) return ageMap;
+        for (FamilyMember member : members) {
+            if (member == null || member.getId() == null) continue;
+            ageMap.put(member.getId(), resolveAge(member));
+        }
+        return ageMap;
+    }
+
+    /**
+     * 取报名人年龄。FamilyMember.getAge() 在 birthday 为空时返回 0(int 基础类型),
+     * 直接消费会把「年龄未知」误判成 0 岁儿童并错收 childFee,故此处显式返回 null 表示未知。
+     */
+    static Integer resolveAge(FamilyMember member) {
+        if (member == null || member.getBirthday() == null) return null;
+        return member.getAge();
+    }
+
+    /**
+     * 报名人身份分类:
+     * self  = 未关联家庭成员(下单人本人,不含儿童语义)
+     * child = 家庭成员且 age < 18
+     * elder = 家庭成员且 age >= 60
+     * adult = 其他成年家庭成员
+     *
+     * 判定只用 FamilyMember 现有字段(age,源自 birthday),不新增角色字段;
+     * 关键回归点:age 为空一律回落 adult,绝不可按 child 处理。
+     */
+    static String classifyParticipant(Long memberId, Integer age) {
+        if (memberId == null) return TYPE_SELF;
+        if (age == null) return TYPE_ADULT;
+        if (age < CHILD_AGE_MAX_EXCLUSIVE) return TYPE_CHILD;
+        if (age >= ELDER_AGE_MIN) return TYPE_ELDER;
+        return TYPE_ADULT;
+    }
+
+    /**
+     * 成人档非会员价(分):优先新列 price,回退历史别名 parentFee。
+     * parentFee 已降级为「成人非会员价」的历史别名(老活动不改数据、不做回迁)。
+     */
+    static Integer resolveAdultNonMemberFee(Activity activity) {
+        Integer price = activity.getPrice();
+        if (price != null && price > 0) return price;
+        Integer parentFee = activity.getParentFee();
+        return parentFee != null ? parentFee : 0;
+    }
+
+    /**
+     * 成人档会员价(分):优先新列 memberPrice。
+     * - memberPrice > 0 → 新列会员价
+     * - memberPrice == null → 存量别名路径:老活动从未写入过 memberPrice,沿用 parentMemberFee
+     *   的历史语义(0=免费),原样保留以免改动存量活动的收费结果
+     * - memberPrice == 0 → 新模型下「会员价留空/为 0」= 同非会员价(§2.2)
+     */
+    static Integer resolveAdultMemberFee(Activity activity) {
+        Integer memberPrice = activity.getMemberPrice();
+        if (memberPrice != null && memberPrice > 0) return memberPrice;
+        if (memberPrice == null) {
+            Integer parentMemberFee = activity.getParentMemberFee();
+            return parentMemberFee != null ? parentMemberFee : resolveAdultNonMemberFee(activity);
+        }
+        return resolveAdultNonMemberFee(activity);
+    }
+
+    /**
+     * 按参与者身份解析单人结算金额(分)。
+     * child → childFee / childMemberFee(与改造前完全一致);
+     * self / elder / adult → 成人档 price / memberPrice(含 parentFee / parentMemberFee 历史别名回退)。
+     * 长者不新增独立价格列(决策 D1),复用成人价,audience_type 仅作展示与筛选标签。
+     */
+    static Integer resolveFee(Activity activity, String participantType, boolean isMember) {
+        if (TYPE_CHILD.equals(participantType)) {
+            Integer childFee = isMember ? activity.getChildMemberFee() : activity.getChildFee();
+            return childFee != null ? childFee : 0;
+        }
+        Integer adultFee = isMember ? resolveAdultMemberFee(activity) : resolveAdultNonMemberFee(activity);
+        return adultFee != null ? adultFee : 0;
+    }
+
+    /**
+     * A2 §2.5 仅会员参加硬门禁:audience_scope=member_only 时仅会员可下单。
+     * 范式对齐 TaskController.checkMemberOnly(后端硬门禁,前端提示仅作体验优化)。
+     *
+     * @return 拦截时的错误文案;通过时返回 null
+     */
+    static String memberOnlyGateMessage(Activity activity, Long userId, boolean isMember) {
+        if (activity == null || !"member_only".equals(activity.getAudienceScope())) return null;
+        if (userId == null) return "请先登录后再报名";
+        if (!isMember) return "该活动仅会员可参加";
+        return null;
+    }
+
     public Result<Map<String, Object>> pay(String orderNo, Long userId) {
         ActivityOrder order = getByOrderNo(orderNo);
         if (order == null) return Result.error("订单不存在");

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

@@ -2642,6 +2642,10 @@ ALTER TABLE activities MODIFY COLUMN require_checkin_review TINYINT DEFAULT 0 CO
 UPDATE activities SET require_checkin_review = 0 WHERE require_checkin_review = 1;
 UPDATE activities a SET a.approved_count = (SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM activity_registrations r WHERE r.activity_id = a.id AND r.status = 'approved') t);
 
+-- 2026-10-26 A2: 活动适用人群字段(迁移421,DatabaseInitializer 幂等落地)
+ALTER TABLE activities ADD COLUMN audience_scope VARCHAR(20) DEFAULT 'all' COMMENT '参与人群范围: all(全部用户)/member_only(仅会员可参加)' AFTER visible_to;
+ALTER TABLE activities ADD COLUMN audience_type VARCHAR(20) DEFAULT 'adult' COMMENT '参与人群构成: adult(仅成人)/include_child/include_elder/include_child_elder' AFTER audience_scope;
+
 -- ============================================================
 -- 新增:系统配置表(角色年龄阈值等)
 -- ============================================================

+ 47 - 0
cfc-backend/src/test/java/com/etotem/cfc/dto/ActivityDTOTest.java

@@ -0,0 +1,47 @@
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.entity.Activity;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * A2 活动费用模型:ActivityDTO.basePrice 必须保留原始普通价,
+ * 不被会员价解析替换(否则会员编辑活动时会把会员价误写成普通价)。
+ */
+class ActivityDTOTest {
+
+    private Activity activity(Integer price, Integer memberPrice) {
+        Activity a = new Activity();
+        a.setId(1L);
+        a.setTitle("测试活动");
+        a.setPrice(price);
+        a.setMemberPrice(memberPrice);
+        return a;
+    }
+
+    /** 非会员看到的是普通价;basePrice 始终为原始普通价 */
+    @Test
+    void basePrice_rawForNonMember() {
+        ActivityDTO dto = ActivityDTO.from(activity(10000, 8000), true, null);
+        assertEquals(10000, dto.getPrice(), "非会员展示普通价");
+        assertEquals(10000, dto.getBasePrice(), "basePrice=原始普通价");
+        assertEquals(8000, dto.getMemberPrice(), "memberPrice 原样返回");
+    }
+
+    /** 会员看到的是会员价,但 basePrice 仍是原始普通价(编辑表单回填的关键) */
+    @Test
+    void basePrice_rawForMember() {
+        ActivityDTO dto = ActivityDTO.from(activity(10000, 8000), false, "FAMILY");
+        assertEquals(8000, dto.getPrice(), "会员展示会员价");
+        assertEquals(10000, dto.getBasePrice(), "basePrice 不被会员价替换,供编辑回填");
+    }
+
+    /** 存量活动(price=0/空)basePrice=0 → 前端兜底历史别名 parentFee */
+    @Test
+    void basePrice_zeroForLegacyPriceNeverSet() {
+        ActivityDTO dto = ActivityDTO.from(activity(0, null), false, "FAMILY");
+        assertEquals(0, dto.getBasePrice(), "price 未写入过 → basePrice=0,前端回退 parentFee");
+        assertNull(dto.getMemberPrice(), "memberPrice 从未写入过 → NULL(走 parentMemberFee 别名)");
+    }
+}

+ 216 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ActivityOrderServiceTest.java

@@ -0,0 +1,216 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.FamilyMember;
+import org.junit.jupiter.api.Test;
+
+import java.util.Calendar;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * A2 活动适用人群 + 费用模型重构(audienceScope/audienceType + 参与者身份分类结算 + 仅会员硬门禁)
+ *
+ * 覆盖验收点:
+ * ⑤ 老活动(audience_type 为空/默认 adult)结算金额与改造前逐分一致 —— 用
+ *    price / parentFee / memberPrice / parentMemberFee 四个字段各非空、组合覆盖
+ *    4 种取值路径各测一例(新列优先 / 旧列回退 / 混合路径)。
+ * ④ 报名人 age 为空 → 一律按成人档计费,不得误判为儿童。
+ * ③ member_only 硬门禁:非会员拒绝、非登录用户引导登录。
+ */
+class ActivityOrderServiceTest {
+
+    /** 构造活动:childFee/childMemberFee 固定为 1000/800 分,四档成人价格由参数指定 */
+    private Activity activity(Integer price, Integer memberPrice, Integer parentFee, Integer parentMemberFee) {
+        return activity(price, memberPrice, parentFee, parentMemberFee, null);
+    }
+
+    private Activity activity(Integer price, Integer memberPrice, Integer parentFee, Integer parentMemberFee,
+                              String audienceType) {
+        Activity a = new Activity();
+        a.setPrice(price);
+        a.setMemberPrice(memberPrice);
+        a.setParentFee(parentFee);
+        a.setParentMemberFee(parentMemberFee);
+        a.setChildFee(1000);
+        a.setChildMemberFee(800);
+        a.setAudienceScope("all");
+        a.setAudienceType(audienceType);
+        return a;
+    }
+
+    /**
+     * 复刻改造前的结算逻辑(ActivityOrderService 旧实现,用于逐分对比):
+     * old = isChild ? (isMember ? childMemberFee : childFee)
+     *               : (isMember ? parentMemberFee : parentFee)
+     */
+    private int oldFee(Activity a, boolean child, boolean member) {
+        if (child) {
+            return member ? nz(a.getChildMemberFee()) : nz(a.getChildFee());
+        }
+        return member ? nz(a.getParentMemberFee()) : nz(a.getParentFee());
+    }
+
+    private int nz(Integer v) {
+        return v == null ? 0 : v;
+    }
+
+    // ============ 验收⑤:4 种成人档取值路径(旧列回退路径必须逐分一致) ============
+
+    /** 路径1:非会员走新列 price(price 非空且 >0) */
+    @Test
+    void adultFee_case1_priceTakesPrecedence() {
+        Activity a = activity(10000, 8000, 0, 0); // price/memberPrice 承担成人价,旧列 0
+        assertEquals(10000, ActivityOrderService.resolveAdultNonMemberFee(a), "非会员 → 新列 price(10000)");
+        assertEquals(8000, ActivityOrderService.resolveAdultMemberFee(a), "会员 → 新列 memberPrice(8000)");
+        assertEquals(10000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "self 非会员");
+        assertEquals(8000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "self 会员");
+        // 儿童档不受影响,与改造前逐分一致
+        assertEquals(1000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_CHILD, false), "child 非会员");
+        assertEquals(800, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_CHILD, true), "child 会员");
+    }
+
+    /** 路径2:新列为 0/空 → 回退旧列 parentFee / parentMemberFee(典型存量小程序活动形态) */
+    @Test
+    void adultFee_case2_legacyColumnsFallback_identicalToOld() {
+        Activity a = activity(0, null, 12000, 9000); // 存量活动:price=0、memberPrice=NULL、旧列承载成人价
+        assertNull(a.getAudienceType(), "存量活动 audience_type 可能为空(迁移 DEFAULT 前创建)");
+        // 非会员与会员 4 种身份 × 新旧实现逐分一致
+        assertEquals(oldFee(a, false, false), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "self 非会员");
+        assertEquals(oldFee(a, false, true), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "self 会员");
+        assertEquals(oldFee(a, true, false), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_CHILD, false), "child 非会员");
+        assertEquals(oldFee(a, true, true), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_CHILD, true), "child 会员");
+    }
+
+    /** 路径3:会员走新列 memberPrice、非会员走旧列 parentFee(混合) */
+    @Test
+    void adultFee_case3_mixed_legacyPrice_newMemberPrice() {
+        Activity a = activity(0, 6000, 11000, 0);
+        assertEquals(11000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "self 非会员 → parentFee(11000) 与旧实现一致");
+        assertEquals(oldFee(a, false, false), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "非会员路径逐分一致");
+        assertEquals(6000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "self 会员 → memberPrice(6000)");
+    }
+
+    /** 路径4:会员走旧列 parentMemberFee(memberPrice 未写入过 = NULL,存量别名路径,0=免费 原样保留) */
+    @Test
+    void adultFee_case4_legacyMemberPriceFallback() {
+        Activity a = activity(0, null, 0, 7000);
+        assertNull(a.getMemberPrice(), "存量活动的 memberPrice 为 NULL");
+        assertEquals(7000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "self 会员 → parentMemberFee(7000)");
+        assertEquals(oldFee(a, false, true), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "会员路径逐分一致");
+    }
+
+    /** 存量别名路径下 parentMemberFee=0(历史语义:会员免费)必须原样保留,不得改为按成人价收费 */
+    @Test
+    void adultFee_legacyMemberPriceZero_staysFree() {
+        Activity a = activity(0, null, 12000, 0);
+        assertEquals(12000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "非会员 → parentFee(12000)");
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "会员 → parentMemberFee=0(免费)原样保留");
+        assertEquals(oldFee(a, false, true), ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "会员路径逐分一致");
+    }
+
+    /** 迁移 DEFAULT='adult' 后,存量活动 audience_type 已是 adult,行为与空值等价(不分支于 audience_type 计费) */
+    @Test
+    void adultFee_audienceTypeDefaultAdult_equivalentToLegacy() {
+        Activity legacyNull = activity(0, null, 12000, 9000, null);
+        Activity defaulted = activity(0, null, 12000, 9000, "adult");
+        for (boolean member : new boolean[]{false, true}) {
+            assertEquals(
+                    ActivityOrderService.resolveFee(legacyNull, ActivityOrderService.TYPE_SELF, member),
+                    ActivityOrderService.resolveFee(defaulted, ActivityOrderService.TYPE_SELF, member),
+                    "audience_type 空/默认 adult 计费结果必须一致");
+        }
+    }
+
+    /** 会员价与旧别名均为 0/空 → 回落成人非会员价(会员不免费) */
+    @Test
+    void adultMemberFee_memberPriceUnset_fallsBackToBasePrice() {
+        Activity a = activity(20000, 0, 0, 0); // 新活动形态:price=20000,memberPrice 未设置
+        assertEquals(20000, ActivityOrderService.resolveAdultMemberFee(a), "会员价留空 = 同非会员价");
+        assertEquals(20000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "self 会员按非会员价");
+        assertEquals(20000, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_ELDER, true), "elder 会员复用成人价");
+    }
+
+    // ============ 验收④:age 为空必须回落 adult(不得误判为儿童) ============
+
+    @Test
+    void classify_ageMissing_neverChild() {
+        assertEquals(ActivityOrderService.TYPE_SELF, ActivityOrderService.classifyParticipant(null, null), "未关联成员=本人");
+        assertEquals(ActivityOrderService.TYPE_ADULT, ActivityOrderService.classifyParticipant(1L, null), "年龄未知→adult,绝不 child");
+        assertEquals(ActivityOrderService.TYPE_ADULT, ActivityOrderService.classifyParticipant(1L, 18), "18 岁不算儿童");
+        assertEquals(ActivityOrderService.TYPE_CHILD, ActivityOrderService.classifyParticipant(1L, 17), "17 岁儿童");
+        assertEquals(ActivityOrderService.TYPE_CHILD, ActivityOrderService.classifyParticipant(1L, 0), "0 岁儿童");
+        assertEquals(ActivityOrderService.TYPE_ADULT, ActivityOrderService.classifyParticipant(1L, 59), "59 岁成人");
+        assertEquals(ActivityOrderService.TYPE_ELDER, ActivityOrderService.classifyParticipant(1L, 60), "60 岁长者");
+        assertEquals(ActivityOrderService.TYPE_ELDER, ActivityOrderService.classifyParticipant(1L, 100), "100 岁长者");
+    }
+
+    /** FamilyMember.birthday 为空 → resolveAge 返回 null(不得当 0 岁儿童) */
+    @Test
+    void resolveAge_birthdayMissing_returnsNull() {
+        FamilyMember m = new FamilyMember();
+        assertNull(ActivityOrderService.resolveAge(m), "birthday 为空 → 年龄未知");
+        assertNull(ActivityOrderService.resolveAge(null), "成员不存在 → 年龄未知");
+    }
+
+    @Test
+    void resolveAge_birthdayPresent_computesYears() {
+        FamilyMember m = new FamilyMember();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.YEAR, -10);
+        cal.add(Calendar.DAY_OF_YEAR, -5);
+        m.setBirthday(cal.getTime());
+        Integer age = ActivityOrderService.resolveAge(m);
+        assertNotNull(age);
+        assertTrue(age == 10 || age == 11, "10 岁生日前后误差 1 年,实际=" + age);
+    }
+
+    // ============ 验收③:仅会员硬门禁(后端拦截,与 TaskController.checkMemberOnly 同范式) ============
+
+    @Test
+    void memberOnlyGate_unloggedUser() {
+        Activity a = activity(0, null, 0, 0);
+        a.setAudienceScope("member_only");
+        assertEquals("请先登录后再报名", ActivityOrderService.memberOnlyGateMessage(a, null, false), "非登录用户→引导登录");
+    }
+
+    @Test
+    void memberOnlyGate_nonMember() {
+        Activity a = activity(0, null, 0, 0);
+        a.setAudienceScope("member_only");
+        assertEquals("该活动仅会员可参加", ActivityOrderService.memberOnlyGateMessage(a, 1L, false), "非会员→拒绝");
+    }
+
+    @Test
+    void memberOnlyGate_member() {
+        Activity a = activity(0, null, 0, 0);
+        a.setAudienceScope("member_only");
+        assertNull(ActivityOrderService.memberOnlyGateMessage(a, 1L, true), "会员→放行");
+    }
+
+    @Test
+    void memberOnlyGate_allActivitiesPass() {
+        Activity a = activity(0, null, 0, 0);
+        a.setAudienceScope("all");
+        assertNull(ActivityOrderService.memberOnlyGateMessage(a, null, false), "all 不加会员门槛,非登录也由既有鉴权兜底");
+        assertNull(ActivityOrderService.memberOnlyGateMessage(a, 1L, false), "all 非会员放行");
+        assertNull(ActivityOrderService.memberOnlyGateMessage(null, 1L, false), "活动不存在由前置校验兜底");
+    }
+
+    /** 免费活动(全部档位为 0/空)→ totalAmount=0 走免报名快照分支(验收⑥:金额口径不变) */
+    @Test
+    void freeActivity_allFeesZero() {
+        Activity a = new Activity();
+        a.setPrice(0);
+        a.setMemberPrice(0);
+        a.setParentFee(0);
+        a.setParentMemberFee(0);
+        a.setChildFee(0);
+        a.setChildMemberFee(0);
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, false), "免费活动 self 非会员 0 分");
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_SELF, true), "免费活动 self 会员 0 分");
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_CHILD, false), "免费活动 child 0 分");
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_ELDER, true), "免费活动 elder 0 分");
+        assertEquals(0, ActivityOrderService.resolveFee(a, ActivityOrderService.TYPE_ADULT, false), "免费活动 adult 0 分");
+    }
+}

+ 234 - 23
cfc-frontend/pages/activity/create.vue

@@ -54,32 +54,63 @@
         <input class="form-input" v-model="form.location" placeholder="请输入活动地点" maxlength="100" />
       </view>
 
-      <!-- 人数 + 价格 -->
+      <!-- 人数 -->
       <view class="form-item-row">
         <view class="form-item half">
           <text class="form-label">最大人数</text>
           <input class="form-input" type="number" v-model="maxParticipantsStr" placeholder="0=不限" />
         </view>
-        <view class="form-item half">
-          <text class="form-label">孩子费用(元)</text>
-          <input class="form-input" type="digit" v-model="childFeeYuan" placeholder="0=免费" />
+      </view>
+
+      <!-- A2 §2.1 参加范围(全部用户 / 仅会员参加) -->
+      <view class="form-item">
+        <text class="form-label">参加范围 *</text>
+        <view class="radio-group">
+          <label class="radio-item" v-for="opt in scopeOptions" :key="opt.value" @tap="selectScope(opt.value)">
+            <view class="radio-icon" :class="{ active: form.audienceScope === opt.value }">
+              <view class="radio-inner" v-if="form.audienceScope === opt.value"></view>
+            </view>
+            <text class="radio-label">{{ opt.label }}</text>
+          </label>
         </view>
       </view>
 
+      <!-- A2 §2.1 参与人群(成人恒选,可叠加儿童 / 长者) -->
+      <view class="form-item">
+        <text class="form-label">参与人群</text>
+        <view class="checkbox-group">
+          <label class="checkbox-item" v-for="opt in audienceTypeChoices" :key="opt.value" @tap="toggleAudienceType(opt.value)">
+            <view class="checkbox-box" :class="{ active: opt.checked }">
+              <text class="checkbox-check" v-if="opt.checked">✓</text>
+            </view>
+            <text class="radio-label">{{ opt.label }}</text>
+          </label>
+        </view>
+        <text class="form-hint">成人始终可参加;勾选「含儿童」后需填写儿童费用</text>
+      </view>
+
+      <!-- A2 §2.2 成人档价格 -->
       <view class="form-item-row">
         <view class="form-item half">
-          <text class="form-label">孩子会员费(元)</text>
-          <input class="form-input" type="digit" v-model="childMemberFeeYuan" placeholder="0=免费" />
+          <text class="form-label">价格(元)</text>
+          <input class="form-input" type="digit" v-model="priceYuan" placeholder="0=免费" />
         </view>
         <view class="form-item half">
-          <text class="form-label">家长费用(元)</text>
-          <input class="form-input" type="digit" v-model="parentFeeYuan" placeholder="0=免费" />
+          <text class="form-label">会员价(元)</text>
+          <input class="form-input" type="digit" v-model="memberPriceYuan" placeholder="留空=同普通价" />
         </view>
       </view>
 
-      <view class="form-item">
-        <text class="form-label">家长会员费(元)</text>
-        <input class="form-input" type="digit" v-model="parentMemberFeeYuan" placeholder="0=免费" />
+      <!-- A2 §2.3 儿童档价格:仅在参与人群含儿童时显示 -->
+      <view class="form-item-row" v-if="audienceChild">
+        <view class="form-item half">
+          <text class="form-label">儿童费用(元)</text>
+          <input class="form-input" type="digit" v-model="childFeeYuan" placeholder="0=免费" />
+        </view>
+        <view class="form-item half">
+          <text class="form-label">儿童会员费(元)</text>
+          <input class="form-input" type="digit" v-model="childMemberFeeYuan" placeholder="0=免费" />
+        </view>
       </view>
 
       <!-- 主办方 -->
@@ -195,6 +226,21 @@ export default {
     // 后台活动管理员可直接发布;服务商账号只能保存后提交审核
     isAdmin: function() {
       return hasActivityManageRole()
+    },
+    // A2 §2.1 参与人群勾选视图(成人恒选,不允许取消;勾选状态映射到 audienceChild/audienceElder)
+    audienceTypeChoices: function() {
+      var self = this
+      return this.audienceTypeOptions.map(function(opt) {
+        var checked = opt.value === 'adult' || self.isAudienceTypeChecked(opt.value)
+        return { value: opt.value, label: opt.label, checked: checked }
+      })
+    },
+    // A2 §2.1 提交给后端的 audience_type 合并取值
+    audienceTypeValue: function() {
+      if (this.audienceChild && this.audienceElder) return 'include_child_elder'
+      if (this.audienceChild) return 'include_child'
+      if (this.audienceElder) return 'include_elder'
+      return 'adult'
     }
   },
   data() {
@@ -220,17 +266,36 @@ export default {
         maxParticipants: 0,
         childFee: 0,
         childMemberFee: 0,
-        parentFee: 0,
-        parentMemberFee: 0,
+        // A2 §2.1 适用人群:audienceScope=全部用户/仅会员参加;audienceType=参与人群构成
+        audienceScope: 'all',
+        audienceType: 'adult',
         visibility: 'public',
         requireRegistration: 1,
         requireCheckinReview: 0,
         checkinPoints: 0
       },
+      // A2 §2.2 成人档价格输入(元);空 = 走默认/别名回退
+      priceYuan: '',
+      memberPriceYuan: '',
+      // 存量活动的 memberPrice 为 NULL(从未写入过),提交时需保持 NULL 以继续沿用 parentMemberFee 别名
+      memberPriceWasNull: false,
+      // 存量活动已有的儿童收费(用于「未勾选含儿童」时不误清零存量收费)
+      legacyChildFee: 0,
+      legacyChildMemberFee: 0,
+      // A2 §2.1 参与人群勾选状态(成人恒选,不提供取消)
+      audienceChild: false,
+      audienceElder: false,
+      scopeOptions: [
+        { value: 'all', label: '全部用户' },
+        { value: 'member_only', label: '仅会员参加' }
+      ],
+      audienceTypeOptions: [
+        { value: 'adult', label: '成人' },
+        { value: 'include_child', label: '含儿童' },
+        { value: 'include_elder', label: '含长者' }
+      ],
       childFeeYuan: '0',
       childMemberFeeYuan: '0',
-      parentFeeYuan: '0',
-      parentMemberFeeYuan: '0',
       maxParticipantsStr: '0',
       datePart: '',
       timePart: '',
@@ -294,15 +359,35 @@ export default {
           self.form.maxParticipants = d.maxParticipants || 0
           self.form.childFee = d.childFee || 0
           self.form.childMemberFee = d.childMemberFee || 0
-          self.form.parentFee = d.parentFee || 0
-          self.form.parentMemberFee = d.parentMemberFee || 0
+          // A2:不再回填 parentFee/parentMemberFee 到 form(历史别名,不再提交),仅按需取旧值兜底价格显示
+          self.form.audienceScope = d.audienceScope || 'all'
+          self.form.audienceType = d.audienceType || 'adult'
           self.form.status = d.status || 'draft'
           self.form.visibility = d.visibility || 'public'
           self.maxParticipantsStr = String(self.form.maxParticipants)
           self.childFeeYuan = self.form.childFee ? (self.form.childFee / 100).toString() : '0'
           self.childMemberFeeYuan = self.form.childMemberFee ? (self.form.childMemberFee / 100).toString() : '0'
-          self.parentFeeYuan = self.form.parentFee ? (self.form.parentFee / 100).toString() : '0'
-          self.parentMemberFeeYuan = self.form.parentMemberFee ? (self.form.parentMemberFee / 100).toString() : '0'
+          // A2 §2.3 存量活动已有的儿童收费:未勾选「含儿童」时不误清零,先暂存
+          self.legacyChildFee = self.form.childFee || 0
+          self.legacyChildMemberFee = self.form.childMemberFee || 0
+          // A2 §2.2 成人档价格回填:用 basePrice(原始普通价)而非 price(会员已解析展示值),
+          // 否则会员编辑时误把会员价当成普通价回写;basePrice 为 0/空时兜底历史别名 parentFee
+          var basePrice = d.basePrice || 0
+          var legacyPrice = (basePrice > 0) ? basePrice : (d.parentFee || 0)
+          self.priceYuan = legacyPrice ? (legacyPrice / 100).toString() : ''
+          // memberPrice 为 NULL 表示存量活动从未写入过(结算走 parentMemberFee 别名),需保留该状态
+          self.memberPriceWasNull = (d.memberPrice === null || d.memberPrice === undefined)
+          var legacyMemberPrice = 0
+          if (d.memberPrice && d.memberPrice > 0) {
+            legacyMemberPrice = d.memberPrice
+          } else if (self.memberPriceWasNull && d.parentMemberFee && d.parentMemberFee > 0) {
+            legacyMemberPrice = d.parentMemberFee
+          }
+          self.memberPriceYuan = legacyMemberPrice ? (legacyMemberPrice / 100).toString() : ''
+          // A2 §2.1 参与人群勾选状态回填
+          var at = d.audienceType || 'adult'
+          self.audienceChild = (at === 'include_child' || at === 'include_child_elder')
+          self.audienceElder = (at === 'include_elder' || at === 'include_child_elder')
           if (d.startTime) {
             var sd = parseDate(d.startTime)
             if (sd) {
@@ -343,6 +428,23 @@ export default {
       this.typeIdx = e.detail.value
       this.form.activityType = this.typeOptions[this.typeIdx].key
     },
+    // A2 §2.1 选择参加范围(全部用户 / 仅会员参加)
+    selectScope(value) {
+      this.form.audienceScope = value
+    },
+    // A2 §2.1 勾选参与人群(成人恒选,不允许取消)
+    toggleAudienceType(value) {
+      if (value === 'adult') return
+      if (value === 'include_child') this.audienceChild = !this.audienceChild
+      if (value === 'include_elder') this.audienceElder = !this.audienceElder
+    },
+    // A2 §2.1 判断某参与人群选项是否已勾选
+    isAudienceTypeChecked(value) {
+      if (value === 'adult') return true
+      if (value === 'include_child') return this.audienceChild
+      if (value === 'include_elder') return this.audienceElder
+      return false
+    },
     onStartDateChange(e) {
       this.datePart = e.detail.value
       this.syncStartTime()
@@ -505,10 +607,34 @@ export default {
     buildPayload() {
       var payload = Object.assign({}, this.form)
       payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
-      payload.childFee = Math.round((parseFloat(this.childFeeYuan) || 0) * 100)
-      payload.childMemberFee = Math.round((parseFloat(this.childMemberFeeYuan) || 0) * 100)
-      payload.parentFee = Math.round((parseFloat(this.parentFeeYuan) || 0) * 100)
-      payload.parentMemberFee = Math.round((parseFloat(this.parentMemberFeeYuan) || 0) * 100)
+      // A2 §2.1 适用人群:参加范围 + 参与人群构成
+      payload.audienceScope = this.form.audienceScope || 'all'
+      payload.audienceType = this.audienceTypeValue
+      // A2 §2.2 成人档价格:price 为普通价;memberPrice 留空 = 同普通价
+      payload.price = Math.round((parseFloat(this.priceYuan) || 0) * 100)
+      if (this.memberPriceYuan) {
+        payload.memberPrice = Math.round((parseFloat(this.memberPriceYuan) || 0) * 100)
+      } else if (this.memberPriceWasNull) {
+        // 存量活动 memberPrice 从未写入过(NULL):保持 NULL,继续沿用 parentMemberFee 历史别名
+        delete payload.memberPrice
+      } else {
+        payload.memberPrice = 0
+      }
+      // A2 §2.3 儿童档价格:仅在参与人群含儿童时提交
+      if (this.audienceChild) {
+        payload.childFee = Math.round((parseFloat(this.childFeeYuan) || 0) * 100)
+        payload.childMemberFee = Math.round((parseFloat(this.childMemberFeeYuan) || 0) * 100)
+      } else if (this.legacyChildFee || this.legacyChildMemberFee) {
+        // 存量活动已有儿童收费但本次未勾选「含儿童」:不改存量收费,避免误清零
+        delete payload.childFee
+        delete payload.childMemberFee
+      } else {
+        payload.childFee = 0
+        payload.childMemberFee = 0
+      }
+      // A2 §2.2 parentFee/parentMemberFee 降级为历史别名,表单不再提交(null 字段不参与 updateById)
+      delete payload.parentFee
+      delete payload.parentMemberFee
       if (this.activityId) payload.id = this.activityId
       // 维度由审核时设置,创建/编辑不提交空维度,避免误清空已有维度
       if (!payload.dimensionCode) delete payload.dimensionCode
@@ -683,6 +809,91 @@ export default {
   box-sizing: border-box;
 }
 
+/* A2 §2.1 参加范围单选(样式对齐 components/GenerationPicker.vue) */
+.radio-group {
+  display: flex;
+  flex-direction: row;
+  gap: 24rpx;
+}
+
+.radio-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.radio-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border: 2rpx solid $facade-border-soft;
+  border-radius: 50%;
+  margin-right: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.radio-icon.active {
+  border-color: $brand-primary;
+}
+
+.radio-inner {
+  width: 18rpx;
+  height: 18rpx;
+  background: $brand-primary;
+  border-radius: 50%;
+}
+
+.radio-label {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+}
+
+/* A2 §2.1 参与人群多选 */
+.checkbox-group {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.checkbox-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.checkbox-box {
+  width: 36rpx;
+  height: 36rpx;
+  border: 2rpx solid $facade-border-soft;
+  border-radius: 8rpx;
+  margin-right: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.checkbox-box.active {
+  border-color: $brand-primary;
+  background: $brand-primary;
+}
+
+.checkbox-check {
+  color: #FFFFFF;
+  font-size: 24rpx;
+  line-height: 1;
+}
+
+/* A2 §2.1 表单提示文案 */
+.form-hint {
+  display: block;
+  font-size: 24rpx;
+  color: $facade-text-slate;
+  margin-top: 12rpx;
+}
+
+
 .form-picker {
   width: 100%;
   height: 80rpx;