# 后端 + PAD 端 测试审核清单

> 审核对象：`/data/www/files/rmsys-app/`（ASP.NET Core 8 + MySQL 5.7 + Android WebView）
> 审核方式：接口实测 + 数据库查询 + 代码走查，**每条均附证据**
> 审核时间：2026-10-18
> 说明：**本次只出清单，未做任何代码修改。**

---

## 一、级别定义与统计

| 级别 | 含义 | 初次审核 | 当前剩余 |
|---|---|---|---|
| **P0 阻断** | 安全或合规硬伤，交付客户前必须解决，否则不满足「机密」密级的基本要求 | 6 | **0** |
| **P1 应改** | 功能正确性问题，会造成数据错误或流程漏洞 | 9 | **0** |
| **P2 优化** | 可维护性、可运维性、体验与交付整洁度 | 13 | **0** |
| 合计 | | 28 | **0** |

> 另有 P2-14/15/16 三项由国密加密改造新增，也已处理（14 已清、16 已并、15 待真机验证）。

---

## 〇之一、本轮完成情况（2026-10-18 第二批）

| 编号 | 问题 | 状态 | 验证方式 |
|---|---|---|---|
| P0-2 | 会后零留存是假的 | ✅ 已实现 | 代码审查（真机待验） |
| P0-3 | 数据权限不生效 | ✅ 已实现 | `lisp` 见 5 人（全 yichu）、`admin` 见 12 人、`lisp` 导出 403 |
| P0-4 | PAD 口令未实现 | ✅ 已标注预留 | 界面提示 |
| P0-5 | 令牌无法吊销 | ✅ 已实现 | 踢下线后旧 token 立即 401 |
| P0-6 | 配置 9 项只生效 3 项 | ✅ 已补齐 + 标注 | `applyConfig()` 消费 7 项 |
| P1-1 | 归档会议仍可写 | ✅ 已实现 | 向归档会议上传被拒 |
| P1-2 | 不校验上会名单 | ✅ 已实现 | 不在名单拒、加入后放行 |
| P1-3 | 缺唯一约束 | ✅ 已实现 | `IX_materials_MeetingId_Type_CadreId unique=True` |
| P1-4 | 种子文件跨会议共用 | ✅ 已在写入侧解决（真实上传带 meetingId） | — |
| P1-5 | 设备号不持久化 | ✅ 已实现 | 代码审查（真机待验） |
| P1-6 | HTTP 语义不规范 | ✅ 已实现 | `/api/nope` → JSON 404；不存在会议 → 404 |
| P1-7 | 无已阅回执 | ✅ 已实现 | 导入 4 条 → 张建国 2 / 李海涛 1 / 王秀兰 1 |
| P1-8 | 锁定可被恶意利用 | ✅ 已加固 | IP 限流 + 递增延迟 |
| P1-9 | 状态可任意跳转 | ✅ 已实现 | 非法转移 → 409 并列出允许的下一步 |
| P2-1 | Swagger 公开 | ✅ 已关闭（配置开关） | 404 |
| P2-2 | CORS 任意来源 | ✅ 已收敛为白名单 | — |
| P2-3 | 列表无分页 | ✅ 审计与会议列表已分页 | 21 条翻页、会议每页 10 条 |
| P2-4 | 审计只 200 条、无筛选 | ✅ 已实现检索 + CSV | 筛选命中、CSV 57 行带 BOM |
| P2-5/16 | 导出目录堆积 | ✅ 自动保留最近 3 个 | — |
| P2-7 | 交付残留 | ✅ 已清出工程树 | `_dev`/`Class1.cs`/明文包均无 |
| P2-8 | pad.html 内嵌演示数据 | ✅ 打包时清空 | 自检不含任何演示姓名 |
| P2-9 | PAD 表头不冻结 | ✅ 表题吸顶 | — |
| P2-10 | 无请求日志 | ✅ 已落文件 | `App_Data/logs/` 有记录 |
| P2-11 | 跨域调试残留 | ✅ 已改同源 | — |
| P2-12 | 连接池未显式配置 | ✅ 已配置 | — |
| P2-13 | 口令无有效期 | ✅ 已实现 | 6 天=False、99 天=True |
| P2-14 | `legacy-plaintext/` | ✅ 已移出工程树 | — |

