feat: HSM main/slave certificate comparison with batch SQL generation

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