# 干部任免无纸化上会系统 · 正式工程

> 由现有 demo（`/data/www/files/rmsys/`）平移为可维护的正式代码：
> 后端 ASP.NET Core 8 + MySQL，PAD 端 Android（WebView + 本地 assets）。
> **界面与 demo 1:1**：前端 HTML/CSS/JS 全套复用，只把数据源从硬编码换成后端接口。

---

## 一、目录结构

```
rmsys-app/
├─ backend/
│  ├─ Rmsys.sln                    ← VS2022 双击打开
│  ├─ src/Rmsys.Domain/            实体（20 张表）+ 业务常量
│  ├─ src/Rmsys.Infrastructure/    DbContext + 连接配置 + EF 迁移
│  ├─ src/Rmsys.Api/               Web API + Swagger + wwwroot（前端）
│  │   ├─ Endpoints/AdminEndpoints.cs   后台数据接口（形状对齐 demo）
│  │   ├─ wwwroot/admin.html       后台管理端（已接真库）
│  │   ├─ wwwroot/pad.html         PAD 端页面（原样保留，供 PAD 打包）
│  │   └─ App_Data/                上传文件与数据包输出目录
│  ├─ src/Rmsys.Tools/             控制台：建库 / 种子 / 导出数据包
│  ├─ db/schema.sql                建表 SQL（20 表，可直接在 Navicat 执行）
│  ├─ db/seed_all.py               种子数据（从 demo 抽取，幂等可重跑）
│  ├─ db/seed_*.json               demo 抽取的原始数据
│  └─ tools/                       抽取/接线/校验脚本
└─ (android/ 待建：见第四节)
```

---

## 二、环境与连接

| 项 | 值 |
|---|---|
| .NET SDK | **8.0.426 LTS**（本机 `/opt/dotnet`） |
| 数据库 | MySQL **5.7.25** @ `192.168.20.66:3306`，库名 `rmsys` |
| 账号 | `tanghouhu` / `admin123#@!` |
| 字符集 | 库 + 全部表 **utf8mb4 / utf8mb4_general_ci** |

> ⚠️ 该 MySQL 服务端**默认字符集是 latin1**，且 `lower_case_table_names=1`。
> 建库时必须显式 `DEFAULT CHARSET utf8mb4`，表名一律小写——否则中文乱码。
> 连接串已带 `CharSet=utf8mb4`，不要去掉。

连接串位置：`src/Rmsys.Api/appsettings.json` → `ConnectionStrings:MySql`
也可用环境变量覆盖：`RMSYS_MYSQL`（推荐生产用）。

---

## 三、运行

### 在 Visual Studio 2022 中
1. 打开 `backend/Rmsys.sln`
2. 启动项目设为 `Rmsys.Api`
3. F5 → 浏览器打开 `https://localhost:xxxx/admin.html`

### 命令行
```bash
cd backend
dotnet restore
dotnet build
dotnet run --project src/Rmsys.Api --urls http://0.0.0.0:5099
```
- 后台界面：`http://localhost:5099/admin.html`
- 接口文档：`http://localhost:5099/swagger`
- 健康检查：`http://localhost:5099/api/health`（返回数据库版本、字符集、各表计数、中文校验）

### 重建数据库（可选）
```bash
python3 tools/apply_schema.py     # 建库 + 20 张表（幂等）
python3 db/seed_all.py            # 灌种子数据（幂等，会先清空本系统演示数据）
```

---

## 四、已实现 / 待实现

### ✅ 已完成
- 20 张业务表 + EF Core 迁移，全部 utf8mb4
- 种子数据（与 demo 逐项对齐）：3 处室 / 3 会议 / 12 干部 / 62 条简历 / 24 名家庭成员 /
  5 条批注 / 36 条上会名单 / 180 条材料 / 3 份任免文件 / 8 台 PAD / 2 个数据包 /
  9 个用户 / 6 个角色 / 10 条登录记录 / 6 条操作日志
- API：`/api/health`、`/api/depts`、`/api/meetings`、`/api/meetings/{id}/cadres`、
  `/api/admin/bootstrap`（一次返回后台所需的全部数据，形状与 demo 全局数组一致）