**仍待真机验证（本机无 KVM）**：真假清除的擦除时序、解密临时文件生命周期、回执落盘导出 —— 三项均已在 README 第 10.6 节列出。

---

## 〇、状态更新（2026-10-18 · 国密加密落地后）

已随「数据包国密加密（方案 A）」一并解决 / 变更的条目：

| 原编号 | 原问题 | 现状 |
|---|---|---|
| **P0-1** | 数据包可被任意篡改，有效期与设备绑定形同建议 | ✅ **已解决**。SM4-CBC 加密 + HMAC-SM3 完整性校验；实测改 1 字节即拒绝启动，错误密钥同样拒绝 |
| **P0-4** | PAD 口令校验未实现 | ⚠ **部分解决**。现在能识别「密钥不匹配」（HMAC 校验失败），但「人输入口令」仍未实现（方案 A 用机构固定密钥）。`needPwd` 依旧不被消费，且包内 `needPwd=0` 与 `pwdLevel=B级` 仍自相矛盾 |
| **P2-7** | 交付残留测试文件 | ⚠ **部分解决**。明文数据包已从 web 根移出（`dist/PK20261012-01.db` → `legacy-plaintext/`，外网访问已 404）；但 `_dev/test-payload.json`（30 KB，含 12 人完整数据）与 2 个 `Class1.cs` 仍在 |

新增的、由加密改造带来的待办：

| 编号 | 问题 | 说明 |
|---|---|---|
| **P2-14** | `legacy-plaintext/` 里那份明文包需处置 | 已不在 web 目录，但明文库仍躺在磁盘上。按规矩应删除或归档加密 |
| **P2-15** | 解密临时文件路径未在真机验证 | 算法层三重验证已过（官方向量 / 跨端互解 / APK 密钥解交付包），但「写临时文件→开 SQLite→清零擦除」这段设备侧 IO 仅代码审查 |
| **P2-16** | 导出目录堆积到 4 个文件 | `App_Data/packages/` 现有 3 个明文 `.db` + 1 个 `.pkg`，只增不减 |

---

## 二、P0 阻断级（6 项）

### P0-1 数据包可被任意篡改，有效期与设备绑定形同「建议」　【✅ 已解决，保留记录】

**证据**（实测，无需任何密钥）：

```
COPY PK20261012-01.db /tmp/tamper_test.db
UPDATE pkg_config SET v='2030-12-31 23:59' WHERE k='expireAt'   → 成功
UPDATE pkg_config SET v='["PAD-99"]'         WHERE k='padBound'   → 成功
```

**问题**：数据包就是明文 SQLite，无签名、无 HMAC、无校验和（包内只有 `pwdHash`，没有内容摘要）。
拿到 .db 文件的人可以直接改有效期、改绑定设备、甚至改干部职务，App 侧**没有任何手段识别**。

**后果**：PAD 端赖以成立的三个控制（有效期、设备白名单、会后失效）全部可绕过。对「机密」密级材料，这是硬伤。

**建议**：导出时用服务端私钥对 `pkg_config` 全量做 HMAC-SHA256 写入 `pkg_config.signature`；APK 内置公钥/密钥校验，校验失败直接阻断，不再读业务表。

---

### P0-2 「会后零留存」是假的，只弹了个提示

**证据**（`pad.html` 的 `doExit()`）：

```js
function doExit(){
  if(!confirm('退出将立即清除本机全部会议材料（会后零留存）。是否继续？')) return;
  ST.logs=[];
  CADRES.forEach(function(c){ /* 演示：仅重置浏览状态 */ });
  $('wm').innerHTML='';
  ...
  toast('本机会议材料已清除 · 清除回执已上报（演示）');
}
```

