feat(sdk): AuthConfigs, JSON Schema, examples, and release checksum CI

Add craftlabs-auth-config.schema.json, Java AuthConfigs model with tests,
example configs aligned to BP-10, C/Java/auth-config documentation,
native header notes, RELEASING guide, and workflow to verify SDK
artifact checksums on release tags.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:05:12 +08:00
parent 65eb983035
commit f94f03bcc2
31 changed files with 1219 additions and 22 deletions
+93
View File
@@ -0,0 +1,93 @@
# CraftLabs 授权 SDK — 发布与完整性
对齐架构文档 [SYSTEM_ARCHITECTURE §9.8](../docs/engineering/SYSTEM_ARCHITECTURE.md):官方渠道、**SHA-256 清单**、**GPG 签名(建议)**、`java``native` **同 Git tag**
## 1. 发布前检查
- [ ] `mvn -f java/pom.xml verify` 通过(JDK 17+)。
- [ ] `native` 已在各目标平台完成构建,且与本次 **同一 tag** 一并交付。
- [ ] `CHANGELOG`(或发布说明)写明 **比特 SDK / 运行时** 兼容版本。
## 2. 生成 SHA256SUMS(必做)
在仓库根目录执行(会先 `mvn -DskipTests package`):
```bash
chmod +x scripts/sdk-release-checksums.sh
./scripts/sdk-release-checksums.sh --output dist/sdk-release
```
已构建过时跳过 Maven
```bash
./scripts/sdk-release-checksums.sh --no-mvn --output dist/sdk-release
```
**本机构建出的 Native** 一并写入清单(路径相对于仓库根,便于客户校验):
```bash
./scripts/sdk-release-checksums.sh --output dist/sdk-release --native-path "$(pwd)/native/build"
```
含完整测试的构建后再生成清单:
```bash
./scripts/sdk-release-checksums.sh --verify --output dist/sdk-release
```
输出:
- `dist/sdk-release/SHA256SUMS` — 每行:`哈希 相对路径`
- `dist/sdk-release/RELEASE-MANIFEST.txt` — 提交 SHA、UTC 时间
**客户校验**(在克隆/解压后的仓库根或同目录结构下):
```bash
sha256sum -c dist/sdk-release/SHA256SUMS
```
macOS
```bash
shasum -a 256 -c dist/sdk-release/SHA256SUMS
```
`SHA256SUMS` 本身做 **分离签名**(本机已配置 GPG):
```bash
SIGN=1 ./scripts/sdk-release-checksums.sh --no-mvn --output dist/sdk-release
```
生成 `SHA256SUMS.asc`;客户使用公布的公钥:`gpg --verify SHA256SUMS.asc SHA256SUMS`
## 3. Maven JAR 的 GPG 签名(强烈建议)
父 POM 已配置 `maven-gpg-plugin`**默认跳过**`gpg.skip=true`),不影响日常 `verify`
发布前在已导入私钥的机器上:
```bash
gpg --version # 确认可用
mvn -f java/pom.xml -Prelease-sign verify
```
**可发布模块**`craftlabs-auth-core``craftlabs-auth-bitanswer``craftlabs-auth-selfhosted`)的 `target/*.jar` 旁会出现 **`.asc`**。`craftlabs-auth-tests` 模块固定 **不签名**
若无私钥或未就绪,可只发 **SHA256SUMS**;待密钥就绪后再打开 `-Prelease-sign`
### CI / 无人值守(可选)
在构建机配置 `MAVEN_GPG_PASSPHRASE` 等环境变量,或使用 `gpg-agent`;勿将私钥提交仓库。GitHub Actions 可用 `GPG_PRIVATE_KEY` secret + [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg) 导入后再执行 `mvn -Prelease-sign verify`
## 4. GitHub Release 建议资产
每个 **tag** 上传:
1. 三个 **release JAR**(及对应 **.asc**,若已签名)
2. 各平台 **Native** 压缩包
3. **`SHA256SUMS`** 与 **`SHA256SUMS.asc`**
4. 固定页面公布 **GPG 公钥指纹** 与下载说明
---
版权所有 © 广州创飞人工智能技术有限公司(以项目实际声明为准)。
+17
View File
@@ -13,4 +13,21 @@
<artifactId>craftlabs-auth-core</artifactId>
<name>CraftLabs Auth — core API</name>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -3,6 +3,9 @@ package cn.craftlabs.auth;
/**
* 授权能力的统一契约:初始化、激活、校验许可、查询特性与释放等生命周期方法。
*
* <p>{@link #initialize(String)} 的 JSON 建议使用 {@link cn.craftlabs.auth.config.AuthConfigs#parse(String)}
* 先校验后再传入,格式见仓库 {@code schemas/craftlabs-auth-config.schema.json} 与 {@code examples/config/}。
*
* <p>实现类负责加载对应 native 或远端适配器;调用方应在不再使用时调用 {@link #close()} 释放底层资源。
*
* <p>版权所有 © 广州创飞人工智能技术有限公司
@@ -0,0 +1,47 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* {@link cn.craftlabs.auth.AuthProvider#initialize(String)} 所用 JSON 的配置模型,与 {@code
* schemas/craftlabs-auth-config.schema.json} 对齐。
*
* <p>版权所有 © 广州创飞人工智能技术有限公司
*
* @author huangping@craftlabs.cn
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AuthConfig(
@JsonProperty("schemaVersion") int schemaVersion,
@JsonProperty("provider") String provider,
@JsonProperty("scenario") String scenario,
@JsonProperty("bitanswer") BitanswerConfigSection bitanswer,
@JsonProperty("selfhosted") SelfhostedConfigSection selfhosted,
@JsonProperty("features") Map<String, FeatureMapping> features,
@JsonProperty("wharf") WharfScenarioSection wharf,
@JsonProperty("school") SchoolScenarioSection school,
@JsonProperty("floating") FloatingScenarioSection floating) {
public AuthConfig {
features = features == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(features));
provider = Objects.requireNonNullElse(provider, "");
scenario = Objects.requireNonNullElse(scenario, "");
}
/** 逻辑特性键对应的比特特征 id;未配置或非数值映射时返回 {@code null}。 */
public Integer bitanswerFeatureId(String logicalFeatureKey) {
FeatureMapping m = features.get(logicalFeatureKey);
return m != null ? m.bitanswerFeatureId() : null;
}
/** 逻辑特性键对应的比特特征名称;未配置时返回 {@code null}。 */
public String bitanswerFeatureName(String logicalFeatureKey) {
FeatureMapping m = features.get(logicalFeatureKey);
return m != null ? m.bitanswerFeatureName() : null;
}
}
@@ -0,0 +1,19 @@
package cn.craftlabs.auth.config;
/**
* {@link AuthConfigs#parse(String)} 或校验失败时抛出。
*
* <p>版权所有 © 广州创飞人工智能技术有限公司
*
* @author huangping@craftlabs.cn
*/
public final class AuthConfigException extends Exception {
public AuthConfigException(String message) {
super(message);
}
public AuthConfigException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,111 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 解析并校验 {@link cn.craftlabs.auth.AuthProvider#initialize(String)} 的 JSON 配置。
*
* <p>版权所有 © 广州创飞人工智能技术有限公司
*
* @author huangping@craftlabs.cn
*/
public final class AuthConfigs {
/** 与 {@code schemas/craftlabs-auth-config.schema.json} 中 {@code schemaVersion} 一致。 */
public static final int SCHEMA_VERSION = 1;
private static final ObjectMapper MAPPER =
new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private AuthConfigs() {}
/** 解析 JSON 并执行 {@link #validate(AuthConfig)}。 */
public static AuthConfig parse(String json) throws AuthConfigException {
if (json == null || json.isBlank()) {
throw new AuthConfigException("config JSON is null or blank");
}
try {
AuthConfig cfg = MAPPER.readValue(json, AuthConfig.class);
validate(cfg);
return cfg;
} catch (JsonProcessingException e) {
String msg = e.getOriginalMessage() != null ? e.getOriginalMessage() : e.getMessage();
throw new AuthConfigException("Invalid config JSON: " + msg, e);
}
}
/** 将配置写回 JSON(便于日志脱敏后落盘或调试)。 */
public static String toJson(AuthConfig config) throws AuthConfigException {
try {
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(config);
} catch (JsonProcessingException e) {
throw new AuthConfigException("Failed to serialize config", e);
}
}
/**
* 校验必填组合;通过后可安全交给 native / 供应商实现。
*
* @throws AuthConfigException 校验失败,消息为多条原因拼接
*/
public static void validate(AuthConfig c) throws AuthConfigException {
List<String> err = new ArrayList<>();
if (c.schemaVersion() != SCHEMA_VERSION) {
err.add("schemaVersion must be " + SCHEMA_VERSION);
}
String provider = norm(c.provider());
if (provider.isEmpty()) {
err.add("provider is required");
} else if (!provider.equals("bitanswer") && !provider.equals("selfhosted")) {
err.add("provider must be bitanswer or selfhosted");
}
String scenario = norm(c.scenario());
if (scenario.isEmpty()) {
err.add("scenario is required");
} else if (!scenario.equals("wharf") && !scenario.equals("school") && !scenario.equals("floating")) {
err.add("scenario must be wharf, school, or floating");
}
if (provider.equals("bitanswer")) {
if (c.bitanswer() == null) {
err.add("bitanswer section is required when provider=bitanswer");
} else if (isBlank(c.bitanswer().url())) {
err.add("bitanswer.url is required and must be non-blank");
}
}
if (provider.equals("selfhosted")) {
if (c.selfhosted() == null) {
err.add("selfhosted section is required when provider=selfhosted");
} else if (isBlank(c.selfhosted().baseUrl())) {
err.add("selfhosted.baseUrl is required and must be non-blank");
}
}
if (scenario.equals("floating")) {
if (c.floating() == null || isBlank(c.floating().projectId())) {
err.add("floating.projectId is required when scenario=floating");
}
}
if (!err.isEmpty()) {
throw new AuthConfigException(String.join("; ", err));
}
}
private static String norm(String s) {
return s == null ? "" : s.trim().toLowerCase(Locale.ROOT);
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
@@ -0,0 +1,17 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* {@code config_json} 中与比特安索客户端相关的字段子集。
*
* @author huangping@craftlabs.cn
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record BitanswerConfigSection(
@JsonProperty("url") String url,
@JsonProperty("loginMode") String loginMode,
@JsonProperty("rootPath") String rootPath,
@JsonProperty("sn") String sn,
@JsonProperty("applicationData") String applicationData) {}
@@ -0,0 +1,14 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 逻辑特性键(如 {@code face})到比特特征项的映射;至少填 id 或 name 之一即可在实现层选用。
*
* @author huangping@craftlabs.cn
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FeatureMapping(
@JsonProperty("bitanswerFeatureId") Integer bitanswerFeatureId,
@JsonProperty("bitanswerFeatureName") String bitanswerFeatureName) {}
@@ -0,0 +1,11 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/** 流动人口按项目授权场景补充字段。 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FloatingScenarioSection(
@JsonProperty("projectId") String projectId,
@JsonProperty("projectName") String projectName,
@JsonProperty("contractRef") String contractRef) {}
@@ -0,0 +1,10 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/** 学校按边设备运营场景补充字段。 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SchoolScenarioSection(
@JsonProperty("edgeDeviceId") String edgeDeviceId,
@JsonProperty("tenantId") String tenantId) {}
@@ -0,0 +1,14 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* {@code provider=selfhosted} 时的 HTTP 后端参数。
*
* @author huangping@craftlabs.cn
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SelfhostedConfigSection(
@JsonProperty("baseUrl") String baseUrl,
@JsonProperty("tenantKey") String tenantKey) {}
@@ -0,0 +1,11 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/** 码头 / 集中授权场景补充字段。 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record WharfScenarioSection(
@JsonProperty("topology") String topology,
@JsonProperty("groupServiceUrl") String groupServiceUrl,
@JsonProperty("notes") String notes) {}
@@ -0,0 +1,78 @@
package cn.craftlabs.auth.config;
import static org.junit.jupiter.api.Assertions.*;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class AuthConfigsTest {
@Test
void parseWharfExampleFromClasspath() throws Exception {
String json = readClasspath("examples/config/wharf.bitanswer.json");
AuthConfig c = AuthConfigs.parse(json);
assertEquals(1, c.schemaVersion());
assertEquals("bitanswer", c.provider());
assertEquals("wharf", c.scenario());
assertNotNull(c.bitanswer());
assertTrue(c.bitanswer().url().startsWith("bit://"));
assertEquals(101, c.bitanswerFeatureId("container_number_detect"));
assertEquals("group", c.wharf().topology());
}
@Test
void parseSchoolExample() throws Exception {
String json = readClasspath("examples/config/school.bitanswer.json");
AuthConfig c = AuthConfigs.parse(json);
assertEquals("school", c.scenario());
assertEquals(201, c.bitanswerFeatureId("face"));
assertEquals("classroom-gate-01", c.school().edgeDeviceId());
}
@Test
void parseFloatingExample_requiresProjectId() throws Exception {
String json = readClasspath("examples/config/floating.bitanswer.json");
AuthConfig c = AuthConfigs.parse(json);
assertEquals("floating", c.scenario());
assertEquals("migrant-flow-prj-2026-001", c.floating().projectId());
}
@Test
void validateFails_whenFloatingWithoutProject() {
String json =
"""
{"schemaVersion":1,"provider":"bitanswer","scenario":"floating",\
"bitanswer":{"url":"http://x"},"floating":{}}\
""";
AuthConfigException ex = assertThrows(AuthConfigException.class, () -> AuthConfigs.parse(json));
assertTrue(ex.getMessage().contains("projectId"));
}
@Test
void validateFails_whenBitanswerWithoutUrl() {
String json =
"""
{"schemaVersion":1,"provider":"bitanswer","scenario":"school",\
"bitanswer":{"url":""}}\
""";
assertThrows(AuthConfigException.class, () -> AuthConfigs.parse(json));
}
@Test
void roundTrip_toJson() throws Exception {
String json = readClasspath("examples/config/school.bitanswer.json");
AuthConfig c = AuthConfigs.parse(json);
String out = AuthConfigs.toJson(c);
AuthConfig again = AuthConfigs.parse(out);
assertEquals(c.scenario(), again.scenario());
assertEquals(c.bitanswerFeatureId("expression"), again.bitanswerFeatureId("expression"));
}
private static String readClasspath(String path) throws Exception {
try (InputStream in = AuthConfigsTest.class.getClassLoader().getResourceAsStream(path)) {
assertNotNull(in, "missing classpath resource: " + path);
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
@@ -0,0 +1,69 @@
package cn.craftlabs.auth.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* BP-10 / I5{@code examples/config/*.json} 与仓库根 {@code schemas/craftlabs-auth-config.schema.json} 一致。
*/
class ExamplesConfigSchemaValidationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void allExampleConfigsValidateAgainstSchema() throws Exception {
Path repoRoot = findRepoRoot();
Path schemaPath = repoRoot.resolve("schemas/craftlabs-auth-config.schema.json");
Path examplesDir = repoRoot.resolve("examples/config");
assertTrue(Files.isRegularFile(schemaPath), "schema missing: " + schemaPath);
assertTrue(Files.isDirectory(examplesDir), "examples dir missing: " + examplesDir);
JsonNode schemaNode = MAPPER.readTree(schemaPath.toFile());
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
JsonSchema schema = factory.getSchema(schemaNode);
try (Stream<Path> stream = Files.list(examplesDir)) {
stream.filter(p -> p.toString().endsWith(".json")).forEach(jsonFile -> {
try {
JsonNode instance = MAPPER.readTree(jsonFile.toFile());
Set<ValidationMessage> errors = schema.validate(instance);
assertTrue(
errors.isEmpty(),
() -> jsonFile.getFileName() + ": " + errors);
} catch (IOException e) {
throw new AssertionError(jsonFile.toString(), e);
}
});
}
}
/**
* 自 {@code user.dir} 向上查找含 {@code schemas/craftlabs-auth-config.schema.json} 的目录(兼容模块目录或仓库根执行)。
*/
static Path findRepoRoot() {
Path p = Path.of(System.getProperty("user.dir", ".")).toAbsolutePath().normalize();
for (int i = 0; i < 8 && p != null; i++) {
Path candidate = p.resolve("schemas/craftlabs-auth-config.schema.json");
if (Files.isRegularFile(candidate)) {
return p;
}
p = p.getParent();
}
throw new IllegalStateException(
"Could not find repo root (schemas/craftlabs-auth-config.schema.json) from user.dir="
+ System.getProperty("user.dir"));
}
}
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"provider": "bitanswer",
"scenario": "floating",
"bitanswer": {
"url": "https://cloud.bitanswer.example/e3",
"loginMode": "REMOTE"
},
"features": {
"face": { "bitanswerFeatureId": 301 }
},
"floating": {
"projectId": "migrant-flow-prj-2026-001",
"projectName": "某市流动人口人像核验",
"contractRef": "PO-2026-8848"
}
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"provider": "bitanswer",
"scenario": "school",
"bitanswer": {
"url": "https://cloud.bitanswer.example/e3",
"loginMode": "AUTO",
"sn": ""
},
"features": {
"face": { "bitanswerFeatureId": 201 },
"expression": { "bitanswerFeatureId": 202 }
},
"school": {
"edgeDeviceId": "classroom-gate-01",
"tenantId": "school-district-demo"
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"provider": "bitanswer",
"scenario": "wharf",
"bitanswer": {
"url": "bit://license.example.com:8273",
"loginMode": "AUTO",
"rootPath": "/var/lib/craftlabs/bitanswer"
},
"features": {
"container_number_detect": { "bitanswerFeatureId": 101 },
"container_number_recognize": { "bitanswerFeatureId": 102 },
"iso_detect": { "bitanswerFeatureId": 103 },
"iso_recognize": { "bitanswerFeatureId": 104 }
},
"wharf": {
"topology": "group",
"groupServiceUrl": "bit://license.example.com:8273",
"notes": "边设备集中连集团服务;特征项与控制台产品一致后替换 id。"
}
}
+2
View File
@@ -28,6 +28,8 @@
</dependencies>
<properties>
<!-- 测试模块不发布;跳过 GPG 以免生成多余 .asc -->
<gpg.skip>true</gpg.skip>
<native.library.path>${project.basedir}/../../native/build</native.library.path>
</properties>
+47
View File
@@ -21,6 +21,10 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>17</maven.compiler.release>
<junit.version>5.10.2</junit.version>
<jackson.version>2.17.2</jackson.version>
<json-schema-validator.version>1.5.7</json-schema-validator.version>
<!-- 默认跳过 GPG;发布签名:mvn -Prelease-sign verify(见 RELEASING.md -->
<gpg.skip>true</gpg.skip>
</properties>
<dependencyManagement>
@@ -36,6 +40,17 @@
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
@@ -52,7 +67,39 @@
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.7</version>
</plugin>
</plugins>
</pluginManagement>
</build>
<!-- 对发布 JAR 做 GPG 分离签名(.asc);显式 -Prelease-sign 时启用 -->
<profiles>
<profile>
<id>release-sign</id>
<properties>
<gpg.skip>false</gpg.skip>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<executions>
<execution>
<id>sign-release-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>