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

> 由现有 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 落盘与回收导出

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

---

## 十一、双目标框架与两套安装包

### 11.1 为什么改成双目标

客户现场有一台 **Windows 7 专业版**。微软官方明确：**.NET 6 是最后一个支持 Windows 7 的版本**（.NET 7/8/9 均要求 Win10 1607+）。
而 Minimal API 恰好从 .NET 6 开始提供，所以 **.NET 6 是唯一同时满足「能跑 Win7」和「能跑这套代码」的版本**。

改造方式：一套源码编两个框架，业务代码不写 `#if`。

```xml
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
```

| 目标框架 | 用途 | 组件版本 |
|---|---|---|
| **net8.0** | Windows 10 / 11 / Server 2016+（在支持期内） | EF Core 8.0.8 / Pomelo 8.0.2 / .NET 8.0.36 |
| **net6.0** | Windows 7 SP1 / 8 / 8.1 | EF Core 6.0.36 / Pomelo 6.0.2 / .NET 6.0.36 |

### 11.2 两处框架差异，全部收在 `Compat/NetCompat.cs`

| 差异点 | .NET 8 | .NET 6 | 处理 |
|---|---|---|---|
| `IFormFile` 端点的抗伪造元数据 | `.DisableAntiforgery()` | 无此机制 | `NoAntiforgery()` 扩展方法 |
| Minimal API JSON 选项 | `ConfigureHttpJsonOptions` | 无此扩展 | `ConfigureHttpJson()` 扩展方法 |

业务代码（`Endpoints/*.cs`、`Program.cs`）只调用这两个方法，因此两侧行为一致。

### 11.3 net6.0 不编译 Migrations

`Migrations/*.Designer.cs` 用的是 Pomelo 8 的 `AutoIncrementColumns`，Pomelo 6 没有。
net6.0 目标下用 `<Compile Remove="Migrations\**\*.cs" />` 排除，
**建库改由 `sql/schema.sql` 完成**（安装程序执行）。

> schema.sql 由 `db/gen_schema.py` 从生产库 `SHOW CREATE TABLE` 生成，
> 并会自动新建临时库回灌、逐表比对「表 / 列 / 索引」后删除临时库。
> 原先那份是旧的，落后 6 个迁移（缺 `sysconfigs`、`readreceipts`、`appointdoccadres`
> 三表与 12 个字段），已重新生成并验证与生产库完全一致。

### 11.4 两套安装包

| 包 | 目录 | 适用系统 | 体积 |
|---|---|---|---|
| **.NET 8 标准包** | `rmsys-installer/` | Windows 10 / 11 / Server 2016+ | 625 MB |
| **Windows 7 专用包** | `rmsys-installer-win7/` | Windows 7 SP1 及以上 | 170 MB |

Win7 包与标准包的差异：

| 项 | 标准包 | Win7 包 |
|---|---|---|
| 后端 | .NET 8.0.36 自包含 | **.NET 6.0.36 自包含** |
| MySQL | 8.0.29（zip，安装时解压） | **5.7.38（已预解压，安装时只复制）** |
| 建库 | EF 迁移（首启自动） | **执行 `sql/schema.sql`** |
| VC++ | 2015-2022 | **2013 + 2015-2022**（5.7 依赖 `msvcr120.dll`） |
| LibreOffice | 25.8 | **暂未内置**（需 6.4.x；官方源无法稳定下载） |
| 脚本语法 | PowerShell 5.1 | **PowerShell 2.0**（Win7 自带） |

### 11.5 Win7 包的脚本为什么要按 PS 2.0 写

Windows 7 自带 PowerShell **2.0**，下列写法全部不可用，已逐项替换：