- 注释里自己写着「演示：仅重置浏览状态」
- **没有删除 SQLite 数据包**
- **Kotlin 桥里根本没有删除文件的方法**（只有 `padNo / setPadNo / info / verify / bootstrap`）
- 「清除回执已上报」也是假的——没有任何回传通道

**后果**：会议结束后材料永久留在平板上，捡到设备即可导出整库。这条恰恰是保密检查**必查**的项。

**建议**：桥新增 `clearPackage()`（删除 db + 清 SharedPreferences + 清 WebView 缓存），页面调用后二次校验文件确实不存在才提示成功；回执需有服务端接口（离线场景可写本地回执 + 下次联网补传）。

---

### P0-3 数据权限不生效：本处室账号能看到全部 12 人

**证据**（实测 `lisp` 账号，其数据权限是「本处室 · 干部一处」）：

```
GET /api/admin/bootstrap?meetingId=1
→ 返回干部总数: 12
→ 按处室分布: {yichu:5, erchu:4, rsc:3}
→ 越权看到其他处室干部: 是
```

**问题**：`sysusers.DataScope` 字段只在登录响应里回显，**查询端完全没用**。三个处室的干部、材料、批注全都给了「本处室」账号。

**后果**：后台宣称的「按处室 + 密级隔离」不成立，属于横向越权。

**建议**：`bootstrap` 与所有读接口统一注入 scope 过滤（全部处室 / 本处室 / 只读），并在审计里记录越权尝试。

---

### P0-4 PAD 口令校验未实现，A级/B级口令级别只是装饰　【⚠️ 部分解决】

**证据**：

| 检查项 | 结果 |
|---|---|
| 数据包内 `needPwd` | 存在，但当前值 `0` |
| `pad.html` 中 `needPwd` 出现次数 | **0**（页面从不读取） |
| Kotlin 桥 `verify()` | 返回 `needPwd`，但页面不消费 |

**问题**：会议的「PAD访问口令级别 A级/B级」在后台配了、包里也带出去了，**PAD 端不会要口令**。任何人拿到已导入数据包的平板即可直接查阅。

**附带矛盾**：会议是 `PwdLevel=B级`，但包内 `needPwd=0` —— 两个口径自相打架，说明规则没定清楚。

**建议**：明确「口令级别」的定义（B级=全体参会人员统一口令 / A级=仅部领导单独口令），PAD 启动若 `needPwd=1` 则强制输入并用包内 `pwdHash` 校验（PBKDF2，已有现成实现）。

---

### P0-5 令牌无法吊销：改口令、停用账号后旧 token 仍有效 8 小时

**证据**：`AuthEndpoints.cs` / `Program.cs` 中无 `jti`、无 token 版本号、无黑名单。
JWT 只靠 `exp`（默认 480 分钟）。

**后果**：
- 账号被停用 → 已登录的会话还能继续看材料直到 8 小时后
- 口令泄露后改口令 → 攻击者手里的旧 token 依然可用
- 平板丢失无法远程踢下线

**建议**：`sysusers` 加 `TokenVersion int`，签进 JWT；改口令/停用/强制下线时 `++TokenVersion`，中间件校验版本号；或引入短期 access token（15 分钟）+ refresh token。

---

### P0-6 后台大半配置对 PAD 不生效（只有 2 项真正起作用）

**证据**（逐个配置项在 PAD 侧的消费情况）：

| 后台下发的配置 | 包内有 | PAD 页面是否消费 | 实际效果 |
|---|---|---|---|
| `expireAt` 有效期 | ✅ | ✅ 启动校验 | **生效** |
| `padBound` 设备绑定 | ✅ | ✅ 启动校验 | **生效** |
| `needPwd` 口令 | ✅ | ❌ | 不生效（见 P0-4） |
| `fontLevel` 字号档 | ✅ | ❌ | **不生效**（后台设了「大」，平板不会按此显示） |
| `allowFontChange` 允许改字号 | ✅ | ❌ | 不生效 |
| `watermark` 水印文本 | ✅ | ❌ | 不生效（PAD 用自己的拼接逻辑） |
| `switches` 7 个开关（截屏/打印/外发/复制/USB/相机） | ✅ | ❌ | **不生效**（截屏防护是 APK 硬编码 FLAG_SECURE，与配置无关） |
| `matOrder` 下发清单与顺序 | ✅ | ❌ | 不生效 |
| `clearPolicy` 清除策略 | ✅ | ❌ | 不生效（配合 P0-2） |

