feat: HSM main/slave certificate comparison with batch SQL generation
- compare usercert_tab by subject raw bytes + certtype - generate single annotated CSV report - generate merge-<timestamp>.sql in current directory for DBA execution - preserve GBK DN bytes via ATTACH + INSERT OR REPLACE / UPDATE - compatible with sqlite3 3.6.20 and JDK 1.8
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
reports/
|
||||||
|
merge-*.sql
|
||||||
|
*.db.bak-*
|
||||||
|
*.tmp
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# HSM 主备数据库证书比对工具
|
||||||
|
|
||||||
|
Java 8 命令行工具,用于比对 HSM 主库、从库 SQLite 数据库中的 `usercert_tab`,生成一份差异报表,并按人工标注的“合并选择”生成**在主库执行的批量 SQL 脚本**。
|
||||||
|
|
||||||
|
- 身份键:`subject` 原始字节 + `certtype`
|
||||||
|
- 三类差异:主库多、从库多、不一致
|
||||||
|
- 报表:单份 CSV(UTF-8 + BOM),含“合并选择”列
|
||||||
|
- 合并方式:生成 `merge-<时间戳>.sql`,由 DBA 在主库执行
|
||||||
|
- 工具本身只读源库,不直接修改任何数据库
|
||||||
|
- 脚本通过 `ATTACH` 从从库按原始字节复制,DN 中文(GBK 字节)不经过 Java 字符串
|
||||||
|
- 不生成日志文件,信息只输出到控制台
|
||||||
|
|
||||||
|
详细需求与设计见 `需求文档.md`、`设计文档.md`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
要求:JDK 8 + Maven 3.x。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn clean package
|
||||||
|
# 产物:target/cert-compare.jar(fat jar,含 sqlite-jdbc 原生库)
|
||||||
|
```
|
||||||
|
|
||||||
|
当前构建:12 个单元测试全部通过,jar 约 9.8 MB。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`config.properties`(UTF-8):
|
||||||
|
|
||||||
|
```properties
|
||||||
|
db.main=/app/cert-compare/main.db
|
||||||
|
db.slave=/app/cert-compare/slave.db
|
||||||
|
db.text.encoding=GB18030
|
||||||
|
dn.match=EXACT
|
||||||
|
sn.normalize=HEX
|
||||||
|
report.dir=/app/cert-compare/reports
|
||||||
|
report.csv.bom=true
|
||||||
|
```
|
||||||
|
|
||||||
|
相对路径以配置文件所在目录为基准。SQL 脚本不配置输出目录,直接写入运行时当前目录。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
### 交互模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Dfile.encoding=UTF-8 -jar cert-compare.jar --config config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
cert-compare> set main /app/cert-compare/main.db
|
||||||
|
cert-compare> set slave /app/cert-compare/slave.db
|
||||||
|
cert-compare> compare
|
||||||
|
cert-compare> sql --report /app/cert-compare/reports/compare-report-20261104-213307-annotated.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一次性命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成报表
|
||||||
|
java -Dfile.encoding=UTF-8 -jar cert-compare.jar compare --config config.properties
|
||||||
|
|
||||||
|
# 2. 人工标注“合并选择”后生成批量 SQL
|
||||||
|
java -Dfile.encoding=UTF-8 -jar cert-compare.jar sql \
|
||||||
|
--config config.properties \
|
||||||
|
--report /app/cert-compare/reports/compare-report-20261104-213307-annotated.csv
|
||||||
|
|
||||||
|
# 3. 由 DBA 备份主库后执行脚本
|
||||||
|
cp -p /app/cert-compare/main.db /app/cert-compare/main.db.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
sqlite3 /app/cert-compare/main.db < /app/cert-compare/merge-20261104-213307.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
`sql` 只生成脚本,不写数据库、不需要确认参数;脚本直接写入运行时的当前目录。
|
||||||
|
|
||||||
|
### 合并选择规则
|
||||||
|
|
||||||
|
| 类型 | 默认值 | 允许值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主库多 | 主 | 仅主 |
|
||||||
|
| 从库多 | 备 | 仅备 |
|
||||||
|
| 不一致 | 空 | 主 / 备 |
|
||||||
|
|
||||||
|
`sql` 生成前会重新比对并校验报表是否过期、标注是否完整;不合法时拒绝生成脚本并提示行号(退出码 5)。
|
||||||
|
|
||||||
|
## 报表
|
||||||
|
|
||||||
|
文件名:`compare-report-yyyyMMdd-HHmmss.csv`。
|
||||||
|
|
||||||
|
列为:`序号,类型,DN,DN_HEX,CN,CERTTYPE,主库SN,从库SN,主库生效,主库失效,从库生效,从库失效,主库状态,从库状态,差异字段,合并选择`。
|
||||||
|
|
||||||
|
- `合并选择` 放在最后一列,方便直接填写“主”或“备”。
|
||||||
|
- 报表不含“备注”列;日期非法、SN/DN 为空等异常在 `compare` 控制台“提示”中输出。
|
||||||
|
- `DN_HEX` 带 `HEX:` 前缀,避免 Excel 把长十六进制转为科学计数法。
|
||||||
|
- 报表可用 Excel 直接编辑,另存为 UTF-8 CSV 或 GBK CSV 均可读取。
|
||||||
|
- `sql` 只信任“合并选择”列,证书数据始终取自当前数据库。
|
||||||
|
|
||||||
|
## 生成的 SQL 脚本
|
||||||
|
|
||||||
|
- 文件名:`merge-yyyyMMdd-HHmmss.sql`,写入运行时当前目录,不覆盖历史脚本。
|
||||||
|
- `ATTACH DATABASE '<从库>' AS cert_src`,按 `hex(subject)` + `certtype` 定位。
|
||||||
|
- 从库多、不一致选备:`INSERT OR REPLACE ... SELECT`(整行复制)。
|
||||||
|
- `dn.match=TRIM` 且主备 DN 字节不同时:`UPDATE ... SET col = (SELECT ...)`,按主库字节定位。
|
||||||
|
- 主库多、不一致选主:不产生语句。
|
||||||
|
- 使用 `BEGIN IMMEDIATE` / `COMMIT` 包裹,可重复执行(幂等)。
|
||||||
|
- 末尾输出 `SELECT 'rows'` 与 `PRAGMA integrity_check` 供执行后自检。
|
||||||
|
- 脚本为 UTF-8 无 BOM,SQL 与字符串字面量全 ASCII,DN 只以十六进制出现。
|
||||||
|
- 兼容服务器 sqlite3 3.6.20。
|
||||||
|
|
||||||
|
## 测试数据
|
||||||
|
|
||||||
|
`scripts/test-data-main.sql` 与 `scripts/test-data-slave.sql` 会在原测试库中增加 3 组“不一致”数据:
|
||||||
|
|
||||||
|
| DN | CERTTYPE | 差异 | 主库 | 从库 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `CN=diff-sn-01` | 0 | SN | `0A0B0C01` | `0A0B0C11` |
|
||||||
|
| `CN=diff-time-02` | 2 | 失效时间 | `20301231235959` | `20311231235959` |
|
||||||
|
| `CN=diff-stat-03` | 5 | 状态 | `0` | `1` |
|
||||||
|
|
||||||
|
执行前请备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -p main.db main.db.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
cp -p slave.db slave.db.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
sqlite3 main.db < scripts/test-data-main.sql
|
||||||
|
sqlite3 slave.db < scripts/test-data-slave.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
当前测试库预期结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
主库 4 条,从库 15 条
|
||||||
|
一致 0、主库多 1、从库多 12、不一致 3
|
||||||
|
报表 16 条
|
||||||
|
执行脚本后主库 16 条(2 条选主、1 条选备)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp target/cert-compare.jar root@172.1.41.139:/app/cert-compare/
|
||||||
|
scp config.properties root@172.1.41.139:/app/cert-compare/
|
||||||
|
scp scripts/cert-compare.sh root@172.1.41.139:/app/cert-compare/
|
||||||
|
ssh root@172.1.41.139
|
||||||
|
cd /app/cert-compare
|
||||||
|
chmod +x cert-compare.sh
|
||||||
|
./cert-compare.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`cert-compare.sh` 已设置 `-Dfile.encoding=UTF-8 -Xmx256m`。
|
||||||
|
|
||||||
|
## 退出码
|
||||||
|
|
||||||
|
| 码 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | 成功 |
|
||||||
|
| 1 | 未预期内部错误 |
|
||||||
|
| 2 | 参数/配置错误 |
|
||||||
|
| 4 | 数据库/结构/完整性错误 |
|
||||||
|
| 5 | 报表过期、标注非法、SQL 脚本生成/写入失败 |
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# cert-compare 运行配置(UTF-8)
|
||||||
|
# 主库
|
||||||
|
db.main=/app/cert-compare/main.db
|
||||||
|
# 从库
|
||||||
|
db.slave=/app/cert-compare/slave.db
|
||||||
|
|
||||||
|
# subject/issuer 实际为 GBK/GB18030 字节
|
||||||
|
db.text.encoding=GB18030
|
||||||
|
|
||||||
|
# DN 匹配:EXACT(按原始字节)| TRIM(去除整体首尾空白)
|
||||||
|
dn.match=EXACT
|
||||||
|
|
||||||
|
# SN 归一化:HEX(大写+去前导0)| EXACT
|
||||||
|
sn.normalize=HEX
|
||||||
|
|
||||||
|
# 报表目录
|
||||||
|
report.dir=/app/cert-compare/reports
|
||||||
|
report.csv.bom=true
|
||||||
|
|
||||||
|
# 批量 SQL 脚本直接输出到运行时的当前目录(merge-<时间戳>.sql)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.sunyard</groupId>
|
||||||
|
<artifactId>cert-compare</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>cert-compare</name>
|
||||||
|
<description>HSM main/slave usercert comparison and annotated merge tool</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<maven.compiler.source>1.8</maven.compiler.source>
|
||||||
|
<maven.compiler.target>1.8</maven.compiler.target>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>3.36.0.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>4.13.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>cert-compare</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>1.8</source>
|
||||||
|
<target>1.8</target>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>com.sunyard.certcompare.CertCompareApp</mainClass>
|
||||||
|
</transformer>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
exec java -Dfile.encoding=UTF-8 -Xmx256m -jar cert-compare.jar "$@"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 在主库中增加 3 条用于测试“不一致”的证书。
|
||||||
|
-- 与 slave-test-data.sql 配合:同一 DN + certtype 在两边都存在,但字段不同。
|
||||||
|
-- 执行前请备份 main.db。
|
||||||
|
|
||||||
|
-- 1) SN 不一致:main=0A0B0C01 / slave=0A0B0C11
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-sn-01', issuer, '0A0B0C01', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20301231235959', certfile, 0, opertime, 0
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
|
|
||||||
|
-- 2) 有效期不一致:main 失效 2030 / slave 失效 2031
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-time-02', issuer, '0A0B0C02', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20301231235959', certfile, 2, opertime, 0
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
|
|
||||||
|
-- 3) 状态不一致:main stat=0 / slave stat=1
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-stat-03', issuer, '0A0B0C03', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20301231235959', certfile, 5, opertime, 0
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 在从库中增加 3 条用于测试“不一致”的证书。
|
||||||
|
-- 与 main-test-data.sql 配合:同一 DN + certtype 在两边都存在,但字段不同。
|
||||||
|
-- 执行前请备份 slave.db。
|
||||||
|
|
||||||
|
-- 1) SN 不一致:main=0A0B0C01 / slave=0A0B0C11
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-sn-01', issuer, '0A0B0C11', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20301231235959', certfile, 0, opertime, 0
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
|
|
||||||
|
-- 2) 有效期不一致:main 失效 2030 / slave 失效 2031
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-time-02', issuer, '0A0B0C02', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20311231235959', certfile, 2, opertime, 0
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
|
|
||||||
|
-- 3) 状态不一致:main stat=0 / slave stat=1
|
||||||
|
INSERT INTO usercert_tab
|
||||||
|
(organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
not_before, not_after, certfile, certtype, opertime, stat)
|
||||||
|
SELECT organid, 'CN=diff-stat-03', issuer, '0A0B0C03', pubkey_alg, pubkey, sign_alg, signinfo,
|
||||||
|
'20260101000000', '20301231235959', certfile, 5, opertime, 1
|
||||||
|
FROM usercert_tab ORDER BY rowid LIMIT 1;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行配置。属性文件使用 UTF-8,相对路径相对于属性文件所在目录解析。
|
||||||
|
*/
|
||||||
|
public class AppConfig {
|
||||||
|
|
||||||
|
public static final String DEFAULT_TEXT_ENCODING = "GB18030";
|
||||||
|
|
||||||
|
private String mainPath;
|
||||||
|
private String slavePath;
|
||||||
|
|
||||||
|
private String textEncoding = DEFAULT_TEXT_ENCODING;
|
||||||
|
private String dnMatch = "EXACT";
|
||||||
|
private String snNormalize = "HEX";
|
||||||
|
private String reportDir = "reports";
|
||||||
|
private boolean csvBom = true;
|
||||||
|
|
||||||
|
private boolean debug;
|
||||||
|
private File configFile;
|
||||||
|
|
||||||
|
public static AppConfig load(File file, boolean debug) throws UserException {
|
||||||
|
AppConfig c = new AppConfig();
|
||||||
|
c.debug = debug;
|
||||||
|
if (file != null) {
|
||||||
|
File abs = file.getAbsoluteFile();
|
||||||
|
if (!abs.isFile()) {
|
||||||
|
throw new UserException("配置文件不存在: " + abs, 2);
|
||||||
|
}
|
||||||
|
Properties p = new Properties();
|
||||||
|
try (Reader r = new InputStreamReader(new FileInputStream(abs), StandardCharsets.UTF_8)) {
|
||||||
|
p.load(r);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UserException("配置文件读取失败: " + abs + " (" + e.getMessage() + ")", 2, e);
|
||||||
|
}
|
||||||
|
c.configFile = abs;
|
||||||
|
c.mainPath = c.resolve(trim(p.getProperty("db.main")));
|
||||||
|
c.slavePath = c.resolve(trim(p.getProperty("db.slave")));
|
||||||
|
String enc = trim(p.getProperty("db.text.encoding"));
|
||||||
|
if (enc != null) {
|
||||||
|
c.textEncoding = enc;
|
||||||
|
}
|
||||||
|
String dm = trim(p.getProperty("dn.match"));
|
||||||
|
if (dm != null) {
|
||||||
|
c.dnMatch = dm;
|
||||||
|
}
|
||||||
|
String sn = trim(p.getProperty("sn.normalize"));
|
||||||
|
if (sn != null) {
|
||||||
|
c.snNormalize = sn;
|
||||||
|
}
|
||||||
|
String rd = trim(p.getProperty("report.dir"));
|
||||||
|
if (rd != null) {
|
||||||
|
c.reportDir = rd;
|
||||||
|
}
|
||||||
|
String bom = trim(p.getProperty("report.csv.bom"));
|
||||||
|
if (bom != null) {
|
||||||
|
c.csvBom = Boolean.parseBoolean(bom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.normalize();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void normalize() throws UserException {
|
||||||
|
try {
|
||||||
|
Charset.forName(textEncoding);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new UserException("不支持的文本编码: " + textEncoding, 2);
|
||||||
|
}
|
||||||
|
dnMatch = dnMatch == null ? "EXACT" : dnMatch.toUpperCase(Locale.ROOT);
|
||||||
|
snNormalize = snNormalize == null ? "HEX" : snNormalize.toUpperCase(Locale.ROOT);
|
||||||
|
if (!"EXACT".equals(dnMatch) && !"TRIM".equals(dnMatch)) {
|
||||||
|
throw new UserException("dn.match 只支持 EXACT/TRIM,当前值: " + dnMatch, 2);
|
||||||
|
}
|
||||||
|
if (!"HEX".equals(snNormalize) && !"EXACT".equals(snNormalize)) {
|
||||||
|
throw new UserException("sn.normalize 只支持 HEX/EXACT,当前值: " + snNormalize, 2);
|
||||||
|
}
|
||||||
|
if (reportDir == null || reportDir.trim().isEmpty()) {
|
||||||
|
reportDir = defaultReportDir();
|
||||||
|
} else {
|
||||||
|
reportDir = resolve(reportDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultReportDir() {
|
||||||
|
return defaultDir("reports");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultDir(String name) {
|
||||||
|
File base = configFile != null
|
||||||
|
? configFile.getParentFile()
|
||||||
|
: new File(System.getProperty("user.dir"));
|
||||||
|
return new File(base, name).getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolve(String path) {
|
||||||
|
if (path == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = path.trim();
|
||||||
|
if (t.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path p = Paths.get(t);
|
||||||
|
if (!p.isAbsolute()) {
|
||||||
|
if (configFile != null) {
|
||||||
|
p = configFile.getParentFile().toPath().resolve(p);
|
||||||
|
} else {
|
||||||
|
p = Paths.get(System.getProperty("user.dir")).resolve(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.normalize().toAbsolutePath().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMainPath() {
|
||||||
|
return mainPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMainPath(String mainPath) {
|
||||||
|
this.mainPath = new File(mainPath).getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSlavePath() {
|
||||||
|
return slavePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSlavePath(String slavePath) {
|
||||||
|
this.slavePath = new File(slavePath).getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTextEncoding() {
|
||||||
|
return textEncoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDnMatch() {
|
||||||
|
return dnMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSnNormalize() {
|
||||||
|
return snNormalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHexSn() {
|
||||||
|
return "HEX".equals(snNormalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReportDir() {
|
||||||
|
return reportDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCsvBom() {
|
||||||
|
return csvBom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDebug() {
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getConfigFile() {
|
||||||
|
return configFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Charset charset() {
|
||||||
|
return Charset.forName(textEncoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public File mainFile() {
|
||||||
|
return mainPath == null ? null : new File(mainPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public File slaveFile() {
|
||||||
|
return slavePath == null ? null : new File(slavePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public File reportDirFile() {
|
||||||
|
return new File(reportDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String v) {
|
||||||
|
if (v == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = v.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.cli.Shell;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 程序入口:交互模式与一次性命令模式。
|
||||||
|
*/
|
||||||
|
public class CertCompareApp {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
int code = new CertCompareApp().run(args);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
int run(String[] args) {
|
||||||
|
File config = null;
|
||||||
|
boolean debug = false;
|
||||||
|
List<String> rest = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < args.length; i++) {
|
||||||
|
String arg = args[i];
|
||||||
|
if ("--config".equals(arg)) {
|
||||||
|
if (++i >= args.length) {
|
||||||
|
throw new UserException("--config 需要配置文件路径", 2);
|
||||||
|
}
|
||||||
|
config = new File(args[i]);
|
||||||
|
} else if ("--debug".equals(arg)) {
|
||||||
|
debug = true;
|
||||||
|
} else if ("--help".equals(arg) || "-h".equals(arg)) {
|
||||||
|
usage(System.out);
|
||||||
|
return 0;
|
||||||
|
} else if ("--version".equals(arg)) {
|
||||||
|
System.out.println("cert-compare 1.0.0");
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
rest.add(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config == null) {
|
||||||
|
File def = new File("config.properties");
|
||||||
|
if (def.isFile()) {
|
||||||
|
config = def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig cfg = AppConfig.load(config, debug);
|
||||||
|
|
||||||
|
if (rest.isEmpty()) {
|
||||||
|
return new Shell(cfg).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
String command = rest.get(0).toLowerCase(Locale.ROOT);
|
||||||
|
if ("compare".equals(command)) {
|
||||||
|
if (rest.size() > 1) {
|
||||||
|
throw new UserException("compare 不接受参数;所有差异都会写入同一份报表", 2);
|
||||||
|
}
|
||||||
|
Operations.runCompare(cfg, System.out);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ("sql".equals(command)) {
|
||||||
|
return runSql(cfg, rest);
|
||||||
|
}
|
||||||
|
usage(System.err);
|
||||||
|
return 2;
|
||||||
|
} catch (UserException e) {
|
||||||
|
System.err.println("[错误] " + e.getMessage());
|
||||||
|
if (debug) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return e.getExitCode();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("[内部错误] " + e.getMessage());
|
||||||
|
if (debug) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int runSql(AppConfig cfg, List<String> rest) throws UserException {
|
||||||
|
File report = null;
|
||||||
|
for (int i = 1; i < rest.size(); i++) {
|
||||||
|
String arg = rest.get(i);
|
||||||
|
if ("--report".equals(arg)) {
|
||||||
|
if (++i >= rest.size()) {
|
||||||
|
throw new UserException("--report 需要报表路径", 2);
|
||||||
|
}
|
||||||
|
report = new File(rest.get(i));
|
||||||
|
} else {
|
||||||
|
throw new UserException("未知参数: " + arg, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File resolved = Operations.resolveReport(cfg, report, null);
|
||||||
|
Operations.runSql(cfg, resolved, System.out);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usage(PrintStream out) {
|
||||||
|
out.println("用法:");
|
||||||
|
out.println(" java -jar cert-compare.jar [--config <file>] [--debug]");
|
||||||
|
out.println(" java -jar cert-compare.jar compare [--config <file>]");
|
||||||
|
out.println(" java -jar cert-compare.jar sql [--config <file>] [--report <csv>]");
|
||||||
|
out.println();
|
||||||
|
out.println("交互命令: set main|slave <path> / status / compare / sql / help / quit");
|
||||||
|
out.println("说明: compare 生成一份 CSV 报表;人工填写“合并选择”(主/备)后执行 sql,");
|
||||||
|
out.println(" 在当前目录生成批量 SQL 脚本,由 DBA 在主库执行完成合并。");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.compare.CompareService;
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.MergeChoice;
|
||||||
|
import com.sunyard.certcompare.report.ReportData;
|
||||||
|
import com.sunyard.certcompare.report.ReportReader;
|
||||||
|
import com.sunyard.certcompare.report.ReportValidator;
|
||||||
|
import com.sunyard.certcompare.report.ReportWriter;
|
||||||
|
import com.sunyard.certcompare.sql.SqlPlan;
|
||||||
|
import com.sunyard.certcompare.sql.SqlScriptService;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* compare / sql 的共用流程,交互模式与一次性模式都调用这里。
|
||||||
|
*/
|
||||||
|
public final class Operations {
|
||||||
|
|
||||||
|
private Operations() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void requireMainSlave(AppConfig cfg) throws UserException {
|
||||||
|
if (cfg.getMainPath() == null) {
|
||||||
|
throw new UserException("未设置主库路径(set main 或 db.main)", 2);
|
||||||
|
}
|
||||||
|
if (cfg.getSlavePath() == null) {
|
||||||
|
throw new UserException("未设置从库路径(set slave 或 db.slave)", 2);
|
||||||
|
}
|
||||||
|
if (!cfg.mainFile().isFile()) {
|
||||||
|
throw new UserException("主库文件不存在: " + cfg.getMainPath(), 2);
|
||||||
|
}
|
||||||
|
if (!cfg.slaveFile().isFile()) {
|
||||||
|
throw new UserException("从库文件不存在: " + cfg.getSlavePath(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static File runCompare(AppConfig cfg, PrintStream out) throws UserException {
|
||||||
|
requireMainSlave(cfg);
|
||||||
|
CompareResult result = new CompareService().compare(cfg);
|
||||||
|
File report = new ReportWriter().write(cfg, result);
|
||||||
|
printCompare(cfg, result, report, out);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SqlPlan runSql(AppConfig cfg, File report, PrintStream out) throws UserException {
|
||||||
|
requireMainSlave(cfg);
|
||||||
|
if (report == null || !report.isFile()) {
|
||||||
|
throw new UserException("报表不存在: " + (report == null ? "" : report.getAbsolutePath()), 5);
|
||||||
|
}
|
||||||
|
CompareResult result = new CompareService().compare(cfg);
|
||||||
|
ReportData data = new ReportReader().read(report);
|
||||||
|
Map<CertKey, MergeChoice> choices = ReportValidator.validate(result, data);
|
||||||
|
SqlPlan plan = new SqlScriptService(cfg).generate(result, choices);
|
||||||
|
out.println();
|
||||||
|
plan.print(out);
|
||||||
|
printSqlInstructions(plan, out);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static File resolveReport(AppConfig cfg, File explicit, File last) throws UserException {
|
||||||
|
if (explicit != null) {
|
||||||
|
if (!explicit.isFile()) {
|
||||||
|
throw new UserException("报表不存在: " + explicit.getAbsolutePath(), 5);
|
||||||
|
}
|
||||||
|
return explicit.getAbsoluteFile();
|
||||||
|
}
|
||||||
|
if (last != null && last.isFile()) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
File dir = cfg.reportDirFile();
|
||||||
|
File newest = null;
|
||||||
|
File[] files = dir.listFiles();
|
||||||
|
if (files != null) {
|
||||||
|
for (File f : files) {
|
||||||
|
String name = f.getName();
|
||||||
|
if (f.isFile() && name.startsWith("compare-report-") && name.endsWith(".csv")) {
|
||||||
|
if (newest == null || f.lastModified() > newest.lastModified()) {
|
||||||
|
newest = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newest == null) {
|
||||||
|
throw new UserException("未找到报表,请先执行 compare 或使用 --report 指定", 5);
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void printCompare(AppConfig cfg, CompareResult result, File report, PrintStream out) {
|
||||||
|
out.println();
|
||||||
|
out.printf("主库: %s 证书数: %d%n", cfg.getMainPath(), result.mainCount);
|
||||||
|
out.printf("从库: %s 证书数: %d%n", cfg.getSlavePath(), result.slaveCount);
|
||||||
|
out.println();
|
||||||
|
out.println("汇总:");
|
||||||
|
out.printf(" 一致 %d%n", result.equalCount);
|
||||||
|
out.printf(" 主库多 %d%n", result.onlyMain.size());
|
||||||
|
out.printf(" 从库多 %d%n", result.onlySlave.size());
|
||||||
|
out.printf(" 不一致 %d%n", result.diff.size());
|
||||||
|
out.printf(" 差异合计 %d%n", result.differenceCount());
|
||||||
|
if (!result.warnings.isEmpty()) {
|
||||||
|
out.println();
|
||||||
|
out.println("提示:");
|
||||||
|
for (String warning : result.warnings) {
|
||||||
|
out.println(" " + warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
out.println("报表:");
|
||||||
|
out.println(" " + report.getAbsolutePath());
|
||||||
|
out.println();
|
||||||
|
out.println("请按“合并选择”列标注:主 或 备,然后执行 sql 生成批量脚本。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void printSqlInstructions(SqlPlan plan, PrintStream out) {
|
||||||
|
String main = plan.mainFile.getAbsolutePath();
|
||||||
|
out.println();
|
||||||
|
out.println("请在主库上执行(执行前先备份):");
|
||||||
|
out.printf(" cp -p %s %s.bak-$(date +%%Y%%m%%d-%%H%%M%%S)%n", main, main);
|
||||||
|
out.printf(" sqlite3 %s < %s%n", main, plan.sqlFile.getAbsolutePath());
|
||||||
|
out.println();
|
||||||
|
out.printf("执行后主库记录数应为 %d 条;脚本末尾会输出 rows 与 integrity_check 结果。%n",
|
||||||
|
plan.expectedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可预期的用户错误。携带进程退出码,CLI 直接使用。
|
||||||
|
*/
|
||||||
|
public class UserException extends Exception {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final int exitCode;
|
||||||
|
|
||||||
|
public UserException(String message) {
|
||||||
|
this(message, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserException(String message, int exitCode) {
|
||||||
|
super(message);
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserException(String message, int exitCode, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getExitCode() {
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.sunyard.certcompare.cli;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引号感知的命令行分词。
|
||||||
|
*/
|
||||||
|
public final class CommandTokenizer {
|
||||||
|
|
||||||
|
private CommandTokenizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> tokenize(String line) throws UserException {
|
||||||
|
List<String> tokens = new ArrayList<>();
|
||||||
|
if (line == null) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
StringBuilder cur = null;
|
||||||
|
boolean inSingle = false;
|
||||||
|
boolean inDouble = false;
|
||||||
|
for (int i = 0; i < line.length(); i++) {
|
||||||
|
char c = line.charAt(i);
|
||||||
|
if (inSingle) {
|
||||||
|
if (c == '\'') {
|
||||||
|
inSingle = false;
|
||||||
|
} else {
|
||||||
|
cur.append(c);
|
||||||
|
}
|
||||||
|
} else if (inDouble) {
|
||||||
|
if (c == '"') {
|
||||||
|
inDouble = false;
|
||||||
|
} else if (c == '\\' && i + 1 < line.length()) {
|
||||||
|
cur.append(line.charAt(++i));
|
||||||
|
} else {
|
||||||
|
cur.append(c);
|
||||||
|
}
|
||||||
|
} else if (c == '\'') {
|
||||||
|
if (cur == null) {
|
||||||
|
cur = new StringBuilder();
|
||||||
|
}
|
||||||
|
inSingle = true;
|
||||||
|
} else if (c == '"') {
|
||||||
|
if (cur == null) {
|
||||||
|
cur = new StringBuilder();
|
||||||
|
}
|
||||||
|
inDouble = true;
|
||||||
|
} else if (Character.isWhitespace(c)) {
|
||||||
|
if (cur != null) {
|
||||||
|
tokens.add(cur.toString());
|
||||||
|
cur = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (cur == null) {
|
||||||
|
cur = new StringBuilder();
|
||||||
|
}
|
||||||
|
cur.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inSingle || inDouble) {
|
||||||
|
throw new UserException("引号未闭合", 2);
|
||||||
|
}
|
||||||
|
if (cur != null) {
|
||||||
|
tokens.add(cur.toString());
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package com.sunyard.certcompare.cli;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.AppConfig;
|
||||||
|
import com.sunyard.certcompare.Operations;
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.db.DbManager;
|
||||||
|
import com.sunyard.certcompare.sql.SqlPlan;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式 Shell。
|
||||||
|
*/
|
||||||
|
public class Shell {
|
||||||
|
|
||||||
|
private final AppConfig cfg;
|
||||||
|
private final BufferedReader reader;
|
||||||
|
private final PrintStream out;
|
||||||
|
private final PrintStream err;
|
||||||
|
|
||||||
|
private File lastReport;
|
||||||
|
private SqlPlan lastSql;
|
||||||
|
|
||||||
|
public Shell(AppConfig cfg) {
|
||||||
|
this(cfg, System.in, System.out, System.err);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Shell(AppConfig cfg, InputStream in, PrintStream out, PrintStream err) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||||
|
this.out = out;
|
||||||
|
this.err = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int run() {
|
||||||
|
boolean tty = System.console() != null;
|
||||||
|
if (tty) {
|
||||||
|
out.println("HSM 主备证书比对工具(cert-compare 1.0.0)");
|
||||||
|
out.println("输入 help 查看命令,quit 退出。");
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
if (tty) {
|
||||||
|
out.print("cert-compare> ");
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
String line;
|
||||||
|
try {
|
||||||
|
line = reader.readLine();
|
||||||
|
} catch (IOException e) {
|
||||||
|
err.println("[错误] 读取输入失败: " + e.getMessage());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (line == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (line.trim().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean keepGoing;
|
||||||
|
try {
|
||||||
|
keepGoing = handle(line);
|
||||||
|
} catch (UserException e) {
|
||||||
|
err.println("[错误] " + e.getMessage());
|
||||||
|
if (cfg.isDebug()) {
|
||||||
|
e.printStackTrace(err);
|
||||||
|
}
|
||||||
|
keepGoing = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
err.println("[内部错误] " + e.getMessage());
|
||||||
|
if (cfg.isDebug()) {
|
||||||
|
e.printStackTrace(err);
|
||||||
|
}
|
||||||
|
keepGoing = true;
|
||||||
|
}
|
||||||
|
if (!keepGoing) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean handle(String line) throws UserException {
|
||||||
|
List<String> tokens = CommandTokenizer.tokenize(line);
|
||||||
|
if (tokens.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String cmd = tokens.get(0).toLowerCase(Locale.ROOT);
|
||||||
|
switch (cmd) {
|
||||||
|
case "quit":
|
||||||
|
case "exit":
|
||||||
|
return false;
|
||||||
|
case "help":
|
||||||
|
help(tokens.size() > 1 ? tokens.get(1).toLowerCase(Locale.ROOT) : null);
|
||||||
|
return true;
|
||||||
|
case "set":
|
||||||
|
set(tokens);
|
||||||
|
return true;
|
||||||
|
case "status":
|
||||||
|
status();
|
||||||
|
return true;
|
||||||
|
case "compare":
|
||||||
|
doCompare(tokens);
|
||||||
|
return true;
|
||||||
|
case "sql":
|
||||||
|
doSql(tokens);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
err.println("[错误] 未知命令: " + cmd + ",输入 help 查看可用命令。");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void set(List<String> tokens) throws UserException {
|
||||||
|
if (tokens.size() < 2) {
|
||||||
|
throw new UserException("用法: set main|slave [path]", 2);
|
||||||
|
}
|
||||||
|
String what = tokens.get(1).toLowerCase(Locale.ROOT);
|
||||||
|
if (!"main".equals(what) && !"slave".equals(what)) {
|
||||||
|
throw new UserException("set 只支持 main / slave", 2);
|
||||||
|
}
|
||||||
|
if (tokens.size() == 2) {
|
||||||
|
showSetting(what);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String path = String.join(" ", tokens.subList(2, tokens.size()));
|
||||||
|
setDb(what, new File(path).getAbsoluteFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showSetting(String what) {
|
||||||
|
if ("main".equals(what)) {
|
||||||
|
printDb("主库", cfg.mainFile());
|
||||||
|
} else {
|
||||||
|
printDb("从库", cfg.slaveFile());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDb(String what, File file) throws UserException {
|
||||||
|
long count = DbManager.validateAndCount(file);
|
||||||
|
String label;
|
||||||
|
if ("main".equals(what)) {
|
||||||
|
cfg.setMainPath(file.getPath());
|
||||||
|
label = "主库";
|
||||||
|
} else {
|
||||||
|
cfg.setSlavePath(file.getPath());
|
||||||
|
label = "从库";
|
||||||
|
}
|
||||||
|
out.printf("[OK] %s: %s (%,d 字节, %d 张证书)%n",
|
||||||
|
label, file.getAbsolutePath(), file.length(), count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doCompare(List<String> tokens) throws UserException {
|
||||||
|
if (tokens.size() > 1) {
|
||||||
|
throw new UserException("compare 不接受参数;所有差异都会写入同一份报表", 2);
|
||||||
|
}
|
||||||
|
lastReport = Operations.runCompare(cfg, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doSql(List<String> tokens) throws UserException {
|
||||||
|
File explicit = null;
|
||||||
|
for (int i = 1; i < tokens.size(); i++) {
|
||||||
|
String arg = tokens.get(i);
|
||||||
|
if ("--report".equals(arg)) {
|
||||||
|
if (i + 1 >= tokens.size()) {
|
||||||
|
throw new UserException("--report 需要报表路径", 2);
|
||||||
|
}
|
||||||
|
explicit = new File(tokens.get(++i));
|
||||||
|
} else {
|
||||||
|
throw new UserException("未知参数: " + arg, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File report = Operations.resolveReport(cfg, explicit, lastReport);
|
||||||
|
lastSql = Operations.runSql(cfg, report, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void status() {
|
||||||
|
out.println("当前状态:");
|
||||||
|
printDb("主库", cfg.mainFile());
|
||||||
|
printDb("从库", cfg.slaveFile());
|
||||||
|
out.println("SQL 输出: " + System.getProperty("user.dir"));
|
||||||
|
out.printf("编码: %s DN 匹配: %s SN 归一化: %s%n",
|
||||||
|
cfg.getTextEncoding(), cfg.getDnMatch(), cfg.getSnNormalize());
|
||||||
|
out.println("主从表结构: " + schemaCompatibility());
|
||||||
|
out.println("最近报表: " + (lastReport == null ? "无" : lastReport.getAbsolutePath()));
|
||||||
|
if (lastSql == null) {
|
||||||
|
out.println("最近 SQL: 无");
|
||||||
|
} else {
|
||||||
|
out.println("最近 SQL: " + lastSql.sqlFile.getAbsolutePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void printDb(String label, File file) {
|
||||||
|
if (file == null) {
|
||||||
|
out.printf(" %-6s 未设置%n", label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String path = file.getAbsolutePath();
|
||||||
|
if (!file.exists()) {
|
||||||
|
out.printf(" %-6s %s(不存在)%n", label, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long count = -1;
|
||||||
|
try (Connection c = DbManager.open(file.getPath(), true)) {
|
||||||
|
if (DbManager.tableExists(c, "usercert_tab")) {
|
||||||
|
count = DbManager.count(c);
|
||||||
|
}
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
// 仅展示大小
|
||||||
|
}
|
||||||
|
out.printf(" %-6s %s(%,d 字节%s)%n", label, path, file.length(),
|
||||||
|
count >= 0 ? ", " + count + " 张证书" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String schemaCompatibility() {
|
||||||
|
if (cfg.getMainPath() == null || cfg.getSlavePath() == null) {
|
||||||
|
return "未设置";
|
||||||
|
}
|
||||||
|
try (Connection main = DbManager.open(cfg.getMainPath(), true);
|
||||||
|
Connection slave = DbManager.open(cfg.getSlavePath(), true)) {
|
||||||
|
if (!DbManager.tableExists(main, "usercert_tab")
|
||||||
|
|| !DbManager.tableExists(slave, "usercert_tab")) {
|
||||||
|
return "缺少 usercert_tab";
|
||||||
|
}
|
||||||
|
return DbManager.columns(main).equals(DbManager.columns(slave)) ? "一致" : "不一致";
|
||||||
|
} catch (SQLException e) {
|
||||||
|
return "检查失败: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void help(String command) {
|
||||||
|
if (command == null) {
|
||||||
|
out.println("可用命令:");
|
||||||
|
out.println(" set main|slave [path] 设置/查看主库、从库");
|
||||||
|
out.println(" status 查看当前状态");
|
||||||
|
out.println(" compare 生成差异报表(主库多 / 从库多 / 不一致)");
|
||||||
|
out.println(" sql [--report <path>] 读取标注后的报表,生成批量 SQL 脚本");
|
||||||
|
out.println(" help [command] 帮助");
|
||||||
|
out.println(" quit | exit 退出");
|
||||||
|
out.println();
|
||||||
|
out.println("示例:");
|
||||||
|
out.println(" set main /app/cert-compare/main.db");
|
||||||
|
out.println(" set slave /app/cert-compare/slave.db");
|
||||||
|
out.println(" compare");
|
||||||
|
out.println(" sql --report /app/cert-compare/reports/compare-report-20261103-205500.csv");
|
||||||
|
out.println(" 然后在主库上执行当前目录下生成的 merge-<时间戳>.sql");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (command) {
|
||||||
|
case "set":
|
||||||
|
out.println("set main <path> 设置主库");
|
||||||
|
out.println("set slave <path> 设置从库");
|
||||||
|
out.println("无参数时显示当前设置。");
|
||||||
|
break;
|
||||||
|
case "compare":
|
||||||
|
out.println("compare");
|
||||||
|
out.println(" 比对主库和从库,生成一份 CSV 报表,只打印汇总统计。");
|
||||||
|
out.println(" 报表中“合并选择”列由人工填写:主 或 备。");
|
||||||
|
break;
|
||||||
|
case "sql":
|
||||||
|
out.println("sql [--report <path>]");
|
||||||
|
out.println(" 读取已标注报表,校验后生成在主库上执行的批量 SQL 脚本。");
|
||||||
|
out.println(" --report 指定报表;缺省使用最近一次 compare 的报表");
|
||||||
|
out.println(" 脚本生成到当前目录:merge-<时间戳>.sql。");
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
out.println("status");
|
||||||
|
out.println(" 显示主库、从库、当前工作目录、表结构兼容性。");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
out.println("未知命令: " + command + ",输入 help 查看可用命令。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.sunyard.certcompare.compare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.AppConfig;
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.db.DbManager;
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
import com.sunyard.certcompare.model.CertRecord;
|
||||||
|
import com.sunyard.certcompare.model.CompareCategory;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.CompareRow;
|
||||||
|
import com.sunyard.certcompare.model.FieldDiff;
|
||||||
|
import com.sunyard.certcompare.model.MergeChoice;
|
||||||
|
import com.sunyard.certcompare.util.DateUtil;
|
||||||
|
import com.sunyard.certcompare.util.SnNormalizer;
|
||||||
|
import com.sunyard.certcompare.util.Text;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主备库比对引擎。以 (subject 原始字节, certtype) 为身份键。
|
||||||
|
*/
|
||||||
|
public class CompareService {
|
||||||
|
|
||||||
|
public CompareResult compare(AppConfig cfg) throws UserException {
|
||||||
|
File main = cfg.mainFile();
|
||||||
|
File slave = cfg.slaveFile();
|
||||||
|
if (main == null || slave == null) {
|
||||||
|
throw new UserException("请先设置主库和从库路径(set main / set slave)", 2);
|
||||||
|
}
|
||||||
|
DbManager.validateFile(main);
|
||||||
|
DbManager.validateFile(slave);
|
||||||
|
|
||||||
|
try (Connection cm = DbManager.open(main.getPath(), true);
|
||||||
|
Connection cs = DbManager.open(slave.getPath(), true)) {
|
||||||
|
DbManager.validateSchema(cm);
|
||||||
|
DbManager.validateSchema(cs);
|
||||||
|
List<CertRecord> mainRecords = DbManager.readAll(cm);
|
||||||
|
List<CertRecord> slaveRecords = DbManager.readAll(cs);
|
||||||
|
return compareRecords(cfg, mainRecords, slaveRecords);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UserException("读取数据库失败: " + e.getMessage(), 4, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompareResult compareRecords(AppConfig cfg, List<CertRecord> mainRecords,
|
||||||
|
List<CertRecord> slaveRecords) throws UserException {
|
||||||
|
boolean trim = "TRIM".equals(cfg.getDnMatch());
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
|
||||||
|
Map<CertKey, CertRecord> mainMap = index(mainRecords, trim, "主库", warnings);
|
||||||
|
Map<CertKey, CertRecord> slaveMap = index(slaveRecords, trim, "从库", warnings);
|
||||||
|
|
||||||
|
Map<CertKey, List<String>> notes = new HashMap<>();
|
||||||
|
collectDataIssues(mainRecords, trim, "主库", cfg.isHexSn(), notes, warnings);
|
||||||
|
collectDataIssues(slaveRecords, trim, "从库", cfg.isHexSn(), notes, warnings);
|
||||||
|
|
||||||
|
Set<CertKey> keys = new TreeSet<>();
|
||||||
|
keys.addAll(mainMap.keySet());
|
||||||
|
keys.addAll(slaveMap.keySet());
|
||||||
|
|
||||||
|
Charset charset = cfg.charset();
|
||||||
|
List<CompareRow> onlyMain = new ArrayList<>();
|
||||||
|
List<CompareRow> onlySlave = new ArrayList<>();
|
||||||
|
List<CompareRow> diff = new ArrayList<>();
|
||||||
|
int equalCount = 0;
|
||||||
|
|
||||||
|
for (CertKey key : keys) {
|
||||||
|
CertRecord m = mainMap.get(key);
|
||||||
|
CertRecord s = slaveMap.get(key);
|
||||||
|
if (m != null && s == null) {
|
||||||
|
List<String> rowNotes = new ArrayList<>(notesOf(notes, key));
|
||||||
|
rowNotes.add("仅主库存在");
|
||||||
|
onlyMain.add(new CompareRow(CompareCategory.ONLY_MAIN, key, m, null,
|
||||||
|
Collections.<FieldDiff>emptyList(), rowNotes, MergeChoice.MAIN.label, decodeDn(m.subjectHex, charset)));
|
||||||
|
} else if (m == null && s != null) {
|
||||||
|
List<String> rowNotes = new ArrayList<>(notesOf(notes, key));
|
||||||
|
rowNotes.add("仅从库存在");
|
||||||
|
onlySlave.add(new CompareRow(CompareCategory.ONLY_SLAVE, key, null, s,
|
||||||
|
Collections.<FieldDiff>emptyList(), rowNotes, MergeChoice.SLAVE.label, decodeDn(s.subjectHex, charset)));
|
||||||
|
} else {
|
||||||
|
List<FieldDiff> diffs = diffFields(cfg, m, s);
|
||||||
|
if (diffs.isEmpty()) {
|
||||||
|
equalCount++;
|
||||||
|
} else {
|
||||||
|
List<String> rowNotes = new ArrayList<>(notesOf(notes, key));
|
||||||
|
rowNotes.add("需人工选择");
|
||||||
|
diff.add(new CompareRow(CompareCategory.DIFF, key, m, s, diffs, rowNotes, "",
|
||||||
|
decodeDn(m.subjectHex, charset)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CompareResult(mainRecords.size(), slaveRecords.size(), equalCount,
|
||||||
|
onlyMain, onlySlave, diff, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<CertKey, CertRecord> index(List<CertRecord> records, boolean trim,
|
||||||
|
String side, List<String> warnings) throws UserException {
|
||||||
|
Map<CertKey, CertRecord> map = new LinkedHashMap<>();
|
||||||
|
for (CertRecord record : records) {
|
||||||
|
CertKey key = new CertKey(CertKey.normalizeSubjectHex(record.subjectHex, trim), record.certtype);
|
||||||
|
CertRecord previous = map.put(key, record);
|
||||||
|
if (previous != null) {
|
||||||
|
throw new UserException(side + "存在重复身份键(规范化后): " + key
|
||||||
|
+ ",请将 dn.match 设置为 EXACT 后重试", 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectDataIssues(List<CertRecord> records, boolean trim, String side,
|
||||||
|
boolean hexSn, Map<CertKey, List<String>> notes,
|
||||||
|
List<String> warnings) {
|
||||||
|
Map<String, Integer> snCounts = new LinkedHashMap<>();
|
||||||
|
for (CertRecord r : records) {
|
||||||
|
String sn = SnNormalizer.normalize(r.sn, hexSn);
|
||||||
|
if (!sn.isEmpty()) {
|
||||||
|
snCounts.put(sn, snCounts.getOrDefault(sn, 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Integer> e : snCounts.entrySet()) {
|
||||||
|
if (e.getValue() > 1) {
|
||||||
|
warnings.add(side + "存在重复 SN: " + e.getKey() + "(" + e.getValue() + " 条)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (CertRecord r : records) {
|
||||||
|
CertKey key = new CertKey(CertKey.normalizeSubjectHex(r.subjectHex, trim), r.certtype);
|
||||||
|
List<String> rowNotes = notes.computeIfAbsent(key, k -> new ArrayList<>());
|
||||||
|
String where = side + " HEX:" + key.subjectHex + " CERTTYPE=" + key.certtype + " ";
|
||||||
|
if (!DateUtil.isValid(r.notBefore) || !DateUtil.isValid(r.notAfter)) {
|
||||||
|
addOnce(rowNotes, side + "日期格式非法");
|
||||||
|
addOnce(warnings, where + "日期格式非法");
|
||||||
|
} else if (!DateUtil.beforeOrEqual(r.notBefore, r.notAfter)) {
|
||||||
|
addOnce(rowNotes, side + "有效期倒置");
|
||||||
|
addOnce(warnings, where + "有效期倒置");
|
||||||
|
}
|
||||||
|
String sn = SnNormalizer.normalize(r.sn, hexSn);
|
||||||
|
if (snCounts.getOrDefault(sn, 0) > 1) {
|
||||||
|
addOnce(rowNotes, side + "SN重复:" + sn);
|
||||||
|
}
|
||||||
|
if (sn.isEmpty()) {
|
||||||
|
addOnce(rowNotes, side + "SN为空");
|
||||||
|
addOnce(warnings, where + "SN为空");
|
||||||
|
}
|
||||||
|
if (r.subjectHex.isEmpty()) {
|
||||||
|
addOnce(rowNotes, side + "DN为空");
|
||||||
|
addOnce(warnings, where + "DN为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FieldDiff> diffFields(AppConfig cfg, CertRecord m, CertRecord s) {
|
||||||
|
List<FieldDiff> diffs = new ArrayList<>();
|
||||||
|
String mainSn = SnNormalizer.normalize(m.sn, cfg.isHexSn());
|
||||||
|
String slaveSn = SnNormalizer.normalize(s.sn, cfg.isHexSn());
|
||||||
|
if (!Objects.equals(mainSn, slaveSn)) {
|
||||||
|
diffs.add(new FieldDiff("sn", m.sn, s.sn));
|
||||||
|
}
|
||||||
|
if (!Objects.equals(m.issuerHex, s.issuerHex)) {
|
||||||
|
diffs.add(new FieldDiff("issuer", m.issuerHex, s.issuerHex));
|
||||||
|
}
|
||||||
|
if (!Objects.equals(m.notBefore, s.notBefore)) {
|
||||||
|
diffs.add(new FieldDiff("not_before", m.notBefore, s.notBefore));
|
||||||
|
}
|
||||||
|
if (!Objects.equals(m.notAfter, s.notAfter)) {
|
||||||
|
diffs.add(new FieldDiff("not_after", m.notAfter, s.notAfter));
|
||||||
|
}
|
||||||
|
if (m.stat != s.stat) {
|
||||||
|
diffs.add(new FieldDiff("stat", String.valueOf(m.stat), String.valueOf(s.stat)));
|
||||||
|
}
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> notesOf(Map<CertKey, List<String>> notes, CertKey key) {
|
||||||
|
List<String> list = notes.get(key);
|
||||||
|
return list == null ? Collections.<String>emptyList() : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addOnce(List<String> list, String value) {
|
||||||
|
if (!list.contains(value)) {
|
||||||
|
list.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decodeDn(String hex, Charset charset) {
|
||||||
|
try {
|
||||||
|
return Text.decode(Text.hexToBytes(hex), charset);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return "HEX:" + hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.sunyard.certcompare.db;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.model.CertRecord;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite 连接、结构校验、投影读取与 SQL 字面量工具。
|
||||||
|
*/
|
||||||
|
public final class DbManager {
|
||||||
|
|
||||||
|
public static final List<String> REQUIRED_COLUMNS = Collections.unmodifiableList(Arrays.asList(
|
||||||
|
"organid", "subject", "issuer", "sn", "pubkey_alg", "pubkey", "sign_alg", "signinfo",
|
||||||
|
"not_before", "not_after", "certfile", "certtype", "opertime", "stat"));
|
||||||
|
|
||||||
|
public static final List<String> COPY_COLUMNS = Collections.unmodifiableList(Arrays.asList(
|
||||||
|
"organid", "subject", "issuer", "sn", "pubkey_alg", "pubkey", "sign_alg", "signinfo",
|
||||||
|
"not_before", "not_after", "certfile", "certtype", "opertime", "stat"));
|
||||||
|
|
||||||
|
private static final byte[] SQLITE_HEADER =
|
||||||
|
"SQLite format 3\u0000".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
private DbManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
Class.forName("org.sqlite.JDBC");
|
||||||
|
} catch (ClassNotFoundException ignored) {
|
||||||
|
// sqlite-jdbc 通过 ServiceLoader 注册时也成立
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Connection open(String path, boolean queryOnly) throws SQLException {
|
||||||
|
File file = new File(path).getAbsoluteFile();
|
||||||
|
Connection c = DriverManager.getConnection("jdbc:sqlite:" + file.getPath());
|
||||||
|
try (Statement st = c.createStatement()) {
|
||||||
|
st.execute("PRAGMA busy_timeout = 5000");
|
||||||
|
if (queryOnly) {
|
||||||
|
st.execute("PRAGMA query_only = ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateFile(File file) throws UserException {
|
||||||
|
if (file == null) {
|
||||||
|
throw new UserException("路径未设置", 2);
|
||||||
|
}
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new UserException("文件不存在: " + file.getAbsolutePath(), 2);
|
||||||
|
}
|
||||||
|
if (!file.isFile()) {
|
||||||
|
throw new UserException("不是普通文件: " + file.getAbsolutePath(), 2);
|
||||||
|
}
|
||||||
|
if (!file.canRead()) {
|
||||||
|
throw new UserException("文件不可读: " + file.getAbsolutePath(), 2);
|
||||||
|
}
|
||||||
|
if (file.length() < 16) {
|
||||||
|
throw new UserException("不是有效的 SQLite 文件: " + file.getAbsolutePath(), 4);
|
||||||
|
}
|
||||||
|
byte[] head = new byte[16];
|
||||||
|
try (InputStream in = new FileInputStream(file)) {
|
||||||
|
int n = in.read(head);
|
||||||
|
if (n < 16) {
|
||||||
|
throw new UserException("不是有效的 SQLite 文件: " + file.getAbsolutePath(), 4);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UserException("文件读取失败: " + file.getAbsolutePath() + " (" + e.getMessage() + ")", 2, e);
|
||||||
|
}
|
||||||
|
if (!Arrays.equals(head, SQLITE_HEADER)) {
|
||||||
|
throw new UserException("不是有效的 SQLite 文件: " + file.getAbsolutePath(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long validateAndCount(File file) throws UserException {
|
||||||
|
validateFile(file);
|
||||||
|
try (Connection c = open(file.getPath(), true)) {
|
||||||
|
validateSchema(c);
|
||||||
|
return count(c);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UserException("数据库打开失败: " + file.getAbsolutePath() + " (" + e.getMessage() + ")", 4, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean tableExists(Connection c, String table) throws SQLException {
|
||||||
|
String sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, table);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> columns(Connection c) throws SQLException {
|
||||||
|
List<String> cols = new ArrayList<>();
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("PRAGMA table_info(usercert_tab)")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
cols.add(rs.getString("name").toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateSchema(Connection c) throws SQLException, UserException {
|
||||||
|
if (!tableExists(c, "usercert_tab")) {
|
||||||
|
throw new UserException("数据库缺少表 usercert_tab", 4);
|
||||||
|
}
|
||||||
|
List<String> cols = columns(c);
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
for (String required : REQUIRED_COLUMNS) {
|
||||||
|
if (!cols.contains(required)) {
|
||||||
|
missing.add(required);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new UserException("usercert_tab 缺少必需字段: " + missing, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long count(Connection c) throws SQLException {
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("SELECT count(*) FROM usercert_tab")) {
|
||||||
|
return rs.next() ? rs.getLong(1) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读取比较所需字段,subject/issuer 使用 hex() 获取原始字节,绝不使用 getString()。
|
||||||
|
*/
|
||||||
|
public static List<CertRecord> readAll(Connection c) throws SQLException {
|
||||||
|
String sql = "SELECT hex(subject) AS subject_hex, hex(issuer) AS issuer_hex, "
|
||||||
|
+ "sn, not_before, not_after, certtype, stat FROM usercert_tab";
|
||||||
|
List<CertRecord> list = new ArrayList<>();
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
list.add(new CertRecord(
|
||||||
|
nullToEmpty(rs.getString("subject_hex")),
|
||||||
|
nullToEmpty(rs.getString("issuer_hex")),
|
||||||
|
nullToEmpty(rs.getString("sn")),
|
||||||
|
nullToEmpty(rs.getString("not_before")),
|
||||||
|
nullToEmpty(rs.getString("not_after")),
|
||||||
|
rs.getInt("certtype"),
|
||||||
|
rs.getInt("stat")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sqlString(String value) {
|
||||||
|
return "'" + value.replace("'", "''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String joinCopyColumns(String alias) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < COPY_COLUMNS.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
if (alias != null && !alias.isEmpty()) {
|
||||||
|
sb.append(alias).append('.');
|
||||||
|
}
|
||||||
|
sb.append(COPY_COLUMNS.get(i));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String v) {
|
||||||
|
return v == null ? "" : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证书身份键:subject 原始字节十六进制 + certtype。
|
||||||
|
*/
|
||||||
|
public final class CertKey implements Comparable<CertKey> {
|
||||||
|
|
||||||
|
public final String subjectHex;
|
||||||
|
public final int certtype;
|
||||||
|
|
||||||
|
public CertKey(String subjectHex, int certtype) {
|
||||||
|
this.subjectHex = subjectHex == null ? "" : subjectHex.trim().toUpperCase(Locale.ROOT);
|
||||||
|
this.certtype = certtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算身份键使用的 subject 十六进制。TRIM 模式仅去除整体首尾的 ASCII 空白字节。
|
||||||
|
*/
|
||||||
|
public static String normalizeSubjectHex(String rawHex, boolean trim) {
|
||||||
|
if (rawHex == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = rawHex.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!trim) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
int end = s.length();
|
||||||
|
while (start + 2 <= end && isAsciiWhitespaceHex(s, start)) {
|
||||||
|
start += 2;
|
||||||
|
}
|
||||||
|
while (end - 2 >= start && isAsciiWhitespaceHex(s, end - 2)) {
|
||||||
|
end -= 2;
|
||||||
|
}
|
||||||
|
return s.substring(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAsciiWhitespaceHex(String s, int offset) {
|
||||||
|
String b = s.substring(offset, offset + 2);
|
||||||
|
return "20".equals(b) || "09".equals(b) || "0A".equals(b)
|
||||||
|
|| "0D".equals(b) || "0B".equals(b) || "0C".equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof CertKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
CertKey other = (CertKey) o;
|
||||||
|
return certtype == other.certtype && subjectHex.equals(other.subjectHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return 31 * subjectHex.hashCode() + certtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(CertKey o) {
|
||||||
|
int c = subjectHex.compareTo(o.subjectHex);
|
||||||
|
if (c != 0) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
return Integer.compare(certtype, o.certtype);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "HEX:" + subjectHex + "|" + certtype;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 usercert_tab 读取的一条投影记录。DN/issuer 只保留十六进制原始字节。
|
||||||
|
*/
|
||||||
|
public class CertRecord {
|
||||||
|
|
||||||
|
public final String subjectHex;
|
||||||
|
public final String issuerHex;
|
||||||
|
public final String sn;
|
||||||
|
public final String notBefore;
|
||||||
|
public final String notAfter;
|
||||||
|
public final int certtype;
|
||||||
|
public final int stat;
|
||||||
|
|
||||||
|
public CertRecord(String subjectHex, String issuerHex, String sn,
|
||||||
|
String notBefore, String notAfter, int certtype, int stat) {
|
||||||
|
this.subjectHex = subjectHex == null ? "" : subjectHex;
|
||||||
|
this.issuerHex = issuerHex == null ? "" : issuerHex;
|
||||||
|
this.sn = sn == null ? "" : sn;
|
||||||
|
this.notBefore = notBefore == null ? "" : notBefore;
|
||||||
|
this.notAfter = notAfter == null ? "" : notAfter;
|
||||||
|
this.certtype = certtype;
|
||||||
|
this.stat = stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CertKey key() {
|
||||||
|
return new CertKey(subjectHex, certtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比对分类。EQUAL 只计数,不进入报表。
|
||||||
|
*/
|
||||||
|
public enum CompareCategory {
|
||||||
|
ONLY_MAIN("主库多"),
|
||||||
|
ONLY_SLAVE("从库多"),
|
||||||
|
DIFF("不一致"),
|
||||||
|
EQUAL("一致");
|
||||||
|
|
||||||
|
public final String label;
|
||||||
|
|
||||||
|
CompareCategory(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompareCategory fromLabel(String label) {
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = label.trim();
|
||||||
|
for (CompareCategory c : values()) {
|
||||||
|
if (c.label.equals(t) || c.name().equalsIgnoreCase(t)) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次完整比对的结果。
|
||||||
|
*/
|
||||||
|
public class CompareResult {
|
||||||
|
|
||||||
|
public final int mainCount;
|
||||||
|
public final int slaveCount;
|
||||||
|
public final int equalCount;
|
||||||
|
public final List<CompareRow> onlyMain;
|
||||||
|
public final List<CompareRow> onlySlave;
|
||||||
|
public final List<CompareRow> diff;
|
||||||
|
public final List<String> warnings;
|
||||||
|
|
||||||
|
public CompareResult(int mainCount, int slaveCount, int equalCount,
|
||||||
|
List<CompareRow> onlyMain, List<CompareRow> onlySlave,
|
||||||
|
List<CompareRow> diff, List<String> warnings) {
|
||||||
|
this.mainCount = mainCount;
|
||||||
|
this.slaveCount = slaveCount;
|
||||||
|
this.equalCount = equalCount;
|
||||||
|
this.onlyMain = onlyMain;
|
||||||
|
this.onlySlave = onlySlave;
|
||||||
|
this.diff = diff;
|
||||||
|
this.warnings = warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CompareRow> differenceRows() {
|
||||||
|
List<CompareRow> rows = new ArrayList<>(onlyMain.size() + onlySlave.size() + diff.size());
|
||||||
|
rows.addAll(onlyMain);
|
||||||
|
rows.addAll(onlySlave);
|
||||||
|
rows.addAll(diff);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int differenceCount() {
|
||||||
|
return onlyMain.size() + onlySlave.size() + diff.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.util.Text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报表中的一行差异记录。
|
||||||
|
*/
|
||||||
|
public class CompareRow {
|
||||||
|
|
||||||
|
public final CompareCategory category;
|
||||||
|
public final CertKey key;
|
||||||
|
public final CertRecord main;
|
||||||
|
public final CertRecord slave;
|
||||||
|
public final List<FieldDiff> diffs;
|
||||||
|
public final List<String> notes;
|
||||||
|
public final String defaultChoice;
|
||||||
|
public final String dnText;
|
||||||
|
|
||||||
|
public CompareRow(CompareCategory category, CertKey key, CertRecord main, CertRecord slave,
|
||||||
|
List<FieldDiff> diffs, List<String> notes, String defaultChoice, String dnText) {
|
||||||
|
this.category = category;
|
||||||
|
this.key = key;
|
||||||
|
this.main = main;
|
||||||
|
this.slave = slave;
|
||||||
|
this.diffs = diffs;
|
||||||
|
this.notes = notes;
|
||||||
|
this.defaultChoice = defaultChoice;
|
||||||
|
this.dnText = dnText == null ? "" : dnText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String cn() {
|
||||||
|
return Text.cnFromDn(dnText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个比较字段的差异。
|
||||||
|
*/
|
||||||
|
public class FieldDiff {
|
||||||
|
|
||||||
|
public final String name;
|
||||||
|
public final String mainValue;
|
||||||
|
public final String slaveValue;
|
||||||
|
|
||||||
|
public FieldDiff(String name, String mainValue, String slaveValue) {
|
||||||
|
this.name = name;
|
||||||
|
this.mainValue = mainValue == null ? "" : mainValue;
|
||||||
|
this.slaveValue = slaveValue == null ? "" : slaveValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.sunyard.certcompare.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工合并选择。报表中显示为“主”/“备”,内部使用枚举,SQL 中只出现 MAIN/SLAVE。
|
||||||
|
*/
|
||||||
|
public enum MergeChoice {
|
||||||
|
MAIN("主"),
|
||||||
|
SLAVE("备");
|
||||||
|
|
||||||
|
public final String label;
|
||||||
|
|
||||||
|
MergeChoice(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MergeChoice fromText(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = text.trim();
|
||||||
|
for (MergeChoice c : values()) {
|
||||||
|
if (c.label.equals(t) || c.name().equalsIgnoreCase(t)) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC4180 风格 CSV 转义与解析。
|
||||||
|
*/
|
||||||
|
public final class Csv {
|
||||||
|
|
||||||
|
private Csv() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String escape(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
boolean need = false;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == ',' || c == '"' || c == '\n' || c == '\r') {
|
||||||
|
need = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!need && !value.isEmpty()) {
|
||||||
|
char first = value.charAt(0);
|
||||||
|
char last = value.charAt(value.length() - 1);
|
||||||
|
if (first == ' ' || last == ' ' || first == '\t' || last == '\t') {
|
||||||
|
need = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!need) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '"' + value.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CsvRecord> parse(String text) throws UserException {
|
||||||
|
List<CsvRecord> rows = new ArrayList<>();
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
List<String> fields = new ArrayList<>();
|
||||||
|
StringBuilder cur = new StringBuilder();
|
||||||
|
boolean inQuotes = false;
|
||||||
|
boolean fieldStarted = false;
|
||||||
|
int line = 1;
|
||||||
|
int recordLine = 1;
|
||||||
|
int len = text.length();
|
||||||
|
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c == '"') {
|
||||||
|
if (i + 1 < len && text.charAt(i + 1) == '"') {
|
||||||
|
cur.append('"');
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c == '\n') {
|
||||||
|
line++;
|
||||||
|
}
|
||||||
|
cur.append(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c == '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
fieldStarted = true;
|
||||||
|
} else if (c == ',') {
|
||||||
|
fields.add(cur.toString());
|
||||||
|
cur.setLength(0);
|
||||||
|
fieldStarted = true;
|
||||||
|
} else if (c == '\r') {
|
||||||
|
// 忽略 CR,换行统一按 LF 处理
|
||||||
|
} else if (c == '\n') {
|
||||||
|
fields.add(cur.toString());
|
||||||
|
cur.setLength(0);
|
||||||
|
rows.add(new CsvRecord(new ArrayList<>(fields), recordLine));
|
||||||
|
fields.clear();
|
||||||
|
line++;
|
||||||
|
recordLine = line;
|
||||||
|
fieldStarted = false;
|
||||||
|
} else {
|
||||||
|
cur.append(c);
|
||||||
|
fieldStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inQuotes) {
|
||||||
|
throw new UserException("报表 CSV 引号未闭合", 5);
|
||||||
|
}
|
||||||
|
if (fieldStarted || cur.length() > 0 || !fields.isEmpty()) {
|
||||||
|
fields.add(cur.toString());
|
||||||
|
rows.add(new CsvRecord(new ArrayList<>(fields), recordLine));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CsvRecord {
|
||||||
|
public final List<String> fields;
|
||||||
|
public final int line;
|
||||||
|
|
||||||
|
public CsvRecord(List<String> fields, int line) {
|
||||||
|
this.fields = fields;
|
||||||
|
this.line = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报表读取结果。
|
||||||
|
*/
|
||||||
|
public class ReportData {
|
||||||
|
|
||||||
|
public final File file;
|
||||||
|
public final int headerLine;
|
||||||
|
public final List<ReportRow> rows;
|
||||||
|
|
||||||
|
public ReportData(File file, int headerLine, List<ReportRow> rows) {
|
||||||
|
this.file = file;
|
||||||
|
this.headerLine = headerLine;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.util.Text;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.nio.charset.CharsetDecoder;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报表读取器:兼容 UTF-8 BOM、UTF-8、GBK/GB18030。
|
||||||
|
*/
|
||||||
|
public class ReportReader {
|
||||||
|
|
||||||
|
public ReportData read(File file) throws UserException {
|
||||||
|
if (file == null || !file.isFile()) {
|
||||||
|
throw new UserException("报表不存在: " + (file == null ? "" : file.getAbsolutePath()), 5);
|
||||||
|
}
|
||||||
|
byte[] bytes;
|
||||||
|
try {
|
||||||
|
bytes = Files.readAllBytes(file.toPath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UserException("报表读取失败: " + file.getAbsolutePath() + " (" + e.getMessage() + ")", 5, e);
|
||||||
|
}
|
||||||
|
if (bytes.length == 0) {
|
||||||
|
throw new UserException("报表为空: " + file.getAbsolutePath(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean bom = bytes.length >= 3
|
||||||
|
&& (bytes[0] & 0xFF) == 0xEF
|
||||||
|
&& (bytes[1] & 0xFF) == 0xBB
|
||||||
|
&& (bytes[2] & 0xFF) == 0xBF;
|
||||||
|
int offset = bom ? 3 : 0;
|
||||||
|
Charset charset;
|
||||||
|
if (bom) {
|
||||||
|
charset = StandardCharsets.UTF_8;
|
||||||
|
} else {
|
||||||
|
charset = isStrictUtf8(bytes) ? StandardCharsets.UTF_8 : Text.GB18030;
|
||||||
|
}
|
||||||
|
String text = new String(bytes, offset, bytes.length - offset, charset);
|
||||||
|
if (!text.isEmpty() && text.charAt(0) == '\uFEFF') {
|
||||||
|
text = text.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Csv.CsvRecord> records = Csv.parse(text);
|
||||||
|
int headerIndex = -1;
|
||||||
|
for (int i = 0; i < records.size(); i++) {
|
||||||
|
List<String> f = records.get(i).fields;
|
||||||
|
if (f.contains("合并选择") && f.contains("DN_HEX")) {
|
||||||
|
headerIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headerIndex < 0) {
|
||||||
|
throw new UserException("报表缺少表头(需包含“合并选择”“DN_HEX”列): " + file.getAbsolutePath(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> header = new ArrayList<>();
|
||||||
|
for (String h : records.get(headerIndex).fields) {
|
||||||
|
header.add(h == null ? "" : h.trim());
|
||||||
|
}
|
||||||
|
Map<String, Integer> index = new HashMap<>();
|
||||||
|
for (int i = 0; i < header.size(); i++) {
|
||||||
|
String h = header.get(i);
|
||||||
|
if (h.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (index.put(h, i) != null) {
|
||||||
|
throw new UserException("报表表头存在重复列: " + h, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
for (String required : ReportWriter.HEADER) {
|
||||||
|
if (!index.containsKey(required)) {
|
||||||
|
missing.add(required);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new UserException("报表缺少必需列: " + missing, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ReportRow> rows = new ArrayList<>();
|
||||||
|
for (int i = headerIndex + 1; i < records.size(); i++) {
|
||||||
|
Csv.CsvRecord record = records.get(i);
|
||||||
|
List<String> fields = record.fields;
|
||||||
|
if (isBlank(fields)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fields.size() > header.size()) {
|
||||||
|
throw new UserException("报表第 " + record.line + " 行列数多于表头(" + fields.size()
|
||||||
|
+ " > " + header.size() + ")", 5);
|
||||||
|
}
|
||||||
|
while (fields.size() < header.size()) {
|
||||||
|
fields.add("");
|
||||||
|
}
|
||||||
|
|
||||||
|
String dnHexRaw = get(fields, index, "DN_HEX").trim();
|
||||||
|
String hex = dnHexRaw.regionMatches(true, 0, "HEX:", 0, 4)
|
||||||
|
? dnHexRaw.substring(4) : dnHexRaw;
|
||||||
|
hex = hex.replaceAll("\\s+", "").toUpperCase(Locale.ROOT);
|
||||||
|
if (!Text.isHex(hex)) {
|
||||||
|
throw new UserException("报表第 " + record.line + " 行 DN_HEX 非法: " + dnHexRaw, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
int certtype;
|
||||||
|
String certtypeText = get(fields, index, "CERTTYPE").trim();
|
||||||
|
try {
|
||||||
|
certtype = Integer.parseInt(certtypeText);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new UserException("报表第 " + record.line + " 行 CERTTYPE 非法: " + certtypeText, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.add(new ReportRow(
|
||||||
|
record.line,
|
||||||
|
get(fields, index, "序号"),
|
||||||
|
get(fields, index, "类型"),
|
||||||
|
get(fields, index, "合并选择"),
|
||||||
|
get(fields, index, "DN"),
|
||||||
|
hex,
|
||||||
|
get(fields, index, "CN"),
|
||||||
|
certtype,
|
||||||
|
get(fields, index, "主库SN"),
|
||||||
|
get(fields, index, "从库SN"),
|
||||||
|
get(fields, index, "主库生效"),
|
||||||
|
get(fields, index, "主库失效"),
|
||||||
|
get(fields, index, "从库生效"),
|
||||||
|
get(fields, index, "从库失效"),
|
||||||
|
get(fields, index, "主库状态"),
|
||||||
|
get(fields, index, "从库状态"),
|
||||||
|
get(fields, index, "差异字段")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReportData(file, records.get(headerIndex).line, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String get(List<String> fields, Map<String, Integer> index, String name) {
|
||||||
|
Integer i = index.get(name);
|
||||||
|
if (i == null || i < 0 || i >= fields.size()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String v = fields.get(i);
|
||||||
|
return v == null ? "" : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(List<String> fields) {
|
||||||
|
for (String f : fields) {
|
||||||
|
if (f != null && !f.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStrictUtf8(byte[] bytes) {
|
||||||
|
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
try {
|
||||||
|
decoder.decode(ByteBuffer.wrap(bytes));
|
||||||
|
return true;
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报表中的一条已标注记录。
|
||||||
|
*/
|
||||||
|
public class ReportRow {
|
||||||
|
|
||||||
|
public final int line;
|
||||||
|
public final String seq;
|
||||||
|
public final String typeText;
|
||||||
|
public final String choiceText;
|
||||||
|
public final String dn;
|
||||||
|
public final String dnHex;
|
||||||
|
public final String cn;
|
||||||
|
public final int certtype;
|
||||||
|
public final String mainSn;
|
||||||
|
public final String slaveSn;
|
||||||
|
public final String mainNotBefore;
|
||||||
|
public final String mainNotAfter;
|
||||||
|
public final String slaveNotBefore;
|
||||||
|
public final String slaveNotAfter;
|
||||||
|
public final String mainStat;
|
||||||
|
public final String slaveStat;
|
||||||
|
public final String diffFields;
|
||||||
|
public final CertKey key;
|
||||||
|
|
||||||
|
public ReportRow(int line, String seq, String typeText, String choiceText, String dn, String dnHex,
|
||||||
|
String cn, int certtype, String mainSn, String slaveSn,
|
||||||
|
String mainNotBefore, String mainNotAfter, String slaveNotBefore, String slaveNotAfter,
|
||||||
|
String mainStat, String slaveStat, String diffFields) {
|
||||||
|
this.line = line;
|
||||||
|
this.seq = seq;
|
||||||
|
this.typeText = typeText;
|
||||||
|
this.choiceText = choiceText;
|
||||||
|
this.dn = dn;
|
||||||
|
this.dnHex = dnHex;
|
||||||
|
this.cn = cn;
|
||||||
|
this.certtype = certtype;
|
||||||
|
this.mainSn = mainSn;
|
||||||
|
this.slaveSn = slaveSn;
|
||||||
|
this.mainNotBefore = mainNotBefore;
|
||||||
|
this.mainNotAfter = mainNotAfter;
|
||||||
|
this.slaveNotBefore = slaveNotBefore;
|
||||||
|
this.slaveNotAfter = slaveNotAfter;
|
||||||
|
this.mainStat = mainStat;
|
||||||
|
this.slaveStat = slaveStat;
|
||||||
|
this.diffFields = diffFields;
|
||||||
|
this.key = new CertKey(dnHex, certtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
import com.sunyard.certcompare.model.CompareCategory;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.CompareRow;
|
||||||
|
import com.sunyard.certcompare.model.MergeChoice;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验报表与当前库比对结果一致,并解析人工标注。
|
||||||
|
*/
|
||||||
|
public final class ReportValidator {
|
||||||
|
|
||||||
|
private ReportValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<CertKey, MergeChoice> validate(CompareResult result, ReportData report)
|
||||||
|
throws UserException {
|
||||||
|
Map<CertKey, CompareRow> expected = new LinkedHashMap<>();
|
||||||
|
for (CompareRow row : result.differenceRows()) {
|
||||||
|
expected.put(row.key, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<CertKey, ReportRow> actual = new LinkedHashMap<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
for (ReportRow row : report.rows) {
|
||||||
|
if (actual.put(row.key, row) != null) {
|
||||||
|
errors.add("报表第 " + row.line + " 行身份键重复: " + row.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<CertKey, CompareRow> entry : expected.entrySet()) {
|
||||||
|
ReportRow row = actual.get(entry.getKey());
|
||||||
|
if (row == null) {
|
||||||
|
errors.add("报表缺少记录: " + entry.getKey());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
CompareCategory actualCategory = CompareCategory.fromLabel(row.typeText);
|
||||||
|
if (actualCategory != entry.getValue().category) {
|
||||||
|
errors.add("报表第 " + row.line + " 行类型为“" + row.typeText.trim()
|
||||||
|
+ "”,当前应为“" + entry.getValue().category.label + "”: " + row.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (CertKey key : actual.keySet()) {
|
||||||
|
if (!expected.containsKey(key)) {
|
||||||
|
errors.add("报表包含当前比对中不存在的记录: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new UserException("报表已过期或与当前数据库不一致,请重新执行 compare:\n "
|
||||||
|
+ String.join("\n ", errors), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<CertKey, MergeChoice> choices = new LinkedHashMap<>();
|
||||||
|
List<String> choiceErrors = new ArrayList<>();
|
||||||
|
for (Map.Entry<CertKey, CompareRow> entry : expected.entrySet()) {
|
||||||
|
CompareRow expectedRow = entry.getValue();
|
||||||
|
ReportRow row = actual.get(entry.getKey());
|
||||||
|
String text = row.choiceText == null ? "" : row.choiceText.trim();
|
||||||
|
MergeChoice choice = MergeChoice.fromText(text);
|
||||||
|
String where = "报表第 " + row.line + " 行(" + expectedRow.category.label
|
||||||
|
+ "," + describe(entry.getKey()) + ")";
|
||||||
|
|
||||||
|
if (expectedRow.category == CompareCategory.ONLY_MAIN) {
|
||||||
|
if (choice != MergeChoice.MAIN) {
|
||||||
|
choiceErrors.add(where + "合并选择为“" + text + "”,主库多只允许填“主”");
|
||||||
|
}
|
||||||
|
choices.put(entry.getKey(), MergeChoice.MAIN);
|
||||||
|
} else if (expectedRow.category == CompareCategory.ONLY_SLAVE) {
|
||||||
|
if (choice != MergeChoice.SLAVE) {
|
||||||
|
choiceErrors.add(where + "合并选择为“" + text + "”,从库多只允许填“备”");
|
||||||
|
}
|
||||||
|
choices.put(entry.getKey(), MergeChoice.SLAVE);
|
||||||
|
} else {
|
||||||
|
if (choice == null) {
|
||||||
|
choiceErrors.add(where + "合并选择为“" + text + "”,不一致记录必须填写“主”或“备”");
|
||||||
|
} else {
|
||||||
|
choices.put(entry.getKey(), choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!choiceErrors.isEmpty()) {
|
||||||
|
throw new UserException("合并选择标注校验失败:\n "
|
||||||
|
+ String.join("\n ", choiceErrors), 5);
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(CertKey key) {
|
||||||
|
return "CERTTYPE=" + key.certtype + ", DN_HEX=HEX:" + key.subjectHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.sunyard.certcompare.report;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.AppConfig;
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.CompareRow;
|
||||||
|
import com.sunyard.certcompare.model.FieldDiff;
|
||||||
|
|
||||||
|
import java.io.BufferedWriter;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一的比较报表(UTF-8 + BOM)。
|
||||||
|
*/
|
||||||
|
public class ReportWriter {
|
||||||
|
|
||||||
|
public static final String[] HEADER = new String[]{
|
||||||
|
"序号", "类型", "DN", "DN_HEX", "CN", "CERTTYPE",
|
||||||
|
"主库SN", "从库SN", "主库生效", "主库失效", "从库生效", "从库失效",
|
||||||
|
"主库状态", "从库状态", "差异字段", "合并选择"
|
||||||
|
};
|
||||||
|
|
||||||
|
public File write(AppConfig cfg, CompareResult result) throws UserException {
|
||||||
|
File dir = cfg.reportDirFile();
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new UserException("报表目录创建失败: " + dir.getAbsolutePath(), 2);
|
||||||
|
}
|
||||||
|
if (!dir.isDirectory()) {
|
||||||
|
throw new UserException("报表路径不是目录: " + dir.getAbsolutePath(), 2);
|
||||||
|
}
|
||||||
|
File out = nextFile(dir);
|
||||||
|
|
||||||
|
try (Writer writer = new BufferedWriter(new OutputStreamWriter(
|
||||||
|
new FileOutputStream(out), StandardCharsets.UTF_8))) {
|
||||||
|
if (cfg.isCsvBom()) {
|
||||||
|
writer.write('\uFEFF');
|
||||||
|
}
|
||||||
|
writeRow(writer, HEADER);
|
||||||
|
int seq = 1;
|
||||||
|
for (CompareRow row : result.differenceRows()) {
|
||||||
|
writeRow(writer, toFields(seq++, row));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UserException("报表写入失败: " + out.getAbsolutePath() + " (" + e.getMessage() + ")", 2, e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private File nextFile(File dir) throws UserException {
|
||||||
|
String base = "compare-report-" + new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
|
||||||
|
File file = new File(dir, base + ".csv");
|
||||||
|
int i = 2;
|
||||||
|
while (file.exists()) {
|
||||||
|
if (i > 999) {
|
||||||
|
throw new UserException("报表目录中同名文件过多: " + base, 2);
|
||||||
|
}
|
||||||
|
file = new File(dir, base + "-" + i + ".csv");
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] toFields(int seq, CompareRow row) {
|
||||||
|
String mainSn = row.main == null ? "" : row.main.sn;
|
||||||
|
String slaveSn = row.slave == null ? "" : row.slave.sn;
|
||||||
|
String mainBefore = row.main == null ? "" : row.main.notBefore;
|
||||||
|
String mainAfter = row.main == null ? "" : row.main.notAfter;
|
||||||
|
String slaveBefore = row.slave == null ? "" : row.slave.notBefore;
|
||||||
|
String slaveAfter = row.slave == null ? "" : row.slave.notAfter;
|
||||||
|
String mainStat = row.main == null ? "" : String.valueOf(row.main.stat);
|
||||||
|
String slaveStat = row.slave == null ? "" : String.valueOf(row.slave.stat);
|
||||||
|
|
||||||
|
StringBuilder diffNames = new StringBuilder();
|
||||||
|
for (FieldDiff d : row.diffs) {
|
||||||
|
if (diffNames.length() > 0) {
|
||||||
|
diffNames.append(';');
|
||||||
|
}
|
||||||
|
diffNames.append(d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String[]{
|
||||||
|
String.valueOf(seq),
|
||||||
|
row.category.label,
|
||||||
|
row.dnText,
|
||||||
|
"HEX:" + row.key.subjectHex,
|
||||||
|
row.cn(),
|
||||||
|
String.valueOf(row.key.certtype),
|
||||||
|
mainSn,
|
||||||
|
slaveSn,
|
||||||
|
mainBefore,
|
||||||
|
mainAfter,
|
||||||
|
slaveBefore,
|
||||||
|
slaveAfter,
|
||||||
|
mainStat,
|
||||||
|
slaveStat,
|
||||||
|
diffNames.toString(),
|
||||||
|
row.defaultChoice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeRow(Writer writer, String[] fields) throws IOException {
|
||||||
|
for (int i = 0; i < fields.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
writer.write(',');
|
||||||
|
}
|
||||||
|
writer.write(Csv.escape(fields[i]));
|
||||||
|
}
|
||||||
|
writer.write('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.sunyard.certcompare.sql;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量 SQL 生成计划(只读统计,不执行任何写操作)。
|
||||||
|
*/
|
||||||
|
public class SqlPlan {
|
||||||
|
|
||||||
|
public final int mainCount;
|
||||||
|
public final int slaveCount;
|
||||||
|
public final int equalCount;
|
||||||
|
public final int onlyMainCount;
|
||||||
|
public final int onlySlaveCount;
|
||||||
|
public final int diffCount;
|
||||||
|
public final int insertCount;
|
||||||
|
public final int diffMainCount;
|
||||||
|
public final int diffSlaveCount;
|
||||||
|
public final int updateCount;
|
||||||
|
public final int expectedCount;
|
||||||
|
public final File mainFile;
|
||||||
|
public final File slaveFile;
|
||||||
|
public final File sqlFile;
|
||||||
|
|
||||||
|
public SqlPlan(CompareResult result, int diffMainCount, int diffSlaveCount,
|
||||||
|
int insertCount, int updateCount, File mainFile, File slaveFile, File sqlFile) {
|
||||||
|
this.mainCount = result.mainCount;
|
||||||
|
this.slaveCount = result.slaveCount;
|
||||||
|
this.equalCount = result.equalCount;
|
||||||
|
this.onlyMainCount = result.onlyMain.size();
|
||||||
|
this.onlySlaveCount = result.onlySlave.size();
|
||||||
|
this.diffCount = result.diff.size();
|
||||||
|
this.insertCount = insertCount;
|
||||||
|
this.diffMainCount = diffMainCount;
|
||||||
|
this.diffSlaveCount = diffSlaveCount;
|
||||||
|
this.updateCount = updateCount;
|
||||||
|
this.expectedCount = result.mainCount + result.onlySlave.size();
|
||||||
|
this.mainFile = mainFile;
|
||||||
|
this.slaveFile = slaveFile;
|
||||||
|
this.sqlFile = sqlFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
out.println("SQL 生成计划:");
|
||||||
|
out.printf(" %-20s %d%n", "主库证书数", mainCount);
|
||||||
|
out.printf(" %-20s %d%n", "从库证书数", slaveCount);
|
||||||
|
out.printf(" %-20s %d%n", "一致", equalCount);
|
||||||
|
out.printf(" %-20s %d%n", "主库多", onlyMainCount);
|
||||||
|
out.printf(" %-20s %d%n", "从库多", onlySlaveCount);
|
||||||
|
out.printf(" %-20s %d%n", "不一致", diffCount);
|
||||||
|
out.printf(" %-20s %d%n", "写入(从库多+选备)", insertCount);
|
||||||
|
out.printf(" %-20s %d%n", "不一致选主", diffMainCount);
|
||||||
|
out.printf(" %-20s %d%n", "不一致选备", diffSlaveCount);
|
||||||
|
out.printf(" %-20s %d%n", "执行后主库记录数", expectedCount);
|
||||||
|
out.printf(" %-20s %s%n", "从库(只读引用)", slaveFile.getAbsolutePath());
|
||||||
|
out.printf(" %-20s %s%n", "SQL 文件", sqlFile.getAbsolutePath());
|
||||||
|
if (updateCount > 0) {
|
||||||
|
out.printf(" %-20s %d%n", "按主库字节替换", updateCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.sunyard.certcompare.sql;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.AppConfig;
|
||||||
|
import com.sunyard.certcompare.UserException;
|
||||||
|
import com.sunyard.certcompare.db.DbManager;
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
import com.sunyard.certcompare.model.CertRecord;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.CompareRow;
|
||||||
|
import com.sunyard.certcompare.model.MergeChoice;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成在主库上执行的批量 SQL 脚本。
|
||||||
|
*
|
||||||
|
* <p>脚本不把 DN/证书内容带进 SQL 文本,而是 ATTACH 从库后按身份键
|
||||||
|
* (subject 原始字节 + certtype)复制,因此 GBK 中文 DN 的字节不会被解码或重编码。</p>
|
||||||
|
*/
|
||||||
|
public class SqlScriptService {
|
||||||
|
|
||||||
|
/** 单条 INSERT 包含的身份键上限,避免 SQL 文本过长。 */
|
||||||
|
static final int KEYS_PER_STATEMENT = 2000;
|
||||||
|
|
||||||
|
static final String SOURCE_ALIAS = "cert_src";
|
||||||
|
|
||||||
|
private final AppConfig cfg;
|
||||||
|
|
||||||
|
public SqlScriptService(AppConfig cfg) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlPlan generate(CompareResult result, Map<CertKey, MergeChoice> choices)
|
||||||
|
throws UserException {
|
||||||
|
// 脚本直接写入进程当前工作目录
|
||||||
|
File dir = new File(System.getProperty("user.dir")).getAbsoluteFile();
|
||||||
|
Script script = build(cfg, result, choices);
|
||||||
|
File file = nextFile(dir);
|
||||||
|
try {
|
||||||
|
Files.write(file.toPath(), script.text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UserException("SQL 文件写入失败: " + file.getAbsolutePath()
|
||||||
|
+ " (" + e.getMessage() + ")", 5, e);
|
||||||
|
}
|
||||||
|
return new SqlPlan(result, script.diffMainCount, script.diffSlaveCount,
|
||||||
|
script.insertCount, script.updateCount,
|
||||||
|
cfg.mainFile(), cfg.slaveFile(), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File nextFile(File dir) throws UserException {
|
||||||
|
String base = "merge-" + new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
|
||||||
|
File file = new File(dir, base + ".sql");
|
||||||
|
int i = 2;
|
||||||
|
while (file.exists()) {
|
||||||
|
if (i > 999) {
|
||||||
|
throw new UserException("当前目录中同名脚本文件过多: " + base, 2);
|
||||||
|
}
|
||||||
|
file = new File(dir, base + "-" + i + ".sql");
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成脚本内容。纯函数,便于单元测试。
|
||||||
|
*/
|
||||||
|
public static Script build(AppConfig cfg, CompareResult result, Map<CertKey, MergeChoice> choices)
|
||||||
|
throws UserException {
|
||||||
|
List<CertRecord> insertRecords = new ArrayList<>();
|
||||||
|
List<CompareRow> updateRows = new ArrayList<>();
|
||||||
|
int diffMain = 0;
|
||||||
|
int diffSlave = 0;
|
||||||
|
|
||||||
|
for (CompareRow row : result.onlySlave) {
|
||||||
|
if (row.slave == null) {
|
||||||
|
throw new UserException("内部错误:从库多记录缺少从库数据: " + row.key, 5);
|
||||||
|
}
|
||||||
|
insertRecords.add(row.slave);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (CompareRow row : result.diff) {
|
||||||
|
MergeChoice choice = choices.get(row.key);
|
||||||
|
if (choice == MergeChoice.MAIN) {
|
||||||
|
diffMain++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (choice != MergeChoice.SLAVE) {
|
||||||
|
throw new UserException("内部错误:不一致记录缺少合并选择: " + row.key, 5);
|
||||||
|
}
|
||||||
|
diffSlave++;
|
||||||
|
if (row.main == null || row.slave == null) {
|
||||||
|
throw new UserException("内部错误:不一致记录缺少数据: " + row.key, 5);
|
||||||
|
}
|
||||||
|
if (sameSubjectBytes(row.main, row.slave)) {
|
||||||
|
insertRecords.add(row.slave);
|
||||||
|
} else {
|
||||||
|
// dn.match=TRIM 时主备 DN 原始字节可能不同,需按主库字节定位后整行字段替换
|
||||||
|
updateRows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Script script = new Script();
|
||||||
|
script.diffMainCount = diffMain;
|
||||||
|
script.diffSlaveCount = diffSlave;
|
||||||
|
script.insertCount = insertRecords.size();
|
||||||
|
script.updateCount = updateRows.size();
|
||||||
|
script.text = render(cfg, result, script, insertRecords, updateRows);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean sameSubjectBytes(CertRecord a, CertRecord b) {
|
||||||
|
return a.subjectHex.equalsIgnoreCase(b.subjectHex) && a.certtype == b.certtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String render(AppConfig cfg, CompareResult result, Script stats,
|
||||||
|
List<CertRecord> insertRecords, List<CompareRow> updateRows) {
|
||||||
|
String mainPath = cfg.mainFile().getAbsolutePath();
|
||||||
|
String slavePath = cfg.slaveFile().getAbsolutePath();
|
||||||
|
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||||||
|
List<String> columns = DbManager.COPY_COLUMNS;
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(4096);
|
||||||
|
sb.append("-- =============================================================\n");
|
||||||
|
sb.append("-- HSM 主备证书合并脚本(由 cert-compare 生成)\n");
|
||||||
|
sb.append("-- 生成时间: ").append(now).append('\n');
|
||||||
|
sb.append("-- 主库: ").append(mainPath).append('\n');
|
||||||
|
sb.append("-- 从库: ").append(slavePath).append('\n');
|
||||||
|
sb.append("--\n");
|
||||||
|
sb.append("-- 执行方式:\n");
|
||||||
|
sb.append("-- 1) 备份主库: cp -p ").append(mainPath)
|
||||||
|
.append(" ").append(mainPath).append(".bak-$(date +%Y%m%d-%H%M%S)\n");
|
||||||
|
sb.append("-- 2) 执行脚本: sqlite3 ").append(mainPath).append(" < <本文件>\n");
|
||||||
|
sb.append("--\n");
|
||||||
|
sb.append("-- 说明:\n");
|
||||||
|
sb.append("-- * 只写主库 usercert_tab;从库以 ATTACH 方式只读引用;\n");
|
||||||
|
sb.append("-- * subject/issuer 在 SQLite 内部按原始字节复制,不经过任何字符集转换,\n");
|
||||||
|
sb.append("-- DN 中的 GBK 中文按原字节写入,不会被重新编码;\n");
|
||||||
|
sb.append("-- * 所有语句按身份键(subject 原始字节 + certtype)定位,不依赖 DN 文本;\n");
|
||||||
|
sb.append("-- * 脚本可重复执行,重复执行结果一致;\n");
|
||||||
|
sb.append("-- * 若执行中报错,请先恢复备份,再排查后重跑,不要跳过错误继续执行;\n");
|
||||||
|
sb.append("-- * 需要回滚时,直接用备份文件覆盖主库。\n");
|
||||||
|
sb.append("--\n");
|
||||||
|
sb.append("-- 统计: 主库 ").append(result.mainCount).append(" 条,从库 ")
|
||||||
|
.append(result.slaveCount).append(" 条;")
|
||||||
|
.append("主库多 ").append(result.onlyMain.size()).append(",")
|
||||||
|
.append("从库多 ").append(result.onlySlave.size()).append(",")
|
||||||
|
.append("不一致 ").append(result.diff.size())
|
||||||
|
.append("(选主 ").append(stats.diffMainCount)
|
||||||
|
.append("、选备 ").append(stats.diffSlaveCount).append(");\n");
|
||||||
|
sb.append("-- 写入 ").append(stats.insertCount).append(" 条");
|
||||||
|
if (stats.updateCount > 0) {
|
||||||
|
sb.append(",按主库原字节替换 ").append(stats.updateCount).append(" 条");
|
||||||
|
}
|
||||||
|
sb.append(";执行后主库应为 ")
|
||||||
|
.append(result.mainCount + result.onlySlave.size()).append(" 条。\n");
|
||||||
|
sb.append("-- =============================================================\n");
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("PRAGMA foreign_keys = OFF;\n");
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("ATTACH DATABASE ").append(DbManager.sqlString(slavePath))
|
||||||
|
.append(" AS ").append(SOURCE_ALIAS).append(";\n");
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("BEGIN IMMEDIATE;\n");
|
||||||
|
|
||||||
|
if (!insertRecords.isEmpty()) {
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("-- 从库多 / 不一致选备(主备 DN 字节相同):按主键插入或整行替换\n");
|
||||||
|
for (int from = 0; from < insertRecords.size(); from += KEYS_PER_STATEMENT) {
|
||||||
|
int to = Math.min(from + KEYS_PER_STATEMENT, insertRecords.size());
|
||||||
|
appendInsert(sb, columns, insertRecords.subList(from, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (CompareRow row : updateRows) {
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("-- 不一致选备(主备 DN 原始字节不同,dn.match=TRIM):按主库字节定位,整行字段替换为从库值\n");
|
||||||
|
appendUpdate(sb, columns, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertRecords.isEmpty() && updateRows.isEmpty()) {
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("-- 没有需要写入的记录(主库已包含全部证书)。\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("COMMIT;\n");
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("DETACH DATABASE ").append(SOURCE_ALIAS).append(";\n");
|
||||||
|
sb.append('\n');
|
||||||
|
sb.append("-- 执行后自检:正常应输出 rows = ")
|
||||||
|
.append(result.mainCount + result.onlySlave.size())
|
||||||
|
.append(" 以及 ok\n");
|
||||||
|
sb.append("SELECT 'rows' AS item, count(*) AS value FROM usercert_tab;\n");
|
||||||
|
sb.append("PRAGMA integrity_check;\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendInsert(StringBuilder sb, List<String> columns, List<CertRecord> records) {
|
||||||
|
String columnList = DbManager.joinCopyColumns(null);
|
||||||
|
sb.append("INSERT OR REPLACE INTO usercert_tab\n");
|
||||||
|
sb.append(" (").append(columnList).append(")\n");
|
||||||
|
sb.append("SELECT ").append(columnList).append('\n');
|
||||||
|
sb.append(" FROM ").append(SOURCE_ALIAS).append(".usercert_tab\n");
|
||||||
|
sb.append(" WHERE ");
|
||||||
|
for (int i = 0; i < records.size(); i++) {
|
||||||
|
CertRecord r = records.get(i);
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append("\n OR ");
|
||||||
|
}
|
||||||
|
sb.append("(hex(subject) = ").append(hexLiteral(r.subjectHex))
|
||||||
|
.append(" AND certtype = ").append(r.certtype).append(')');
|
||||||
|
}
|
||||||
|
sb.append(";\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendUpdate(StringBuilder sb, List<String> columns, CompareRow row) {
|
||||||
|
CertRecord main = row.main;
|
||||||
|
CertRecord slave = row.slave;
|
||||||
|
String where = " WHERE hex(subject) = " + hexLiteral(slave.subjectHex)
|
||||||
|
+ " AND certtype = " + slave.certtype;
|
||||||
|
sb.append("UPDATE usercert_tab SET\n");
|
||||||
|
for (int i = 0; i < columns.size(); i++) {
|
||||||
|
sb.append(" ").append(columns.get(i)).append(" = (SELECT ").append(columns.get(i))
|
||||||
|
.append(" FROM ").append(SOURCE_ALIAS).append(".usercert_tab")
|
||||||
|
.append(where).append(')');
|
||||||
|
sb.append(i + 1 < columns.size() ? ",\n" : "\n");
|
||||||
|
}
|
||||||
|
sb.append(" WHERE hex(subject) = ").append(hexLiteral(main.subjectHex))
|
||||||
|
.append(" AND certtype = ").append(main.certtype).append(";\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hexLiteral(String hex) {
|
||||||
|
String h = hex == null ? "" : hex.trim().toUpperCase(Locale.ROOT);
|
||||||
|
return DbManager.sqlString(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成结果:脚本文本 + 统计。
|
||||||
|
*/
|
||||||
|
public static class Script {
|
||||||
|
public String text = "";
|
||||||
|
public int insertCount;
|
||||||
|
public int updateCount;
|
||||||
|
public int diffMainCount;
|
||||||
|
public int diffSlaveCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.sunyard.certcompare.util;
|
||||||
|
|
||||||
|
import java.time.DateTimeException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 14 位 yyyyMMddHHmmss 时间校验。
|
||||||
|
*/
|
||||||
|
public final class DateUtil {
|
||||||
|
|
||||||
|
private DateUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(String value) {
|
||||||
|
if (value == null || value.length() != 14) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 14; i++) {
|
||||||
|
if (!Character.isDigit(value.charAt(i))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int year = Integer.parseInt(value.substring(0, 4));
|
||||||
|
int month = Integer.parseInt(value.substring(4, 6));
|
||||||
|
int day = Integer.parseInt(value.substring(6, 8));
|
||||||
|
int hour = Integer.parseInt(value.substring(8, 10));
|
||||||
|
int minute = Integer.parseInt(value.substring(10, 12));
|
||||||
|
int second = Integer.parseInt(value.substring(12, 14));
|
||||||
|
try {
|
||||||
|
LocalDate.of(year, month, day);
|
||||||
|
} catch (DateTimeException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hour < 24 && minute < 60 && second < 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean beforeOrEqual(String a, String b) {
|
||||||
|
return a != null && b != null && a.compareTo(b) <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.sunyard.certcompare.util;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SN 归一化。仅用于比较,展示和写回始终使用原始值。
|
||||||
|
*/
|
||||||
|
public final class SnNormalizer {
|
||||||
|
|
||||||
|
private SnNormalizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalize(String sn, boolean hexMode) {
|
||||||
|
if (sn == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String trimmed = sn.trim();
|
||||||
|
if (!hexMode) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
String upper = trimmed.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.isEmpty()) {
|
||||||
|
return upper;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < upper.length(); i++) {
|
||||||
|
if (Character.digit(upper.charAt(i), 16) < 0) {
|
||||||
|
return upper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
while (i < upper.length() - 1 && upper.charAt(i) == '0') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return upper.substring(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.sunyard.certcompare.util;
|
||||||
|
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GB18030 与十六进制工具。DN/issuer 全程按原始字节处理。
|
||||||
|
*/
|
||||||
|
public final class Text {
|
||||||
|
|
||||||
|
public static final Charset GB18030 = Charset.forName("GB18030");
|
||||||
|
|
||||||
|
private Text() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String decode(byte[] raw) {
|
||||||
|
return decode(raw, GB18030);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String decode(byte[] raw, Charset charset) {
|
||||||
|
if (raw == null || raw.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new String(raw, charset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] hexToBytes(String hex) {
|
||||||
|
if (hex == null) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
String s = hex.trim();
|
||||||
|
if (s.regionMatches(true, 0, "HEX:", 0, 4)) {
|
||||||
|
s = s.substring(4);
|
||||||
|
}
|
||||||
|
s = s.replaceAll("\\s+", "");
|
||||||
|
if ((s.length() & 1) != 0) {
|
||||||
|
throw new IllegalArgumentException("十六进制长度必须为偶数: " + hex);
|
||||||
|
}
|
||||||
|
byte[] out = new byte[s.length() / 2];
|
||||||
|
for (int i = 0; i < out.length; i++) {
|
||||||
|
int hi = Character.digit(s.charAt(i * 2), 16);
|
||||||
|
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
|
||||||
|
if (hi < 0 || lo < 0) {
|
||||||
|
throw new IllegalArgumentException("非法十六进制字符: " + hex);
|
||||||
|
}
|
||||||
|
out[i] = (byte) ((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bytesToHex(byte[] raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length * 2);
|
||||||
|
for (byte b : raw) {
|
||||||
|
sb.append(Character.toUpperCase(Character.forDigit((b >> 4) & 0x0F, 16)));
|
||||||
|
sb.append(Character.toUpperCase(Character.forDigit(b & 0x0F, 16)));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isHex(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((value.length() & 1) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
if (Character.digit(value.charAt(i), 16) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 DN 中提取 CN,仅用于展示。
|
||||||
|
*/
|
||||||
|
public static String cnFromDn(String dn) {
|
||||||
|
if (dn == null || dn.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
for (String part : dn.split(",")) {
|
||||||
|
String p = part.trim();
|
||||||
|
if (p.regionMatches(true, 0, "CN=", 0, 3)) {
|
||||||
|
return p.substring(3).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int idx = dn.toUpperCase(Locale.ROOT).indexOf("CN=");
|
||||||
|
if (idx >= 0) {
|
||||||
|
String rest = dn.substring(idx + 3);
|
||||||
|
int comma = rest.indexOf(',');
|
||||||
|
return (comma >= 0 ? rest.substring(0, comma) : rest).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.model.CertKey;
|
||||||
|
import com.sunyard.certcompare.report.Csv;
|
||||||
|
import com.sunyard.certcompare.report.ReportWriter;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class CsvTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void escapeHandlesCommaQuoteAndNewline() {
|
||||||
|
assertEquals("abc", Csv.escape("abc"));
|
||||||
|
assertEquals("\"a,b\"", Csv.escape("a,b"));
|
||||||
|
assertEquals("\"a\"\"b\"", Csv.escape("a\"b"));
|
||||||
|
assertEquals("\"a\nb\"", Csv.escape("a\nb"));
|
||||||
|
assertEquals("\" a \"", Csv.escape(" a "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseRoundTrip() throws Exception {
|
||||||
|
String line = "1,\"C=CN,CN=a,b\",\"say \"\"hi\"\"\"\n";
|
||||||
|
List<Csv.CsvRecord> records = Csv.parse(line);
|
||||||
|
assertEquals(1, records.size());
|
||||||
|
assertEquals(3, records.get(0).fields.size());
|
||||||
|
assertEquals("C=CN,CN=a,b", records.get(0).fields.get(1));
|
||||||
|
assertEquals("say \"hi\"", records.get(0).fields.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseMultilineQuotedField() throws Exception {
|
||||||
|
String text = "a,\"line1\nline2\",c\nx,y,z\n";
|
||||||
|
List<Csv.CsvRecord> records = Csv.parse(text);
|
||||||
|
assertEquals(2, records.size());
|
||||||
|
assertEquals("line1\nline2", records.get(0).fields.get(1));
|
||||||
|
assertEquals(1, records.get(0).line);
|
||||||
|
assertEquals(3, records.get(1).line);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void certKeyIsCaseInsensitiveOnHex() {
|
||||||
|
CertKey a = new CertKey("B3C2", 1);
|
||||||
|
CertKey b = new CertKey("b3c2", 1);
|
||||||
|
assertEquals(a, b);
|
||||||
|
assertEquals(a.hashCode(), b.hashCode());
|
||||||
|
assertNotEquals(a, new CertKey("B3C2", 2));
|
||||||
|
assertTrue(a.compareTo(new CertKey("B3C3", 0)) < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void trimSubjectHexOnlyRemovesWhitespaceBytes() {
|
||||||
|
assertEquals("B3C2", CertKey.normalizeSubjectHex("20B3C220", true));
|
||||||
|
assertEquals("20B3C220", CertKey.normalizeSubjectHex("20B3C220", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void headerEndsWithChoiceAndHasNoNoteColumn() {
|
||||||
|
String[] header = ReportWriter.HEADER;
|
||||||
|
assertEquals("合并选择", header[header.length - 1]);
|
||||||
|
int choiceCount = 0;
|
||||||
|
for (String h : header) {
|
||||||
|
assertNotEquals("备注", h);
|
||||||
|
if ("合并选择".equals(h)) {
|
||||||
|
choiceCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1, choiceCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.model.CertRecord;
|
||||||
|
import com.sunyard.certcompare.model.CompareCategory;
|
||||||
|
import com.sunyard.certcompare.model.CompareResult;
|
||||||
|
import com.sunyard.certcompare.model.CompareRow;
|
||||||
|
import com.sunyard.certcompare.model.MergeChoice;
|
||||||
|
import com.sunyard.certcompare.sql.SqlScriptService;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class SqlScriptTest {
|
||||||
|
|
||||||
|
private static CompareRow row(CompareCategory category, CertRecord main, CertRecord slave) {
|
||||||
|
CertRecord any = main != null ? main : slave;
|
||||||
|
return new CompareRow(category, any.key(), main, slave,
|
||||||
|
new ArrayList<com.sunyard.certcompare.model.FieldDiff>(),
|
||||||
|
new ArrayList<String>(), "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CertRecord rec(String hex, int certtype) {
|
||||||
|
return new CertRecord(hex, "4F3D4341", "0A0B0C01",
|
||||||
|
"20260101000000", "20301231235959", certtype, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppConfig config() throws Exception {
|
||||||
|
AppConfig cfg = AppConfig.load(null, false);
|
||||||
|
cfg.setMainPath("/app/cert-compare/main.db");
|
||||||
|
cfg.setSlavePath("/app/cert-compare/slave.db");
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void insertAndUpdateStatementsAreByteBased() throws Exception {
|
||||||
|
CompareRow onlySlave = row(CompareCategory.ONLY_SLAVE, null, rec("B3C2", 0));
|
||||||
|
CompareRow sameBytes = row(CompareCategory.DIFF, rec("434445", 2), rec("434445", 2));
|
||||||
|
CompareRow mainChosen = row(CompareCategory.DIFF, rec("464748", 5), rec("464748", 5));
|
||||||
|
CompareRow trimCase = row(CompareCategory.DIFF, rec("20B3C220", 1), rec("B3C2", 1));
|
||||||
|
CompareRow onlyMain = row(CompareCategory.ONLY_MAIN, rec("494A", 4), null);
|
||||||
|
|
||||||
|
List<CompareRow> onlyMainRows = new ArrayList<>();
|
||||||
|
onlyMainRows.add(onlyMain);
|
||||||
|
List<CompareRow> onlySlaveRows = new ArrayList<>();
|
||||||
|
onlySlaveRows.add(onlySlave);
|
||||||
|
List<CompareRow> diffRows = new ArrayList<>();
|
||||||
|
diffRows.add(sameBytes);
|
||||||
|
diffRows.add(mainChosen);
|
||||||
|
diffRows.add(trimCase);
|
||||||
|
CompareResult result = new CompareResult(2, 2, 0, onlyMainRows, onlySlaveRows, diffRows,
|
||||||
|
new ArrayList<String>());
|
||||||
|
|
||||||
|
Map<com.sunyard.certcompare.model.CertKey, MergeChoice> choices = new HashMap<>();
|
||||||
|
choices.put(sameBytes.key, MergeChoice.SLAVE);
|
||||||
|
choices.put(mainChosen.key, MergeChoice.MAIN);
|
||||||
|
choices.put(trimCase.key, MergeChoice.SLAVE);
|
||||||
|
|
||||||
|
SqlScriptService.Script script = SqlScriptService.build(config(), result, choices);
|
||||||
|
|
||||||
|
assertEquals(1, script.diffMainCount);
|
||||||
|
assertEquals(2, script.diffSlaveCount);
|
||||||
|
assertEquals(2, script.insertCount);
|
||||||
|
assertEquals(1, script.updateCount);
|
||||||
|
|
||||||
|
String slavePath = config().slaveFile().getAbsolutePath();
|
||||||
|
assertTrue(script.text.contains("ATTACH DATABASE '" + slavePath + "' AS cert_src;"));
|
||||||
|
assertTrue(script.text.contains("INSERT OR REPLACE INTO usercert_tab"));
|
||||||
|
assertTrue(script.text.contains("(hex(subject) = 'B3C2' AND certtype = 0)"));
|
||||||
|
assertTrue(script.text.contains("(hex(subject) = '434445' AND certtype = 2)"));
|
||||||
|
assertTrue(script.text.contains("UPDATE usercert_tab SET"));
|
||||||
|
assertTrue(script.text.contains("hex(subject) = '20B3C220'"));
|
||||||
|
assertTrue(script.text.contains("BEGIN IMMEDIATE;"));
|
||||||
|
assertTrue(script.text.contains("COMMIT;"));
|
||||||
|
assertTrue(script.text.contains("DETACH DATABASE cert_src;"));
|
||||||
|
assertTrue(script.text.contains("PRAGMA integrity_check;"));
|
||||||
|
|
||||||
|
// 选主的记录不应产生任何语句
|
||||||
|
assertFalse(script.text.contains("'464748'"));
|
||||||
|
// DN 只以十六进制出现,脚本正文不包含解码后的中文
|
||||||
|
assertFalse(script.text.contains("陈超"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void emptyDifferencesStillProduceValidScript() throws Exception {
|
||||||
|
CompareResult result = new CompareResult(1, 1, 1,
|
||||||
|
new ArrayList<CompareRow>(), new ArrayList<CompareRow>(),
|
||||||
|
new ArrayList<CompareRow>(), new ArrayList<String>());
|
||||||
|
SqlScriptService.Script script = SqlScriptService.build(config(), result,
|
||||||
|
new HashMap<com.sunyard.certcompare.model.CertKey, MergeChoice>());
|
||||||
|
assertTrue(script.text.contains("没有需要写入的记录"));
|
||||||
|
assertEquals(0, script.insertCount);
|
||||||
|
assertEquals(0, script.updateCount);
|
||||||
|
assertTrue(script.text.contains("BEGIN IMMEDIATE;"));
|
||||||
|
assertTrue(script.text.contains("COMMIT;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.sunyard.certcompare;
|
||||||
|
|
||||||
|
import com.sunyard.certcompare.util.DateUtil;
|
||||||
|
import com.sunyard.certcompare.util.SnNormalizer;
|
||||||
|
import com.sunyard.certcompare.util.Text;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class UtilTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void hexRoundTripPreservesGbkBytes() {
|
||||||
|
byte[] gbk = new byte[]{(byte) 0xB3, (byte) 0xC2, (byte) 0xB3, (byte) 0xAC};
|
||||||
|
String hex = Text.bytesToHex(gbk);
|
||||||
|
assertEquals("B3C2B3AC", hex);
|
||||||
|
assertEquals("陈超", Text.decode(Text.hexToBytes(hex)));
|
||||||
|
assertEquals("B3C2B3AC", Text.bytesToHex(Text.hexToBytes("HEX:b3c2b3ac")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void snNormalizationRemovesLeadingZerosAndUppercases() {
|
||||||
|
assertEquals("1971EE53B08", SnNormalizer.normalize(" 01971ee53b08 ", true));
|
||||||
|
assertEquals("0", SnNormalizer.normalize("000", true));
|
||||||
|
assertEquals("abc", SnNormalizer.normalize("abc", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void dateValidation() {
|
||||||
|
assertTrue(DateUtil.isValid("20260101000000"));
|
||||||
|
assertFalse(DateUtil.isValid("20261301000000"));
|
||||||
|
assertFalse(DateUtil.isValid("2026010100000"));
|
||||||
|
assertFalse(DateUtil.isValid("20260101240000"));
|
||||||
|
assertTrue(DateUtil.beforeOrEqual("20260101000000", "20270101000000"));
|
||||||
|
assertFalse(DateUtil.beforeOrEqual("20270101000000", "20260101000000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void cnExtraction() {
|
||||||
|
assertEquals("172.1.40.188", Text.cnFromDn("C=CN,ST=北京,L=北京,O=信雅达,OU=双证,CN=172.1.40.188"));
|
||||||
|
assertEquals("041@x@陈超@y", Text.cnFromDn("C=cn,O=CFCA,CN=041@x@陈超@y"));
|
||||||
|
assertEquals("", Text.cnFromDn("C=CN,O=none"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# HSM 主备数据库证书比对工具 需求文档
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文档版本 | v0.4 |
|
||||||
|
| 编写日期 | 2026-11-04 |
|
||||||
|
| 状态 | 已更新(批量 SQL 方案) |
|
||||||
|
| 关联文档 | `设计文档.md` |
|
||||||
|
| 测试环境 | 172.1.41.139(CentOS 6.5 / JDK 1.8 / SQLite) |
|
||||||
|
| 测试数据 | `/app/cert-compare/main.db`(4 条)、`/app/cert-compare/slave.db`(15 条) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
HSM 系统使用 SQLite3 数据库保存用户证书,证书保存在 `usercert_tab` 表中,生产规模约 3000 张。主、备两套系统各自维护一份数据库,长期运行后可能出现:
|
||||||
|
|
||||||
|
- 主库中存在、从库中不存在的证书(主库多);
|
||||||
|
- 从库中存在、主库中不存在的证书(从库多);
|
||||||
|
- 两边都存在同一张证书(同一 DN + 证书类型),但 SN、有效期、状态等字段不一致;
|
||||||
|
- 同一 DN 下存在多张证书(例如双证),不能简单地按 DN 去重。
|
||||||
|
|
||||||
|
主备库中的证书都是业务需要的,不存在“多余证书”的概念。比对的目的不是删除数据,而是:
|
||||||
|
|
||||||
|
1. 找出主库多、从库多、字段不一致三类差异;
|
||||||
|
2. 由业务人员在报表中逐条确认合并后采用“主”还是“备”;
|
||||||
|
3. 以主库为基础,把从库多出的证书补进来,并按人工标注处理冲突,最终形成完整可用的主库数据。
|
||||||
|
|
||||||
|
目前依赖人工执行 `sqlite3` 命令核对,存在以下问题:
|
||||||
|
|
||||||
|
- 表中 `subject`/`issuer` 实际存储的是 **GBK/GB18030 字节**,而 SQLite 头部声明为 UTF-8,直接用 UTF-8 工具读取会出现乱码甚至错误合并;
|
||||||
|
- 证书身份不是单纯的 DN,表主键为 `(subject, certtype)`;
|
||||||
|
- 3000 条数据人工比对不可行,且容易遗漏;
|
||||||
|
- 合并时如果经过 Java `String` 转换,会破坏原始 GBK 字节,导致写回的数据无法被原系统正确查询。
|
||||||
|
|
||||||
|
因此需要一个可在生产环境直接运行的 Java CLI 工具,支持交互式操作和脚本化调用,完成主备库证书比对,并依据人工标注生成在主库执行的批量 SQL 脚本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 建设目标
|
||||||
|
|
||||||
|
1. 通过配置文件同时加载主库、从库两个 SQLite3 数据库。
|
||||||
|
2. 以主库为基准,准确识别三类差异:
|
||||||
|
- **主库多**:仅主库存在;
|
||||||
|
- **从库多**:仅从库存在;
|
||||||
|
- **不一致**:两边都有,但比较字段不同。
|
||||||
|
3. 提供 `set main/slave`、`compare`、`sql` 等交互命令。
|
||||||
|
4. `compare` 只输出**一份报表**,包含三类差异;汇总统计直接打印在控制台。
|
||||||
|
5. 报表预留“合并选择”字段,人工填写“主”或“备”。
|
||||||
|
6. `sql` 基于人工标注生成**在主库上执行的批量 SQL 脚本**:
|
||||||
|
- 以主库为基准;
|
||||||
|
- 从库多的证书全部补入;
|
||||||
|
- 不一致的证书按人工标注选择主库版本或从库版本;
|
||||||
|
- 脚本由 DBA 在主库执行,工具本身不直接写数据库;
|
||||||
|
- 从库仅以 `ATTACH` 只读引用,DN 中文按原始字节复制。
|
||||||
|
7. 不输出日志文件;运行信息、错误信息只打印到控制台。
|
||||||
|
8. 兼容生产环境:CentOS 6.5、glibc 2.12、JDK 1.8、无外网、无 Maven。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
|
||||||
|
### 3.1 本期范围(In Scope)
|
||||||
|
|
||||||
|
- 仅处理 `usercert_tab` 表;
|
||||||
|
- 主库、从库两个数据库路径,以及 SQL 脚本输出目录的配置与切换;
|
||||||
|
- 证书比对与三类差异统计;
|
||||||
|
- 单份 CSV 报表(含人工合并选择列);
|
||||||
|
- 依据报表标注生成批量 SQL 脚本,由 DBA 在主库执行完成合并;
|
||||||
|
- 控制台汇总、错误提示、退出码;
|
||||||
|
- 交互式 Shell 与一次性命令两种使用方式;
|
||||||
|
- 在测试库上完成验收。
|
||||||
|
|
||||||
|
### 3.2 不在本期范围(Out of Scope)
|
||||||
|
|
||||||
|
- HSM 硬件、密钥、私钥的读取或操作;
|
||||||
|
- 证书签发、吊销、CRL、信任列表维护;
|
||||||
|
- `trustlist_tab`、`apetcert_tab`、`sym_key` 等其他表的合并(SQL 脚本只操作 `usercert_tab`);
|
||||||
|
- 修改现有数据库表结构或索引;
|
||||||
|
- Web UI、服务端常驻进程、定时任务;
|
||||||
|
- 日志文件、审计日志;
|
||||||
|
- 证书密码学有效性验证(签名链、私钥匹配等);
|
||||||
|
- 报表中除“主/备”之外的其他合并策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 术语
|
||||||
|
|
||||||
|
| 术语 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 主库(main) | 基础数据库,合并时以它为基础 |
|
||||||
|
| 从库(slave) | 参与比对、提供差异证书的数据库 |
|
||||||
|
| 批量 SQL 脚本 | `sql` 生成的 `merge-<时间戳>.sql`,由 DBA 在主库执行完成合并 |
|
||||||
|
| DN / subject | 证书主体名称,表中为 `C=...,O=...,OU=...,CN=...` 形式的自定义顺序字符串,实际为 GBK 字节 |
|
||||||
|
| SN | 证书序列号,表中为十六进制字符串,可能带前导 0,逻辑上唯一 |
|
||||||
|
| certtype | 证书类型,表主键的一部分;同一 DN 可有多张不同 certtype 的证书(如双证) |
|
||||||
|
| 身份键 | 判断“同一张证书”的键:`subject 原始字节 + certtype` |
|
||||||
|
| 主库多 | 主库存在、从库不存在的证书 |
|
||||||
|
| 从库多 | 从库存在、主库不存在的证书 |
|
||||||
|
| 不一致 | 身份键相同,但 SN、有效期、状态等比较字段不同 |
|
||||||
|
| 一致 | 身份键相同且比较字段全部相同;只在汇总统计中计数,不进入报表 |
|
||||||
|
| 合并选择 | 报表中的人工标注列,取值“主”或“备” |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 用户与使用场景
|
||||||
|
|
||||||
|
| 角色 | 场景 |
|
||||||
|
| --- | --- |
|
||||||
|
| 运维人员 | 例行核对主备证书差异,导出报表 |
|
||||||
|
| 实施人员 | 数据迁移、容灾切换后核对并生成批量 SQL 脚本,交 DBA 在主库执行 |
|
||||||
|
| 研发/测试 | 定位双证、续期、状态差异问题 |
|
||||||
|
| 业务人员 | 在报表中逐条标注不一致证书采用主库还是从库版本 |
|
||||||
|
| 自动化脚本 | 执行 `compare` 生成报表,标注后执行 `sql` 生成批量脚本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据规则(已按测试库验证)
|
||||||
|
|
||||||
|
### 6.1 表结构
|
||||||
|
|
||||||
|
`usercert_tab` 主要字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 比对角色 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `organid` | varchar(32) | 机构标识 | 扩展字段 |
|
||||||
|
| `subject` | varchar(256) | DN,GBK/GB18030 字节 | **身份键**、展示字段 |
|
||||||
|
| `issuer` | varchar(256) | 颁发者,GBK/GB18030 字节 | 默认比较字段 |
|
||||||
|
| `sn` | varchar(64) | 证书序列号,十六进制字符串 | **唯一编号**、默认比较字段 |
|
||||||
|
| `pubkey_alg` | int | 公钥算法 | 扩展字段 |
|
||||||
|
| `pubkey` | varchar(1032) | 公钥 | 扩展字段(比较哈希) |
|
||||||
|
| `sign_alg` | int | 签名算法 | 扩展字段 |
|
||||||
|
| `signinfo` | varchar(512) | 签名信息 | 扩展字段(比较哈希) |
|
||||||
|
| `not_before` | varchar(14) | 生效时间 `yyyyMMddHHmmss` | 默认比较字段 |
|
||||||
|
| `not_after` | varchar(14) | 失效时间 `yyyyMMddHHmmss` | 默认比较字段 |
|
||||||
|
| `certfile` | varchar(2048) | PEM 证书内容 | 扩展字段(比较哈希) |
|
||||||
|
| `certtype` | smallint | 证书类型 | **身份键** |
|
||||||
|
| `opertime` | varchar(14) | 操作时间 | 扩展字段 |
|
||||||
|
| `stat` | smallint | 状态 | 默认比较字段 |
|
||||||
|
|
||||||
|
主键:`PRIMARY KEY (subject, certtype)`。
|
||||||
|
|
||||||
|
> 重要:不能只用 DN 判断同一张证书。测试库中 `sge` 机构同一 DN 存在 `certtype=0` 和 `certtype=2` 两张证书,若按 DN 去重会误判。
|
||||||
|
|
||||||
|
### 6.2 编码规则
|
||||||
|
|
||||||
|
- SQLite `PRAGMA encoding` 返回 `UTF-8`,但 `subject`、`issuer` 等字段实际写入的是 **GBK/GB18030 字节**;
|
||||||
|
- 使用 JDBC `getString()` 读取会把非法 UTF-8 字节替换为 `U+FFFD`,**严禁用于比对和回写**;
|
||||||
|
- 必须通过 `hex(col)` 或 `CAST(col AS BLOB)` 获取原始字节;
|
||||||
|
- 展示时按 GB18030 解码;解码失败时显示十六进制和转义字符;
|
||||||
|
- 合并写入必须使用 SQLite 内部 `ATTACH + INSERT ... SELECT`,避免 Java 字符串编码转换。
|
||||||
|
|
||||||
|
### 6.3 SN 规则
|
||||||
|
|
||||||
|
- 测试库中 SN 均为十六进制字符串,且与证书 DER 序列号一致;
|
||||||
|
- 存在前导 0,例如表中 `01971EE53B08`,DER 序列号为 `1971EE53B08`;
|
||||||
|
- 比对时默认归一化:去首尾空白、转大写、去前导 0;展示和写回保留原始值;
|
||||||
|
- SN 在单个库内逻辑唯一;跨库同 SN 视为同一序列号,但仍以身份键定位证书。
|
||||||
|
|
||||||
|
### 6.4 时间规则
|
||||||
|
|
||||||
|
- `not_before`、`not_after` 为 14 位 `yyyyMMddHHmmss` 本地时间字符串;
|
||||||
|
- 测试库全部记录与证书 DER 有效期一致;
|
||||||
|
- 比对按原始字符串比较,同时校验格式与 `not_before <= not_after`;
|
||||||
|
- 可选出报告:已过期、未生效、有效,按当前系统时间计算。
|
||||||
|
|
||||||
|
### 6.5 certtype 说明
|
||||||
|
|
||||||
|
测试库中观察到 `certtype` 取值 `0/1/2/4/5`,同一 DN 可出现多个值。本期不硬编码业务含义,报表按数值展示;后续可通过配置映射为中文名称。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 功能需求
|
||||||
|
|
||||||
|
### FR-01 配置加载
|
||||||
|
|
||||||
|
- 支持 UTF-8 编码的 `config.properties`;
|
||||||
|
- 至少包含:主库路径、从库路径、文本编码、DN 匹配方式、SN 归一化方式、报表目录;
|
||||||
|
- 命令行参数优先于配置文件;
|
||||||
|
- 配置文件不存在时使用内置默认值,但执行 `compare`/`sql` 前必须已有有效配置;
|
||||||
|
- 配置错误时给出明确中文提示。
|
||||||
|
|
||||||
|
**验收**:无参数启动进入交互模式;配置中的路径正确显示;路径错误时提示“文件不存在/不可读”,且不覆盖原配置。
|
||||||
|
|
||||||
|
### FR-02 交互式 Shell
|
||||||
|
|
||||||
|
- 启动后显示 `cert-compare>` 提示符;
|
||||||
|
- 支持命令:`set`、`status`、`compare`、`sql`、`help`、`quit`/`exit`;
|
||||||
|
- 命令大小写不敏感;
|
||||||
|
- 支持单引号/双引号包裹含空格的参数;
|
||||||
|
- 空行忽略;`Ctrl-D` 等同于 `quit`;
|
||||||
|
- `TERM=dumb` 或非 TTY 时不输出 ANSI 颜色控制字符;
|
||||||
|
- 单条命令失败不退出 Shell,仅打印错误。
|
||||||
|
|
||||||
|
**验收**:连续执行多条命令,错误后仍可继续;`quit` 正常退出并返回退出码 0。
|
||||||
|
|
||||||
|
### FR-03 set main
|
||||||
|
|
||||||
|
```text
|
||||||
|
set main <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 校验文件存在、可读、是合法 SQLite 文件、包含 `usercert_tab` 表、必需字段齐全;
|
||||||
|
- 成功时输出:绝对路径、文件大小、证书总数;
|
||||||
|
- 仅保存路径,不长期持有数据库连接,避免锁库;
|
||||||
|
- 无参数执行 `set main` 时显示当前主库路径与状态。
|
||||||
|
|
||||||
|
### FR-04 set slave
|
||||||
|
|
||||||
|
同 FR-03,用于设置从库路径。
|
||||||
|
|
||||||
|
### FR-05 SQL 脚本输出位置
|
||||||
|
|
||||||
|
- 不提供单独的输出目录设置命令;
|
||||||
|
- `sql` 生成的脚本直接写入**运行时的当前工作目录**;
|
||||||
|
- 脚本文件名固定为 `merge-<yyyyMMdd-HHmmss>.sql`,每次生成新文件,不覆盖历史脚本;
|
||||||
|
- 启动脚本 `cert-compare.sh` 会先 `cd` 到程序目录,因此在服务器上脚本默认落在 `/app/cert-compare/`。
|
||||||
|
|
||||||
|
### FR-06 status
|
||||||
|
|
||||||
|
显示当前会话状态:
|
||||||
|
|
||||||
|
- 主库、从库的绝对路径、是否存在、文件大小、证书数量;
|
||||||
|
- 当前工作目录(即 SQL 脚本输出位置);
|
||||||
|
- 当前编码、DN 匹配方式、SN 归一化方式;
|
||||||
|
- 主库与从库表结构是否一致;
|
||||||
|
- 最近一次 `compare` 生成的报表路径;
|
||||||
|
- 最近一次 `sql` 生成的脚本路径。
|
||||||
|
|
||||||
|
### FR-07 compare 分类
|
||||||
|
|
||||||
|
对两库全部证书按身份键分类:
|
||||||
|
|
||||||
|
| 分类 | 判定 | 是否进入报表 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主库多(ONLY_MAIN) | 主库有、从库无 | 是 |
|
||||||
|
| 从库多(ONLY_SLAVE) | 从库有、主库无 | 是 |
|
||||||
|
| 不一致(DIFF) | 两边都有,至少一个默认比较字段不同 | 是 |
|
||||||
|
| 一致(EQUAL) | 两边都有,默认比较字段全部相同 | 否,仅汇总计数 |
|
||||||
|
|
||||||
|
默认比较字段:`sn`(归一化后)、`issuer`(原始字节)、`not_before`、`not_after`、`stat`。
|
||||||
|
|
||||||
|
`compare` 不做过滤、不做查询、不接收业务筛选参数;所有差异一次性写入同一份报表,由使用者在报表中自行过滤。
|
||||||
|
|
||||||
|
**验收**:测试库 `main.db`(4 条)与 `slave.db`(15 条)比对结果为:一致 0、主库多 1、从库多 12、不一致 3;报表共 16 条差异记录。
|
||||||
|
|
||||||
|
### FR-08 compare 报表
|
||||||
|
|
||||||
|
- 只生成**一份 CSV 报表**,不再拆分为多个文件;
|
||||||
|
- 文件名:`compare-report-<yyyyMMdd-HHmmss>.csv`;
|
||||||
|
- 控制台只打印汇总统计和报表路径,不打印逐条明细;
|
||||||
|
- CSV 使用 UTF-8 + BOM,保证 Excel 打开中文不乱码;
|
||||||
|
- 报表只包含主库多、从库多、不一致三类记录,不包含一致记录;
|
||||||
|
- 报表至少包含以下列:
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 序号 | 报表内序号,便于人工沟通 |
|
||||||
|
| 类型 | 主库多 / 从库多 / 不一致 |
|
||||||
|
| DN | GB18030 解码后的 DN,便于阅读 |
|
||||||
|
| DN_HEX | 原始 DN 字节十六进制,带 `HEX:` 前缀,用于精确匹配 |
|
||||||
|
| CN | 从 DN 提取的 CN,仅展示 |
|
||||||
|
| CERTTYPE | 证书类型 |
|
||||||
|
| 主库SN / 从库SN | 两边 SN,不存在的一侧留空 |
|
||||||
|
| 主库生效 / 主库失效 | 主库有效期,不存在的一侧留空 |
|
||||||
|
| 从库生效 / 从库失效 | 从库有效期,不存在的一侧留空 |
|
||||||
|
| 主库状态 / 从库状态 | 两边 `stat`,不存在的一侧留空 |
|
||||||
|
| 差异字段 | 不一致时列出差异字段,如 `sn;not_after` |
|
||||||
|
| 合并选择 | **最后一列,人工填写:主 / 备** |
|
||||||
|
|
||||||
|
- `合并选择` 固定放在最后一列,便于 Excel 中直接拉到最右侧填写;
|
||||||
|
- 报表不再包含“备注”列,数据异常信息改由 `compare` 在控制台“提示”中输出;
|
||||||
|
- `DN_HEX` 统一加 `HEX:` 前缀,避免 Excel 把长十六进制转换成科学计数法;`sql` 读取时自动去掉前缀;
|
||||||
|
- 默认不输出 `certfile`、`pubkey`、`signinfo` 等大字段;
|
||||||
|
- 报表生成后,控制台打印报表绝对路径和汇总统计。
|
||||||
|
|
||||||
|
### FR-09 人工标注规则
|
||||||
|
|
||||||
|
- **主库多**:只有主库记录,`合并选择` 默认填 `主`,且只允许 `主`;
|
||||||
|
- **从库多**:只有从库记录,`合并选择` 默认填 `备`,且只允许 `备`;
|
||||||
|
- **不一致**:`合并选择` 默认留空,必须人工填写 `主` 或 `备`;
|
||||||
|
- 空白或非法值(非“主/备”)在 `sql` 时视为错误,拒绝生成脚本并提示具体行号;
|
||||||
|
- 人工只能修改 `合并选择` 列,不应修改 DN、DN_HEX、CERTTYPE 等定位列;
|
||||||
|
- 报表保存为 UTF-8 或 GBK/GB18030 均可,`sql` 读取时自动识别。
|
||||||
|
|
||||||
|
### FR-10 sql 生成计划
|
||||||
|
|
||||||
|
`sql` 读取已人工标注的报表,校验后生成批量 SQL 脚本,并先打印计划:
|
||||||
|
|
||||||
|
- 主库证书数、从库证书数;
|
||||||
|
- 一致、主库多、从库多、不一致数量;
|
||||||
|
- 写入记录数(从库多 + 不一致选备);
|
||||||
|
- 不一致中选择“主”的数量、选择“备”的数量;
|
||||||
|
- 执行后主库应有的记录数;
|
||||||
|
- 从库路径(脚本以 `ATTACH` 只读引用)与脚本输出路径;
|
||||||
|
- 控制台给出“备份主库 + `sqlite3 主库 < 脚本`”的执行命令。
|
||||||
|
|
||||||
|
`sql` 只写脚本文件,不连接主库执行、不修改任何数据库。
|
||||||
|
|
||||||
|
### FR-11 批量 SQL 脚本内容
|
||||||
|
|
||||||
|
- 脚本输出到运行时的当前工作目录,文件名 `merge-<yyyyMMdd-HHmmss>.sql`;
|
||||||
|
- 脚本本身不携带证书数据,而是 `ATTACH DATABASE '<从库>' AS cert_src` 后按身份键复制;
|
||||||
|
- **从库多**的证书:`INSERT OR REPLACE ... SELECT ... FROM cert_src.usercert_tab WHERE ...`;
|
||||||
|
- **不一致 + 选择主**:不产生语句,主库版本自然保留;
|
||||||
|
- **不一致 + 选择备**:用从库版本整行替换主库版本;
|
||||||
|
- **主库多**:不产生语句,主库版本自然保留;
|
||||||
|
- 所有语句按身份键(`hex(subject)` + `certtype`)定位,**不出现 DN 文本**,从库记录由 SQLite 内部按原始字节复制,DN 中的 GBK 中文不会被解码或重新编码;
|
||||||
|
- 脚本使用 `BEGIN IMMEDIATE` / `COMMIT` 包裹,可重复执行且结果一致(幂等);
|
||||||
|
- 脚本末尾输出 `select 'rows', count(*)` 与 `PRAGMA integrity_check` 供执行后自检;
|
||||||
|
- 脚本为 UTF-8(无 BOM),SQL 语句与字符串字面量全部为 ASCII,仅注释包含中文。
|
||||||
|
|
||||||
|
### FR-12 sql 报表校验
|
||||||
|
|
||||||
|
生成脚本前必须校验:
|
||||||
|
|
||||||
|
1. 报表存在、可解析、列头完整;
|
||||||
|
2. 报表中的差异记录与当前两库重新比对的结果一致(差异数量、身份键、类型);报表中的 SN/有效期等字段仅供人工阅读,脚本数据始终取自当前数据库;
|
||||||
|
3. 每条记录都有合法的“合并选择”;
|
||||||
|
4. 报表中的差异键集合、类型与当前比对一致;差异集合发生变化时视为报表过期,必须重新 `compare`。
|
||||||
|
|
||||||
|
校验失败时拒绝生成脚本,提示“报表已过期或标注不完整,请重新执行 compare 并标注”,不产生任何脚本。
|
||||||
|
|
||||||
|
### FR-13 脚本执行与结果校验
|
||||||
|
|
||||||
|
脚本由 DBA 在主库上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -p /app/cert-compare/main.db /app/cert-compare/main.db.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
sqlite3 /app/cert-compare/main.db < /app/cert-compare/merge-20261104-213307.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后应满足:
|
||||||
|
|
||||||
|
1. 脚本输出 `rows` 等于“主库数量 + 从库多数量”;
|
||||||
|
2. `PRAGMA integrity_check` 返回 `ok`;
|
||||||
|
3. 选择“主”的不一致记录保持主库版本;
|
||||||
|
4. 选择“备”的不一致记录等于从库版本;
|
||||||
|
5. 从库多记录全部存在,DN/SN/时间等字段与从库一致;
|
||||||
|
6. 从库文件不被修改(脚本只读引用);
|
||||||
|
7. 主库 DN 中文按原始字节写入,`hex(subject)` 与来源库一致。
|
||||||
|
|
||||||
|
### FR-14 错误与退出码
|
||||||
|
|
||||||
|
- 不生成任何日志文件;
|
||||||
|
- 运行信息、警告、错误只输出到控制台;
|
||||||
|
- `--debug` 时可在控制台输出详细堆栈;
|
||||||
|
- 退出码:
|
||||||
|
|
||||||
|
| 退出码 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | 成功(交互模式正常退出) |
|
||||||
|
| 1 | 未预期内部错误 |
|
||||||
|
| 2 | 参数/配置错误 |
|
||||||
|
| 4 | 数据库打开失败、表结构不兼容、完整性校验失败 |
|
||||||
|
| 5 | 报表过期、标注非法、SQL 脚本生成/写入失败 |
|
||||||
|
|
||||||
|
### FR-15 一次性命令模式
|
||||||
|
|
||||||
|
支持不进入交互 Shell,直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成报表
|
||||||
|
java -Dfile.encoding=UTF-8 -jar cert-compare.jar compare \
|
||||||
|
--config /app/cert-compare/config.properties
|
||||||
|
|
||||||
|
# 人工标注报表后生成批量 SQL
|
||||||
|
java -Dfile.encoding=UTF-8 -jar cert-compare.jar sql \
|
||||||
|
--config /app/cert-compare/config.properties \
|
||||||
|
--report /app/cert-compare/reports/compare-report-20261104-213307-annotated.csv
|
||||||
|
|
||||||
|
# 由 DBA 在主库执行生成的脚本
|
||||||
|
sqlite3 /app/cert-compare/main.db < /app/cert-compare/merge-20261104-213307.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
一次性模式与交互模式共用同一套比对、报表、SQL 生成引擎,结果必须一致。
|
||||||
|
|
||||||
|
### FR-16 help
|
||||||
|
|
||||||
|
- `help` 输出命令列表与示例;
|
||||||
|
- `help compare`、`help sql` 输出用法说明;
|
||||||
|
- 未知命令给出提示,不退出 Shell。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 非功能需求
|
||||||
|
|
||||||
|
| 编号 | 需求 |
|
||||||
|
| --- | --- |
|
||||||
|
| NFR-01 兼容性 | 可在 CentOS 6.5 / glibc 2.12 / JDK 1.8 上运行;无外网依赖;不要求服务器安装 Maven |
|
||||||
|
| NFR-02 性能 | 3000 张证书:`compare` ≤ 3 秒,`sql` ≤ 3 秒;生成的脚本可在 sqlite3 3.6.20 上执行;JVM 内存 ≤ 256MB |
|
||||||
|
| NFR-03 编码正确性 | DN/issuer 原始字节零损失;合并前后十六进制一致;脚本中不出现 DN 文本;禁止 `U+FFFD` 参与比较 |
|
||||||
|
| NFR-04 数据安全 | 工具默认只读源库;不直接修改任何数据库,只生成 SQL 脚本;不生成日志文件 |
|
||||||
|
| NFR-05 可靠性 | 脚本使用事务包裹且可重复执行;执行失败可按备份回滚;从库 SHA-256 不变 |
|
||||||
|
| NFR-06 易用性 | 中文提示;报表可直接用 Excel 标注;错误信息包含原因与处理建议 |
|
||||||
|
| NFR-07 可维护性 | 分层设计;核心逻辑有单元测试;不修改现有表结构 |
|
||||||
|
| NFR-08 可部署性 | 单个可执行 fat jar + 配置文件 + 启动脚本即可部署 |
|
||||||
|
| NFR-09 报表兼容性 | 报表读取兼容 UTF-8 BOM、UTF-8、GBK/GB18030 三种保存方式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 交互与输出示例
|
||||||
|
|
||||||
|
### 9.1 设置数据库
|
||||||
|
|
||||||
|
```text
|
||||||
|
cert-compare> set main /app/cert-compare/main.db
|
||||||
|
[OK] 主库: /app/cert-compare/main.db (459,776 字节, 4 张证书)
|
||||||
|
|
||||||
|
cert-compare> set slave /app/cert-compare/slave.db
|
||||||
|
[OK] 从库: /app/cert-compare/slave.db (459,776 字节, 15 张证书)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 比对
|
||||||
|
|
||||||
|
```text
|
||||||
|
cert-compare> compare
|
||||||
|
|
||||||
|
主库: /app/cert-compare/main.db 证书数: 4
|
||||||
|
从库: /app/cert-compare/slave.db 证书数: 15
|
||||||
|
|
||||||
|
汇总:
|
||||||
|
一致 0
|
||||||
|
主库多 1
|
||||||
|
从库多 12
|
||||||
|
不一致 3
|
||||||
|
差异合计 16
|
||||||
|
|
||||||
|
报表:
|
||||||
|
/app/cert-compare/reports/compare-report-20261103-205500.csv
|
||||||
|
|
||||||
|
请按“合并选择”列标注:主 或 备,然后执行 sql 生成批量脚本。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 报表示例(CSV)
|
||||||
|
|
||||||
|
```csv
|
||||||
|
序号,类型,DN,DN_HEX,CN,CERTTYPE,主库SN,从库SN,主库生效,主库失效,从库生效,从库失效,主库状态,从库状态,差异字段,合并选择
|
||||||
|
1,从库多,"C=CN,ST=北京,L=北京,O=信雅达,OU=双证,CN=172.1.40.188","HEX:433D434E2C...",172.1.40.188,0,,01971EE53B08,,,20250529160000,20260529160000,,0,,备
|
||||||
|
2,主库多,"C=cn,O=CFCA OCA1 SM2,OU=Local RA OCA1,OU=Business Customers,CN=041@...@陈超@...","HEX:433D636E2C...",041@...@陈超@...,1,1177482454,,20260806025132,20270806025132,,,,0,,主
|
||||||
|
14,不一致,CN=diff-sn-01,"HEX:434E3D646966662D736E2D3031",diff-sn-01,0,0A0B0C01,0A0B0C11,20260101000000,20301231235959,20260101000000,20301231235959,0,0,sn,主
|
||||||
|
15,不一致,CN=diff-stat-03,"HEX:434E3D646966662D737461742D3033",diff-stat-03,5,0A0B0C03,0A0B0C03,20260101000000,20301231235959,20260101000000,20301231235959,0,1,stat,主
|
||||||
|
16,不一致,CN=diff-time-02,"HEX:434E3D646966662D74696D652D3032",diff-time-02,2,0A0B0C02,0A0B0C02,20260101000000,20301231235959,20260101000000,20311231235959,0,0,not_after,备
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 生成批量 SQL
|
||||||
|
|
||||||
|
```text
|
||||||
|
cert-compare> sql --report /app/cert-compare/reports/compare-report-20261104-213307-annotated.csv
|
||||||
|
|
||||||
|
SQL 生成计划:
|
||||||
|
主库证书数 4
|
||||||
|
从库证书数 15
|
||||||
|
一致 0
|
||||||
|
主库多 1
|
||||||
|
从库多 12
|
||||||
|
不一致 3
|
||||||
|
写入(从库多+选备) 13
|
||||||
|
不一致选主 2
|
||||||
|
不一致选备 1
|
||||||
|
执行后主库记录数 16
|
||||||
|
从库(只读引用) /app/cert-compare/slave.db
|
||||||
|
SQL 文件 /app/cert-compare/merge-20261104-213307.sql
|
||||||
|
|
||||||
|
请在主库上执行(执行前先备份):
|
||||||
|
cp -p /app/cert-compare/main.db /app/cert-compare/main.db.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
sqlite3 /app/cert-compare/main.db < /app/cert-compare/merge-20261104-213307.sql
|
||||||
|
|
||||||
|
执行后主库记录数应为 16 条;脚本末尾会输出 rows 与 integrity_check 结果。
|
||||||
|
```
|
||||||
|
|
||||||
|
执行脚本后:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rows|16
|
||||||
|
ok
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 验收标准
|
||||||
|
|
||||||
|
| 编号 | 场景 | 期望结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AC-01 | `set main/slave` | 路径正确显示;主库 4 条、从库 15 条;当前目录可写 |
|
||||||
|
| AC-02 | `compare` | 一致 0、主库多 1、从库多 12、不一致 3;控制台只打印汇总 |
|
||||||
|
| AC-03 | 报表 | 只生成一份 CSV,含 16 条差异记录;“合并选择”为最后一列,无“备注”列 |
|
||||||
|
| AC-04 | 默认标注 | 主库多默认“主”,从库多默认“备”,不一致留空 |
|
||||||
|
| AC-05 | 标注后 sql | 生成 1 个 SQL 脚本;1 条 INSERT OR REPLACE(13 个身份键);无 UPDATE 语句 |
|
||||||
|
| AC-06 | 不一致选主 | 执行脚本后该身份键字段与主库一致 |
|
||||||
|
| AC-07 | 不一致选备 | 执行脚本后该身份键字段与从库一致 |
|
||||||
|
| AC-08 | 报表编码 | UTF-8 BOM 报表 Excel 打开中文正常;GBK 保存后 sql 仍可读取 |
|
||||||
|
| AC-09 | 报表过期 | 数据库变化后使用旧报表生成脚本,拒绝执行并提示重新 compare |
|
||||||
|
| AC-10 | 标注非法 | 不一致记录留空或填“其他”,生成脚本被拒绝并提示行号 |
|
||||||
|
| AC-11 | 同一 DN 不同 certtype | `sge` 的 certtype=0 与 2 均保留,不互相覆盖 |
|
||||||
|
| AC-12 | 字节保持 | 执行脚本后每条 `hex(subject)` 与来源库一致,`typeof(subject)='text'` |
|
||||||
|
| AC-13 | 源库只读 | 生成脚本前后两源文件 SHA-256 不变;执行后从库 SHA-256 不变 |
|
||||||
|
| AC-14 | 无日志文件 | 运行过程不生成 `*.log`,信息仅输出到控制台 |
|
||||||
|
| AC-15 | 3000 条模拟数据 | compare ≤ 3s,sql ≤ 3s,内存 ≤ 256MB |
|
||||||
|
| AC-16 | 脚本兼容性 | 脚本可在服务器 sqlite3 3.6.20 上一次执行成功,重复执行结果一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 假设、约束与待确认事项
|
||||||
|
|
||||||
|
### 11.1 已确认
|
||||||
|
|
||||||
|
- `set main` / `set slave` 设置主库、从库路径;
|
||||||
|
- `sql` 生成的脚本直接写入运行时的当前工作目录;
|
||||||
|
- `sql` 只生成脚本,不直接修改任何数据库;脚本由 DBA 在主库执行;
|
||||||
|
- 脚本通过 `ATTACH` 从从库按原始字节复制,DN 中文不经过字符集转换;
|
||||||
|
- 证书身份键为 **DN + certtype**,SN 作为唯一编号参与比较;
|
||||||
|
- 主库多、从库多、不一致三类差异均进入同一份报表;
|
||||||
|
- 合并选择只有“主”“备”两种;
|
||||||
|
- 不生成日志文件。
|
||||||
|
|
||||||
|
### 11.2 假设
|
||||||
|
|
||||||
|
- `usercert_tab` 结构在生产与测试环境一致;
|
||||||
|
- 证书数量约 3000,单库文件规模在数十 MB 以内;
|
||||||
|
- 生产比对可在业务低峰或维护窗口执行,避免与 HSM 应用并发写冲突;
|
||||||
|
- 服务器 `/tmp` 可写且允许执行(sqlite-jdbc 需解压本地库);
|
||||||
|
- 业务人员使用 Excel 或文本编辑器修改报表的“合并选择”列。
|
||||||
|
|
||||||
|
### 11.3 待确认
|
||||||
|
|
||||||
|
1. `certtype` 各数值的业务含义(0/1/2/4/5),是否需要报表中文映射;
|
||||||
|
2. 生产环境是否允许停应用/只读挂载,或必须在线比对;
|
||||||
|
3. 是否需要同时核对 `trustlist_tab`、`apetcert_tab`(本期不做);
|
||||||
|
4. SQL 脚本文件命名与保留策略(当前为 `merge-<时间戳>.sql`,不覆盖历史脚本);
|
||||||
|
5. SN 前导 0 归一化是否与现网查询逻辑一致;
|
||||||
|
6. DN 是否需要忽略属性大小写/顺序的“近似匹配”(本期默认严格字节匹配);
|
||||||
|
7. 主库多/从库多的“合并选择”是否允许改成其他值(本期固定为主/备,确保不丢证书)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 里程碑
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 交付物 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 | 环境验证、需求与设计 | 本文件、`设计文档.md` |
|
||||||
|
| M2 | 工程骨架、配置、set/status | 可启动的 CLI、配置加载、库校验 |
|
||||||
|
| M3 | compare 引擎与单份报表 | 三类差异、控制台汇总、CSV |
|
||||||
|
| M4 | 报表读取、标注校验、批量 SQL 生成 | SQL 脚本、脚本执行验证 |
|
||||||
|
| M5 | 测试与部署 | 单元/集成测试、3000 条压测、部署脚本、验收记录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 附:测试库现状摘要
|
||||||
|
|
||||||
|
| 项目 | main.db | slave.db |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 文件大小 | 459,776 字节 | 459,776 字节 |
|
||||||
|
| `usercert_tab` 记录数 | 4 | 15 |
|
||||||
|
| certtype 分布 | 0:1、1:1、2:1、5:1 | 0:5、2:3、4:1、5:6 |
|
||||||
|
| 重复身份键 | 无 | 无 |
|
||||||
|
| 同一 DN 多证书 | 无 | 有(`sge`,certtype 0/2) |
|
||||||
|
| 重复 SN | 无 | 无 |
|
||||||
|
| 日期格式异常 | 无 | 无 |
|
||||||
|
| 完整性检查 | ok | ok |
|
||||||
|
|
||||||
|
其中 3 条为专门构造的“不一致”测试数据(主备同 DN + certtype,SN/有效期/状态不同):
|
||||||
|
|
||||||
|
| DN | CERTTYPE | 差异 | 主库 | 从库 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `CN=diff-sn-01` | 0 | SN | `0A0B0C01` | `0A0B0C11` |
|
||||||
|
| `CN=diff-time-02` | 2 | 失效时间 | `20301231235959` | `20311231235959` |
|
||||||
|
| `CN=diff-stat-03` | 5 | 状态 | `0` | `1` |
|
||||||
|
|
||||||
|
按本期规则,测试库的预期比对结论为:
|
||||||
|
|
||||||
|
- 主库多:**1 张**;
|
||||||
|
- 从库多:**12 张**;
|
||||||
|
- 不一致:**3 张**;
|
||||||
|
- 一致:0 张;
|
||||||
|
- 报表差异记录:16 条;
|
||||||
|
- 执行 SQL 后(示例标注:2 条选主、1 条选备):**16 张**。
|
||||||
Reference in New Issue
Block a user