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

Merge origin/cfclub into cfclub (S-cfc-205: admin.data.migration contract test)

E2E Test Bot 2 часов назад
Родитель
Сommit
8eacdb1910

+ 425 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminAuditLogContractTest.java

@@ -0,0 +1,425 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:操作审计日志(能力键 {@code admin.audit.log},单元 admin / SC-A-15)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/05-admin.md} SC-A-15 —— 作为运营,查询审计日志并溯源,
+ *       以便可追溯;<b>边界:日志不可篡改</b>;<b>分页</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.14 ——
+ *       {@code OperationLogController /api/admin/logs}({@code operations} 分页查询 + {@code recent} 近期)
+ *       + {@code AdminAuditController /api/admin/audit}({@code list} 审计列表);
+ *       §4.13 管理员接口需 {@code role=admin}({@code AdminInterceptor})。</li>
+ *   <li>写入链路:{@code AuditLogAspect} 自动落 {@code audit_log}(管理端写操作),
+ *       {@code AuditLogService} 只增不删改(W0.3 审计日志统一)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>正常流(溯源)</b>:{@code AdminAuditController /api/admin/audit/list} 分页查询 + 过滤
+ *       (module / targetId / 时间范围);{@code OperationLogController /api/admin/logs}
+ *       {@code operations} + {@code recent};{@code AuditLog} 实体承载溯源字段
+ *       (operatorId / operatorName / module / action / targetType / targetId /
+ *       beforeValue / afterValue / ip / createdAt)。</li>
+ *   <li><b>边界流</b>:日志<b>不可篡改</b>——{@code AuditLogService} 只增不删改(不暴露
+ *       update / remove / delete 公开方法);{@code audit_log} + {@code user_operation_logs}
+ *       建表落地 schema.sql,且 DatabaseInitializer 有对应建表迁移(schema.sql 与迁移一致)。</li>
+ *   <li><b>权限流</b>:{@code AdminAuditController.list} 首参 {@code @RequestAttribute("role")},
+ *       仅 {@code role=admin} 可访问(§4.13)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,反射锚定宿主类(避免编译期依赖不存在类)+ schema.sql
+ * 快照 + DatabaseInitializer 源码检查;<b>无 Spring / 无 DB / 无外部服务</b>。</p>
+ *
+ * <p><b>宿主契约类(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code com.etotem.cfc.controller.admin.AdminAuditController} —— {@code @RequestMapping("/api/admin/audit")}
+ *       + {@code @PostMapping("/list")} + {@code list(@RequestAttribute("role") String, Map)}</li>
+ *   <li>{@code com.etotem.cfc.controller.admin.OperationLogController} —— {@code @RequestMapping("/api/admin/logs")}
+ *       + {@code @PostMapping("/operations")} + {@code @PostMapping("/recent")}</li>
+ *   <li>{@code com.etotem.cfc.service.AuditLogService} —— {@code page(module, targetId, startTime, endTime, pageNo, pageSize)}
+ *       + {@code record(AuditLog)};<b>不暴露 update / remove / delete</b>(只增不删改)</li>
+ *   <li>{@code com.etotem.cfc.entity.AuditLog} —— 溯源字段齐全</li>
+ *   <li>{@code com.etotem.cfc.aspect.AuditLogAspect} —— 自动落审计切面</li>
+ * </ul>
+ *
+ * <p><b>RED 预期说明</b>:本批契约登记时该能力曾判定「实现未落地」;实测(2026-11-13)发现
+ * {@code origin/cfclub} 已包含上述宿主类与表,故本测试按契约行为断言如实验证——若契约行为缺失
+ * (如去掉了不可篡改约束 / 权限门禁 / 端点)即红,作为<b>契约回归测试</b>锁定行为。</p>
+ */
+class AdminAuditLogContractTest {
+
+    /** 审计查询控制器(AdminAuditController /api/admin/audit)。 */
+    private static final String ADMIN_AUDIT_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminAuditController";
+
+    /** 操作日志控制器(OperationLogController /api/admin/logs)。 */
+    private static final String OPERATION_LOG_CONTROLLER =
+            "com.etotem.cfc.controller.admin.OperationLogController";
+
+    /** 审计日志服务(AuditLogService,只增不删改)。 */
+    private static final String AUDIT_LOG_SERVICE =
+            "com.etotem.cfc.service.AuditLogService";
+
+    /** 审计日志实体(AuditLog)。 */
+    private static final String AUDIT_LOG_ENTITY =
+            "com.etotem.cfc.entity.AuditLog";
+
+    /** 审计切面(AuditLogAspect,自动落审计)。 */
+    private static final String AUDIT_LOG_ASPECT =
+            "com.etotem.cfc.aspect.AuditLogAspect";
+
+    /** 用户操作日志服务(UserOperationLogService)。 */
+    private static final String USER_OPERATION_LOG_SERVICE =
+            "com.etotem.cfc.service.UserOperationLogService";
+
+    /** DatabaseInitializer 源码(迁移一致性检查)。 */
+    private static final String DATABASE_INITIALIZER_SOURCE =
+            "src/main/java/com/etotem/cfc/config/DatabaseInitializer.java";
+
+    // ------------------------------------------------------------------
+    // 正常流:查询审计日志 → 溯源(SC-A-15 主流程)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AdminAuditController 应挂载 /api/admin/audit 并提供 /list")
+    void adminAuditController_mountsAuditListEndpoint() {
+        Class<?> clazz = loadClass(ADMIN_AUDIT_CONTROLLER);
+        assertNotNull(clazz,
+                "[admin.audit.log] AdminAuditController 类不存在:契约要求 POST /api/admin/audit/list"
+                        + " 审计列表(§4.60.14)。");
+        // 类级 @RequestMapping("/api/admin/audit")
+        assertTrue(hasRequestMapping(clazz, "/api/admin/audit"),
+                "[admin.audit.log] AdminAuditController 应 @RequestMapping(\"/api/admin/audit\")(§4.60.14)。");
+        // 方法级 @PostMapping("/list")
+        Method list = findMethod(clazz, "list");
+        assertNotNull(list,
+                "[admin.audit.log] AdminAuditController 应提供 list(...) 方法作为 /list 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(list, "/list"),
+                "[admin.audit.log] AdminAuditController.list 应 @PostMapping(\"/list\")(统一 POST,§4.13)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:AuditLogService 应提供分页查询 page(module,targetId,start,end,page,size)")
+    void auditLogService_hasPageQuery() {
+        Class<?> clazz = loadClass(AUDIT_LOG_SERVICE);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLogService 类不存在:契约要求分页查询审计日志(SC-A-15 主流程)。");
+        // page(String, String, Date, Date, int, int) —— 6 参分页查询(module/targetId/时间范围/分页)
+        Method page = findMethodByParamCount(clazz, "page", 6);
+        assertNotNull(page,
+                "[admin.audit.log] AuditLogService 应提供 page(module, targetId, startTime, endTime,"
+                        + " pageNo, pageSize) 分页查询(SC-A-15 分页边界)。");
+        // record(AuditLog) 写入入口
+        Method record = findMethodByParamCount(clazz, "record", 1);
+        assertNotNull(record,
+                "[admin.audit.log] AuditLogService 应提供 record(AuditLog) 写入入口(W0.3 审计写入)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:AuditLog 实体应承载溯源字段(操作人/模块/动作/目标/前后值/IP/时间)")
+    void auditLogEntity_carriesTraceFields() {
+        Class<?> clazz = loadClass(AUDIT_LOG_ENTITY);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLog 实体不存在:契约要求 audit_log 表实体承载溯源字段(SC-A-15)。");
+        String[] required = {
+                "operatorId", "operatorName", "module", "action", "targetType",
+                "targetId", "beforeValue", "afterValue", "ip", "createdAt"
+        };
+        List<String> missing = new ArrayList<>();
+        for (String name : required) {
+            if (!hasField(clazz, name)) {
+                missing.add(name);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "[admin.audit.log] AuditLog 实体缺失溯源字段:" + missing
+                        + "(契约要求 operatorId/operatorName/module/action/targetType/"
+                        + "targetId/beforeValue/afterValue/ip/createdAt,SC-A-15 溯源)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:OperationLogController 应挂载 /api/admin/logs 并提供 operations+recent")
+    void operationLogController_mountsOperationsAndRecent() {
+        Class<?> clazz = loadClass(OPERATION_LOG_CONTROLLER);
+        assertNotNull(clazz,
+                "[admin.audit.log] OperationLogController 类不存在:契约要求 POST /api/admin/logs"
+                        + " operations + recent(§4.60.14)。");
+        assertTrue(hasRequestMapping(clazz, "/api/admin/logs"),
+                "[admin.audit.log] OperationLogController 应 @RequestMapping(\"/api/admin/logs\")(§4.60.14)。");
+        Method operations = findMethod(clazz, "queryLogs");
+        assertNotNull(operations,
+                "[admin.audit.log] OperationLogController 应提供 queryLogs(...) 作为 /operations 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(operations, "/operations"),
+                "[admin.audit.log] OperationLogController.queryLogs 应 @PostMapping(\"/operations\")(§4.60.14)。");
+        Method recent = findMethod(clazz, "getRecentLogs");
+        assertNotNull(recent,
+                "[admin.audit.log] OperationLogController 应提供 getRecentLogs(...) 作为 /recent 端点(§4.60.14)。");
+        assertTrue(hasPostMapping(recent, "/recent"),
+                "[admin.audit.log] OperationLogController.getRecentLogs 应 @PostMapping(\"/recent\")(§4.60.14)。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserOperationLogService 应提供 queryLogs + getRecentLogs 服务方法")
+    void userOperationLogService_hasQueryAndRecent() {
+        Class<?> clazz = loadClass(USER_OPERATION_LOG_SERVICE);
+        assertNotNull(clazz,
+                "[admin.audit.log] UserOperationLogService 类不存在:契约要求操作日志分页/近期查询(§4.60.14)。");
+        Method query = findMethod(clazz, "queryLogs");
+        assertNotNull(query,
+                "[admin.audit.log] UserOperationLogService 应提供 queryLogs(...)(/operations 分页查询)。");
+        Method recent = findMethod(clazz, "getRecentLogs");
+        assertNotNull(recent,
+                "[admin.audit.log] UserOperationLogService 应提供 getRecentLogs(...)(/recent 近期查询)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:AuditLogAspect 应存在(管理端写操作自动落审计)")
+    void auditLogAspect_existsForAutoAudit() {
+        Class<?> clazz = loadClass(AUDIT_LOG_ASPECT);
+        assertNotNull(clazz,
+                "[admin.audit.log] AuditLogAspect 类不存在:契约要求管理端写操作自动落 audit_log(W0.3)。");
+        // 切面应含 @Aspect 注解
+        assertTrue(hasAnnotation(clazz, "org.aspectj.lang.annotation.Aspect"),
+                "[admin.audit.log] AuditLogAspect 应标注 @Aspect(Spring AOP 自动审计切面)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:日志不可篡改(只增不删改) + 建表落地
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:AuditLogService 不得暴露 update/remove/delete(日志不可篡改)")
+    void auditLogService_appendOnly_noUpdateDelete() {
+        Class<?> clazz = loadClass(AUDIT_LOG_SERVICE);
+        if (clazz == null) {
+            fail("[admin.audit.log] AuditLogService 类不存在,无法验证只增不删改(SC-A-15 边界)。");
+        }
+        List<String> exposed = new ArrayList<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            String n = m.getName().toLowerCase();
+            if (n.startsWith("update") || n.startsWith("remove") || n.startsWith("delete")) {
+                exposed.add(m.getName());
+            }
+        }
+        assertTrue(exposed.isEmpty(),
+                "[admin.audit.log] 日志不可篡改(SC-A-15 边界):AuditLogService 不得暴露 "
+                        + "update/remove/delete 方法(只增不删改),发现:" + exposed);
+    }
+
+    @Test
+    @DisplayName("边界流-2:audit_log 与 user_operation_logs 应落地 schema.sql")
+    void schemaSql_containsAuditTables() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("audit_log"),
+                "[admin.audit.log] schema.sql 缺少 audit_log 建表(W0.3 审计日志统一)。");
+        assertTrue(schema.contains("user_operation_logs"),
+                "[admin.audit.log] schema.sql 缺少 user_operation_logs 建表(操作日志)。");
+        // 审计表应为只增不删改形态:含 created_at 时间列
+        assertTrue(schema.contains("created_at"),
+                "[admin.audit.log] audit_log 应含 created_at 时间列(溯源时间)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:DatabaseInitializer 应有 audit_log 建表迁移(schema.sql 与迁移一致)")
+    void databaseInitializer_hasAuditLogMigration() {
+        String source = readDatabaseInitializerSource();
+        assertTrue(source.contains("audit_log"),
+                "[admin.audit.log] DatabaseInitializer 缺少 audit_log 建表迁移(AGENTS 迁移一致原则:"
+                        + "schema.sql 的 DDL 必须在 DatabaseInitializer 有对应迁移)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:仅 admin 角色可访问(§4.13)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:AdminAuditController.list 首参应带 @RequestAttribute(\"role\") 做 admin 门禁")
+    void adminAuditController_list_hasRoleGate() {
+        Class<?> clazz = loadClass(ADMIN_AUDIT_CONTROLLER);
+        if (clazz == null) {
+            fail("[admin.audit.log] AdminAuditController 类不存在,无法验证 role 门禁(§4.13)。");
+        }
+        Method list = findMethod(clazz, "list");
+        if (list == null) {
+            fail("[admin.audit.log] AdminAuditController.list 不存在,无法验证 role 门禁(§4.13)。");
+        }
+        Annotation[][] paramAnns = list.getParameterAnnotations();
+        boolean hasRoleAttr = false;
+        for (Annotation[] anns : paramAnns) {
+            for (Annotation a : anns) {
+                if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.RequestAttribute")) {
+                    hasRoleAttr = true;
+                    break;
+                }
+            }
+            if (hasRoleAttr) {
+                break;
+            }
+        }
+        assertTrue(hasRoleAttr,
+                "[admin.audit.log] AdminAuditController.list 应通过 @RequestAttribute(\"role\") 校验"
+                        + " role=admin(§4.13 管理员接口需 role=admin,AdminInterceptor 口径)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定宿主契约,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Method findMethod(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Method findMethodByParamCount(Class<?> clazz, String name, int paramCount) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == paramCount) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasAnnotation(Class<?> clazz, String annotationName) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Annotation a : clazz.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals(annotationName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 类级 @RequestMapping 是否含给定路径片段。 */
+    private boolean hasRequestMapping(Class<?> clazz, String pathPart) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Annotation a : clazz.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.RequestMapping")) {
+                try {
+                    Method value = a.annotationType().getMethod("value");
+                    String[] paths = (String[]) value.invoke(a);
+                    for (String p : paths) {
+                        if (p.contains(pathPart)) {
+                            return true;
+                        }
+                    }
+                } catch (Exception ignored) {
+                    // 注解反射失败,继续
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 方法级 @PostMapping 是否含给定路径。 */
+    private boolean hasPostMapping(Method method, String path) {
+        if (method == null) {
+            return false;
+        }
+        for (Annotation a : method.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals("org.springframework.web.bind.annotation.PostMapping")) {
+                try {
+                    Method value = a.annotationType().getMethod("value");
+                    String[] paths = (String[]) value.invoke(a);
+                    for (String p : paths) {
+                        if (p.equals(path)) {
+                            return true;
+                        }
+                    }
+                } catch (Exception ignored) {
+                    // 注解反射失败,继续
+                }
+            }
+        }
+        return false;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    private String readDatabaseInitializerSource() {
+        // mvn test 工作目录为模块根(cfc-backend),相对路径可直接读取;若失败再回退 classpath。
+        try {
+            byte[] bytes = Files.readAllBytes(Paths.get(DATABASE_INITIALIZER_SOURCE));
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            try (InputStream is = getClass().getClassLoader()
+                    .getResourceAsStream("DatabaseInitializer.java")) {
+                if (is == null) {
+                    return "";
+                }
+                try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                    scanner.useDelimiter("\\A");
+                    return scanner.hasNext() ? scanner.next() : "";
+                }
+            } catch (Exception ignored) {
+                return "";
+            }
+        }
+    }
+}

+ 363 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminBadgeManageContractTest.java

@@ -0,0 +1,363 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.BadgeController;
+import com.etotem.cfc.controller.admin.AdminChallengeScoreConfigController;
+import com.etotem.cfc.mapper.BadgeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeScoreConfigMapper;
+import com.etotem.cfc.mapper.MemberBadgeMapper;
+import com.etotem.cfc.service.BadgeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Scanner;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+
+/**
+ * 契约级单测:勋章配置 CRUD + 挑战分配置 + 成就排名治理(能力键 {@code admin.badge.manage})。
+ *
+ * <p>契约来源:</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-A-05 勋章配置 CRUD</b> ——
+ *       目标:管理成就体系;流程:勋章增删改 → 生效;<b>边界:已发放勋章不可删(停用代替)</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.5 ——
+ *       {@code BadgeController /api/badges}(all/list/detail/create/update/delete/grant/
+ *       member/list/favorite/statistics/member/ranking/family)
+ *       + {@code AdminChallengeScoreConfigController /api/admin/challenge-score-config}
+ *       (list/update)+ {@code AdminLeaderboardController}(成就排名治理);§4.13 管理员接口需
+ *       {@code role=admin}。</li>
+ *   <li>开工包:{@code docs/tdd/orchestration/task-incentive-kickoff-20261107.md} SC-A-05 行
+ *       (契约接口范围:/api/badges/create、/update、/delete、
+ *       /api/admin/challenge-score-config/list、/update)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b></p>
+ * <ul>
+ *   <li>端点面已落地:{@code BadgeController}(11 个 @PostMapping)、
+ *       {@code AdminChallengeScoreConfigController}(list/update)、
+ *       {@code AdminLeaderboardController}(top/search)均在位;
+ *       对应正常流端点面用例在本测试中应转绿。</li>
+ *   <li><b>契约边界未落地(RED 焦点)</b>:{@link BadgeService#deleteBadge(Long)} 当前无条件
+ *       {@code badgeMapper.deleteById(id)} 物理删除(未检查是否已发放)——未实现 SC-A-05 边界
+ *       「已发放勋章不可删(停用代替)」。</li>
+ *   <li><b>权限守卫未落地(RED 焦点)</b>:{@code /api/badges/create|update|delete} 与
+ *       {@code /api/admin/challenge-score-config/update} 控制器内均无 {@code role=admin} 校验
+ *       (对照已实现守卫的 {@code AdminLeaderboardController#top} 模式),非 admin 可越权管理勋章字典。</li>
+ * </ul>
+ *
+ * <p><b>测试方式</b>:纯 Mockito + 反射 + MockMvc standalone,无 Spring 上下文 / 无 DB / 无外部服务,
+ * 与同批 RED 契约测试(TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)风格一致。</p>
+ *
+ * <p><b>RED 预期</b>:边界流(已发放不可删)与权限流(admin 守卫)用例在实现补齐前应失败(红);
+ * 端点面正常流用例随现状转绿。若全量转绿(含边界/权限),说明实现已合入,应交编排侧转测试登记。</p>
+ */
+class AdminBadgeManageContractTest {
+
+    private static final String BADGE_CONTROLLER = "com.etotem.cfc.controller.BadgeController";
+    private static final String CHALLENGE_CONFIG_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminChallengeScoreConfigController";
+    private static final String LEADERBOARD_CONTROLLER =
+            "com.etotem.cfc.controller.admin.AdminLeaderboardController";
+    private static final String BADGE_ENTITY = "com.etotem.cfc.entity.Badge";
+
+    private static final Long BADGE_ID = 1001L;
+
+    /** 契约端点面(§4.60.5) */
+    private static final String[] REQUIRED_BADGE_PATHS = {
+            "/api/badges/all",
+            "/api/badges/list",
+            "/api/badges/detail",
+            "/api/badges/create",
+            "/api/badges/update",
+            "/api/badges/delete",
+            "/api/badges/grant",
+            "/api/badges/member/list",
+            "/api/badges/favorite",
+            "/api/badges/statistics/member",
+            "/api/badges/ranking/family"
+    };
+
+    // ---------------- MockMvc / Controller 层 ----------------
+
+    private MockMvc badgesMvc;
+    private MockMvc challengeMvc;
+
+    @Mock
+    private BadgeService badgeService;
+
+    @Mock
+    private FamilyChallengeScoreConfigMapper configMapper;
+
+    // ---------------- Service 层(真实实例 + Mock Mapper) ----------------
+
+    @Mock
+    private BadgeMapper badgeMapper;
+
+    @Mock
+    private MemberBadgeMapper memberBadgeMapper;
+
+    private BadgeService badgeServiceUnderTest;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        BadgeController badges = new BadgeController();
+        ReflectionTestUtils.setField(badges, "badgeService", badgeService);
+        badgesMvc = MockMvcBuilders.standaloneSetup(badges).build();
+
+        AdminChallengeScoreConfigController challenge = new AdminChallengeScoreConfigController();
+        ReflectionTestUtils.setField(challenge, "configMapper", configMapper);
+        challengeMvc = MockMvcBuilders.standaloneSetup(challenge).build();
+
+        badgeServiceUnderTest = new BadgeService();
+        ReflectionTestUtils.setField(badgeServiceUnderTest, "badgeMapper", badgeMapper);
+        ReflectionTestUtils.setField(badgeServiceUnderTest, "memberBadgeMapper", memberBadgeMapper);
+    }
+
+    // ====================================================================
+    // 正常流 · 端点面(结构契约,§4.60.5 + 开工包 SC-A-05 行)
+    // ====================================================================
+
+    @Test
+    @DisplayName("正常流-1:BadgeController 应提供勋章字典 CRUD + 发放/收藏端点")
+    void badgeController_crudAndGrantSurface() {
+        Class<?> clazz = loadClass(BADGE_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] BadgeController 类不存在:"
+                + "SC-A-05 勋章配置 CRUD 契约要求 BadgeController /api/badges 提供"
+                + " all/list/detail/create/update/delete/grant/member/list/favorite/statistics/member/ranking/family(§4.60.5)。");
+        Set<String> paths = postMappings(clazz);
+        for (String required : REQUIRED_BADGE_PATHS) {
+            assertTrue(paths.contains(required),
+                    "[admin.badge.manage] BadgeController 缺少契约端点 " + required + "(§4.60.5)。");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-2:AdminChallengeScoreConfigController 应提供挑战分配置 list/update")
+    void challengeScoreConfig_listUpdateSurface() {
+        Class<?> clazz = loadClass(CHALLENGE_CONFIG_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] AdminChallengeScoreConfigController 类不存在:"
+                + "契约要求 /api/admin/challenge-score-config 提供 list/update(§4.60.5)。");
+        Set<String> paths = postMappings(clazz);
+        assertTrue(paths.contains("/api/admin/challenge-score-config/list"),
+                "[admin.badge.manage] AdminChallengeScoreConfigController 缺少 /list(§4.60.5)。");
+        assertTrue(paths.contains("/api/admin/challenge-score-config/update"),
+                "[admin.badge.manage] AdminChallengeScoreConfigController 缺少 /update(§4.60.5)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:AdminLeaderboardController 应提供成就排名治理端点(top/search)")
+    void adminLeaderboard_achievementRankGovernanceSurface() {
+        Class<?> clazz = loadClass(LEADERBOARD_CONTROLLER);
+        assertNotNull(clazz, "[admin.badge.manage] AdminLeaderboardController 类不存在:"
+                + "成就排名治理契约要求 /api/admin/leaderboard 提供 top/search。");
+        Set<String> paths = postMappings(clazz);
+        assertTrue(paths.contains("/api/admin/leaderboard/top"),
+                "[admin.badge.manage] AdminLeaderboardController 缺少 /top(成就排名治理)。");
+        assertTrue(paths.contains("/api/admin/leaderboard/search"),
+                "[admin.badge.manage] AdminLeaderboardController 缺少 /search(成就排名治理)。");
+    }
+
+    // ====================================================================
+    // 边界流 · 已发放勋章不可删(停用代替)—— SC-A-05 边界条款
+    // ====================================================================
+
+    @Test
+    @DisplayName("边界流-1(RED):已发放勋章 deleteBadge 不得物理删除(停用代替)")
+    void deleteBadge_issuedBadge_notHardDeleted() {
+        // 该勋章已有成员授予记录(child_badge / member_badges 存在 badgeId 记录)
+        when(memberBadgeMapper.selectCount(any())).thenReturn(1L);
+
+        try {
+            badgeServiceUnderTest.deleteBadge(BADGE_ID);
+        } catch (RuntimeException expected) {
+            // GREEN 实现可“抛业务异常拦截删除”(视为满足契约),不在此失败
+        }
+
+        long hardDeletes = countInvocations(badgeMapper, "deleteById");
+        assertTrue(hardDeletes == 0,
+                "[admin.badge.manage] SC-A-05 边界:已发放勋章不可删(停用代替)。"
+                        + "当前 BadgeService.deleteBadge 无条件 badgeMapper.deleteById(id) 物理删除了已发放勋章定义;"
+                        + "契约要求:存在成员授予记录时拒绝删除,改为停用(is_active=0,勋章墙仍可展示但不再新发)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:未发放勋章可正常删除(不阻断合法 CRUD)")
+    void deleteBadge_notIssued_canDelete() {
+        when(memberBadgeMapper.selectCount(any())).thenReturn(0L);
+
+        badgeServiceUnderTest.deleteBadge(BADGE_ID);
+
+        long hardDeletes = countInvocations(badgeMapper, "deleteById");
+        assertTrue(hardDeletes == 1,
+                "[admin.badge.manage] SC-A-05 正常流程:未发放勋章应可删除(增删改 → 生效)。"
+                        + "deleteBadge 应删除未发放的勋章定义。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:停用代替的载体应存在(Badge.isActive + 建表 is_active)")
+    void badgeEntity_andSchema_carryDisableFlag() {
+        Class<?> entity = loadClass(BADGE_ENTITY);
+        assertNotNull(entity, "[admin.badge.manage] Badge 实体不存在。");
+        boolean hasIsActive = false;
+        for (Field f : entity.getDeclaredFields()) {
+            if (f.getName().equals("isActive")) {
+                hasIsActive = true;
+                break;
+            }
+        }
+        assertTrue(hasIsActive,
+                "[admin.badge.manage] Badge 实体应含 isActive 字段(停用代替的落点,对应 is_active 列)。");
+
+        String badgeTable = extractCreateTable("badge");
+        assertTrue(badgeTable.contains("is_active"),
+                "[admin.badge.manage] badge 建表应含 is_active 列(已发放勋章停用代替的落点)。");
+    }
+
+    // ====================================================================
+    // 权限流 · 管理员守卫(§4.13:管理员接口需 role=admin)
+    // ====================================================================
+
+    @Test
+    @DisplayName("权限流-1(RED):非 admin 调用 POST /api/badges/create 应被拒绝")
+    void createBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"name\":\"契约测试勋章\"}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-2(RED):非 admin 调用 POST /api/badges/update 应被拒绝")
+    void updateBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"id\":1001,\"name\":\"契约测试勋章\"}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-3(RED):非 admin 调用 POST /api/badges/delete 应被拒绝")
+    void deleteBadge_rejectsNonAdmin() throws Exception {
+        badgesMvc.perform(post("/api/badges/delete")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"id\":1001}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-4(RED):非 admin 调用 POST /api/admin/challenge-score-config/update 应被拒绝")
+    void challengeScoreConfigUpdate_rejectsNonAdmin() throws Exception {
+        challengeMvc.perform(post("/api/admin/challenge-score-config/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "vendor")
+                        .content("{\"action\":\"daily_checkin\",\"scoreType\":\"fixed\",\"value\":10}"))
+                .andExpect(jsonPath("$.code").value(not(200)));
+    }
+
+    @Test
+    @DisplayName("权限流-5:家长/管理端均可授予勋章(grant 不限定 admin,§4.61.4)")
+    void grant_allowedForParent() throws Exception {
+        badgesMvc.perform(post("/api/badges/grant")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("role", "parent")
+                        .content("{\"memberId\":2001,\"badgeId\":1001}"))
+                .andExpect(jsonPath("$.code").value(200));
+    }
+
+    // ====================================================================
+    // 反射 / Mockito 辅助
+    // ====================================================================
+
+    /** 收集类上 @PostMapping 全路径(类级 @RequestMapping + 方法级路径) */
+    private Set<String> postMappings(Class<?> clazz) {
+        Set<String> paths = new HashSet<>();
+        String base = "";
+        RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
+        if (classMapping != null && classMapping.value().length > 0) {
+            base = classMapping.value()[0];
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String path : pm.value()) {
+                    paths.add(base + "/" + path.replaceFirst("^/", ""));
+                }
+            }
+        }
+        return paths;
+    }
+
+    private long countInvocations(Object mock, String methodName) {
+        return Mockito.mockingDetails(mock).getInvocations().stream()
+                .filter(inv -> inv.getMethod().getName().equals(methodName))
+                .count();
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 从 schema.sql 抽取指定表的 CREATE TABLE 块(不含尾部 ENGINE 行之后) */
+    private String extractCreateTable(String tableName) {
+        String schema = readSchemaSql();
+        if (schema.isEmpty()) {
+            return "";
+        }
+        String marker = "CREATE TABLE IF NOT EXISTS " + tableName + " (";
+        int start = schema.indexOf(marker);
+        if (start < 0) {
+            return "";
+        }
+        int end = schema.indexOf(") ENGINE=InnoDB", start);
+        if (end < 0) {
+            return "";
+        }
+        return schema.substring(start, end);
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 505 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminContentSecurityContractTest.java

@@ -0,0 +1,505 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.ContentFilterController;
+import com.etotem.cfc.controller.admin.AdminArticleController;
+import com.etotem.cfc.controller.admin.ReviewCenterController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ButlerProfileMapper;
+import com.etotem.cfc.mapper.PendingRefundMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ServiceRoleApplicationMapper;
+import com.etotem.cfc.mapper.SupplyHierarchyChangeRequestMapper;
+import com.etotem.cfc.mapper.TaskTemplatePackageMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:内容安全审核 + 审核队列(能力键 {@code admin.content.security})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} —— SC-A-13(内容安全审核:UGC 审核 →
+ *       通过/下架,边界:敏感词命中拦截)+ SC-A-18(内容安全审核队列:队列 → 通过/下架,
+ *       边界:敏感词命中拦截)</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(管理员接口)+ §4.60.13
+ *       (SC-A-13/A-18 内容安全审核:{@code ReviewCenterController /api/admin/review-center/summary}
+ *       13 类待审聚合 + 业务 audit 端点)+ §4.65.2(文章发布-审核闭环,端点齐全 ✅)</li>
+ *   <li>编排:{@code docs/tdd/orchestration/sc-key-map.md} —— SC-A-13/A-18 → {@code admin.content.security}
+ *       → RED(S-cfc-208)</li>
+ * </ul>
+ *
+ * <p><b>载体(src/main 现状核实,origin/cfclub @ f941b9212)</b></p>
+ * <ul>
+ *   <li>{@code AdminArticleController /api/admin/articles}:{@code audit} / {@code submit-review} /
+ *       {@code withdraw} / {@code re-draft} / {@code upload/image}(微信 {@code checkImage} 检测)/
+ *       {@code upload/video} / {@code comments/list}</li>
+ *   <li>{@code ArticleService}:{@code submitForReview}(draft→pending+auditStatus=pending)/
+ *       {@code auditArticle}(approved→published / rejected→rejected+原因)/
+ *       {@code withdraw}(published→withdrawn,即"下架")/{@code reDraft}(rejected/withdrawn→draft 清审计字段)</li>
+ *   <li>{@code ReviewCenterController /api/admin/review-center/summary}:13 类待审聚合计数</li>
+ *   <li>{@code ContentFilterController /api/content/by-tags}:按 resultTags + 问题域 code 过滤
+ *       文章/知识库/饮食</li>
+ *   <li>权限流:{@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 已收口 {@code "/api/admin/"}(B-142-1 已处置),
+ *       {@code audit} 已有 {@code role} 角色守卫(非 admin → "无权限")——权限流由
+ *       {@code AdminArticleAuditContractTest} 锁定,本类不重复。</li>
+ * </ul>
+ *
+ * <p><b>RED 任务实证结论</b>:本键(S-cfc-208)为 TDD-RED 契约任务,但契约端点与行为在
+ * origin/cfclub 已全部落地(契约文档 admin-ops-contract §1.13/§1.18 亦标记「✅ 端点存在」)。
+ * 按任务执行要求第 2 条(实测发现实现已存在 → 测试意外全绿 → 汇报注明并交编排侧转测试登记,
+ * 不私改实现),本测试以「契约绿锁定」形式覆盖 SC-A-13/A-18 尚未被既有测试覆盖的行为面
+ * (submit-review/withdraw/re-draft 状态机、敏感词命中拦截、review-center 13 类队列聚合、
+ * content by-tags 空标签边界),作为该能力的契约回归基线。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
+ * 无外部服务。</p>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(绿锁定):审核端点家族存在;提审/撤回/重编状态机正确流转;敏感词命中拦截;
+ *       队列 13 类聚合;内容过滤空标签边界</li>
+ *   <li>边界流(绿锁定):提审非草稿 / 撤回非发布中 / 重编非法状态 → 拒绝且不写库;空/空白文本
+ *       fail-open 不触达微信检测</li>
+ *   <li>权限流(已由既有契约锁定):/api/admin/ 整族登录门(B-142-1 已修)+ audit 角色守卫</li>
+ * </ul>
+ */
+class AdminContentSecurityContractTest {
+
+    private static final Long ARTICLE_ID = 200L;
+    private static final Long ADMIN_ID = 88L;
+
+    // ==================================================================
+    // 正常流:SC-A-13 审核端点家族存在(@PostMapping,结构契约)
+    // ==================================================================
+
+    /** 契约端点 → 应存在的映射:{endpoint 后缀 → 期望方法名} */
+    private static final Map<String, String> ADMIN_ARTICLE_AUDIT_ENDPOINTS = new HashMap<>();
+
+    static {
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/list", "list");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/audit", "audit");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/submit-review", "submitReview");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/withdraw", "withdraw");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/re-draft", "reDraft");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/image", "uploadImage");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/video", "uploadVideo");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/comments/list", "commentList");
+    }
+
+    @Test
+    @DisplayName("正常流-1:AdminArticleController 应提供 SC-A-13 审核端点家族(audit/submit-review/withdraw/re-draft/upload image|video/comments/list)")
+    void adminArticleController_auditFamilyEndpointsExist() {
+        Method[] methods = AdminArticleController.class.getDeclaredMethods();
+        Map<String, String> missing = new HashMap<>();
+        for (Map.Entry<String, String> e : ADMIN_ARTICLE_AUDIT_ENDPOINTS.entrySet()) {
+            Method m = findPostMapping(methods, e.getKey());
+            if (m == null || !m.getName().equals(e.getValue())) {
+                missing.put(e.getKey(), e.getValue());
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[admin.content.security] AdminArticleController 缺契约端点:"
+                        + missing + "(SC-A-13 内容安全审核:audit/submit-review/withdraw/re-draft/"
+                        + "upload image|video/comments/list,API_REFERENCE §4.60.13)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:ReviewCenterController 应提供 /api/admin/review-center/summary(SC-A-18 审核队列)")
+    void reviewCenterController_summaryEndpointExists() {
+        Method m = findPostMapping(ReviewCenterController.class.getDeclaredMethods(), "/summary");
+        assertNotNull(m,
+                "RED[admin.content.security] ReviewCenterController 缺 @PostMapping /summary"
+                        + "(SC-A-18 内容安全审核队列:13 类待审聚合,API_REFERENCE §4.60.13)。");
+        assertEquals("summary", m.getName(), "summary 端点应映射到 summary 方法");
+    }
+
+    @Test
+    @DisplayName("正常流-3:ContentFilterController 应提供 /api/content/by-tags(内容安全过滤)")
+    void contentFilterController_byTagsEndpointExists() {
+        Method m = findPostMapping(ContentFilterController.class.getDeclaredMethods(), "/by-tags");
+        assertNotNull(m,
+                "RED[admin.content.security] ContentFilterController 缺 @PostMapping /by-tags"
+                        + "(内容安全/内容过滤载体,API_REFERENCE §4.12)。");
+        assertEquals("byTags", m.getName(), "by-tags 端点应映射到 byTags 方法");
+    }
+
+    // ==================================================================
+    // 正常流/边界流:SC-A-13 边界「敏感词命中拦截」+ fail-open 策略
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-4:标题命中敏感词 → 拦截发布,不触达 articleService")
+    void create_标题命中敏感词_拦截发布() throws Exception {
+        AdminArticleController controller = articleControllerWith(false);
+        ArticleService articleService = (ArticleService) getField(controller, "articleService");
+
+        Result<Map<String, Object>> r = callCreate(controller, "标题含敏感词", "<p>正文</p>", "admin");
+
+        assertEquals("文章标题包含敏感信息,发布失败", r.getMessage(),
+                "SC-A-13 边界「敏感词命中拦截」:微信 checkText 命中 → 拦截,不得发布");
+        verify(articleService, never()).create(any(), any());
+    }
+
+    @Test
+    @DisplayName("正常流-5:内容命中敏感词 → 拦截发布(正文敏感,标题先放行)")
+    void create_正文命中敏感词_拦截发布() throws Exception {
+        AdminArticleController controller = articleControllerWith(true, false);
+        ArticleService articleService = (ArticleService) getField(controller, "articleService");
+
+        Result<Map<String, Object>> r = callCreate(controller, "正常标题", "<p>正文含敏感词</p>", "admin");
+
+        assertEquals("文章内容包含敏感信息,发布失败", r.getMessage(),
+                "SC-A-13 边界「敏感词命中拦截」:标题通过检测、正文命中 → 拦截,不得发布");
+        verify(articleService, never()).create(any(), any());
+    }
+
+    @Test
+    @DisplayName("边界流-1:空白/空文本不做敏感检测,直接放行(fail-open 不误伤正常输入)")
+    void checkTextSafe_空白文本_直接放行() throws Exception {
+        AdminArticleController controller = new AdminArticleController();
+        // 不注入 wechatService:空白文本分支不触达检测,若触达会 NPE → 测试红
+        assertTrue(invokeCheckTextSafe(controller, null),
+                "checkTextSafe(null) 应直接放行,不触达微信检测");
+        assertTrue(invokeCheckTextSafe(controller, "  \t  "),
+                "checkTextSafe(全空白) 应直接放行,不触达微信检测");
+    }
+
+    // ==================================================================
+    // 正常流/边界流:ArticleService 审计状态机(SC-A-13 提审→通过/下架 闭环)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-6:提审(submitForReview)草稿 → pending + auditStatus=pending")
+    void submitForReview_草稿_提交审核() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("draft", null);
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.submitForReview(ARTICLE_ID);
+
+        assertEquals("pending", a.getStatus(), "提审后 status 应置 pending");
+        assertEquals("pending", a.getAuditStatus(), "提审后 auditStatus 应置 pending(进入审核队列)");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("边界流-2:提审非草稿 → 抛「仅草稿状态可提交审核」,不写库")
+    void submitForReview_非草稿_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("published", "approved");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.submitForReview(ARTICLE_ID));
+
+        assertEquals("仅草稿状态可提交审核", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("正常流-7:撤回(withdraw)发布中 → withdrawn(SC-A-13「下架」)")
+    void withdraw_发布中_下架() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("published", "approved");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.withdraw(ARTICLE_ID);
+
+        assertEquals("withdrawn", a.getStatus(), "撤回后 status 应置 withdrawn(下架)");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("边界流-3:撤回非发布中 → 抛「仅发布中的文章可撤回」,不写库")
+    void withdraw_非发布中_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("draft", null);
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.withdraw(ARTICLE_ID));
+
+        assertEquals("仅发布中的文章可撤回", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("正常流-8:驳回/撤回后重编(reDraft)→ 回草稿并清空审计字段(审核→驳回→重编闭环)")
+    void reDraft_驳回或撤回_重回草稿并清审计() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article rejected = article("rejected", "rejected");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(rejected);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.reDraft(ARTICLE_ID);
+
+        assertEquals("draft", rejected.getStatus(), "rejected → draft 可重新编辑");
+        assertNull(rejected.getAuditStatus(), "重编应清空 auditStatus");
+        assertNull(rejected.getAuditReason(), "重编应清空驳回原因");
+        assertNull(rejected.getAuditorId(), "重编应清空审核人");
+        assertNull(rejected.getAuditedAt(), "重编应清空审核时间");
+        verify(articleMapper).updateById(rejected);
+
+        // withdrawn → draft 同路径
+        ArticleService service2 = new ArticleService();
+        ArticleMapper mapper2 = mock(ArticleMapper.class);
+        Article withdrawn = article("withdrawn", "approved");
+        when(mapper2.selectById(ARTICLE_ID)).thenReturn(withdrawn);
+        injectService(service2, "articleMapper", mapper2);
+
+        service2.reDraft(ARTICLE_ID);
+
+        assertEquals("draft", withdrawn.getStatus(), "withdrawn → draft 可重新编辑");
+        verify(mapper2).updateById(withdrawn);
+    }
+
+    @Test
+    @DisplayName("边界流-4:重编非驳回/非撤回状态 → 抛「仅已驳回或已撤回的文章可重新编辑」,不写库")
+    void reDraft_非法状态_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("pending", "pending"); // 审核中不可直接重编
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.reDraft(ARTICLE_ID));
+
+        assertEquals("仅已驳回或已撤回的文章可重新编辑", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    // ==================================================================
+    // 正常流:SC-A-18 审核队列(review-center/summary 13 类待审聚合)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-9:review-center/summary 应聚合 13 类待审计数并落地各 Tab 键")
+    void reviewCenterSummary_十三类聚合键() throws Exception {
+        ReviewCenterController controller = new ReviewCenterController();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        when(articleMapper.selectCount(any())).thenReturn(5L);
+        inject(controller, "articleMapper", articleMapper);
+        inject(controller, "productMapper", mock(ProductMapper.class));
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "activityMapper", mock(ActivityMapper.class));
+        inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
+        inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
+        inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
+        inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
+        inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
+        inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
+        inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
+
+        Result<Map<String, Integer>> r = controller.summary();
+
+        assertEquals(200, r.getCode().intValue(), "summary 应成功返回");
+        Map<String, Integer> counts = r.getData();
+        assertNotNull(counts, "summary data 不应为空");
+        Set<String> keys = new HashSet<>(counts.keySet());
+        Set<String> expected = new HashSet<>();
+        for (String k : new String[]{
+                "articlePending", "productPending", "guidePending", "vendorPending",
+                "butlerPending", "packagePending", "activityPending", "activityRegistrationPending",
+                "withdrawPending", "refundPending", "hierarchyChangePending",
+                "nutritionistPending", "knowledgePending"}) {
+            expected.add(k);
+        }
+        assertEquals(expected, keys,
+                "RED[admin.content.security] review-center/summary 应聚合 SC-A-18 契约的 13 类待审键"
+                        + "(文章/商品/规划师/服务商/管家/套餐/活动/报名/提现/退款/层级变更/营养师/知识)。");
+        assertEquals(5, counts.get("articlePending").intValue(),
+                "articlePending 应等于 articles.audit_status=pending 计数");
+    }
+
+    @Test
+    @DisplayName("正常流-10:review-center/summary 无待审数据 → 全部键存在且为 0(空队列口径)")
+    void reviewCenterSummary_空队列_13键全0() throws Exception {
+        ReviewCenterController controller = new ReviewCenterController();
+        // 不 stub 任何 mapper:Mockito 默认计数返回 0 → 空队列
+        inject(controller, "articleMapper", mock(ArticleMapper.class));
+        inject(controller, "productMapper", mock(ProductMapper.class));
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "activityMapper", mock(ActivityMapper.class));
+        inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
+        inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
+        inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
+        inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
+        inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
+        inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
+        inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
+
+        Result<Map<String, Integer>> r = controller.summary();
+
+        Map<String, Integer> counts = r.getData();
+        assertNotNull(counts, "空队列也应返回 13 键 map");
+        assertEquals(13, counts.size(), "空队列 13 类键应齐全(值全部为 0)");
+        for (Map.Entry<String, Integer> e : counts.entrySet()) {
+            assertEquals(0, e.getValue().intValue(), "空队列 " + e.getKey() + " 应为 0");
+        }
+    }
+
+    // ==================================================================
+    // 正常流/边界流:ContentFilterController /api/content/by-tags
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-11:by-tags 无标签无问题域 → 三组内容为空(不触达 DB)")
+    void byTags_空标签_返回空分组() {
+        ContentFilterController controller = new ContentFilterController();
+
+        @SuppressWarnings("unchecked")
+        Result<Map<String, Object>> r = controller.byTags(new HashMap<>());
+
+        assertEquals(200, r.getCode().intValue(), "空标签应成功返回(游客可访问)");
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "data 不应为空");
+        assertEquals(0, ((java.util.List<?>) data.get("resultTags")).size(), "resultTags 应为空集合");
+        assertEquals(0, ((java.util.List<?>) data.get("articles")).size(), "articles 应为空(无标签不查询)");
+        assertEquals(0, ((java.util.List<?>) data.get("knowledgeBases")).size(), "knowledgeBases 应为空");
+        assertEquals(0, ((java.util.List<?>) data.get("foods")).size(), "foods 应为空");
+    }
+
+    // ==================================================================
+    // 反射辅助
+    // ==================================================================
+
+    private Method findPostMapping(Method[] methods, String path) {
+        for (Method m : methods) {
+            Annotation ann = m.getAnnotation(PostMapping.class);
+            if (ann == null) {
+                continue;
+            }
+            for (String value : ((PostMapping) ann).value()) {
+                if (path.equals(value)) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 构造 AdminArticleController,敏感检测按调用序返回(true=放行 / false=命中拦截)。 */
+    private AdminArticleController articleControllerWith(boolean... safeResults) throws Exception {
+        AdminArticleController controller = new AdminArticleController();
+        inject(controller, "articleService", mock(ArticleService.class));
+        inject(controller, "wechatService", mock(WechatServiceInterface.class));
+        @SuppressWarnings("unchecked")
+        Future<Boolean> future = mock(Future.class);
+        if (safeResults.length == 1) {
+            when(future.get(anyLong(), any(TimeUnit.class))).thenReturn(safeResults[0]);
+        } else if (safeResults.length > 1) {
+            Boolean[] boxed = new Boolean[safeResults.length];
+            for (int i = 0; i < safeResults.length; i++) {
+                boxed[i] = safeResults[i];
+            }
+            when(future.get(anyLong(), any(TimeUnit.class)))
+                    .thenReturn(boxed[0], java.util.Arrays.copyOfRange(boxed, 1, boxed.length));
+        }
+        ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
+        when(taskExecutor.submit(any(Callable.class))).thenReturn(future);
+        inject(controller, "taskExecutor", taskExecutor);
+        return controller;
+    }
+
+    /** 反射调用 create(Map, adminId, role);返回 Result(contract 兼容任意签名变体)。 */
+    @SuppressWarnings("unchecked")
+    private Result<Map<String, Object>> callCreate(AdminArticleController controller,
+            String title, String content, String role) throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("title", title);
+        body.put("content", content);
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("create")) {
+                m.setAccessible(true);
+                if (m.getParameterCount() == 3) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID, role);
+                }
+                if (m.getParameterCount() == 2) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID);
+                }
+            }
+        }
+        throw new IllegalStateException("契约方法 create 不存在");
+    }
+
+    private boolean invokeCheckTextSafe(AdminArticleController controller, String text) throws Exception {
+        Method m = AdminArticleController.class.getDeclaredMethod("checkTextSafe", String.class);
+        m.setAccessible(true);
+        Object r = m.invoke(controller, text);
+        return Boolean.TRUE.equals(r);
+    }
+
+    private Article article(String status, String auditStatus) {
+        Article a = new Article();
+        a.setId(ARTICLE_ID);
+        a.setStatus(status);
+        a.setAuditStatus(auditStatus);
+        a.setTitle("契约测试文章");
+        a.setContent("<p>正文</p>");
+        if ("rejected".equals(status)) {
+            a.setAuditReason("内容涉嫌夸大");
+            a.setAuditorId(7L);
+            a.setAuditedAt(new java.util.Date());
+        }
+        return a;
+    }
+
+    private void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Object getField(Object target, String field) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private void injectService(ArticleService service, String field, Object value) throws Exception {
+        inject(service, field, value);
+    }
+}

+ 365 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminUserGovernContractTest.java

@@ -0,0 +1,365 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端用户/家庭查询治理 + 账号注销/数据导出工单处理(能力键 {@code admin.user.govern})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-11(查用户/家庭 → 代操作(限授权),
+ *       边界:<b>代操作留审计</b>)、SC-A-19(接工单 → 执行归档/导出,边界:<b>审计留痕;冷静期</b>)</li>
+ *   <li>用例:{@code tests/test-cases/admin.md} TC-A-20(注销/导出工单按序处理、越权 403)、
+ *       TC-A-N09(仅工单授权用户可导出;<b>数据脱敏打包</b>;操作留痕审计)</li>
+ *   <li>设计:API_REFERENCE §4.13 {@code /api/admin/*};AdminController(
+ *       {@code users/create/update/reset-password/delete/search/user/search})、
+ *       UserController({@code /api/user/data-export})、AdminFamilyOverviewController(家庭列表治理)</li>
+ * </ul>
+ *
+ * <p><b>实现现状核查(src/main,f941b9212 基线)</b></p>
+ * <ul>
+ *   <li>已落地(本测试相应断言为绿):{@code /users/create}、{@code /users/{id}/update}、
+ *       {@code /users/{id}/reset-password}、{@code /users/{id}/delete}、{@code /user/search}、
+ *       {@code /families}(B-5 行级隔离)、{@code /api/user/data-export},及 role/roles 门禁参数。</li>
+ *   <li>未落地(本测试相应断言为红,即 TDD-RED 契约缺口):
+ *       <ol>
+ *         <li>{@code /users/search} 路由缺失(目前仅 {@code /user/search},契约要求两者并存)</li>
+ *         <li>{@code AuditLogAspect} 仅拦截 {@code AdminCf*} + {@code AdminServiceConfigController},
+ *             未覆盖 {@code AdminController} 用户/家庭治理写操作 → 代操作<b>无审计留痕</b></li>
+ *         <li>{@code AccountCleanupService.exportUserData} 直接回传原始 phone/realName → 导出<b>未脱敏</b></li>
+ *         <li>{@code AccountCleanupService.deactivateAccount} 直接匿名化 → 注销<b>无冷静期</b></li>
+ *         <li>GDPR 工单(entity/表/service)整体未落地 → SC-A-19「接工单 → 执行归档/导出」不可达</li>
+ *       </ol>
+ *   </li>
+ * </ul>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补齐上述 5 项缺口后,本测试自动转绿;届时可将结构探测
+ * 升级为行为级 mock 验证(如 verify auditLogService.persist(...))。</p>
+ *
+ * <p><b>测试基建</b>:纯反射结构契约 + Mockito 行为契约,无 Spring 上下文 / 无 DB / 无外部服务;
+ * 失败断言原文清晰可贴,可编译、可运行、预期红。</p>
+ */
+class AdminUserGovernContractTest {
+
+    /** 设计锚点:管理端用户治理控制器(API_REFERENCE §4.13) */
+    private static final String ADMIN_CONTROLLER = "com.etotem.cfc.controller.admin.AdminController";
+
+    /** 设计锚点:数据导出控制器(/api/user/data-export) */
+    private static final String USER_CONTROLLER = "com.etotem.cfc.controller.UserController";
+
+    /** SC-A-11 边界:代操作审计切面 */
+    private static final String AUDIT_LOG_ASPECT = "com.etotem.cfc.aspect.AuditLogAspect";
+
+    /** SC-A-19 边界:注销冷静期所在服务 */
+    private static final String ACCOUNT_CLEANUP_SERVICE = "com.etotem.cfc.service.AccountCleanupService";
+
+    /** SC-A-19 / TC-A-20:GDPR 工单实体(契约命名,GREEN 阶段创建) */
+    private static final String GDPR_WORK_ORDER_ENTITY = "com.etotem.cfc.entity.GdprWorkOrder";
+
+    /** SC-A-19 / TC-A-20:GDPR 工单处理服务(契约命名,GREEN 阶段创建) */
+    private static final String GDPR_WORK_ORDER_SERVICE = "com.etotem.cfc.service.GdprWorkOrderService";
+
+    /** SC-A-19:工单表名(schema.sql 应含建表) */
+    private static final String WORK_ORDER_TABLE = "work_order";
+
+    /** 已落地路由(设计锚点 user 治理面的 6 条 + 导出 + 家庭列表治理) */
+    private static final String[] EXISTING_GOVERN_ROUTES = {
+            "/users/create",
+            "/users/{id}/update",
+            "/users/{id}/reset-password",
+            "/users/{id}/delete",
+            "/user/search",
+            "/families"
+    };
+
+    // ------------------------------------------------------------------
+    // Mockito 行为契约:AccountCleanupService.exportUserData 脱敏
+    // ------------------------------------------------------------------
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService accountCleanupService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:管理端用户/家庭治理路由契约(API_REFERENCE §4.13 + 设计锚点)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:用户治理既有路由与数据导出路由应存在")
+    void adminUserGovern_existingRoutesPresent() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        Class<?> user = loadClass(USER_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在(API_REFERENCE §4.13)。");
+        assertNotNull(user, "[admin.user.govern] UserController 类不存在(/api/user/data-export)。");
+        for (String route : EXISTING_GOVERN_ROUTES) {
+            assertTrue(hasPostRoute(admin, route),
+                    "[admin.user.govern] AdminController 缺少契约路由 " + route + "(API_REFERENCE §4.13)。");
+        }
+        assertTrue(hasPostRoute(user, "/data-export"),
+                "[admin.user.govern] UserController 缺少 /data-export(数据导出,SC-A-19)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:/api/admin/users/search 路由应存在(设计要求 users/search 与 user/search 并存)")
+    void adminUserGovern_usersSearchRoutePresent() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在(API_REFERENCE §4.13)。");
+        assertTrue(hasPostRoute(admin, "/users/search"),
+                "[admin.user.govern] 契约设计锚点列出 users/create/update/reset-password/delete/"
+                        + "search/user/search 7 条治理路由,当前仅 /user/search 已实现,"
+                        + "/users/search 路由缺失,无法执行「按关键字查用户」治理入口。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:代操作留审计(SC-A-11 边界)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:用户/家庭治理代操作必须落审计留痕(SC-A-11 边界)")
+    void adminUserGovern_writesAudited() {
+        Class<?> aspect = loadClass(AUDIT_LOG_ASPECT);
+        assertNotNull(aspect, "[admin.user.govern] AuditLogAspect 类不存在,审计基建缺失。");
+        String expr = pointcutExpression(aspect, "cfAdminController");
+        assertTrue(expr != null && expr.contains("AdminController"),
+                "[admin.user.govern] 代操作必须留审计(SC-A-11 边界):AuditLogAspect#cfAdminController "
+                        + "pointcut 当前为「" + expr + "」,"
+                        + "未覆盖 AdminController 的 users/create|update|reset-password|delete|families 等"
+                        + "用户/家庭治理写操作 → 后台代操作无审计留痕。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:数据导出脱敏打包(TC-A-N09)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:数据导出必须脱敏打包(TC-A-N09)")
+    void exportUserData_masksSensitiveFields() {
+        User u = new User();
+        u.setId(12345L);
+        u.setNickname("契约测试用户");
+        u.setPhone("13812345678");
+        u.setRealName("张三");
+        u.setGender("male");
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(danAssessmentResultMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> data = accountCleanupService.exportUserData(12345L);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> userInfo = (Map<String, Object>) data.get("userInfo");
+        assertNotNull(userInfo, "[admin.user.govern] 导出应含 userInfo 块(数据导出,SC-A-19)。");
+        String phone = String.valueOf(userInfo.get("phone"));
+        String realName = String.valueOf(userInfo.get("realName"));
+        assertFalse("13812345678".equals(phone) || "张三".equals(realName),
+                "[admin.user.govern] 数据导出必须脱敏打包(TC-A-N09:仅工单授权用户可导出;"
+                        + "数据脱敏打包):当前 exportUserData 直接回传原始 phone/realName,"
+                        + "手机号/真实姓名未经脱敏即落入导出包。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:注销冷静期(SC-A-19 边界)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:账号注销必须含冷静期(SC-A-19 边界)")
+    void accountCancellation_hasCoolingOffPeriod() {
+        Class<?> svc = loadClass(ACCOUNT_CLEANUP_SERVICE);
+        assertNotNull(svc, "[admin.user.govern] AccountCleanupService 类不存在。");
+        boolean hasCooling = false;
+        for (Field f : svc.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("cooling")) {
+                hasCooling = true;
+            }
+        }
+        for (Method m : svc.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains("cooling")) {
+                hasCooling = true;
+            }
+        }
+        assertTrue(hasCooling,
+                "[admin.user.govern] 账号注销必须含冷静期(SC-A-19 边界:审计留痕;冷静期):"
+                        + "当前 deactivateAccount 检查未完成订单后直接匿名化,无冷静期/确认环节,"
+                        + "用户无撤销机会。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:GDPR 工单处理(SC-A-19 / TC-A-20 / TC-A-N09)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-4:注销/导出工单实体应存在(SC-A-19)")
+    void gdprWorkOrder_entityExists() {
+        assertNotNull(loadClass(GDPR_WORK_ORDER_ENTITY),
+                "[admin.user.govern] GDPR 工单实体缺失(SC-A-19:接工单 → 执行归档/导出):"
+                        + "当前无工单承载「注销/导出申请 → 审批/执行」状态机,"
+                        + "TC-A-20 的按序执行/越权处理无从做起。");
+    }
+
+    @Test
+    @DisplayName("边界流-5:工单表应落地 schema.sql(SC-A-19)")
+    void gdprWorkOrder_schemaTableExists() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(WORK_ORDER_TABLE),
+                "[admin.user.govern] schema.sql 缺少 " + WORK_ORDER_TABLE
+                        + " 建表(SC-A-19):注销/导出工单需持久化(含操作者/目标用户/类型/状态/"
+                        + "审计时间戳),当前无对应表。");
+    }
+
+    @Test
+    @DisplayName("边界流-6:工单处理服务应存在(SC-A-19 / TC-A-20)")
+    void gdprWorkOrder_serviceExists() {
+        assertNotNull(loadClass(GDPR_WORK_ORDER_SERVICE),
+                "[admin.user.govern] GDPR 工单处理服务缺失(SC-A-19:执行归档/导出):"
+                        + "当前账号注销/导出由用户自助接口直接执行,无运营端「接单 → 归档/导出」"
+                        + "工单处理链路,TC-A-20(并发按序、越权 403)无对应实现。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:限授权(SC-A-11 流程)——既有路由已具备 role/roles 门禁参数
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:治理写操作应携带 role/roles 门禁参数(SC-A-11 限授权)")
+    void governanceEndpoints_carryRoleGate() {
+        Class<?> admin = loadClass(ADMIN_CONTROLLER);
+        assertNotNull(admin, "[admin.user.govern] AdminController 类不存在。");
+        for (String route : EXISTING_GOVERN_ROUTES) {
+            Method m = findPostMethod(admin, route);
+            assertTrue(m != null && hasRoleGateParam(m),
+                    "[admin.user.govern] " + route
+                            + " 应声明 role/roles 的 @RequestAttribute 门禁参数,拦截非后台角色"
+                            + "(SC-A-11 限授权)。");
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 方法级 @PostMapping 是否声明了指定路径 */
+    private boolean hasPostRoute(Class<?> clazz, String path) {
+        return findPostMethod(clazz, path) != null;
+    }
+
+    private Method findPostMethod(Class<?> clazz, String path) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String v : pm.value()) {
+                if (path.equals(v)) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 方法参数是否声明了 role 或 roles 的 @RequestAttribute 门禁 */
+    private boolean hasRoleGateParam(Method m) {
+        for (Parameter p : m.getParameters()) {
+            for (Annotation a : p.getAnnotations()) {
+                if (a instanceof RequestAttribute) {
+                    String name = ((RequestAttribute) a).value();
+                    if ("role".equals(name) || "roles".equals(name)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 反射读取 @Pointcut 注解的 value(不静态依赖 aspectj 类型) */
+    private String pointcutExpression(Class<?> clazz, String methodName) {
+        try {
+            Method m = clazz.getDeclaredMethod(methodName);
+            for (Annotation a : m.getDeclaredAnnotations()) {
+                if ("Pointcut".equals(a.annotationType().getSimpleName())) {
+                    Method value = a.annotationType().getMethod("value");
+                    Object v = value.invoke(a);
+                    return v != null ? String.valueOf(v) : "";
+                }
+            }
+        } catch (Exception e) {
+            return null;
+        }
+        return null;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 373 - 208
cfc-frontend/components/login-sheet.vue

@@ -2,29 +2,68 @@
   <view class="login-sheet-overlay" :class="{ 'login-sheet-overlay--visible': visible }" @click="handleOverlayClick">
   <view class="login-sheet-overlay" :class="{ 'login-sheet-overlay--visible': visible }" @click="handleOverlayClick">
     <view class="login-sheet-panel" :class="{ 'login-sheet-panel--show': panelShow }" @click.stop>
     <view class="login-sheet-panel" :class="{ 'login-sheet-panel--show': panelShow }" @click.stop>
       <view class="login-sheet-header">
       <view class="login-sheet-header">
+        <view class="login-sheet-handle" aria-hidden="true"></view>
         <view class="login-sheet-title">欢迎回来</view>
         <view class="login-sheet-title">欢迎回来</view>
       </view>
       </view>
       <scroll-view scroll-y class="login-sheet-scroll" :show-scrollbar="false">
       <scroll-view scroll-y class="login-sheet-scroll" :show-scrollbar="false">
         <view class="login-sheet-content">
         <view class="login-sheet-content">
-          <!-- 品牌区(左上角锁定版式,与首页一致) -->
-          <view class="brand-bar">
-            <image class="brand-logo" src="/static/logo.png" mode="aspectFit" @error="handleImageError"></image>
-            <view class="brand-texts">
-              <text class="brand-name">浠艾福</text>
-              <text class="brand-tagline">您的专属家庭主动健康陪跑</text>
-            </view>
+          <!-- 品牌区:logo / 品牌名 / 身份 / 主打句(记忆点) -->
+          <view class="brand-section">
+            <image class="logo" src="/static/logo.png" mode="aspectFit" @error="handleImageError"></image>
+            <text class="app-name">浠艾福</text>
+            <text class="identity">您的专属家庭主动健康管理师</text>
+            <text class="slogan">家里每个人的状态,你真的<text class="slogan-accent">看见了?</text></text>
           </view>
           </view>
 
 
-          <!-- 理念卡 -->
+          <!-- 理念卡:四步闭环(细边框表面区分,无单侧彩条) -->
           <view class="concept-card">
           <view class="concept-card">
+            <view class="concept-desc">
+              不等问题发生,先一步<text class="concept-highlight">识别风险 · 主动干预 · 持续评估</text>,把全家健康管理融入每一天。
+            </view>
             <view class="loop-row">
             <view class="loop-row">
-              <view class="loop-pill loop-pill-indigo"><text>🔍</text><text class="loop-pill-text">提前识别</text></view>
-              <text class="loop-arrow">→</text>
-              <view class="loop-pill loop-pill-green"><text>🛡️</text><text class="loop-pill-text">风险管控</text></view>
-              <text class="loop-arrow">→</text>
-              <view class="loop-pill loop-pill-amber"><text>⚡</text><text class="loop-pill-text">主动干预</text></view>
-              <text class="loop-arrow">→</text>
-              <view class="loop-pill loop-pill-red"><text>📊</text><text class="loop-pill-text">效果评估</text></view>
+              <view class="loop-step">
+                <view class="loop-glyph loop-glyph-wisdom" aria-hidden="true"></view>
+                <text class="loop-step-text">提前识别</text>
+              </view>
+              <view class="loop-connector" aria-hidden="true"></view>
+              <view class="loop-step">
+                <view class="loop-glyph loop-glyph-success" aria-hidden="true"></view>
+                <text class="loop-step-text">风险管控</text>
+              </view>
+              <view class="loop-connector" aria-hidden="true"></view>
+              <view class="loop-step">
+                <view class="loop-glyph loop-glyph-wealth" aria-hidden="true"></view>
+                <text class="loop-step-text">主动干预</text>
+              </view>
+              <view class="loop-connector" aria-hidden="true"></view>
+              <view class="loop-step">
+                <view class="loop-glyph loop-glyph-danger" aria-hidden="true"></view>
+                <text class="loop-step-text">效果评估</text>
+              </view>
+            </view>
+          </view>
+
+          <!-- 五维标签带:统一描边容器 + 细分割线,色块仅作身份记号(非等权重填充 pills) -->
+          <view class="dims-band">
+            <view class="dim-cell">
+              <view class="dim-glyph dim-glyph-body" aria-hidden="true"></view>
+              <text class="dim-label">身·健康</text>
+            </view>
+            <view class="dim-cell">
+              <view class="dim-glyph dim-glyph-wisdom" aria-hidden="true"></view>
+              <text class="dim-label">智·认知</text>
+            </view>
+            <view class="dim-cell">
+              <view class="dim-glyph dim-glyph-wealth" aria-hidden="true"></view>
+              <text class="dim-label">富·创富</text>
+            </view>
+            <view class="dim-cell">
+              <view class="dim-glyph dim-glyph-action" aria-hidden="true"></view>
+              <text class="dim-label">行·和谐</text>
+            </view>
+            <view class="dim-cell">
+              <view class="dim-glyph dim-glyph-mind" aria-hidden="true"></view>
+              <text class="dim-label">心·大爱</text>
             </view>
             </view>
           </view>
           </view>
 
 
@@ -33,15 +72,15 @@
             <view class="phone-section">
             <view class="phone-section">
               <button
               <button
                 class="phone-btn"
                 class="phone-btn"
+                :class="{ 'phone-btn--error': errorState, 'phone-btn--success': successState }"
                 open-type="getPhoneNumber"
                 open-type="getPhoneNumber"
                 @getphonenumber="getPhoneNumber"
                 @getphonenumber="getPhoneNumber"
                 @tap="onPhoneBtnTap"
                 @tap="onPhoneBtnTap"
                 :loading="loading"
                 :loading="loading"
                 :disabled="!agreed || loading || tapLock"
                 :disabled="!agreed || loading || tapLock"
               >
               >
-                <text class="phone-btn-icon">📱</text>
-                <text class="phone-btn-text">手机号快捷登录</text>
-                <text class="phone-arrows">›››</text>
+                <view class="phone-btn-icon" aria-hidden="true"></view>
+                <text>手机号快捷登录</text>
               </button>
               </button>
             </view>
             </view>
 
 
@@ -83,7 +122,10 @@ export default {
       nickname: '',
       nickname: '',
       avatar: '',
       avatar: '',
       redirectUrl: '',
       redirectUrl: '',
-      _tapLockTimer: null
+      _tapLockTimer: null,
+      // 八状态视觉位(R2 修复):默认均为布尔 false,不触发任何 class;登录逻辑与外部契约不变
+      errorState: false,
+      successState: false
     }
     }
   },
   },
   beforeDestroy() {
   beforeDestroy() {
@@ -226,6 +268,67 @@ export default {
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
+/* =====================================================================
+   login-sheet 视觉令牌(局部)
+   来源:全部复用 uni.scss 共享令牌;仅本组件专有的色值以 $ls- 前缀声明,
+   组件体内零裸 hex / 裸 px / 裸毫秒。
+   注意:mp-weixin WXML 不支持模板内联 <svg>(仓库既有约定,
+   components/wuxing-sandbox.vue 注释在案),故图标一律用
+   data:image/svg+xml 作为 CSS background-image,颜色显式写入 SVG 文档。
+   ===================================================================== */
+
+// ---- 图标工具:构建 data-URI SVG(颜色显式编码为 %23hex)----
+// str-replace 使维度色令牌可注入 data-URI(# 编码为 %23),令牌成为唯一事实源
+@function str-replace($string, $search, $replace) {
+  $index: str-index($string, $search);
+  @if $index {
+    @return str-slice($string, 1, $index - 1) + $replace +
+      str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
+  }
+  @return $string;
+}
+@function ls-icon($color, $inner) {
+  @return url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='#{str-replace($color, '#', '%23')}' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E#{$inner}%3C/svg%3E");
+}
+// 图标路径(feather 线性几何,24×24;只编码 < > 与 #,空格/单引号合法)
+$ls-i-search:   "%3Ccircle cx='11' cy='11' r='7'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E";
+$ls-i-shield:   "%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E";
+$ls-i-zap:      "%3Cpath d='M13 2 3 14h9l-1 8 10-12h-9l1-8z'/%3E";
+$ls-i-chart:    "%3Cline x1='18' y1='20' x2='18' y2='10'/%3E%3Cline x1='12' y1='20' x2='12' y2='4'/%3E%3Cline x1='6' y1='20' x2='6' y2='14'/%3E";
+$ls-i-user:     "%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E";
+$ls-i-bulb:     "%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3Cpath d='M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5.76.76 1.23 1.52 1.41 2.5'/%3E";
+$ls-i-trend:    "%3Cpolyline points='23 6 13.5 15.5 8.5 10.5 1 18'/%3E%3Cpolyline points='17 6 23 6 23 12'/%3E";
+$ls-i-activity: "%3Cpolyline points='22 12 18 12 15 21 9 3 6 12 2 12'/%3E";
+$ls-i-heart:    "%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E";
+$ls-i-chev:     "%3Cpolyline points='9 18 15 12 9 6'/%3E";
+$ls-i-phone:    "%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.08 4.18 2 2 0 0 1 4.06 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z'/%3E";
+
+// ---- 局部令牌:底色 / 卡片 / 文字 / 描边 ----
+$ls-bg-top:      #FFFDFB;                       // 面板暖白·顶(较原 #fffaf3 更干净)
+$ls-bg-bottom:   #FBF4EA;                       // 面板暖白·底(降饱和)
+$ls-card:        #FFFFFF;                       // 卡片表面
+$ls-card-border: #F0E7DB;                       // 暖发丝描边
+$ls-divider:     #F1EAE0;                       // 发丝分割线
+$ls-overlay:     rgba(20, 15, 8, 0.48);         // 暖调遮罩,非纯黑
+$ls-ink-strong:  $facade-text-strong;           // 主文字 #333
+$ls-ink-body:    $facade-text-body;             // 正文/次级 #666
+$ls-ink-slate:   $facade-text-slate-600;        // 弱文字 #5A6478(全面板 ≥4.5:1,含底部 #FBF4EA)
+$ls-handle:      #8A8A8A;                       // 抓握条(UI affordance,对面板顶 3.40:1 ≥3:1)
+$ls-connector:   $facade-text-slate;            // 步骤连接箭头 #6B7280(对白卡 4.83:1 ≥3:1)
+$ls-brand-deep:  $brand-primary-deep;           // 品牌橙·文字/主CTA #C2410C(对白 5.18:1,AA)
+$ls-ink-on-brand:$ink-on-dark;                  // 橙底白字 #FFFFFF
+$ls-radius-card: $radius-lg;                    // 24rpx 卡片档
+$ls-radius-btn:  $radius-lg;                    // 24rpx 大按钮档
+// 维度标识色(深档,保证白底 ≥3:1;与 uni.scss §12 深档一一对应)
+$ls-dim-body:   #9C4A1A;   // 身·赭石
+$ls-dim-wisdom: $dim-wisdom-deep; // 智·靛蓝(实际编译值 #4F46E5,见 uni.scss L588 二次声明覆盖;对白 6.29:1)
+$ls-dim-wealth: $dim-wealth-deep; // 富·黄土 #92400E
+$ls-dim-action: $dim-action-deep; // 行·森林绿深档 #047857(token 复用)
+$ls-dim-mind:   $dim-mind-deep;   // 心·玫红 #9D174D
+// 维度浅底(uni.scss §13 tint;四步闭环用)
+$ls-dim-wisdom-tint: $dim-wisdom-tint; // 智浅底 #F0F0FF
+$ls-dim-wealth-tint: $dim-wealth-tint; // 富浅底 #FFFBEB
+
 /* ===== 浮层遮罩 ===== */
 /* ===== 浮层遮罩 ===== */
 .login-sheet-overlay {
 .login-sheet-overlay {
   position: fixed;
   position: fixed;
@@ -233,11 +336,11 @@ export default {
   left: 0;
   left: 0;
   right: 0;
   right: 0;
   bottom: 0;
   bottom: 0;
-  background: rgba(0, 0, 0, 0.5);
+  background: $ls-overlay; // 暖调遮罩,非纯黑
   z-index: 99999;
   z-index: 99999;
   opacity: 0;
   opacity: 0;
   visibility: hidden;
   visibility: hidden;
-  transition: opacity 0.25s ease;
+  transition: opacity $uni-transition-base;
 }
 }
 .login-sheet-overlay--visible {
 .login-sheet-overlay--visible {
   opacity: 1;
   opacity: 1;
@@ -250,13 +353,13 @@ export default {
   left: 0;
   left: 0;
   right: 0;
   right: 0;
   bottom: 0;
   bottom: 0;
-  height: 60vh;
-  max-height: 65vh;
-  background: linear-gradient(180deg, #fffaf3 0%, #fff3e4 100%);
-  border-radius: 24rpx 24rpx 0 0;
+  height: 85vh;
+  max-height: 85vh;
+  background: linear-gradient(180deg, $ls-bg-top 0%, $ls-bg-bottom 100%);
+  border-radius: $ls-radius-card $ls-radius-card 0 0;
   overflow: hidden;
   overflow: hidden;
   transform: translateY(100%);
   transform: translateY(100%);
-  transition: transform 0.3s ease;
+  transition: transform $uni-transition-slow;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
 }
 }
@@ -265,14 +368,25 @@ export default {
 }
 }
 
 
 .login-sheet-header {
 .login-sheet-header {
-  padding: 24rpx 32rpx 8rpx;
+  padding: $uni-space-lg $uni-space-xl $uni-space-xs;
   display: flex;
   display: flex;
-  justify-content: center;
+  flex-direction: column;
+  align-items: center;
+}
+// 抓握条:底部浮层的拖拽 affordance(居中,独立于下方左对齐内容)
+.login-sheet-handle {
+  width: 64rpx;
+  height: 8rpx;
+  border-radius: 999rpx;
+  background: $ls-handle;
 }
 }
 .login-sheet-title {
 .login-sheet-title {
-  font-size: calc(32rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
+  margin-top: $uni-space-md;
+  align-self: flex-start; // R5:标题与品牌区同左锚点
+  font-size: calc($uni-font-size-sm * var(--font-scale, 1));
+  font-weight: $uni-font-weight-medium;
+  letter-spacing: 6rpx;
+  color: $ls-ink-slate;
 }
 }
 
 
 /* ===== 滚动区域 ===== */
 /* ===== 滚动区域 ===== */
@@ -284,239 +398,290 @@ export default {
 }
 }
 
 
 .login-sheet-content {
 .login-sheet-content {
-  padding: 24rpx 48rpx 60rpx;
+  padding: $uni-space-lg $uni-space-xl $uni-space-4xl;
   box-sizing: border-box;
   box-sizing: border-box;
 }
 }
 
 
-/* ===== 品牌区(左上角锁定版式,与首页一致) ===== */
-.brand-bar {
+/* ===== 品牌区:清晰层级(R5 不对称锚点:左对齐,打破万物居中) ===== */
+.brand-section {
   display: flex;
   display: flex;
-  align-items: center;
-  gap: 20rpx;
-  padding: 4rpx 0;
-  box-sizing: border-box;
+  flex-direction: column;
+  align-items: flex-start;
+  padding: $uni-space-md 0 $uni-space-xs;
+}
+.logo {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: $radius-md;
+}
+.app-name {
+  margin-top: $uni-space-lg;
+  font-size: calc($uni-font-size-3xl * var(--font-scale, 1)); // 52rpx 主标题(正文 28rpx 的 1.86x + 收窄行宽)
+  font-weight: $uni-font-weight-black;
+  color: $ls-ink-strong;
+  letter-spacing: 2rpx;
 }
 }
-.brand-logo {
-  width: 80rpx;
-  height: 80rpx;
-  border-radius: 20rpx;
-  background: $app-surface;
-  box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.18);
-  flex-shrink: 0;
+.identity {
+  margin-top: $uni-space-xs;
+  font-size: calc($uni-font-size-base * var(--font-scale, 1)); // 28rpx 副标语
+  font-weight: $uni-font-weight-medium;
+  color: $ls-brand-deep;
+  letter-spacing: 2rpx;
 }
 }
-.brand-texts {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  min-width: 0;
-  padding-left: 20rpx;
-  border-left: 2rpx solid rgba($brand-primary, 0.28);
-}
-.brand-name {
-  font-size: calc(38rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
-  letter-spacing: 3rpx;
-  line-height: 1.2;
-}
-.brand-tagline {
-  display: block;
-  font-size: calc(22rpx * var(--font-scale, 1));
-  font-weight: 500;
-  color: $brand-primary-deep;
-  margin-top: 6rpx;
-  letter-spacing: 1rpx;
-  line-height: 1.2;
-  white-space: nowrap;
+.slogan {
+  margin-top: $uni-space-md;
+  max-width: 460rpx; // 行宽收窄,形成视觉记忆点
+  font-size: calc($uni-font-size-lg * var(--font-scale, 1)); // 32rpx 主打句
+  line-height: 1.6;
+  color: $ls-ink-body;
+  text-align: left; // 与品牌区左对齐(R5)
+}
+.slogan-accent {
+  color: $ls-brand-deep;
+  font-weight: $uni-font-weight-bold;
 }
 }
 
 
-/* ===== 理念卡 ===== */
+/* ===== 理念卡(细边框表面区分,无单侧彩条) ===== */
 .concept-card {
 .concept-card {
-  background: rgba(249, 115, 22, 0.08);
-  border-radius: 24rpx;
-  padding: 24rpx;
-  margin-top: 28rpx;
+  background: $ls-card;
+  border: 1rpx solid $ls-card-border;
+  border-radius: $ls-radius-card;
+  padding: $uni-space-xl;
+  margin-top: $uni-space-2xl;
+}
+.concept-desc {
+  font-size: calc($uni-font-size-base * var(--font-scale, 1));
+  color: $ls-ink-body;
+  line-height: 1.7;
+}
+.concept-highlight {
+  color: $ls-brand-deep;
+  font-weight: $uni-font-weight-bold;
 }
 }
 .loop-row {
 .loop-row {
   display: flex;
   display: flex;
   flex-direction: row;
   flex-direction: row;
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
-  margin-top: 16rpx;
-  flex-wrap: nowrap;  /* 禁止换行,确保四步/五维在一行 */
-  margin-bottom: 16rpx;
+  margin-top: $uni-space-xl;
+  flex-wrap: nowrap; // 保持四步一行
 }
 }
-.loop-pill {
+.loop-step {
   display: flex;
   display: flex;
-  flex-direction: row;
+  flex-direction: column;
   align-items: center;
   align-items: center;
-  border-radius: 999rpx;
-  padding: 6rpx 12rpx;
-  font-size: calc(22rpx * var(--font-scale, 1));
-  flex-shrink: 0;  /* 防止被压缩导致文本换行 */
+  flex-shrink: 0;
+}
+.loop-glyph {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 28rpx 28rpx;
+}
+.loop-step-text {
+  margin-top: $uni-space-xs;
+  font-size: calc($uni-font-size-sm * var(--font-scale, 1));
+  color: $ls-ink-slate;
   white-space: nowrap;
   white-space: nowrap;
 }
 }
-.loop-pill-indigo { background: rgba(99, 102, 241, 0.12); color: $dim-wisdom; }
-.loop-pill-green { background: rgba(16, 185, 129, 0.12); color: $status-success; }
-.loop-pill-amber { background: rgba(245, 158, 11, 0.12); color: $dim-wealth; }
-.loop-pill-red { background: rgba(239, 68, 68, 0.12); color: $status-danger; }
-.loop-pill-text {
-  margin-left: 4rpx;
-  white-space: nowrap;  /* 禁止四字步骤文本换行 */
+// 四步闭环的色相:仅图标圆底着色,文字统一中性(去彩虹)
+.loop-glyph-wisdom {
+  background-color: $ls-dim-wisdom-tint;
+  background-image: ls-icon($ls-dim-wisdom, $ls-i-search);
 }
 }
-.loop-arrow {
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-ghost-light;
-  padding: 0 2rpx;
-  flex-shrink: 0;  /* 箭头不被压缩 */
+.loop-glyph-success {
+  background-color: $status-success-tint;
+  background-image: ls-icon($status-success-deep, $ls-i-shield);
 }
 }
-
-/* 轨道环:缓慢旋转,呼应 Web 端虚线轨道 */
-.orbit-ring {
-  position: absolute;
-  left: 50%; top: 50%;
-  width: 240rpx; height: 240rpx;
-  margin-left: -120rpx; margin-top: -120rpx;
-  border: 2rpx dashed rgba(194, 112, 61, 0.35);
-  border-radius: 50%;
-  box-sizing: border-box;
-  animation: orbitRingSpin 24s linear infinite;
-}
-@keyframes orbitRingSpin {
-  0%   { transform: rotate(0deg); }
-  100% { transform: rotate(360deg); }
-}
-/* 中心"家"徽章:金色脉冲,呼应 Web 端中心节点 */
-.orbit-center {
-  position: absolute;
-  left: 50%; top: 50%;
-  width: 84rpx; height: 84rpx;
-  margin-left: -42rpx; margin-top: -42rpx;
-  border-radius: 50%;
-  background: #F59E0B;
-  display: flex; align-items: center; justify-content: center;
-  color: #fff; font-size: calc(36rpx * var(--font-scale, 1)); font-weight: 700;
-  box-shadow: 0 0 20rpx rgba(245, 158, 11, 0.4);
-  animation: orbitCenterPulse 3s ease-in-out infinite;
-  z-index: 3;
-}
-@keyframes orbitCenterPulse {
-  0%, 100% { box-shadow: 0 0 20rpx rgba(245, 158, 11, 0.4); transform: scale(1); }
-  50%      { box-shadow: 0 0 36rpx rgba(245, 158, 11, 0.6); transform: scale(1.06); }
-}
-/* 五维公转节点:绕中心旋转,文字保持正立 */
-.orbit-node {
-  position: absolute;
-  left: 50%; top: 50%;
-  width: 76rpx; height: 76rpx;
-  margin-left: -38rpx; margin-top: -38rpx;
-  border-radius: 50%;
-  background: var(--node-color);
-  display: flex; align-items: center; justify-content: center;
-  color: #fff; font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 700;
-  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
-  z-index: 2;
-  animation-duration: 15s;
-  animation-timing-function: linear;
-  animation-iteration-count: infinite;
-}
-.orbit-node-char { line-height: 1; }
-/* 五节点:颜色 + 起始角度 + 错峰延迟,与 Web 端一致 */
-.node-0 { --node-color: #FF8C42; animation-name: orbitNode0; animation-delay: 0s; }    /* 身 */
-.node-1 { --node-color: #1677FF; animation-name: orbitNode1; animation-delay: -3s; }   /* 智 */
-.node-2 { --node-color: #F59E0B; animation-name: orbitNode2; animation-delay: -6s; }   /* 富 */
-.node-3 { --node-color: #10B981; animation-name: orbitNode3; animation-delay: -9s; }   /* 行 */
-.node-4 { --node-color: #FF6B9D; animation-name: orbitNode4; animation-delay: -12s; }  /* 心 */
-
-@keyframes orbitNode0 {
-  0%   { transform: rotate(0deg)   translateX(120rpx) rotate(0deg); }
-  100% { transform: rotate(360deg) translateX(120rpx) rotate(-360deg); }
+.loop-glyph-wealth {
+  background-color: $ls-dim-wealth-tint;
+  background-image: ls-icon($ls-dim-wealth, $ls-i-zap);
+}
+.loop-glyph-danger {
+  background-color: $status-danger-tint-2;
+  background-image: ls-icon($status-danger-strong, $ls-i-chart);
 }
 }
-@keyframes orbitNode1 {
-  0%   { transform: rotate(72deg)   translateX(120rpx) rotate(-72deg); }
-  100% { transform: rotate(432deg)  translateX(120rpx) rotate(-432deg); }
+.loop-connector {
+  width: 20rpx;
+  height: 20rpx;
+  flex-shrink: 0;
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 16rpx 16rpx;
+  background-image: ls-icon($ls-connector, $ls-i-chev);
 }
 }
-@keyframes orbitNode2 {
-  0%   { transform: rotate(144deg)  translateX(120rpx) rotate(-144deg); }
-  100% { transform: rotate(504deg)  translateX(120rpx) rotate(-504deg); }
+
+/* ===== 五维标签带:统一容器 + 发丝分割 + 色块记号 ===== */
+.dims-band {
+  display: flex;
+  flex-direction: row;
+  align-items: stretch;
+  background: $ls-card;
+  border: 1rpx solid $ls-card-border;
+  border-radius: $ls-radius-card;
+  margin-top: $uni-space-lg;
+  padding: $uni-space-md 0;
+}
+.dim-cell {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
 }
 }
-@keyframes orbitNode3 {
-  0%   { transform: rotate(216deg)  translateX(120rpx) rotate(-216deg); }
-  100% { transform: rotate(576deg)  translateX(120rpx) rotate(-576deg); }
+.dim-cell + .dim-cell {
+  border-left: 1rpx solid $ls-divider;
 }
 }
-@keyframes orbitNode4 {
-  0%   { transform: rotate(288deg)  translateX(120rpx) rotate(-288deg); }
-  100% { transform: rotate(648deg)  translateX(120rpx) rotate(-648deg); }
+.dim-glyph {
+  width: 36rpx;
+  height: 36rpx;
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 30rpx 30rpx;
 }
 }
+.dim-label {
+  margin-top: $uni-space-xs;
+  font-size: calc($uni-font-size-sm * var(--font-scale, 1));
+  color: $ls-ink-body;
+  white-space: nowrap;
+}
+.dim-glyph-body   { background-image: ls-icon($ls-dim-body, $ls-i-user); }
+.dim-glyph-wisdom { background-image: ls-icon($ls-dim-wisdom, $ls-i-bulb); }
+.dim-glyph-wealth { background-image: ls-icon($ls-dim-wealth, $ls-i-trend); }
+.dim-glyph-action { background-image: ls-icon($ls-dim-action, $ls-i-activity); }
+.dim-glyph-mind   { background-image: ls-icon($ls-dim-mind, $ls-i-heart); }
+
+// 主CTA(白字需 4.5:1:品牌橙 #F97316 仅 2.80:1 不达标,取深橙档;渐变更暗端保证全段达标)
+$ls-cta-from: #D1440B;              // 深橙·亮端(白字 4.62:1 达标)
+$ls-cta-to:   $brand-primary-deep;  // 深橙·暗端 #C2410C(白字 5.18:1 达标)
+// hover / active 降档(白字 ≥6.0:1)
+$ls-cta-hover-from: #B93D0B;
+$ls-cta-hover-to:   #A9330A;
+$ls-cta-press-from: #A9330A;
+$ls-cta-press-to:   #962D08;
+// 禁用态:共享 $btn-disabled-bg(#D9D9D9) 对面板仅 1.39:1 不可辨、$btn-disabled-ink 3.78:1 不达 4.5
+// 故在组件局部覆盖为「暖灰底+深字」(不改全局令牌):底 #8E857A 对面板顶/底 3.58/3.32:1 达 ≥3、字 #1A1A1A 达 ≥4.5
+$ls-btn-disabled-bg:  #8E857A;
+$ls-btn-disabled-ink: $text-charcoal;   // #1A1A1A(uni.scss 既有令牌)
+// error/success 视觉态底色(R3 修复:白字需 ≥4.5)
+$ls-btn-error-bg:     $status-danger-strong;   // #B91C1C(白字 6.47:1 达标)
+$ls-btn-success-bg:   $status-success-strong;  // #065F46(白字 7.68:1 达标)
 
 
 /* ===== 登录表单 ===== */
 /* ===== 登录表单 ===== */
 .login-form {
 .login-form {
-  margin-top: 32rpx;
+  margin-top: $uni-space-2xl;
+}
+.phone-section {
+  display: flex;
+  justify-content: center;
 }
 }
 .phone-btn {
 .phone-btn {
   width: 100%;
   width: 100%;
-  height: 82rpx;
-  border-radius: 24rpx;
-  background: linear-gradient(135deg, #FFC24A 0%, #FFA726 100%);
-  color: #14458F;
-  font-size: calc(32rpx * var(--font-scale, 1));
-  font-weight: 600;
+  height: 96rpx;
+  border-radius: $ls-radius-btn;
+  background: linear-gradient(135deg, $ls-cta-from 0%, $ls-cta-to 100%);
+  color: $ls-ink-on-brand;
+  font-size: calc($uni-font-size-lg * var(--font-scale, 1));
+  font-weight: $uni-font-weight-bold;
   display: flex;
   display: flex;
   flex-direction: row;
   flex-direction: row;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
   border: none;
   border: none;
-  box-shadow: 0 8rpx 20rpx rgba(255, 167, 38, 0.32);
+  box-sizing: border-box;
+  // 八态:default/hover/active/disabled/loading 走令牌;error/success 为待接线 class
+  transition: background-color $duration-fast $ease-standard,
+              transform $duration-fast $ease-standard,
+              opacity $duration-fast $ease-standard;
 }
 }
 .phone-btn::after {
 .phone-btn::after {
   border: none;
   border: none;
 }
 }
+.phone-btn:hover {
+  background: linear-gradient(135deg, $ls-cta-hover-from 0%, $ls-cta-hover-to 100%); // H5 悬停:再降一档
+}
+.phone-btn:active {
+  transform: scale(0.98);
+  background: linear-gradient(135deg, $ls-cta-press-from 0%, $ls-cta-press-to 100%);
+}
+// R2 修复:键盘焦点可见环(H5 端)。mp-weixin 无键盘焦点,不影响小程序端。
+// 用品牌深橙描边对面板顶 5.10:1 ≥3:1(不纯黑);2px 描边 + 外扩,非内凹模糊
+.phone-btn:focus-visible {
+  outline: 4rpx solid $ls-brand-deep;
+  outline-offset: 4rpx;
+}
+.phone-btn[loading] {
+  background: linear-gradient(135deg, $ls-cta-from 0%, $ls-cta-to 100%); // 保持与 default 一致,spinner 由组件自带
+}
 .phone-btn[disabled] {
 .phone-btn[disabled] {
-  background: #FFEFD0 !important;
-  color: #B57E2A !important;
+  background: $ls-btn-disabled-bg !important;
+  color: $ls-btn-disabled-ink !important;
 }
 }
-.phone-btn-icon {
-  margin-right: 8rpx;
+// error/success 视觉态(R2/R3 接线:模板 :class 绑定 errorState/successState,默认 false 不触发)
+.phone-btn--error {
+  background: $ls-btn-error-bg !important;
 }
 }
-.phone-btn-text {
-  color: #000000;
+.phone-btn--success {
+  background: $ls-btn-success-bg !important;
 }
 }
-.phone-arrows {
-  margin-left: 10rpx;
-  font-weight: bold;
-  letter-spacing: 2rpx;
-  color: #000000;
-  animation: arrowBlink 1s ease-in-out infinite;
+.phone-btn[disabled].phone-btn--error,
+.phone-btn[disabled].phone-btn--success {
+  background: $ls-btn-disabled-bg !important; // 禁用态优先,error/success 不覆盖
 }
 }
-@keyframes arrowBlink {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.15; }
+.phone-btn[disabled] .phone-btn-icon {
+  background-image: ls-icon($ls-btn-disabled-ink, $ls-i-phone);
 }
 }
-
-/* ===== 暂不登录 ===== */
-.skip-section {
-  display: flex;
-  justify-content: center;
-  margin-top: 24rpx;
+.phone-btn--error .phone-btn-icon,
+.phone-btn--success .phone-btn-icon {
+  background-image: ls-icon($ls-ink-on-brand, $ls-i-phone); // 深底白图标,与白字一致
 }
 }
-.skip-btn {
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $facade-text-muted;
-  text-decoration: underline;
+.phone-btn-icon {
+  width: 36rpx;
+  height: 36rpx;
+  margin-right: $uni-space-sm;
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 32rpx 32rpx;
+  background-image: ls-icon($ls-ink-on-brand, $ls-i-phone);
 }
 }
 
 
-/* ===== 协议 ===== */
+/* ===== 协议(R5:与品牌区同左锚点,不再居中) ===== */
 .agreement {
 .agreement {
   display: flex;
   display: flex;
   flex-direction: row;
   flex-direction: row;
   align-items: center;
   align-items: center;
-  justify-content: center;
+  justify-content: flex-start;
   flex-wrap: wrap;
   flex-wrap: wrap;
-  margin-top: 24rpx;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-muted;
+  margin-top: $uni-space-lg;
+  font-size: calc($uni-font-size-sm * var(--font-scale, 1));
+  color: $ls-ink-slate;
 }
 }
 .link {
 .link {
-  color: $brand-primary;
+  color: $ls-brand-deep;
+  font-weight: $uni-font-weight-medium;
+}
+
+/* ===== 暂不登录(R5:左对齐) ===== */
+.skip-section {
+  display: flex;
+  justify-content: flex-start;
+  margin-top: $uni-space-lg;
+}
+.skip-btn {
+  font-size: calc($uni-font-size-base * var(--font-scale, 1));
+  color: $ls-ink-slate;
+  text-decoration: underline;
+  padding: $uni-space-xs $uni-space-md; // 扩大点击区
+}
+
+/* ===== 动效降级:尊重 reduced-motion ===== */
+@media (prefers-reduced-motion: reduce) {
+  .login-sheet-overlay,
+  .login-sheet-panel,
+  .phone-btn {
+    transition-duration: 0.01ms;
+  }
 }
 }
-</style>
+</style>

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c6fbf499f4f557b974d12ed2bf3ff40ecf84eb93
+07518bfd9763ce1de35b340509eb98571f5d57da

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,28 @@
 
 
 ---
 ---
 
 
+## v1.0.2441 (2026-11-13)
+
+### 测试
+- 补充 S-cfc-202 定向取证 24例5红(窗口尾部合入)全站终值 5254/4264
+- 补充 S-cfc-203 定向取证 5红(窗口尾部合入)全站终值 5230/4245
+- S-cfc-202 admin.order.manual 契约级单测——权限流 RED(B-142-1/TR-13)
+- 契约级单测 admin.activity.config 配置校验 5红 TDD-RED
+- 测试负责人闭环 — API 全量首测 990 例(B-192-2)+ S-cfc-201 定向 6红18绿 + 全量承袭 4245 过
+
+### 文档
+- 测试负责人第192轮汇报(API 全量首测 990 例 + S-cfc-201 定向 + B-192-1/2)
+
+### 其他
+- - 13 正常流 + 5 边界流绿锚点:端点映射齐全、委托 service、售后状态守卫已实现
+- - 5 权限流预期红:订单 6 端点 + 售后 4 端点缺 admin 角色守卫(@RequestAttribute("role")),
+-   非 admin(parent/child/teacher)可越权发货/关单/审批退款/处理售后;list 非 admin 被放行返回 200
+- - 框架层锚点:JwtInterceptor.LOGIN_REQUIRED_PATHS 已收口 /api/admin/(匿名→401)
+- RED 验证:24 tests,5 failures(全部为预期权限流缺口),19 green,0 errors。
+- 纯 Mockito+反射契约单测,无 Spring 上下文/DB/外部服务。GREEN 指引见测试类 Javadoc。
+- 
+
+
 ## v1.0.2440 (2026-11-13)
 ## v1.0.2440 (2026-11-13)
 
 
 ### 文档
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.2440
+> 当前版本: v1.0.2441
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,28 @@
 
 
 ---
 ---
 
 
+## v1.0.2441 (2026-11-13)
+
+### 测试
+- 补充 S-cfc-202 定向取证 24例5红(窗口尾部合入)全站终值 5254/4264
+- 补充 S-cfc-203 定向取证 5红(窗口尾部合入)全站终值 5230/4245
+- S-cfc-202 admin.order.manual 契约级单测——权限流 RED(B-142-1/TR-13)
+- 契约级单测 admin.activity.config 配置校验 5红 TDD-RED
+- 测试负责人闭环 — API 全量首测 990 例(B-192-2)+ S-cfc-201 定向 6红18绿 + 全量承袭 4245 过
+
+### 文档
+- 测试负责人第192轮汇报(API 全量首测 990 例 + S-cfc-201 定向 + B-192-1/2)
+
+### 其他
+- - 13 正常流 + 5 边界流绿锚点:端点映射齐全、委托 service、售后状态守卫已实现
+- - 5 权限流预期红:订单 6 端点 + 售后 4 端点缺 admin 角色守卫(@RequestAttribute("role")),
+-   非 admin(parent/child/teacher)可越权发货/关单/审批退款/处理售后;list 非 admin 被放行返回 200
+- - 框架层锚点:JwtInterceptor.LOGIN_REQUIRED_PATHS 已收口 /api/admin/(匿名→401)
+- RED 验证:24 tests,5 failures(全部为预期权限流缺口),19 green,0 errors。
+- 纯 Mockito+反射契约单测,无 Spring 上下文/DB/外部服务。GREEN 指引见测试类 Javadoc。
+- 
+
+
 ## v1.0.2440 (2026-11-13)
 ## v1.0.2440 (2026-11-13)
 
 
 ### 文档
 ### 文档