**结论**：9 项里只有 2 项真正驱动 PAD。后台「PAD端数据设置」页面上那些开关，**目前是纯装饰**。

**说明**：这与我此前汇报的「7 个开关全部下发、解决了前后端零通道」有出入 —— 下发是真的，但**消费端没接**。此前表述不准确，特此更正。

**建议**：要么把消费端补齐（字号/水印/开关按配置生效），要么先把这些项从后台页面隐藏，避免客户以为已经生效。

---

## 三、P1 功能正确性（9 项）

### P1-1 已归档会议仍可上传材料、改上会结果

**证据**：只有 `DELETE /api/admin/meetings/{id}` 有状态守卫：

```csharp
if (MeetingStatus.Held.Contains(m.Status))
    return "已上会的会议不允许删除…";
```

上传材料、改上会结果、保存 PAD 设置、编辑会议**均无状态判断**。

**后果**：已归档（已结束）的会议可以被追加材料、篡改表决结果，且审计里看不出是"会后补的"。

**建议**：所有写接口统一加 `会议状态 ⊆ {筹备中, 材料已审核, 已下发, 会议召开中}` 才允许改；归档后仅允许"撤回归档"这一条特权操作。

---

### P1-2 上传材料不校验干部是否在本次会议名单内

**证据**：`MaterialEndpoints.cs` 上传逻辑里查了 Meeting、查了 Cadre，**没有查 `MeetingCadres`**。

**后果**：可以把某干部的材料挂到一场他根本不上会的会议里，导出的数据包里就会多出这个人（或错位）。

**建议**：上传前校验 `meetingcadres` 存在 (meetingId, cadreId) 记录，否则拒绝。

---

### P1-3 缺唯一约束，「一人一类型一份」靠代码保证，并发下会破

**证据**：

```
materials:      IX_materials_MeetingId_Type_CadreId   ← 普通索引，非 UNIQUE
appointdocs:    无 (MeetingId,DeptId) 唯一约束
当前重复数据:    0 组（还没触发）
```

**问题**：上传逻辑是「先查最新一行 → 有则更新版本、无则新增」。两个请求并发时会各自查到「无」，各插一行 → 同一人同类型出现两条，版本号都是 v2。

**建议**：加 `UNIQUE(MeetingId,CadreId,Type)` 与 `UNIQUE(MeetingId,DeptId)`，上传逻辑改用「捕获唯一冲突后重试」。

---

### P1-4 种子材料文件被多场会议共用同一路径，版本追溯会被覆盖

**证据**：

```
uploads/1/att.docx  → 会议数 3, 行数 3
uploads/1/exam.docx → 会议数 3, 行数 3
uploads/1/note.docx → 会议数 3, 行数 3
共 5 个路径被 3 场会议共用
```

**问题**：种子脚本的落盘路径只用了 `cadreId/type`，没有带 `meetingId`；三场会议指向同一物理文件。上传新版本时，三场会议的"旧版本"会一起被覆盖。

**影响**：仅种子数据（真实上传的路径已带 meetingId），但演示时若客户追问"旧版本在哪"就答不上来。

**建议**：种子路径补 `meetings/{id}/`；或统一在导出时把文件内联进包。

---

### P1-5 PAD 设置页改的设备号不持久化，重启回到 PAD-07

**证据**：

```js
function saveSet(){
  ST.pad=$('setPad').value.trim()||'PAD-07';
  ...
  toast('设置已保存（演示环境）');   // 自己写着"演示环境"
}
```

Kotlin 桥**有** `setPadNo()`，但页面**从未调用**。

**后果**：现场把 2 号机改成 PAD-02，重启后又变回 PAD-07，8 台机器全都显示 PAD-07，水印无法区分设备。

