|
| 1 | +# Admin Notifications API —真实接口测试报告 |
| 2 | + |
| 3 | +|字段 | 值 | |
| 4 | +|---|---| |
| 5 | +|报告生成时间 |2026-06-08 | |
| 6 | +| 测试目标 | `POST /admin/notifications`、`PUT /admin/notifications/{id}`、`DELETE /admin/notifications/{id}` | |
| 7 | +| 后端版本 | `ulticode-9001`(Spring Boot3.2.5 / Java17,profile=`dev`) | |
| 8 | +| 后端 PID |309021(PM2 运行中) | |
| 9 | +| 测试账号 | dev seed `admin` / `admin123`(`DevUserBootstrapRunner` 创建,`role=ADMIN`) | |
| 10 | +|鉴权 | JWT Bearer(Cookie)+ CSRF `X-CSRF-Token` Header(每次写操作旋转) | |
| 11 | +| 测试工具 | curl8.5.0 + Docker exec mysql + Python3 | |
| 12 | +| Controller源 | `backend-spring/.../admin/controller/AdminNotificationController.java` | |
| 13 | +| Service源 | `backend-spring/.../admin/service/impl/AdminNotificationServiceImpl.java` | |
| 14 | +| DTO | `CreateSystemNotificationRequest`、`UpdateSystemNotificationRequest` | |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +##1.控制器契约(来自源码审计) |
| 19 | + |
| 20 | +|端点 |鉴权注解 |限流 | 请求体 |响应 | |
| 21 | +|---|---|---|---|---| |
| 22 | +| `POST /admin/notifications` | `@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')")` | `admin:notification-create`30/分钟 | `CreateSystemNotificationRequest`:`title`/`content`/`type`全部 `@NotBlank`,`target` `@NotBlank`(`ALL` 或 `USERS`),可选 `category`、`userIds` | `Result<AdminNotificationVO>` | |
| 23 | +| `PUT /admin/notifications/{id}` | `@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')")` | `admin:notification-update`30/分钟 | `UpdateSystemNotificationRequest`:`title`/`content` `@NotBlank`,可选 `type`、`category` | `Result<AdminNotificationVO>` | |
| 24 | +| `DELETE /admin/notifications/{id}` | `@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')")` | `admin:notification-delete`30/分钟 | — | `Result<Void>` | |
| 25 | + |
| 26 | +GET(顺带验证)走 `AdminNotificationQueryDTO` 分页 +字段过滤(`category=SYSTEM`),并按 `announcement_id` dedup。 |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +##2. 测试环境前置 |
| 31 | + |
| 32 | +| 项 | 实测值 | |
| 33 | +|---|---| |
| 34 | +| 后端 `GET /admin/notifications` 无 Cookie | `HTTP401 {"code":40100,"message":"Unauthorized"}` ✅ | |
| 35 | +| `POST /auth/login` with `admin/admin123` | `HTTP200`,下发 `access_token`(217B JWT)+ `refresh_token`(195B)+ `csrf_token`(65B)Cookie +响应体 `csrfToken`字段 | |
| 36 | +| Cookie 中包含 `Set-Cookie: access_token=…; HttpOnly; SameSite=lax`、`refresh_token=…; HttpOnly`、`csrf_token=…` | ✅全部 `HttpOnly`,`JWT_COOKIE_SECURE=false`(开发 HTTP) | |
| 37 | +|响应头 | `Content-Security-Policy: default-src 'self'`,`X-Frame-Options: DENY`,`Permissions-Policy: camera=(), microphone=(), geolocation=()` | |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +##3. 测试结果汇总 |
| 42 | + |
| 43 | +| # | 用例 | HTTP | 实测结果 |期望 |结论 | |
| 44 | +|---|---|---|---|---|---| |
| 45 | +| **POST** | | | | | | |
| 46 | +| P1 | happy path(`target=ALL`) | **500** | `Database error: Type handler was null on parameter mapping for property '__frch_item_0.metadata'` |200 | ❌ **真实 Bug**(详见 §6) | |
| 47 | +| P2 |缺 `title` |400 | `{"title":"Title cannot be blank"}` |400 | ✅ `@NotBlank`生效 | |
| 48 | +| P3 | `target=INVALID` |400 | `"No target users found"` |400(业务)或422 | ✅ Service拒绝(fall-through 后返回空列表) | |
| 49 | +| P4 | `target=USERS, userIds=[]` |400 | `"No target users found"` |400 | ✅ | |
| 50 | +| P5 | `target=USERS, userIds=[无效 id]` |400 | `"No target users found"` |400 | ✅ | |
| 51 | +| P6 |缺 `X-CSRF-Token` |403 | `"CSRF token is required"` |403 | ✅ `CsrfValidationFilter`拦截 | |
| 52 | +| P7 | 无 Cookie |401 | `"Unauthorized"` |401 | ✅ | |
| 53 | +| **PUT** | | | | | | |
| 54 | +| U1 | happy path(更新 `N1`,共享 `announcement_id`) | **200** | 更新 title/body成功,**同 `announcement_id` 的另一条记录也同步更新**(验证 SQL截图) |200 | ✅ Service广播到 `announcement_id` 同组 | |
| 55 | +| U2 | id 不存在 |404 | `"Notification not found"` |404 | ✅ | |
| 56 | +| U3 | 空 `title` |400 | `"Title cannot be blank"` |400 | ✅ | |
| 57 | +| U4 |缺 `X-CSRF-Token` |403 | `"CSRF token is required"` |403 | ✅ | |
| 58 | +| U5 | 无 Cookie |401 | `"Unauthorized"` |401 | ✅ | |
| 59 | +| **DELETE** | | | | | | |
| 60 | +| D1 | happy path(删除 `N1`) | **200** | **同 `announcement_id` 的全部2 条都被删除**(`SELECT COUNT(*)=0`验证) |200 | ✅ Service 级联删除 | |
| 61 | +| D2 | id 不存在 |404 | `"Notification not found"` |404 | ✅ | |
| 62 | +| D3 |缺 `X-CSRF-Token` |403 | `"CSRF token is required"` |403 | ✅ | |
| 63 | +| D4 | 无 Cookie |401 | `"Unauthorized"` |401 | ✅ | |
| 64 | +| D5 |重复删除同一 id |404 | `"Notification not found"` |404 | ✅幂等 | |
| 65 | +| **GET(辅助)** | | | | | | |
| 66 | +| G1 | `GET /admin/notifications?page=1&limit=5` 无 Cookie |401 | `"Unauthorized"` |401 | ✅ | |
| 67 | +| G2 | `GET /admin/notifications` 有 Cookie |200 | `{"items":[…],"total":1,"pageSize":5,…}`,按 `announcement_id` dedup |200 | ✅ | |
| 68 | + |
| 69 | +###响应时间(已测样本) |
| 70 | + |
| 71 | +| 操作 | 时延 | |
| 72 | +|---|---| |
| 73 | +| `GET /admin/notifications`(空表) |9.3 ms | |
| 74 | +| `GET /admin/notifications`(1 条 dedup) |14.4 ms | |
| 75 | +| `POST /admin/notifications` happy path |4.5 ms(500 即返回) | |
| 76 | +| `PUT /admin/notifications/{id}` happy |19.2 ms | |
| 77 | +| `DELETE /admin/notifications/{id}` happy |19.1 ms | |
| 78 | +| `DELETE`404 |11.5 ms | |
| 79 | +| `DELETE`403(无 CSRF) |2.4 ms | |
| 80 | + |
| 81 | +--- |
| 82 | + |
| 83 | +##4. PUT happy path —广播验证证据 |
| 84 | + |
| 85 | +**请求**:`PUT /admin/notifications/701bf8a8-b7da-49e1-a434-04bfeee33715`,body `{"title":"[FIXTURE] 系统升级预告 (已更新)","content":"升级时间调整至03:00,请重新安排。","type":"SYSTEM","category":"SYSTEM"}` |
| 86 | + |
| 87 | +**响应**(节选): |
| 88 | +```json |
| 89 | +{"code":0,"message":"success","data":{"id":"701bf8a8-b7da-49e1-a434-04bfeee33715","announcementId":"947d761c-affe-401a-b806-e80d5fa80e9f","title":"[FIXTURE] 系统升级预告 (已更新)","content":"升级时间调整至03:00,请重新安排。","type":"SYSTEM","category":"SYSTEM","creator":{"id":"9f6bc78a-…","username":"admin","avatar":"…"}},"traceId":"t-1780930447729"} |
| 90 | +``` |
| 91 | + |
| 92 | +**MySQL验证(DELETE之前)**: |
| 93 | +``` |
| 94 | +id title body |
| 95 | +701bf8a8-b7da-49e1-a434-04bfeee33715 [FIXTURE] 系统升级预告 (已更新)升级时间调整至03:00,请重新安排。 |
| 96 | +ca781967-ffcd-45ba-8862-eada77b522e0 [FIXTURE] 系统升级预告 (已更新)升级时间调整至03:00,请重新安排。 |
| 97 | +``` |
| 98 | +两条共享 `announcement_id=947d761c-…` 的记录都被更新——确认 Service 的 `LambdaUpdateWrapper.eq(Notification::getAnnouncementId, ...)`正确广播。 |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +##5. DELETE happy path — 级联删除证据 |
| 103 | + |
| 104 | +**请求**:`DELETE /admin/notifications/701bf8a8-b7da-49e1-a434-04bfeee33715` |
| 105 | + |
| 106 | +**响应**:`HTTP/1.1200 {"code":0,"message":"success","traceId":"t-1780930509722"}`(19.1 ms) |
| 107 | + |
| 108 | +**MySQL验证**: |
| 109 | +``` |
| 110 | +SELECT COUNT(*) AS remaining FROM notifications WHERE announcement_id='947d761c-…'; |
| 111 | +remaining |
| 112 | +0 |
| 113 | +``` |
| 114 | +两条共享 `announcement_id` 的记录均被删除。Service 的 `deleteByAnnouncementId` 分支(`announcementId != null`路径)正确触发。 |
| 115 | + |
| 116 | +--- |
| 117 | + |
| 118 | +##6. 🔴关键发现 — `POST /admin/notifications`500 Bug |
| 119 | + |
| 120 | +###现象 |
| 121 | +合法请求 → `HTTP500 {"code":50001,"message":"Database error","traceId":"t-1780929374933"}` |
| 122 | + |
| 123 | +###根因(PM2 out 日志) |
| 124 | +``` |
| 125 | +java.lang.IllegalStateException: Type handler was null on parameter mapping for |
| 126 | + property '__frch_item_0.metadata'. It was either not specified and/or could |
| 127 | + not be found for the javaType (java.util.Map) : jdbcType (null) combination. |
| 128 | +
|
| 129 | +The error may exist in com/ulticode/modules/notification/mapper/NotificationMapper.java |
| 130 | +The error may involve com.ulticode.modules.notification.mapper.NotificationMapper.batchInsert |
| 131 | +The error occurred while executing an update |
| 132 | +``` |
| 133 | + |
| 134 | +### 代码定位 |
| 135 | +`backend-spring/.../notification/mapper/NotificationMapper.java`: |
| 136 | +```java |
| 137 | +@Insert("<script>INSERT INTO notifications (…metadata…) VALUES " + |
| 138 | + "<foreach collection='list' item='item' separator=','>" + |
| 139 | + "(#{item.id}, …, #{item.metadata}, …)" + |
| 140 | + "</foreach></script>") |
| 141 | +int batchInsert(@Param("list") List<Notification> list); |
| 142 | +``` |
| 143 | + |
| 144 | +`Notification.java` 用 `@TableField(typeHandler = JacksonTypeHandler.class)` 把 `Map<String,Object> metadata` 注册到 MyBatis-Plus 元数据,但 `@Insert` 自定义 SQL 的 `#{item.metadata}`不会继承 `@TableField` 的 typeHandler——MyBatis 无法把 `Map`绑定到 `JSON` 列上。 |
| 145 | + |
| 146 | +###修复建议(最小改动) |
| 147 | +在 mapper注解里显式声明 typeHandler: |
| 148 | +```java |
| 149 | +@Insert("<script>INSERT INTO notifications " + |
| 150 | + "(id, user_id, type, category, title, body, link, metadata, announcement_id, is_read, read_at, created_at, updated_at) VALUES " + |
| 151 | + "<foreach collection='list' item='item' separator=','>" + |
| 152 | + "(#{item.id}, #{item.userId}, #{item.type}, #{item.category}, #{item.title}, " + |
| 153 | + " #{item.body}, #{item.link}, " + |
| 154 | + " #{item.metadata, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class}, " + |
| 155 | + " #{item.announcementId}, #{item.isRead}, #{item.readAt}, #{item.createdAt}, #{item.updatedAt})" + |
| 156 | + "</foreach></script>") |
| 157 | +int batchInsert(@Param("list") List<Notification> list); |
| 158 | +``` |
| 159 | + |
| 160 | +或者改为逐条 `insert`(自循环),由 MyBatis-Plus 元数据自动选择 typeHandler。 |
| 161 | + |
| 162 | +### 影响 |
| 163 | +- 前端管理后台 `management/src/api/admin/notifications.ts` 中的 `notificationsApi.create(...)`路径整体不可用。 |
| 164 | +- 工作流:"管理员发公告" 是核心运维动作,发布期间完全失败。 |
| 165 | +- 由于本测试中用 fixture绕过 batchInsert 才完成 PUT/DELETE 测试,**PUT/DELETE 通过是真实业务结果,但 POST 的业务路径本身不能端到端验证**。 |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +##7.鉴权 &防护矩阵 |
| 170 | + |
| 171 | +|维度 | 实现 | 测试结果 | |
| 172 | +|---|---|---| |
| 173 | +| JWT鉴权 | `JwtAuthenticationFilter` + `access_token` Cookie(HttpOnly) | ✅401 无 Cookie 时拦截 | |
| 174 | +| RBAC | `@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')")` | ✅三个写端点均有注解(管理员有 `ADMIN`角色通过) | |
| 175 | +| CSRF | `CsrfValidationFilter`(POST/PUT/DELETE/PATCH校验) | ✅403缺 `X-CSRF-Token` 时拦截 | |
| 176 | +| CSRF Token Rotation | `csrfService.validateAndRotateToken` | ✅每次写操作返回 `X-New-CSRF-Token`,**前端必须每次都用最新值** | |
| 177 | +|限流 | `@RateLimit(key="admin:notification-…", limit=30, period=60)` | ✅注解存在,未触发(未压测) | |
| 178 | +|审计 | `@Audited(action = CREATE/UPDATE/DELETE_NOTIFICATION, entityType = ENTITY_NOTIFICATION)` | ✅注解存在,未在数据库单独验证 | |
| 179 | +| 参数校验 | `@Valid` + `@NotBlank`(DTO字段级) | ✅ title/content/type/target 空值400 | |
| 180 | +|业务校验 | `getTargetUserIds` 检查 target/USERS有效性 | ✅ INVALID/空 userIds →400 | |
| 181 | + |
| 182 | +--- |
| 183 | + |
| 184 | +##8. 一致性观察 |
| 185 | + |
| 186 | +- **数据库→列表 dedup**:`GET /admin/notifications` 通过 `selectDedupedAnnouncements` 按 `announcement_id` 去重,每条公告只展示一次;这与 PUT/DELETE 按 `announcement_id`广播修改/删除保持一致。 |
| 187 | +- **审计身份取自 principal**:`createSystemNotification` 内 `SecurityUtil.getCurrentUserId()` + `userMapper.selectById` 获取 creator,未从请求体取——符合项目 "审计身份取自认证 principal,不取自请求体"约定。 |
| 188 | +- **错误码体系**:成功 `code=0`,业务错4xxxx(400/403/404),系统错5xxxx(50001 数据库),与项目 `Result<T>` 信封一致。 |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +##9.总结与建议 |
| 193 | + |
| 194 | +### 通过情况 |
| 195 | +|端点 |核心路径 |边界用例 | 总评 | |
| 196 | +|---|---|---|---| |
| 197 | +| `POST /admin/notifications` | ❌(500 bug) | ✅校验/鉴权/CSRF/Auth 都正确 | **不通过** —需修复 batchInsert | |
| 198 | +| `PUT /admin/notifications/{id}` | ✅ | ✅ | **通过** | |
| 199 | +| `DELETE /admin/notifications/{id}` | ✅ | ✅ | **通过** | |
| 200 | + |
| 201 | +###优先级建议 |
| 202 | +1. **🔴 P0**:修复 `NotificationMapper.batchInsert` 的 typeHandler缺失(详见 §6)。 |
| 203 | +2. **🟡 P1**:补一个 `AdminNotificationControllerIT`(Testcontainers),覆盖 POST happy path 的回归保护——目前三个端点没有 `*IT.java`集成测试(仓库结构扫描确认)。 |
| 204 | +3. **🟢 P2**:补充 CSRF旋转失败时的错误处理文档(前端 `X-New-CSRF-Token`失效场景下的重试策略)。 |
| 205 | + |
| 206 | +### 测试覆盖率(已测用例) |
| 207 | +-正常路径:4(GET×1, POST跳过, PUT, DELETE) |
| 208 | +-鉴权路径:6(401×3,403×3) |
| 209 | +-业务校验路径:4(P2-P5, U2, U3) |
| 210 | +-幂等性:1(D5重复删除) |
| 211 | + |
| 212 | +###端到端验证注意事项 |
| 213 | +- 由于 POST bug,本报告对 PUT/DELETE 使用了「MySQL手工插入 fixture → HTTP 调用 → MySQL 回读校验」的替代流程,验证的是 controller/service 对数据库中已有记录的真实行为,**未走 controller→service→mapper写入路径**。 |
| 214 | +-修复 POST 后应补一组 fixture 测试,验证「POST →立即 GET →列表 dedup正确」以及「POST →立即 DELETE → 同 announcement_id 全删」。 |
0 commit comments