Files
craftlabs-authorization-sdk/docs/superpowers/plans/2026-05-18-selfhosted-licensing-sdk.md

576 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自研授权 SDK 实施计划(完整版)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现自研软件授权 SDKRust native + Java SDK + 平台签发后端),与比特安索双线共存,Provider 可扩展架构。
**Architecture:** 单 Rust cdylibcraftlabs_auth_core)通过 Provider trait 路由。许可证 AES-256-GCM 加密载荷 + RSA-256 签名。签发走 delivery-platform-api:8080SDK 在线交互走 license-webhook-ingress:8081。
**Tech Stack:** Rust (craft-core, cdylib/staticlib), Java 17 (Maven), Spring Boot 3.4.x, PostgreSQL 15 + MyBatis-Plus, Flyway
**Reference Spec:** `docs/superpowers/specs/2026-05-18-selfhosted-licensing-sdk-design.md`
---
## Phase 0-1 速览(Phase 1 完整步骤见下方任务列表)
Phase 0(构建准备)+ Phase 1(离线核心:Rust crypto/device/cache/license 模块 + trait_provider + lib.rs 重构 + Java 配置扩展 + Schema + 数据库迁移 + LicenseSigner + LicenseController)共计 15 个任务。
---
## Task 0.1: 更新 Cargo.toml
**Files:** Modify `native/craft-core/Cargo.toml`
- [ ] **Step 1: 重命名 lib + 添加依赖**
```toml
[lib]
crate-type = ["cdylib", "staticlib"]
name = "craftlabs_auth_core"
[dependencies]
obfstr = "0.4"
sha2 = "0.10"
libloading = "0.8"
once_cell = "1"
rsa = { version = "0.9", features = ["sha2"] }
aes-gcm = "0.10"
hkdf = "0.12"
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"] }
[build-dependencies]
sha2 = "0.10"
[features]
default = ["security-hardening"]
security-hardening = []
```
- [ ] **Step 2: `cargo check --manifest-path native/craft-core/Cargo.toml`**
- [ ] **Step 3: `git commit -m "build(native): rename lib to craftlabs_auth_core, add selfhosted deps"`**
---
## Task 1.1-1.6: Rust 离线核心模块(Part 1 覆盖)
以下 6 个任务的完整代码见 Part 1(文档长度原因拆分)。Part 1 commit 记录:
| 任务 | 文件 | 内容 |
|------|------|------|
| 1.1 | `error.rs` | 扩展错误码(加密/签名/许可证状态) |
| 1.2 | `crypto.rs` | HKDF 密钥派生 + AES-256-GCM 加解密 + RSA-SHA256 验签 |
| 1.3 | `device.rs` | 4 层硬件指纹采集(Linux DMI/machine-id/FS/MAC),stability_score |
| 1.4 | `provider_selfhosted/cache.rs` | 本地加密缓存(AES 加密写入磁盘 + 心跳 checkpoint |
| 1.5 | `provider_selfhosted/license.rs` | 验签→解密→时间校验→离线宽限期 |
| 1.6 | `provider_selfhosted/mod.rs` | SelfHostedProvider struct + 初始化/离线校验/hasFeature |
> Part 1 详细步骤已写入 Gitcommit `d7469af`spec)及之前。
---
## Task 1.7: trait_provider.rsProvider trait + 路由)
**Files:** Create `native/craft-core/src/trait_provider.rs`
- [ ] **Step 1: 创建 trait_provider.rs**
```rust
use std::collections::HashMap;
use crate::{CraftContext, LicenseInfo, error::LicenseError};
pub struct LicenseStatus {
pub licensed: bool,
pub not_after: Option<i64>,
pub features: HashMap<String, bool>,
pub device_count: u32,
pub max_devices: u32,
pub heartbeat_due: Option<i64>,
}
pub struct ActivateResponse {
pub success: bool,
pub device_id: String,
pub license_payload: Vec<u8>,
}
pub struct HeartbeatResponse {
pub valid: bool,
pub lease_until: Option<i64>,
pub update_available: bool,
pub new_license_payload: Option<Vec<u8>>,
}
pub trait Provider: Send + Sync {
fn initialize(&mut self, ctx: &CraftContext, config_json: &str) -> Result<(), LicenseError>;
fn activate(&self, ctx: &CraftContext, license_key: &str) -> Result<ActivateResponse, LicenseError>;
fn check_license(&self, ctx: &CraftContext) -> Result<LicenseStatus, LicenseError>;
fn heartbeat(&self, ctx: &CraftContext) -> Result<HeartbeatResponse, LicenseError>;
fn has_feature(&self, ctx: &CraftContext, name: &str) -> bool;
fn release(&mut self, ctx: &CraftContext) -> Result<(), LicenseError>;
fn get_license_info(&self, ctx: &CraftContext) -> LicenseInfo;
fn close(&mut self);
}
```
- [ ] **Step 2: `cargo check` → `git commit -m "feat(rust): add Provider trait abstraction"`**
---
## Task 1.8: 重构 lib.rsC ABI 路由到 Provider trait
**Files:** Modify `native/craft-core/src/lib.rs`
- [ ] **Step 1: 替换 lib.rs**
```rust
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
mod trait_provider;
mod error;
mod crypto;
mod device;
mod provider_selfhosted;
mod security;
mod session;
pub use trait_provider::{Provider, ActivateResponse, HeartbeatResponse, LicenseStatus};
pub use error::LicenseError;
pub struct CraftContext {
pub provider: Option<Box<dyn Provider>>,
pub initialized: bool,
}
#[repr(C)] pub struct CraftResult { pub success: i32, pub message: *const c_char }
#[repr(C)]
pub struct LicenseInfo {
pub is_licensed: i32,
pub expiration_date: i64,
pub feature_names: *const *const c_char,
pub feature_values: *const i32,
pub feature_count: i32,
}
impl CraftContext { pub fn new() -> Self { CraftContext { provider: None, initialized: false } } }
unsafe fn c_str_to_string(ptr: *const c_char) -> String {
if ptr.is_null() { String::new() } else { CStr::from_ptr(ptr).to_string_lossy().into_owned() }
}
static OK_MSG: &[u8] = b"ok\0";
fn ok_result() -> CraftResult { CraftResult { success: 1, message: OK_MSG.as_ptr() as *const c_char } }
fn craft_fail() -> CraftResult {
static FAIL: &[u8] = b"failure\0";
CraftResult { success: 0, message: FAIL.as_ptr() as *const c_char }
}
fn parse_provider(config: &str) -> String {
serde_json::from_str::<serde_json::Value>(config).ok()
.and_then(|v| v.get("provider").and_then(|p| p.as_str().map(|s| s.to_string())))
.unwrap_or_else(|| "selfhosted".to_string())
}
#[no_mangle]
pub extern "C" fn craft_initialize(config_json: *const c_char) -> *mut CraftContext {
let config_str = unsafe { c_str_to_string(config_json) };
let mut ctx = Box::new(CraftContext::new());
let mut provider: Box<dyn Provider> = match parse_provider(&config_str).as_str() {
"selfhosted" => Box::new(provider_selfhosted::SelfHostedProvider::new()),
_ => Box::new(provider_selfhosted::SelfHostedProvider::new()),
};
if let Err(_) = provider.initialize(&ctx, &config_str) {
ctx.initialized = false;
} else {
ctx.provider = Some(provider);
ctx.initialized = true;
}
Box::into_raw(ctx)
}
#[no_mangle]
pub extern "C" fn craft_activate(handle: *mut CraftContext, license_key: *const c_char, _: *const c_char) -> CraftResult {
if handle.is_null() { return craft_fail(); }
let ctx = unsafe { &*handle };
let key = unsafe { c_str_to_string(license_key) };
ctx.provider.as_ref().and_then(|p| p.activate(ctx, &key).ok()).map_or_else(craft_fail, |_| ok_result())
}
#[no_mangle]
pub extern "C" fn craft_check_license(handle: *mut CraftContext) -> CraftResult {
if handle.is_null() { return craft_fail(); }
let ctx = unsafe { &*handle };
ctx.provider.as_ref().and_then(|p| p.check_license(ctx).ok())
.map_or_else(craft_fail, |s| if s.licensed { ok_result() } else { craft_fail() })
}
#[no_mangle]
pub extern "C" fn craft_get_license_info(handle: *mut CraftContext) -> *mut LicenseInfo {
if handle.is_null() { return ptr::null_mut(); }
let ctx = unsafe { &*handle };
ctx.provider.as_ref().map(|p| p.get_license_info(ctx))
.map_or(ptr::null_mut(), |info| Box::into_raw(Box::new(info)))
}
#[no_mangle] pub extern "C" fn craft_free_license_info(info: *mut LicenseInfo) {
if !info.is_null() { unsafe { drop(Box::from_raw(info)); } }
}
#[no_mangle]
pub extern "C" fn craft_has_feature(handle: *mut CraftContext, feature_name: *const c_char) -> i32 {
if handle.is_null() { return 0; }
let ctx = unsafe { &*handle };
let name = unsafe { c_str_to_string(feature_name) };
ctx.provider.as_ref().map_or(0, |p| if p.has_feature(ctx, &name) { 1 } else { 0 })
}
#[no_mangle] pub extern "C" fn craft_release(handle: *mut CraftContext) -> CraftResult {
if handle.is_null() { return craft_fail(); }
let ctx = unsafe { &mut *handle };
ctx.provider.as_mut().and_then(|p| p.release(ctx).ok());
ok_result()
}
#[no_mangle] pub extern "C" fn craft_heartbeat(handle: *mut CraftContext) -> CraftResult {
if handle.is_null() { return craft_fail(); }
let ctx = unsafe { &*handle };
ctx.provider.as_ref().and_then(|p| p.heartbeat(ctx).ok()).map_or_else(craft_fail, |_| ok_result())
}
#[no_mangle]
pub extern "C" fn craft_destroy(handle: *mut CraftContext) {
if !handle.is_null() {
unsafe { let ctx = &mut *handle; if let Some(ref mut p) = ctx.provider { p.close(); } drop(Box::from_raw(handle)); }
}
}
```
- [ ] **Step 2: 在 provider_selfhosted/mod.rs 追加 Provider trait 实现**
```rust
use crate::trait_provider::{Provider, ActivateResponse, HeartbeatResponse, LicenseStatus};
impl Provider for SelfHostedProvider {
fn initialize(&mut self, _ctx: &CraftContext, config_json: &str) -> Result<(), LicenseError> {
let cfg: serde_json::Value = serde_json::from_str(config_json).map_err(|_| LicenseError::InvalidFormat("config"))?;
let sh = cfg.get("selfhosted").ok_or(LicenseError::ConfigMissing("selfhosted"))?;
let base_url = sh.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").to_string();
let tenant_key = sh.get("tenantKey").and_then(|v| v.as_str()).unwrap_or("").to_string();
let ogd = sh.get("offlineGraceDays").and_then(|v| v.as_u64()).unwrap_or(7) as u32;
let hih = sh.get("heartbeatIntervalHours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
let pubkey = option_env!("CRAFTLABS_SELFHOSTED_PUBKEY").unwrap_or("").to_string();
self.initialize(base_url, tenant_key, ogd, hih, pubkey)
}
fn activate(&self, _ctx: &CraftContext, _lk: &str) -> Result<ActivateResponse, LicenseError> {
Err(LicenseError::Network("online activation not yet implemented".into()))
}
fn check_license(&self, _ctx: &CraftContext) -> Result<LicenseStatus, LicenseError> {
self.check_license_offline()?;
let c = self.cache.license.as_ref().ok_or(LicenseError::NoCachedLicense)?;
Ok(LicenseStatus { licensed: true, not_after: c.not_after, features: c.features.clone(), device_count: 0, max_devices: c.max_devices, heartbeat_due: None })
}
fn heartbeat(&self, _ctx: &CraftContext) -> Result<HeartbeatResponse, LicenseError> {
Err(LicenseError::Network("heartbeat not yet implemented".into()))
}
fn has_feature(&self, _ctx: &CraftContext, name: &str) -> bool { self.has_feature_offline(name) }
fn release(&mut self, _ctx: &CraftContext) -> Result<(), LicenseError> { Ok(()) }
fn get_license_info(&self, _ctx: &CraftContext) -> LicenseInfo { self.get_license_info_offline() }
fn close(&mut self) { let _ = self.persist_cache(); }
}
```
- [ ] **Step 3: `cargo check` + `cargo test` → `git commit -m "feat(rust): refactor C ABI to route through Provider trait"`**
---
## Task 1.9-1.10: Java 配置 + Schema 扩展
**Files:** Modify `SelfhostedConfigSection.java`, `FeatureMapping.java`, `schemas/craftlabs-auth-config.schema.json`, `examples/config/school.selfhosted.json`
- [ ] **Step 1: SelfhostedConfigSection 新增字段**
```java
public record SelfhostedConfigSection(
@JsonProperty("baseUrl") String baseUrl,
@JsonProperty("tenantKey") String tenantKey,
@JsonProperty("offlineGraceDays") Integer offlineGraceDays, // ★ 新增
@JsonProperty("heartbeatIntervalHours") Integer heartbeatIntervalHours, // ★ 新增
@JsonProperty("publicKeyPem") String publicKeyPem) { // ★ 新增
public SelfhostedConfigSection {
offlineGraceDays = offlineGraceDays != null ? offlineGraceDays : 7;
heartbeatIntervalHours = heartbeatIntervalHours != null ? heartbeatIntervalHours : 24;
}
}
```
- [ ] **Step 2: FeatureMapping 新增 selfhostedFeatureKey**
```java
public record FeatureMapping(
@JsonProperty("bitanswerFeatureId") Integer bitanswerFeatureId,
@JsonProperty("bitanswerFeatureName") String bitanswerFeatureName,
@JsonProperty("selfhostedFeatureKey") String selfhostedFeatureKey) {} // ★ 新增
```
- [ ] **Step 3: Schema 扩展 selfhosted properties**
```json
"offlineGraceDays": { "type": "integer", "minimum": 0, "maximum": 365, "default": 7 },
"heartbeatIntervalHours": { "type": "integer", "minimum": 1, "maximum": 720, "default": 24 },
"publicKeyPem": { "type": "string" }
```
同步扩展 `features.additionalProperties` 增加 `selfhostedFeatureKey`
- [ ] **Step 4: 更新 `examples/config/school.selfhosted.json`**
- [ ] **Step 5: `mvn compile -pl craftlabs-auth-core` + Schema 校验 → `git commit -m "feat: extend Java config and Schema for selfhosted SDK"`**
---
## Task 1.11: 平台数据库迁移(Flyway V2
**Files:** Create `services/delivery-platform-api/src/main/resources/db/migration/V2__selfhosted_licensing.sql`
核心 6 张表:`platform_license_policies`, `platform_license_keys`, `platform_licenses`, `platform_license_features`, `platform_license_activations`, `platform_license_heartbeats`。完整 DDL 见设计文档 §3.6。
- [ ] **Step: `docker compose up -d postgres` + `mvn flyway:migrate` → commit**
---
## Task 1.12: LicenseSigner.javaRSA 签名 + AES 加密签发)
**Files:** Create `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseSigner.java`
```java
@Service
public class LicenseSigner {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String EMBEDDED_SALT = "craftlabs-license-salt-v1-2026-05";
public String sign(SignedLicensePayload payload, PlatformLicenseKey key) throws Exception {
byte[] payloadJson = MAPPER.writeValueAsBytes(payload);
byte[] aesKey = deriveAesKey(EMBEDDED_SALT, payload.getLicenseId());
byte[] encrypted = aesGcmEncrypt(aesKey, payloadJson);
String payloadB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted);
PrivateKey privateKey = loadPrivateKey(key.getPrivateKey());
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(encrypted);
String sigB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(sig.sign());
LicenseDocument doc = LicenseDocument.builder()
.version(1).licenseId(payload.getLicenseId())
.payload(payloadB64)
.signature(SignatureBlock.builder().algorithm("RS256").keyId(key.getKeyId()).value(sigB64).build())
.build();
return MAPPER.writeValueAsString(doc);
}
private byte[] deriveAesKey(String salt, String licenseId) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt.getBytes("UTF-8"), "HmacSHA256"));
byte[] prk = mac.doFinal(new byte[0]);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(licenseId.getBytes("UTF-8"));
mac.update((byte) 0x01);
return mac.doFinal();
}
private byte[] aesGcmEncrypt(byte[] key, byte[] plaintext) throws Exception {
byte[] nonce = new byte[12]; new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
byte[] ct = cipher.doFinal(plaintext);
byte[] r = new byte[12 + ct.length]; System.arraycopy(nonce, 0, r, 0, 12); System.arraycopy(ct, 0, r, 12, ct.length);
return r;
}
}
```
- [ ] **Step: `mvn compile` → `git commit -m "feat(platform): add LicenseSigner for RSA+AES license issuance"`**
---
## Task 1.13-1.14: Entity/Mapper + LicenseService + LicenseController
**Files:** Create 5 组 Entity+Mapper`persistence/license/`+ LicenseService + LicenseController
核心 API
```
POST /api/v1/licenses → 签发许可证(需 LICENSE_OPS 角色)
GET /api/v1/licenses/{id} → 查询详情
POST /api/v1/licenses/{id}/revoke → 吊销
```
SecurityConfig 新增:`.requestMatchers("/api/v1/licenses/**").hasAnyRole("LICENSE_OPS", "ADMIN")`
- [ ] **Step: `mvn compile` + `mvn test -Dtest=LicenseControllerTest` → commit**
---
## Task 1.15: Phase 1 集成验证
- [ ] **Step 1: 生成 RSA 密钥对**
```bash
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
```
- [ ] **Step 2: `export CRAFTLABS_SELFHOSTED_PUBKEY="$(cat public_key.pem)"`**
- [ ] **Step 3: `cargo test --manifest-path native/craft-core/Cargo.toml` → all pass**
- [ ] **Step 4: 启动服务 + curl 签发 → Rust 离线验签通过**
```bash
curl -X POST http://localhost:8080/api/v1/licenses \
-H "Authorization: Bearer $TOKEN" -d '{"tenantId":"test",...}'
# 将返回的 license.json 保存 → Rust 集成测 validate
```
- [ ] **Step 5: Commit**
---
## Phase 2: 在线激活(3 个任务)
### Task 2.1: protocol.rsHTTP 请求/响应序列化)
**Files:** Create `native/craft-core/src/provider_selfhosted/protocol.rs`
Serialize/Deserialize DTOs`ActivateRequest`, `ActivateResponseBody`, `HeartbeatRequest/Response`, `CheckRequest/Response`, `ReleaseRequest/Response`。HMAC 签名工具函数 `build_hmac_signature`
新增依赖:`reqwest = "0.12"`, `hex = "0.4"`, `hmac = "0.12"`, `rand = "0.8"`, `tokio = "1"`
- [ ] **Step: `cargo check` → commit**
### Task 2.2: activate.rs(在线激活 HTTPS 请求)
**Files:** Create `native/craft-core/src/provider_selfhosted/activate.rs`
```rust
pub async fn online_activate(config: &SelfHostedConfig, fp: &DeviceFingerprint, license_key: &str)
-> Result<ActivateResponse, LicenseError>
{
let req = protocol::ActivateRequest { license_key: license_key.to_string(), device_fingerprint: /* fp */ };
let body = serde_json::to_string(&req).unwrap();
// POST /license/v1/activate + HMAC headers ...
match resp.status() {
200 => Ok(parse_body),
409 => Err(DeviceLimitReached),
403 => Err(LicenseRevoked),
}
}
```
`provider_selfhosted/mod.rs``activate()` 中调用 `tokio::runtime::Handle::current().block_on(online_activate(...))`
- [ ] **Step: `cargo test` → commit**
### Task 2.3: Webhook 侧 LicenseController + ActivateService
**Files:** Create license endpoints in `services/license-webhook-ingress`
- `NonceValidator.java`: Nonce 去重(ConcurrentHashMap + 5min 窗口)
- `LicenseActivateService.java`: 查许可证状态 + 终端配额 + 设备匹配 + 下发生效
- `LicenseController.java`: `POST /license/v1/activate`Bearer tenantKey + HMAC header 校验)
- [ ] **Step: `mvn compile` + `mvn test` → commit**
---
## Phase 3: 心跳 + 离线兜底(2 个任务)
### Task 3.1: heartbeat.rs(在线心跳 + 吊销检测)
**Files:** Create `native/craft-core/src/provider_selfhosted/heartbeat.rs`
```rust
pub async fn online_heartbeat(config: &SelfHostedConfig, device_hash: &str, license_key: &str)
-> Result<HeartbeatResponse, LicenseError>
{
// POST /license/v1/heartbeat →
// 200: 更新租约 + 可选下发新许可证
// 410: LicenseRevoked
}
```
更新 mod.rs heartbeat() 实现。
### Task 3.2: Webhook HeartbeatService + CheckService + ReleaseService
**Files:** Append to webhook `LicenseController`
- `POST /license/v1/heartbeat` — 更新 `last_heartbeat`,返回租约续期时间
- `POST /license/v1/check` — 在线校验许可证有效性
- `POST /license/v1/release` — 释放终端占用
---
## Phase 4: 完善与生产加固(5 个任务)
### Task 4.1: build.rs 嵌入 RSA 公钥
**Files:** Modify `native/craft-core/build.rs`, Create `native/craft-core/embedded/pubkey.pem`
```rust
// build.rs
fn main() {
let pubkey = fs::read_to_string("embedded/pubkey.pem").unwrap_or_default();
println!("cargo:rustc-env=CRAFTLABS_SELFHOSTED_PUBKEY={}", pubkey.trim());
println!("cargo:rerun-if-changed=embedded/pubkey.pem");
}
```
将生产公钥放入 `embedded/pubkey.pem`(不提交私钥)。
### Task 4.2: SelfHostedAuthProvider 加载正确库
**Files:** Modify `java/craftlabs-auth-selfhosted/.../SelfHostedAuthProvider.java`
`System.loadLibrary("craftlabs_auth_bitanswer")``System.loadLibrary("craftlabs_auth_core")`
### Task 4.3: MultiProviderSmokeTest
**Files:** Create `java/craftlabs-auth-tests/.../MultiProviderSmokeTest.java`
测试:初始化 selfhosted → 离线校验 → close → 初始化 bitanswer → close,双线不冲突。
### Task 4.4: CI 适配
- `ci-native.yml`: 更新 artifact name,确保 `embedded/pubkey.pem` 存在
- `ci-platform.yml`: 新增 license 模块测试 Job
### Task 4.5: 最终集成验证
```bash
cargo test --manifest-path native/craft-core/Cargo.toml # Rust 全量
mvn -f java/pom.xml verify # Java SDK
mvn -f services/pom.xml verify # 平台
docker compose -f services/docker-compose.yml up -d postgres # 起库
# curl 签发 → Rust 离线验签 → curl 在线激活 → 终端满 409 → 吊销 410
```
---
## 总结
| Phase | 任务数 | 核心交付 |
|-------|--------|----------|
| P0 | 1 | Cargo.toml 依赖 |
| P1 | 14 | Rust 离线核心 + Java 配置 + 平台签发后端 |
| P2 | 3 | 在线激活(Rust HTTPS + Webhook 端点) |
| P3 | 2 | 心跳 + 离线兜底 + 吊销检测 |
| P4 | 5 | build.rs 嵌入 + 测试 + CI |
| **合计** | **25** | |