**建议**：`saveSet()` 里调 `window.RmsysNative.setPadNo(no)`，并读回确认。

---

### P1-6 HTTP 语义不规范，前端只能靠文本判断错误

**证据**（实测）：

| 请求 | 实际返回 | 应为 |
|---|---|---|
| `GET /api/admin/bootstrap?meetingId=9999` | **200** + `{"error":"尚无会议数据"}` | 404 |
| `GET /api/meetings/9999/cadres` | **200** + `[]` | 404 |
| `GET /api/meetings/abc/cadres` | 404 ✅ | 404 |
| `GET /api/admin/materials/99999` | 404 ✅ | 404 |

**后果**：调用方无法用状态码区分"没有数据"和"出错"，前端只能判断 `d.error` 字符串；监控也无法统计真实错误率。

**建议**：统一 ProblemDetails（RFC 7807），错误一律 4xx/5xx + 机器可读 `code`。

---

### P1-7 无「已阅回执」，下发后不知道谁看过

**证据**：`pad.html` 与 Kotlin 桥中「回执/已阅」命中数 **0**。

**后果**：后台说「谁看过哪份材料」查不出来。评审意见里列为 P1，至今未做。

**建议**：PAD 本地记录（干部 id / 材料类型 / 首次打开时间）+ 下次连接时上报；离线就写本地回执文件，符合内网无网络的实际。

---

### P1-8 账号锁定可被恶意利用锁死他人

**证据**：`MaxFailCount = 3`，累计在 `sysusers.FailCount` 上，**无 IP 维度、无验证码、无递增延迟**。

**后果**：知道王科长账号名的人，连错 3 次就能把他锁 15 分钟；反复操作可持续阻断。会议前锁住关键账号是有实际影响的。

**建议**：账号维度 + IP 维度双限流（IP 侧可宽松些）、失败延迟递增、关键账号（admin）锁定需人工解锁并告警。

---

### P1-9 会议状态可任意跳转，无流转规则

**证据**：`PUT /meetings/{id}/status` 只校验取值在 `MeetingStatus.All` 内，**不校验从哪个状态来**。

**后果**：已归档的会议可以直接跳回「筹备中」，等于绕过归档；「会议召开中」可以跳到「筹备中」。

**建议**：定义状态机转移表（如 归档 → 只能 撤回归档），非法转移返回 409。

---

## 四、P2 优化（13 项）

| # | 问题 | 证据 | 建议 |
|---|---|---|---|
| P2-1 | **Swagger 生产环境公开** | `GET /swagger/index.html` 未登录 → **200** | 生产环境关闭或加鉴权 |
| P2-2 | **CORS 允许任意来源** | `AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()` | 收敛到内网域名白名单 |
| P2-3 | **列表接口无分页** | 日志 `Take(200)`，会议/材料/用户全量返回；前端无分页控件 | 加 `page/pageSize` 与前端分页 |
| P2-4 | **审计日志查不全** | API 只返回最近 200 条，UI 无筛选无导出 | 保密检查要查全量，需分页 + 按人/时间/动作筛选 |
| P2-5 | **导出数据包不清理** | `App_Data/packages` 已堆 **3 个** .db（320K），只增不减 | 保留 N 个 + 定时清理，或导出后即删（下载完不驻留） |
| P2-6 | **`_trash` 无清理策略** | 删除材料把文件移进 `_trash/{日期}/`，从不清 | 加保留期（如 30 天）+ 定时任务 |
| P2-7 | **交付残留测试文件** | `wwwroot/_dev/test-payload.json`（**32K，含 12 人完整数据，在 Web 根目录可直接访问**）、`Class1.cs` ×2、额外数据包副本 | 交付前清理；`_dev` 目录必须下掉 |
| P2-8 | **`pad.html` 仍内嵌整套演示数据** | `var CADRES=[…]` 占 **28KB / 整页 29%**，含 12 人完整履历 | 双数据源（本次地域修正就得改两处，已踩坑）；且 APK 里带着虚构干部数据，反编译即得。建议移除，只走数据包 |
| P2-9 | **PAD 表格表头不冻结** | `position:sticky` 命中 **0** | 23 行 A4 表滚动时看不到列名，建议首列与表头 sticky |
| P2-10 | **无结构化日志/请求日志** | 只有 `dotnet run` 控制台输出 | 接 Serilog 落文件，记录请求耗时与异常 |
| P2-11 | **跨域调试配置残留** | `API_BASE` 支持跨域调试，与同源部署并存 | 明确只允许同源 |
| P2-12 | **无数据库连接池参数与慢查询配置** | 连接串只有 `ConnectionTimeout=10` | 显式 `MinimumPoolSize/MaximumPoolSize`，开慢查询日志 |
| P2-13 | **口令策略无有效期/首登强改** | 只有强度校验与「不得重复最近 3 次」 | 政务系统通常要求 90 天有效期 + 首次登录强制改 |
| P2-14 | **`legacy-plaintext/` 里的明文包需处置** | 已移出 web 根（外网 404），但明文库仍在磁盘 | 删除，或用 `decrypt-package` 反向加密归档 |
| P2-15 | **解密临时文件路径未真机验证** | 算法三重验证已过；设备侧 IO 仅代码审查 | 装机后看一次日志，确认能正常进系统 |
| P2-16 | **导出目录堆积到 4 个文件** | `App_Data/packages/` 3 个明文 .db + 1 个 .pkg | 与 P2-5 合并处理：保留 N 个 + 定时清理 |