| 原写法 | 问题 | 改法 |
|---|---|---|
| `#Requires -RunAsAdministrator` | PS 4.0+，2.0 直接报语法错 | `WindowsPrincipal.IsInRole` 手工判断 |
| `$PSScriptRoot` | PS 3.0+ | `Split-Path -Parent $MyInvocation.MyCommand.Definition` |
| `tar.exe` | Win10 1803+ 才有 | MySQL 随包预解压，安装时直接复制 |
| `Expand-Archive` | PS 5.0+ | 兜底用 .NET `ZipFile` |
| `ConvertFrom-Json` / `ConvertTo-Json` | PS 3.0+ | 不改编配置，连接串只写环境变量 `RMSYS_MYSQL` |
| `Invoke-WebRequest` | PS 3.0+ | `System.Net.WebClient` |
| `Get-NetIPAddress` | PS 3.0+ 模块 | `System.Net.Dns` 解析本机地址 |

### 11.6 构建与验证命令

```bash
# 编译两个框架（0 错误；4 个警告是「net6.0 已停止支持」的提示）
dotnet build Rmsys.sln

# 发布 Win7 用后端（自包含，目标机不需要装 .NET）
dotnet publish src/Rmsys.Api -c Release -f net6.0 -r win-x64 --self-contained true -o <输出目录>

# 回归冒烟（任意已启动实例）
python3 tools/smoke.py http://127.0.0.1:5099

# 重新生成完整 schema.sql（含回灌比对）
python3 db/gen_schema.py

# 打 Win7 安装包
cd /data/www/files/rmsys-installer-win7 && python3 package.py && python3 verify_pkg.py
```

### 11.7 验证记录（本轮实测）

| 验证项 | 结果 |
|---|---|
| 双目标编译 | net6.0 + net8.0 均 0 错误 |
| net8.0 回归冒烟 | 14 项全过（探活/鉴权/权限/分页/错误语义/编码） |
| net6.0 冒烟 | 14 项全过，与 net8 行为一致 |
| **模拟全新首装**（只用 schema.sql 建库 + net6 启动） | 24 表建成 → 自动建角色6/处室3/admin → 生成 JwtKey 与初始口令 → 用初始口令登录成功 → 空库 bootstrap 正常 |
| schema.sql 与生产库比对 | 表 24/24、列、索引**完全一致** |
| Win7 安装包核对 | 38 项全过（结构/无口令泄露/PS2 语法/运行时/net6/MySQL x64/VC++2013/schema 完整性） |

### 11.8 安装脚本必须先过语法解析（教训）

**事故**：win7 包首次交付后，客户运行报

```
所在位置 …\install.ps1:544 字符: 58
+ try { Start-Process "http://127.0.0.1:$AppPort/admin.html" <<<<
缺少终止符: "。
FullyQualifiedErrorId : TerminatorExpectedAtEndOfString
```

真实坏行不在 544，而在 **537 行**——那里被重复插入了一段文字：

```powershell
Write-Host "  服务名称：Write-Host "  服务名称：  $SvcApp（后台 …）
```

字符串里的 `"` 提前闭合，引号一路串到 544 行才报错。PowerShell 在启动时**先解析整个脚本**，
所以报错时一行都没执行（好处：没留下半成品环境）。

**根因**：这个坏行在改造前就存在于 `install.ps1`（备份文件里同样有），
Win10 标准包也是同一个源，同样会挂——只是客户先在 Win7 上被 `#Requires` 挡住了，没走到这儿。

**为什么没提前发现**：我之前只用「括号/引号字符统计」判断语法，统计方法对 here-string（`@"`…`"@`）
和跨行结构会失真，漏掉了真实错误。

**现在固定做法：用 PowerShell 自己的解析器检查**

`tools/psparse/` 是一个小工具（引 NuGet 的 `System.Management.Automation`），
用 `Parser.ParseFile` 做真正的语法解析：

```bash
cd tools/psparse && dotnet build -c Release
./bin/Release/net8.0/psparse <任意 .ps1 文件…>
# 输出：✔ 语法通过  install.ps1 （3036 个词法单元）
```