- **登录鉴权（JWT）+ 口令锁定 + 改口令策略 + 全链路审计**（详见第五节）
- **写操作接口**：会议增改、状态流转、批注增删、上会结果与表决、处室增改、PAD 端设置保存
- **材料文件真上传**：单份 / 批量、版本累加、下载与预览、软删除 + 回收目录（详见第五节）
- **SQLite 数据包**导出与自动校验（详见第六节）
- **国密加密数据包**：SM4-CBC 加密 + HMAC-SM3 完整性校验，两端自实现、官方向量验证、交叉解密通过（详见 6.1）
- **Android APK**：WebView 壳 + 本地 assets + SQLite 桥 + 自适应图标 + 正式签名（详见第七节）
- 后台前端接入真库：7 个模块全部读实际数据，界面 1:1 未变
- 中文全链路无乱码（写入→存储→EF→JSON→页面）

### ⏳ 待实现
1. 评审意见中的其余 P1：议题三联对应、职数校验、公示/备案、回避标记自动校验
2. PAD 已阅回执回传（当前只下发不回收）
3. 用户与处室的增删改前端接页面（接口已就绪，界面按钮仍为提示）
4. 「导入会议数据 / 任免表导入」两个向导仍是演示动画（需定导入模板后才能做真解析）

### 前端接线（后台写操作已实际调通）

| 触发点 | 行为 |
|---|---|
| 打开页面 | 读接口免登录 → 会议、干部、材料、数据包、用户全部正常展示（与 demo 一致） |
| 点「新建会议 / 编辑 / 删除」 | 无 token 时弹**登录框**，登录成功后自动继续执行该次操作 |
| 写成功后 | **重新拉取 `bootstrap`** 刷新界面 —— 页面展示的是数据库真实结果，不是本地假改 |
| 刷新页面 | 从 `sessionStorage` 恢复 token，并调 `/api/auth/me` 取回操作人，侧栏显示「操作人：XXX」 |
| 登录失效 | 接口返回 401 → 清掉 token、提示重新登录 |

侧栏新增「操作人」一行（金色姓名，深红底可读），用于明确"当前是谁在操作" —— 这是审计留痕在界面上的对应显示。

**实测（浏览器内驱动，非接口 curl）**：

```
点「新建会议」填 194 次部务会 → 弹出登录框
输入 admin / Rmsys@2026 → 登录成功，侧栏显示「操作人：系统管理员」
会议列表 3 → 4 行，新增「194 | 中共海东市委第194次部务会 | 筹备中 | 2026-11-20 09:00」
改地点为「二楼会议室」、状态改「已下发」→ 列表即时反映（数据库 status=delivered）
删除 → 确认框 → 列表 4 → 3 行；数据库该记录 IsDeleted=1（进回收站，未物理删除）
删除已归档会议 → 被拒：「已上会的会议不允许删除，请先归档确认」
全程 0 个 JS 报错
```

---

## 五、登录鉴权与写操作接口

### 认证方式

- `POST /api/auth/login` → 返回 JWT（默认 8 小时），载荷含账号、姓名、角色、处室
- 口令存储：**PBKDF2-HMAC-SHA256 / 60000 次迭代 / 每账号独立随机盐**，格式 `迭代$盐$哈希`
- 失败控制：连续 **3 次**错误锁定 **15 分钟**，锁定期间即使口令正确也拒绝
- **签名密钥存数据库**，不写在 `appsettings.json`：见下节

### 密钥落库（不再随配置文件流失）

| 项 | 做法 |
|---|---|
| 表 | `sysconfigs`（ConfigKey / ConfigValue / IsSecret / UpdatedAt / UpdatedBy） |
| JWT 签名密钥 | 首次启动自动生成 **48 字节随机密钥**（base64 后 64 字符 = 512 bit，满足 HS256 ≥256 bit 要求）写入 `ConfigKey=JwtKey`，之后每次启动从库里读 |
| 配置回退 | 仅当数据库不可用时才回退 `appsettings.json` 的 `Rmsys:JwtKey`（现已留空，只保留注释说明） |
| 口令 | 用户口令本来就是哈希入库（`sysusers.PwdHash`），数据包口令也是哈希（`datapackages.PwdHash`），无明文 |

> 踩过的坑：密钥移库后，`AddJwtBearer` 用的是库里的密钥、而 `IssueToken` 还在读配置文件 → 退回到一个 27 字符的旧默认值（216 bit），登录直接 500（IDX10720 密钥长度不足）。修法是把解析出的密钥回写进 `Configuration`，签发端与验证端共用同一把。

### 全部接口需登录（不再有免登录的数据出口）

