commit fe28ddeaacdce5786089e4449ab7bb8d162dda5a Author: cheney Date: Wed Nov 4 15:18:42 2026 +0800 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-.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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd8ec6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +*.class +reports/ +merge-*.sql +*.db.bak-* +*.tmp +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8be151 --- /dev/null +++ b/README.md @@ -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 脚本生成/写入失败 | diff --git a/config.properties b/config.properties new file mode 100644 index 0000000..ea79001 --- /dev/null +++ b/config.properties @@ -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) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..60bd17d --- /dev/null +++ b/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + com.sunyard + cert-compare + 1.0.0 + jar + + cert-compare + HSM main/slave usercert comparison and annotated merge tool + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + org.xerial + sqlite-jdbc + 3.36.0.3 + + + junit + junit + 4.13.2 + test + + + + + cert-compare + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 1.8 + 1.8 + UTF-8 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + + + com.sunyard.certcompare.CertCompareApp + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/scripts/cert-compare.sh b/scripts/cert-compare.sh new file mode 100644 index 0000000..4198708 --- /dev/null +++ b/scripts/cert-compare.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" || exit 1 +exec java -Dfile.encoding=UTF-8 -Xmx256m -jar cert-compare.jar "$@" diff --git a/scripts/test-data-main.sql b/scripts/test-data-main.sql new file mode 100644 index 0000000..dd059a9 --- /dev/null +++ b/scripts/test-data-main.sql @@ -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; diff --git a/scripts/test-data-slave.sql b/scripts/test-data-slave.sql new file mode 100644 index 0000000..6f9fc4c --- /dev/null +++ b/scripts/test-data-slave.sql @@ -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; diff --git a/src/main/java/com/sunyard/certcompare/AppConfig.java b/src/main/java/com/sunyard/certcompare/AppConfig.java new file mode 100644 index 0000000..3fdc623 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/AppConfig.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/CertCompareApp.java b/src/main/java/com/sunyard/certcompare/CertCompareApp.java new file mode 100644 index 0000000..516eaf6 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/CertCompareApp.java @@ -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 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 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 ] [--debug]"); + out.println(" java -jar cert-compare.jar compare [--config ]"); + out.println(" java -jar cert-compare.jar sql [--config ] [--report ]"); + out.println(); + out.println("交互命令: set main|slave / status / compare / sql / help / quit"); + out.println("说明: compare 生成一份 CSV 报表;人工填写“合并选择”(主/备)后执行 sql,"); + out.println(" 在当前目录生成批量 SQL 脚本,由 DBA 在主库执行完成合并。"); + } +} diff --git a/src/main/java/com/sunyard/certcompare/Operations.java b/src/main/java/com/sunyard/certcompare/Operations.java new file mode 100644 index 0000000..7f4bfb6 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/Operations.java @@ -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 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); + } +} diff --git a/src/main/java/com/sunyard/certcompare/UserException.java b/src/main/java/com/sunyard/certcompare/UserException.java new file mode 100644 index 0000000..443f704 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/UserException.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/cli/CommandTokenizer.java b/src/main/java/com/sunyard/certcompare/cli/CommandTokenizer.java new file mode 100644 index 0000000..ce55698 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/cli/CommandTokenizer.java @@ -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 tokenize(String line) throws UserException { + List 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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/cli/Shell.java b/src/main/java/com/sunyard/certcompare/cli/Shell.java new file mode 100644 index 0000000..a44ed38 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/cli/Shell.java @@ -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 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 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 tokens) throws UserException { + if (tokens.size() > 1) { + throw new UserException("compare 不接受参数;所有差异都会写入同一份报表", 2); + } + lastReport = Operations.runCompare(cfg, out); + } + + private void doSql(List 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 ] 读取标注后的报表,生成批量 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 设置主库"); + out.println("set slave 设置从库"); + out.println("无参数时显示当前设置。"); + break; + case "compare": + out.println("compare"); + out.println(" 比对主库和从库,生成一份 CSV 报表,只打印汇总统计。"); + out.println(" 报表中“合并选择”列由人工填写:主 或 备。"); + break; + case "sql": + out.println("sql [--report ]"); + 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 查看可用命令。"); + } + } +} diff --git a/src/main/java/com/sunyard/certcompare/compare/CompareService.java b/src/main/java/com/sunyard/certcompare/compare/CompareService.java new file mode 100644 index 0000000..fe27bda --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/compare/CompareService.java @@ -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 mainRecords = DbManager.readAll(cm); + List 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 mainRecords, + List slaveRecords) throws UserException { + boolean trim = "TRIM".equals(cfg.getDnMatch()); + List warnings = new ArrayList<>(); + + Map mainMap = index(mainRecords, trim, "主库", warnings); + Map slaveMap = index(slaveRecords, trim, "从库", warnings); + + Map> notes = new HashMap<>(); + collectDataIssues(mainRecords, trim, "主库", cfg.isHexSn(), notes, warnings); + collectDataIssues(slaveRecords, trim, "从库", cfg.isHexSn(), notes, warnings); + + Set keys = new TreeSet<>(); + keys.addAll(mainMap.keySet()); + keys.addAll(slaveMap.keySet()); + + Charset charset = cfg.charset(); + List onlyMain = new ArrayList<>(); + List onlySlave = new ArrayList<>(); + List 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 rowNotes = new ArrayList<>(notesOf(notes, key)); + rowNotes.add("仅主库存在"); + onlyMain.add(new CompareRow(CompareCategory.ONLY_MAIN, key, m, null, + Collections.emptyList(), rowNotes, MergeChoice.MAIN.label, decodeDn(m.subjectHex, charset))); + } else if (m == null && s != null) { + List rowNotes = new ArrayList<>(notesOf(notes, key)); + rowNotes.add("仅从库存在"); + onlySlave.add(new CompareRow(CompareCategory.ONLY_SLAVE, key, null, s, + Collections.emptyList(), rowNotes, MergeChoice.SLAVE.label, decodeDn(s.subjectHex, charset))); + } else { + List diffs = diffFields(cfg, m, s); + if (diffs.isEmpty()) { + equalCount++; + } else { + List 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 index(List records, boolean trim, + String side, List warnings) throws UserException { + Map 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 records, boolean trim, String side, + boolean hexSn, Map> notes, + List warnings) { + Map 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 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 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 diffFields(AppConfig cfg, CertRecord m, CertRecord s) { + List 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 notesOf(Map> notes, CertKey key) { + List list = notes.get(key); + return list == null ? Collections.emptyList() : list; + } + + private static void addOnce(List 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; + } + } +} diff --git a/src/main/java/com/sunyard/certcompare/db/DbManager.java b/src/main/java/com/sunyard/certcompare/db/DbManager.java new file mode 100644 index 0000000..e457fe0 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/db/DbManager.java @@ -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 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 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 columns(Connection c) throws SQLException { + List 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 cols = columns(c); + List 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 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 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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/CertKey.java b/src/main/java/com/sunyard/certcompare/model/CertKey.java new file mode 100644 index 0000000..645a731 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/CertKey.java @@ -0,0 +1,76 @@ +package com.sunyard.certcompare.model; + +import java.util.Locale; + +/** + * 证书身份键:subject 原始字节十六进制 + certtype。 + */ +public final class CertKey implements Comparable { + + 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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/CertRecord.java b/src/main/java/com/sunyard/certcompare/model/CertRecord.java new file mode 100644 index 0000000..58b1b14 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/CertRecord.java @@ -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); + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/CompareCategory.java b/src/main/java/com/sunyard/certcompare/model/CompareCategory.java new file mode 100644 index 0000000..596948a --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/CompareCategory.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/CompareResult.java b/src/main/java/com/sunyard/certcompare/model/CompareResult.java new file mode 100644 index 0000000..f7eb78b --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/CompareResult.java @@ -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 onlyMain; + public final List onlySlave; + public final List diff; + public final List warnings; + + public CompareResult(int mainCount, int slaveCount, int equalCount, + List onlyMain, List onlySlave, + List diff, List warnings) { + this.mainCount = mainCount; + this.slaveCount = slaveCount; + this.equalCount = equalCount; + this.onlyMain = onlyMain; + this.onlySlave = onlySlave; + this.diff = diff; + this.warnings = warnings; + } + + public List differenceRows() { + List 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(); + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/CompareRow.java b/src/main/java/com/sunyard/certcompare/model/CompareRow.java new file mode 100644 index 0000000..3a79cab --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/CompareRow.java @@ -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 diffs; + public final List notes; + public final String defaultChoice; + public final String dnText; + + public CompareRow(CompareCategory category, CertKey key, CertRecord main, CertRecord slave, + List diffs, List 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); + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/FieldDiff.java b/src/main/java/com/sunyard/certcompare/model/FieldDiff.java new file mode 100644 index 0000000..7613e1a --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/FieldDiff.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/model/MergeChoice.java b/src/main/java/com/sunyard/certcompare/model/MergeChoice.java new file mode 100644 index 0000000..c5606df --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/model/MergeChoice.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/Csv.java b/src/main/java/com/sunyard/certcompare/report/Csv.java new file mode 100644 index 0000000..c04593c --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/Csv.java @@ -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 parse(String text) throws UserException { + List rows = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return rows; + } + List 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 fields; + public final int line; + + public CsvRecord(List fields, int line) { + this.fields = fields; + this.line = line; + } + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/ReportData.java b/src/main/java/com/sunyard/certcompare/report/ReportData.java new file mode 100644 index 0000000..8dd8593 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/ReportData.java @@ -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 rows; + + public ReportData(File file, int headerLine, List rows) { + this.file = file; + this.headerLine = headerLine; + this.rows = rows; + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/ReportReader.java b/src/main/java/com/sunyard/certcompare/report/ReportReader.java new file mode 100644 index 0000000..c91a712 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/ReportReader.java @@ -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 records = Csv.parse(text); + int headerIndex = -1; + for (int i = 0; i < records.size(); i++) { + List 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 header = new ArrayList<>(); + for (String h : records.get(headerIndex).fields) { + header.add(h == null ? "" : h.trim()); + } + Map 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 missing = new ArrayList<>(); + for (String required : ReportWriter.HEADER) { + if (!index.containsKey(required)) { + missing.add(required); + } + } + if (!missing.isEmpty()) { + throw new UserException("报表缺少必需列: " + missing, 5); + } + + List rows = new ArrayList<>(); + for (int i = headerIndex + 1; i < records.size(); i++) { + Csv.CsvRecord record = records.get(i); + List 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 fields, Map 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 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; + } + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/ReportRow.java b/src/main/java/com/sunyard/certcompare/report/ReportRow.java new file mode 100644 index 0000000..562d355 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/ReportRow.java @@ -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); + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/ReportValidator.java b/src/main/java/com/sunyard/certcompare/report/ReportValidator.java new file mode 100644 index 0000000..469a75d --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/ReportValidator.java @@ -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 validate(CompareResult result, ReportData report) + throws UserException { + Map expected = new LinkedHashMap<>(); + for (CompareRow row : result.differenceRows()) { + expected.put(row.key, row); + } + + Map actual = new LinkedHashMap<>(); + List errors = new ArrayList<>(); + for (ReportRow row : report.rows) { + if (actual.put(row.key, row) != null) { + errors.add("报表第 " + row.line + " 行身份键重复: " + row.key); + } + } + + for (Map.Entry 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 choices = new LinkedHashMap<>(); + List choiceErrors = new ArrayList<>(); + for (Map.Entry 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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/report/ReportWriter.java b/src/main/java/com/sunyard/certcompare/report/ReportWriter.java new file mode 100644 index 0000000..a28332c --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/report/ReportWriter.java @@ -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'); + } +} diff --git a/src/main/java/com/sunyard/certcompare/sql/SqlPlan.java b/src/main/java/com/sunyard/certcompare/sql/SqlPlan.java new file mode 100644 index 0000000..3ff247a --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/sql/SqlPlan.java @@ -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); + } + } +} diff --git a/src/main/java/com/sunyard/certcompare/sql/SqlScriptService.java b/src/main/java/com/sunyard/certcompare/sql/SqlScriptService.java new file mode 100644 index 0000000..c318a7e --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/sql/SqlScriptService.java @@ -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 脚本。 + * + *

脚本不把 DN/证书内容带进 SQL 文本,而是 ATTACH 从库后按身份键 + * (subject 原始字节 + certtype)复制,因此 GBK 中文 DN 的字节不会被解码或重编码。

+ */ +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 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 choices) + throws UserException { + List insertRecords = new ArrayList<>(); + List 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 insertRecords, List 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 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 columns, List 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 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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/util/DateUtil.java b/src/main/java/com/sunyard/certcompare/util/DateUtil.java new file mode 100644 index 0000000..5901548 --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/util/DateUtil.java @@ -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; + } +} diff --git a/src/main/java/com/sunyard/certcompare/util/SnNormalizer.java b/src/main/java/com/sunyard/certcompare/util/SnNormalizer.java new file mode 100644 index 0000000..10a19ad --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/util/SnNormalizer.java @@ -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); + } +} diff --git a/src/main/java/com/sunyard/certcompare/util/Text.java b/src/main/java/com/sunyard/certcompare/util/Text.java new file mode 100644 index 0000000..9d8d91b --- /dev/null +++ b/src/main/java/com/sunyard/certcompare/util/Text.java @@ -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 ""; + } +} diff --git a/src/test/java/com/sunyard/certcompare/CsvTest.java b/src/test/java/com/sunyard/certcompare/CsvTest.java new file mode 100644 index 0000000..d6ce923 --- /dev/null +++ b/src/test/java/com/sunyard/certcompare/CsvTest.java @@ -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 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 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); + } +} diff --git a/src/test/java/com/sunyard/certcompare/SqlScriptTest.java b/src/test/java/com/sunyard/certcompare/SqlScriptTest.java new file mode 100644 index 0000000..db8886d --- /dev/null +++ b/src/test/java/com/sunyard/certcompare/SqlScriptTest.java @@ -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(), + new ArrayList(), "", ""); + } + + 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 onlyMainRows = new ArrayList<>(); + onlyMainRows.add(onlyMain); + List onlySlaveRows = new ArrayList<>(); + onlySlaveRows.add(onlySlave); + List diffRows = new ArrayList<>(); + diffRows.add(sameBytes); + diffRows.add(mainChosen); + diffRows.add(trimCase); + CompareResult result = new CompareResult(2, 2, 0, onlyMainRows, onlySlaveRows, diffRows, + new ArrayList()); + + Map 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(), new ArrayList(), + new ArrayList(), new ArrayList()); + SqlScriptService.Script script = SqlScriptService.build(config(), result, + new HashMap()); + assertTrue(script.text.contains("没有需要写入的记录")); + assertEquals(0, script.insertCount); + assertEquals(0, script.updateCount); + assertTrue(script.text.contains("BEGIN IMMEDIATE;")); + assertTrue(script.text.contains("COMMIT;")); + } +} diff --git a/src/test/java/com/sunyard/certcompare/UtilTest.java b/src/test/java/com/sunyard/certcompare/UtilTest.java new file mode 100644 index 0000000..9703865 --- /dev/null +++ b/src/test/java/com/sunyard/certcompare/UtilTest.java @@ -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")); + } +} diff --git a/设计文档.md b/设计文档.md new file mode 100644 index 0000000..1883335 --- /dev/null +++ b/设计文档.md @@ -0,0 +1,1272 @@ +# 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. 设计概述 + +工具采用命令行 + 交互式 Shell 的形态,读取主库、从库中的 `usercert_tab`,以 `(subject 原始字节, certtype)` 为身份键进行比对,输出**一份包含三类差异的 CSV 报表**: + +- 主库多(仅主库存在); +- 从库多(仅从库存在); +- 不一致(两边都有,比较字段不同)。 + +报表中预留“合并选择”列,由业务人员填写“主”或“备”。`sql` 读取标注后的报表,生成在主库执行的批量 SQL 脚本: + +- 以主库为基准; +- 从库多的证书全部补入; +- 不一致的证书按标注选择主库或从库版本; +- 主库多的证书自然保留; +- 工具只生成脚本,由 DBA 在主库执行;执行前备份,从库只读引用。 + +设计重点: + +1. **编码安全**:全程以原始字节处理 `subject`/`issuer`,禁止使用 JDBC `getString()` 读取 GBK 字段; +2. **身份键正确**:DN + certtype,避免双证场景误判; +3. **报表驱动合并**:合并决策来自人工标注,不在代码里写死主库优先; +4. **字节级脚本**:`ATTACH` 从库后由 SQLite 内部复制行,脚本中只出现 `hex(subject)`,不出现 DN 文本; +5. **非破坏性**:工具不写任何数据库;脚本使用事务,失败可回滚,执行前备份主库; +6. **无日志文件**:运行信息、错误只输出到控制台; +7. **老环境兼容**:JDK 8 字节码、sqlite-jdbc 3.36.0.3、无外网、无 Maven,脚本兼容 sqlite3 3.6.20。 + +--- + +## 2. 环境验证报告 + +验证时间:2026-11-03,验证账号:`root@172.1.41.139`。以下结论均为实际执行结果。 + +### 2.1 服务器基础环境 + +| 项目 | 实测结果 | +| --- | --- | +| 操作系统 | Red Hat Enterprise Linux Server release 6.5 (Santiago) | +| 内核 | Linux 2.6.32-431.el6.x86_64 | +| 架构 | x86_64 | +| glibc | 2.12 | +| CPU | 8 核 | +| 内存 | 7.8 GB 总量,约 7.1 GB 可用 | +| 磁盘 | `/` 117 GB,剩余 63 GB | +| 时区/时间 | CST(UTC+8) | +| 语言环境 | `LANG=zh_CN.UTF-8` | +| `/app/cert-compare` | root 可读写,验证通过 | +| `/tmp` | 可写,sqlite-jdbc 可解压并加载本地库 | + +### 2.2 Java 与构建工具 + +| 项目 | 实测结果 | +| --- | --- | +| `java -version` | 1.8.0_152(HotSpot 64-Bit) | +| `javac -version` | 1.8.0_152 | +| 默认 `file.encoding` | UTF-8 | +| `sun.jnu.encoding` | UTF-8 | +| Maven / Gradle | 服务器**未安装** | +| 本地构建环境 | JDK 8 + Maven 3.8.5 可用 | + +**结论**:服务器只需 JRE 8 即可运行;在本地构建 Java 8 目标字节码的 fat jar 后上传部署。 + +### 2.3 SQLite 与 JDBC 驱动 + +| 项目 | 实测结果 | +| --- | --- | +| `sqlite3 --version` | 3.6.20(CentOS 6.5 自带,较旧) | +| `PRAGMA journal_mode` | `delete`(非 WAL) | +| `PRAGMA encoding` | `UTF-8`(仅文件头声明,见 2.5) | +| `PRAGMA integrity_check` | main.db / slave.db 均为 `ok` | +| sqlite-jdbc 3.36.0.3 | 在服务器加载成功,读写正常,**选用** | +| sqlite-jdbc 3.8.11.2 | 可运行,但版本过旧,不选 | +| sqlite-jdbc 3.45.3.0 | 启动报 `NoClassDefFoundError: org/slf4j/LoggerFactory`,需额外 slf4j 依赖,不选 | +| 内置 SQLite 版本(3.36.0.3) | 3.36.0 | +| `VACUUM INTO`(JDBC) | 3.36.0 支持,但本方案已不使用 | +| `VACUUM INTO`(系统 CLI) | 3.6.20 不支持,报 `near "INTO": syntax error` | + +**结论**:使用 `org.xerial:sqlite-jdbc:3.36.0.3`,其原生库可在 glibc 2.12 上运行;生成的脚本不得使用 3.6.20 不支持的新语法(CTE、UPSERT、`VACUUM INTO`、row-value 等)。 + +### 2.4 数据库与数据特征 + +| 项目 | main.db | slave.db | +| --- | --- | --- | +| 文件大小 | 459,776 字节 | 459,776 字节 | +| 表数量 | 13 | 13 | +| `usercert_tab` 记录数 | 4 | 15 | +| certtype 分布 | 0:1、1:1、2:1、5:1 | 0:5、2:3、4:1、5:6 | +| 不一致测试数据 | 3 组 | 3 组 | +| 重复身份键 `(subject,certtype)` | 无 | 无 | +| 同一 DN 多证书 | 无 | 有:`sge` 的 certtype=0 与 2 | +| 重复 SN | 无 | 无 | +| 日期格式异常 | 无 | 无 | +| 完整性检查 | ok | ok | +| 文件占用 | 无进程持有 | 无进程持有 | + +`usercert_tab` 主键: + +```sql +PRIMARY KEY (`subject`, `certtype`) +``` + +关键字段:`organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo, not_before, not_after, certfile, certtype, opertime, stat`。 + +### 2.5 编码验证(关键) + +**现象**:SQLite 头部声明 UTF-8,但 `subject`/`issuer` 实际是 GBK 字节。 + +以 main.db 唯一记录为例: + +```text +JDBC getString("subject") 长度:109(包含替换字符 U+FFFD) +CAST(subject AS BLOB) 长度:110 字节 +原始十六进制:... 40 B3 C2 B3 AC 40 ... +GB18030 解码:... @陈超@ ... +``` + +- `B3 C2 B3 AC` 是“陈超”的 GBK 编码; +- 直接用 `getString()` 会把 `B3 C2` 等非法 UTF-8 字节替换为 `U+FFFD`,导致不同 DN 可能被错误合并; +- 通过 `CAST(subject AS BLOB)` / `hex(subject)` 获取原始字节后,按 GB18030 解码显示完全正确。 + +**设计结论**: + +- 比对、键计算全部使用原始字节; +- 仅在展示和 CSV 输出时按 GB18030 解码; +- 数据写入由 SQLite 内部完成,不经过 Java `String`。 + +### 2.6 批量 SQL 机制验证 + +主备 DN 以 GBK/GB18030 字节存储在 TEXT 列中,直接 `getString()` 会产生 `U+FFFD`。因此合并改为生成由 DBA 在主库执行的 SQL 脚本;脚本以 `ATTACH` 引用从库并按 `hex(subject)` + `certtype` 定位,复制过程完全在 SQLite 内部完成。 + +早期在 main.db 副本上的机制验证结果: + +- `ATTACH DATABASE ... AS cert_src` 成功; +- `INSERT OR REPLACE ... SELECT ... FROM cert_src.usercert_tab` 成功; +- 插入前后 `hex(subject)` 完全一致; +- `typeof(subject)` 保持 `text`,不是 `blob`; +- 事务回滚后记录数恢复,回滚正常; +- 说明 `ATTACH + INSERT ... SELECT` 可在不接触 Java 字符串的情况下保持 GBK 字节和字段类型。 + +服务器端(sqlite3 3.6.20)对生成脚本的执行验证: + +```text +rows|16 +ok +``` + +- 服务器实际脚本(`dn.match=EXACT`)为 1 条 `INSERT OR REPLACE ... SELECT`,覆盖 13 个身份键; +- `dn.match=TRIM` 且主备 DN 字节不同的场景由单元测试覆盖,生成 `UPDATE ... SET col = (SELECT ...)`; +- 重复执行后仍为 16 条(幂等); +- 执行前后 main.db / slave.db 的 SHA-256 不变; +- “陈超”记录 `hex(subject)` 与主库逐字节一致(221 字节),`iconv` 解码中文正常。 + +**设计结论**:`sql` 只生成脚本,不直接写数据库;脚本使用 `ATTACH + INSERT OR REPLACE / UPDATE`,由 SQLite 内部按原始字节复制,从而彻底规避 Java 侧编码问题。此前的 `VACUUM INTO` 快照方案已取消。 + +### 2.7 字段与证书一致性验证 + +使用 `cryptography` 解析原有 13 条记录的 PEM 证书,与表字段对比: + +| 校验项 | 结果 | +| --- | --- | +| 表 `sn` 与 DER 序列号 | 13/13 一致(含前导 0 归一化后) | +| 表 `not_before` 与证书生效时间 | 13/13 一致 | +| 表 `not_after` 与证书失效时间 | 13/13 一致 | +| 表 `subject` 与证书主体 | 逻辑一致,但表内顺序为 `C..CN`,PEM 为 `CN..C` | +| PEM 主体字符编码 | 个别证书内部主体字符串不是合法 UTF-8,工具显示替换符 | + +**设计结论**: + +- 以表字段为唯一比对依据,不从 PEM 反推 DN/SN; +- 表内 DN 顺序与 RFC4514 输出不同,不做 RFC 规范化,默认按原始字节比较; +- 不提供证书密码学验证功能。 + +### 2.8 环境验证结论 + +| 结论 | 说明 | +| --- | --- | +| 可实施 | Java 8 + sqlite-jdbc 3.36.0.3 + GBK 字节处理方案已在目标服务器验证 | +| 主要风险 | 编码误用、生产库并发写、报表人工编辑错误、DN 近似匹配、certtype 业务含义未知 | +| 构建方式 | 本地 Maven 构建 fat jar,上传服务器运行;服务器不安装 Maven | +| 测试数据 | 主库 4 条、从库 15 条;预期主库多 1、从库多 12、不一致 3 | + +--- + +## 3. 总体架构 + +```text ++----------------------------------------------------------+ +| cert-compare.sh | +| java -Dfile.encoding=UTF-8 -Xmx256m -jar cert-compare.jar| ++----------------------------+-----------------------------+ + | + +--------v---------+ + | CertCompareApp | + | 启动/模式选择 | + +--------+---------+ + | + +---------+---------+ + | | + +------v-------+ +-------v--------+ + | Shell | | 一次性命令 | + | 交互循环 | | compare / sql | + +------+-------+ +-------+--------+ + | | + +---------+---------+ + | + +--------v---------+ + | Operations | + | compare / sql | + +--------+---------+ + | + +------------------+-------------------+ + | | + +------v-------+ +-------v--------+ + | Compare | | ReportReader | + | Service | | ReportValidator| + +------+-------+ +-------+--------+ + | | + +------v-------+ +-------v--------+ + | ReportWriter | | SqlScript | + | 单份 CSV | | Service | + +------+-------+ +-------+--------+ + | | + | +-------v--------+ + | | merge-*.sql | + | | 由 DBA 执行 | + | +----------------+ + | | + +------------------+-------------------+ + | + +--------v---------+ + | DbManager | + | sqlite-jdbc | + +--------+---------+ + | + +----------+----------+ + | | + +-----v-----+ +-----v-----+ + | main.db | | slave.db | + | (只读) | | (只读) | + +-----------+ +-----------+ +``` + +分层职责: + +| 层 | 职责 | +| --- | --- | +| CLI | 交互循环、命令分词、参数解析、帮助、退出码 | +| Operations | 组织 compare / sql 流程,控制台输出 | +| Service | 比对、报表生成、报表读取与校验、批量 SQL 生成 | +| Dao | 数据库打开、Schema 校验、只读查询 | +| Report | 单份 CSV 写入、编码识别、CSV 解析、标注校验 | +| Util | GB18030、十六进制、路径、时间 | + +--- + +## 4. 模块与类设计 + +包名:`com.sunyard.certcompare`。实际源码结构: + +```text +src/main/java/com/sunyard/certcompare/ + CertCompareApp.java main 入口,交互/一次性模式 + AppConfig.java 配置模型与默认值 + Operations.java compare / sql 公共流程 + UserException.java 业务异常 + 退出码 + cli/ + Shell.java 交互循环与命令分发 + CommandTokenizer.java 引号感知分词 + compare/ + CompareService.java 比对主流程(分类、字段差异、异常提示) + db/ + DbManager.java 连接、Schema 校验、hex 读取、SQL 字面量 + model/ + CertRecord.java 投影记录(DN/issuer 为十六进制原始字节) + CertKey.java 身份键(subjectHex + certtype) + CompareCategory.java 一致/主库多/从库多/不一致 + CompareResult.java 分类结果与统计 + CompareRow.java 报表行数据 + FieldDiff.java 字段差异 + MergeChoice.java 人工选择:主/备 + report/ + Csv.java RFC4180 转义与解析 + ReportWriter.java 单份 CSV 报表 + ReportReader.java 报表读取、编码识别 + ReportRow.java 报表行 + ReportData.java 报表数据 + ReportValidator.java 报表过期与标注校验 + sql/ + SqlPlan.java 生成计划与统计 + SqlScriptService.java 批量 SQL 脚本生成 + util/ + Text.java GB18030 解码、hex 工具、CN 提取 + SnNormalizer.java SN 归一化 + DateUtil.java 14 位时间校验 +``` + +### 4.1 核心类职责 + +| 类 | 职责 | +| --- | --- | +| `CertCompareApp` | 解析全局参数(`--config`、`--debug`),选择交互/一次性模式 | +| `AppConfig` | 读取 UTF-8 properties;路径解析;默认值;校验编码与匹配方式 | +| `Operations` | compare / sql 流程编排、控制台汇总与执行提示 | +| `DbManager` | 建立只读连接、`busy_timeout`、Schema 校验、`hex()` 投影读取 | +| `CertKey` | 不可变身份键,`equals/hashCode` 基于字节十六进制 + certtype | +| `SnNormalizer` | 去空白、大写、去前导 0;保留原始值用于输出 | +| `CompareService` | 读取两库、构造 Map、分类、生成结果与提示 | +| `ReportWriter` | 生成单份 CSV,预填默认合并选择 | +| `ReportReader` | 识别 UTF-8/GB18030、解析 CSV、读取标注 | +| `ReportValidator` | 校验报表与当前比对结果一致、标注合法 | +| `SqlScriptService` | 生成 ATTACH + INSERT OR REPLACE / UPDATE 的批量脚本 | +| `SqlPlan` | 生成计划统计与控制台展示 | + +--- + +## 5. 配置设计 + +### 5.1 配置文件示例 + +`config.properties`(UTF-8): + +```properties +# 数据库路径 +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=./reports +report.csv.bom=true +``` + +批量 SQL 脚本不通过配置指定目录,直接写入运行时的当前工作目录。 + +### 5.2 优先级 + +```text +命令行参数 > 配置文件 > 内置默认值 +``` + +### 5.3 配置校验 + +- 主库、从库必须存在、是普通文件、可读、是合法 SQLite 文件; +- SQL 脚本写入当前工作目录;目录不可写时生成失败并提示; +- 编码必须是 JVM 支持的字符集(默认 GB18030); +- `dn.match`、`sn.normalize` 仅接受枚举值; +- 报表目录不存在时自动创建,创建失败则报错; +- 配置错误退出码 2,交互模式仅提示不退出; +- 不生成任何日志文件,不读取日志配置。 + +--- + +## 6. CLI 设计 + +### 6.1 启动方式 + +```bash +# 交互模式 +java -Dfile.encoding=UTF-8 -jar cert-compare.jar +java -Dfile.encoding=UTF-8 -jar cert-compare.jar --config /app/cert-compare/config.properties + +# 一次性模式 +java -Dfile.encoding=UTF-8 -jar cert-compare.jar compare --config /app/cert-compare/config.properties +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 +``` + +### 6.2 交互命令语法 + +```text +set main +set slave +set main | set slave # 无参数:显示当前值 +status +compare +sql [--report ] +help [command] +quit | exit +``` + +### 6.3 命令行为 + +| 命令 | 行为 | +| --- | --- | +| `set main` | 校验并设置主库,打印大小/数量 | +| `set slave` | 校验并设置从库 | +| `status` | 显示主库/从库、当前工作目录(SQL 输出位置)、数量、结构兼容性、最近报表/脚本 | +| `compare` | 比对、生成单份报表、控制台打印汇总 | +| `sql` | 读取标注报表,校验后生成批量 SQL 脚本并打印执行命令 | +| `help` | 命令/参数帮助 | +| `quit` | 退出,返回 0 | + +交互实现: + +- `BufferedReader` 读取标准输入; +- `CommandTokenizer` 支持 `"..."`、`'...'` 和反斜杠转义; +- 命令名大小写不敏感; +- `TERM=dumb` 时不使用 ANSI 颜色; +- 每条命令独立捕获异常,打印错误后继续; +- 不长期持有数据库连接,命令结束后关闭,避免锁库; +- 不写日志文件。 + +### 6.4 compare 参数 + +`compare` 不接受业务筛选、查询、过滤参数。所有差异一次性进入同一份报表,由使用者在报表中自行过滤。 + +### 6.5 sql 参数 + +| 参数 | 说明 | +| --- | --- | +| `--report ` | 指定已标注的报表;缺省使用本次会话最近一次 compare 的报表,若没有则使用报表目录中最新的 `compare-report-*.csv` | + +`sql` 不写数据库、不改源库,只在当前工作目录生成 `merge-.sql`,因此不需要确认参数。 + +### 6.6 退出码 + +| 码 | 含义 | +| --- | --- | +| 0 | 成功 | +| 1 | 未预期内部错误 | +| 2 | 参数/配置错误 | +| 4 | 数据库/结构/完整性错误 | +| 5 | 报表过期、标注非法、SQL 脚本生成/写入失败 | + +--- + +## 7. 数据访问与编码设计 + +### 7.1 连接 + +```text +jdbc:sqlite:/app/cert-compare/main.db +``` + +连接后执行: + +```sql +PRAGMA busy_timeout = 5000; +``` + +说明: + +- sqlite-jdbc 3.36.0.3 内部 SQLite 3.36.0,仅用于只读查询; +- 源库默认不执行任何写操作; +- `compare` 可设置 `PRAGMA query_only = ON` 进一步防误写; +- 若生产库被占用,返回明确的 `SQLITE_BUSY` 提示,建议维护窗口执行。 + +### 7.2 读取投影 + +默认比较不读取大字段: + +```sql +SELECT rowid, + hex(subject) AS subject_hex, + hex(issuer) AS issuer_hex, + hex(organid) AS organid_hex, + sn, pubkey_alg, sign_alg, + not_before, not_after, certtype, opertime, stat +FROM usercert_tab +``` + +约束: + +- 不查询 `subject`/`issuer` 的字符串形态; +- 不使用 `getString()` 读取上述字段; +- 生成脚本时只读取 `hex(subject)`,数据复制在 SQLite 内部完成,不经过 Java 字符串。 + +### 7.3 编码工具 + +```java +public final class Gb18030 { + public static final Charset CHARSET = Charset.forName("GB18030"); + + public static String decode(byte[] raw) { + if (raw == null) return ""; + return new String(raw, CHARSET); + } + + public static byte[] encode(String text) { + return text.getBytes(CHARSET); + } +} +``` + +展示策略: + +- 正常:GB18030 解码; +- 异常:输出 `hex=...` 与可打印 ASCII,避免抛出; +- 控制台/CSV 统一 UTF-8 输出; +- CSV 首部写入 BOM `\uFEFF`。 + +### 7.4 身份键 + +```java +final class CertKey { + final String subjectHex; // 大写十六进制,原始字节 + final int certtype; +} +``` + +键字符串形式:`subjectHex + "|" + certtype`。 + +- 默认 `dn.match=EXACT`:`subjectHex` 完全一致; +- `TRIM`:仅去除 subject 整体首尾的 ASCII 空白后再取十六进制; +- 不做大小写归一化、不做属性顺序重排、不做 RFC4514 规范化; +- 报表中的 `DN_HEX` 加 `HEX:` 前缀,读取时去掉;键计算始终用裸十六进制。 + +### 7.5 SN 归一化 + +```text +raw = trim(sn) +normalized = raw.toUpperCase(Locale.ROOT) +while (normalized.length() > 1 && normalized.charAt(0) == '0') remove first char +``` + +- 仅当 `sn.normalize=HEX` 时启用; +- 非十六进制字符时退化为 `trim + uppercase` 并记录控制台警告; +- 展示与 CSV 均使用原始 `sn`。 + +### 7.6 时间校验 + +- 长度必须为 14; +- 必须全部为数字; +- 可按 `yyyyMMddHHmmss` 解析; +- `not_before <= not_after`; +- 校验失败标记 `INVALID_DATE`,在 `compare` 控制台“提示”中列出(HEX + CERTTYPE),不阻止 compare;`sql` 仍按报表标注生成脚本,并在控制台提示该记录风险。 + +--- + +## 8. 比对设计 + +### 8.1 流程 + +```text +1. 校验配置与两库结构 +2. 从 main 读取投影记录 -> List +3. 从 slave 读取投影记录 -> List +4. 分别构建 Map> +5. 检测库内重复键、重复 SN、非法日期(诊断信息) +6. 遍历两库键的并集: + - 仅 main -> ONLY_MAIN(主库多) + - 仅 slave -> ONLY_SLAVE(从库多) + - 两边都有 -> DiffEngine 比较 + 无差异 -> EQUAL(仅计数) + 有差异 -> DIFF(不一致) +7. 汇总统计 +8. 控制台打印汇总;仅将 ONLY_MAIN/ONLY_SLAVE/DIFF 写入一份 CSV +``` + +### 8.2 分类矩阵 + +| main | slave | 分类 | 是否进报表 | 默认合并选择 | +| --- | --- | --- | --- | --- | +| 有 | 无 | ONLY_MAIN(主库多) | 是 | 主 | +| 无 | 有 | ONLY_SLAVE(从库多) | 是 | 备 | +| 有 | 有,字段相同 | EQUAL(一致) | 否 | - | +| 有 | 有,字段不同 | DIFF(不一致) | 是 | 空,待人工填写 | +| 库内重复键 | - | 诊断告警 | 按实际分类 | - | +| 日期非法 | - | 诊断告警 | 按实际分类 | - | + +### 8.3 字段比较 + +默认比较字段: + +| 字段 | 比较方式 | +| --- | --- | +| `sn` | 归一化后字符串相等 | +| `issuer` | 原始字节相等 | +| `not_before` | 原始字符串相等 | +| `not_after` | 原始字符串相等 | +| `stat` | 整数相等 | + +`opertime`、`organid`、公钥、证书 PEM 等不参与默认比较,避免操作时间等元数据造成大量误报。 + +`FieldDiff` 输出:字段名、主库值、从库值。二进制字段输出十六进制或哈希,避免控制台/报表乱码。 + +### 8.4 数据异常 + +| 异常 | 判定 | 处理 | +| --- | --- | --- | +| `DUPLICATE_KEY` | 同一库内身份键出现多次 | 理论上被主键阻止;如出现,直接报错 | +| `DUPLICATE_SN` | 同一库内归一化 SN 重复但身份键不同 | 控制台“提示”告警,不阻止生成脚本 | +| `INVALID_DATE` | 非 14 位/非法时间/起止倒置 | 控制台“提示”告警,仍按标注生成脚本 | +| `EMPTY_DN` / `EMPTY_SN` | 空值 | 控制台“提示”告警 | +| `DECODE_ERROR` | GB18030 解码异常 | 展示十六进制;不影响字节比对 | + +### 8.5 复杂度 + +- 时间:O(n + m),n/m 为两库记录数; +- 空间:O(n + m),默认不加载大字段; +- 3000 条规模下内存占用预计 < 20 MB(不含 JVM 基础开销)。 + +--- + +## 9. 报表设计 + +### 9.1 文件 + +- 每次 `compare` 生成一份 CSV:`/compare-report-yyyyMMdd-HHmmss.csv`; +- 不生成 `slave-extra`、`main-missing`、`field-diff`、`summary` 等额外文件; +- 汇总信息只打印到控制台; +- 报表包含三类差异记录,不包含一致记录; +- 若两库完全一致,生成只有表头的空报表,仍打印汇总。 + +### 9.2 列定义 + +| 列 | 说明 | +| --- | --- | +| 序号 | 报表内序号 | +| 类型 | 主库多 / 从库多 / 不一致 | +| DN | GB18030 解码后的展示值 | +| DN_HEX | `HEX:` + 原始字节大写十六进制 | +| CN | 从 DN 提取的 CN,仅展示 | +| CERTTYPE | 证书类型 | +| 主库SN / 从库SN | 两边 SN,不存在的一侧留空 | +| 主库生效 / 主库失效 | 主库有效期,不存在的一侧留空 | +| 从库生效 / 从库失效 | 从库有效期,不存在的一侧留空 | +| 主库状态 / 从库状态 | 两边 `stat`,不存在的一侧留空 | +| 差异字段 | 不一致时列出,如 `sn;not_after` | +| 合并选择 | **最后一列,人工填写:主 / 备** | + +- `合并选择` 固定放在最后一列,方便在 Excel 中直接向右填写; +- 不设“备注”列;日期非法、有效期倒置、SN/DN 为空、重复 SN 等异常改在 `compare` 控制台“提示”中输出; +- 读取端按表头列名定位,不依赖列顺序,因此列顺序调整不影响旧报表读取。 + +### 9.3 默认标注 + +| 类型 | 默认值 | 允许值 | +| --- | --- | --- | +| 主库多 | 主 | 仅主 | +| 从库多 | 备 | 仅备 | +| 不一致 | 空 | 主 / 备 | + +默认标注让业务人员只需要处理“不一致”行;主库多、从库多已按唯一可行来源预填。 + +### 9.4 CSV 规格 + +- 编码:UTF-8 + BOM; +- 分隔符:逗号; +- 换行:`\n`; +- 字段内逗号、引号、换行按 RFC4180 转义; +- `DN_HEX` 加 `HEX:` 前缀,防止 Excel 将长十六进制转为科学计数法; +- `DN` 用双引号包裹(包含逗号); +- 报表生成后控制台打印绝对路径。 + +### 9.5 人工编辑建议 + +- 只修改最后一列“合并选择”,不要改动 DN、DN_HEX、CERTTYPE、类型等定位列; +- Excel 保存时选择“CSV UTF-8(逗号分隔)”,或直接保存为 GBK CSV; +- 不要增删行、不要调整列顺序; +- 修改后用 `sql` 校验;校验失败会指出具体行号/列名。 + +### 9.6 报表读取兼容性 + +`CompareReportReader` 按以下顺序识别编码: + +1. UTF-8 BOM; +2. 严格 UTF-8 解码; +3. 失败则按 GB18030 解码(兼容 Excel 另存为 ANSI/GBK)。 + +CSV 解析器支持: + +- 引号包裹字段; +- 字段内逗号、换行; +- 双引号转义 `""`; +- 空字段; +- 表头前可有空行(容错)。 + +### 9.7 报表一致性校验 + +`sql` 不信任报表中的业务字段,只信任“合并选择”。生成脚本前重新执行一次 compare,并校验: + +1. 报表行集合与当前差异键集合完全相同; +2. 每行的“类型”与当前分类一致; +3. 主库多、从库多的身份键分别只在一侧存在;不一致行的身份键两边都存在; +4. 报表中的 SN、有效期等字段仅供人工阅读,脚本数据始终取自当前数据库,不取自报表文本; +5. 无重复行、无缺失行、无额外行; +6. 每条记录标注合法。 + +任一不满足即判定报表过期或被修改,拒绝生成脚本并提示重新 `compare`。 + +--- + +## 10. 批量 SQL 设计 + +### 10.1 前置条件 + +- 主库、从库均已通过 Schema 校验; +- 两库必需字段一致; +- 报表存在且通过 9.7 校验; +- 报表中所有“不一致”行已填写“主”或“备”; +- 当前工作目录可写(脚本直接写入当前目录); +- 脚本执行前由 DBA 备份主库(脚本头注释中给出命令)。 + +### 10.2 合并语义 + +| 报表类型 | 合并选择 | 脚本动作 | +| --- | --- | --- | +| 主库多 | 主 | 无语句(主库已有) | +| 从库多 | 备 | `INSERT OR REPLACE ... SELECT` | +| 不一致(主备 DN 字节相同) | 主 | 无语句(保留主库版本) | +| 不一致(主备 DN 字节相同) | 备 | `INSERT OR REPLACE ... SELECT`(整行替换) | +| 不一致(主备 DN 字节不同,TRIM) | 主 | 无语句(保留主库版本) | +| 不一致(主备 DN 字节不同,TRIM) | 备 | `UPDATE ... SET col = (SELECT ...)`(按主库字节定位) | +| 一致 | - | 不在报表中;主库已有 | + +结果:执行后主库 = 原主库全部证书 + 从库多证书;不一致记录按标注取其一;不删除任何证书。 + +### 10.3 生成步骤 + +```text +1. 计算 CompareResult +2. 读取报表并校验标注(9.7) +3. 分流: + - 从库多 → INSERT 集合 + - 不一致 + 选备 + DN 字节相同 → INSERT 集合 + - 不一致 + 选备 + DN 字节不同 → UPDATE 集合 + - 不一致 + 选主 / 主库多 → 不需要语句 +4. 生成 SqlPlan(统计 + 输出路径) +5. 在当前工作目录创建 merge-.sql(UTF-8 无 BOM) +6. 写注释头:主从路径、执行方式、字节保持说明、统计 +7. ATTACH DATABASE '<从库>' AS cert_src +8. BEGIN IMMEDIATE +9. 每 2000 个身份键一组,生成 INSERT OR REPLACE ... SELECT +10. 每条 TRIM 场景生成一条 UPDATE +11. COMMIT +12. DETACH DATABASE cert_src +13. SELECT 'rows', count(*) + PRAGMA integrity_check 自检 +14. 控制台打印计划与 DBA 执行命令 +``` + +说明: + +- 身份键 = `hex(subject)` + `certtype`,脚本中只出现十六进制,不出现 DN 文本; +- 主备 DN 字节相同才用 `INSERT OR REPLACE` 整行替换; +- 主备 DN 字节不同(`dn.match=TRIM`)时改用 `UPDATE`:按主库字节定位,按从库字节取新值,避免“按从库字节删不到主库行”导致丢数据; +- 单条 `INSERT` 最多 2000 个身份键(`KEYS_PER_STATEMENT`),避免 SQL 文本过长; +- 脚本使用 `BEGIN IMMEDIATE` / `COMMIT` 包裹,失败可整体回滚; +- 报表文本中的 SN、有效期等只用于人工阅读,脚本数据始终取自当前数据库。 + +### 10.4 关键 SQL + +包装: + +```sql +PRAGMA foreign_keys = OFF; + +ATTACH DATABASE '/app/cert-compare/slave.db' AS cert_src; + +BEGIN IMMEDIATE; +``` + +从库多 / 不一致选备(主备 DN 字节相同): + +```sql +INSERT OR REPLACE INTO usercert_tab + (organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo, + not_before, not_after, certfile, certtype, opertime, stat) +SELECT organid, subject, issuer, sn, pubkey_alg, pubkey, sign_alg, signinfo, + not_before, not_after, certfile, certtype, opertime, stat + FROM cert_src.usercert_tab + WHERE (hex(subject) = '434E3D646966662D736E2D3031' AND certtype = 0) + OR (hex(subject) = 'B3C2B3AC' AND certtype = 2); +``` + +不一致选备(主备 DN 字节不同,`dn.match=TRIM`): + +```sql +UPDATE usercert_tab SET + organid = (SELECT organid FROM cert_src.usercert_tab WHERE hex(subject) = '<从库DN_HEX>' AND certtype = 1), + ... + stat = (SELECT stat FROM cert_src.usercert_tab WHERE hex(subject) = '<从库DN_HEX>' AND certtype = 1) + WHERE hex(subject) = '<主库DN_HEX>' AND certtype = 1; +``` + +末尾自检: + +```sql +COMMIT; + +DETACH DATABASE cert_src; + +SELECT 'rows' AS item, count(*) AS value FROM usercert_tab; +PRAGMA integrity_check; +``` + +注意: + +- `subject` 为 TEXT,SQLite 默认 BINARY 比较,即按原始字节比较; +- 使用 `hex()` 作为身份键,避免 BLOB/TEXT 类型比较陷阱; +- 不使用 `COLLATE NOCASE`,避免改变 DN 语义; +- 若从库存在库内重复键,主键冲突会让事务失败并回滚; +- SQL 语句与字符串字面量全部为 ASCII,仅注释包含中文;文件为 UTF-8 无 BOM。 + +### 10.5 生成计划 + +控制台示例: + +```text +SQL 生成计划: + 主库证书数 4 + 从库证书数 15 + 一致 0 + 主库多 1 + 从库多 12 + 不一致 3 + 写入(从库多+选备) 13 + 不一致选主 2 + 不一致选备 1 + 执行后主库记录数 16 + 从库(只读引用) /app/cert-compare/slave.db + SQL 文件 /app/cert-compare/merge-20261104-213307.sql +``` + +### 10.6 执行与结果校验 + +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 +``` + +| 校验项 | 方法 | +| --- | --- | +| 脚本退出码 | `sqlite3` 返回 0 | +| 文件完整性 | `PRAGMA integrity_check` = ok | +| 总数 | `SELECT 'rows'` = `mainCount + count(从库多)` | +| 从库多已插入 | 按身份键检查存在,且关键字段与从库一致 | +| 不一致选主 | 关键字段与主库一致 | +| 不一致选备 | 关键字段与从库一致 | +| 主库记录未变 | 未在选择“备”集合中的主库记录保持原值 | +| 从库未变 | 执行前后从库 SHA-256 一致 | +| 字节保持 | `hex(subject)` 与来源库一致,`typeof(subject) = 'text'` | + +### 10.7 备份、回滚与脚本保留 + +- 工具只生成脚本,不打开、不修改任何数据库文件; +- 执行前必须备份主库,脚本头注释给出 `cp -p` 备份命令; +- 脚本使用 `BEGIN IMMEDIATE` / `COMMIT`,中途报错时事务不提交; +- 需要回滚时用备份覆盖主库:`cp -p main.db.bak-<时间戳> main.db`; +- 历史脚本保留在当前工作目录,不自动删除、不覆盖(同名时追加 `-2`、`-3`); +- 脚本文件权限遵循系统 umask。 + +### 10.8 幂等与重复执行 + +脚本是幂等的:源库相同、标注相同时重复执行结果一致。 + +- `INSERT OR REPLACE` 按主键 `(subject, certtype)` 覆盖,不会累积重复记录; +- `UPDATE` 按主键定位并覆盖字段,重复执行结果不变; +- 已执行过的脚本再次执行仍得到同一记录集(实测两次执行后均为 16 条); +- 若两次执行之间源库或标注发生变化,需重新 `compare` 并生成新脚本。 + +--- + +## 11. 控制台输出、错误与退出码 + +### 11.1 compare 输出 + +```text +主库: /app/cert-compare/main.db 证书数: 4 +从库: /app/cert-compare/slave.db 证书数: 15 + +汇总: + 一致 0 + 主库多 1 + 从库多 12 + 不一致 3 + 差异合计 16 + +报表: + /app/cert-compare/reports/compare-report-20261104-213307.csv + +请按“合并选择”列标注:主 或 备,然后执行 sql 生成批量脚本。 +``` + +### 11.2 sql 输出 + +```text +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 结果。 +``` + +### 11.3 异常分类 + +| 异常 | 提示 | 退出码 | +| --- | --- | --- | +| 参数缺失/未知参数 | 用法 + 示例 | 2 | +| 配置文件错误 | 具体键与原因 | 2 | +| 文件不存在/不可读 | 路径 + 权限建议 | 2/4 | +| 非 SQLite 文件 | 文件头校验失败 | 4 | +| 表/字段缺失 | 缺失项列表 | 4 | +| 数据库被占用 | SQLITE_BUSY + 建议维护窗口 | 4 | +| 完整性校验失败 | 具体结果 | 4 | +| 报表过期/标注非法 | 行号 + 期望值 + 重新 compare 建议 | 5 | +| SQL 脚本生成/写入失败 | 路径 + 磁盘空间 + 权限建议 | 5 | + +### 11.4 日志 + +- **不生成日志文件**; +- 不创建 `logs/` 目录; +- 运行信息、警告、错误只输出到控制台(stdout/stderr); +- `--debug` 时在控制台输出异常堆栈; +- 不输出证书 PEM、公钥、签名等敏感内容。 + +### 11.5 安全 + +- 工具默认只读源库,不直接修改任何数据库; +- 合并由 DBA 执行生成的 SQL 脚本完成,执行前须备份主库; +- 脚本中的路径、十六进制字面量均经过单引号转义; +- 脚本不包含 DN 文本,仅包含 `hex(subject)`; +- 不在控制台/报表中输出证书大字段。 + +--- + +## 12. 性能与容量 + +| 项目 | 设计目标 | 依据 | +| --- | --- | --- | +| 3000 条 compare | ≤ 3s | 两次全表扫描 + 内存 Map | +| 3000 条 sql | ≤ 3s | 只生成脚本,不连接数据库写入 | +| 生成脚本数据量 | 3000 条 | 每 2000 个身份键一组 INSERT,约数百 KB | +| 内存 | ≤ 256MB | 默认不加载 certfile/pubkey | +| 磁盘 | 主库大小 + 脚本大小 | 工具不生成数据库副本 | +| 报表 | 3000 行以内 | 仅差异记录,通常远小于全量 | +| 脚本执行(人工) | 视数据量,3000 条约数秒 | `ATTACH` + 集合 SQL,SQLite 内部复制 | + +优化点: + +- 单次 SELECT 读取全部投影字段; +- 使用 `HashMap`,初始容量按记录数预估; +- 脚本使用集合 SQL(一条语句覆盖多个身份键),不逐行往返; +- 报表流式写入,不拼接大字符串; +- 报表读取一次性载入内存(差异通常远小于 3000); +- 脚本生成不打开数据库,避免锁库与一致性开销。 + +--- + +## 13. 测试设计 + +### 13.1 测试分层 + +| 层级 | 内容 | +| --- | --- | +| 单元测试 | 编码、SN 归一化、时间校验、身份键、DiffEngine、CSV 转义/解析、SQL 脚本生成 | +| 集成测试 | 复制测试库到临时目录,执行 compare/标注/sql,并执行生成的脚本 | +| 回归测试 | GBK 十六进制、报表过期、非法标注、脚本幂等性、源库哈希不变 | +| 性能测试 | 生成 3000 条含中文 DN 的数据 | + +### 13.2 测试夹具 + +| 夹具 | 构造 | 预期 | +| --- | --- | --- | +| A 现有测试库 | main 4 条 / slave 15 条 | 主库多 1、从库多 12、不一致 3、一致 0;报表 16 行 | +| B 完全一致 | slave 复制 main | 报表只有表头;脚本无写入语句 | +| C 双证 | 同一 DN,certtype 0/2 | 视为两张证书,不合并、不误报 | +| D 字段差异 | 同 DN+certtype,SN/时间/状态不同 | 分类不一致;选主/选备分别验证 | +| E 报表异常 | 空标注、非法标注、旧报表、缺行、多行 | sql 拒绝并提示行号/原因 | +| F 3000 条 | 生成中文 DN、分布 certtype/状态 | 性能达标;执行后字节不变 | +| G 编码回归 | 含“陈超”“北京”“信雅达”等 GBK 字符 | `hex(subject)` 执行前后一致 | +| H 无日志 | 执行完整流程 | 目录中无 `*.log`、无 `logs/` | + +### 13.3 关键断言 + +1. `hex(subject)` 执行脚本前后逐条一致; +2. `typeof(subject) = 'text'`; +3. 生成脚本前后 main.db / slave.db 的 SHA-256 不变; +4. 执行后总数 = 主库数 + 从库多; +5. 选主的不一致记录等于主库版本,选备的等于从库版本; +6. 相同源库与标注重复执行脚本,结果行数与字段保持一致; +7. 脚本正文(除注释)全部为 ASCII,不含 DN 文本; +8. 报表 UTF-8 BOM 正确,Excel 打开无乱码; +9. GBK 保存的报表可被读取并通过校验; +10. 脚本可在 sqlite3 3.6.20 上一次执行成功; +11. 不生成日志文件,不产生 `.tmp` 文件。 + +### 13.4 手工验收命令 + +```bash +cd /app/cert-compare +./cert-compare.sh +# set main /app/cert-compare/main.db +# set slave /app/cert-compare/slave.db +# compare +# (编辑报表“合并选择”列) +# sql --report reports/compare-report-....csv + +# 由 DBA 备份后执行(脚本生成在当前目录) +cp -p main.db main.db.bak-$(date +%Y%m%d-%H%M%S) +sqlite3 main.db < merge-*.sql +``` + +--- + +## 14. 构建与部署 + +### 14.1 本地构建 + +`pom.xml` 关键配置: + +```xml + + 1.8 + 1.8 + UTF-8 + + + + + org.xerial + sqlite-jdbc + 3.36.0.3 + + + junit + junit + 4.13.2 + test + + +``` + +打包: + +```bash +mvn clean package +# 产物:target/cert-compare.jar(shade fat jar) +``` + +要求: + +- `maven-shade-plugin` 合并依赖并设置 `Main-Class`; +- 保留 `META-INF/services/java.sql.Driver`; +- 不压缩/不破坏 sqlite-jdbc 原生库资源; +- 字节码目标 1.8; +- jar 内包含 `org.sqlite` 原生库。 + +### 14.2 部署目录 + +```text +/app/cert-compare/ + cert-compare.jar + config.properties + cert-compare.sh + reports/ # compare 报表 + merge-*.sql # 由 sql 生成到当前目录(启动脚本已 cd 到此目录) + main.db # 现有 + slave.db # 现有 + main.db.bak-* # 执行脚本前由 DBA 备份 +``` + +启动脚本: + +```sh +#!/bin/sh +cd "$(dirname "$0")" || exit 1 +exec java -Dfile.encoding=UTF-8 -Xmx256m -jar cert-compare.jar "$@" +``` + +### 14.3 部署步骤 + +```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 +``` + +### 14.4 运行注意 + +- sqlite-jdbc 会把原生库解压到 `java.io.tmpdir`,当前 `/tmp` 可写且可执行; +- 若生产环境 `/tmp` 挂载 `noexec`,需通过 `-Dorg.sqlite.tmpdir=/app/cert-compare/tmp` 指定可执行目录; +- 生成的脚本只使用 sqlite3 3.6.20 已支持的语法(`ATTACH`、`INSERT OR REPLACE ... SELECT`、`BEGIN IMMEDIATE`、`DETACH`、`PRAGMA integrity_check`); +- 生产执行脚本建议在维护窗口进行,或确认 HSM 应用无写入; +- 执行脚本前必须用 `cp -p` 备份主库; +- 不创建日志目录/日志文件。 + +### 14.5 部署与实测结果(2026-11-04) + +| 项目 | 结果 | +| --- | --- | +| 构建产物 | `target/cert-compare.jar`,9,799,848 字节,Java 8 字节码(major 52) | +| 单元测试 | 12 个用例全部通过(含 `SqlScriptTest` 2 个) | +| 部署路径 | `/app/cert-compare/{cert-compare.jar,config.properties,cert-compare.sh,reports/}` | +| 服务器运行 | JDK 1.8.0_152,sqlite-jdbc 3.36.0.3 原生库加载正常 | +| 测试数据 | main.db 4 条、slave.db 15 条(新增 3 组不一致) | +| compare | 一致 0、主库多 1、从库多 12、不一致 3、差异合计 16 | +| 报表列 | 无“备注”列;“合并选择”为最后一列 | +| 脚本输出位置 | 在 `/tmp/cert-restore-test` 下运行,脚本生成到该当前目录(`merge-20261104-215550.sql`,4,154 字节) | +| sql | 1 条 INSERT(13 个键);从库多 12 + 不一致选备 1 | +| 脚本执行 | 在 main.db 副本上以 sqlite3 3.6.20 执行,输出 `rows|16`、`ok` | +| 中文恢复 | 从库 2 条中文 DN(“北京 / 信雅达”)恢复后逐字段一致,`hex(subject)` 完全相同 | +| 中文解码 | 恢复库中文 DN 经 GB18030→UTF-8 解码为 `C=CN,ST=北京,L=北京,O=信雅达,...` | +| 主库中文保持 | 主库“陈超”记录执行脚本后 `hex(subject)`、SN 均未变化 | +| 选主校验 | `CN=diff-sn-01` 保留主库 SN `0A0B0C01`;`CN=diff-stat-03` 保留主库 `stat=0` | +| 选备校验 | `CN=diff-time-02` 使用从库失效时间 `20311231235959` | +| 字节保持 | 执行后“陈超”记录 `hex(subject)` 与主库完全一致(221 字节),`typeof(subject)=text`,iconv 解码正常 | +| 源库只读 | 生成/执行前后 main.db、slave.db SHA-256 均不变 | +| 幂等 | 同一脚本重复执行两次,记录数均为 16,`integrity_check=ok` | +| 报表兼容 | UTF-8 BOM 与 GB18030 保存的报表均可读取并通过校验 | +| 旧报表兼容 | 含“备注”、合并选择在中间的旧报表仍可读取并生成脚本 | +| 异常校验 | 空标注/非法标注/缺失报表均拒绝,退出码 5 | +| 交互模式 | `set main`、`set slave`、`status`、`sql --report`、`quit` 正常,退出码 0 | +| 日志 | 未生成 `*.log`,未创建 `logs/`,无 `.tmp` 残留 | + +测试数据脚本:`scripts/test-data-main.sql`、`scripts/test-data-slave.sql`;执行前已备份为 `main.db.bak-*`、`slave.db.bak-*`。 + +--- + +## 15. 风险与对策 + +| 风险 | 影响 | 对策 | +| --- | --- | --- | +| GBK 字段被按 UTF-8 读取 | DN 乱码、错误匹配 | 强制 `hex` 读取;脚本走 SQL 内部复制;编码回归测试 | +| 报表被 Excel 改坏 | 标注丢失、DN_HEX 变科学计数法 | `HEX:` 前缀;重新 compare;严格校验报表与当前库一致 | +| 报表保存为 GBK | 读取乱码 | 读取器自动识别 UTF-8/GB18030 | +| 报表过期 | 脚本内容与当前库不符 | sql 前重新 compare 并逐项校验,失败即拒绝 | +| 不一致行未标注 | 无法决定版本 | 默认留空;sql 拒绝并提示行号 | +| 标注写错(主/备) | 选错证书版本 | 计划中展示选主/选备数量;执行后校验字段与所选源一致 | +| 脚本执行出错 | 主库部分变更 | `BEGIN IMMEDIATE` / `COMMIT` 事务;执行前备份;失败按备份回滚 | +| 生产库正在写入 | 脚本执行锁等待、`SQLITE_BUSY` | 维护窗口;脚本使用 `BEGIN IMMEDIATE` | +| 从库路径变化 | 脚本 ATTACH 失败 | 生成脚本时写入绝对路径;执行前确认从库未移动 | +| 仅按 DN 比对 | 双证被误判 | 身份键加入 certtype | +| DN 属性大小写/空格差异 | 逻辑同一 DN 被当作两张 | 默认严格匹配,预留 TRIM;报表自行过滤 | +| TRIM 场景主备 DN 字节不同 | 按从库字节删除会丢数据 | 改用 `UPDATE`,按主库字节定位 | +| SN 前导 0 差异 | 误报不一致 | 默认 HEX 归一化,展示保留原值 | +| certtype 含义未知 | 报表可读性差 | 本期显示数值,预留映射配置 | +| 单条 SQL 过长 | sqlite3 解析慢或失败 | 每 2000 个身份键拆分一条语句 | +| 测试数据仅 19 条(主 4 + 从 15) | 性能/边界覆盖不足 | 构造 3000 条及异常夹具 | +| `/tmp` noexec | 原生库加载失败 | 支持 `org.sqlite.tmpdir` | + +--- + +## 16. 待确认事项 + +1. `certtype` 取值 0/1/2/4/5 的业务名称,是否需要在报表中显示中文; +2. 生产环境是否允许维护窗口/停写,或必须在线只读; +3. SQL 脚本命名(当前 `merge-<时间戳>.sql`)与历史脚本保留策略; +4. 主库多/从库多的“合并选择”是否允许改成空(跳过);本期按“所有证书都需要”固定为主/备; +5. SN 前导 0 归一化是否与现网业务查询完全一致; +6. 是否需要 DN 近似匹配(大小写、属性顺序)作为可选模式; +7. 报表是否需要 XLSX 格式(当前 CSV,Excel 可直接编辑)。 + +--- + +## 17. 附录 + +### 17.1 表结构(测试库) + +```sql +CREATE TABLE "usercert_tab" ( + `organid` varchar(32) NOT NULL, + `subject` varchar(256) NOT NULL, + `issuer` varchar(256) NOT NULL, + `sn` varchar(64) NOT NULL, + `pubkey_alg` int(4) NOT NULL, + `pubkey` varchar(1032) NOT NULL, + `sign_alg` int(4) NOT NULL, + `signinfo` varchar(512) NOT NULL, + `not_before` varchar(14) NOT NULL, + `not_after` varchar(14) NOT NULL, + `certfile` varchar(2048) NOT NULL, + `certtype` smallint(4) NOT NULL, + `opertime` varchar(14) NOT NULL, + `stat` smallint(4) NOT NULL, + PRIMARY KEY (`subject`, `certtype`) +); +``` + +### 17.2 测试库分类预期 + +| 分类 | 数量 | 说明 | +| --- | --- | --- | +| ONLY_MAIN(主库多) | 1 | 主库“陈超”证书不在从库 | +| ONLY_SLAVE(从库多) | 12 | 从库原有 12 张证书不在主库 | +| DIFF(不一致) | 3 | 3 组构造数据:SN、失效时间、状态各一组 | +| EQUAL(一致) | 0 | 无相同身份键且字段全同 | +| 报表记录 | 16 | 1 + 12 + 3 | +| 执行脚本后主库总数 | 16 | 4(主库)+ 12(从库多) | + +### 17.3 已验证的关键命令 + +```bash +# 版本与环境 +java -version +sqlite3 --version +cat /etc/redhat-release + +# 完整性 +sqlite3 main.db 'pragma integrity_check;' +sqlite3 slave.db 'pragma integrity_check;' + +# 编码证据 +sqlite3 main.db "select hex(subject) from usercert_tab;" + +# JDBC 兼容性(服务器) +java -cp '.:sqlite-jdbc-3.36.0.3.jar' JdbcProbe /app/cert-compare/main.db + +# 生成脚本并在主库副本上执行 +./cert-compare.sh sql --report reports/compare-report-....csv +cp -p main.db test-main.db +sqlite3 test-main.db < merge-....sql +sqlite3 test-main.db "select count(*) from usercert_tab; pragma integrity_check;" +``` + +### 17.4 环境验证证据摘要 + +| 证据 | 结果 | +| --- | --- | +| JDBC 读取原始字节 | `hex=...B3C2B3AC...`,GB18030 解码为“陈超” | +| JDBC `getString` | 长度 109,含 `U+FFFD`,不可用于比对 | +| ATTACH INSERT SELECT | 插入后 `hex` 相同、`typeof=text` | +| 事务回滚 | 记录数恢复,回滚成功 | +| 脚本执行(sqlite3 3.6.20) | `rows|16`、`ok`,重复执行仍为 16 条 | +| sqlite-jdbc 3.36 | 在 glibc 2.12 加载成功 | +| sqlite-jdbc 3.45 | 缺 slf4j,不选 | +| 证书字段一致性 | 13/13 SN、有效期与 DER 一致 | + +--- + +## 18. 后续实施顺序 + +1. 搭建 Maven 工程与 fat jar 构建; +2. 实现配置、数据库校验、`set`/`status`; +3. 实现字节级 DAO 与身份键、compare 引擎; +4. 实现单份 CSV 报表(含“合并选择”列); +5. 实现报表读取、编码识别、标注校验; +6. 实现 SQL 生成计划、`ATTACH + INSERT OR REPLACE / UPDATE` 脚本; +7. 编写测试夹具(含 3000 条生成器)并验收; +8. 部署到 `/app/cert-compare`,完成真实库只读比对与脚本执行演练。 diff --git a/需求文档.md b/需求文档.md new file mode 100644 index 0000000..5a42731 --- /dev/null +++ b/需求文档.md @@ -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 +``` + +- 校验文件存在、可读、是合法 SQLite 文件、包含 `usercert_tab` 表、必需字段齐全; +- 成功时输出:绝对路径、文件大小、证书总数; +- 仅保存路径,不长期持有数据库连接,避免锁库; +- 无参数执行 `set main` 时显示当前主库路径与状态。 + +### FR-04 set slave + +同 FR-03,用于设置从库路径。 + +### FR-05 SQL 脚本输出位置 + +- 不提供单独的输出目录设置命令; +- `sql` 生成的脚本直接写入**运行时的当前工作目录**; +- 脚本文件名固定为 `merge-.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-.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-.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 张**。