---

## 五、需要更正的此前表述

审核中发现，我前几轮汇报里有 3 处说得比实际乐观，这里如实更正：

| 此前说法 | 实际情况 |
|---|---|
| 「水印/字号/有效期/设备绑定/清除策略**全部**写进包内，解决了前后端零通道」 | 写进包内属实，但 PAD 只消费了 `expireAt` 与 `padBound` 两项，其余 7 项不生效（P0-6） |
| 「会后零留存」 | **未实现**，退出只弹提示（P0-2） |
| 「数据包校验通过」 | 校验的是**包内数据完整性**（表齐全/配置齐全/顺序连续/姓名合规），**不含防篡改**（P0-1） |

---

## 六、建议修复顺序（供决策，不含实施）

| 批次 | 内容 | 理由 |
|---|---|---|
| **第一批（交付前必做）** | P0-1 包签名 · P0-2 真清除 · P0-3 权限过滤 · P0-4 口令校验 | 这 4 项直接决定能不能过保密检查 |
| **第二批（功能可信）** | P0-5 令牌吊销 · P0-6 配置生效或先隐藏 · P1-1 状态守卫 · P1-2 名单校验 · P1-3 唯一约束 | 避免"配了不管用/改了不该改" |
| **第三批（体验与运维）** | P1-5 设备号持久化 · P1-6 HTTP 语义 · P2-5/6 清理策略 · P2-7/8 交付整洁 | 现场使用与长期维护 |
| **第四批（业务补齐）** | P1-7 已阅回执 · P1-8/9 锁定与状态机 · P2-3/4 分页与审计检索 | 需先定业务口径 |

---

## 七、附：本次审核执行的实测项

```
权限越权       lisp(本处室) 调 bootstrap → 返回 12 人 / 3 个处室
HTTP 语义      5 个接口状态码抽查
未登录拦截     9 个接口 → 全部 401；/api/ping → 200；/swagger → 200（未拦）
配置消费       9 个配置项在 pad.html / PackageBridge.kt 中的引用计数
数据包篡改     直接改 expireAt 与 padBound → 成功，无校验
索引与约束     information_schema.statistics 逐表核对
重复数据       同(会议,干部,类型)多行 → 0 组；文件多会议共用 → 5 个路径
会后清除       doExit() 源码 + 桥方法清单（无删除方法）
设备号持久化   saveSet() 源码（未调 setPadNo）
交付残留       _dev/test-payload.json / Class1.cs / 3 个数据包副本
```