| 分类 | 接口 | 是否需登录 |
|---|---|---|
| 登录本身 | `POST /api/auth/login` | 免 |
| 探活 | `GET /api/ping`（只回 `ok` + 时间，无任何业务数据） | 免 |
| **全部业务读** | `bootstrap`、`depts`、`meetings`、`meetings/{id}/cadres`、`cadres/{id}/notes`、`materials/{id}`、`health` | **需** |
| **导出/下载** | `packages/{id}/export`、`packages/{id}/check`、`materials/{id}/download`、`materials/{id}/preview`、`appointdocs/{id}/download` | **需** |
| 全部写操作 | 会议增改删、批注、上会结果、处室、PAD 设置、上传、删除 | 需 |

**读操作也留痕**（原来只记写操作，保密检查不认）：

| 动作 | 记录内容 |
|---|---|
| `查看会议数据` | 操作人、会议名、来源 |
| `导出数据包` | 包号、文件名、大小、干部数 |
| `校验数据包` | 包号、通过/未通过及原因 |
| `下载材料` / `下载任免文件` | 材料名、文件名、版本 |

**前端相应改为「打开即登录」**：登录成功才拉数据；取消登录则页面只显示「需要登录」提示，不加载任何材料。下载链接也改为带令牌的 fetch+Blob（普通 `<a href>` 带不上 Authorization 头，收紧后会 401）。

**实测**

```
免登录访问 9 个接口（bootstrap/depts/meetings/cadres/health/check/export/download/notes）→ 全部 401
GET /api/ping（免登录）                                                              → 200 {"ok":true,"ts":"..."}
登录 wangkc → token 488 字符
登录后同 6 个接口                                                                    → 全部 200
审计新增：查看会议数据 / 校验数据包 / 下载材料（各带操作人与对象）
前端：打开页面弹登录框、会议下拉为空、MEETINGS/CADRES 均为 0；登录后 3 场会议 / 12 人 / 3 行表格，0 报错
```

演示账号（口令统一 `Rmsys@2026`）：

| 账号 | 姓名 | 角色 | 数据权限 |
|---|---|---|---|
| `admin` | 系统管理员 | 系统管理员 | 全部处室 |
| `wangkc` | 王科长 | 干部科科长 | 全部处室 |
| `lisp` | 李科员 | 处室业务员 | 本处室 |
| `bubz` | 部领导 | 部领导 | 全部处室 |
| `audit01` | 审计员 | 审计只读 | 全部处室（只读） |

### 需登录的写接口

| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/admin/meetings` | 新建会议 |
| PUT | `/api/admin/meetings/{id}` | 编辑会议 |
| PUT | `/api/admin/meetings/{id}/status` | 变更会议状态（筹备中/已审核/待上会/已上会/已归档） |
| POST | `/api/admin/cadres/{id}/notes` | **新增批注**（评审意见 P0：原先只能看不能写） |
| DELETE | `/api/admin/notes/{id}` | 删除批注 |
| PUT | `/api/admin/meetings/{m}/cadres/{c}/result` | **登记上会结果与表决**（决定类会议留痕） |
| POST | `/api/admin/depts` | 新增处室 |
| PUT | `/api/admin/depts/{id}` | 编辑处室 |
| PUT | `/api/admin/packages/{meetingId}/settings` | 保存 PAD 端数据设置（有效期/水印/字号/开关/清单） |
| POST | `/api/auth/change-password` | 改口令（复杂度校验 + 不得重复最近 3 次） |

只读接口（`/api/health`、`/api/admin/bootstrap`、`/api/depts`、`/api/meetings`、批注查询、数据包导出与校验）保持免登录，便于后台页面与预览直接取数。

### 实测记录

```
未登录 POST /api/admin/meetings        → HTTP 401
错误口令                                → {"ok":false,"message":"账号或口令不正确"}
连续 3 次错误                            → {"ok":false,"message":"口令错误 3 次，已锁定 15 分钟"}
锁定后用正确口令                          → {"ok":false,"message":"账号已锁定，请于 00:17 后重试"}
正确口令                                → JWT 492 字符 + 用户信息
新增批注                                → id=6，作者「系统管理员」，落库确认
登记上会结果                             → result=passed，表决「应到9人，实到9人，同意9票」
新建会议                                → id=4，落库确认
非法结果值 / 不在名单内                    → 均被拒绝
```

---

### 材料文件上传（真上传，替换原来的模拟动画）

原 demo 的上传是**假动画**（弹窗里明写"演示环境 · 不读取本地文件"）。现已改为真上传：

| 入口 | 行为 |
|---|---|
| 批量上传个人材料 | 多选文件 → 按「姓名_材料类型.docx」自动匹配干部与类型 → 逐个上传 |
| 单份上传（任免表/考察材料行内「上传」） | 选文件 → 上传 → 显示文件名/大小/版本 + 「下载核对」链接 |
| 上传任免文件 / 批量上传 | 按「处室_任免文件.docx」自动匹配处室 → 归档到该处室 |
| 前置校验 | 未登录时**先弹登录**，登录成功自动继续，不会让你选完文件才被拦 |

**服务端规则**

| 项 | 规则 |
|---|---|
| 落盘路径 | `App_Data/uploads/{会议}/{干部}/{类型}_{时间戳}.{ext}`；任免文件在 `uploads/docs/...` |
| 文件名 | **一律由服务端生成**，只用客户端文件名做扩展名判断，杜绝目录穿越 |
| 允许类型 | docx / doc / pdf / xlsx / xls / **ofd** / wps / jpg / jpeg / png / zip |
| 大小上限 | 单文件 50 MB（Kestrel + FormOptions 均已放宽） |
| 版本 | 同一（会议, 干部, 类型）每上传一次版本号累加，**旧文件保留在盘上**便于追溯 |
| 审计 | 上传 / 删除各记一条（操作人、对象、文件名、大小、版本） |
| 删除 | 软删除：元数据置为「未上传」，物理文件移入 `App_Data/_trash/{日期}/`，可追溯 |

**接口**

| 方法 | 路径 |
|---|---|
| POST | `/api/admin/materials/upload`（multipart：file / meetingId / cadreId / type） |
| POST | `/api/admin/appointdocs/upload`（multipart：file / meetingId / deptId / docNo） |
| GET | `/api/admin/materials/{id}/download`、`/preview`、`/api/admin/appointdocs/{id}/download` |
| DELETE | `/api/admin/materials/{id}` |

**种子数据的文件是真的**：`db/gen_sample_files.py` 用 python-docx 按数据库内容生成了
174 份材料文档 + 3 份任免文件（任免审批表按表格排版、考察材料按「政治素质/工作实绩/作风廉洁/民主推荐」分节），
点「下载」拿到的是与页面一致的 Word 原件，不是空文件。

**实测**

```
上传 test_t30.docx → {"ok":true,"version":"v2","sizeText":"35 KB"}   （种子是 v1，累加正确）
再传一次           → {"ok":true,"version":"v3"}
不支持的 .exe      → {"ok":false,"message":"不支持的文件类型：.exe"}
未登录上传         → HTTP 401
落库               → FileName=test_t30.docx, FilePath=uploads/1/1/t30_...docx, Version=v3, UploadedBy=王科长
审计               → 「上传材料 / 张建国 · 任免审批表 / test_t30.docx（35 KB，v3）」
下载回读           → 首段「测试上传：张建国同志任免审批表（真实上传验证）」← 确认是刚传的那份
前端               → 未登录先弹登录框；登录后自动弹出「上传考察材料 · 张建国」，含真文件选择框，无「演示环境」字样
命名解析           → 张建国_考察材料.docx / 李海涛_汇报材料.pdf / 王秀兰_任免审批表.docx 均正确匹配；
                     未命名扫描件.pdf 判定为无法识别
