mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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();
|
||||
}
|
||||
}
|
||||
+17
@@ -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) {}
|
||||
+11
@@ -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) {}
|
||||
+10
@@ -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) {}
|
||||
+14
@@ -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) {}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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。"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user