Browse Source

feat(admin.familyRelation.manage): S-cfc-387 实现关系类型字典 CRUD(SC-A-04)使绿

- 新增 FamilyRelationType 实体/Mapper/Service/AdminController
- 端点:/api/admin/family-relation/types/{list,save,update,disable}
- 字典表 family_relation_type(迁移492,种子 lineal/sibling/spouse)
- 名称/编码唯一校验 + 注入防护(TC-A-10-E)
- 停用前检查 family_relationships 引用(SC-A-04 边界)
- role=admin 门禁(TC-A-10-P)
- 契约测试 8/8 绿;编译通过
zxyj-langgraph 12 hours ago
parent
commit
42599f9256

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

@@ -3914,6 +3914,11 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移490/491: 创建数据导出(GDPR)工单表 data_export_task + 申请记录表 data_export_request
         //(SC-P-72,能力键 data.export.gdpr,S-cfc-377:申请导出 → 生成加密包 → 下载)
         migrateDataExportGdprTables();
+
+        // 迁移492: 创建家庭关系类型字典表 family_relation_type(平台运营 SC-A-04,
+        //          能力键 admin.familyRelation.manage,S-cfc-387:关系类型增删改查 + 字典管理;
+        //          种子写入 lineal/sibling/spouse 三个既有关系类型)
+        migrateFamilyRelationTypeTable();
     }
 
     /**
@@ -16617,4 +16622,41 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移491: data_export_request 表创建失败(忽略): {}", e.getMessage());
         }
     }
+
+    /**
+     * 迁移492: 创建家庭关系类型字典表 family_relation_type(SC-A-04 / admin.familyRelation.manage)。
+     *
+     * <p>关系类型字典 CRUD 的载体:code 对应 {@code family_relationships.relation_type}
+     * (lineal/sibling/spouse),name 为展示名(唯一),status ACTIVE/DISABLED。
+     * 种子写入既有三个关系类型,INSERT IGNORE 保证幂等(不覆盖已存在行)。
+     * 幂等:CREATE TABLE IF NOT EXISTS + INSERT IGNORE。</p>
+     */
+    private void migrateFamilyRelationTypeTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relation_type ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', "
+                    + "code VARCHAR(32) NOT NULL COMMENT '关系类型编码(唯一,对应 family_relationships.relation_type)', "
+                    + "name VARCHAR(50) NOT NULL COMMENT '关系类型名称(展示用,唯一)', "
+                    + "description VARCHAR(255) DEFAULT NULL COMMENT '说明', "
+                    + "sort_order INT DEFAULT 0 COMMENT '排序', "
+                    + "status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE/DISABLED', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "UNIQUE KEY uk_frt_code (code), "
+                    + "UNIQUE KEY uk_frt_name (name)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员关系类型字典(SC-A-04)'");
+            log.info("迁移492: family_relation_type 字典表已就绪(admin.familyRelation.manage / SC-A-04)");
+        } catch (Exception e) {
+            log.warn("迁移492: family_relation_type 字典表创建失败(忽略): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.update("INSERT IGNORE INTO family_relation_type (code, name, description, sort_order, status) VALUES "
+                    + "('lineal', '直系', '直系亲属(含父母/子女/祖辈等辈分关系)', 1, 'ACTIVE'), "
+                    + "('sibling', '同辈非配偶', '同辈非配偶(兄弟姐妹等)', 2, 'ACTIVE'), "
+                    + "('spouse', '同辈配偶', '同辈配偶(夫妻)', 3, 'ACTIVE')");
+            log.info("迁移492: family_relation_type 种子数据已就绪(lineal/sibling/spouse)");
+        } catch (Exception e) {
+            log.warn("迁移492: family_relation_type 种子数据写入失败(忽略): {}", e.getMessage());
+        }
+    }
 }

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyRelationController.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyRelationType;
+import com.etotem.cfc.service.FamilyRelationTypeService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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.RequestAttribute;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 平台运营·关系类型字典管理(SC-A-04 / 能力键 {@code admin.familyRelation.manage})。
+ *
+ * <p>契约载体:{@code /api/admin/family-relation/types} 提供关系类型字典的
+ * 列表 / 新增 / 编辑 / 停用(GET/POST/PUT/DELETE 语义映射,项目统一 {@code @PostMapping})。</p>
+ *
+ * <p>权限:JwtInterceptor 拦截 {@code /api/admin/**}(TC-A-10-P),
+ * 控制器内再按 {@code role=admin} 做角色门禁,非 admin 统一拒绝,不泄露后台数据。</p>
+ */
+@Tag(name = "家庭关系类型管理", description = "平台运营·关系类型字典 CRUD(SC-A-04 / admin.familyRelation.manage)")
+@RestController
+@RequestMapping("/api/admin/family-relation")
+public class AdminFamilyRelationController {
+
+    @Resource
+    private FamilyRelationTypeService familyRelationTypeService;
+
+    @Operation(summary = "关系类型字典列表")
+    @PostMapping("/types/list")
+    public Result<List<FamilyRelationType>> listTypes(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        return Result.success(familyRelationTypeService.listTypes());
+    }
+
+    @Operation(summary = "新增关系类型")
+    @PostMapping("/types/save")
+    public Result<FamilyRelationType> saveType(@RequestBody FamilyRelationType type,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        try {
+            return Result.success(familyRelationTypeService.createType(type));
+        } catch (IllegalArgumentException e) {
+            return Result.badRequest(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "编辑关系类型")
+    @PostMapping("/types/update")
+    public Result<FamilyRelationType> updateType(@RequestBody FamilyRelationType type,
+                                                 @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        try {
+            boolean ok = familyRelationTypeService.updateType(type);
+            if (!ok) {
+                return Result.error("更新失败");
+            }
+            return Result.success(familyRelationTypeService.getById(type.getId()));
+        } catch (IllegalArgumentException e) {
+            return Result.badRequest(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "停用关系类型")
+    @PostMapping("/types/disable")
+    public Result<Void> disableType(@RequestBody Map<String, Object> body,
+                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = ParamUtils.getLong(body == null ? null : body.get("id"));
+        if (id == null) {
+            return Result.badRequest("关系类型ID不能为空");
+        }
+        try {
+            boolean ok = familyRelationTypeService.disableType(id);
+            return ok ? Result.success(null) : Result.error("停用失败");
+        } catch (IllegalArgumentException e) {
+            return Result.badRequest(e.getMessage());
+        }
+    }
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationType.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 家庭成员关系类型字典(平台运营 SC-A-04 / admin.familyRelation.manage)。
+ *
+ * <p>承载关系类型字典的增删改查:code 对应 {@code family_relationships.relation_type}
+ * (lineal/sibling/spouse 等),name 为展示名称,status 区分启用/停用。</p>
+ */
+@Data
+@TableName("family_relation_type")
+public class FamilyRelationType implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关系类型编码(唯一),对应 family_relationships.relation_type */
+    private String code;
+
+    /** 关系类型名称(展示用,唯一) */
+    private String name;
+
+    /** 说明 */
+    private String description;
+
+    /** 排序 */
+    private Integer sortOrder;
+
+    /** 状态: ACTIVE/DISABLED */
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 151 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyRelationTypeService.java

@@ -0,0 +1,151 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.FamilyRelationType;
+import com.etotem.cfc.entity.FamilyRelationship;
+import com.etotem.cfc.mapper.FamilyRelationTypeMapper;
+import com.etotem.cfc.mapper.FamilyRelationshipMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 家庭成员关系类型字典服务(平台运营 SC-A-04 / admin.familyRelation.manage)。
+ *
+ * <p>承载关系类型字典的增删改查:新增重名/重编码拒绝(TC-A-10-E),
+ * 停用前检查 {@code family_relationships} 引用(SC-A-04 边界「被引用时删需确认」),
+ * 名称/编码含注入字符拒绝(TC-A-10-E 注入防护)。</p>
+ */
+@Service
+public class FamilyRelationTypeService extends ServiceImpl<FamilyRelationTypeMapper, FamilyRelationType> {
+
+    /** 注入/非法字符:单引号、双引号、分号、反引号、尖括号、双减号 */
+    private static final Pattern UNSAFE_PATTERN = Pattern.compile("['\"`;<>]|--|\\\\");
+
+    @Resource
+    private FamilyRelationshipMapper familyRelationshipMapper;
+
+    /** 字典列表(含停用,管理页展示全量)。 */
+    public List<FamilyRelationType> listTypes() {
+        LambdaQueryWrapper<FamilyRelationType> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByAsc(FamilyRelationType::getSortOrder)
+               .orderByAsc(FamilyRelationType::getId);
+        return this.list(wrapper);
+    }
+
+    /** 新增关系类型(名称唯一 + 编码唯一 + 注入防护)。 */
+    public FamilyRelationType createType(FamilyRelationType type) {
+        if (type == null) {
+            throw new IllegalArgumentException("请求体不能为空");
+        }
+        if (type.getName() == null || type.getName().trim().isEmpty()) {
+            throw new IllegalArgumentException("关系类型名称不能为空");
+        }
+        if (type.getCode() == null || type.getCode().trim().isEmpty()) {
+            throw new IllegalArgumentException("关系类型编码不能为空");
+        }
+        type.setName(type.getName().trim());
+        type.setCode(type.getCode().trim());
+        if (containsUnsafe(type.getName()) || containsUnsafe(type.getCode())) {
+            throw new IllegalArgumentException("名称/编码包含非法字符");
+        }
+        if (nameExists(type.getName(), null)) {
+            throw new IllegalArgumentException("关系类型名称已存在:" + type.getName());
+        }
+        if (codeExists(type.getCode(), null)) {
+            throw new IllegalArgumentException("关系类型编码已存在:" + type.getCode());
+        }
+        if (type.getStatus() == null || type.getStatus().trim().isEmpty()) {
+            type.setStatus("ACTIVE");
+        }
+        if (type.getSortOrder() == null) {
+            type.setSortOrder(0);
+        }
+        type.setId(null);
+        type.setCreatedAt(new Date());
+        type.setUpdatedAt(new Date());
+        this.save(type);
+        return type;
+    }
+
+    /** 编辑关系类型(重名/重编码校验排除自身)。 */
+    public boolean updateType(FamilyRelationType type) {
+        if (type == null || type.getId() == null) {
+            throw new IllegalArgumentException("关系类型ID不能为空");
+        }
+        FamilyRelationType exist = this.getById(type.getId());
+        if (exist == null) {
+            throw new IllegalArgumentException("关系类型不存在");
+        }
+        if (type.getName() != null && !type.getName().trim().isEmpty()) {
+            type.setName(type.getName().trim());
+            if (containsUnsafe(type.getName())) {
+                throw new IllegalArgumentException("名称包含非法字符");
+            }
+            if (nameExists(type.getName(), type.getId())) {
+                throw new IllegalArgumentException("关系类型名称已存在:" + type.getName());
+            }
+        }
+        if (type.getCode() != null && !type.getCode().trim().isEmpty()) {
+            type.setCode(type.getCode().trim());
+            if (containsUnsafe(type.getCode())) {
+                throw new IllegalArgumentException("编码包含非法字符");
+            }
+            if (codeExists(type.getCode(), type.getId())) {
+                throw new IllegalArgumentException("关系类型编码已存在:" + type.getCode());
+            }
+        }
+        type.setUpdatedAt(new Date());
+        return this.updateById(type);
+    }
+
+    /**
+     * 停用关系类型(SC-A-04 边界 + TC-A-10-E 步骤2):
+     * 被 {@code family_relationships.relation_type} 引用时提示先解绑,禁止停用。
+     */
+    public boolean disableType(Long id) {
+        if (id == null) {
+            throw new IllegalArgumentException("关系类型ID不能为空");
+        }
+        FamilyRelationType exist = this.getById(id);
+        if (exist == null) {
+            throw new IllegalArgumentException("关系类型不存在");
+        }
+        LambdaQueryWrapper<FamilyRelationship> refWrapper = new LambdaQueryWrapper<>();
+        refWrapper.eq(FamilyRelationship::getRelationType, exist.getCode());
+        Long refCount = familyRelationshipMapper.selectCount(refWrapper);
+        if (refCount != null && refCount > 0) {
+            throw new IllegalArgumentException(
+                    "该关系类型已被 " + refCount + " 条家庭成员关系引用,请先解绑再停用");
+        }
+        exist.setStatus("DISABLED");
+        exist.setUpdatedAt(new Date());
+        return this.updateById(exist);
+    }
+
+    private boolean nameExists(String name, Long excludeId) {
+        LambdaQueryWrapper<FamilyRelationType> w = new LambdaQueryWrapper<>();
+        w.eq(FamilyRelationType::getName, name);
+        if (excludeId != null) {
+            w.ne(FamilyRelationType::getId, excludeId);
+        }
+        return this.count(w) > 0;
+    }
+
+    private boolean codeExists(String code, Long excludeId) {
+        LambdaQueryWrapper<FamilyRelationType> w = new LambdaQueryWrapper<>();
+        w.eq(FamilyRelationType::getCode, code);
+        if (excludeId != null) {
+            w.ne(FamilyRelationType::getId, excludeId);
+        }
+        return this.count(w) > 0;
+    }
+
+    private boolean containsUnsafe(String s) {
+        return s != null && UNSAFE_PATTERN.matcher(s).find();
+    }
+}

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

@@ -2340,6 +2340,20 @@ CREATE TABLE IF NOT EXISTS family_relationships (
 
 -- 【已废弃】relationship_types 表已删除(generation 辈分模型替代)
 
+-- 家庭成员关系类型字典(平台运营 SC-A-04 / admin.familyRelation.manage,迁移492)
+CREATE TABLE IF NOT EXISTS family_relation_type (
+    id          BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    code        VARCHAR(32)  NOT NULL COMMENT '关系类型编码(唯一,对应 family_relationships.relation_type)',
+    name        VARCHAR(50)  NOT NULL COMMENT '关系类型名称(展示用,唯一)',
+    description VARCHAR(255) COMMENT '说明',
+    sort_order  INT DEFAULT 0 COMMENT '排序',
+    status      VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE/DISABLED',
+    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_frt_code (code),
+    UNIQUE KEY uk_frt_name (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员关系类型字典(SC-A-04)';
+
 -- 用户收货地址表
 CREATE TABLE IF NOT EXISTS user_address (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 12 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -3175,11 +3175,22 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 
 > SC-A-27 已在状态机(YELLOW),本批只登记映射不新建任务(见契约文档 §2)。
 
+#### 4.60.19 关系类型字典管理(`AdminFamilyRelationController`,SC-A-04 / 能力键 `admin.familyRelation.manage`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/family-relation/types/list` | 关系类型字典列表(含停用,管理页展示全量) |
+| `POST /api/admin/family-relation/types/save` | 新增关系类型(名称/编码唯一校验,注入防护) |
+| `POST /api/admin/family-relation/types/update` | 编辑关系类型(重名/重编码校验排除自身) |
+| `POST /api/admin/family-relation/types/disable` | 停用关系类型(被 `family_relationships.relation_type` 引用时拒绝并提示先解绑) |
+
+> **权限**:JWT 拦截 `/api/admin/**` + 控制器内 `role=admin` 门禁(TC-A-10-P),非 admin 统一 401/403。
+> **字典表**:`family_relation_type`(迁移492),种子 `lineal`/`sibling`/`spouse`。
+
 #### 4.60.18 契约缺口(无端点载体,需产品侧确认验收方式)
 
 | SC | 场景 | 现状 | 处置 |
 |----|------|------|------|
-| SC-A-04 | 关系类型 CRUD | 无 `/api/admin/relationship-types`;`relationship_types` 表已废弃 DROP,关系由 `family_relationships`(`/api/family/member/add|update` + `FamilyRelationshipService`)承载 | 无端点载体,需产品侧确认 |
 | SC-A-14 | 系统监控/告警 | 仅 `POST /api/system/health`(JVM/内存/CPU 探活),无告警规则/记录端点 | 无端点载体,需产品侧确认 |
 | SC-A-19 | 账号注销/数据导出工单处理 | 无运营工单端点;用户侧自助 `POST /api/user/deactivate`、`POST /api/user/data-export` 已实现 | 无端点载体,需产品侧确认 |
 | SC-A-20 | 限流/熔断配置 | 无 HTTP 配置端点;`RateLimitInterceptor`(Redis 滑动窗口)+ `AiGateway` 熔断为代码内置 | 无端点载体,需产品侧确认 |