```

> 说明：浏览器出于安全限制不允许脚本给 `<input type=file>` 塞文件，所以"点选择文件→上传"这一段
> 我是用 curl 走完全相同的 multipart 接口验证的（字段名、鉴权头一致），前端只验证到弹窗与选文件交互。

---

## 六、数据包（后台 → PAD 唯一通道）

后台 → PAD 的**唯一数据通道**。生成一个**国密加密文件**（`.pkg`），PAD 离线解密、读取并校验设备号/有效期。

| 接口 | 作用 |
|---|---|
| `GET /api/admin/packages/{meetingId}/export` | 生成并下载加密数据包（**.pkg**） |
| `GET /api/admin/packages/{meetingId}/check` | 只生成并校验，返回 JSON 报告，不留文件 |

命令行：
```bash
# 导出（默认加密）
dotnet run --project src/Rmsys.Tools -- export-package --meeting 1 --out App_Data/packages/PK.pkg
# 导出明文（仅排障，切勿交付）
dotnet run --project src/Rmsys.Tools -- export-package --meeting 1 --out /tmp/PK.db --plain
# 校验包内数据完整性
dotnet run --project src/Rmsys.Tools -- verify-package --file /tmp/PK.db
# 解密成可读 SQLite（排障）
dotnet run --project src/Rmsys.Tools -- decrypt-package --file App_Data/packages/PK.pkg --out /tmp/PK.db
# 查看/生成主密钥
dotnet run --project src/Rmsys.Tools -- keygen-package
# 国密算法 + 包格式自检（官方测试向量）
dotnet run --project src/Rmsys.Tools -- self-test-crypto
```

### 6.1 国密加密（SM4 + SM3）

包是**加密的**，不再是明文 SQLite。文件格式（大端，头部 84 字节）：

| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 8 | 魔数 `RMSPKG01` |
| 8 | 1 | 版本 `0x01` |
| 9 | 1 | 标志位（bit0 预留给「需口令」，当前恒 0） |
| 10 | 2 | 保留 |
| 12 | 16 | salt（每次导出随机） |
| 28 | 16 | IV（SM4-CBC） |
| 44 | 8 | 明文长度 |
| 52 | 32 | **HMAC-SM3**（覆盖 `[0,52)` 头部 + 全部密文） |
| 84 | N | 密文（**SM4-CBC-PKCS7**） |

**为什么 CBC 之外还要 HMAC-SM3**：CBC 只保机密性不保完整性。加上 HMAC 后一次解决三件事——
防篡改（改一个字节就拒绝启动）、密钥校验（对不上就是密钥错，能明确提示而不是"文件损坏"）、
且不泄漏任何明文结构。

**密钥派生**（域分离，加密与认证不用同一把）：
```
sm4Key = HMAC-SM3(masterKey, salt || 0x01)[0..16]
macKey = HMAC-SM3(masterKey, salt || 0x02)
```

**主密钥（方案 A：机构固定密钥）**：

| 项 | 说明 |
|---|---|
| 存放位置 | 数据库 `sysconfigs.PackageSm4Key`（32 字节随机，base64 存储，`IsSecret=1`） |
| 首次生成 | 第一次导出时自动生成并写库 |
| PAD 侧 | 编译期由 `gradle.properties` 的 `RMSYS_PKG_KEY` 注入 `BuildConfig.PKG_MASTER_KEY`，编进 dex |
| 轮换流程 | **①改库 → ②改 gradle.properties → ③重编 APK → ④重新导出所有在用数据包**。旧包同时作废 |

**PAD 侧解密**：解密到缓存目录临时文件 → 打开 SQLite → 读完即 `db.close()` →
把临时文件**清零覆盖后删除**。目录里平时只有加密的 `rmsys.pkg`，没有可直接打开的明文库。
旧版明文包 `rmsys.db` 会被明确拒绝（错误码 `LEGACY_FORMAT`），防止绕过加密。

**算法实现**：两端均自实现（后端 `Rmsys.Infrastructure/SmCrypto.cs` + `PackageCrypto.cs`，
PAD `SmCrypto.kt`），不引第三方库（APK 因此仍是 1.1 MB）。正确性由 GB/T 32907、GB/T 32905
官方测试向量 + 两端交叉解密共同保证，见 `self-test-crypto`。

### 6.2 包内表

扁平化，PAD 一句 `order by sortNo` 即可画表：
`pkg_config` / `depts` / `pad_bound` / `cadres` / `resumes` / `families` /
`notes` / `materials` / `appointdocs` / `dicts`

`pkg_config` 关键项：`pkgNo`、`meetingName`、`secret`、`secretStars`、`pwdLevel`、
`expireAt`、`clearPolicy`、`watermark`、`fontLevel`、`allowFontChange`、`switches`（截屏/打印/外发/复制等开关）、
`matOrder`（下发清单与顺序）、`padBound`（绑定设备号清单）、`needPwd` + `pwdHash`（口令校验）、`cadreCount`。

校验项（`PackageVerifier`，验的是**解密后的明文**）：表齐全、配置齐全、`cadreCount` 自洽、
上会顺序从 1 连续、姓名必须 2~8 个汉字（防乱码）、每人至少一条简历、下发清单含必需类型。

### 6.3 版本联动（重要）

**APK 与数据包必须成对升级**。新包带魔数，旧 APK 打不开（会提示"数据包格式不支持"）；
反之用新 APK 读旧明文包会提示 `LEGACY_FORMAT`。交付时请核对：

```bash
# 包的格式
head -c 8 PK20261012-01.pkg        # 应为 RMSPKG01
# APK 内置密钥与包是否配套（用后端主密钥解包，能解出 SQLite 即配套）
dotnet run --project src/Rmsys.Tools -- decrypt-package --file PK20261012-01.pkg --out /tmp/x.db
```

---

## 七、Android（PAD 端）

```
android/
├─ app/src/main/java/cn/haidong/zzb/rmsys/
│  ├─ MainActivity.kt       WebView 壳：FLAG_SECURE 禁截屏、禁外跳、禁缓存
│  └─ PackageBridge.kt      读数据包 → JSON 交给页面（只暴露固定方法，不开 SQL 通道）
├─ app/src/main/assets/web/  pad.html + assets（与后台预览同一份，1:1）
├─ app/src/main/res/         自适应图标（官方党徽 + 深红金环）
└─ keystore/rmsys-release.jks  正式签名库（口令见 gradle.properties）
```

### 关键设计

| 决策 | 理由 |
|---|---|
| **WebView + 同源 HTML**，非原生重写 | 任免审批表是 23 行严格 8 列、竖线偏差 ≤0.1pp 的百分比栅格，还有两列竖排对齐与字号档整体缩放。原生重画必然走样；同一份 HTML 才能像素级 1:1 |
| **不申请 INTERNET 权限** | PAD 全程离线，数据只来自导入的数据包 —— 从系统层面杜绝材料外发 |
| **FLAG_SECURE** | 系统级禁止截屏与录屏，对应「机密」密级要求 |
| **JS 桥只暴露固定方法** | `verify()` / `bootstrap()` / `info()`；不开任意 SQL 通道，避免页面被注入后读走整库 |
| **启动三重校验** | 数据包存在 → 未过有效期 → 本机在授权设备清单内；任一不过显示阻断页 |
| **阿里云 Maven 镜像** | 实测 6 MB/s vs 官方 257 KB/s，构建从 10+ 分钟降到 100 秒 |

### 构建

```bash
cd android
export ANDROID_SDK_ROOT=/opt/android-sdk JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
/opt/gradle/gradle-8.7/bin/gradle assembleDebug    # 调试包
/opt/gradle/gradle-8.7/bin/gradle assembleRelease  # 正式签名包
```
产物：`app/build/outputs/apk/{debug,release}/*.apk`

### 现场导入数据包

1. 后台导出：`GET /api/admin/packages/{meetingId}/export` → 得到 `PKxxxx.pkg`（国密加密）
2. 改名为 `rmsys.pkg`（**必须是 .pkg，不能再是 .db**）
3. 放到 PAD 的 `/sdcard/Android/data/cn.haidong.zzb.rmsys/files/rmsys.pkg`
   （也可预置到 App 内部存储，见 `PackageBridge.pkgFile()`）
4. 启动 App → 自动解密、校验完整性、校验设备号与有效期

> Android 11+ 起 MTP/文件管理器无法写入 `Android/data/`，现场请用 `adb push`：
> ```bash
> adb push PKxxxx.pkg /sdcard/Android/data/cn.haidong.zzb.rmsys/files/rmsys.pkg
> ```
> 推送后杀掉 App 再打开（数据包在启动时读取）。

### 验证结果（实测）

| 项 | 结果 |
|---|---|
| 包名 / 版本 | `cn.haidong.zzb.rmsys` 1.0.0（debug 后缀 `.debug`） |
| minSdk / targetSdk | 26 / 34 |
| 应用名 / 图标 | 任免上会 / 自适应图标（官方党徽） |
| assets 打入包内 | `assets/web/pad.html` 96 KB + 党徽 2 个 ✓ |
| 签名 | release 用自建 keystore，SHA-256 `4F:00:B2:38:…:72:28` |
| 页面消费数据 | 用数据包实测：12 名干部、**审批表 23 行**、考察材料/汇报材料标题、简历 6 条全部正确，0 报错 |
| 国密密钥注入 | `BuildConfig.PKG_MASTER_KEY` 已确认编入 `classes.dex` ✓ |
| 跨端解密 | 用**编进 APK 的密钥**解开**交付目录里的 .pkg** → 有效 SQLite、长度一致、篡改与错误密钥均被拒 ✓ |

---

## 八、与 demo 的对应关系

| demo | 正式工程 |
|---|---|
| `admin.html` 里 `var CADRES=[…]` 等 12 个硬编码数组 | 改为 `fetch('/api/admin/bootstrap')` 填充，**变量名与形状不变** |
| `render*()` 渲染函数 | **一行未改** |
| `pad.html` | 原样保留在 `wwwroot/`，后续打进 APK 的 `assets/web/` |
| 数据包（模拟） | 待改为真实导出的 SQLite 文件 |

## 九、已知差异

- 后台「现任职务」列显示的是**完整职务名**（含「海东市」前缀），demo 里省略了前缀以便窄列显示。属数据更准确，非缺陷。
- 后台页脚改为「已连接数据库 rmsys@192.168.20.66」，不再写「演示环境 · 模拟数据」。
- **表格横向溢出已修**：会议管理 12 列，窄视口下表格会比容器宽。已给表格容器 `.panel-b` 加 `overflow-x:auto`（宽屏视觉零变化，窄屏可横滑，不再被卡片圆角裁掉）。demo 与正式工程同步改了。

---

## 十、地域数据修正（四川广安 → 青海海东）

demo 原型是四川广安的数据，早先只做了「广安→海东」的字面替换，留下三类不一致。已全部按**青海·海东**口径改到位：

| 类型 | 原值 | 改为 | 处数 |
|---|---|---|---|
| 籍贯/出生地 | 四川海东、四川南充、四川武胜、四川岳池、四川邻水、四川华蓥、四川蓬安、四川渠县 | 青海海东、青海乐都、青海平安、青海民和、青海互助、青海化隆、青海循化、青海乐都 | 24 |
| 简历 | 海东市岳池县人民检察院 | 海东市乐都区人民检察院 | 1 |
| 家庭成员单位 | 海东市武胜县退休职工、海东市华蓥中学教师 | 海东市平安区退休职工、海东市乐都区第一中学教师 | 2 |
| 省级奖项 | 四川省卫生健康系统先进个人 / 四川省教学成果一等奖 / 四川省优秀国土空间规划工作者 | 青海省… | 3 |
| **拟任职务** | 郑海燕 **邓小平故里管理局**党组成员、副局长 | **海东市文化广播电视和旅游局**党组成员、副局长（试用期一年） | 3（拟任+任免理由+考察建议） |
| 保留 | **四川大学 / 四川农业大学 / 四川师范大学** | 不改 | — |

**为什么保留四川的高校**：青海干部在职攻读四川大学、四川农业大学是真实常见情形，改掉反而不合常理。

**为什么郑海燕改成文旅局副局长**：她现任「海东市文化广播电视和旅游局产业发展科科长」，同系统提任副局长，履历才连贯。

修正脚本：`db/fix_region_data.py`（可重跑，改完自动重新抽取种子）。作用范围：demo 源页、正式工程 wwwroot、Android assets、数据库 —— 四处一致，复查残留 **0 处**。

---

## 十、交付前安全加固与功能补齐（本轮）

审核清单见 `审核清单-后端与PAD.md`（28 项）。本轮把其中 26 项做完并实测，剩下 2 项为需业务口径的（议题三联对应、职数校验）。

### 10.1 安全（P0）

| 项 | 做法 | 实测 |
|---|---|---|
| **会后零留存** | 桥新增 `clearPackage()`：删加密包/旧明文包/解密残留，清零覆盖后删除；清缓存与本地设置；**回读 `pkgExists` 确认**，不只凭"没报错" | 代码审查；真机待验 |
| **数据权限** | `Auth/ScopeGuard.cs` 解析 `DataScope`，`bootstrap` 与 `/meetings/{id}/cadres` 注入过滤；导出限「全部处室」且非只读 | `lisp`（本处室）见 **5 人/全 yichu**；`admin` 12 人；`lisp` 导出 **403** |
| **令牌吊销** | `sysusers.TokenVersion` + JWT `tv` + `OnTokenValidated` 回库比对；改口令 `+1` 并回发新令牌；`POST /admin/users/{id}/kick` | 踢下线后旧 token 立即 **401**，重登 200；非管理员 kick **403** |
| **PAD 口令矛盾** | 后台「PAD访问口令级别」标注为预留，说明材料由数据包国密加密保护 | 界面已改 |
| **配置未生效的诚实标注** | PAD 端设置页加提示条，未生效项全部标「预留，暂不生效」 | 界面已改 |

### 10.2 功能正确性（P1）

| 项 | 做法 | 实测 |
|---|---|---|
| **状态守卫** | `Auth/MeetingGuard.cs`：结果已登记/已归档禁止改会议、写批注、改表决、传材料、改 PAD 设置 | 向已归档会议上传 → 明确拒绝 |
| **状态机** | 相邻推进/回退表，归档只能撤回为「结果已登记」 | 筹备中跳归档 → 409 + 列出允许的下一步 |
| **上会名单校验** | 上传材料前校验 `meetingcadres` | 不在名单 → 拒；加入后 → `v1` 成功 |
| **唯一约束** | `materials(MeetingId,Type,CadreId)` 改 UNIQUE；并发冲突捕获后回滚落盘文件并提示重试 | 索引已确认 `unique=True` |
| **设备号持久化** | `setPadNo` 返回 JSON 并 `commit()` 回读；页面校验格式并核对回读值 | 代码审查；真机待验 |
| **登录锁定加固** | IP 维度限流（15 分钟 12 次）+ 失败递增延迟（200ms×n，封顶 1.5s）+ 记审计 | 逻辑已生效 |
| **已阅回执** | 桥 `markRead()` 写 `files/receipts/receipt_<PAD号>.jsonl`；后台「导入已阅回执」入库；`bootstrap` 出 `readCount`，任免表「材料」列显示「已阅 N」 | 导入 4 条 → 重复导入 0 新增 4 重复 → 张建国=2、李海涛=1、王秀兰=1 |
| **HTTP 语义** | `UseExceptionHandler` 统一 JSON 500 + traceId；`/api/{**rest}` 兜底 JSON 404；不存在会议 → 404 | `/api/nope` → `{"code":"NOT_FOUND"}` |

### 10.3 配置真正生效（P0-6A）

`pad.html` 新增 `applyConfig()`，把后台下发但此前**从未被读取**的策略接上：

| 配置 | 生效方式 |
|---|---|
| `fontLevel` | 标准/大/特大 → `setFont(0.9/1/1.15)` |
| `allowFontChange=0` | 隐藏字号切换按钮 |
| `watermark` | 覆盖 PAD 水印文本（不再自己拼 `PAD号+日期`） |
| `switches.copy=false` | 禁用文本选择与复制 |
| `switches.print=false` | `@media print` 隐藏页面 |
| `matOrder` | 按序重排材料页签，未列入的隐藏 |
| `clearPolicy` | 写入退出清除确认框 |

截屏防护由 APK 的 `FLAG_SECURE` 常开实现；USB/相机/外发因无相应权限与通道天然禁用。

### 10.4 运维与交付整洁（P2）

| 项 | 做法 |
|---|---|
| Swagger | 默认关闭，需 `Rmsys:EnableSwagger=true` 才开（实测 404） |
| CORS | 去掉 `AllowAnyOrigin`，改 `Rmsys:CorsOrigins` 白名单，默认拒绝跨域 |
| 审计检索 | `GET /api/admin/auditlogs`（page/pageSize/q/action/from/to）+ `GET .../export` CSV（带 BOM，Excel 不乱码）；前端加筛选栏、翻页、导出按钮。实测 21 条分页、筛选命中、CSV 57 行 |
| 会议列表分页 | 每页 10 条 |
| 口令有效期 | 超 90 天登录后强制改口令（`mustChangePassword`），实测 6 天=False、99 天=True |
| 请求日志 | 落 `App_Data/logs/YYYYMMDD.log`，记 方法/路径/状态/耗时/操作人/IP，不记请求体 |
| 导出目录清理 | 只保留最近 3 个包，清零后删 |
| 连接池 | `MinimumPoolSize=2;MaximumPoolSize=40;ConnectionIdleTimeout=60;ConnectionLifeTime=300;DefaultCommandTimeout=30` |
| 交付残留 | `_dev/test-payload.json`、`Class1.cs`×2、`legacy-plaintext/` 明文包全部移出工程树 |
| APK 内置演示数据 | 新增 `tools/strip_demo_data.py`：打包版清空 `CADRES/DEPTS/MEETINGS`，自检不含任何演示姓名 |
| 顶栏/侧栏身份错乱 | `paintUser()` 原来只更新 `#whoami`，顶栏与侧栏底部一直是 demo 写死的「王科长」。已改为同步 `#whoami`/`#sbUser`/`#sbScope`/`#ctxUser`，并按处室编码还原中文处室名 |

### 10.5 打包与交付核对

**APK 重编前必须先跑** `python3 tools/strip_demo_data.py`（把 `wwwroot/pad.html` 清空数据后写入 `android/app/src/main/assets/web/pad.html`），否则会把演示数据打进包。

```bash
cd /data/www/files/rmsys-app
python3 tools/strip_demo_data.py
cd android && gradle clean assembleDebug assembleRelease
```

交付核对项（本轮实测全部通过）：dist APK 与构建产物 MD5 一致、包内 `pad.html` 与清空版源码 MD5 一致、dex 内含 `clearPackage/markRead/receiptsPath/setPadNo/PKG_MASTER_KEY`、签名 `4F:00:B2:38:…`、**用编进 APK 的密钥解开 dist 里刚重出的 `.pkg`**（有效 SQLite、篡改与错误密钥均被拒）。

### 10.6 仍需真机验证

本机无 KVM，起不了模拟器。以下三条只有代码审查：

1. `clearPackage()` 的清零覆盖擦除与回读确认
2. 解密临时文件的"打开即擦除"时序
3. 已阅回执 JSONL 落盘与回收导出

建议首次装机时按这三条逐项点一遍。