**打包前必须对「从 zip 里抽出来的」脚本跑一遍**（不是对源目录跑——要验交付物本身）：

```bash
unzip -p 安装包.zip <顶层目录>/install.ps1  > /tmp/chk/install.ps1
unzip -p 安装包.zip <顶层目录>/uninstall.ps1 > /tmp/chk/uninstall.ps1
tools/psparse/bin/Release/net8.0/psparse /tmp/chk/*.ps1
```

另需检查 PowerShell 7 解析器会放过、但 5.1 会拒的语法（`??`、三元 `a ? b : c`）——
本项目两个脚本均未使用。

### 11.9 还要做「开箱校验仿真」（第二个教训）

**事故**：win7 包第二次交付后，客户运行报

```
[11:10:25] 安装失败: 安装包不完整，缺少 thirdparty 下的 MySQL 压缩包
```

**根因**：脚本开头的开箱完整性校验只认 `thirdparty\mysql-*.zip`，
而 Win7 包为了绕开「没有 `tar.exe` / `Expand-Archive`」改成了**随包预解压目录** `thirdparty\mysql\`。
等于安装程序第一步就把自己拦下了。

语法没错、结构没错、文件都齐——**是「校验逻辑」和「包的形态」不匹配**。

**固定做法：把脚本里的开箱校验抽出来，对照 zip 实际内容逐条验**

`tools/verify_preflight.py`（安装包目录里也有一份）：

```bash
python3 verify_preflight.py <安装包.zip> <顶层目录名>
```

它会：

1. 抽出所有 `Test-Path "$Src\..."` 的检查项，逐条比对包内是否存在
2. 抽出所有 `Get-ChildItem "$Src\thirdparty" -Filter '...'` 的检查项，
   并区分「必需（后面紧跟 `Fail`）」与「可选（缺失有兜底）」
3. 对带 `-or` 的二选一条件做语义断言（如「预解压目录 或 压缩包」）
4. 核对兜底必需的 `app\Rmsys.Api.exe`、`sql\schema.sql` 等

**本轮两个包的结果**：

| 包 | Test-Path 检查 | MySQL 形态 | 结果 |
|---|---|---|---|
| Win7 v1.2 | 3/3 满足 | 目录 有 / 压缩包 无 ✔ | 全部满足 |
| 标准 v1.8 | 2/2 满足 | 目录 无 / 压缩包 有 ✔ | 全部满足 |

**发布前必跑的三件套**（缺一不可）：

```bash
# 1. 语法（用 PowerShell 自己的解析器，且验 zip 里抽出的文件）
tools/psparse/bin/Release/net8.0/psparse /tmp/chk/*.ps1
# 2. 开箱校验仿真
python3 verify_preflight.py 安装包.zip 顶层目录
# 3. 结构 / 口令泄露 / 编码 / 版本
python3 verify_pkg.py
```

### 11.10 第四个坑：skip-name-resolve 导致 ERROR 1130

**现象**：安装到「创建数据库与专用账号」时报

```
ERROR 1130 (HY000): Host '127.0.0.1' is not allowed to connect to this MySQL server
```

**根因**：my.ini 里有 `skip-name-resolve`。
开启后 MySQL **不做反向解析**，客户端连 `127.0.0.1` 时服务端看到的 host 就是字面量 `127.0.0.1`；
而 `--initialize` 只创建了 `'root'@'localhost'`，两者匹配不上 → 拒绝连接。

**为什么「等待数据库就绪」是绿的**：`mysqladmin ping` 在 **Access denied 时也返回 0**
（官方文档明确：返回 0 只表示服务在跑）。所以那一步只证明了进程活着，没验证能否登录。

**修法（两个包都已改）**：

| 改动 | 说明 |
|---|---|
| my.ini 去掉 `skip-name-resolve` | 反向解析 127.0.0.1 → localhost，`'root'@'localhost'` 就能匹配 |
| 建库 SQL 同时建 `@'localhost'` 与 `@'127.0.0.1'` 两套账号 | 双保险：万一 hosts 缺条目/域策略导致反解失败也能连 |
| 写完 my.ini 后重启已运行的 MySQL 服务 | 重装场景必需，否则运行中的进程还在用旧配置 |
| 就绪检查改为「ping + 真实 `SELECT 1`」 | ping 不可靠，必须验证认证；失败时把最后一条错误带进提示 |

**教训**：`mysqladmin ping` 的退出码不能用来说明「能登录」。凡是「就绪」判断，都要用一次真实查询。

### 11.11 第五个坑：卸载脚本也有 `#Requires -RunAsAdministrator`

**现象**：Win7 上运行「卸载.bat」立即报

```
无法处理第 1 行的 "Requires" 语句，因为该语句的格式不正确。
"Requires" 语句必须为以下格式之一：-shellid / -version / -pssnapin
```

**根因**：`uninstall.ps1` 第 1 行也是 `#Requires -RunAsAdministrator`。
之前只修了 `install.ps1`，**漏了卸载脚本**。

**为什么现有验证没抓到**：`System.Management.Automation` 的解析器（PS 7）**认为这行合法**，
只有 PS 2.0 才会拒。所以语法解析通过 ≠ Win7 能跑。

**顺势查出的同类问题**：

| 位置 | 问题 | 修法 |
|---|---|---|
| uninstall.ps1 | `Get-NetFirewallRule` / `Remove-NetFirewallRule` 是 PS 3.0 的 NetSecurity 模块 | 改 `netsh advfirewall` |
| uninstall.ps1 | `SetEnvironmentVariable('RMSYS_MYSQL')` 写了两遍 | 去重 |
| uninstall.ps1 | `Log '文字' + (Join-Path …) 'Green'` 参数拼接错误 | 加括号 |
| **install.ps1** | **防火墙规则用 NetSecurity 命令 → Win7 上加不上（被 try/catch 吞掉），5099 端口实际没放行** | **改 `netsh advfirewall`** |

最后一条是**功能性缺陷**：Win7 装完后防火墙没放行 5099，局域网其他机器访问后台会被挡，
但安装过程一路绿字、看不出来。

**新增两道固定门槛**（已并入 `verify_pkg.py`，打包核对时自动跑）：

| 工具 | 作用 |
|---|---|
| `tools/lint_ps2.py` | 专扫 PS 3.0+ 专有写法：`#Requires -RunAsAdministrator`、`$PSScriptRoot`、`*-NetFirewallRule`、`Get-NetIPAddress`、`Invoke-WebRequest`、`ConvertFrom-Json`、`Expand-Archive` 等 17 条规则 |
| `tools/verify_scripts.py` | 从 **zip 里**抽出 .ps1 → 跑 lint_ps2 + PowerShell 解析器，验交付物本身 |

**教训**：PowerShell 7 解析通过，不代表 Windows 7 的 2.0 能跑。
凡是给 Win7 的脚本，必须过 `lint_ps2.py`，并且**安装和卸载两个脚本都要过**。

---

## 十二、授权（License）模块

### 12.1 设计要点

完整设计见 `docs/授权设计.md`（含第 15 节「实现说明」）。要点：

| 项 | 做法 |
|---|---|
| 绑定 | 机器码 = SM3(CPU 处理器 ID + 主板序列号 + Windows MachineGuid)，**不含硬盘/MAC** |
| 签名 | 国密 **SM2 + SM3**（BouncyCastle），payload 原始字节签名 |
| 到期判定 | **单调时间**：判定时间 = max(本机时间, 历史最大时间)，回拨无效但不锁定 |
| 锚点 | 状态文件（HMAC-SM3）+ 数据库 `sysconfigs` + 注册表，取最大值 |
| 三态 | 未激活（只显示激活页）/ 正常 / 只读（可查看导出，禁止写） |
| 续期 | 同机器码重新签发 → 导入，立即生效，不重装不动数据 |

### 12.2 代码位置

| 文件 | 职责 |
|---|---|
| `src/Rmsys.Infrastructure/Licensing/Sm2.cs` | SM2 签名/验签、SM3、HMAC-SM3、Base32 |
| `src/Rmsys.Infrastructure/Licensing/MachineCode.cs` | 机器码采集（Win7 兼容 + 取不到时沿用缓存） |
| `src/Rmsys.Infrastructure/Licensing/LicenseService.cs` | 加载/验签/三态/单调时间/锚点 |
| `src/Rmsys.Infrastructure/Licensing/BuiltinPublicKey.cs` | 内置公钥（只有公钥，私钥不进交付物） |
| `src/Rmsys.Api/Endpoints/LicenseEndpoints.cs` | 3 个接口：机器码、状态、导入 |
| `src/Rmsys.Api/Program.cs` | 授权守卫中间件 + 3 个命令行开关 |
| `src/Rmsys.LicenseTool/` | **签发工具（内部，不交付客户）** |

### 12.3 密钥管理（务必遵守）

| 项 | 位置 / 做法 |
|---|---|
| 私钥 | `/root/.rmsys/license-keys/private.txt`（**不在 nginx 根目录下，不进 Git，不进安装包**） |
| 公钥 | 已写入 `BuiltinPublicKey.cs`（kid = `LIC-2026`） |
| 备份 | 私钥必须离线备份两份，口令分开保管；丢失后无法再签发，所有客户需重新激活 |
| 轮换 | 在 `LicenseService.TrustedKeys` 追加新 kid → 公钥，旧许可证仍可验签 |

### 12.4 签发与续期

```bash
cd backend
dotnet run --project src/Rmsys.LicenseTool -c Release -- sign \
  --key /root/.rmsys/license-keys/private.txt \
  --machine HD7K-3M9P-QX2W-8TZR-5N4C \
  --customer "中共海东市委组织部" --months 12 --out license.lic
```

客户侧三个入口：后台「系统与审计 → 授权管理」、`Rmsys.Api.exe --import-license`、
`--license-status` / `--machine-code` 查状态与机器码。安装程序结束时也会直接打印机器码。

### 12.5 已知边界

| 场景 | 结果 |
|---|---|
| 拷安装包到别的机器 | 拒绝（机器码不匹配） |
| 改许可证内容 / 到期日 | 验签失败 |
| 改系统时间到过去 | 判定时间不倒退，只记告警 |
| 改系统时间到未来 > 30 天 | 忽略该跳变（防 BIOS 故障误杀），基准不推进 |
| 改状态文件 | HMAC 校验失败，锚点被丢弃并告警 |
| 改数据库锚点 | 与其余锚点不一致，取最大值 + 告警 |
| 三处锚点全删 | 判定时间仍不早于许可证签发日（签发日在签名保护内） |
| 虚拟机快照回滚 | **挡不住**（锚点随磁盘回滚），靠合同约定物理机 |
| 反编译打补丁 | 只能提高门槛 |

---

## 十三、许可证签发工具（图形界面）与台账

### 13.1 为什么把签发做成 GUI + SQLite

原先签发只有内部命令行工具（`src/Rmsys.LicenseTool`），每次都要开发人员敲命令。
现在改为交付一个 **Windows 单文件 exe**，服务方自己就能操作：

| 项 | 说明 |
|---|---|
| 程序 | `RmsysLicenseTool.exe`（win-x64 单文件，68.8 MB，**目标机不需要装 .NET**） |
| 界面 | 四个页签：① 签发许可证 / ② 许可证台账 / ③ 校验许可证 / ④ 设置 |
| 台账 | **SQLite**：`%LOCALAPPDATA%\RmsysLicense\licenses.db` |
| 配置 | `%LOCALAPPDATA%\RmsysLicense\settings.json`（记住私钥路径、签发方、输出目录） |

### 13.2 代码结构（本轮重构）

签发与验签逻辑抽到独立项目，**GUI 与服务端共用同一套实现**，不会签出两种格式：

```
src/Rmsys.Licensing.Core/          ← 不依赖 EF / MySQL，可被 GUI 引用
    Sm2.cs            SM2 签名验签、SM3、HMAC-SM3、Base32
    MachineCode.cs    机器码采集（Win7 兼容；取不到时沿用缓存）
    LicenseFile.cs    license.lic 外层与 payload 模型
    Signer.cs         组装 payload + 签名（LicenseSigner）
    Verifier.cs       验签（LicenseVerifier，含 TrustedKeys 公钥表）
    Ledger.cs         SQLite 台账（LicenseLedger / LicenseRecord）
    BuiltinPublicKey.cs  内置公钥（只放公钥）

src/Rmsys.Infrastructure/Licensing/LicenseService.cs
    服务端运行时：三态判定、单调时间、三处锚点（引用上面的 Core）

src/Rmsys.LicenseTool/            内部命令行工具（保留，便于脚本化）
src/Rmsys.LicenseTool.Gui/        WinForms 图形工具（交付给服务方）
```

> 注意：`LicenseService.VerifyContent` 现在是一行委派 `LicenseVerifier.Verify`，
> 原来重复的验签代码已删除；`TrustedKeys` 公钥表移到了 `Verifier.cs`。

### 13.3 台账表结构

```sql
create table licenses (
    id           integer primary key autoincrement,
    serial       text not null,          -- 许可证号
    machine_code text not null,          -- 授权给哪台机器
    customer     text not null default '',
    kid          text not null default '',
    not_before   text not null default '',   -- ISO 8601
    expire_at    text not null default '',
    period_text  text not null default '',   -- 「1 年」「30 天」
    issued_at    text not null default '',
    seats        integer not null default 1,
    remark       text not null default '',
    file_path    text not null default '',   -- 上次导出的 .lic 路径
    payload_hash text not null default '',   -- payload 的 SM3 摘要
    created_at   text not null default ''
);
create index ix_lic_machine on licenses(machine_code);
create index ix_lic_serial  on licenses(serial);
```

台账**不参与验签**：许可证是否有效由 SM2 验签决定，台账被改也伪造不出真许可证。
它的用途是对账、续期追溯、季度巡检（可导出 CSV）。

### 13.4 客户侧生效时机

| 导入方式 | 生效时间 |
|---|---|
| 后台「授权管理」页导入 | 立即（接口内直接刷新） |
| 命令行 `--import-license` | **下一个请求即生效**（未激活/只读态每次请求都会重新判定） |
| 手工放文件 + 重启服务 | 重启后 |

未激活页每 8 秒自动重查一次，所以命令行导入后页面会自己变为正常，不用手工刷新。

### 13.5 密钥交付（重要）

| 文件 | 位置 | 是否可公开 |
|---|---|---|
| `private.txt` | `/root/.rmsys/license-keys/`（**不在 nginx 根目录**） | **绝对不可** |
| 密钥包 zip | `/root/.rmsys/license-keys/许可证密钥包-v1.0.zip` | 不可，仅服务方保管 |
| `public.txt` | 同上（也已编入程序） | 可公开 |

私钥交付给服务方后，**必须离线备份两份、口令分开保管**：丢失则再也签不出许可证
（所有客户到期后无法续期）；泄露则任何人都能签发。详见密钥包内《密钥保管说明.txt》。

---

## 十四、2026-10-29 后台三项修复记录

### 14.1 任免表预览没有照片（真实根因：预览用的是演示数据）

**问题**：后台点「预览」看不到照片。

**根因（三层，第 1 条最严重）**：

| # | 根因 | 说明 |
|---|---|---|
| 1 | `pad.html` 零网络请求 | 它是给 PAD 用的离线页，数据由 Android 桥从 `.pkg` 读；后台 iframe 里没有桥，于是**一直渲染文件内置的演示数据**（张建国等），看到的根本不是本人那份表 |
| 2 | 部分照片路径缺 `uploads/` 前缀 | 早期导入写成 `photos/4.jpg`，实际文件在 `uploads/photos/`，或干脆没落盘 |
| 3 | 考察材料预览在浏览器里无提示地不支持 | `showPdf` 只走原生桥，无桥时显示「预览环境不支持显示原件」 |

**修复**：

- 新增 `GET /api/admin/pad-preview?meetingId=X`（`Endpoints/PadPreviewEndpoints.cs`）：
  输出结构与数据包内 `bootstrap` **完全一致**（`meeting` / `depts` / `cadres`（含 `photo` data URI）/ `materials` / `docs`），
  供 `pad.html` 嵌入模式渲染真实数据。
- `pad.html`：解析 `?access_token=`；embed 分支在**无原生桥**时先 `fetch` 该接口覆盖
  `MEETINGS/CADRES/ST.mats/ST.docs`，再走原有渲染；`ST.sel='all'` 避免处室过滤导致「未找到干部」。
- `pad.html` `showPdf`：无桥时用 `<iframe src="/api/admin/materials/{id}/preview?access_token=…">` 直接显示后端渲染的 PDF。
- 新增 `Rmsys.Infrastructure/PhotoStore.cs`：按「原路径 → 补/去 uploads 前缀」解析，防 `../` 越界；
  `LrmxExporter.LoadPhoto` 与 `PackageExporter.PhotoDataUri` 均改为调用它。
- `admin.html` `openPreview` 统一附加 `access_token`；`previewCadre` / `previewMat` 附带 `&meet=<当前会议id>`。

**遗留**：Id 4/7/9（陈晓东、孙建华、郑海燕）照片文件磁盘上确实不存在（早期导入未落盘），
按用户决定**不加照片上传入口**，后续由重新导入 `.lrmx` 自然补上（导入时照片会落到 `uploads/photos/`）。

### 14.2 考察材料批量上传（按文件名自动匹配人员）

- 「考察材料」页签新增「批量上传考察材料」按钮 → `wizUploadExam()`，复用 `batchUploadDialog` 框架。
- 新增 `matchCadreByFileName(fn)`：先按「姓名_类型」严格解析，再退化为「文件名包含姓名」，
  包含匹配取**最长姓名**（避免「张三」抢「张三丰」的文件），并容错「同志/任免审批表」等尾缀。
- 匹配不到的文件 `parse` 返回 `null` → 框架标红跳过，不会乱归档。

**踩坑提醒**：材料上传是「原地换版」（同 `CadreId+Type` 覆盖为 v(N+1)），不是插入新行。
测试后清理新记录会把旧版一起带走，需按同结构相邻记录整行恢复。已在测试中恢复 Id=37。

### 14.3 任免文件文头模板更换

| 项 | 结果 |
|---|---|
| 新模板 | `wwwroot/templates/市委常委会汇报材料模板.docx`（由客户 `.doc` 经 LibreOffice 转 docx） |
| 旧模板 | 已从下载目录移除，备份在 `backup/templates/任免文件文头模板-旧版红头通知.docx` |
| 前端 | `dlDocTemplate()` 下载文件名同步改为新名字 |
| 文种差异 | 旧=红头《关于×××等×名同志任免的通知》；新=套红《常委会会议任免干部有关情况汇报》。按客户决定**只做替换**，不保留两个按钮 |

### 14.4 发布产物的一个坑（务必注意）

`dotnet publish -o <固定目录>` **不会清理**目标目录里已被删除的文件。
本轮替换模板后首次 publish，输出目录里仍残留旧的 `任免文件文头模板.docx`，
会一起被打进安装包。**发布前必须先 `rm -rf` 输出目录**，并核对 `wwwroot/templates/` 内容。
