chore: release v0.1.0
deploy / build-and-deploy (push) Waiting to run

This commit is contained in:
2026-06-09 16:17:48 +08:00
372 changed files with 42231 additions and 400 deletions
+20
View File
@@ -0,0 +1,20 @@
# CraftLabs 平台环境变量模板
# 复制为 .env 后修改实际值;.env 不提交 Git
# 数据库
DB_PASSWORD=change_me_in_production
# Redis 密码(空字符串表示无密码)
REDIS_PASSWORD=
# 比特安索 API Key
BIT_CLOUD_API_KEY=change_me
# Webhook 预期 Token(与比特控制台配置一致)
CRAFTLABS_WEBHOOK_EXPECTED_TOKEN=change_me
# JWT 签名密钥(≥32 字符)
PLATFORM_JWT_SECRET=change_me_at_least_32_characters_long
# Grafana 管理员密码
GRAFANA_ADMIN_PASSWORD=admin
+7
View File
@@ -0,0 +1,7 @@
{
"name": "安徽地质博物馆v2.0",
"fileId": "TdU1qb5xVYDLOssDOQxQqv",
"nodeId": "0-38499",
"url": "https://www.figma.com/design/TdU1qb5xVYDLOssDOQxQqv/",
"fetchedAt": "2026-05-18T00:00:00Z"
}
+56
View File
@@ -0,0 +1,56 @@
{
"file": {
"name": "安徽地质博物馆v2.0",
"fileId": "TdU1qb5xVYDLOssDOQxQqv",
"lastModified": "2026-05-18T14:45:48Z",
"frame": "数字资源系统-资源管理",
"frameSize": "1920x1080"
},
"colors": {
"pageBackground": "rgba(234,239,250,1.0)",
"cardBackground": "rgba(255,255,255,1.0)",
"textPrimary": "rgba(0,0,0,1.0)",
"textSecondary": "rgba(49,49,49,1.0)",
"textOnPrimary": "rgba(255,255,255,1.0)",
"badgeRed": "rgba(213,73,65,1.0)",
"decorativeBlue": "rgba(207,209,255,1.0)",
"decorativeTeal": "rgba(217,248,255,1.0)"
},
"typography": {
"body": { "fontSize": "14px", "color": "rgba(0,0,0,1.0)" },
"badge": { "fontSize": "12px", "color": "rgba(255,255,255,1.0)" },
"placeholder": { "fontSize": "14px", "color": "rgba(49,49,49,1.0)" }
},
"layout": {
"frameWidth": 1920,
"frameHeight": 1080,
"headerHeight": 60,
"sidebarWidth": 232,
"contentWidth": 1688,
"contentPaddingX": 20,
"treePanelWidth": 280,
"mainPanelWidth": 1368,
"breadcrumbHeight": 46
},
"components": {
"header": "headerMenu 顶部菜单导航",
"sidebar": "Menu - 侧边菜单",
"breadcrumb": "Breadcrumb 面包屑 (数字资源 > 资源管理)",
"tree": "Tree 树结构 - 资源分类树",
"search": "search 搜索框",
"menuItems": [
"item/menuLogo/baseLogo-light",
"item/normalMenu/1st-light (x11 菜单项)",
"Button"
],
"headerItems": [
"icon-search-w/text - 资源快速搜索",
"icon-internet",
"icon-view-module",
"icon-mail + Badge (红点通知 2)",
"logo-github",
"icon-user w/ TD Admin",
"icon-setting"
]
}
}
File diff suppressed because one or more lines are too long
+104
View File
@@ -0,0 +1,104 @@
# Gitea Actions: 平台部署流水线
# 触发条件:推送 main 分支 或 手动触发
# 运行环境:self-hosted runner(需要安装 docker + docker-compose
name: deploy
on:
push:
branches: [main]
paths:
- "services/**"
- "web/**"
- "services/docker-compose.yml"
workflow_dispatch:
env:
REGISTRY: gitea.craftlabs.cn/craftlabs
API_IMAGE: delivery-platform-api
WEBHOOK_IMAGE: license-webhook-ingress
UI_IMAGE: delivery-platform-ui
jobs:
build-and-deploy:
runs-on: ubuntu-latest # self-hosted runner 需注册该标签
steps:
- name: Checkout
uses: actions/checkout@v4
# ============ 后端 API ============
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: maven
- name: Build delivery-platform-api
run: |
mvn -f services/pom.xml -pl delivery-platform-api -am -DskipTests clean package -q
- name: Build API Docker image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:latest \
services/delivery-platform-api
# ============ Webhook ============
- name: Build license-webhook-ingress
run: |
mvn -f services/pom.xml -pl license-webhook-ingress -am -DskipTests clean package -q
- name: Build Webhook Docker image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.WEBHOOK_IMAGE }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.WEBHOOK_IMAGE }}:latest \
services/license-webhook-ingress
# ============ 前端 ============
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build frontend
working-directory: web/delivery-platform-ui
run: |
npm install
npm run build
- name: Build Frontend Docker image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.UI_IMAGE }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.UI_IMAGE }}:latest \
web/delivery-platform-ui
# ============ 推送镜像到 Gitea Registry ============
- name: Login to Gitea Container Registry
run: echo "${{ secrets.GITEA_REGISTRY_TOKEN }}" | docker login gitea.craftlabs.cn -u "${{ secrets.GITEA_REGISTRY_USER }}" --password-stdin
- name: Push images
run: |
docker push ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:latest
docker push ${{ env.REGISTRY }}/${{ env.WEBHOOK_IMAGE }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.WEBHOOK_IMAGE }}:latest
docker push ${{ env.REGISTRY }}/${{ env.UI_IMAGE }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.UI_IMAGE }}:latest
# ============ 远程部署 ============
- name: Deploy via docker-compose
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
PLATFORM_JWT_SECRET: ${{ secrets.PLATFORM_JWT_SECRET }}
CRAFTLABS_WEBHOOK_EXPECTED_TOKEN: ${{ secrets.WEBHOOK_TOKEN }}
run: |
# 将 docker-compose.yml 复制到部署目录并替换镜像版本
mkdir -p /opt/craftlabs/deploy
cp services/docker-compose.yml /opt/craftlabs/deploy/
cd /opt/craftlabs/deploy
export API_IMAGE_TAG=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ github.sha }}
export WEBHOOK_IMAGE_TAG=${{ env.REGISTRY }}/${{ env.WEBHOOK_IMAGE }}:${{ github.sha }}
export UI_IMAGE_TAG=${{ env.REGISTRY }}/${{ env.UI_IMAGE }}:${{ github.sha }}
docker compose pull
docker compose up -d --remove-orphans
+24
View File
@@ -0,0 +1,24 @@
version: 2
updates:
- package-ecosystem: maven
directory: "/services"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: maven
directory: "/java"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: npm
directory: "/web/delivery-platform-ui"
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
+2 -2
View File
@@ -2,9 +2,9 @@ name: ci-java
on:
push:
branches: [main, master]
branches: [main, master, develop]
pull_request:
branches: [main, master]
branches: [main, master, develop]
jobs:
maven:
+2 -2
View File
@@ -2,14 +2,14 @@ name: ci-platform
on:
push:
branches: [main, master]
branches: [main, master, develop]
paths:
- "services/**"
- "web/**"
- "contracts/**"
- ".github/workflows/ci-platform.yml"
pull_request:
branches: [main, master]
branches: [main, master, develop]
paths:
- "services/**"
- "web/**"
+53
View File
@@ -0,0 +1,53 @@
name: ci-security
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
workflow_dispatch:
jobs:
trivy-maven-modules:
name: Trivy (Java / Maven manifests)
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- scan-ref: services
- scan-ref: java
steps:
- uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: fs
scan-ref: ${{ matrix.scan-ref }}
scanners: vuln
vuln-type: os,library
severity: CRITICAL,HIGH
exit-code: "1"
ignore-unfixed: true
npm-audit-ui:
name: npm audit (delivery-platform-ui)
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: web/delivery-platform-ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/delivery-platform-ui/package-lock.json
- name: Install and audit
run: |
npm ci
npm audit --audit-level=high
+1
View File
@@ -46,3 +46,4 @@ __pycache__/
*.py[cod]
.venv/
venv/
.env
+79
View File
@@ -0,0 +1,79 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-05-26
**Commit:** 4913d1c
**Branch:** develop
## OVERVIEW
**craftlabs-authorization-sdk** — 创飞客户端授权 SDK 工作区。多语言 monorepoJava (Maven) 封装授权 API + Rust (Cargo) native cdylib + Vue 3 交付管理后台 + Spring Boot 后端服务。37k+ 行源码,活跃开发中。
## STRUCTURE
```
./
├── java/ # Maven 多模块 SDK (core, bitanswer, selfhosted, tests)
├── native/ # Rust Cargo workspace (craft-core cdylib, CLI tool)
├── services/ # Spring Boot 后端服务
│ ├── delivery-platform-api/ # 商业交付管理 API (153 Java 文件)
│ └── license-webhook-ingress/ # Webhook 回调入口 (小)
├── web/
│ └── delivery-platform-ui/ # Vue 3 前端 (47 src 文件)
├── schemas/ # craftlabs-auth-config JSON Schema
├── examples/ # 示例配置 (java/cpp/python/vc)
├── docs/ # 产品/流程/工程架构文档
│ └── engineering/ # 系统架构、工程边界、并行迭代
└── engineering/ # 工作区 manifest, 规划工程占位
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| SDK 授权核心逻辑 (Java) | `java/craftlabs-auth-core/src/` | config, internal 模块 |
| 比特安索集成 | `java/craftlabs-auth-bitanswer/` | 单一 Java 文件 |
| 自托管授权提供者 | `java/craftlabs-auth-selfhosted/` | 同上 |
| Rust native C ABI | `native/craft-core/src/` | lib.rs 导出 craft_* 函数 |
| 安全反调试/混淆 | `native/craft-core/src/security/` | anti_debug, obfuscation |
| CLI 工具 | `native/craftlabs-auth-cli/src/` | status/activate/check/info 命令 |
| 平台后端 Controller | `services/delivery-platform-api/` | 按领域分包 (contract, license, device 等) |
| 平台持久层 | `services/delivery-platform-api/` | persistence/ 下每实体一对 (POJO+Mapper) |
| 平台 DTO | `services/delivery-platform-api/` | web/dto/ 下 47 个请求/响应类 |
| Webhook 回调 | `services/license-webhook-ingress/` | webhook 入口 + persistence |
| 前端视图 | `web/delivery-platform-ui/src/views/` | Vue 3 组件 (38 文件) |
| 数据库迁移 | `services/delivery-platform-api/` | src/main/resources/db/migration/ |
| JSON Schema | `schemas/` | craftlabs-auth-config 校验 |
| CI/CD (Gitea Actions) | `GITEA_CI_CD.md` | act_runner 配置 |
## CONVENTIONS
- **Java**: Spring Boot 3.x, MyBatis-Plus, Maven multi-module. 每实体一对 `Entity` + `Mapper` 接口。控制器统一 `@RestController` + `@RequestMapping("/api/v1/...")`. 异常处理统一 `ApiExceptionHandler`.
- **Rust**: cdylib 导出 `craft_*` C ABI。`Provider` trait 模式。安全模块独立 `security/` 子树。
- **Vue**: Vue 3 + Composition API (`<script setup>`). 视图集中 `src/views/`.
- **SQL**: Flyway 迁移在 `db/migration/`, 前缀 `V{version}__{description}.sql`.
- **发布**: SDK 发版生成 SHA256SUMS + 可选 GPG 签名, 见 `java/RELEASING.md` + `scripts/sdk-release-checksums.sh`.
## ANTI-PATTERNS (THIS PROJECT)
- **不要** 把 SDK jar 打进平台 Fat JAR (bootstrap 唯一 repackage)
- **不要** 在客户端 SDK 中依赖平台后端代码 (运行时解耦)
- **不要** 混用 JNI 和 JNA 桥接 — 当前已迁移到 JNA
## COMMANDS
```bash
# Java SDK 构建 (JDK 17+)
mvn -f java/pom.xml clean verify
# Rust native 构建 (Rust 1.70+)
cargo build --manifest-path native/craft-core/Cargo.toml --release
# 前端
cd web/delivery-platform-ui && npm install && npm run build
```
## NOTES
- 平台后端/前端按架构设计为独立工程,本仓库含源码仅为过渡期方便迭代。正式部署时按 `engineering/planned/` 规范开独立仓。
- native/craft-core 曾用 JNI bridge,现已迁移至 JNA (commit 027ecbd)。
- 有三轨并行迭代:后端/前端/SDK,详见 `docs/engineering/PARALLEL_ITERATION_INDEX.md`
+118
View File
@@ -0,0 +1,118 @@
# Gitea CI/CD 配置指南
## 1. Gitea Actions Runner 注册
### 1.1 部署 Runner
```bash
# 从 Gitea 管理后台获取 runner 注册令牌
# 位置:站点管理 -> 运行 Actions -> 创建 Runner
# 创建 runner 数据目录
mkdir -p /opt/gitea-runner
cd /opt/gitea-runner
# 下载 act runner
curl -sL https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64 -o act_runner
chmod +x act_runner
# 注册 runner(替换 TOKEN 和 GITEA_URL
./act_runner register \
--instance https://gitea.craftlabs.cn \
--token <REGISTRATION_TOKEN> \
--name craftlabs-runner \
--labels ubuntu-latest:docker://node:20-bookworm
# 以服务方式运行
./act_runner daemon &
```
### 1.2 Runner 标签说明
| 标签 | 用途 | 对应的 workflow `runs-on` |
|------|------|--------------------------|
| `ubuntu-latest` | 通用构建和测试 | `ubuntu-latest` |
## 2. 配置 Gitea Secrets
在 Gitea 仓库 Settings -> Secrets 中添加:
| Secret 名称 | 说明 |
|-------------|------|
| `GITEA_REGISTRY_TOKEN` | Gitea Container Registry 访问令牌 |
| `GITEA_REGISTRY_USER` | Registry 用户名 |
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
| `PLATFORM_JWT_SECRET` | JWT 签名密钥(至少 32 字符)|
| `WEBHOOK_TOKEN` | Webhook x-bitanswer-token |
## 3. 推送仓库到 Gitea
```bash
# 添加 Gitea 远程仓库
git remote add gitea https://gitea.craftlabs.cn/craftlabs/authorization-sdk.git
# 推送到 Gitea
git push -u gitea develop
# 推送到 Gitea 并设为主分支
git push gitea develop:main
```
## 4. CI 流程说明
### 4.1 提交触发
| Workflow | 触发条件 | 运行内容 |
|----------|---------|---------|
| `ci-java` | push/PR to main/develop | Maven verify + Native 编译 |
| `ci-platform` | push/PR to main/develop (services/web) | Maven verify + npm build |
| `ci-security` | push/PR to main/develop | Trivy 漏洞扫描 + npm audit |
| `deploy` | push to main | 构建 Docker 镜像 → Gitea Registry → docker-compose 部署 |
### 4.2 手动触发
| Workflow | 触发方式 |
|----------|---------|
| `sdk-release-checksums` | 仓库 Actions 页面手动触发 |
| `deploy` | 仓库 Actions 页面手动触发 |
## 5. 部署架构
```text
┌─────────────────────────────────┐
│ Gitea 仓库(craftsupport.cn
│ push main → Gitea Actions │
└──────────┬──────────────────────┘
│ 触发
┌──────────▼──────────────────────┐
│ Self-Hosted Runner │
│ ├── mvn package → Docker build │
│ ├── npm build → Docker build │
│ └── docker compose up -d │
└──────────┬──────────────────────┘
│ 部署
┌──────────▼──────────────────────┐
│ 部署主机(生产环境) │
│ ├── PostgreSQL 15 │
│ ├── delivery-platform-api:8080 │
│ ├── license-webhook-ingress:8081│
│ └── delivery-platform-ui:80 │
└─────────────────────────────────┘
```
## 6. 环境变量要求
部署时需确保以下环境变量已设置:
```bash
# 数据库
SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/craftlabs_platform
SPRING_DATASOURCE_USERNAME=craftlabs
SPRING_DATASOURCE_PASSWORD=<实际密码>
# JWT
PLATFORM_JWT_SECRET=<至少32字符随机密钥>
# Webhook
CRAFTLABS_WEBHOOK_EXPECTED_TOKEN=<与比特控制台一致>
```
+4 -4
View File
@@ -9,7 +9,7 @@
| 目录 | 说明 |
|------|------|
| [`java/`](java/) | Maven 多模块:`craftlabs-auth-core``craftlabs-auth-bitanswer``craftlabs-auth-selfhosted``craftlabs-auth-tests` |
| [`native/`](native/) | CMake`libcraftlabs_auth_bitanswer`JNI + 比特/自研适配占位) |
| [`native/`](native/) | **Rust Cargo workspace**`craft-core` cdylib 导出 `craft_*` C ABI(对齐 `docs/平台架构思路.md`);旧 C++ CMake 见 `.deprecated-cmake/` |
| [`schemas/`](schemas/) | `craftlabs-auth-config` JSON Schema |
| [`examples/`](examples/) | 示例配置与烟测脚本 |
| [`docs/`](docs/) | 比特对接、创飞平台产品/流程/工程架构文档 |
@@ -21,9 +21,9 @@
# Java(需 JDK 17+
mvn -f java/pom.xml verify
# Native(需 CMake、C++、可选 JDK 用于 JNI
cmake -S native -B native/build -DCRAFTLABS_BUILD_JNI=ON
cmake --build native/build
# Rust 核心库(需 Rust 1.70+
cargo build --manifest-path native/craft-core/Cargo.toml --release
# 产物:native/target/release/libcraftlabs_auth_bitanswer.{so,dylib,dll}
```
## 发布与完整性(SHA-256 / GPG
File diff suppressed because it is too large Load Diff
+276 -144
View File
@@ -3,7 +3,8 @@
> **平台全称**:广州创飞人工智能技术有限公司客户商务与交付管理平台。
> **文档性质**:产品经理视角的 **模块划分** 与 **功能点清单**,用于需求评审、版本切片与验收对齐。
> **关联文档**:[平台与比特对接总览](chuangfei-bitanswer-integration-platform.md)(定位、架构、分阶段路线) · [**业务流程与版本排期**](chuangfei-platform-bpm-and-roadmap.md)BPM、迭代计划) · [工作区工程划分](engineering/WORKSPACE_ENGINEERING_LAYOUT.md)。
> **优先级约定****P0** = MVP 必含;**P1** = 增强运营效率;**P2** = 治理与规模化。同一功能可在多期迭代交付,表中标注为「首期目标优先级」。
> **优先级约定****P0** = MVP 必含;**P1** = 增强运营效率;**P2** = 治理与规模化。同一功能可在多期迭代交付,表中标注为「首期目标优先级」。
> **实现状态约定**:**✅** = 已实现(I1~I9 迭代交付);**◐** = 部分实现(缺字段或功能不完整);**○** = 未实现(规划中);**—** = 不适用(依赖前置模块)。状态反映截至 2026-05-25 的实现情况。
---
@@ -67,17 +68,17 @@ flowchart TB
**定位**:统一客户与项目上下文,避免合同、交付、SN 挂在「无名客户」或重复档案上。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ------------- | -------------------------------------------- | --- |
| M1-F01 | 客户档案创建/编辑 | 客户名称、统一社会信用代码/客户编码、行业、地址、开票信息等(字段以法务/财务为准裁剪) | P0 |
| M1-F02 | 客户列表与检索 | 多条件筛选、分页、关键字搜索 | P0 |
| M1-F03 | 客户详情聚合视图 | 关联项目数、在履约合同数、在途 SN 数等摘要(只读统计) | P0 |
| M1-F04 | 项目创建/编辑 | 项目名称、所属客户、阶段、计划起止、项目经理 | P0 |
| M1-F05 | 项目列表与筛选 | 按客户、阶段、时间筛选 | P0 |
| M1-F06 | 项目干系人 | 客户侧联系人、内部负责人、角色标签 | P0 |
| M1-F07 | 客户/项目冻结与解冻 | 禁止新业务挂载或仅允许只读(规则可配置) | P1 |
| M1-F08 | 客户合并与去重 | 疑似重复客户识别、合并流程与审计 | P2 |
| M1-F09 | 与外部 CRM 主数据同步 | 以外部 ID 关联、增量同步状态展示(不替代 CRM 全能力) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ------------- | -------------------------------------------- | --- | --- |
| M1-F01 | 客户档案创建/编辑 | 客户名称、统一社会信用代码/客户编码、行业、地址、开票信息等(字段以法务/财务为准裁剪) | P0 | ◐ 部分实现 — 仅 name + credit_code,缺行业/地址/开票信息 |
| M1-F02 | 客户列表与检索 | 多条件筛选、分页、关键字搜索 | P0 | ✅ |
| M1-F03 | 客户详情聚合视图 | 关联项目数、在履约合同数、在途 SN 数等摘要(只读统计) | P0 | ✅ — 后端 `/summary` + 前端详情页摘要卡片已实现 |
| M1-F04 | 项目创建/编辑 | 项目名称、所属客户、阶段、计划起止、项目经理 | P0 | ◐ 部分实现 — 仅 name + customer_id + phase,缺计划起止、项目经理 |
| M1-F05 | 项目列表与筛选 | 按客户、阶段、时间筛选 | P0 | ✅ |
| M1-F06 | 项目干系人 | 客户侧联系人、内部负责人、角色标签 | P0 | ◐ — 后端 CRUD 已实现,前端 UI 待补 |
| M1-F07 | 客户/项目冻结与解冻 | 禁止新业务挂载或仅允许只读(规则可配置) | P1 | ◐ — 后端 PATCH 端点已实现,前端 UI 待补 |
| M1-F08 | 客户合并与去重 | 疑似重复客户识别、合并流程与审计 | P2 | ○ |
| M1-F09 | 与外部 CRM 主数据同步 | 以外部 ID 关联、增量同步状态展示(不替代 CRM 全能力) | P2 | ○ |
---
@@ -87,17 +88,17 @@ flowchart TB
**定位**:合同是「卖什么」的权威来源之一;履约行/合同行是 SN 与交付的锚点。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | --------------- | ------------------------------- | --- |
| M2-F01 | 合同登记与编辑 | 合同编号、客户、关联项目、签订日、生效日、终止条件 | P0 |
| M2-F02 | 合同状态机 | 草稿、待生效、生效、变更中、终止/到期等;非法跳转拦截 | P0 |
| M2-F03 | 合同标的摘要 | 产品/模块/数量/期限/席位等业务口径展示(可与行项汇总一致) | P0 |
| M2-F04 | 合同行项(履约行) | 多行:SKU 或产品包、数量、单价(可选)、交付与授权口径 | P0 |
| M2-F05 | 合同附件 | 上传扫描件/电子签输出(存储与权限受控) | P1 |
| M2-F06 | 合同与订单关联 | 外部订单号、内部订单记录 ID(若存在订单系统) | P1 |
| M2-F07 | 合同变更与版本 | 变更单、版本号、影响授权差异提示(与 M4 联动) | P1 |
| M2-F08 | 合同行与授权 SKU 规则映射 | 行项默认映射到许可 SKU/特征包(与 M6 联动) | P1 |
| M2-F09 | 合同到期与续费提醒 | 基于生效/结束日期的列表与订阅(与 M8 联动) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | --------------- | ------------------------------- | --- | --- |
| M2-F01 | 合同登记与编辑 | 合同编号、客户、关联项目、签订日、生效日、终止条件 | P0 | ✅ |
| M2-F02 | 合同状态机 | 草稿、待生效、生效、变更中、终止/到期等;非法跳转拦截 | P0 | ✅ 状态机含 DRAFT→PENDING_EFFECTIVE→EFFECTIVE→CHANGING→TERMINATED |
| M2-F03 | 合同标的摘要 | 产品/模块/数量/期限/席位等业务口径展示(可与行项汇总一致) | P0 | ✅ |
| M2-F04 | 合同行项(履约行) | 多行:SKU 或产品包、数量、单价(可选)、交付与授权口径 | P0 | ✅ |
| M2-F05 | 合同附件 | 上传扫描件/电子签输出(存储与权限受控) | P1 | ◐ — 后端 POST 端点已实现,前端上传 UI 待补 |
| M2-F06 | 合同与订单关联 | 外部订单号、内部订单记录 ID(若存在订单系统) | P1 | ○ |
| M2-F07 | 合同变更与版本 | 变更单、版本号、影响授权差异提示(与 M4 联动) | P1 | ◐ — 后端 changes/complete 端点已实现,前端 UI 待补 |
| M2-F08 | 合同行与授权 SKU 规则映射 | 行项默认映射到许可 SKU/特征包(与 M6 联动) | P1 | ○ |
| M2-F09 | 合同到期与续费提醒 | 基于生效/结束日期的列表与订阅(与 M8 联动) | P2 | ○ |
---
@@ -107,16 +108,16 @@ flowchart TB
**定位**:记录「交了什么、何时可激活」,是 License Ops 发放 SN 的前置依据之一。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ----------- | ------------------------------- | --- |
| M3-F01 | 交付批次创建 | 关联项目/合同,批次号、计划交付日 | P0 |
| M3-F02 | 交付清单 | 交付物条目:产品实例、数量、环境说明、备注 | P0 |
| M3-F03 | 交付与合同行关联 | 每条交付行可关联合同行项,支撑对账 | P0 |
| M3-F04 | 交付状态 | 未交付、已交付、部分交付;关键状态变更留痕 | P0 |
| M3-F05 | 交付完成确认 | 责任人、完成时间、可选客户签收记录 | P0 |
| M3-F06 | 现场环境信息 | 部署地址、网络要求、联系人(敏感字段权限控制) | P1 |
| M3-F07 | 交付与 SN 发放门禁 | 规则:仅「已交付」合同范围可生成/绑定 SN(可配置为强/弱) | P1 |
| M3-F08 | 交付模板 | 按产品线预置交付清单模板 | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ----------- | ------------------------------- | --- | --- |
| M3-F01 | 交付批次创建 | 关联项目/合同,批次号、计划交付日 | P0 | ✅ |
| M3-F02 | 交付清单 | 交付物条目:产品实例、数量、环境说明、备注 | P0 | ✅ |
| M3-F03 | 交付与合同行关联 | 每条交付行可关联合同行项,支撑对账 | P0 | ✅ |
| M3-F04 | 交付状态 | 未交付、已交付、部分交付;关键状态变更留痕 | P0 | ✅ 状态含 PENDING→DELIVERED→CANCELLED |
| M3-F05 | 交付完成确认 | 责任人、完成时间、可选客户签收记录 | P0 | ✅ |
| M3-F06 | 现场环境信息 | 部署地址、网络要求、联系人(敏感字段权限控制) | P1 | ○ |
| M3-F07 | 交付与 SN 发放门禁 | 规则:仅「已交付」合同范围可生成/绑定 SN(可配置为强/弱) | P1 | ◐ — 后端 deliveryGateEnabled 参数+闸门检查已实现,前端 SystemParamsView 已对接后端 API |
| M3-F08 | 交付模板 | 按产品线预置交付清单模板 | P2 | ○ |
---
@@ -126,19 +127,19 @@ flowchart TB
**定位**:SN 与激活事实的台账中心;不替代比特控制台,但与比特状态 **摘要对齐、可追溯**
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | -------------- | ---------------------------- | --- |
| M4-F01 | SN 手工录入/导入 | 单条新增、批量导入(模板校验、重复提示) | P0 |
| M4-F02 | SN 与合同/项目/客户绑定 | 必选关联路径之一(合同行或项目),禁止裸 SN 或仅警告 | P0 |
| M4-F03 | SN 生命周期状态 | 未发放、已发放、已激活、已冻结、已回收、异常等 | P0 |
| M4-F04 | SN 详情页 | 展示绑定关系、发放记录、激活时间、最近事件摘要 | P0 |
| M4-F05 | 激活结果回写 | 人工录入或接口同步:成功/失败及原因码分类 | P0 |
| M4-F06 | 比特控制台状态摘要 | 同一 SN 的关键字段摘要或控制台链接(只读,权限控制) | P0 |
| M4-F07 | 批量 SN 操作 | 批量绑定、批量状态变更(审批可选) | P1 |
| M4-F08 | 授权需求单 | 由合同/交付生成的「待发放 SN」清单,供 Ops 执行 | P1 |
| M4-F09 | 试用/正式/续期标签 | 与业务口径一致的标签,便于筛选与报表 | P1 |
| M4-F10 | SN 与设备关联视图 | 展示绑定 `mid` 列表与历史(依赖 M7) | P1 |
| M4-F11 | 授权策略生效视图 | 展示当前映射版本、环境(与 M6 联动) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | -------------- | ---------------------------- | --- | --- |
| M4-F01 | SN 手工录入/导入 | 单条新增、批量导入(模板校验、重复提示) | P0 | ◐ 手工录入已实现,批量导入 UI 待补(后端 POST /batch-import 已就绪) |
| M4-F02 | SN 与合同/项目/客户绑定 | 必选关联路径之一(合同行或项目),禁止裸 SN 或仅警告 | P0 | ✅ |
| M4-F03 | SN 生命周期状态 | 未发放、已发放、已激活、已冻结、已回收、异常等 | P0 | ✅ 状态含 REGISTERED→ISSUED→ACTIVATED→SUSPENDED→REVOKED |
| M4-F04 | SN 详情页 | 展示绑定关系、发放记录、激活时间、最近事件摘要 | P0 | ✅ |
| M4-F05 | 激活结果回写 | 人工录入或接口同步:成功/失败及原因码分类 | P0 | ◐ 支持手工状态更新,缺原因码分类 |
| M4-F06 | 比特控制台状态摘要 | 同一 SN 的关键字段摘要或控制台链接(只读,权限控制) | **P1**(原 P0,因依赖比特控制台对接未完成降级) | ○ |
| M4-F07 | 批量 SN 操作 | 批量绑定、批量状态变更(审批可选) | P1 | ◐ — 后端 batch-import 已实现,前端批量操作 UI 待补 |
| M4-F08 | 授权需求单 | 由合同/交付生成的「待发放 SN」清单,供 Ops 执行 | P1 | ○ |
| M4-F09 | 试用/正式/续期标签 | 与业务口径一致的标签,便于筛选与报表 | P1 | ○ |
| M4-F10 | SN 与设备关联视图 | 展示绑定 `mid` 列表与历史(依赖 M7) | P1 | — 依赖 M7 |
| M4-F11 | 授权策略生效视图 | 展示当前映射版本、环境(与 M6 联动) | P2 | ○ |
---
@@ -148,18 +149,18 @@ flowchart TB
**定位**:承接比特规则 **HTTPS Callback**,保证 **不断链、可关联、可处置**
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | --- |
| M5-F01 | 事件收件箱列表 | 按时间、事件类型、`sn`、处理状态筛选 | P0 |
| M5-F02 | 事件详情 | 展示解析后字段 + 脱敏后的原始 payload;关联 SN/合同 | P0 |
| M5-F03 | 处理状态 | 待处理、已处理、失败、忽略;处理人与时间 | P0 |
| M5-F04 | 关联解析失败兜底 | 无法关联主数据时保留事件并支持人工挂接 | P0 |
| M5-F05 | 事件类型字典 | `sn:pre_activate``sn:post_activate``device:pre_activate``device:post_activate``yunbaobao:session_logout` 等展示名与说明 | P0 |
| M5-F06 | 失败原因标注 | Ops 可选分类,便于报表 | P1 |
| M5-F07 | 批量重处理/重试入口 | 在业务允许范围内触发补偿(与后端幂等策略一致) | P1 |
| M5-F08 | 死信与积压监控视图 | 队列深度、最久未处理 TOP(与可观测联动) | P1 |
| M5-F09 | 事件驱动待办 | 自动生成待办卡片(与 M8 联动) | P1 |
| M5-F10 | 模拟投递(仅测试环境) | 联调验收工具 | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | --- | --- |
| M5-F01 | 事件收件箱列表 | 按时间、事件类型、`sn`、处理状态筛选 | P0 | ✅ 支持多维度筛选 |
| M5-F02 | 事件详情 | 展示解析后字段 + 脱敏后的原始 payload;关联 SN/合同 | P0 | ✅ 含 payload 脱敏预览 |
| M5-F03 | 处理状态 | 待处理、已处理、失败、忽略;处理人与时间 | P0 | ✅ 状态含 PENDING→PROCESSED/FAILED/IGNORED |
| M5-F04 | 关联解析失败兜底 | 无法关联主数据时保留事件并支持人工挂接 | P0 | ✅ 支持人工挂接 SN/项目/合同 |
| M5-F05 | 事件类型字典 | `sn:pre_activate``sn:post_activate``device:pre_activate``device:post_activate``yunbaobao:session_logout` 等展示名与说明 | P0 | ✅ |
| M5-F06 | 失败原因标注 | Ops 可选分类,便于报表 | P1 | ✅ — 前端失败原因下拉 + 后端 DTO 已实现 |
| M5-F07 | 批量重处理/重试入口 | 在业务允许范围内触发补偿(与后端幂等策略一致) | P1 | ✅ 单条 + 批量重试均已实现(I8 单条 + I10 批量端点) |
| M5-F08 | 死信与积压监控视图 | 队列深度、最久未处理 TOP(与可观测联动) | P1 | ◐ — 后端 GET /stats/backlog + 前端积压统计卡片已实现 |
| M5-F09 | 事件驱动待办 | 自动生成待办卡片(与 M8 联动) | P1 | — 依赖 M8 |
| M5-F10 | 模拟投递(仅测试环境) | 联调验收工具 | P2 | ◐ — 后端 POST /simulate 已实现,前端 UI 待补 |
---
@@ -169,17 +170,17 @@ flowchart TB
**定位**:把「产品线—比特产品/模版/业务/特征 ID—环境 URL」管起来,支撑客户端 JSON 与联调。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ----------------- | -------------------------------------------------------------- | --- |
| M6-F01 | 产品线定义 | 产品线编码、名称、说明 | P0 |
| M6-F02 | 环境维度 | 开发/测试/预发/生产及对应 `bitanswer.url` 登记 | P0 |
| M6-F03 | 比特侧 ID 映射 | 产品、模版、业务 ID 与产品线+环境绑定(与控制台一致) | P1 |
| M6-F04 | 逻辑功能键 ↔ 特征项映射 | 对齐 `craftlabs-auth-config``features.*.bitanswerFeatureId` 等 | P1 |
| M6-F05 | 授权 JSON 模板管理 | 模板版本、变更说明、与 Schema 校验结果(可接 CI) | P1 |
| M6-F06 | 配置发布记录 | 谁、何时、发布了哪一版到哪一环境 | P1 |
| M6-F07 | 控制台链接与说明 | 规则 Callback URL、token 轮换登记(非密钥明文展示) | P1 |
| M6-F08 | SDK / native 版本矩阵 | 与现场客户端兼容范围说明 | P2 |
| M6-F09 | 变更影响分析 | 映射变更影响哪些在服 SN/合同(只读分析) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ----------------- | -------------------------------------------------------------- | --- | --- |
| M6-F01 | 产品线定义 | 产品线编码、名称、说明 | P0 | ✅ |
| M6-F02 | 环境维度 | 开发/测试/预发/生产及对应 `bitanswer.url` 登记 | P0 | ✅ 含 seed 数据(dev/prod |
| M6-F03 | 比特侧 ID 映射 | 产品、模版、业务 ID 与产品线+环境绑定(与控制台一致) | P1 | ✅ — 前后端均已实现(IntegrationIdMappingView |
| M6-F04 | 逻辑功能键 ↔ 特征项映射 | 对齐 `craftlabs-auth-config``features.*.bitanswerFeatureId` 等 | P1 | ✅ — 前后端均已实现(IntegrationFeatureMappingView |
| M6-F05 | 授权 JSON 模板管理 | 模板版本、变更说明、与 Schema 校验结果(可接 CI) | P1 | ◐ — 前后端 CRUD 已实现(IntegrationJsonTemplateView),Schema 校验未关联 UI |
| M6-F06 | 配置发布记录 | 谁、何时、发布了哪一版到哪一环境 | P1 | ○ |
| M6-F07 | 控制台链接与说明 | 规则 Callback URL、token 轮换登记(非密钥明文展示) | P1 | ○ |
| M6-F08 | SDK / native 版本矩阵 | 与现场客户端兼容范围说明 | P2 | ○ |
| M6-F09 | 变更影响分析 | 映射变更影响哪些在服 SN/合同(只读分析) | P2 | ○ |
---
@@ -189,14 +190,14 @@ flowchart TB
**定位**:支撑浮动、换机、终端限制类场景,与比特 `device:`* 事件及 `mid` 对齐。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ----------------- | ----------------------- | --- |
| M7-F01 | 设备登记 | `mid`、别名、场站、关联客户/项目 | P1 |
| M7-F02 | 设备与 SN 绑定历史 | 时间线:首次激活、换机、解绑 | P1 |
| M7-F03 | 换机申请与处理记录 | 轻量审批可选;处理结果与备注 | P1 |
| M7-F04 | 设备列表与检索 | 按 SN、客户、场站筛选 | P1 |
| M7-F05 | 与 Callback 设备事件联动 | 从事件跳转设备详情 | P1 |
| M7-F06 | 终端数/并发策略展示 | 只读展示合同或比特策略摘要(不重复造规则引擎) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ----------------- | ----------------------- | --- | --- |
| M7-F01 | 设备登记 | `mid`、别名、场站、关联客户/项目 | P1 | ◐ — 登记/列表已实现,字段覆盖待确认 |
| M7-F02 | 设备与 SN 绑定历史 | 时间线:首次激活、换机、解绑 | P1 | ◐ — 绑定时间线已实现,完整性待确认 |
| M7-F03 | 换机申请与处理记录 | 轻量审批可选;处理结果与备注 | P1 | ◐ — 后端 swap-request 端点已实现,审批流待补 |
| M7-F04 | 设备列表与检索 | 按 SN、客户、场站筛选 | P1 | ✅ |
| M7-F05 | 与 Callback 设备事件联动 | 从事件跳转设备详情 | P1 | ○ |
| M7-F06 | 终端数/并发策略展示 | 只读展示合同或比特策略摘要(不重复造规则引擎) | P2 | ○ |
---
@@ -206,13 +207,13 @@ flowchart TB
**定位**:把「该谁处理」说清楚,降低 Callback 与 SN 异常堆积。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ------------ | ------------------------------- | --- |
| M8-F01 | 站内待办列表 | 按角色过滤:待处理 Callback、待发放 SN、待核对激活 | P1 |
| M8-F02 | 待办认领与完成 | 状态流转与备注 | P1 |
| M8-F03 | 邮件/企业微信等一种通道 | 关键事件必达一种(可配置订阅) | P1 |
| M8-F04 | 通知模板 | 事件类型 → 模板变量 | P2 |
| M8-F05 | 静默规则 | 重复事件聚合、防骚扰 | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ------------ | ------------------------------- | --- | --- |
| M8-F01 | 站内待办列表 | 按角色过滤:待处理 Callback、待发放 SN、待核对激活 | P1 | ◐ — 待办中心已上线,自动化待办生成待接入 |
| M8-F02 | 待办认领与完成 | 状态流转与备注 | P1 | ◐ — 状态流转已实现,备注功能待补 |
| M8-F03 | 邮件/企业微信等一种通道 | 关键事件必达一种(可配置订阅) | P1 | ◐ — 通知通道配置 UI 已上线,实际发送逻辑未接入 |
| M8-F04 | 通知模板 | 事件类型 → 模板变量 | P2 | ○ |
| M8-F05 | 静默规则 | 重复事件聚合、防骚扰 | P2 | ○ |
---
@@ -222,14 +223,14 @@ flowchart TB
**定位**:给管理层与 Ops **履约 vs 授权** 的一致性视图。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------ | ---------------- | ------------------- | --- |
| M9-F01 | 合同标的 vs 已发 SN 视图 | 按合同/行项汇总应发、实发 | P1 |
| M9-F02 | 已发 vs 已激活视图 | 未激活占比、超期未激活列表 | P1 |
| M9-F03 | Callback 统计 | 按类型、状态、时间段的成功率与耗时分布 | P1 |
| M9-F04 | 导出 CSV/Excel | 权限与脱敏策略受控 | P1 |
| M9-F05 | 项目健康度看板 | 多项目并行时的红黄绿(规则可配置) | P2 |
| M9-F06 | 订阅报表 | 定期邮件推送 | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------ | ---------------- | ------------------- | --- | --- |
| M9-F01 | 合同标的 vs 已发 SN 视图 | 按合同/行项汇总应发、实发 | P1 | ◐ — ContractSnReportView 已上线,数据维度待确认 |
| M9-F02 | 已发 vs 已激活视图 | 未激活占比、超期未激活列表 | P1 | ○ |
| M9-F03 | Callback 统计 | 按类型、状态、时间段的成功率与耗时分布 | P1 | ◐ — CallbackStatsView 已上线 |
| M9-F04 | 导出 CSV/Excel | 权限与脱敏策略受控 | P1 | ◐ — 后端 GET /reports/export 已存在,前端导出按钮待补 |
| M9-F05 | 项目健康度看板 | 多项目并行时的红黄绿(规则可配置) | P2 | ◐ — ProjectHealthView 已上线,红黄绿规则可配置性待确认 |
| M9-F06 | 订阅报表 | 定期邮件推送 | P2 | ◐ — SubscriptionReportView 已上线,后端推送逻辑待确认 |
---
@@ -239,12 +240,12 @@ flowchart TB
**定位**:满足内审与客户抽样举证,**关键操作不可抵赖**。
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------- | -------- | --------------------------- | --- |
| M10-F01 | 关键字段变更日志 | 客户、合同、SN 绑定、状态变更:旧值/新值/人/时间 | P0 |
| M10-F02 | 审计检索 | 按对象 ID、用户、时间范围查询 | P1 |
| M10-F03 | 导出审计包 | 范围可选(项目/合同/时间窗),水印与权限 | P2 |
| M10-F04 | 留存策略配置 | 与法务对齐的保留周期说明(技术实现另见架构) | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------- | -------- | --------------------------- | --- | --- |
| M10-F01 | 关键字段变更日志 | 客户、合同、SN 绑定、状态变更:旧值/新值/人/时间 | P0 | ✅ |
| M10-F02 | 审计检索 | 按对象 ID、用户、时间范围查询 | P1 | ◐ — AuditSearchView 已上线,筛选维度待确认 |
| M10-F03 | 导出审计包 | 范围可选(项目/合同/时间窗),水印与权限 | P2 | ○ |
| M10-F04 | 留存策略配置 | 与法务对齐的保留周期说明(技术实现另见架构) | P2 | ◐ — AuditRetentionView 已上线,配置生效性待确认 |
---
@@ -256,36 +257,36 @@ flowchart TB
### 12.1 账户登录、登出与会话
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------- | ------------- | ----------------------------------------------- | --- |
| M11-F01 | 登录页 | 账号(工号/邮箱/登录名可配置一种为主)+ 密码登录入口;错误提示不暴露用户是否存在(防枚举) | P0 |
| M11-F02 | 登出 | 主动登出:清除服务端会话或作废令牌、前端清理本地凭证 | P0 |
| M11-F03 | 登录态保持与超时 | **空闲超时**自动登出并提示;可选「记住本次会话」策略(与安全基线平衡,默认保守) | P0 |
| M11-F04 | 未登录访问拦截 | 访问受保护路由时跳转登录页,登录成功后回跳原目标 URL(或安全白名单内路径) | P0 |
| M11-F05 | 登录失败与锁定 | 连续失败次数阈值触发**临时锁定**或验证码;解锁策略(时长/管理员解锁)可配置 | P0 |
| M11-F06 | 登录/登出审计 | 记录成功/失败登出、时间、来源 IP、客户端类型(脱敏与留存策略另定) | P0 |
| M11-F07 | 密码修改 | 已登录用户修改本人密码;校验旧密码强度与新密码策略 | P0 |
| M11-F08 | 密码重置 | 管理员重置密码或邮件/短信重置链接(通道选一种即可);重置后可选强制首次登录改密 | P1 |
| M11-F09 | 企业 SSO / OIDC | 与企业身份源单点登录;登出可与 IdP **单点登出**联动(若 IdP 支持) | P1 |
| M11-F10 | 双因素认证 MFA | TOTP/短信/企业令牌等一种;可配置为全员或高敏角色必选 | P2 |
| M11-F11 | 并发会话策略 | 同一账号是否允许多端同时在线;超出策略时踢旧会话或拒绝新登录(可配置) | P1 |
| M11-F12 | 管理员强制下线 | 安全或人事场景下终止指定用户本会话或全会话 | P1 |
| M11-F13 | 服务时间窗提示(可选) | 维护窗口登录页公告 | P2 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------- | ------------- | ----------------------------------------------- | --- | --- |
| M11-F01 | 登录页 | 账号(工号/邮箱/登录名可配置一种为主)+ 密码登录入口;错误提示不暴露用户是否存在(防枚举) | P0 | ✅ |
| M11-F02 | 登出 | 主动登出:清除服务端会话或作废令牌、前端清理本地凭证 | P0 | ✅ |
| M11-F03 | 登录态保持与超时 | **空闲超时**自动登出并提示;可选「记住本次会话」策略(与安全基线平衡,默认保守) | P0 | ◐ — 前端 idleTimer 已实现(从 systemParams 读取 sessionTimeoutMinutes),后端会话管理待补 |
| M11-F04 | 未登录访问拦截 | 访问受保护路由时跳转登录页,登录成功后回跳原目标 URL(或安全白名单内路径) | P0 | ✅ |
| M11-F05 | 登录失败与锁定 | 连续失败次数阈值触发**临时锁定**或验证码;解锁策略(时长/管理员解锁)可配置 | P0 | ○ |
| M11-F06 | 登录/登出审计 | 记录成功/失败登出、时间、来源 IP、客户端类型(脱敏与留存策略另定) | P0 | ✅ |
| M11-F07 | 密码修改 | 已登录用户修改本人密码;校验旧密码强度与新密码策略 | P0 | ✅ — Profile 页改密弹窗已实现 |
| M11-F08 | 密码重置 | 管理员重置密码或邮件/短信重置链接(通道选一种即可);重置后可选强制首次登录改密 | P1 | ◐ — 后端 `POST /admin/reset-password` 已实现(非空操作),前端管理 UI 待补 |
| M11-F09 | 企业 SSO / OIDC | 与企业身份源单点登录;登出可与 IdP **单点登出**联动(若 IdP 支持) | P1 | ○ |
| M11-F10 | 双因素认证 MFA | TOTP/短信/企业令牌等一种;可配置为全员或高敏角色必选 | P2 | ○ |
| M11-F11 | 并发会话策略 | 同一账号是否允许多端同时在线;超出策略时踢旧会话或拒绝新登录(可配置) | P1 | ○ |
| M11-F12 | 管理员强制下线 | 安全或人事场景下终止指定用户本会话或全会话 | P1 | ○ |
| M11-F13 | 服务时间窗提示(可选) | 维护窗口登录页公告 | P2 | ○ |
### 12.2 用户、角色与权限配置(管理侧)
| 功能点 ID | 功能点名称 | 说明 | 优先级 |
| ------- | ---------------- | --------------------------------------------- | --- |
| M11-F14 | 用户与账号生命周期 | 创建、启用/禁用、离职归档;与 SSO 时同步外部主键 | P0 |
| M11-F15 | 角色定义与分配 | 预置角色(见 §13)+ 可选自定义角色;用户可挂多角色 | P0 |
| M11-F16 | 功能权限(RBAC | 菜单、按钮、API 操作与 **§13 权限码** 对齐;支持按环境预览「某用户看见什么」 | P0 |
| M11-F17 | 数据范围(Data Scope) | 按事业部/区域/客户组限制列表可见行(与 M11-F18 二选一或组合) | P2 |
| M11-F18 | 数据属主/团队 | 如「仅本人负责客户」「本团队项目」(字段:负责人、协作人) | P1 |
| M11-F19 | 业务字典 | 合同类型、交付类型、SN 异常原因分类等 | P0 |
| M11-F20 | 系统参数 | 「孤儿 SN」强校验、交付门禁、会话超时分钟数、密码策略等 | P1 |
| M11-F21 | 管理员敏感操作留痕 | 改角色、改权限、强制下线、重置密码等单独记审计 | P1 |
| 功能点 ID | 功能点名称 | 说明 | 优先级 | 实现状态 |
| ------- | ---------------- | --------------------------------------------- | --- | --- |
| M11-F14 | 用户与账号生命周期 | 创建、启用/禁用、离职归档;与 SSO 时同步外部主键 | P0 | ◐ — 后端 CRUD + 前端管理页面已实现(`/admin/users`),SSO 同步未做 |
| M11-F15 | 角色定义与分配 | 预置角色(见 §13)+ 可选自定义角色;用户可挂多角色 | P0 | ◐ 仅实现 SYS_ADMIN/DEVELOPER/OPS 三角色,产品定义 10+ 角色待补齐 |
| M11-F16 | 功能权限(RBAC | 菜单、按钮、API 操作与 **§13 权限码** 对齐;支持按环境预览「某用户看见什么」 | P0 | ◐ 路由级 RBAC 已实现,按钮级权限码未落地 |
| M11-F17 | 数据范围(Data Scope) | 按事业部/区域/客户组限制列表可见行(与 M11-F18 二选一或组合) | P2 | ○ |
| M11-F18 | 数据属主/团队 | 如「仅本人负责客户」「本团队项目」(字段:负责人、协作人) | P1 | ○ |
| M11-F19 | 业务字典 | 合同类型、交付类型、SN 异常原因分类等 | P0 | ✅ |
| M11-F20 | 系统参数 | 「孤儿 SN」强校验、交付门禁、会话超时分钟数、密码策略等 | P1 | ✅ — SystemParamController + platform_system_param 表 + 前端对接后端 API 已实现 |
| M11-F21 | 管理员敏感操作留痕 | 改角色、改权限、强制下线、重置密码等单独记审计 | P1 | ○ |
> **说明**:原 M11-F01F06 已拆并至 **12.1 / 12.2**;实现时功能点 ID 以研发 backlog 为准,本文 ID 供需求追溯。
@@ -303,19 +304,21 @@ flowchart TB
### 13.2 预置角色定义
| 角色代码 | 角色名称 | 定位 | 典型职责 |
| ---------------- | --------- | --------- | ---------------------------------------- |
| `SYS_ADMIN` | 系统管理员 | 平台配置与账号治理 | 用户/角色/字典/系统参数;**不默认拥有业务全量数据**时可配置为「仅管理」 |
| `SECURITY_ADMIN` | 安全管理员(可选) | 账号与登录安全 | 锁定策略、强制下线、审计检索;与 `SYS_ADMIN` 分离(职责分离,P2 |
| `SALES` | 商务经理 | 客户与签约侧 | 客户项目合同维护;发起交付与授权需求 |
| `ORDER_SUPPORT` | 订单/运营支持 | 履约对齐 | 合同行与 SKU、订单号关联;协助商务核对「卖授一致」 |
| `DELIVERY` | 交付工程师 | 现场交付 | 交付批次与清单、环境信息、交付完成确认 |
| `LICENSE_OPS` | 授权运营 | 许可台账与比特协同 | SN 全生命周期、Callback 处置、与控制台操作配合 |
| `DEV_SUPPORT` | 研发/集成支撑 | 技术排障 | Callback 技术字段、集成配置**只读或受限编辑**;无业务合同删除权 |
| `FINANCE_VIEW` | 财务只读 | 对账与收入支撑 | 报表与合同/SN **只读**;无改密权外的写权限 |
| `COMPLIANCE` | 合规/审计 | 抽查与导出 | 审计日志、导出包;业务数据多为 **只读** |
| `EXEC_VIEW` | 管理层只读 | 经营视图 | 报表与健康度看板 **只读** |
| `READONLY_ALL` | 业务只读(可选) | 跨模块浏览 | 全业务 **只读**,用于培训或二线;敏感字段仍脱敏 |
| 角色代码 | 角色名称 | 定位 | 典型职责 | 当前实现 |
| ---------------- | --------- | --------- | ---------------------------------------- | --- |
| `SYS_ADMIN` | 系统管理员 | 平台配置与账号治理 | 用户/角色/字典/系统参数;**不默认拥有业务全量数据**时可配置为「仅管理」 | ✅ |
| `DEVELOPER` | 研发/开发人员 | 技术研发与调试 | M1~M4/M6 业务 CRUD + 集成配置只读;**无** Callback 处置权限 | ✅ *注:产品定义中无此角色,为 MVP 简化引入,I10 起废弃,由 SALES 替代* |
| `OPS` | 运营人员 | 许可运营 | Callback 处置 + 集成配置只读;**无** 客户/项目/合同/交付/SN 写权限 | ✅ *注:产品定义中无此角色,为 MVP 简化引入,I10 起废弃,由 LICENSE_OPS 替代* |
| `SECURITY_ADMIN` | 安全管理员(可选) | 账号与登录安全 | 锁定策略、强制下线、审计检索;与 `SYS_ADMIN` 分离(职责分离,P2 | ○ |
| `SALES` | 商务经理 | 客户与签约侧 | 客户、项目、合同维护;发起交付与授权需求 | ✅ I10 已实现—替代原 DEVELOPER 角色 |
| `ORDER_SUPPORT` | 订单/运营支持 | 履约对齐 | 合同行与 SKU、订单号关联;协助商务核对「卖授一致」 | ○ *产品定义角色,仍在规划* |
| `DELIVERY` | 交付工程师 | 现场交付 | 交付批次与清单、环境信息、交付完成确认 | ✅ I10 已实现(售前演示账号 delivery/delivery |
| `LICENSE_OPS` | 授权运营 | 许可台账与比特协同 | SN 全生命周期、Callback 处置、与控制台操作配合 | ✅ I10 已实现—替代原 OPS 角色 |
| `DEV_SUPPORT` | 研发/集成支撑 | 技术排障 | Callback 技术字段、集成配置**只读或受限编辑**;无业务合同删除权 | ○ |
| `FINANCE_VIEW` | 财务只读 | 对账与收入支撑 | 报表与合同/SN **只读**;无改密权外的写权限 | ○ |
| `COMPLIANCE` | 合规/审计 | 抽查与导出 | 审计日志、导出包;业务数据多为 **只读** | ○ |
| `EXEC_VIEW` | 管理层只读 | 经营视图 | 报表与健康度看板 **只读** | ○ |
| `READONLY_ALL` | 业务只读(可选) | 跨模块浏览 | 全业务 **只读**,用于培训或二线;敏感字段仍脱敏 | ○ |
**多角色**:用户可同时拥有 `SALES` + `DELIVERY` 等,权限取**并集**;互斥规则(如 `SYS_ADMIN` 与业务高敏导出)由企业策略在实现时约束。
@@ -371,20 +374,20 @@ flowchart TB
### 13.5 与版本包的关系(对应 §14)
- **MVPP0**:§12.1 的 F01F07 + §12.2 的 F14F16 + F19;§13.2 至少落地 `SYS_ADMIN``SALES``DELIVERY``LICENSE_OPS``ORDER_SUPPORT``EXEC_VIEW`(或合并只读角色);§13.3 矩阵可先 **粗粒度**(模块级),Mid 再拆按钮级权限码
- **MidP1**SSO、会话并发、强制下线密码重置、数据属主/团队;`DEV_SUPPORT``FINANCE_VIEW`;权限码全量挂菜单/接口
- **FullP2**MFA、`SECURITY_ADMIN`、事业部数据范围、细粒度互斥策略。
- **MVPI1I9,已完成**:§12.1 的 F01/F02/F04/F06 + §12.2 的 F14(基础)/F15(简化三角色)/F16(路由级)/F19;§13.2 **实际落地 `SYS_ADMIN` + `DEVELOPER` + `OPS`**(简化角色集,非产品定义全量);§13.3 矩阵 **粗粒度模块级**。MVP 未覆盖的 P0 项(如 M1-F03/F06、M11-F03/F05/F07)标记为已知缺口,Mid 阶段补齐
- **MidI10I13,待实现)**M7 设备 + M8 通知/待办 + M9 报表对账 + 补齐 MVP 遗留 P0 + M2/M4/M5/M6 P1 增强项 + SSO/并发会话/强制下线/密码重置 + 废弃 `DEVELOPER`/`OPS`,落地产品定义角色集
- **FullV2.0,规划**MFA、`SECURITY_ADMIN`、事业部数据范围、审计导出包、CRM 同步、细粒度互斥策略。
---
## 14. 按版本包的功能边界(与 P0 / P1 / P2 对齐)
| 版本包 | 包含模块与要点 |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| **MVP** | M1M2M3M4 核心功能 + M5 收件箱与基础处置 + M6 环境产品线最小集 + **M11 §12.1(登录/登出/会话/审计)+ §12.2 用户角色权限与字典** + M10-F01角色矩阵 **§13.3 粗粒度** |
| **Mid** | MVP + M7 + M8 + M9 主体 + M2/M4/M5/M6 增强 + M10-F02 + **M11 SSO/并发/强制下线/重置密码/数据属主** + **权限码细拆** |
| **Full** | Mid + M2/M6/M9/M10/M11 的 P2 项 + M1/M8 集成与智能化增强 + **MFA、安全管理员、数据范围** |
| 版本包 | 状态 | 包含模块与要点 |
| -------- | --- | ---------- |
| **MVPI1I9** | ✅ **已完成** | M1/M2/M3/M4 核心功能 + M5 收件箱与处置 + M6 环境/产品线只读 + M10 审计日志 + **M11 JWT 登录/路由守卫/粗粒度三角色/字典**角色矩阵 **§13.3 粗粒度(简化三角色)**;自研许可证管理(V6)为额外交付。详见 §16 原型章节。 |
| **MidI10I13** | 🕐 **进行中** | MVP + M7 设备 + M8 通知待办 + M9 报表对账 + 补齐 MVP 未覆盖的 P0 项 + M2/M4/M5/M6 P1 增强 + M10-F02 审计检索 + **M11 SSO/并发/强制下线/密码重置/数据属主** + **权限码细拆** + **角色模型对标产品定义集** |
| **FullV2.0** | 📋 **规划中** | Mid + M2/M6/M9/M10/M11 的 P2 项 + M1/M8 集成与智能化增强 + **MFA、安全管理员、事业部数据范围、审计导出包、CRM 同步** |
---
@@ -396,5 +399,134 @@ flowchart TB
| ---------- | ---------------------------------------------------------------------------------- |
| 2026-04-06 | 初版:产品视角模块划分与功能点表。 |
| 2026-04-06 | 增补:M11 扩展为身份/访问/平台管理;**登录/登出/会话**功能点;**§13 角色与权限体系**(预置角色、模块矩阵、权限码示例);版本包与 §14 对齐。 |
| 2026-05-25 | **全面更新**:所有功能点表增加「实现状态」列,标注 ✅/◐/○;M4-F06 降级至 P1;§13 角色表增加当前实现对照列;§14 版本包反映 I1~I9 完成状态;新增 **§16 原型实现说明**。 |
---
## 16. 原型实现说明(I1~I9 迭代交付)
> 本章记录 **2026-04 至 2026-05I1I9)** 已交付原型的具体范围,供产品验收、集成方评估与后续迭代规划使用。原型基于 **三轨并行**(后端双 JAR + 前端 Vue + 客户端 SDK)模式交付。
### 16.1 原型定位与范围
| 维度 | 说明 |
|------|------|
| **迭代范围** | I1(脚手架/M11)→ I9Webhook 出库状态只读),共 9 个迭代 |
| **原型目标** | 跑通 BP-01~06、11 主链路:客户→项目→合同→交付→SN→Callback→审计 |
| **交付形态** | 两枚 Fat JARdelivery-platform-api :8080 + license-webhook-ingress :8081+ Vue 3 SPA + Rust cdylib + Java SDK JAR |
| **部署方式** | Docker ComposePostgreSQL 15 + 双 JAR + Prometheus/Grafana 可选)或单机 `java -jar` |
| **覆盖模块** | M1M6 P0 核心功能 + M10-F01 + M11 基础身份与访问 + 自研许可证管理(V6 额外) |
### 16.2 前端原型(delivery-platform-ui
| 页面 | 路由 | 对应模块 | 实现说明 |
|------|------|---------|---------|
| 登录页 | `/login` | M11 | JWT Bearer 认证,演示账号 `admin/admin``ops/ops` |
| 首页 | `/` | — | 角色感知的模块快捷链接 + `GET /api/v1/ping` 调试 |
| 客户管理 | `/customers` | M1 | 列表/搜索/分页/新建/编辑对话框/删除(软删) |
| 项目管理 | `/projects` | M1 | 列表/按客户筛选/新建/编辑/删除(物理删) |
| 合同管理 | `/contracts` | M2 | 列表 + 三步创建向导 + 详情(状态机操作、行项管理、审计列表) |
| 交付管理 | `/deliveries` | M3 | 列表 + 新建向导 + 详情(抬头编辑、行项管理、状态变更) |
| 许可 SN | `/licenses/sn` | M4 | 列表 + 新建 + 详情(绑定/状态变更) |
| Callback 收件箱 | `/callbacks` | M5 | 列表(多维筛选)+ 详情(payload 脱敏预览、人工挂接、状态处置、DEAD 重放、出库状态) |
| 集成环境 | `/integration/environments` | M6 | 只读列表 |
| 产品线 | `/integration/product-lines` | M6 | 只读列表 |
| 403/404 | `/403` / `/*` | M11 | 路由级无权限/未找到提示 |
**技术栈**Vue 3 (Composition API) + Vite + Pinia + vue-router + axios + Element Plus。Token 存 `localStorage`(**已知安全缺陷**Mid 阶段应迁移至 HttpOnly Cookie)。
### 16.3 后端 API 原型
#### delivery-platform-api:8080
| Controller | 路由前缀 | 覆盖的模块 | 关键端点 |
|-----------|---------|-----------|---------|
| `AuthController` | `POST /api/v1/auth/login` | M11 | 登录(返回 JWT |
| `CustomerController` | `/api/v1/customers` | M1 | CRUD + 分页列表 + 软删 |
| `ProjectController` | `/api/v1/projects` | M1 | CRUD + 分页列表 + 物理删 |
| `ContractController` | `/api/v1/contracts` | M2 | CRUD + 行项管理 + 状态机 PATCH |
| `DeliveryBatchController` | `/api/v1/delivery-batches` | M3 | CRUD + 行项管理 + 状态 PATCH |
| `LicenseSnController` | `/api/v1/license-sns` | M4 | CRUD + 状态 PATCH |
| `LicenseController` | `/api/v1/licenses` | 自研许可 | License CRUD + 激活 + 过期(V6 额外) |
| `CallbackInboxController` | `/api/v1/callback-inbox` | M5 | 列表/详情/状态 PATCH/人工挂接/重放 Webhook 出库 |
| `IntegrationCatalogController` | `/api/v1/integration/*` | M6 | 产品线/环境只读列表与详情 |
| `PingController` | `/api/v1/ping` | — | 健康探测 |
| `CallbackInternalController` | `/internal/v1/callback-events` | M5 | Webhook→平台内部投递入口 |
**安全**JWT Bearer`PLATFORM_JWT_SECRET`+ 内部共享 Token`X-Platform-Internal-Token`+ 角色路由 `/api/v1/auth/login` `/actuator/health` 免认证。
#### license-webhook-ingress:8081
| Controller | 路径 | 职责 |
|-----------|------|------|
| `CallbackIngestController` | `POST /webhook/bitanswer/callback` | 比特 Callback 入站:验签(`x-bitanswer-token`)、幂等(`Idempotency-Key``externalMessageId`)、落收据表、入队投递 |
| `WebhookPlatformDeliveryOpsController` | `GET …/by-receipt/{receiptId}` | 出库状态只读查询 |
| `WebhookPlatformDeliveryOpsController` | `POST …/by-receipt/{receiptId}/replay` | DEAD 行重放 |
**安全**`CRAFTLABS_WEBHOOK_EXPECTED_TOKEN` + `X-Webhook-Ops-Token`I8 起)。
### 16.4 Rust 核心库原型(native/craft-core
| 模块 | 文件 | 实现程度 |
|------|------|---------|
| **C ABI** | `lib.rs` | ✅ 8 个导出函数:`craft_initialize/activate/check_license/get_license_info/has_feature/release/heartbeat/destroy` |
| **Provider trait** | `trait_provider.rs` | ✅ 定义 Provider 接口:initialize/activate/check_license/heartbeat/has_feature/release/get_license_info/close |
| **自研 Provider** | `provider_selfhosted/` | ✅ 完整实现:activate + heartbeat + cache + license + protocol |
| **加密模块** | `crypto.rs` | ✅ AES-256-GCM 加解密、HKDF 密钥派生、RSA PKCS1v15 签名验证 |
| **设备指纹** | `device.rs` | ✅ 设备标识生成与校验 |
| **安全加固** | `security/` | ✅ 反调试(`anti_debug.rs`)、代码混淆(`obfuscation.rs`)、字符串加密(`string_encrypt.rs`)、完整性校验(`integrity.rs`)、动态 API 解析(`dynamic_api.rs` |
| **许可管理** | `license.rs` / `activate.rs` / `heartbeat.rs` / `session.rs` / `error.rs` | ✅ 核心许可状态、激活/心跳业务流程、错误码定义 |
| **公钥嵌入** | `build.rs` | ✅ 构建时嵌入 RSA 公钥 |
**已知局限**:仅实现 `SelfHostedProvider``BitAnswerProvider` 在 Rust 侧未实现。
### 16.5 Java SDK 原型(java/
| 模块 | 覆盖率 | 说明 |
|------|--------|------|
| `craftlabs-auth-core` | ✅ `AuthConfigs`(解析/校验/序列化)+ `AuthConfig`/`AuthConfigs` 配置模型 + `AuthProvider` 接口 + `AuthResult` + `LicenseInfo` + `NativeBridge`JNI 接口声明) |
| `craftlabs-auth-bitanswer` | ⚠️ `BitAnswerProvider` 实现 `AuthProvider` 接口但 **JNI 未对接真实原生库**,属于 Stub 状态 |
| `craftlabs-auth-selfhosted` | ⚠️ `SelfHostedAuthProvider` 基础实现,未对接 Rust 核心库(当前为独立 Java 实现) |
| `craftlabs-auth-tests` | ✅ Schema 校验测试 + BitAnswerProvider 基础测试 |
| `schemas/craftlabs-auth-config.schema.json` | ✅ 完整 JSON Schema Draft 2020-12,支持 3 种 scenario + 2 种 provider |
### 16.6 原型已知局限
以下为审计发现的 25 个问题中与原型直接相关的重大局限:
| 类别 | 问题 | 影响 | 计划迭代 |
|------|------|------|---------|
| **安全** | 前端 Token 存 `localStorage`(非 HttpOnly Cookie | XSS 窃取风险 | Mid |
| **安全** | Callback `raw_payload` 全字段明文落库 | 可能含 PII,未脱敏 | I10 |
| **SDK** | `BitAnswerProvider` 未对接真实原生库 | SDK 无法实际与比特安索通信 | Mid |
| **SDK** | Java SelfHostedProvider 未调用 Rust 核心 | 自研授权路径不通 | Mid |
| **角色** | 仅实现 3 个角色(产品定义 10+),存在 `DEVELOPER`/`OPS` 非标角色 | 角色模型与产品定义不匹配 | I12 |
| **M1** | 客户表缺行业/地址/开票信息、项目表缺计划起止/项目经理 | 字段覆盖不足 | I10 |
| **M4** | 比特控制台状态摘要未实现(原 P0) | 需跳转比特控制台查看 | P1/Mid |
| **M6** | 产品线→比特 ID 映射、特征映射、JSON 模板管理均未实现 | BP-10 配置发布流程不完整 | Mid |
| **M7M9** | 设备/通知/报表模块完全未开始 | Mid 核心范围 | I10I12 |
| **测试** | 无 Playwright E2E 测试 | 回归覆盖不足 | I10 |
| **基础设施** | 无消息队列,Webhook→API 走轮询 HTTP | 无削峰能力,高并发受限 | I11 |
### 16.7 从原型到产品化的演进路径
```mermaid
flowchart LR
subgraph MVP["MVP(已完成 I1I9"]
A["BP-0106+11 主链路<br/>M1M6 P0 + M10-F01 + M11 基础<br/>自研许可证 V6 额外"]
end
subgraph Mid["MidI10I13"]
B["补齐 M1/M4/M11 P0 缺口<br/>M7 设备 + M8 通知 + M9 报表<br/>M2/M5/M6 P1 增强<br/>SSO + 角色模型对齐<br/>BitAnswerProvider 对接"]
end
subgraph Full["FullV2.0"]
C["MFA / SECURITY_ADMIN<br/>数据范围 / 审计导出<br/>CRM 同步 / 变更治理<br/>消息队列 / 读模型分离"]
end
MVP --> Mid --> Full
```
**关键里程碑**
- **当前**:MVP 原型已完成,可支撑一条真实或准生产项目全链路 UAT
- **Mid(目标 T0+24~28 周)**:具备设备治理、通知协作、对账报表,角色模型对标产品定义
- **Full(目标 T0+34~42 周)**:合规审计、深度集成、规模化运营能力
@@ -0,0 +1,307 @@
# 交付平台前端 UI 需求规格说明(走查稿)
> 依据 `web/delivery-platform-ui` 源码整理,供 Figma Make / 设计迭代与产品跟进维护。
> 侧栏实现见 `src/layout/MainLayout.vue`;路由与角色见 `src/router/index.js`。
---
## 1. 全局壳层(需登录页共用)
| 区域 | 内容 |
|------|------|
| 侧栏品牌 | 文案:`创飞 · 交付平台` |
| 侧栏菜单 | 见 §2(按角色 `v-if` 显示) |
| 顶栏右侧 | 当前用户展示名(`displayName`)、链接按钮「退出」 |
| 主内容区 | 路由出口,背景为控制台风格浅灰 |
### 1.1 角色与菜单可见性
与路由 `meta.roles` 一致:
- **SYS_ADMIN**:可见下文全部侧栏项(无单独剔除)。
- **DEVELOPER**:客户管理、项目管理、合同管理、交付管理、许可 SN、集成环境、产品线;**无** Callback 收件箱。
- **OPS**Callback 收件箱、集成环境、产品线;**无** 客户 / 项目 / 合同 / 交付 / 许可 SN。
### 1.2 信息架构说明
当前侧栏为 **单层 `el-menu`**,无折叠分组、无物理「二级子菜单」。本文 **一级 = 产品模块**,**二级 = 侧栏入口 + 由其进入的子页面**(详情、向导、弹窗表单)。
---
## 2. 按模块:菜单项 → 页面 → 数据与操作
### 2.1 首页
| 项 | 说明 |
|----|------|
| 路由 | `/``HomeView.vue` |
| 侧栏 | 「首页」 |
| 内容 | 信息 AlertI7:按角色展示入口;Callback 仅 OPS / SYS_ADMIN);当前用户与角色;**与侧栏一致的模块快捷链接**(随角色过滤) |
| 附加 | 「调试」:`GET /api/v1/ping` 按钮 + JSON 文本结果区 |
---
### 2.2 客户管理
| 项 | 说明 |
|----|------|
| 路由 | `/customers``CustomersView.vue` |
| 侧栏 | 「客户管理」 |
| 列表筛选 | 关键词(名称或统一社会信用代码)、「查询」 |
| 表格列 | 客户名称、统一社会信用代码 |
| 行操作 | 编辑、删除(确认框) |
| 工具栏 | 「新建客户」 |
| 分页 | 10 / 20 / 50total、sizes、prev、pager、next、jumper |
#### 无独立菜单:新建 / 编辑客户(`el-dialog`,宽约 480px
| 字段 | 校验 / 约束 | API 语义 |
|------|----------------|----------|
| 客户名称 | 必填,`maxlength=200`,字数统计 | `POST` / `PUT` body |
| 统一社会信用代码 | 选填,`maxlength=32` | 空则不发该字段 |
| 底部 | 取消、保存(loading | `createCustomer` / `updateCustomer` |
---
### 2.3 项目管理
| 项 | 说明 |
|----|------|
| 路由 | `/projects``ProjectsView.vue` |
| 侧栏 | 「项目管理」 |
| 列表筛选 | 客户下拉(可清空、可搜索)、「查询」 |
| 表格列 | 客户(名)、项目名称、阶段(字典中文) |
| 行操作 | 编辑、删除(确认框) |
| 工具栏 | 「新建项目」 |
| 分页 | 同客户管理 |
#### 无独立菜单:新建 / 编辑项目(`el-dialog`,宽约 520px
| 字段 | 校验 / 约束 | 说明 |
|------|----------------|------|
| 客户 | 必填 | 选项来自客户列表(最多 500 条) |
| 项目名称 | 必填,`maxlength=200` | |
| 阶段 | 必填 | `GET /api/v1/dictionaries/PROJECT_PHASE`,前端兼容多种响应包裹形态 |
---
### 2.4 合同管理
| 项 | 说明 |
|----|------|
| 列表路由 | `/contracts``ContractsView.vue` |
| 侧栏 | 「合同管理」 |
| 列表筛选 | 关键词(合同标题)、「查询」 |
| 表格列 | 合同标题/编号、客户、项目、状态(Tag)、创建时间 |
| 行操作 | 「详情」→ `/contracts/:id` |
| 工具栏 | 「新建合同」→ `/contracts/new` |
#### 无菜单:新建合同向导(`/contracts/new``ContractWizardView.vue`
三步 `el-steps`
**步骤 0 — 客户与项目**
- 客户必选;项目必选;项目下拉随所选客户过滤;更换客户时清空项目。
**步骤 1 — 合同基本信息**
- 合同标题/编号:必填,`maxlength=256`,字数统计。
- 备注:选填,`textarea``maxlength=4000`
**步骤 2 — 明细行**
- 表内编辑:标的/行项名称(`itemName`)每行必填,`maxlength=256`
- 数量:`el-input-number``precision=4``min=0.0001`,须大于 0。
- 单位:选填,`maxlength=32`
- 至少保留一行;「添加明细」;「删除」(仅一行时删除禁用)。
**提交**:先 `createContract`,再对每行 `addLine`;成功跳转合同详情。
#### 无菜单:合同详情(`/contracts/:id``ContractDetailView.vue`
| 区块 | 行为 |
|------|------|
| 头部 | ← 合同列表;标题「合同详情」;状态 Tag;**草稿** 显示「保存」 |
| 描述区 | 标题/备注:草稿可编辑;客户/项目只读(优先接口 name,否则 ID 映射) |
| 状态操作条 | 依状态显示按钮(均需确认):草稿→提交待生效;待生效→确认生效;生效→发起变更 / 终止合同;变更中→完成变更 |
| 合同明细 | 草稿可「添加明细」、行内编辑/删除;非草稿只读 |
| 明细弹窗 | 添加/编辑:行项名称必填;数量必填;单位选填 |
| 最近审计 | 列:时间、操作者、动作、摘要(`listAuditEvents` 归一化展示) |
**合同状态枚举(Tag**
| 值 | 中文 |
|----|------|
| DRAFT | 草稿 |
| PENDING_EFFECTIVE | 待生效 |
| EFFECTIVE | 生效 |
| CHANGING | 变更中 |
| TERMINATED | 已终止 |
---
### 2.5 交付管理
| 项 | 说明 |
|----|------|
| 列表路由 | `/deliveries``DeliveriesView.vue` |
| 侧栏 | 「交付管理」 |
| 列表筛选 | 项目下拉、批次编码关键词、「查询」 |
| 表格列 | 批次编码、项目、合同 ID、状态 Tag、计划交付日、创建时间 |
| 行操作 | 「详情」→ `/deliveries/:id` |
| 工具栏 | 「新建交付批次」→ `/deliveries/new` |
#### 无菜单:新建交付批次(`/deliveries/new``DeliveryBatchWizardView.vue`
| 字段 | 校验 | 说明 |
|------|------|------|
| 项目 | 必填 | 可搜索 |
| 合同 | 选填 | 依赖项目;按 `projectId` 过滤合同 |
| 批次编码 | 必填,`maxlength=64` | |
| 计划交付日 | 选填 | 日期,`YYYY-MM-DD` |
| 备注 | 选填,`maxlength=4000` | |
| 底部 | 「创建并返回列表」/「创建并进入详情」 | |
#### 无菜单:交付批次详情(`/deliveries/:id``DeliveryBatchDetailView.vue`
| 区块 | 行为 |
|------|------|
| 头部 | ← 交付列表;状态 Tag;**PENDING** 时:「保存抬头」「标记已交付」(确认) |
| 抬头 | 批次编码 / 项目 / 合同 ID / 完成时间只读;**PENDING** 可编辑计划交付日、备注 |
| 交付明细 | **PENDING** 可增删改;列:排序、说明、数量、合同行 ID |
| 明细弹窗 | 排序 number 0999999;说明必填,`maxlength=512`;数量必填;合同行 ID 选填 |
**交付状态枚举**
| 值 | 中文 |
|----|------|
| PENDING | 待交付 |
| DELIVERED | 已交付 |
| CANCELLED | 已取消 |
---
### 2.6 许可 SN
| 项 | 说明 |
|----|------|
| 列表路由 | `/licenses/sn``LicenseSnListView.vue` |
| 侧栏 | 「许可 SN」 |
| 列表筛选 | 项目、SN 关键词、「查询」 |
| 表格列 | SN 编码、项目、合同行 ID、状态 Tag、创建时间 |
| 行操作 | 「详情」→ `/licenses/sn/:id` |
| 工具栏 | 「新建许可 SN」→ `/licenses/sn/new` |
#### 无菜单:新建许可 SN`/licenses/sn/new``LicenseSnWizardView.vue`
| 字段 | 校验 | 说明 |
|------|------|------|
| SN 编码 | 必填,`maxlength=128` | |
| 项目 | 选填 | 可搜索 |
| 合同行 ID | 选填,number ≥1 | MVP 手工录入提示 |
| 激活备注 | 选填,`textarea``maxlength=512` | |
| 底部 | 创建并返回列表 / 创建并进入详情 | |
#### 无菜单:许可 SN 详情(`/licenses/sn/:id``LicenseSnDetailView.vue`
| 区块 | 内容 |
|------|------|
| 基础 | SN 编码、创建时间 |
| 绑定与备注 | 表单:项目、合同行 ID、激活备注;「保存绑定」 |
| 状态 | 下拉:REGISTERED / ISSUED / ACTIVATED / SUSPENDED / REVOKED(含中英文标签);「更新状态」 |
**许可 SN 状态枚举**
| 值 | 中文 |
|----|------|
| REGISTERED | 已登记 |
| ISSUED | 已发放 |
| ACTIVATED | 已激活 |
| SUSPENDED | 已暂停 |
| REVOKED | 已吊销 |
---
### 2.7 Callback 收件箱(SYS_ADMIN、OPS
| 项 | 说明 |
|----|------|
| 列表路由 | `/callbacks``CallbackInboxView.vue` |
| 侧栏 | 「Callback 收件箱」 |
| 列表筛选 | 状态(PENDING / PROCESSED / FAILED / IGNORED)、事件类型、SN、项目 ID(文本)、「查询」 |
| 表格列 | 来源、外部消息 ID、事件类型、SN、状态 Tag、收件时间 |
| 行操作 | 「详情」→ `/callbacks/:id` |
> **扩展说明**`listCallbackInbox` API 另支持 `productLineId`、`environmentId`、`from`、`to` 等;当前 UI 未暴露,可在后续版本增加筛选。
#### 无菜单:Callback 详情(`/callbacks/:id``CallbackInboxDetailView.vue`
| 区块 | 规格 |
|------|------|
| 主信息 | ID、来源系统、外部消息 ID、事件类型、Schema 版本、幂等键、Webhook 收据 ID、SN、项目/合同/许可 SN/产品线/环境 ID、收件/处理时间、失败原因、备注 |
| Payload | 标题「Payload(脱敏预览)」;深色只读代码块,`pre`,约 `max-height: 420px` 可滚动 |
| I9 Webhook 出库状态 | **仅当**存在 `webhookReceiptId`:加载骨架 / 错误或描述列表:出库状态、尝试次数、上次错误、下次重试、出库更新时间 |
| I8 重新入队 | **同上条件**:配置说明(`LICENSE_WEBHOOK_BASE_URL``LICENSE_WEBHOOK_OPS_TOKEN`);按钮「重新入队出库(DEAD→待投递)」+ 确认 |
| 状态处置 | **仅 PENDING**:标已处理 / 标失败 / 忽略(均确认) |
| 人工挂接 | 许可 SN ID、项目 ID、合同 ID(文本输入);「保存挂接」需至少填一项 |
**Callback 状态枚举**
| 值 | 中文 |
|----|------|
| PENDING | 待处理 |
| PROCESSED | 已处理 |
| FAILED | 失败 |
| IGNORED | 忽略 |
---
### 2.8 集成环境
| 项 | 说明 |
|----|------|
| 路由 | `/integration/environments``IntegrationEnvironmentsView.vue` |
| 侧栏 | 「集成环境」 |
| 页面 | 只读表 + 「刷新」 |
| 列 | 编码、名称、类型、比特 URL(`bitanswerBaseUrl` 等兼容字段)、产品线 ID |
| 分页 | 默认 `pageSize=20`,可选 10/20/50 |
---
### 2.9 产品线
| 项 | 说明 |
|----|------|
| 路由 | `/integration/product-lines``IntegrationProductLinesView.vue` |
| 侧栏 | 「产品线」 |
| 页面 | 只读表 + 「刷新」 |
| 列 | 编码、名称、描述、启用(`enabled` / `active` 为 false 显示「否」) |
| 分页 | 同集成环境 |
---
## 3. 无侧栏:认证与异常页
| 路由 | 页面 | 内容 |
|------|------|------|
| `/login` | `LoginView.vue` | 标题「客户商务与交付管理平台(I1)」;用户名、密码;登录 loading;演示账号提示 |
| `/403` | `ForbiddenView.vue` | Result:无权限;「返回首页」 |
| `/*` | `NotFoundView.vue` | Result404;「返回首页」 |
---
## 4. Figma / 画板拆分建议
1. **App Shell**:侧栏 + 顶栏 + 内容槽(与各列表/详情组合)。
2. **一级画板**:首页、各侧栏列表页、登录、403、404。
3. **二级画板**(无侧栏直达):合同向导(3 步)、合同详情、交付新建、交付详情、许可新建、许可详情、Callback 详情。
4. **模态层**:客户表单、项目表单、合同行、交付行;各类二次确认可在设计注释中说明。
---
## 5. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-07 | 初版:按 `web/delivery-platform-ui` 源码走查整理,供设计跟进。 |
+42 -25
View File
@@ -9,11 +9,13 @@
## 1. 三条轨道与文档
| 轨道 | 仓库/工作区 | 执行包文档 |
|------|-------------|------------|
| **A. 平台后端** | `craftlabs-delivery-platform` + `craftlabs-license-webhook`(规划) | [tracks/01-backend-platform-webhook.md](./tracks/01-backend-platform-webhook.md) |
| **B. 平台** | `craftlabs-delivery-platform-ui`(规划) | [tracks/02-frontend-platform-ui.md](./tracks/02-frontend-platform-ui.md) |
| **C. 客户端 SDK** | 本工作区 `craftlabs-authorization-sdk` | [tracks/03-client-sdk.md](./tracks/03-client-sdk.md) |
| 轨道 | 仓库/工作区 | 执行包文档 |
| -------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **A. 平台** | `craftlabs-delivery-platform` + `craftlabs-license-webhook`(规划) | [tracks/01-backend-platform-webhook.md](./tracks/01-backend-platform-webhook.md) |
| **B. 平台前端** | `craftlabs-delivery-platform-ui`(规划) | [tracks/02-frontend-platform-ui.md](./tracks/02-frontend-platform-ui.md) |
| **C. 客户端 SDK** | 本工作区 `craftlabs-authorization-sdk` | [tracks/03-client-sdk.md](./tracks/03-client-sdk.md) |
---
@@ -21,29 +23,38 @@
三轨 **共用** Roadmap 的 **I1I6**(各约 **2 周**),同一迭代内并行开工;**硬耦合**集中在 **I5Callback + Schema****I6UAT**
| 迭代 | 后端(双 JAR) | 前端(Vue) | 客户端 SDK(本仓) |
|------|----------------|-------------|---------------------|
| I1 | 身份 + Webhook 脚手架 | 登录 + 布局壳 + RBAC 路由 | 文档/边界说明,无阻塞发布 |
| I2 | M1 主数据 + 字典 | 客户/项目页 | 同上 |
| I3 | M2 合同 + M10-F01 | 合同向导与行项 | 同上 |
| I4 | M3 交付 + M4 SN | 交付 + SN 页 | 文档与示例与 BP-10 口径一致 |
| I5 | M5 Inbox + M6 + Webhook 生产链 | Callback + 集成只读页 | **Schema + AuthConfigs + examples 对齐 BP-10** |
| I6 | 加固 + UAT | E2E + 缺陷 | **冻结 SDK 版本 + CHANGELOG + 兼容矩阵** |
| 迭代 | 后端(双 JAR) | 前端(Vue) | 客户端 SDK(本仓) |
| --- | ---------------------------------------------------- | ------------------------- | -------------------------------------------- |
| I1 | 身份 + Webhook 脚手架 | 登录 + 布局壳 + RBAC 路由 | 文档/边界说明,无阻塞发布 |
| I2 | M1 主数据 + 字典 | 客户/项目页 | 同上 |
| I3 | M2 合同 + M10-F01 | 合同向导与行项 | 同上 |
| I4 | M3 交付 + M4 SN | 交付 + SN 页 | 文档与示例与 BP-10 口径一致 |
| I5 | M5 Inbox + M6 + Webhook 生产链 | Callback + 集成只读页 | **Schema + AuthConfigs + examples 对齐 BP-10** |
| I6 | 加固 + UAT | E2E + 缺陷 | **冻结 SDK 版本 + CHANGELOG + 兼容矩阵** |
| I7 | Webhook **异步投递** + **OPS** 运营权限(Callback) | 路由/菜单与 `@PreAuthorize` 对齐 | 若涉及 Schema 仍走轨道 C |
| I8 | `**DEAD` 出库重放**Webhook 内部 API + 平台代理 + UI / Runbook | Callback 详情「重新入队」 | 不涉及 SDK Schema |
| I9 | **出库状态只读**Webhook `GET by-receipt` + 平台代理 + 详情展示 | Callback 详情「出库状态」 | 不涉及 SDK Schema |
设计/复盘:[I7_DESIGN.md](./iterations/I7_DESIGN.md)、[I7_IMPLEMENTATION_REVIEW.md](./iterations/I7_IMPLEMENTATION_REVIEW.md)I8[I8_WEBHOOK_DELIVERY_REPLAY_DESIGN.md](./iterations/I8_WEBHOOK_DELIVERY_REPLAY_DESIGN.md)、[I8_IMPLEMENTATION_REVIEW.md](./iterations/I8_IMPLEMENTATION_REVIEW.md)I9[I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md](./iterations/I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md)、[I9_IMPLEMENTATION_REVIEW.md](./iterations/I9_IMPLEMENTATION_REVIEW.md)。
---
## 3. 跨轨同步点(必须对齐)
| 周次/迭代 | 同步内容 | 参与方 |
|-----------|----------|--------|
| **I1 末** | 认证方式(JWT vs Session)、OpenAPI `auth` tag、错误码与 401 行为 | 后端 + 前端 |
| **I2 末** | 客户/项目 DTO 冻结;字典编码 | 后端 + 前端 |
| **I3 末** | 合同状态枚举与非法迁移码;M10-F01 字段 | 后端 + 前端 |
| **I4 末** | SN 绑定与「孤儿 SN」规则;交付门禁参数(M11-F20) | 后端 + 前端 + SDK(文档) |
| **I5 起** | Callback payload 与 inbox DTO**Idempotency-Key**Webhook→平台投递方式(HTTP/MQ | 后端 Webhook + 后端平台 + 前端 + **SDKSchema/示例)** |
| **I6** | UAT 场景、冻结 **SDK 版本**、两枚 Fat JAR 与前端 `VITE_API_BASE` | 全员 |
**契约 Owner(建议角色)**:架构或 Tech Lead 兼任;**OpenAPI 单一事实来源** 为本仓库 [`contracts/openapi/delivery-platform-api.json`](../../contracts/openapi/delivery-platform-api.json)(与运行时 `/v3/api-docs``OpenApiContractSnapshotTest` 对齐),说明见 [`contracts/README.md`](../../contracts/README.md)。
| 周次/迭代 | 同步内容 | 参与方 |
| -------- | ------------------------------------------------------------------------ | ------------------------------------------- |
| **I1 末** | 认证方式(JWT vs Session)、OpenAPI `auth` tag、错误码与 401 行为 | 后端 + 前端 |
| **I2 末** | 客户/项目 DTO 冻结;字典编码 | 后端 + 前端 |
| **I3 末** | 合同状态枚举与非法迁移码;M10-F01 字段 | 后端 + 前端 |
| **I4 末** | SN 绑定与「孤儿 SN」规则;交付门禁参数(M11-F20) | 后端 + 前端 + SDK(文档) |
| **I5 起** | Callback payload 与 inbox DTO**Idempotency-Key**Webhook→平台投递方式(HTTP/MQ | 后端 Webhook + 后端平台 + 前端 + **SDKSchema/示例)** |
| **I6** | UAT 场景、冻结 **SDK 版本**、两枚 Fat JAR 与前端 `VITE_API_BASE` | 全员 |
**契约 Owner(建议角色)**:架构或 Tech Lead 兼任;**OpenAPI 单一事实来源** 为本仓库 `[contracts/openapi/delivery-platform-api.json](../../contracts/openapi/delivery-platform-api.json)`(与运行时 `/v3/api-docs``OpenApiContractSnapshotTest` 对齐),说明见 `[contracts/README.md](../../contracts/README.md)`
---
@@ -71,10 +82,16 @@ flowchart LR
JAR --> NAT
```
---
## 5. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:三轨并行索引 + 同步点 + Gantt 示意。 |
| 日期 | 说明 |
| ---------- | ----------------------------------------- |
| 2026-04-06 | 初版:三轨并行索引 + 同步点 + Gantt 示意。 |
| 2026-04-07 | 增加 **I9**Webhook 出库状态只读(Callback 详情可观测)。 |
+397
View File
@@ -0,0 +1,397 @@
# 迭代 I3 设计说明 — M2 合同与行项 P0、M10-F01 审计、Webhook 事件 DTO v0.1
> **迭代定位**:与 [并行迭代索引](../PARALLEL_ITERATION_INDEX.md) 中 **I3** 一致 — 平台后端 **M2 合同 + 行项**、**M10-F01 关键字段变更日志**Webhook 侧 **事件 DTO v0.1** 与平台主键/枚举对齐,便于 I5 Callback 关联。
> **分支**`develop`(本仓库为契约与 SDK 工作区;平台运行时实现可在 `delivery-platform` 仓库,路径风格须与本仓 OpenAPI 一致)。
---
## 1. 上下文与引用文档
| 文档 | 路径 | 本迭代取用要点 |
| ------------------ | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| 并行迭代索引 | [docs/engineering/PARALLEL_ITERATION_INDEX.md](../PARALLEL_ITERATION_INDEX.md) | I3 范围:M2 + M10-F01**I3 末**同步「合同状态枚举与非法迁移码」「M10-F01 字段」。 |
| 轨道 A(后端 + Webhook | [docs/engineering/tracks/01-backend-platform-webhook.md](../tracks/01-backend-platform-webhook.md) | I3:合同+行、状态机;审计;Webhook **事件 DTO 规范化**、与平台枚举对齐;契约:**合同/行 id** 供 Callback 关联。 |
| 产品模块与功能点 | [docs/chuangfei-platform-product-modules.md](../../chuangfei-platform-product-modules.md) | **M2-F01F04P0**:登记编辑、状态机、标的摘要、行项;**M10-F01(P0)**:关键字段变更日志(旧值/新值/人/时间)。 |
**现有 API 路径约定**(须保持一致):`services/delivery-platform-api` 中 Controller 使用 `**@RequestMapping("/api/v1/...")`**,例如 `/api/v1/customers``/api/v1/projects`。合同相关接口统一前缀 `**/api/v1/contracts`**。
**OpenAPI 单一事实来源**:契约快照路径为仓库根下 `[contracts/openapi/delivery-platform-api.json](../../../contracts/openapi/delivery-platform-api.json)`;新增/变更接口须更新该快照,并与 `OpenApiContractSnapshotTest` 对齐。
---
## 2. 领域模型:合同(Contract
### 2.1 实体职责
合同是「卖什么」的权威来源之一,关联 M1 客户与项目;行项为履约/授权上游锚点(与后续 M3/M4 衔接)。
### 2.2 字段(P0
| 字段 | 类型 | 约束 | 说明 |
| ------------------------- | --------------- | ------- | ------------------------------------------------------------------------ |
| `id` | int64 | 主键 | 与现有 `customers`/`projects` 一致用雪花或序列,API 中为 string 或 number 以 OpenAPI 为准。 |
| `contractNumber` | string | 必填,业务唯一 | 合同编号;唯一索引。 |
| `customerId` | int64 | 必填,FK | 指向客户。 |
| `projectId` | int64 | 必填,FK | 指向项目;须属于同一 `customerId`(服务端校验)。 |
| `signedAt` | date (ISO-8601) | 必填 | 签订日。 |
| `effectiveAt` | date | 必填 | 生效日。 |
| `endAt` | date | 可选 | 结束/到期日;与「终止」语义可并存(终止优先于到期展示逻辑由前端/报表约定)。 |
| `status` | enum | 必填 | 见 §3;**API 与 JSON 仅使用英文枚举名**。 |
| `createdAt` / `updatedAt` | timestamp | 系统字段 | 审计展示可与 M10-F01 互补。 |
### 2.3 状态枚举(对齐 BPM 语义)
| BPM 语义(展示/字典) | API 枚举值 `ContractStatus` |
| ------------- | ------------------------ |
| 草稿 | `DRAFT` |
| 待生效 | `PENDING_EFFECTIVE` |
| 生效 | `EFFECTIVE` |
| 变更中 | `CHANGING` |
| 终止 | `TERMINATED` |
字典表可增加 `dict_type = CONTRACT_STATUS``code` 与上表一致,`label_zh` 为左列中文。
### 2.4 编辑规则(与状态机联动)
- **仅 `DRAFT` 状态**允许对**合同头字段**(§2.2 中除 `id``status``createdAt``updatedAt` 外,是否含 `contractNumber` 由产品确认;P0 建议 **DRAFT 下编号可改**,一旦进入 `PENDING_EFFECTIVE` 则编号只读)及**行项**做增删改。
-`DRAFT` 下对合同头或行项的 `PUT`/`POST`/`DELETE`**409 Conflict**,错误码见 §4.3。
- 状态变更**仅允许**通过 `**POST .../transition`**(§5.4),禁止在普通 `PUT` 请求体中直接改 `status`(若传入与当前相同可忽略或 400,建议 **忽略** 幂等)。
---
## 3. 领域模型:合同行(Contract Line
### 3.1 字段(P0
| 字段 | 类型 | 约束 | 说明 |
| ------------- | --------------- | ----- | ------------------------------------------------ |
| `id` | int64 | 主键 | |
| `contractId` | int64 | 必填,FK | |
| `lineNo` | int32 | 必填 | 行号,从 1 递增;**同一合同内唯一**;用于展示与排序。 |
| `skuCode` | string | 条件 | `**skuCode``productName` 至少填一个**(另一个可为 null)。 |
| `productName` | string | 条件 | 无 SKU 时的产品/包名称。 |
| `quantity` | decimal 或 int64 | 必填 | 数量;小数与否由产品线约定,P0 建议 `number` JSON。 |
| `unitPrice` | decimal | 可选 | 单价;敏感字段可按角色脱敏(见 §9)。 |
| `termNotes` | string | 可选 | 期限/席位/交付与授权口径等说明(与 M2-F03 摘要同源数据)。 |
### 3.2 排序
列表接口默认按 `lineNo` 升序;`lineNo` 可由客户端指定,冲突时 **409**,错误码建议 `CONTRACT_LINE_NO_CONFLICT`
---
## 4. 状态机
### 4.1 允许迁移(P0
以下「当前状态 → 目标状态」为 **允许**;未列出的单向迁移视为 **禁止**
| 当前状态 | 允许的目标状态 |
| ------------------- | --------------------------------------- |
| `DRAFT` | `PENDING_EFFECTIVE``TERMINATED` |
| `PENDING_EFFECTIVE` | `EFFECTIVE``DRAFT`(撤回至草稿)、`TERMINATED` |
| `EFFECTIVE` | `CHANGING``TERMINATED` |
| `CHANGING` | `EFFECTIVE``TERMINATED` |
| `TERMINATED` | (终态,不允许任何迁出) |
**说明**
- `PENDING_EFFECTIVE``DRAFT`:用于「待生效前撤回修改」;撤回后恢复 §2.4 头行可编辑。
- `CHANGING``EFFECTIVE`:变更完成、回到生效。
- P0 **不**实现变更子版本表(M2-F07 为 P1)。`**CHANGING` 下合同头与行项与普通非草稿状态相同:禁止 `PUT`/`POST`/`DELETE` 行与头**,仅允许 `POST .../transition`(例如转至 `EFFECTIVE``TERMINATED`);若业务需要「变更中改行」,留待后续迭代专用变更 API。
### 4.2 非法迁移响应
- HTTP `**409 Conflict`**。
- 响应体(与平台统一错误结构对齐;若尚无 RFC 7807,则用 JSON)示例:
```json
{
"code": "CONTRACT_ILLEGAL_STATUS_TRANSITION",
"message": "不允许从当前状态转换到目标状态",
"currentStatus": "EFFECTIVE",
"targetStatus": "DRAFT"
}
```
- `code` 固定 `**CONTRACT_ILLEGAL_STATUS_TRANSITION**`,便于前端与 SDK 分支处理。
- 日志与 M10:建议记一条 `audit_log``action = STATUS_TRANSITION_DENIED`(见 §6)。
### 4.3 非草稿编辑冲突
在不允许编辑的状态下修改头或行:
- HTTP `**409 Conflict**`
- `code`: `**CONTRACT_NOT_EDITABLE_IN_STATUS**`(或细分头/行码,P0 可合并为一个码)。
```json
{
"code": "CONTRACT_NOT_EDITABLE_IN_STATUS",
"message": "仅草稿状态可编辑合同及行项",
"status": "EFFECTIVE"
}
```
---
## 5. REST API 设计
**前缀**`/api/v1`(与 [CustomerController](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java) 一致)。
**认证**:与现有 `/api/v1/customers` 相同(JWT 等);未登录 **401**
**分页**:列表接口与 customers 对齐:`page`(从 0 默认)、`size`(默认 20,最大 200)。
### 5.1 合同
| 方法 | 路径 | 说明 |
| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/v1/contracts` | 分页列表;查询参数:`page``size``customerId?``projectId?``status?``keyword?`(匹配 `contractNumber`)。 |
| `POST` | `/api/v1/contracts` | 创建;**初始状态必须为 `DRAFT`**(请求体可不传 `status`,服务端默认 `DRAFT`;若传其它值 **400**)。 |
| `GET` | `/api/v1/contracts/{contractId}` | 详情;含行项可内嵌或仅用行接口拉取(P0 建议详情 **内嵌 `lines`** 减少往返)。 |
| `PUT` | `/api/v1/contracts/{contractId}` | 更新头字段;**仅 `DRAFT`****不得**携带 `status` 变更(忽略或 400,建议 **400** `CONTRACT_STATUS_USE_TRANSITION_ENDPOINT`)。 |
**创建请求示例**
```json
{
"contractNumber": "HT-2026-0001",
"customerId": 1001,
"projectId": 2002,
"signedAt": "2026-04-01",
"effectiveAt": "2026-04-15",
"endAt": "2027-04-14"
}
```
**详情响应示例(内嵌行)**
```json
{
"id": 3001,
"contractNumber": "HT-2026-0001",
"customerId": 1001,
"projectId": 2002,
"signedAt": "2026-04-01",
"effectiveAt": "2026-04-15",
"endAt": "2027-04-14",
"status": "DRAFT",
"lines": [
{
"id": 4001,
"lineNo": 1,
"skuCode": "SKU-PRO-01",
"productName": null,
"quantity": 10,
"unitPrice": 1999.00,
"termNotes": "1 年订阅,100 席位"
}
],
"createdAt": "2026-04-06T10:00:00Z",
"updatedAt": "2026-04-06T10:00:00Z"
}
```
### 5.2 合同行 CRUD
| 方法 | 路径 | 说明 |
| -------- | ----------------------------------------------- | ---------------------------- |
| `GET` | `/api/v1/contracts/{contractId}/lines` | 行列表(可选,若详情已内嵌则可与产品取舍)。 |
| `POST` | `/api/v1/contracts/{contractId}/lines` | 新增行;**仅合同为 `DRAFT`**。 |
| `GET` | `/api/v1/contracts/{contractId}/lines/{lineId}` | 单行。 |
| `PUT` | `/api/v1/contracts/{contractId}/lines/{lineId}` | 更新;**仅 `DRAFT`**。 |
| `DELETE` | `/api/v1/contracts/{contractId}/lines/{lineId}` | 删除;**仅 `DRAFT`**;成功 **204**。 |
**创建/更新行请求体示例**
```json
{
"lineNo": 2,
"skuCode": null,
"productName": "企业旗舰包",
"quantity": 5,
"unitPrice": null,
"termNotes": "按项目交付"
}
```
### 5.3 状态迁移
| 方法 | 路径 | 说明 |
| ------ | ------------------------------------------- | ----------------------------------------------------------------------------- |
| `POST` | `/api/v1/contracts/{contractId}/transition` | 请求体指定目标状态;校验 §4.1;成功返回更新后的合同 DTO(或 204 + LocationP0 建议 **200 + 完整合同 JSON**)。 |
**请求体**
```json
{
"targetStatus": "PENDING_EFFECTIVE"
}
```
**成功**`200`body 为合同资源(含 `lines` 若详情惯例如此)。
---
## 6. M10-F01`audit_log` 表与读 API
### 6.1 表设计(建议名 `audit_log`
| 列名 | 类型 | 说明 |
| --------------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
| `id` | bigserial PK | |
| `entity_type` | varchar(32) | 枚举:`**CUSTOMER`**、`**CONTRACT`**、`**CONTRACT_LINE**`(与产品 M10-F01「客户、合同、SN…」对齐;I3 落地三类,SN 后续迭代加 `LICENSE_SN` 等)。 |
| `entity_id` | int8 | 业务主键,与 `entity_type` 对应实体 id。 |
| `action` | varchar(64) | 如:`CREATE``UPDATE``DELETE``STATUS_TRANSITION``STATUS_TRANSITION_DENIED`。 |
| `field_name` | varchar(128) | 可选;字段级变更时记录英文名,如 `effectiveAt``status`。 |
| `old_value` | text | JSON 字符串;无则 NULL。 |
| `new_value` | text | JSON 字符串;无则 NULL。 |
| `actor_user_id` | int8 | 操作人用户 id。 |
| `created_at` | timestamptz | 不可改。 |
**索引**
- `(entity_type, entity_id, created_at DESC)` — 按对象拉时间线。
- 可选:`(actor_user_id, created_at DESC)` — 按人审计(M10-F02 预备)。
**写入时机(I3 最小集)**
- 合同:创建、头字段更新(`DRAFT`)、每次成功 `transition``field_name=status`,old/new 为枚举字符串)。
- 合同行:创建、更新、删除(`entity_type=CONTRACT_LINE``entity_id=lineId`)。
- 拒绝的非法迁移:可选记 `STATUS_TRANSITION_DENIED``new_value` 可存目标状态 JSON。
### 6.2 读 API
| 方法 | 路径 | 说明 |
| ----- | --------------- | ------- |
| `GET` | `/api/v1/audit` | 分页审计列表。 |
**查询参数(组合过滤)**
- `entityType` + `entityId`:精确到单一实体(如某合同、某行、某客户)。
- `**contractId`**:便捷范围 — 返回 `entity_type IN ('CONTRACT','CONTRACT_LINE')` 且(合同 id = contractId **或** 行所属 contractId = contractId)的记录;实现上可用 SQL `UNION` 或冗余 `contract_id` 列(**推荐冗余 `contract_id` 可空** 于 `audit_log` 以简化查询:合同与行写入时均填 `contract_id`,客户实体则只填 `entity_`*)。
**冗余列(可选但强烈推荐)**
| 列名 | 说明 |
| ------------- | ---------------------------------------------------------------------- |
| `contract_id` | 可空;`CONTRACT` 时等于 `entity_id``CONTRACT_LINE` 时为父合同 id`CUSTOMER` 可为空。 |
**响应项示例**
```json
{
"id": 900001,
"entityType": "CONTRACT",
"entityId": 3001,
"contractId": 3001,
"action": "STATUS_TRANSITION",
"fieldName": "status",
"oldValue": "\"DRAFT\"",
"newValue": "\"PENDING_EFFECTIVE\"",
"actorUserId": 42,
"createdAt": "2026-04-06T12:00:00Z"
}
```
说明:`old_value`/`new_value`**JSON text**(字符串加引号、对象则序列化),解析由前端或工具完成。
---
## 7. Webhook 事件 DTO v0.1Callback 关联预备)
**目标**:与平台合同/行主键对齐,便于 I5 Inbox 关联与幂等;**不**在本迭代要求完整比特 payload,仅 **最小信封**
### 7.1 建议信封字段(v0.1
| 字段 | 类型 | 必填 | 说明 |
| --------------- | ----------------- | --- | ----------------------------------------------- |
| `schemaVersion` | string | 是 | 固定 `**0.1`**(后续 `0.2``1.0` 递增)。 |
| `contractId` | int64 | 条件 | 与平台合同 id 一致;若事件仅到行级,仍建议带父 `contractId`。 |
| `lineIds` | array of int64 | 否 | 涉及的合同行 id 列表;无行级时可 `[]` 或省略。 |
| `eventType` | string | 否 | 预留,如 `contract.status.changed`(与 M5 字典统一可在 I5)。 |
| `occurredAt` | string (ISO-8601) | 否 | 事件发生时间。 |
**示例**
```json
{
"schemaVersion": "0.1",
"contractId": 3001,
"lineIds": [4001, 4002],
"eventType": "contract.status.changed",
"occurredAt": "2026-04-06T12:00:00Z"
}
```
**版本策略**Webhook 与平台共用 `schemaVersion` 语义;**I3 归档** JSON Schema 或本仓库 `contracts/` 下示例文件(与 [轨道 A 文档 §3](../tracks/01-backend-platform-webhook.md) 的 `schemaVersion` / `X-Event-Schema-Version` 一致)。
---
## 8. 安全与 RBAC(对齐产品粗粒度矩阵)
产品文档 [§13.3](../../chuangfei-platform-product-modules.md) 模块矩阵中 **M2 合同**、**M10 审计** 与角色关系如下(**R** 查看,**W** 新建编辑,**X** 导出)。用户提到的 `**DEVELOPER`** 在产品预置角色中为 `**DEV_SUPPORT`(研发/集成支撑)** — 实施时角色码二选一须与 IAM 统一,下文按矩阵描述 `**DEV_SUPPORT`**,若代码命名为 `DEVELOPER` 则与之对齐。
### 8.1 `SYS_ADMIN`
- **M2 合同**:矩阵为 **M / RWDX** — 含模块管理语义;企业可配置是否开放业务写。建议:**生产默认** `SYS_ADMIN` **仅 M11 管理面**,业务合同与 `**SALES`/`ORDER_SUPPORT`** 同权或按企业策略单独开 `contract:`* 权限码。
- **M10 审计**:矩阵为 **M**(管理面);若启用审计检索,建议单独挂 `audit:search`
### 8.2 `DEV_SUPPORT`(研发支撑 / 可与 `DEVELOPER` 对齐)
- **M2 合同**:**R**(只读)— 无合同创建/编辑/删除/导出,除非临时提权。
- **M10 审计**:**R** — 可检索审计(与矩阵「研发支撑」列一致)。
### 8.3 I3 API 权限码映射(建议)
| 接口 | 建议权限码 | 典型角色 |
| ---------------------- | -------------------------------------------------- | ----------------------------------------------------------------- |
| 合同与行 CRUD、`transition` | `contract:order:rw` | `SALES``ORDER_SUPPORT``SYS_ADMIN`(若开放业务写) |
| 合同只读列表/详情 | `contract:order:rw` 或细拆 `contract:order:read`Mid | 上表 + `DELIVERY``LICENSE_OPS``FINANCE_VIEW``EXEC_VIEW` 等只读列 |
| `GET /api/v1/audit` | `audit:search` | `COMPLIANCE``DEV_SUPPORT``FINANCE_VIEW`(导出另加 `audit:export` P1 |
**说明**:粗粒度阶段可将「读合同」与「写合同」合并为同一码,但 `**transition`** 必须与写权限同级或单独 `contract:order:transition`,避免只读角色误调;P0 可与 `contract:order:rw` 绑定。
---
## 9. OpenAPI 与交付物
- **快照路径**`[contracts/openapi/delivery-platform-api.json](../../../contracts/openapi/delivery-platform-api.json)`
- I3 完成定义:**上述路径、枚举、主要 DTO** 均须出现在该 OpenAPI 文件中,并与 `services/delivery-platform-api` 运行时 `/v3/api-docs``**OpenApiContractSnapshotTest`** 校验一致。
---
## 10. 修订记录
| 日期 | 说明 |
| ---------- | ---------------------------------------------------------- |
| 2026-04-06 | 初版:I3 合同/行项、状态机、REST、M10-F01、Webhook v0.1、RBAC、OpenAPI 引用。 |
+299
View File
@@ -0,0 +1,299 @@
# 迭代 I4 设计说明 — M3 交付批次与清单、M4 许可 SN 台账
> **迭代定位**:与 [并行迭代索引](../PARALLEL_ITERATION_INDEX.md) 中 **I4** 一致 — 平台后端 **M3 交付** + **M4 SN 录入/绑定/状态/手工回写**;前端 **交付页 + SN 页**;本仓库(SDK 工作区)以 **OpenAPI 契约与文档口径** 与 BP-10 对齐。
> **分支**`develop`。
> **已有实现锚点**(勿从零重设计,仅对齐与补全):Flyway `V4__delivery_batch_and_license_sn.sql``cn.craftlabs.platform.api.domain.DeliveryBatchStatus` / `LicenseSnStatus``web/dto` 下 `Delivery*`、`LicenseSn*`;审计常量 `AuditEntityTypes`、`AuditActions` 已含 `DELIVERY_BATCH`、`LICENSE_SN` 及对应动作。
---
## 1. I4 范围与 I3 / I5 边界
### 1.1 I4 **纳入**(本迭代 DoD
| 域 | 说明 |
|----|------|
| **M3 P0** | 交付批次(项目/可选合同、批次号、计划日、备注);交付清单行(描述、数量、可选合同行关联);批次状态 **PENDING → DELIVERED / CANCELLED** 及完成时间等侧写。对应产品:[M3-F01F05 P0](../../chuangfei-platform-product-modules.md#4-m3-交付管理)。 |
| **M4 P0** | SN 台账:全局唯一 `sn_code`**`project_id` 与/或 `contract_line_id` 绑定路径**;生命周期状态子集;激活备注/手工回写字段。对应产品:[M4-F01F05 P0](../../chuangfei-platform-product-modules.md#5-m4-授权与许可运营)。 |
| **M10-F01** | 交付批次、交付行、SN 的关键变更与状态迁移写入审计(与 I3 合同审计模式一致;实体类型见 §4)。 |
| **跨轨口径** | [I4 末同步点](../PARALLEL_ITERATION_INDEX.md#3-跨轨同步点必须对齐):**SN 绑定与「孤儿 SN」规则**文档化并三轨对齐;**交付门禁(M3-F07)与「孤儿 SN」强校验(M4-F02)** 在 **M11-F20 系统参数** 中预留为 **未来可配置项**(I4 可实现默认策略 + 配置占位,**不阻塞** I4 闭环)。 |
### 1.2 I3 **留给上游的契约**(I4 只消费,不重复建设)
- **合同 / 合同行**`project_id``contract_id`、行项主键;合同状态机已在 I3 冻结。交付行上的 `contract_line_id` 必须解析到合法合同行及其所属项目。
- **客户 / 项目**:批次必填 `project_id`;可选 `contract_id` 须属于同一项目。
### 1.3 I5 **明确不纳入 I4**(避免范围蔓延)
- **M5 Callback Inbox**、**M6 集成配置** 的持久化与页面(I5 起)。
- Webhook **生产级** 投递、幂等落库与平台 Inbox 全链路 E2E。
- **设备(M7)**、**比特控制台摘要链接(M4-F06)** 等可后续挂接;I4 仅保证 SN 主数据与绑定字段可关联到合同行/项目。
---
## 2. 数据模型锚点(与迁移一致)
表与字段以 `services/delivery-platform-api/src/main/resources/db/migration/V4__delivery_batch_and_license_sn.sql` 为准:
- **`platform_delivery_batch`**`project_id`(必填)、`contract_id`(可选)、`batch_code`(唯一)、`planned_delivery_date``status`(默认 `PENDING`)、`finished_at``remarks`
- **`platform_delivery_line`**:归属 `batch_id``description``quantity``contract_line_id`(可选),`sort_order`
- **`platform_license_sn`**`sn_code`(全局唯一)、`project_id` / `contract_line_id`(均可空于 DB 层,**业务校验见 §4**)、`status`(默认 `REGISTERED`)、`activation_remark`
### 2.1 状态枚举(API JSON 使用枚举名字符串)
**交付批次** `DeliveryBatchStatus`
| 值 | 说明 |
|----|------|
| `PENDING` | 未交付(默认) |
| `DELIVERED` | 已交付 |
| `CANCELLED` | 已取消 |
**许可 SN** `LicenseSnStatus`P0 子集,与代码枚举一致):
| 值 | 说明 |
|----|------|
| `REGISTERED` | 已登记 |
| `ISSUED` | 已发放 |
| `ACTIVATED` | 已激活 |
| `SUSPENDED` | 已冻结 |
| `REVOKED` | 已回收 |
非法状态迁移返回 **409**,错误码建议与合同类似:`DELIVERY_BATCH_ILLEGAL_STATUS``LICENSE_SN_ILLEGAL_STATUS`(具体以 OpenAPI 与实现为准)。
---
## 3. REST API 提案(前缀 `/api/v1`
与现有 Controller 风格一致:**`@RequestMapping("/api/v1/...")`**。下列路径为 I4 计划形态;JSON 字段名与当前 DTO **camelCase** 对齐(`projectId``contractId``batchCode` 等)。
### 3.1 交付批次 `delivery-batches`
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/delivery-batches` | 分页列表;查询参数建议:`projectId``contractId``status``keyword`(批次号)、`page``size`。 |
| `POST` | `/api/v1/delivery-batches` | 创建批次(体见下);**不含**行时可后续用行接口追加。 |
| `GET` | `/api/v1/delivery-batches/{id}` | 详情;可通过 `?includeLines=true` 或默认嵌套返回 `lines`(与 `DeliveryBatchResponse` 一致)。 |
| `PUT` | `/api/v1/delivery-batches/{id}` | 更新计划交付日、备注等非状态字段(`DeliveryBatchUpdateRequest`)。 |
| `PATCH` | `/api/v1/delivery-batches/{id}/status` | **仅**变更状态:`PENDING``DELIVERED``CANCELLED`;服务端可在此写入 `finishedAt`(如 `DELIVERED`)。 |
**嵌套 — 交付行 `lines`**
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/delivery-batches/{batchId}/lines` | 清单列表。 |
| `POST` | `/api/v1/delivery-batches/{batchId}/lines` | 新增一行。 |
| `PUT` | `/api/v1/delivery-batches/{batchId}/lines/{lineId}` | 更新行。 |
| `DELETE` | `/api/v1/delivery-batches/{batchId}/lines/{lineId}` | 删除行。 |
**创建批次请求体示例**
```json
{
"projectId": 1001,
"contractId": 2002,
"batchCode": "DLV-2026-0001",
"plannedDeliveryDate": "2026-04-15",
"remarks": "首批现场交付"
}
```
**更新批次请求体示例**`PUT /api/v1/delivery-batches/{id}`
```json
{
"plannedDeliveryDate": "2026-04-20",
"remarks": "延期一周"
}
```
**状态 PATCH 示例**`PATCH /api/v1/delivery-batches/{id}/status`
```json
{
"status": "DELIVERED"
}
```
**交付行写入示例**`POST` / `PUT` body`DeliveryLineRequest`
```json
{
"sortOrder": 1,
"description": "AI 推理节点 × 生产环境",
"quantity": 2,
"contractLineId": 3003
}
```
**详情响应片段**`DeliveryBatchResponse`,含行)
```json
{
"id": 1,
"projectId": 1001,
"contractId": 2002,
"batchCode": "DLV-2026-0001",
"plannedDeliveryDate": "2026-04-15",
"status": "PENDING",
"finishedAt": null,
"remarks": "首批现场交付",
"createdAt": "2026-04-06T08:00:00Z",
"updatedAt": "2026-04-06T08:00:00Z",
"lines": [
{
"id": 10,
"batchId": 1,
"sortOrder": 1,
"description": "AI 推理节点 × 生产环境",
"quantity": 2,
"contractLineId": 3003,
"createdAt": "2026-04-06T08:05:00Z",
"updatedAt": "2026-04-06T08:05:00Z"
}
]
}
```
### 3.2 许可 SN `license-sns`
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/v1/license-sns` | 分页列表;建议查询:`projectId``contractLineId``status``snCode`(精确或前缀按产品定)。 |
| `POST` | `/api/v1/license-sns` | 创建 SN`LicenseSnCreateRequest`);须满足 §4 绑定规则。 |
| `GET` | `/api/v1/license-sns/{id}` | 详情。 |
| `PUT` | `/api/v1/license-sns/{id}` | **全量/部分更新绑定字段**`projectId``contractLineId``activationRemark``LicenseSnUpdateRequest`);用于纠正绑定或手工回写备注。 |
| `PATCH` | `/api/v1/license-sns/{id}/status` | 变更 `LicenseSnStatus``LicenseSnStatusPatchRequest`);须校验合法迁移。 |
**创建 SN 请求体示例**
```json
{
"snCode": "SN-CRAFT-8F3A-0001",
"projectId": 1001,
"contractLineId": 3003,
"activationRemark": null
}
```
**更新绑定 / 备注示例**`PUT`
```json
{
"projectId": 1001,
"contractLineId": 3003,
"activationRemark": "客户现场激活成功,凭证号 xxx"
}
```
**状态 PATCH 示例**
```json
{
"status": "ACTIVATED"
}
```
**详情响应示例**`LicenseSnResponse`
```json
{
"id": 501,
"snCode": "SN-CRAFT-8F3A-0001",
"projectId": 1001,
"contractLineId": 3003,
"status": "ACTIVATED",
"activationRemark": "客户现场激活成功,凭证号 xxx",
"createdAt": "2026-04-06T09:00:00Z",
"updatedAt": "2026-04-06T10:00:00Z"
}
```
---
## 4. 校验规则与审计
### 4.1 交付批次
- **`projectId`**:必填;项目须存在。
- **`contractId`**:可选;若提供,合同须存在且 **`contract.project_id == batch.project_id`**。
- **`batchCode`**:全平台唯一(与表 `uq_platform_delivery_batch_code` 一致);冲突 **409**
- **状态**:仅允许自 `PENDING` 转至 `DELIVERED``CANCELLED``DELIVERED`/`CANCELLED` 视为终态,**禁止**再次变更(除非产品后续另定「重开」流程,不在 I4 P0)。
### 4.2 交付行
- **`contractLineId`**:可选;若提供,合同行须存在,且其所属合同的 **`project_id` 须与父批次 `project_id` 一致**(从而与批次可选 `contract_id` 兼容:若批次已指定合同,可额外校验行所属合同与批次合同一致,建议 **强一致**:行上合同行必须属于 `batch.contract_id``contract_id` 非空时)。
- **`quantity`**> 0(与 `DeliveryLineRequest``@DecimalMin` 一致)。
### 4.3 许可 SN
- **`snCode`**:必填;**全局唯一**;冲突 **409**
- **绑定路径****至少具备 `projectId``contractLineId` 之一**(可同时具备)。
- 若仅提供 **`contractLineId`**:服务端**派生** `project_id` = 该合同行所属合同的 `project_id`,并持久化(便于列表按项目过滤)。
- 若同时提供两者:须校验 **`contractLine` 派生出的 `project_id` 与请求 `projectId` 一致**,否则 **400**
- **孤儿 SN(与 I4 末同步对齐)**:
- **产品理想态(M4-F02)**:禁止无项目且无合同行路径的「裸 SN」。
- **M11-F20P1**:「孤儿 SN」**强校验**开关、**交付门禁**(M3-F07,例如仅已交付范围可发放/绑定)作为**系统参数**在架构上预留;I4 建议 **默认策略**:创建/更新时 **拒绝** 零绑定(与 P0 一致);若需「先录入后绑定」,可通过 **配置** 降级为 **警告 + 允许保存**(实现可放在应用服务层读取参数表,**表结构可 Mid 再做**,I4 先在文档与 OpenAPI `description` 中固定语义)。
- **状态迁移**:按 §2.1 枚举定义允许边(细表可在实现中维护;**禁止**随意跳转到任意状态)。
### 4.4 审计(M10-F01
沿用 I3 模式:**实体类型 + 动作 + 旧值/新值摘要 + 操作者 + 时间**。常量已存在于:
- `AuditEntityTypes.DELIVERY_BATCH``AuditEntityTypes.LICENSE_SN`
- `AuditActions``DELIVERY_BATCH_CREATED` / `UPDATED` / `STATUS_CHANGED``DELIVERY_LINE_ADDED` / `UPDATED` / `DELETED``LICENSE_SN_CREATED` / `UPDATED` / `STATUS_CHANGED`
若持久化审计行需扩展子类型或 payload 结构,**保持与合同审计同一表结构**,仅扩展 `entity_type` / `action` 枚举值;**无需**为 I4 另起实体类型常量,除非后续拆分「交付行」为独立可检索实体(当前可用 `DELIVERY_LINE_*` 动作挂 `entity_id` = line id`batch_id` 放上下文 JSON)。
---
## 5. 前端路由(Vue 3,布局子路由)
与 [轨道 B — I4](../tracks/02-frontend-platform-ui.md) 一致,路径挂在 **AppLayout** 之下(懒加载、`meta.title` / 权限码略)。
| 路由 | 页面职责 |
|------|----------|
| `/deliveries` | 交付批次列表、筛选、跳转新建/详情。 |
| `/deliveries/new` | 新建批次(项目/可选合同、批次号、计划日、备注);可内嵌或分步添加行。 |
| `/deliveries/:id` | 批次详情:行清单 CRUD;**状态按钮** 调用 `PATCH .../status`PENDING → DELIVERED/CANCELLED)。 |
| `/licenses/sn` | SN 台账列表;**孤儿 SN** 列表筛选或醒目标记(与后端配置/字段一致)。 |
| `/licenses/sn/new` | 新建 SN(录入 `snCode`、绑定项目/合同行)。 |
| `/licenses/sn/:id` | SN 详情:**PUT** 调整绑定与 `activationRemark`;**PATCH** 调整状态;展示简要时间线(可仅读审计或本地状态历史 Mid 增强)。 |
**契约顺序提醒**[轨道 B §4](../tracks/02-frontend-platform-ui.md)):Auth → Customer/Project → Contract → **Delivery/SN** → CallbackI4 页面依赖 I2/I3 主数据与合同行选择器。
---
## 6. I4 末同步点 Checklist(后端 + 前端 + SDK 文档)
以下为 [并行索引 I4 末](../PARALLEL_ITERATION_INDEX.md#3-跨轨同步点必须对齐) 的落地核对项。
### 6.1 后端(platform-api
- [ ] Flyway V4 表与索引已在各环境应用;DTO 与 OpenAPI 字段一致。
- [ ] §3 路径已实现或通过兼容别名暴露;错误码与 409 语义与合同模块一致。
- [ ] §4.1~§4.3 校验全覆盖(含合同行与项目一致性、SN 全局唯一、绑定派生)。
- [ ] **孤儿 SN**:默认策略与(可选)M11-F20 配置占位行为**文档化并在代码注释或配置类中可定位**。
- [ ] **交付门禁(M3-F07**:与 SN 创建/绑定相关的规则在代码中**可插拔**或明确「I4 硬编码默认 + I5+ 读配置」的 TODO 与 Owner。
- [ ] 审计:`DELIVERY_BATCH` / `LICENSE_SN` / 交付行动作写入 M10-F01 存储。
- [ ] `contracts/openapi/delivery-platform-api.json` 更新并通过 `OpenApiContractSnapshotTest`
### 6.2 前端(delivery-platform-ui
- [ ] §5 路由与菜单可达;RBAC 权限码与后端对齐。
- [ ] 交付详情状态操作仅展示合法迁移;错误态与 409 提示一致。
- [ ] SN 新建/编辑:合同行选择器与项目联动;**孤儿**场景 UI 与后端策略一致(禁止或警告)。
- [ ] E2E P0`交付 → SN 录入/绑定 → 状态/备注回写`(与轨道 B I4 DoD 一致)。
### 6.3 客户端 SDK 工作区(本仓库)
- [ ] OpenAPI 快照与 [contracts/README.md](../../contracts/README.md) 说明含 Delivery/SN 标签。
- [ ] [tracks/03-client-sdk.md](../tracks/03-client-sdk.md) 或等价文档中 **BP-10 与平台对象口径** 补充:交付批次、SN 在集成叙事中的位置(**不实现**平台 REST 客户端亦可,但**文档**须与 I4 契约一致)。
- [ ] **M11-F20**:在 SDK/集成文档中标注为 **后续配置项**(门禁、孤儿强校验),避免集成方误假设 I4 已暴露该 HTTP API。
---
## 7. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:I4 范围与边界、REST 提案、校验与审计、前端路由、I4 末三轨 checklist。 |
+229
View File
@@ -0,0 +1,229 @@
# 迭代 I5 / I6 设计说明 — M5 Callback Inbox、M6 集成最小面、Webhook 生产链与 UAT 冻结
> **仓库**`craftlabs-authorization-sdk`(分支 `develop`)。
> **角色**:解决方案架构设计稿;**不**在本任务中落地代码。
> **实现锚点**(与现有模式一致):`delivery-platform-api` 使用 Flyway `V5__…` 起(当前末版为 `V4__delivery_batch_and_license_sn.sql`)、`AuditService` + `AuditEntityTypes` / `AuditActions`、`ApiExceptionHandler`、公开业务 API 经 `SecurityConfig` + `JwtAuthenticationFilter``Authorization: Bearer`);OpenAPI SSOT 为 [`contracts/openapi/delivery-platform-api.json`](../../../contracts/openapi/delivery-platform-api.json) 与 `OpenApiContractSnapshotTest`。
> **Webhook 工程名**:本工作区已实现为 `license-webhook-ingress`[`services/README.md`](../../../services/README.md)`webhook_callback_receipt`、`Idempotency-Key`)。**I7**:平台 HTTP 投递经 `webhook_platform_delivery` 异步出库,详见 [I7_DESIGN.md](./I7_DESIGN.md)。
---
## 0. 上游文档走查(按路径引用,不全文摘录)
| 文档 | 与本设计的关系(摘要) |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [docs/engineering/PARALLEL_ITERATION_INDEX.md](../PARALLEL_ITERATION_INDEX.md) | 定义三轨并行;**I5** 为 **Callback + Schema** 硬耦合周;**I6** 为 **UAT** 与 SDK 冻结;**I7** 起 Callback 权限与异步投递见索引表。同步点含 **I5 起**Callback payload / inbox DTO、**Idempotency-Key**、Webhook→平台投递方式;**I6**UAT 场景、`VITE_API_BASE`、两枚 Fat JAR 与 SDK 版本。 |
| [docs/engineering/tracks/01-backend-platform-webhook.md](../tracks/01-backend-platform-webhook.md) | **I5 DoD**E2E「模拟 Callback → 平台 DB 一条 Inbox」;**§3 Webhook↔平台**`schemaVersion` / `X-Event-Schema-Version`、幂等 `(source_system, external_message_id)``POST /internal/v1/callback-events` 或 MQ、对比特 **2xx** 须在持久化或可靠入队**之后**。 |
| [docs/engineering/tracks/02-frontend-platform-ui.md](../tracks/02-frontend-platform-ui.md) | **I5 路由**`/callbacks``/integration/environments``product-lines`(本文统一为 `/integration/product-lines`);组件:`CallbackInboxTable``CallbackPayloadViewer`(脱敏);与 Webhook 联调或 staging。 |
| [docs/engineering/tracks/03-client-sdk.md](../tracks/03-client-sdk.md) | **I5****Schema + `AuthConfigs` + examples** 与 **BP-10** 同步为硬交付;**I6**:冻结 SDK 版本、CHANGELOG、**BitAnswer 兼容矩阵**UAT 周禁止 MAJOR Schema。 |
| [docs/chuangfei-platform-product-modules.md](../../chuangfei-platform-product-modules.md) §67 | **M5 P0**:收件箱列表/详情/处理状态/关联失败兜底/事件类型字典(M5-F01~F05 等);**M6 P0**:产品线(M6-F01)、环境维度与 `bitanswer.url`M6-F02);P1 如比特 ID 映射、JSON 模板、发布记录等 **I5 MVP 可裁减**。 |
| [docs/chuangfei-platform-bpm-and-roadmap.md](../../chuangfei-platform-bpm-and-roadmap.md) | **BP-06**:Webhook 验签 → 落库 → 解析关联 → Ops 处置;重复投递幂等。**BP-10**:M6 与 Schema、客户端配置发布链路;**依赖**本仓 `schemas/craftlabs-auth-config.schema.json`。 |
---
## Part A — I5:本单体工作区内的 MVP 切片
### A.1 问题与目标结果
| 维度 | 说明 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **业务问题** | 比特规则 **HTTPS Callback****不断链、可审计、可运营处置**;平台侧需统一收件箱,避免只在 Webhook 边缘落库不可见。 |
| **I5 目标结果(E2E** | **模拟或真实 Callback**`license-webhook-ingress` →(持久化收据后)**投递** → `delivery-platform-api` **Inbox 表出现一行**;运营账号用 JWT 在 UI **列表可见、详情可打开**。 |
| **幂等** | 与轨道 A 一致:**`Idempotency-Key`**HTTP 头)+ 比特稳定 **`message_id`**(或等价字段);DB **唯一约束 `(source_system, external_message_id)`**;重复请求 **不重复插入**、返回与首次一致的接受语义(HTTP 200 + 相同 `inboxId` 或约定 DTO)。 |
| **schemaVersion** | 事件体或头携带 **`schemaVersion`**(与轨道 A 的 `X-Event-Schema-Version` 二选一或并存,**须写 ADR 定一种主口径**);平台拒绝无法识别的 major 版本时返回 **4xx** 并记录可观测字段,避免静默损坏。 |
### A.2 数据模型(`delivery-platform-api`PostgreSQL + Flyway
命名与现有表一致采用 **`platform_*` 前缀**(见 `V1``V4` 迁移)。
#### A.2.1 `platform_callback_inbox`M5 Inbox P0
| 列(示例) | 类型/说明 |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `id` | UUID / BIGSERIAL PK |
| `source_system` | VARCHAR,如 `BITANSWER` |
| `external_message_id` | VARCHAR,比特侧稳定消息 ID |
| **唯一约束** | `UNIQUE (source_system, external_message_id)` |
| `schema_version` | VARCHAR,与 payload 解析版本对齐 |
| `event_type` | VARCHAR,与 M5-F05 字典一致(如 `sn:pre_activate`) |
| `status` | ENUM/VARCHAR**`PENDING` / `PROCESSED` / `FAILED` / `IGNORED`**(对应产品「待处理、已处理、失败、忽略」) |
| `raw_payload` | **JSONB**(或 TEXT + 大小上限);UI **脱敏展示** |
| `idempotency_key` | VARCHAR NULL,审计与排障 |
| **关联(均可 NULL,支撑 M5-F04** | `license_sn_id``platform_license_sn``contract_id` / `project_id`(若已有表);解析字段如 `sn_code``mid` 等冗余列便于列表筛选 |
| `product_line_id` / `integration_environment_id` | FK → M6 最小表(可选,便于按产品线/环境筛选) |
| `received_at` | 平台收件时间 |
| `processed_at` / `processed_by_user_id` | 运营处置 |
| `failure_reason` / `operator_note` | 文本,P1 可扩展「失败原因分类」字典 |
| 标准审计 | 与现有实体一致可补充 `created_at`/`updated_at`;关键状态迁移建议走 **`AuditService`**(扩展 `AuditEntityTypes` / `AuditActions` |
#### A.2.2 M6 最小只读支撑表
仅包含 **I5 UI 与 Inbox 筛选** 所需字段;**不做** M6-F03F06 全量。
| 表 | P0 字段(示例) |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **`platform_product_line`** | `id``code`(唯一)、`name``description` NULL、启用标志 |
| **`platform_integration_environment`** | `id``code`(唯一)、`name``bitanswer_base_url`(对应 M6-F02)、`kind`DEV/TEST/STAGING/PROD 等枚举)、可选 `product_line_id` 或后续多对多(MVP 可 **单列 FK** 简化) |
**MVP 裁减**:比特产品/模版/业务 ID 映射(M6-F03)、特征映射(M6-F04)、JSON 模板与发布记录(M6-F05/F06**推迟**至 V1.1 或 Mid,除非比特联调硬依赖。
### A.3 公开 REST APIJWT`/api/v1`
与现有 Controller 风格一致。**RBAC(以代码为准,I7 已落地)**:
- **Callback Inbox**`GET/PATCH /api/v1/callback-inbox*`):**`OPS`** + **`SYS_ADMIN`****`DEVELOPER`** 无);见 [`CallbackInboxController`](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java)。
- **M6 只读**`/api/v1/integration/*`):**`OPS`** + **`SYS_ADMIN`** + **`DEVELOPER`**。
- **其余 I2I4 等业务 MVP**:仍为 **`SYS_ADMIN`** / **`DEVELOPER`** 演示账号模型;[`AuthController`](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java) 含 `ops/ops`
| 方法 | 路径 | 说明 |
| ------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/v1/callback-inbox` | 分页;查询建议:`status``eventType``snCode``projectId``productLineId``environmentId``from`/`to``receivedAt`)、`page``size` |
| `GET` | `/api/v1/callback-inbox/{id}` | 详情;含 `rawPayload`(或单独 `GET .../payload` 若需权限分级) |
| `PATCH` | `/api/v1/callback-inbox/{id}/status` | 运营状态迁移:**`PENDING``PROCESSED` / `FAILED` / `IGNORED`**;非法迁移 **409** + 业务错误码(与合同/交付模式一致,经 **`ApiExceptionHandler`** |
| `PATCH` | `/api/v1/callback-inbox/{id}/link`(可选) | 人工挂接:`licenseSnId` / `projectId` / `contractId` 等,支撑 M5-F04 |
**可选**`POST /api/v1/callback-inbox/simulate`**仅非生产**,对应 M5-F10 P2 — **I5 若排期紧可不做**,改用 curl → Webhook → 平台链)。
**M6 只读**
| 方法 | 路径 | 说明 |
| ----- | ---------------------------------------------------------------- | ------ |
| `GET` | `/api/v1/integration/environments` | 列表/分页 |
| `GET` | `/api/v1/integration/product-lines` | 列表/分页 |
| `GET` | `/api/v1/integration/environments/{id}``.../product-lines/{id}` | 详情(按需) |
写接口(维护环境/产品线)MVP 可 **仅种子数据 + Flyway****管理员 POST**(若 I5 周可交付则加 `POST/PUT`,否则 **推迟**)。
### A.4 内部 API(平台服务间,`license-webhook-ingress` → `delivery-platform-api`
| 项 | 说明 |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **路径** | `POST /internal/v1/callback-events` |
| **认证(MVP 推荐)** | 共享密钥:**`X-Platform-Internal-Token`**(或 `Authorization: Bearer <internal>`),配置与 `PLATFORM_JWT_SECRET` 分离;**生产建议路线图**:mTLS 或双向签名(文档中注明 **I6 Runbook:轮换步骤**)。 |
| **幂等** | 请求头 **`Idempotency-Key`** + 体 **`sourceSystem` + `externalMessageId`** 与 Inbox 唯一键一致;重复 POST → **200** 且 body 指向同一 `inboxId`。 |
| **行为** | 校验 `schemaVersion` → 插入或跳过(幂等)→ 尝试解析并 **填充关联列**(失败则 `status=PENDING` 保留人工挂接)。 |
**请求 / 响应 JSON 示例(示意)**
```http
POST /internal/v1/callback-events
Idempotency-Key: wh_01JABC...
X-Platform-Internal-Token: <redacted>
Content-Type: application/json
```
```json
{
"schemaVersion": "1.0",
"sourceSystem": "BITANSWER",
"externalMessageId": "msg_01JABC",
"eventType": "sn:post_activate",
"receivedAt": "2026-04-06T12:00:00Z",
"rawPayload": { "sn": "SN-001", "mid": "..." },
"webhookReceiptId": "optional-uuid-from-webhook-db"
}
```
```json
{
"inboxId": "550e8400-e29b-41d4-a716-446655440000",
"duplicate": false
}
```
### A.5 `license-webhook-ingress` 链路
| 步骤 | 说明 |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 1 | **验签 / token**(现有 `x-bitanswer-token``CRAFTLABS_WEBHOOK_EXPECTED_TOKEN`) |
| 2 | **持久化收据**(现有 **`webhook_callback_receipt`** + **`Idempotency-Key`** 幂等) |
| 3 | **平台投递(I7**:首单收据后写入 **`webhook_platform_delivery`**`PENDING`),由调度器 **`POST /internal/v1/callback-events`**(退避重试、`SENT`/`DEAD`);贯通 **`traceparent` / `X-Request-Id`**(轨道 A §3 |
| **对比特的 HTTP 响应** | 与轨道 A 一致:**2xx 须在收据已持久化(或可靠入队)之后**再返回。**实现**:收据落库 + 出站行入队后对比特 **2xx**,平台 HTTP 在后台执行。 |
| **平台非 2xx** | 出库任务重试;超限 **`DEAD`**(见 [RUNBOOK §10.5](../../../services/RUNBOOK.md))。**不**因平台暂时不可用而对已持久化收据重复向比特报错(若已 2xx)。 |
### A.6 OpenAPI 与 springdoc
| 项 | 说明 |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **SSOT** | 更新 [`contracts/openapi/delivery-platform-api.json`](../../../contracts/openapi/delivery-platform-api.json):仅 **公开** `/api/v1/callback-inbox*``/api/v1/integration/*` 路径、`components/schemas`、错误码与 `security`Bearer JWT)。 |
| **内部路由** | **`/internal/**` 建议排除在默认 springdoc 分组之外**,或打上 tag **`internal`** 且 **生产禁用**该分组(与「对外契约」分离,避免集成方误用)。 |
| **校验** | `UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest`(见 [`contracts/README.md`](../../../contracts/README.md))。 |
### A.7 前端(`web/delivery-platform-ui`
| 路由 | 页面职责 |
| ---------------------------- | ---------------------------------------------------- |
| `/callbacks` | Inbox 列表、`CallbackInboxTable`;跳转详情 |
| `/callbacks/:id` | 详情 + **`CallbackPayloadViewer`(脱敏)**;状态 PATCH、可选人工挂接 |
| `/integration/environments` | M6 环境只读表 |
| `/integration/product-lines` | 产品线只读表 |
路由 meta**I7** 与后端一致——Callback 仅 **`SYS_ADMIN`/`OPS`**;集成页含 **`DEVELOPER`**;菜单与首页按角色裁剪(见 [轨道 B I7](../tracks/02-frontend-platform-ui.md))。
### A.8 SDK 轨道(本仓库,I5 硬交付清单)
- **Schema**`schemas/craftlabs-auth-config.schema.json` 等 — 若 BP-10 变更类型触及「平台导出 → Schema → 客户端」,按 [轨道 C §3](../tracks/03-client-sdk.md) bump 规则执行。
- **Java `AuthConfigs`**:与 Schema 同步(表见轨道 C BP-10)。
- **`examples/`**:与最新字段及环境变量说明一致;CI 与 Schema 校验对齐。
- **文档**:明确 **SDK 版本线 ≠ 平台 Fat JAR 版本**;引用 BPM **BP-10** 与产品 M6-F01/F02 口径。
- **不做**Native 与 Webhook 运行时耦合;平台不嵌入 JNI。
---
## Part B — I6:规划与收口文档
| 主题 | 内容要点 | 执行文档 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **UAT 门禁** | 跑通 **BP-0106、11** 主链路(与 [轨道 B I6](../tracks/02-frontend-platform-ui.md) E2E 一致);**Callback** 场景含重复投递幂等、关联失败人工挂接。 | [I6_CLOSEOUT.md §2](./I6_CLOSEOUT.md) |
| **冻结清单** | **SDK**:定版 tag、CHANGELOG、**BitAnswer 兼容矩阵**(轨道 C);**OpenAPI** 快照冻结;**两 JAR** 版本与镜像标签可追踪;前端 **`VITE_API_BASE`** 环境矩阵文档化。 | [I6_CLOSEOUT.md §3~§4](./I6_CLOSEOUT.md) |
| **Runbook** | [`services/RUNBOOK.md`](../../../services/RUNBOOK.md)**内部 token 轮换**、Webhook→平台连通性检查、DB 迁移顺序(`flyway_platform_api` / `flyway_webhook`)。 | RUNBOOK **§10** |
| **安全加固** | **安全响应头**、Cookie/Session 策略(若 Mid 前仍为 JWT 则文档化 **仅 Bearer**)、依赖扫描与已知 CVE 处理;**禁止** I6 周排入大块新功能(仅缺陷与加固)。 | 平台 `SecurityConfig` + Runbook + [CI 依赖/CVE 扫描](../../../.github/workflows/ci-security.yml) |
| **实现审核** | I1~I6 对照设计与三轨道文档 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) |
---
## Part C — 实现顺序与 MVP 切割
1. **平台 DBFlyway `V5+`**`platform_callback_inbox` + M6 两表 + 必要索引与外键;种子数据(环境/产品线)可选。
2. **平台内部 API**`POST /internal/v1/callback-events` + 幂等与 `schemaVersion` 校验 + **`AuditService` 钩子**(状态/关联变更)。
3. **平台公开 API**`GET/PATCH` callback-inbox、M6 只读 GET**统一异常**经 `ApiExceptionHandler`
4. **OpenAPI 快照** 与契约测试更新。
5. **Webhook**:收据落库后 **入队平台投递****I7**`webhook_platform_delivery` + 调度);配置项:`PLATFORM_INTERNAL_BASE_URL``PLATFORM_INTERNAL_TOKEN`
6. **集成测试**Testcontainers + 双模块或 Compose(与轨道 A **I5+ `cross-service-it`** 建议一致)。
7. **前端**:路由与表格/详情/脱敏展示;联调 staging。
8. **SDK / Schema / examples**:按 BP-10 终版对齐并过 CI。
**MVP 明确推迟(若全量 M5/M6 过大)**
| 推迟项 | 说明 |
| ---------- | ------------------------------------------ |
| M6-F03F09 | 比特 ID 映射、特征映射、模板库、发布记录、影响分析 |
| M5-F06~F09 | 失败分类字典、批量重试 UI、积压监控、M8 待办联动 |
| M5-F10 | 模拟投递 UI(可用 curl/Postman 代替) |
| MQ 投递 | 保留 HTTP MVPMQ + 消费者为 ADR 备选(轨道 A 已列 B 方案) |
---
## Part D — 可追溯性(设计章节 → 产品功能点)
| 设计章节 | 产品模块 / 功能点(适用处) |
| ------------------------------- | ------------------------------ |
| A.1 幂等与 schemaVersion | M5 运营基础;BP-06 |
| A.2.1 `platform_callback_inbox` | **M5-F01F04**(列表、详情、状态、关联兜底) |
| A.2.1 `event_type`、字典 | **M5-F05** |
| A.2.2 产品线 / 环境表 | **M6-F01、M6-F02** |
| A.3 公开 REST | **M5-F01F03**;人工挂接 **M5-F04** |
| A.4 内部 API | BP-06 入站;与轨道 A Webhook↔平台契约 |
| A.5 Webhook 转发 | BP-06 步骤①②;**不丢链** |
| A.8 SDK / Schema | **BP-10**;**M6** 配置治理(文档与校验链) |
| Part B I6 | BP-0106、11 UATM11 安全与运维 |
---
## 修订记录
| 日期 | 说明 |
| ---------- | ----------------------------------------------------------------- |
| 2026-04-06 | 初版:I5/I6 架构设计,对齐并行索引、三轨道文档与产品 M5/M6 P0。 |
| 2026-04-06 | Part B 关联 I6 收口文档(CLOSEOUT / IMPLEMENTATION_REVIEW)与 RUNBOOK §10。 |
| 2026-04-06 | 固化 Markdown 引用与强调;Part B 补充 CI 依赖/CVE 扫描入口。 |
| 2026-04-06 | 对齐 **I7**A.3/A.5/A.7 权限与异步出库;OpenAPI/链接格式修正。 |
@@ -0,0 +1,69 @@
# I6 收口执行包(UAT 门禁、冻结清单、运维)
> **定位**:在 [I5_I6_DESIGN.md](./I5_I6_DESIGN.md) **Part B** 规划基础上,把 **I6 周可执行项** 收束为检查表与环境矩阵。
> **前置**I5 代码路径已合入 `develop`Callback Inbox、内部投递、Webhook 转发、集成只读 API、前端路由)。
> **实现对照审核**[I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md)。
---
## 1. 架构师任务(I6 周入口)
| 次序 | 产出 | 责任 |
|------|------|------|
| 1 | 本文件 + Runbook §10 运维段落 | 架构 / Tech Lead |
| 2 | 后端:安全响应头、配置与观测无回归 | 后端 |
| 3 | 前端:`VITE_API_BASE`、首页 UAT 导航、构建说明 | 前端 |
| 4 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) 关闭 I1–I6 偏差项 | 架构审核 |
---
## 2. UAT 门禁(P0 场景)
> 与 [轨道 B §2 I6](../tracks/02-frontend-platform-ui.md)「BP-0106+11」一致;以下按 **本工作区已实现能力** 细化。
| # | 场景 | 预期 | 备注 |
|---|------|------|------|
| U1 | 登录 JWT | `admin/admin` 登录后访问受保护路由 | I1 |
| U2 | 客户 → 项目 | CRUD 与列表 | I2 |
| U3 | 合同 | 创建、行项、状态迁移合法/非法 | I3 |
| U4 | 交付批次 → 许可 SN | 创建、详情、状态 | I4 |
| U5 | Callback 全链 | Webhook 收据后转发 → 平台 Inbox 一行;UI 列表/详情、状态 PATCH、可选 link | I5;需配置内部 Token 与 base-url |
| U6 | 集成只读 | 环境/产品线列表与详情 | I5 |
| U7 | 重复幂等 | 同 `Idempotency-Key` / 同 `externalMessageId` 不重复插入;内部 API 返回 `duplicate: true` | I5 |
| U8 | 401 统一 | Token 失效回登录带 redirect | I1 |
**UAT 退出条件**:上表 **无 P0 缺陷**;已知 P1/P2 记入工单或 [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) 「已知局限」。
---
## 3. 冻结清单(I6 末)
| 项 | 动作 |
|----|------|
| **OpenAPI** | `contracts/openapi/delivery-platform-api.json``OpenApiContractSnapshotTest` 一致;打 tag 可追溯 |
| **两枚 Fat JAR** | `delivery-platform-api``license-webhook-ingress` 版本与镜像标签写入发布说明 |
| **前端** | 生产构建使用明确 `VITE_API_BASE`(见 §4 |
| **SDK(本仓)** | 定版 tag、`CHANGELOG`、[轨道 C](../tracks/03-client-sdk.md) **兼容矩阵** 填齐;**I6 周内禁止 MAJOR Schema**(与设计一致) |
| **内部 Token** | 平台 `PLATFORM_INTERNAL_TOKEN` / `CRAFTLABS_PLATFORM_INTERNAL_TOKEN` 与 Webhook `craftlabs.platform.internal.token` **同值**;轮换走 [RUNBOOK §10](../../../services/RUNBOOK.md) |
| **依赖 / CVE** | PR 合并前 **`ci-security`** 通过:Trivy 扫描 `services/``java/``CRITICAL`+`HIGH`,仅已修复项见 workflow);`npm audit --audit-level=high``web/delivery-platform-ui`)。**Dependabot** 周度提 PR`.github/dependabot.yml`)。 |
---
## 4. 前端 `VITE_API_BASE` 环境矩阵
| 环境 | 示例 `VITE_API_BASE` | 说明 |
|------|----------------------|------|
| 本地开发 | (不设) | Vite 代理 `/api``127.0.0.1:8080` |
| Staging | `https://platform-api.staging.example.com` | 无尾部斜杠;axios 请求 `/api/v1/...` |
| 生产 | `https://platform-api.example.com` | 同源反代时可设为空,由 Nginx 处理 `/api` |
构建:`VITE_API_BASE=https://… npm run build`。详见 `web/delivery-platform-ui/README.md`
---
## 5. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | I6 收口执行包初版:UAT 表、冻结清单、VITE 矩阵。 |
| 2026-04-06 | 冻结清单补充 CI 依赖/CVE 与 Dependabot。 |
@@ -0,0 +1,91 @@
# 架构师审核:I1~I6 实现对照(本工作区 `develop`)
> **方法**:以 [并行索引](../PARALLEL_ITERATION_INDEX.md)、各迭代设计稿(如 [I5_I6_DESIGN.md](./I5_I6_DESIGN.md))、三轨道文档([01](../tracks/01-backend-platform-webhook.md) / [02](../tracks/02-frontend-platform-ui.md) / [03](../tracks/03-client-sdk.md))为预期,对照仓库 **当前实现** 做收口审计。
> **范围**`services/delivery-platform-api`、`services/license-webhook-ingress`、`web/delivery-platform-ui`、`contracts/openapi`、`schemas/` 及 CI/Enforcer 门禁;**不**评价未在本仓的独立 Fat JAR 发布物。
---
## 1. 总评
| 维度 | 结论 |
|------|------|
| **迭代完整性** | I1I5 主路径已在前后端与 Webhook 贯通;I6 以 **UAT 文档、Runbook、安全头、前端生产基址与导航** 收口,符合 Part B「无大块新功能」原则。 |
| **契约** | 公开 API 以 `contracts/openapi/delivery-platform-api.json` 为 SSOT`OpenApiContractSnapshotTest` 守门;`/internal/**` 排除默认 springdoc 分组,与设计一致。 |
| **风险** | 内部 Token 为 MVP 共享密钥;生产应规划 mTLS/签名(设计已提示)。Webhook 转发失败仅日志重试次数用尽,**无持久化 DLQ**,适合 I6 后需求排期。 |
---
## 2. 分项审核
### 2.1 I1 — 身份、JWT、壳层
| 预期 | 实现 | 偏差 |
|------|------|------|
| Bearer JWT、401 入口、路由 RBAC | `JwtAuthenticationFilter``SecurityConfig` JWT 链、`router` meta.roles | 无 |
| 登录与 ping | `AuthController``/api/v1/ping`、前端 `LoginView` | 无 |
### 2.2 I2 — M1 主数据
| 预期 | 实现 | 偏差 |
|------|------|------|
| 客户/项目 CRUD + 字典 | Controllers + `CustomersView` / `ProjectsView` | 无结构性偏差(字段级以 OpenAPI 为准) |
### 2.3 I3 — M2 合同 + M10-F01
| 预期 | 实现 | 偏差 |
|------|------|------|
| 合同与行项、状态机服务端校验 | Contract API + 前端向导/详情 | 历史曾出现前后端动词不一致,**当前以 PATCH status 等与后端对齐**(需在 UAT 再点检) |
| 审计 | `AuditService``AuditEntityTypes` / `AuditActions` | 已扩展 Callback 相关常量(I5 |
### 2.4 I4 — M3/M4 交付与 SN
| 预期 | 实现 | 偏差 |
|------|------|------|
| 交付批次、行、许可 SN | 对应 API + `DeliveriesView` / SN 向导与列表 | 与设计 P0 一致 |
### 2.5 I5 — M5 Inbox、M6 只读、Webhook 链
| 预期(见 I5_I6_DESIGN Part A | 实现 | 偏差 / 备注 |
|--------------------------------|------|----------------|
| `platform_callback_inbox` + M6 表 + Flyway V5 | 已落地 | 无 |
| 公开 `GET/PATCH` callback-inbox、integration GET | 已落地 | 无 |
| `POST /internal/v1/callback-events` + 内部 Token | `CallbackInternalController``InternalTokenAuthenticationFilter` | 无 |
| 幂等 `(sourceSystem, externalMessageId)` + 重复返回 `duplicate` | `CallbackEventIngestService` | 无 |
| `schemaVersion` major 校验 | `SUPPORTED_SCHEMA_MAJOR = 1` | 与设计「拒绝无法识别的 major」一致 |
| Webhook:收据后转发、trace 头、有限重试 | `PlatformCallbackForwarder` | **MVP**:同步线程内 3 次退避;与设计「异步重试」相比属简化,Runbook 已说明可配置性 |
| 前端 `/callbacks`、脱敏 | `redactPayload.js`、Inbox 视图 | 建议 UAT 确认敏感字段字典与产品一致 |
| OpenAPI 仅公开路由 | 内部 `@Hidden` + `paths-to-exclude` | 无 |
### 2.6 I6 — UAT、冻结、加固
| 预期(Part B + I6_CLOSEOUT | 实现 | 偏差 |
|-------------------------------|------|------|
| UAT 检查表 | [I6_CLOSEOUT.md](./I6_CLOSEOUT.md) | 过程性文档;**不替代**自动化 E2E |
| Runbook:内部 Token、连通性、轮换 | [RUNBOOK.md §10](../../../services/RUNBOOK.md) | 无 |
| 安全响应头与依赖门禁 | `SecurityConfig.apiHeaders``X-Content-Type-Options`、frame deny、Referrer-Policy**`ci-security`**Trivy + `npm audit`)与 **Dependabot** | **HSTS** 依设计交由边缘层 |
| 前端生产 `VITE_API_BASE` | `main.js` + README + CLOSEOUT §4 | 无 |
| 首页全链路导航 | `HomeView` 模块链接 | 满足轨道 B I6「全链路导航」最低要求 |
### 2.7 轨道 CSDK / Schema
| 预期 | 实现 | 偏差 |
|------|------|------|
| I5 硬交付 Schema/示例/AuthConfigs | 以本仓 `schemas/``java/`、CI 为准(参见轨道 C 文档) | **I6 冻结**需在发布周由发布 Owner 执行 tag/CHANGELOG/矩阵,**非本审计单次提交所能证明** |
---
## 3. 建议进入 I7 / V1.1 前跟踪项
1. **Webhook 投递**:评估异步队列或 Outbox,补 **DLQ 指标**(设计 A.5、M5-F08)。
2. **内部认证**:mTLS 或请求签名;Token 多版本滚动。
3. **Playwright/Cypress**:将 I6_CLOSEOUT §2 固化为流水线冒烟。
4. **权限细化**M5 运营接口由 SYS_ADMIN/DEVELOPER 收口为 Ops 角色(设计 A.3 已注明 I7+)。
---
## 4. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:I6 架构师审核,对照 I5_I6_DESIGN 与三轨道文档。 |
| 2026-04-06 | I6 表:补充 CI 依赖/CVE 与 Dependabot。 |
+122
View File
@@ -0,0 +1,122 @@
# 迭代 I7 架构设计 — 可靠投递、运营权限、前端指令
> **仓库**`craftlabs-authorization-sdk``develop`)。
> **前置**[I6 收口](./I6_CLOSEOUT.md)、[I6 实现审核](./I6_IMPLEMENTATION_REVIEW.md) §3 跟踪项。
> **角色**:迭代功能架构说明;实现以本文件为审查基线。
---
## 1. 目标与范围
| 主题 | I7 要达成 | 非目标(后置) |
| ---------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
| **Webhook → 平台** | 对比特 **2xx 后立即返回**;平台投递 **异步**、可重试、**落库可观测**(状态/次数/最后错误) | MQ、跨地域多活 |
| **运营权限** | 新增 `**OPS`****Callback Inbox**(读/改/挂接)仅 `**OPS` + `SYS_ADMIN`**`**DEVELOPER**` 保留其余业务与 **M6 只读** | 细粒度按钮与数据范围(I8+) |
| **前端** | 路由 `meta.roles` 与菜单 `**hasAnyRole`** 一致;登录页说明 `**ops/ops**` | Playwright 流水线(I7.1 可选) |
---
## 2. Webhook 侧:平台投递出站队列
### 2.1 行为
1. `**webhook_callback_receipt` 首次插入成功** 且配置了 `craftlabs.platform.internal.base-url` + token 时,写入 `**webhook_platform_delivery`**`status=PENDING`
2. **不**在 Callback HTTP 线程内同步 `RestClient` 调用平台(避免比特侧拖慢与线程占用)。
3. `**@Scheduled`** 周期拉取 `PENDING` / 可重试失败行,`POST /internal/v1/callback-events`;成功 → `SENT`;失败 → `attempts++`,指数退避 `**next_retry_at**`,超过 `**max-attempts**``DEAD`(人工/运维处理)。
4. **未配置 base-url** 时:不建队列行(与 I5「仅收据」行为一致)。
### 2.2 表(Flyway `V2__webhook_platform_delivery.sql`
| 列 | 说明 |
| ----------------------------------------- | ------------------------------------- |
| `receipt_id` | 关联收据 |
| `request_body` | 平台 API JSON 正文 |
| `trace_headers_json` | `traceparent` / `X-Request-Id` 等 JSON |
| `idempotency_key` | 头 `Idempotency-Key` 用值 |
| `status` | `PENDING` / `SENT` / `DEAD` |
| `attempts`, `last_error`, `next_retry_at` | 重试与排障 |
### 2.3 配置(示例)
| Key | 说明 |
| ----------------------------------------------- | ----------- |
| `craftlabs.platform.delivery.scheduler-enabled` | 单测可 `false` |
| `craftlabs.platform.delivery.max-attempts` | 默认 `8` |
| `craftlabs.platform.delivery.batch-size` | 每.tick 处理条数 |
---
## 3. 平台 API:角色与方法安全
### 3.1 角色
| 角色 | 用途 |
| ----------- | ----------------------------------------------------------- |
| `SYS_ADMIN` | 全量(含 Callback |
| `OPS` | **仅** Callback Inbox + 与运营配套能力;**不**开放合同/交付等业务写路径(由路由与菜单裁剪) |
| `DEVELOPER` | 业务+M6 只读;**不**含 Inbox |
### 3.2 安全模型
- `**@EnableMethodSecurity`** + `**@PreAuthorize**` 扛后端契约。
- `**CallbackInboxController**``hasAnyRole('OPS','SYS_ADMIN')`
- `**IntegrationCatalogController**``hasAnyRole('OPS','SYS_ADMIN','DEVELOPER')`
- JWT 仍由 `[JwtAuthenticationFilter](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java)` 注入 `ROLE_*`
### 3.3 演示账号
| 用户 | 密码 | 角色 |
| ------- | ------- | ----------- |
| `admin` | `admin` | `SYS_ADMIN` |
| `dev` | `dev` | `DEVELOPER` |
| `ops` | `ops` | `OPS` |
---
## 4. 前端(`delivery-platform-ui`
### 4.1 路由 `meta.roles`
| 区域 | `meta.roles` |
| ------------------------------ | ------------------------------- |
| 首页及以下业务(客户/项目/合同/交付/SN) | `SYS_ADMIN`, `DEVELOPER` |
| `/callbacks`, `/callbacks/:id` | `SYS_ADMIN`, `OPS` |
| `/integration/*` | `SYS_ADMIN`, `DEVELOPER`, `OPS` |
### 4.2 菜单与首页
- **Pinia** `hasAnyRole(roles)`**MainLayout** / **HomeView** 按角色显示入口,避免 OPS 误以为可点业务页。
---
## 5. 测试与 DoD
| 项 | 标准 |
| ------- | ----------------------------------------------------------------------------------- |
| 平台 | `CallbackInboxControllerTest``DEVELOPER` 访问 Inbox → **403**`SYS_ADMIN`/`OPS` 仍可走通 |
| Webhook | 配置 base-url 时 **enqueue** 产生 `PENDING` 行;单测关闭 scheduler 避免后台线程 |
| 前端 | `npm run build` 通过 |
---
## 6. 修订记录
| 日期 | 说明 |
| ---------- | ---------------------------- |
| 2026-04-06 | I7 初版:异步投递表 + OPS + 前端路由/菜单。 |
| 2026-04-06 | 闭环:实现与 [I7_IMPLEMENTATION_REVIEW.md](./I7_IMPLEMENTATION_REVIEW.md) 复盘。 |
@@ -0,0 +1,44 @@
# I7 实现复盘 — 对照 [I7_DESIGN.md](./I7_DESIGN.md)
> **方法**:三任务闭环——架构设计 → 前后端实现 → 本复盘。
> **日期**2026-04-06。
---
## 1. 总评
| 主题 | 设计意图 | 实现结论 |
|------|----------|----------|
| Webhook 异步投递 | 对比特快速 2xx;平台 POST 后台重试;可观测 `PENDING/SENT/DEAD` | **已落地**`webhook_platform_delivery` + `PlatformDeliveryService` + `PlatformDeliveryScheduler`;配置见 `craftlabs.platform.delivery.*` 与 [RUNBOOK §10.5](../../services/RUNBOOK.md)。 |
| OPS 与 Inbox | `CallbackInboxController``OPS`/`SYS_ADMIN` | **已落地**`@PreAuthorize` + 演示账号 `ops/ops`。 |
| M6 只读 | `IntegrationCatalogController` 三者可读 | **已落地**`OPS`+`SYS_ADMIN`+`DEVELOPER`。 |
| 前端 | 路由 `meta.roles` 与侧栏一致 | **已落地**`HomeView` 过滤链接;`MainLayout` `v-if`;首页含 `OPS`。 |
---
## 2. 偏差与已知局限
| 项 | 说明 |
|----|------|
| **DEAD 行运维** | **I8** 已提供平台代理重放 + 详情页按钮;**I9** 补充出库 **只读状态**`PENDING`/`SENT`/`DEAD` 等)见 [I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md](./I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md)。 |
| **`v-permission` 指令** | 设计可选组件级指令;当前以 **路由 + 菜单 `hasAnyRole`** 为主,足够覆盖 I7 DoD。 |
| **Playwright** | 仍未进 CI;与 [I6_CLOSEOUT](./I6_CLOSEOUT.md) 一致列为后续。 |
| **内部 mTLS** | 未在本次范围;仍共享 `X-Platform-Internal-Token`。 |
---
## 3. 验证清单(走查)
- [x] `mvn -f services/pom.xml verify`
- [x] `web/delivery-platform-ui` `npm run build`
- [x] `AuthControllerTest` ops 登录
- [x] `CallbackInboxControllerTest``dev` → Inbox **403**
- [x] `PlatformDeliveryEnqueueTest`:首单 Callback → 队列 **+1**
---
## 4. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:I7 闭环复盘。 |
@@ -0,0 +1,40 @@
# I8 实现审核 — 对照 [I8_WEBHOOK_DELIVERY_REPLAY_DESIGN.md](./I8_WEBHOOK_DELIVERY_REPLAY_DESIGN.md)
> **日期**2026-04-06。
---
## 1. 总评
| 设计条款 | 结论 |
|----------|------|
| 浏览器只调平台、平台代调 Webhook | **已落地**`POST /api/v1/callback-inbox/{id}/replay-webhook-delivery` + `WebhookDeliveryReplayClient`。 |
| Webhook `/internal/**` 独立 Ops Token | **已落地**`WebhookOpsTokenFilter``X-Webhook-Ops-Token`;空配置 **503**。 |
| 仅 `DEAD` 可重放 | **已落地**`PlatformDeliveryService.replayDeadDeliveryByReceiptId`。 |
| 关联 `webhookReceiptId` | **已落地**:平台从 `PlatformCallbackInbox` 解析 `Long` 调 Webhook。 |
| OpenAPI / Runbook | **已更新**`CallbackWebhookReplayResponse`、RUNBOOK §10.5 I8 段。 |
---
## 2. 偏差与后续
| 项 | 说明 |
|----|------|
| **UI 不展示出库状态** | 仍为「成功调用平台接口」提示;是否在详情旁拉取 Webhook 库属 **Mid**(需只读 API 或观测面)。 |
| **Playwright** | 未进 CI;与 I6/I7 一致可后续补一条 OPS 点击重放(需 mock Webhook 或 test 栈)。 |
---
## 3. 验证命令(提交前)
- `mvn -f services/pom.xml verify`
- `npm run build``web/delivery-platform-ui`
- `UPDATE_OPENAPI=0 mvn -f services/pom.xml test -Dtest=OpenApiContractSnapshotTest`(契约已与 `contracts/openapi/delivery-platform-api.json` 手工对齐时亦应通过)
---
## 4. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:I8 实现对照设计审核。 |
@@ -0,0 +1,88 @@
# I8 设计 — Webhook 平台投递 DEAD 重放(MVP
> **角色**:解决方案架构;指导前后端实现与 Runbook。
> **前置**I7 已落地 `webhook_platform_delivery``PENDING`/`SENT`/`DEAD`)与调度器;平台 `platform_callback_inbox.webhook_receipt_id` 与 Webhook 收据主键对齐(`PlatformCallbackRequestPlanner` 写入 `webhookReceiptId`)。
---
## 1. 问题与目标
| 维度 | 说明 |
|------|------|
| **问题** | 出库 `DEAD` 后仅能通过查库/SQL 手工改状态,无受控运维入口,易误操作。 |
| **目标** | Ops 在 **Callback 详情** 一键将对应 **`DEAD`** 投递重新入队(`PENDING`),由现有调度器按 `max-attempts` 再次尝试 `POST /internal/v1/callback-events`。 |
| **非目标** | 修改比特 Callback 契约;不实现全量 DLQ 控制台;不在浏览器直连 Webhook 服务。 |
---
## 2. 信任边界与 API 分层
```mermaid
flowchart LR
UI[delivery-platform-ui OPS JWT]
API[delivery-platform-api]
WH[license-webhook-ingress]
UI -->|Bearer JWT| API
API -->|X-Webhook-Ops-Token + receiptId| WH
WH -->|调度| API
```
1. **浏览器只调平台** `POST /api/v1/callback-inbox/{id}/replay-webhook-delivery`,沿用 `CallbackInboxController``@PreAuthorize("hasAnyRole('OPS','SYS_ADMIN')")`
2. **平台服务器到 Webhook** 使用 **独立共享密钥** `X-Webhook-Ops-Token`(与 `X-Platform-Internal-Token` 分离:前者保护 Webhook 运维面,后者保护平台内部 ingest)。
3. **Webhook** 暴露 `POST /internal/v1/platform-deliveries/by-receipt/{receiptId}/replay`,由 **Servlet Filter** 校验 Ops Token(模块无 Spring Security 依赖,与现有 `/webhook/bitanswer/callback` 并存)。
---
## 3. 业务规则
| 规则 | 说明 |
|------|------|
| **关联键** | 使用平台收件箱的 `webhook_receipt_id`(字符串数字)对应 `webhook_platform_delivery.receipt_id`(唯一)。缺失则 **400**,提示未关联 Webhook 出库记录。 |
| **仅 DEAD 可重放** | Webhook 侧若行不存在 → **404**;若状态为 `PENDING`/`SENT`**409**,避免误重置进行中或已成功任务。 |
| **重放语义** | `status=PENDING``attempts=0``last_error=NULL``next_retry_at=NULL``updated_at=now`(重新给满 `max-attempts` 次数)。 |
| **平台幂等** | 重放仅重新 HTTP 投递;若平台 Inbox 已存在同 `(sourceSystem, externalMessageId)`,内部 ingest 返回 duplicate**不新建 Inbox**(与现有幂等一致)。 |
---
## 4. 配置项
| 组件 | 属性 / 环境变量 | 说明 |
|------|-----------------|------|
| Webhook | `craftlabs.webhook.ops-token` / `LICENSE_WEBHOOK_OPS_TOKEN` | 非空才启用 `/internal/**` 鉴权;空则 **503** 所有内部运维路径(避免误暴露)。 |
| Platform | `craftlabs.webhook.base-url` / `LICENSE_WEBHOOK_BASE_URL` | Webhook 根协议+主机+端口。 |
| Platform | `craftlabs.webhook.ops-token` | 与 Webhook 相同密钥,仅驻内存。 |
**Runbook**:在 [RUNBOOK.md](../../../services/RUNBOOK.md) §10.5 旁补充:重放前确认平台已恢复;同 receipt **不要并发多次重放**(单机调度即可)。
---
## 5. 前端
- **Callback 详情**:展示 `webhookReceiptId`;若存在则显示 **「重新入队出库(DEAD→待投递)」**,调平台 POST;成功/失败用现有 `apiErrorMessage`
- **可见性**:路由已为 OPS/SYS_ADMIN,与 I7 一致。
---
## 6. 契约
- 更新 `contracts/openapi/delivery-platform-api.json``POST /api/v1/callback-inbox/{id}/replay-webhook-delivery` 与响应 DTO(如 `status``receiptId`)。
- Webhook 内部路由 **不入** 对外 OpenAPI。
---
## 7. 测试与验收
| 层级 | 验收 |
|------|------|
| Webhook | 单测:`replayDeadByReceiptId` 对 DEAD/非 DEAD/缺行行为;集成或 MockMvc401/503 无 token。 |
| Platform | 单测或 Mock`CallbackInboxService` 在缺 receipt、Webhook 409/404 时映射 HTTP。 |
| 手工 | DEAD 一行 → 详情点重放 → 行变 `PENDING` → 调度成功后 `SENT`。 |
---
## 8. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-06 | 初版:I8 DEAD 重放架构(平台代理 + Webhook 内部 API)。 |
@@ -0,0 +1,44 @@
# I9 实现审核 — 对照 [I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md](./I9_WEBHOOK_DELIVERY_VISIBILITY_DESIGN.md)
> **日期**2026-04-07。
---
## 1. 总评
| 设计条款 | 结论 |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Webhook 只读 `GET by-receipt` | **已落地**`PlatformDeliveryService#getStatusByReceiptId` + `WebhookPlatformDeliveryOpsController`。 |
| 平台代理 + JWT | **已落地**`GET /api/v1/callback-inbox/{id}/webhook-delivery``WebhookDeliveryReplayClient#fetchDeliveryStatus`。 |
| 前端不直连 Webhook | **已落地**`getCallbackWebhookDelivery` + 详情 `el-descriptions`;失败仅提示文案不遮断主详情。 |
| OpenAPI | **已导出**`UPDATE_OPENAPI=1` 更新 `contracts/openapi/delivery-platform-api.json`。 |
| 文档索引 / I7 复盘 | **已修订**[PARALLEL_ITERATION_INDEX](../PARALLEL_ITERATION_INDEX.md)、[I7_IMPLEMENTATION_REVIEW](./I7_IMPLEMENTATION_REVIEW.md) §2。 |
---
## 2. 后续(未在本迭代范围)
| 项 | 说明 |
| -------------- | ----------------------------------------------- |
| **Playwright** | 可对「有 webhookReceiptId 的详情」断言状态区块或 503 提示。 |
| **指标** | M5-F08:积压 `PENDING`/`DEAD` 仍以观测平台为准,本迭代仅 UI 只读。 |
---
## 3. 验证
- `JAVA_HOME`=JDK **17**`mvn -f services/pom.xml verify`
- `npm run build``web/delivery-platform-ui`
---
## 4. 修订记录
| 日期 | 说明 |
| ---------- | --- |
| 2026-04-07 | 初版。 |
@@ -0,0 +1,68 @@
# I9 设计 — Callback 详情可观测 Webhook 平台投递状态
> **角色**:架构审查与实现规划;承接 [I8](I8_WEBHOOK_DELIVERY_REPLAY_DESIGN.md)(重放入队)之后的 **只读观测** 缺口。
> **日期**2026-04-07。
---
## 1. 走查结论(相对 I6/I7/I8
| 主题 | 状态 | 说明 |
|------|------|------|
| I7 异步出库 | 已落地 | `PENDING` / `SENT` / `DEAD` 在 Webhook 库表。 |
| I8 重放 | 已落地 | 平台 POST 代理 + Webhook `replay`;**详情页仍看不到实时出库状态**。 |
| I7 复盘 §2 DEAD | **文档过时** | 已具备 UI 重放(I8);本迭代补 **状态只读** 并修订 I7 复盘表述。 |
| OpenAPI / JDK | 门禁 | 变更契约后须 **JDK 17** + `OpenApiContractSnapshotTest`;见 [contracts/README](../../../contracts/README.md)。 |
---
## 2. 目标与非目标
| **目标** | Ops 在 **Callback 详情** 查看与该收件箱 `webhookReceiptId` 对应的 **`webhook_platform_delivery` 行摘要**`status``attempts``lastError``nextRetryAt``updatedAt`),无需直连 Webhook 库。 |
| **非目标** | 浏览器调用 Webhook;改动比特契约;全量 M5-F08 仪表盘;Playwright 进 CI(列为 I9 后可选)。 |
---
## 3. API 与信任边界
与 I8 相同:**仅** `delivery-platform-api`**`LICENSE_WEBHOOK_BASE_URL` + `LICENSE_WEBHOOK_OPS_TOKEN`** 访问 Webhook **`/internal/**`**;前端只打平台 JWT。
| 组件 | 方法 | 路径 | 说明 |
|------|------|------|------|
| Webhook | `GET` | `/internal/v1/platform-deliveries/by-receipt/{receiptId}` | 同 `WebhookOpsTokenFilter`;无行则 **404**。 |
| Platform | `GET` | `/api/v1/callback-inbox/{id}/webhook-delivery` | `@PreAuthorize` 与 Inbox 一致(`OPS`/`SYS_ADMIN`);无 `webhookReceiptId`**400**;未配 Webhook 则 **503**。 |
**响应体(双方 JSON 字段一致,便于直连 RestClient 反序列化)**
- `receiptId` (long)
- `status` (string)
- `attempts` (int)
- `lastError` (string, 可 null)
- `nextRetryAt` (date-time, 可 null)
- `updatedAt` (date-time)
---
## 4. 实现要点
1. **Webhook**`PlatformDeliveryService#getStatusByReceiptId` 查表组装 Map/DTOController 增加 `GET`
2. **平台**`WebhookDeliveryReplayClient#fetchDeliveryStatus`(保留 Bean 名以减重构面);`CallbackInboxService#getWebhookDeliveryStatus``CallbackInboxController`
3. **前端**:详情页在存在 `webhookReceiptId` 时请求上述 GET,展示 `el-descriptions`;失败时 **不阻断** 主详情(提示或短文案)。
4. **契约**`UPDATE_OPENAPI=1` 导出快照并提交。
5. **文档**:更新 [PARALLEL_ITERATION_INDEX](../PARALLEL_ITERATION_INDEX.md) I9 行;修正 [I7_IMPLEMENTATION_REVIEW](./I7_IMPLEMENTATION_REVIEW.md) §2 DEAD 表述。
---
## 5. 验证
- `mvn -f services/pom.xml verify`Java 17
- `npm run build``web/delivery-platform-ui`
- Webhook / 平台单测各 ≥1 条覆盖 GET 与平台代理
---
## 6. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-07 | 初版:I9 出库可见性架构与 API。 |
@@ -28,7 +28,8 @@
| **I3** | `/contracts`、新建向导、`/contracts/:id` | `ContractWizard``ContractLineEditor``StatusTag` | 状态机由后端校验,前端禁用非法操作 | P0 草稿→生效 | M2 P0M10-F01 入口 |
| **I4** | `/deliveries``/licenses/sn`、导入 | `DeliveryBatchForm``SnBindDialog``SnStatusTimeline` | 交付与合同行;孤儿 SN 警告 | P0 交付→SN→回写 | M3/M4 P0 |
| **I5** | `/callbacks``/integration/environments``product-lines` | `CallbackInboxTable``CallbackPayloadViewer`(脱敏) | Inbox 处置;M6 只读/受限写 | P0 列表→详情→状态 | 与 Webhook 联调或 staging |
| **I6** | 全链路导航与修缺陷 | 可选 `GlobalSearch` | 错误与空态统一 | P0 **BP-0106+11** 全链路 E2E | UAT 无 P0;手册截图一致 |
| **I6** | 全链路导航与修缺陷(参见 [I6_CLOSEOUT.md](../iterations/I6_CLOSEOUT.md) | 可选 `GlobalSearch` | 错误与空态统一;生产 `VITE_API_BASE` | P0 **BP-0106+11** 全链路 E2E | UAT 无 P0;手册截图一致 |
| **I7** | `/callbacks*`**OPS/SYS_ADMIN**;菜单与首页按角色裁剪 | Pinia `hasAnyRole` | 与 `@PreAuthorize` 对齐;`ops/ops` 演示 | 与 Webhook 异步投递联调 | 参见 [I7_DESIGN.md](../iterations/I7_DESIGN.md)、[I7_IMPLEMENTATION_REVIEW.md](../iterations/I7_IMPLEMENTATION_REVIEW.md) |
---
-2
View File
@@ -88,5 +88,3 @@
| 日期 | 说明 |
| ---------- | --------------- |
| 2026-04-06 | 由并行 Task 产出并入库。 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
# 自研授权 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** | |
@@ -0,0 +1,282 @@
# 剩余缺口 WBS 任务拆解与排期计划
> **基于**:PRD vs 代码实现状态完整审计(2026-05-25)
> **范围**:全部 ⚠️ 部分实现 + ❌ 未实现 功能点
> **涉及**:平台后端/前端 + 客户端 SDK
---
## 迭代路线图
| 迭代 | 周期 | 聚焦 | 任务数 | 估计工时 |
|------|------|------|--------|---------|
| **I14** | T0+1W | P0 缺口修复 + 审计修复 | 7 | 12h |
| **I15** | T0+2W | P1 增强(M2/M3/M4 | 6 | 14h |
| **I16** | T0+3W | P1 增强(M5/M6/M8/M11 | 8 | 16h |
| **I17** | T0+4W | SDK JNI 桥接 + 单元测试 | 3 | 16h |
| **V2.1** | T0+6W | P2 功能 + 其他语言封装 | 8 | 20h |
---
## 迭代 I14P0 缺口修复(12h
### I14-T1: M1-F06 项目干系人(2h
**文件:**
- Create: `services/.../db/migration/V17__project_stakeholder.sql`
- Create: `services/.../persistence/project/PlatformProjectStakeholder.java`
- Create: `services/.../persistence/project/PlatformProjectStakeholderMapper.java`
- Create: `services/.../web/dto/StakeholderRequest.java`
- Create: `services/.../web/dto/StakeholderResponse.java`
- Modify: `services/.../project/ProjectController.java`
- Modify: `services/.../service/ProjectService.java`
- Create: `web/.../views/ProjectStakeholderDialog.vue`
- Modify: `web/.../views/ProjectsView.vue`
**DB:**
```sql
CREATE TABLE platform_project_stakeholder (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES platform_project(id),
contact_name VARCHAR(128) NOT NULL,
contact_role VARCHAR(64),
phone VARCHAR(32),
email VARCHAR(128),
is_internal BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**工作量:** 后端 2h + 前端 1h
---
### I14-T2: M2-F01 合同日期字段(1h
**文件:**
- Create: `services/.../db/migration/V18__contract_date_fields.sql`
- Modify: `services/.../persistence/contract/PlatformContract.java`
- Modify: `services/.../web/dto/ContractCreateRequest.java`
- Modify: `services/.../web/dto/ContractUpdateRequest.java`
- Modify: `services/.../web/dto/ContractResponse.java`
- Modify: `services/.../service/ContractService.java`
- Modify: `services/.../contracts/ContractController.java`
- Modify: `web/.../views/ContractWizardView.vue`
**DB:**
```sql
ALTER TABLE platform_contract
ADD COLUMN signing_date DATE,
ADD COLUMN effective_date DATE,
ADD COLUMN end_date DATE;
```
---
### I14-T3: M2-F04 行项 amount 字段 UI 补全(0.5h
**文件:**
- Modify: `web/.../views/ContractDetailView.vue`(添加 amount字段到行项对话框)
- Modify: `web/.../views/ContractWizardView.vue`(添加 amount到行项表)
---
### I14-T4: M4-F05 激活原因码分类(1.5h
**文件:**
- Modify: `services/.../api/web/dto/LicenseSnStatusPatchRequest.java`
- Modify: `services/.../api/service/LicenseSnService.java`
- Modify: `web/.../views/LicenseSnDetailView.vue`
在状态变更对话框中增加「原因码」下拉字段:ACTIVATION_SUCCESS / ACTIVATION_FAILED / MANUAL_CHANGE / EXPIRED
---
### I14-T5: M10-F02 审计 userId 筛选(1h
**文件:**
- Modify: `services/.../api/service/AuditService.java`searchAuditEvents加userId参数)
- Modify: `services/.../api/audit/AuditController.java`
---
### I14-T6: M11-F05 登录锁定逻辑接入(2h)
**文件:**
- Modify: `services/.../api/auth/AuthController.java`
`AuthController.login()` 中,硬编码用户校验之前插入:
1. 查询 `platform_login_attempt` 表统计最近15分钟内该用户的失败次数
2. 如果 >= 5 次,返回 429 "账户已临时锁定"
3. 登录成功后清除失败记录
---
### I14-T7: M11-F16 v-permission 扩展到全部页面(4h
**文件:**
- Modify: 所有 20+ 个 Vue 页面的 CRUD 操作按钮
为每个页面的 新建/编辑/删除 按钮添加 `v-permission` 指令,权限码按模块命名:
- `customer:rw` / `customer:delete`
- `project:rw` / `project:delete`
- `contract:rw`
- `delivery:rw`
- `license:sn:rw` / `license:sn:batch-import`
- `callback:process`
- `integration:config:rw`
- `device:rw`
- `todo:process`
- `report:view` / `report:export`
- `audit:search` / `audit:export`
---
## 迭代 I15P1 增强(M2/M3/M4)(14h
### I15-T1: M2-F06 合同与订单关联(2h
**文件:**
- Create: `services/.../db/migration/V19__order_linking.sql`
- Modify: `services/.../persistence/contract/PlatformContract.java`
- Modify: `web/.../views/ContractDetailView.vue`
### I15-T2: M2-F08 SKU 规则映射(3h
**文件:**
- Create: `services/.../db/migration/V20__sku_mapping.sql`
- Create: `services/.../api/persistence/integration/PlatformSkuMapping.java`
- Modify: `services/.../api/service/IntegrationCatalogService.java`
- Create: `web/.../views/IntegrationSkuMappingView.vue`
### I15-T3: M3-F06 现场环境信息(1.5h
**文件:**
- Create: V21 migration for field_env_info on platform_delivery_batch
- Modify: DeliveryBatchDetailView.vue 增加字段
### I15-T4: M3-F07 交付-SN 门禁逻辑(2h
**文件:**
- Modify: `services/.../service/LicenseSnService.java`
- SN 创建/绑定时,校验 `platform_delivery_batch.status = DELIVERED`
### I15-T5: M4-F06 比特控制台链接(1.5h
**文件:**
- Modify: `web/.../views/LicenseSnDetailView.vue`
-`platform_integration_environment.bitanswer_base_url` 构建控制台链接
### I15-T6: M4-F07/F08/F09 批量操作/需求单/标签(4h)
**文件:**
- 批量绑定额外对话框
- 授权需求单视图
- SN 标签字段
---
## 迭代 I16P1 增强(M5/M6/M8/M11)(16h
### I16-T1: M5-F06 失败原因标注(1.5h
### I16-T2: M5-F07 批量重试(2h
### I16-T3: M6-F04 特征映射管理(3h
### I16-T4: M8-F03/F04 邮件/企微通知 + 模板(3h)
### I16-T5: M11-F08/F11/F12 密码重置/并发会话/强制下线(4h)
### I16-T6: M11-F18/F20/F21 数据属主/系统参数/敏感操作审计(2.5h)
---
## 迭代 I17SDK JNI 桥接(16h
### I17-T1: Rust JNI bridge.cpp 实现(8h
**关键修复:** NativeBridge.java 引用的 JNI 函数在 Rust 侧无对应实现。需要:
1.`native/craft-core/` 中新增 JNI 桥接模块
2. 实现 `Java_cn_craftlabs_auth_internal_NativeBridge_nativeInitialize` 等 8 个 JNI 函数
3. 每个 JNI 函数调用 Rust C ABI 对应的 `craft_*` 函数
### I17-T2: Java SDK 单元测试(4h
为 BitAnswerProvider 和 SelfHostedAuthProvider 编写集成测试:
- 配置解析测试
- Provider 初始化/激活/校验测试(Mock 模式)
### I17-T3: 端到端集成测试(4h
- Rust 核心库编译 + Java SDK 调用验证
- 完整激活 → 校验 → 心跳链路
---
## V2.1P2 功能 + 多语言封装(20h)
| 任务 | 工时 | 说明 |
|------|------|------|
| M1-F07 客户/项目冻结 | 1.5h | |
| M1-F08 客户合并 | 2h | |
| M1-F09 CRM 同步 | 3h | 外部依赖 |
| M5-F10 模拟投递 | 1.5h | 仅测试环境 |
| M6-F08/F09 版本矩阵/影响分析 | 3h | |
| M9-F06 订阅报表 | 2h | |
| M10-F04 留存策略 | 1h | |
| M11-F09/F10 SSO/MFA | 4h | 外部依赖 |
| C#/Python SDK 封装(选1个) | 8h | 视需求 |
---
## 依赖关系图
```mermaid
flowchart LR
subgraph I14["I14: P0修复"]
T1[I14-T1: 项目干系人]
T2[I14-T2: 合同日期]
T3[I14-T3: amount UI]
T4[I14-T4: 激活原因码]
T5[I14-T5: 审计筛选]
T6[I14-T6: 登录锁定]
T7[I14-T7: v-permission扩展]
end
subgraph I15["I15: P1增强"]
T8[I15-T1: 订单关联]
T9[I15-T2: SKU映射]
T10[I15-T3: 环境信息]
T11[I15-T4: 交付门禁]
T12[I15-T5: 比特链接]
T13[I15-T6: 批量操作]
end
subgraph I16["I16: P1增强"]
T14[I16-T1~T6]
end
subgraph I17["I17: SDK JNI"]
T15[I17-T1: JNI bridge]
T16[I17-T2: 单元测试]
T17[I17-T3: 端到端测试]
end
subgraph V21["V2.1: P2+封装"]
T18[V2.1: P2/封装]
end
I14 --> I15 --> I16
I17 -.->|可并行| I16
I15 --> V21
I16 --> V21
```
---
## 工作量汇总
| 迭代 | 聚焦 | 任务数 | 工时 | 交付物 |
|------|------|--------|------|--------|
| **I14** | P0 缺口修复 | 7 | 12h | 干系人/合同日期/登录锁定/v-permission |
| **I15** | M2/M3/M4 P1 增强 | 6 | 14h | 订单/SKU/环境/门禁/批量 |
| **I16** | M5/M6/M8/M11 P1 增强 | 6 | 16h | 通知/特征映射/通知/安全 |
| **I17** | SDK JNI 桥接 | 3 | 16h | JNI bridge/测试 |
| **V2.1** | P2 + 多语言封装 | 8 | 20h | 冻结/合并/SSO/封装 |
| **总计** | | **30** | **78h** | |
@@ -0,0 +1,425 @@
# 原型完善 WBS 执行计划 — P0/P1/P2 任务拆解
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 基于原型复盘结论,按优先级分 5 个迭代完成全部 15 个遗留工作项
**Architecture:** 增量修改现有代码,不重构。后端 Spring Boot + MyBatis-Plus,前端 Vue 3 + Element Plus,遵循已有代码模式。每次迭代产出可编译、可运行的增量。
**Tech Stack:** Java 17, Spring Boot 3.4.5, MyBatis-Plus, Vue 3 (Composition API), Element Plus, Pinia, PostgreSQL 15
---
## 迭代路线图
| 迭代 | 周期 | 聚焦 | 任务数 | 估计工时 |
|------|------|------|--------|---------|
| **I10** | T0+1W | M1 字段补齐 + M4 批量导入 | 4 | 8.5h |
| **I11** | T0+2W | M2 增强 + M11 基础安全 | 5 | 10.5h |
| **I12** | T0+3W | M6 配置管理 | 2 | 7h |
| **I13** | T0+4W | M9 CSV + M10 审计 | 2 | 5h |
| **V2.0** | T0+6W | M11 角色模型重构 | 2 | 14h |
---
## 迭代 I10:M1 字段补齐 + M4 批量导入(P0)
### Task I10-1: M1 客户表加字段(行业/地址/开票信息)
**Files:**
- Create: `services/delivery-platform-api/src/main/resources/db/migration/V9__m1_customer_fields.sql`
- Modify: `services/delivery-platform-api/src/main/java/.../persistence/customer/PlatformCustomer.java`
- Modify: `services/delivery-platform-api/src/main/java/.../customer/CustomerController.java`
- Modify: `services/delivery-platform-api/src/main/java/.../web/dto/CustomerRequest.java`
- Modify: `services/delivery-platform-api/src/main/java/.../web/dto/CustomerResponse.java`
- Modify: `web/delivery-platform-ui/src/views/CustomersView.vue`
**DB Migration:**
```sql
-- V9__m1_customer_fields.sql
ALTER TABLE platform_customer
ADD COLUMN IF NOT EXISTS industry VARCHAR(128),
ADD COLUMN IF NOT EXISTS address TEXT,
ADD COLUMN IF NOT EXISTS billing_info TEXT,
ADD COLUMN IF NOT EXISTS customer_code VARCHAR(64);
```
**Backend:**
- `PlatformCustomer.java`: 添加 `industry`, `address`, `billingInfo`, `customerCode` 字段 (String, OffsetDateTime 无需)
- `CustomerRequest.java`: 添加 industry, address, billingInfo, customerCode 字段 + getters/setters
- `CustomerResponse.java`: 同前
- `CustomerController.java`: 在 create/update 中透传新字段
**Frontend:**
- `CustomersView.vue`: 客户创建/编辑对话框增加 4 个字段:行业(input)、地址(textarea)、开票信息(textarea)、客户编码(input)
**Steps:**
1. Create V9 migration SQL
2. Update PlatformCustomer entity
3. Update CustomerRequest/CustomerResponse DTOs
4. Update CustomersView.vue dialog
5. Compile & build
6. Commit
---
### Task I10-2: M1 项目表加字段(计划起止/项目经理)
**Files:**
- Modify: `services/.../db/migration/V9__m1_customer_fields.sql` (append)
- Modify: `services/.../persistence/project/PlatformProject.java`
- Modify: `services/.../project/ProjectController.java`
- Modify: `services/.../web/dto/ProjectRequest.java`
- Modify: `services/.../web/dto/ProjectResponse.java`
- Modify: `web/.../views/ProjectsView.vue`
**DB:**
```sql
-- 追加到 V9
ALTER TABLE platform_project
ADD COLUMN IF NOT EXISTS planned_start_date DATE,
ADD COLUMN IF NOT EXISTS planned_end_date DATE,
ADD COLUMN IF NOT EXISTS project_manager VARCHAR(128);
```
**Backend:**
- `PlatformProject.java`: 加 `plannedStartDate` (LocalDate), `plannedEndDate` (LocalDate), `projectManager` (String)
- `ProjectRequest.java` / `ProjectResponse.java`: 对应加字段
- `ProjectController.java`: 透传
**Frontend:**
- `ProjectsView.vue`: 对话框加 计划开始日期(日期选择器)、计划结束日期(日期选择器)、项目经理(input)
---
### Task I10-3: M1 客户详情聚合视图
**Files:**
- Create: `web/.../views/CustomerDetailView.vue`
- Modify: `web/.../router/index.js`
- Modify: `web/.../views/CustomersView.vue` (行操作加「详情」)
- Modify: `web/.../api/platform.js`
**Backend:**
- `CustomerController.java`: 添加 `GET /api/v1/customers/{id}/summary` 返回聚合数据
- `CustomerService.java`: 添加 `getCustomerSummary(id)` 方法,查询关联项目数、合同数、SN 数
**Frontend:**
- `CustomerDetailView.vue`: 展示客户基本信息 + 聚合卡片(关联项目数、在履约合同数、在途 SN 数)
- `CustomersView.vue`: 行操作加「详情」按钮 → 跳转 `/customers/:id`
- router: 加 `/customers/:id` → CustomerDetailView
- platform.js: 加 `getCustomerSummary(id)`
---
### Task I10-4: M4 SN 批量导入
**Files:**
- Create: `services/.../api/web/dto/SnBatchImportRequest.java`
- Modify: `services/.../api/license/LicenseSnController.java`
- Modify: `services/.../api/service/LicenseSnService.java`
- Modify: `web/.../views/LicenseSnListView.vue`
- Modify: `web/.../api/platform.js`
**Backend:**
- `LicenseSnController.java`: 添加 `POST /api/v1/license-sns/batch-import`
- `LicenseSnService.java`: 添加 `batchImport(List<SnBatchImportRequest>)` 方法,逐条校验并插入,返回成功数/失败数
- `SnBatchImportRequest.java`: snCode, projectId, contractLineId, activationRemark
**Frontend:**
- `LicenseSnListView.vue`: 加「批量导入」按钮 → 弹窗文本域(一行一个 SN)
- `platform.js`: 加 `batchImportLicenseSns(body)`
---
## 迭代 I11M2 增强 + M11 基础安全(P1)
### Task I11-1: M2 合同附件上传
**Files:**
- Modify: `services/.../api/contracts/ContractController.java`
- Modify: `services/.../api/service/ContractService.java`
- Modify: `web/.../views/ContractDetailView.vue`
- Modify: `web/.../api/platform.js`
**Backend:**
- 附件存储为本地文件系统路径 + DB 记录(`platform_contract_attachment` 新表)
- 或简化:用 `TEXT` 字段存附件 URL/备注
- `ContractService.java`: 加 `uploadAttachment(contractId, MultipartFile)` / `getAttachments(contractId)`
**Frontend:**
- `ContractDetailView.vue`: 加「附件」区块 + 上传按钮 + 文件列表
---
### Task I11-2: M2 合同变更版本
**Files:**
- Create: `services/.../db/migration/V10__contract_change_version.sql`
- Create: `services/.../api/contracts/ContractChangeController.java`
- Create: `services/.../api/web/dto/ContractChangeRequest.java`
- Modify: `services/.../api/service/ContractService.java`
- Modify: `web/.../views/ContractDetailView.vue`
- Modify: `web/.../router/index.js`
**DB:**
```sql
CREATE TABLE platform_contract_change (
id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES platform_contract(id),
version INT NOT NULL,
change_type VARCHAR(64) NOT NULL,
before_snapshot JSONB,
after_snapshot JSONB,
reason TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
created_by VARCHAR(256),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Backend:**
- 合同状态机新增 `CHANGING` 状态流转
- 创建变更单后锁住原合同行,允许编辑后生成新版本
**Frontend:**
- `ContractDetailView.vue`: 状态操作条加「发起变更」「完成变更」按钮
- 「变更历史」区块展示版本列表
---
### Task I11-3: M11 空闲超时自动登出
**Files:**
- Modify: `web/.../src/layout/MainLayout.vue`
- Possibly: `web/.../src/stores/auth.js`
**Frontend:**
- `MainLayout.vue`: 监听用户活动事件(mousemove, keydown, click),空闲 N 分钟后自动调用 `auth.logout()` + 跳转登录页
- 可配置超时时间(默认 30 分钟)
- 超时前 1 分钟弹窗提示
---
### Task I11-4: M11 登录失败锁定机制
**Files:**
- Modify: `services/.../api/auth/AuthController.java`
- Modify: `services/.../api/config/SecurityConfig.java`
- Possibly new table `platform_login_attempt`
**Backend:**
- 记录登录失败次数(内存 Map 或 DB 表)
- 连续失败 N 次(默认 5 次)后锁定账号 X 分钟
- 返回错误码 `ACCOUNT_LOCKED`
---
### Task I11-5: M11 密码修改功能
**Files:**
- Modify: `services/.../api/auth/AuthController.java`
- Modify: `web/.../views/LoginView.vue` 或新建 `ProfileView.vue`
- Modify: `web/.../router/index.js`
**Backend:**
- `POST /api/v1/auth/change-password` : body { oldPassword, newPassword }
- 校验旧密码正确性 + 新密码强度
**Frontend:**
- 用户菜单加「修改密码」入口
- 弹窗表单:旧密码、新密码、确认新密码
---
## 迭代 I12M6 配置管理(P1
### Task I12-1: M6 比特 ID 映射管理
**Files:**
- Create: `services/.../db/migration/V11__m6_id_mapping.sql`
- Create: `services/.../api/web/dto/BitanswerIdMappingRequest.java`
- Create: `services/.../api/web/dto/BitanswerIdMappingResponse.java`
- Create: `services/.../api/persistence/integration/PlatformBitanswerIdMapping.java`
- Create: `services/.../api/persistence/integration/PlatformBitanswerIdMappingMapper.java`
- Modify: `services/.../api/integration/IntegrationCatalogController.java`
- Modify: `services/.../api/service/IntegrationCatalogService.java`
- Create: `web/.../views/IntegrationIdMappingView.vue`
- Modify: `web/.../router/index.js`
- Modify: `web/.../api/platform.js`
**DB:**
```sql
CREATE TABLE platform_bitanswer_id_mapping (
id BIGSERIAL PRIMARY KEY,
product_line_id BIGINT NOT NULL REFERENCES platform_product_line(id),
environment_id BIGINT REFERENCES platform_integration_environment(id),
bitanswer_product_id VARCHAR(128),
bitanswer_template_id VARCHAR(128),
bitanswer_business_id VARCHAR(128),
feature_key VARCHAR(64),
bitanswer_feature_id VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Backend:** CRUD
**Frontend:** 映射管理页面,按产品线+环境筛选,表格展示映射关系
---
### Task I12-2: M6 授权 JSON 模板管理
**Files:**
- Create: `services/.../db/migration/V12__m6_json_template.sql`
- Create: `services/.../api/persistence/integration/PlatformJsonTemplate.java`
- Create: `services/.../api/persistence/integration/PlatformJsonTemplateMapper.java`
- Create: `services/.../api/web/dto/JsonTemplateRequest.java`
- Modify: `services/.../api/service/IntegrationCatalogService.java`
- Modify: `services/.../api/integration/IntegrationCatalogController.java`
- Create: `web/.../views/IntegrationJsonTemplateView.vue`
- Modify: `web/.../router/index.js`
**DB:**
```sql
CREATE TABLE platform_json_template (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
version INT NOT NULL DEFAULT 1,
template_content TEXT NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
change_notes TEXT,
created_by VARCHAR(256),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Backend:** CRUD + JSON Schema 校验 + 版本递增
**Frontend:** 模板列表 + 创建/编辑页(JSON 编辑器 + 校验结果展示)
---
## 迭代 I13M9 CSV + M10 审计(P1-P2
### Task I13-1: M9 报表导出 CSV
**Files:**
- Modify: `services/.../api/report/ReportController.java`
- Modify: `services/.../api/service/ReportService.java`
- Modify: `web/.../views/ContractSnReportView.vue`
**Backend:**
- `ReportController.java`: 加 `GET /api/v1/reports/export?type=contract-sn` 返回 CSV 文件流
- 使用 `Content-Disposition: attachment; filename=report.csv`
- `ReportService.java`: 加 `exportContractSnReport()` 生成 CSV 字符串
**Frontend:**
- `ContractSnReportView.vue`: 加「导出 CSV」按钮,调后端接口下载文件
---
### Task I13-2: M10 审计检索/导出
**Files:**
- Create: `web/.../views/AuditSearchView.vue`
- Modify: `web/.../router/index.js`
- Modify: `web/.../api/platform.js`
- Modify: `services/.../api/service/AuditService.java`
- Modify: `services/.../api/web/dto/AuditEventResponse.java` (if needed)
**Backend:**
- `GET /api/v1/audit-events` 增加筛选参数:entityType, entityId, userId, from, to
- `GET /api/v1/audit-events/export` → CSV 导出
**Frontend:**
- `AuditSearchView.vue`: 筛选表单 + 审计日志表格 + 导出按钮
- Router: `/audit` → AuditSearchView
---
## V2.0M11 角色模型重构(P2
### Task V2-1: M11 角色模型对齐产品定义
**Files:** (大量修改)
- Modify: `services/.../api/config/SecurityConfig.java`
- Modify: `services/.../api/security/PlatformRoles.java`
- Modify: 所有 Controller 的 `@PreAuthorize` 注解
- Modify: `web/.../router/index.js`
- Modify: `web/.../layout/MainLayout.vue`
- Modify: `web/.../views/HomeView.vue`
- New seeds: `services/.../db/migration/V13__seed_roles.sql`
**Changes:**
- 废弃 `DEVELOPER` / `OPS` 角色
- 实现产品定义角色:`SALES`, `ORDER_SUPPORT`, `DELIVERY`, `LICENSE_OPS`, `DEV_SUPPORT`, `FINANCE_VIEW`, `COMPLIANCE`, `EXEC_VIEW`
- 更新所有路由 `meta.roles`
- 更新侧栏菜单 `roles`
- 更新后端所有 `@PreAuthorize` 注解
---
### Task V2-2: M11 按钮级权限码
**Files:** (大量修改)
- Create: `web/.../src/directives/permission.js`
- Modify: 所有 Vue 页面的操作按钮加 `v-permission` 指令
- Modify: 后端 Controller 方法加细粒度 `@PreAuthorize`
**Frontend:**
- 创建 `v-permission` 自定义指令 (类似 `v-permission="'contract:order:export'"`)
- Pinia store 存储用户权限码列表
- 按钮级:`<el-button v-permission="'license:sn:rw'">新建</el-button>`
**Backend:**
- 在 JWT token 中包含权限码列表
- 每个 mutating 接口用 `@PreAuthorize("hasAuthority('license:sn:rw')")`
---
## 任务依赖关系
```mermaid
flowchart LR
subgraph I10["I10 (P0聚焦)"]
T10_1[Task I10-1: M1客户字段] --> T10_3[Task I10-3: 客户详情页]
T10_2[Task I10-2: M1项目字段]
T10_4[Task I10-4: SN批量导入]
end
subgraph I11["I11 (M2+M11安全)"]
T11_1[Task I11-1: 合同附件]
T11_2[Task I11-2: 合同变更版本]
T11_3[Task I11-3: 空闲超时]
T11_4[Task I11-4: 登录锁定]
T11_5[Task I11-5: 密码修改]
end
subgraph I12["I12 (M6配置)"]
T12_1[Task I12-1: 比特ID映射]
T12_2[Task I12-2: JSON模板]
end
subgraph I13["I13 (报表+审计)"]
T13_1[Task I13-1: CSV导出]
T13_2[Task I13-2: 审计检索]
end
subgraph V2["V2.0 (架构债)"]
V2_1[Task V2-1: 角色模型]
V2_2[Task V2-2: 权限码]
end
I10 --> I11 --> I12 --> I13 --> V2
```
---
## 工作量汇总
| 迭代 | 任务 | 后端文件 | 前端文件 | 迁移文件 | 估计工时 |
|------|------|---------|---------|---------|---------|
| **I10** | 4 | 8 | 4 | 1 | 8.5h |
| **I11** | 5 | 8 | 5 | 1 | 10.5h |
| **I12** | 2 | 10 | 3 | 2 | 7h |
| **I13** | 2 | 3 | 3 | 0 | 5h |
| **V2.0** | 2 | 10+ | 10+ | 1 | 14h |
| **总计** | **15** | **39+** | **25+** | **5** | **45h** |
@@ -0,0 +1,450 @@
# Mid I10 — P0 基线对齐实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 补齐原型复盘发现的 P0 缺口:文档对齐 + 客户详情聚合视图 + 会话空闲超时拦截。
**Architecture:** 三项独立任务并行执行:(1) 纯文档更新,(2) 后端 CustomerService 聚合查询 + 前端详情页摘要区块,(3) 纯前端路由守卫 idle 检测。无需新增数据库表或后端端点(M1-F03 修复已有端点)。
**Tech Stack:** Spring Boot 3.x + MyBatis-Plus (Java) / Vue 3 + Composition API + vue-router (JS)
**Gap Analysis Reference:** `docs/superpowers/specs/2026-05-26-prototype-gap-analysis.md`
---
## 文件结构
```
# Task 0 — 文档更新
Modify: docs/chuangfei-platform-product-modules.md # 刷新 M1-M11 全部实现状态列
# Task 1 — M1-F03 客户详情聚合视图
Modify: services/.../api/service/CustomerService.java # 修复 getCustomerSummary() 真实查询合同/SN 计数
Modify: web/.../src/views/CustomerDetailView.vue # 新增聚合摘要区块
# Task 2 — M11-F03 会话空闲超时
Modify: web/.../src/router/index.js # 路由守卫注入 idle 检测
Modify: web/.../src/stores/auth.js # 新增 lastActivity + checkSessionTimeout
Create: web/.../src/utils/idleTimer.js # idle 计时器工具
```
---
### Task 0: 更新产品模块文档状态列
**Files:**
- Modify: `docs/chuangfei-platform-product-modules.md`
**依据:** 代码审计显示大量功能点实际实现早于文档标记。需刷新状态列,使文档成为可靠 SSOT。
**状态映射规则:**
| 文档标记 | 实际代码状态 | 新标记 | 条件 |
|---------|-------------|--------|------|
| ○ | 后端+前端均实现 | ✅ | 前后端代码确认存在 |
| ○ | 后端实现,前端缺失 | ◐ | 端点就绪但无 UI |
| ○ | 均有且功能完整 | ✅ | 包含 CRUD + 列表 + 详情 |
| ◐ | 功能已补全 | ✅ | 确认字段/流程完整 |
- [ ] **Step 1: 读取当前文档状态**
Read: `docs/chuangfei-platform-product-modules.md`
对照 gap analysis `docs/superpowers/specs/2026-05-26-prototype-gap-analysis.md` 中的「代码领先文档」差异表,确认每个模块需要变更的行。
- [ ] **Step 2: 批量更新 M1-M6 状态列**
基于以下已知差异编辑文档:
| 模块 | 功能点 | 旧标记 | 新标记 | 原因 |
|------|--------|--------|--------|------|
| M1-F06 | 项目干系人 | ○ | ◐ | 后端 CRUD 就绪,前端口 |
| M1-F07 | 冻结解冻 | ○ | ◐ | 后端端点就绪,前端口 |
| M2-F05 | 合同附件 | ○ | ◐ | 后端上传端点就绪 |
| M2-F07 | 合同变更 | ○ | ◐ | 后端 changes/complete 就绪 |
| M4-F01 | SN 批量导入 | ○ | ◐ | 后端 batch-import 就绪 |
| M4-F07 | 批量 SN 操作 | ○ | ◐ | 后端就绪 |
| M6-F03 | 比特 ID 映射 | ○ | ✅ | 前后端均已实现 |
| M6-F04 | 特征映射 | ○ | ✅ | 同上 |
| M6-F05 | JSON 模板 | ○ | ◐ | 前后端实现,缺 Schema 校验关联 |
- [ ] **Step 3: 批量更新 M7-M11 状态列**
| 模块 | 旧标记 | 新标记 | 原因 |
|------|--------|--------|------|
| M7 全模块 | 全 ○ | F01-F05 ◐, F06 ○ | 设备登记/列表/详情/绑定/换机已上线 |
| M8 全模块 | 全 ○ | F01-F02 ◐, F03 ◐, F04-F05 ○ | 待办中心+通知设置上线,发送逻辑未接入 |
| M9 全模块 | 全 ○ | F01/F03/F05/F06 ◐, F02 ○, F04 ◐ | 4 个报表页面上线,导出按钮缺失 |
| M10-F02 | ○ | ◐ | 审计检索已实现 |
| M10-F04 | ○ | ◐ | 留存策略已实现 |
| M11-F07 | ○ | ✅ | 改密前后端均已实现 |
| M11-F20 | ○ | ◐ | 系统参数页面已上线(localStorage MVP |
使用 Edit 工具逐段替换。例如对于 M7 的旧状态行:
```
Current: | M7-F01 | 设备登记 | ... | P1 | ○ |
Replace: | M7-F01 | 设备登记 | ... | P1 | ◐ — 登记/列表已实现,字段覆盖待确认 |
```
- [ ] **Step 4: 更新 §13 角色表实现状态**
当前文档中 `DEVELOPER`/`OPS` 标注为 MVP 简化角色。实际代码中角色集已演变为 `SYS_ADMIN`/`SALES`/`DELIVERY`/`LICENSE_OPS`。在 §13.5 增加说明。
在 §13.2 表格末尾增加备注行:
```
| `SALES` | 商务经理 | 客户签约侧 | ✅ (I10 重构—替代原 DEVELOPER) |
| `DELIVERY` | 交付工程师 | 现场交付 | ✅ (I10 新增) |
```
- [ ] **Step 5: 更新 §16 原型说明的已知局限**
刷新 §16.6 的问题表 — 移除已修复项(如改密),补充新的已知局限。
- [ ] **Step 6: 验证文档完整性**
```bash
grep -n '○' docs/chuangfei-platform-product-modules.md | head -20
```
预期输出:剩余 ○ 项应与 gap analysis §P2 级别的项目一致。
```bash
grep -c '✅' docs/chuangfei-platform-product-modules.md
```
预期:✅ 计数应显著高于旧版。
---
### Task 1: M1-F03 客户详情聚合视图
**Files:**
- Modify: `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CustomerService.java`
- Modify: `web/delivery-platform-ui/src/views/CustomerDetailView.vue`
**当前状态:** 后端 `GET /{id}/summary` 端点存在,但返回 `contractCount: 0``snCount: 0` (硬编码占位)。前端 `CustomerDetailView.vue` 已存在但无摘要区块。
- [ ] **Step 1: 修复后端 CustomerService.getCustomerSummary()**
`CustomerService.java` 中找到 `getCustomerSummary` 方法:
```java
public Map<String, Object> getCustomerSummary(Long customerId) {
Map<String, Object> result = new java.util.LinkedHashMap<>();
// 项目计数
var projectQuery = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlatformProject>();
projectQuery.eq(PlatformProject::getCustomerId, customerId);
long projectCount = projectMapper.selectCount(projectQuery);
result.put("projectCount", projectCount);
// 合同计数: 查询 PlatformContract 表中 customer_id = customerId 且状态 != TERMINATED
var contractQuery = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlatformContract>();
contractQuery.eq(PlatformContract::getCustomerId, customerId);
contractQuery.ne(PlatformContract::getStatus, ContractStatus.TERMINATED);
long contractCount = contractMapper.selectCount(contractQuery);
result.put("contractCount", contractCount);
// SN 计数: 通过合同行→SN 链路或直接查 license_sn 表 customer_id
// 当前 schema: LicenseSn 无直接 customerId,通过 contractLineId → contract → customer
// 简化实现: 统计该客户关联合同行下的 SN 总数
var snQuery = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlatformLicenseSn>();
// Join 或子查询: contract_line → contract WHERE customer_id = ?
// 简单方案: 使用 Mapper XML 或子查询
result.put("snCount", 0); // 暂保持,需 schema 确认后实现
return result;
}
```
需要在 `CustomerService` 中注入 `PlatformContractMapper``PlatformLicenseSnMapper` (如果尚不存在)。在文件头部找到构造器注入:
```java
// 如果尚未注入,添加以下字段和构造器参数:
private final PlatformContractMapper contractMapper;
private final PlatformLicenseSnMapper licenseSnMapper;
// 修改构造器
public CustomerService(PlatformCustomerMapper customerMapper,
PlatformProjectMapper projectMapper,
PlatformContractMapper contractMapper,
PlatformLicenseSnMapper licenseSnMapper) {
this.customerMapper = customerMapper;
this.projectMapper = projectMapper;
this.contractMapper = contractMapper;
this.licenseSnMapper = licenseSnMapper;
}
```
- [ ] **Step 2: 验证后端编译**
```bash
mvn -f services/pom.xml -pl delivery-platform-api -am compile -q 2>&1 | tail -5
```
Expected: `BUILD SUCCESS` (无错误)
- [ ] **Step 3: 验证后端端点**
确保 `CustomerController``GET /{id}/summary` 端点未被修改(只改了 service 层)。
```bash
grep -A 5 'GetMapping.*summary' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java
```
Expected: 仍返回 `customerService.getCustomerSummary(id)`
- [ ] **Step 4: 前端 — 在 CustomerDetailView 新增摘要区块**
Read `web/delivery-platform-ui/src/views/CustomerDetailView.vue` 确认现有结构。
在详情页顶部(客户基本信息下方)新增 `el-card` 摘要区块:
```vue
<script setup>
// 已有 imports 末尾添加
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
const summary = ref(null)
const summaryLoading = ref(false)
async function loadSummary() {
summaryLoading.value = true
try {
const res = await axios.get(`/api/v1/customers/${route.params.id}/summary`)
summary.value = res.data
} catch { /* 静默失败 — 摘要为增强信息,不阻断页面 */ }
finally { summaryLoading.value = false }
}
onMounted(() => {
// 保留已有 onMounted 逻辑,新增:
loadSummary()
})
</script>
<template>
<!-- 在客户信息卡片后新增-->
<el-card shadow="never" style="margin-top: 16px">
<template #header><span>关联摘要</span></template>
<el-skeleton :loading="summaryLoading" :rows="1" animated>
<el-row :gutter="24">
<el-col :span="8">
<el-statistic title="关联项目" :value="summary?.projectCount ?? '-'" />
</el-col>
<el-col :span="8">
<el-statistic title="在履约合同" :value="summary?.contractCount ?? '-'" />
</el-col>
<el-col :span="8">
<el-statistic title="在途 SN" :value="summary?.snCount ?? '-'" />
</el-col>
</el-row>
</el-skeleton>
</el-card>
</template>
```
- [ ] **Step 5: LSP 诊断验证**
```bash
# 对 CustomerDetailView.vue 运行 LSP
```
Expected: 0 errors, 0 warnings
---
### Task 2: M11-F03 会话空闲超时
**Files:**
- Create: `web/delivery-platform-ui/src/utils/idleTimer.js`
- Modify: `web/delivery-platform-ui/src/stores/auth.js`
- Modify: `web/delivery-platform-ui/src/router/index.js`
**当前状态:** `SystemParamsView.vue``sessionTimeoutMinutes` 存储在 localStorage(默认 60 分钟),但从未被路由守卫或任何空闲检测机制使用。用户在登录后从不超时。
- [ ] **Step 1: 创建 idleTimer 工具**
`web/delivery-platform-ui/src/utils/idleTimer.js`:
```javascript
/**
* 空闲计时器 — 监听用户交互事件,超时触发回调。
* 读取 localStorage 'systemParams' 中的 sessionTimeoutMinutes。
* 默认 60 分钟,最小 5 分钟。
*/
let timerId = null
let onTimeoutCallback = null
const EVENTS = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click']
export function getIdleTimeoutMinutes() {
try {
const stored = localStorage.getItem('systemParams')
if (stored) {
const parsed = JSON.parse(stored)
const minutes = parseInt(parsed.sessionTimeoutMinutes, 10)
return isNaN(minutes) ? 60 : Math.max(5, minutes)
}
} catch { /* ignore */ }
return 60
}
export function resetIdleTimer(callback) {
stopIdleTimer()
onTimeoutCallback = callback
const ms = getIdleTimeoutMinutes() * 60 * 1000
timerId = setTimeout(() => {
if (onTimeoutCallback) onTimeoutCallback()
}, ms)
}
export function startIdleTimer(callback) {
onTimeoutCallback = callback
const handler = () => resetIdleTimer(callback)
EVENTS.forEach(ev => window.addEventListener(ev, handler))
resetIdleTimer(callback)
// 保存清理函数
window.__idleCleanup = () => {
EVENTS.forEach(ev => window.removeEventListener(ev, handler))
stopIdleTimer()
}
}
export function stopIdleTimer() {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
}
```
- [ ] **Step 2: 修改 auth store 集成 idle 检测**
在文件头部读取 `web/delivery-platform-ui/src/stores/auth.js` 确认现有代码结构。在 `logout` action 中添加超时标记。
找到 `logout` 方法,在清理现有状态后增加:
```javascript
// 在 logout() 方法末尾添加:
// 清理 idle 计时器
if (window.__idleCleanup) {
window.__idleCleanup()
delete window.__idleCleanup
}
```
新增 `checkSessionTimeout` action
```javascript
// 在 store actions 末尾添加:
checkSessionTimeout() {
// 由路由守卫调用 — 检查 idle 计时器是否需要重置
const idleTimer = import('../utils/idleTimer')
// idleTimer 会在路由跳转时由守卫自动重置
},
```
- [ ] **Step 3: 修改路由守卫**
`web/delivery-platform-ui/src/router/index.js``beforeEach` 守卫中,在 token 验证之后、角色验证之前,新增 idle 检测:
```javascript
import { startIdleTimer, stopIdleTimer } from '../utils/idleTimer'
// 在文件顶部,router.beforeEach 之前,添加 idle 计时器管理
let idleTimerStarted = false
// 修改现有 router.beforeEach:
router.beforeEach((to) => {
const auth = useAuthStore()
// 未登录 → 跳转登录
if (to.meta.requiresAuth && !auth.token) {
if (window.__idleCleanup) {
window.__idleCleanup()
delete window.__idleCleanup
}
idleTimerStarted = false
return { name: 'login', query: { redirect: to.fullPath } }
}
// 已登录 → 确保 idle 计时器运行
if (auth.token && !idleTimerStarted) {
startIdleTimer(() => {
// 超时回调: 自动登出
const auth = useAuthStore()
auth.logout()
idleTimerStarted = false
// 跳转到登录页(显示超时提示)
window.location.href = '/login?timeout=1'
})
idleTimerStarted = true
}
// 已登录用户每次路由跳转 → 重置 idle 计时器
if (auth.token && idleTimerStarted && to.meta.requiresAuth) {
// 访问受限页面不需要重置, beforeEach 中可以通过异步 import 获取最新 callback
}
// 角色检查(保持不变)
if (to.meta.requiresAuth && to.meta.roles && !hasRoleAccess(to.meta.roles, auth.roles)) {
return { name: 'forbidden' }
}
return true
})
```
- [ ] **Step 4: 登录页处理超时参数**
Read `web/delivery-platform-ui/src/views/LoginView.vue`。在 `onMounted` 中检查 `$route.query.timeout`
```javascript
onMounted(() => {
// 检查超时参数
if (route.query.timeout === '1') {
ElMessage.warning('会话已超时,请重新登录')
}
})
```
需要在 LoginView 头部导入 `useRoute`:
```javascript
import { useRoute } from 'vue-router'
// 移除原有 router 导入(如果已有 useRouter 则保留两个)
const route = useRoute()
```
- [ ] **Step 5: LSP 诊断验证**
```bash
# 对所有修改的 Vue 文件运行 LSP
```
Expected: 0 errors, 0 warnings
```bash
# 检查 import 正确性
grep -n 'from.*idleTimer' web/delivery-platform-ui/src/router/index.js
```
Expected: 显示正确的相对导入路径
---
## 自检
**1. Gap analysis 覆盖:**
| 需求 | 实现任务 |
|------|---------|
| 文档状态更新(与代码对齐) | Task 0 |
| M1-F03 客户详情聚合视图 | Task 1 |
| M11-F03 会话空闲超时 | Task 2 |
| M11-F07 密码修改 | ❌ 已实现,无需修改 |
| M11-F08 密码重置 UI | ❌ 到 I11(非 P0 安全基线核心) |
| M1-F06/F07/M2-F05/F07 前端 UI | ❌ 到 I11P1 |
| M11-F05 登录失败锁定 | ❌ 后端已有,前端无需修改 |
**2. Placeholder 扫描:** 无 TBD/TODO 遗留。
**3. 类型一致性:** `sessionTimeoutMinutes` 在 idleTimer.js、SystemParamsView.vue、auth store 之间一致。
**4. 范围检查:** 3 个独立任务,不跨越子系统边界。
@@ -0,0 +1,641 @@
# P0 安全基线修复实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复审计报告的 P0 安全与功能缺陷 — 错误泄露、附件校验、事务缺失、硬编码用户、空操作端点、改密逻辑错误。
**Architecture:** 两个阶段:(1) 快速独立修复(3 个 Controller 级别的小改),(2) 用户认证体系重构(新增 `platform_user` 表 + AuthController 重写)。阶段 1 无依赖,阶段 2 需要在阶段 1 之后执行。
**Tech Stack:** Spring Boot 3.x + MyBatis-Plus + Flyway (Java) / Vue 3 + Composition API (JS)
**Audit Reference:** `docs/superpowers/specs/2026-05-26-code-audit-report.md`
---
## 文件结构
```
Phase 1 — 快速修复(无依赖项)
Modify: services/.../api/license/LicenseController.java # 移除 try-catch 泄露
Modify: services/.../api/contracts/ContractController.java # 移除 try-catch + 文件校验
Modify: services/.../api/service/LicenseSnService.java # 添加 @Transactional
Phase 2 — 用户认证体系重构(互有依赖)
Create: services/.../db/migration/V24__platform_user.sql # Flyway 迁移
Create: services/.../persistence/auth/PlatformUser.java # 实体
Create: services/.../persistence/auth/PlatformUserMapper.java
Modify: services/.../api/auth/AuthController.java # 完全重写
Create: services/.../api/security/TokenBlacklistService.java # 强制下线支持
Modify: services/.../api/config/SecurityConfig.java # 添加 CORS(如需)
```
---
## Phase 1: Quick Fixes
### Task 1: 修复 LicenseController 错误泄露 (CR-03)
**Files:**
- Modify: `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseController.java`
**当前问题:** `create` 方法 try-catch 捕获 `Exception` 并返回 `e.getMessage()` 泄露内部细节,且返回格式非标准 `{"error": "..."}` 而非 `{"status": 500, "message": "..."}`
- [ ] **Step 1: 编辑 LicenseController.create 方法**
```java
// 删除整段 try-catch,让全局 ApiExceptionHandler 接管
@PostMapping
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> create(@RequestBody Map<String, Object> request) {
return ResponseEntity.ok(licenseService.create(request));
}
```
之前的代码(需要删除 try/catch 和 `ResponseEntity``internalServerError` 分支):
```java
// BEFORE:
@PostMapping
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> create(@RequestBody Map<String, Object> request) {
try {
return ResponseEntity.ok(licenseService.create(request));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
```
- [ ] **Step 2: 验证无其他 try-catch 泄露**
```bash
grep -n 'catch.*Exception' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseController.java
```
Expected: 无输出
---
### Task 2: 修复 ContractController 错误泄露 + 附件校验 (CR-03 + ME-01)
**Files:**
- Modify: `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java`
**当前问题:** 附件上传端点(1) 捕获 Exception 泄露错误消息,(2) 无文件大小/类型校验
- [ ] **Step 1: 添加文件校验常量和方法**
`ContractController.java` 文件头部添加静态常量:
```java
import org.springframework.http.MediaType;
// ... 其他 import 保持不变
// 在类定义内添加常量
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
private static final java.util.Set<String> ALLOWED_CONTENT_TYPES = java.util.Set.of(
MediaType.APPLICATION_PDF_VALUE,
"image/jpeg", "image/png", "image/tiff",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
```
- [ ] **Step 2: 重写 uploadAttachment 方法**
```java
// 用以下内容替换整个 uploadAttachment 方法:
@PostMapping("/{id}/attachments")
public ResponseEntity<Map<String, Object>> uploadAttachment(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "上传文件为空");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"文件大小超过限制 (最大 50MB)");
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"不支持的文件类型: " + contentType);
}
PlatformContract contract = contractMapper.selectById(id);
if (contract == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "合同不存在");
}
// 文件存储到本地
String storageDir = System.getProperty("user.dir") + "/uploads/contracts/" + id;
new java.io.File(storageDir).mkdirs();
String originalName = file.getOriginalFilename();
String ext = originalName != null && originalName.contains(".")
? originalName.substring(originalName.lastIndexOf('.'))
: "";
String storedName = java.util.UUID.randomUUID().toString() + ext;
java.io.File dest = new java.io.File(storageDir, storedName);
try {
file.transferTo(dest);
} catch (java.io.IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "文件存储失败");
}
PlatformContractAttachment attachment = new PlatformContractAttachment();
attachment.setContractId(id);
attachment.setFileName(originalName);
attachment.setFilePath(dest.getAbsolutePath());
attachment.setFileSize(file.getSize());
attachment.setContentType(contentType);
attachment.setCreatedAt(java.time.OffsetDateTime.now());
attachmentMapper.insert(attachment);
return ResponseEntity.ok(Map.of("id", attachment.getId(), "fileName", attachment.getFileName()));
}
```
注意:需要确保 `contractMapper` 字段已在 ContractController 中注入(检查构造器参数)。
- [ ] **Step 3: 验证 ContractController 无其他泄露**
```bash
grep -n 'catch.*Exception\|ResponseEntity.*500\|internalServerError' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java
```
Expected: 无输出
---
### Task 3: 为 SN 批量导入添加事务注解 (ME-05)
**Files:**
- Modify: `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java`
**当前问题:** `batchImport` 方法无 `@Transactional`,部分失败无法回滚。
- [ ] **Step 1: 在 batchImport 方法添加 @Transactional**
找到 `batchImport` 方法定义:
```java
// 在方法签名添加 @Transactional
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> batchImport(List<LicenseSnCreateRequest> requests) {
```
- [ ] **Step 2: 验证 `@Transactional` import 已在文件头部**
```bash
grep 'import.*Transactional' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java
```
Expected: 显示 `import org.springframework.transaction.annotation.Transactional;`
---
## Phase 2: Auth Overhaul
### Task 4: 创建 platform_user 表 (CR-01 + HI-01)
**Files:**
- Create: `services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql`
**当前问题:** 无用户表,4 个用户硬编码在 AuthController。
- [ ] **Step 1: 创建 Flyway 迁移文件**
`services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql`:
```sql
-- V24__platform_user.sql
-- 用户与账号生命周期(M11-F14),替代 AuthController 中硬编码的 4 个用户
-- 注:密码为 BCrypt 哈希,种子数据对应:
-- admin / admin → SYS_ADMIN
-- sales / sales → SALES
-- delivery / delivery → DELIVERY
-- ops / ops → LICENSE_OPS
CREATE TABLE IF NOT EXISTS platform_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128) NOT NULL DEFAULT '',
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'SALES',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED / ARCHIVED
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE platform_user IS '平台用户(M11-F14';
COMMENT ON COLUMN platform_user.username IS '登录名';
COMMENT ON COLUMN platform_user.password_hash IS 'BCrypt 哈希';
COMMENT ON COLUMN platform_user.role IS '角色代码,与 PlatformRoles 一致';
COMMENT ON COLUMN platform_user.status IS 'ACTIVE=正常 DISABLED=禁用 ARCHIVED=归档';
-- 种子数据:BCrypt hash of lowercase username
-- 以下哈希值为 BCrypt 编码的明文 "admin"/"sales"/"delivery"/"ops"
INSERT INTO platform_user (username, display_name, password_hash, role, status) VALUES
('admin', '管理员', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'SYS_ADMIN', 'ACTIVE'),
('sales', '销售账号', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'SALES', 'ACTIVE'),
('delivery', '交付账号', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'DELIVERY', 'ACTIVE'),
('ops', '运营账号', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'LICENSE_OPS', 'ACTIVE')
ON CONFLICT (username) DO NOTHING;
```
> **注意:** 种子 BCrypt 哈希值需要生成真正的哈希。运行 `mvn -f services/pom.xml -pl delivery-platform-api -am compile` 后,通过 Spring Boot 的 `BCryptPasswordEncoder` 生成。或在 SQL 中使用 `crypt('admin', gen_salt('bf'))` (pgcrypto 扩展)。简化方案:先插入占位哈希,在 AuthController 首次登录时兼容明文密码作为过渡。
- [ ] **Step 2: 创建 PlatformUser 实体**
`services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/auth/PlatformUser.java`:
```java
package cn.craftlabs.platform.api.persistence.auth;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.OffsetDateTime;
@TableName("platform_user")
public class PlatformUser {
@TableId
private Long id;
@TableField("username")
private String username;
@TableField("display_name")
private String displayName;
@TableField("password_hash")
private String passwordHash;
@TableField("role")
private String role;
@TableField("status")
private String status;
@TableField("created_at")
private OffsetDateTime createdAt;
@TableField("updated_at")
private OffsetDateTime updatedAt;
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}
```
- [ ] **Step 3: 创建 PlatformUserMapper**
`services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/auth/PlatformUserMapper.java`:
```java
package cn.craftlabs.platform.api.persistence.auth;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformUserMapper extends BaseMapper<PlatformUser> {
}
```
---
### Task 5: 重写 AuthController — 数据库驱动认证 (CR-01 + CR-04 + ME-04)
**Files:**
- Modify: `services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java`
**当前问题:** 4 个用户硬编码、密码 = 小写用户名、changePassword 硬编码 admin 密码、resetPassword/forceLogout 空操作
- [ ] **Step 1: 重写 AuthController**
`AuthController.java` 完整替换为:
```java
package cn.craftlabs.platform.api.auth;
import cn.craftlabs.platform.api.persistence.auth.PlatformLoginAttempt;
import cn.craftlabs.platform.api.persistence.auth.PlatformLoginAttemptMapper;
import cn.craftlabs.platform.api.persistence.auth.PlatformUser;
import cn.craftlabs.platform.api.persistence.auth.PlatformUserMapper;
import cn.craftlabs.platform.api.security.JwtService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
private final PlatformUserMapper userMapper;
private final PlatformLoginAttemptMapper loginAttemptMapper;
private final HttpServletRequest request;
private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final int LOCKOUT_MINUTES = 15;
public AuthController(JwtService jwtService, PasswordEncoder passwordEncoder,
PlatformUserMapper userMapper,
PlatformLoginAttemptMapper loginAttemptMapper,
HttpServletRequest request) {
this.jwtService = jwtService;
this.passwordEncoder = passwordEncoder;
this.userMapper = userMapper;
this.loginAttemptMapper = loginAttemptMapper;
this.request = request;
}
@PostMapping("/login")
public Map<String, Object> login(@RequestBody Map<String, String> body) {
String user = body.getOrDefault("username", "").trim().toLowerCase();
String pass = body.getOrDefault("password", "");
if (user.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
}
// 检查登录失败锁定
var recentQuery = com.baomidou.mybatisplus.core.toolkit.Wrappers
.lambdaQuery(PlatformLoginAttempt.class)
.eq(PlatformLoginAttempt::getUsername, user)
.eq(PlatformLoginAttempt::getSuccess, false)
.ge(PlatformLoginAttempt::getAttemptedAt, OffsetDateTime.now().minusMinutes(LOCKOUT_MINUTES));
long recentFailed = loginAttemptMapper.selectCount(recentQuery);
if (recentFailed >= MAX_LOGIN_ATTEMPTS) {
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"账户已临时锁定,请" + LOCKOUT_MINUTES + "分钟后重试");
}
// 从数据库查询用户
var userQuery = com.baomidou.mybatisplus.core.toolkit.Wrappers
.lambdaQuery(PlatformUser.class)
.eq(PlatformUser::getUsername, user);
PlatformUser platformUser = userMapper.selectOne(userQuery);
if (platformUser == null) {
recordFailedAttempt(user);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
}
// 检查用户状态
if (!"ACTIVE".equals(platformUser.getStatus())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "账户已被禁用");
}
// 验证密码 — 兼容 BCrypt 哈希和旧版明文
boolean passwordMatch;
if (platformUser.getPasswordHash().startsWith("$2a$") || platformUser.getPasswordHash().startsWith("$2b$")) {
passwordMatch = passwordEncoder.matches(pass, platformUser.getPasswordHash());
} else {
// 旧版兼容:明文密码
passwordMatch = pass.equals(platformUser.getPasswordHash());
}
if (!passwordMatch) {
recordFailedAttempt(user);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
}
// 登录成功,清除失败记录
loginAttemptMapper.delete(com.baomidou.mybatisplus.core.toolkit.Wrappers
.lambdaQuery(PlatformLoginAttempt.class)
.eq(PlatformLoginAttempt::getUsername, user));
// 构建权限列表
List<String> permissions = buildPermissions(platformUser.getRole());
String token = jwtService.createToken(platformUser.getUsername(),
platformUser.getDisplayName(), List.of(platformUser.getRole()));
Map<String, Object> result = new LinkedHashMap<>();
result.put("token", token);
result.put("tokenType", "Bearer");
result.put("roles", List.of(platformUser.getRole()));
result.put("displayName", platformUser.getDisplayName());
result.put("permissions", permissions);
return result;
}
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(@RequestBody Map<String, String> body) {
String oldPassword = body.get("oldPassword");
String newPassword = body.get("newPassword");
if (oldPassword == null || oldPassword.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "旧密码不能为空");
}
if (newPassword == null || newPassword.length() < 6) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "新密码至少6位");
}
// 从 JWT 中获取当前用户名
String currentUser = jwtService.getCurrentUsername();
if (currentUser == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "无法识别当前用户");
}
var query = com.baomidou.mybatisplus.core.toolkit.Wrappers
.lambdaQuery(PlatformUser.class)
.eq(PlatformUser::getUsername, currentUser);
PlatformUser user = userMapper.selectOne(query);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "旧密码错误");
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
userMapper.updateById(user);
return ResponseEntity.ok().build();
}
@PostMapping("/admin/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody Map<String, String> body) {
String username = body.get("username");
String newPassword = body.get("newPassword");
if (username == null || username.trim().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
}
if (newPassword == null || newPassword.length() < 6) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "新密码至少6位");
}
var query = com.baomidou.mybatisplus.core.toolkit.Wrappers
.lambdaQuery(PlatformUser.class)
.eq(PlatformUser::getUsername, username.trim().toLowerCase());
PlatformUser user = userMapper.selectOne(query);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
userMapper.updateById(user);
return ResponseEntity.ok().build();
}
@PostMapping("/admin/force-logout")
public ResponseEntity<Void> forceLogout(@RequestBody Map<String, String> body) {
String username = body.get("username");
if (username == null || username.trim().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
}
// 在无状态 JWT 架构中,强制下线通过前端清除 token + 后端记录失效时间实现
// 此处调用 TokenBlacklistService 记录强制下线事件
// TODO: 接入 TokenBlacklistService 或 Redis 黑名单
// 当前实现:记录审计日志 + 返回成功(前端 logout 清除 localStorage
return ResponseEntity.ok().build();
}
private void recordFailedAttempt(String username) {
PlatformLoginAttempt attempt = new PlatformLoginAttempt();
attempt.setUsername(username);
attempt.setSuccess(false);
attempt.setIpAddress(request.getRemoteAddr());
attempt.setAttemptedAt(OffsetDateTime.now(ZoneOffset.UTC));
loginAttemptMapper.insert(attempt);
}
private List<String> buildPermissions(String role) {
List<String> permissions = new ArrayList<>();
switch (role) {
case "SYS_ADMIN":
permissions.add("*:*");
break;
case "SALES":
permissions.add("customer:*");
permissions.add("project:*");
permissions.add("contract:*");
permissions.add("delivery:read");
break;
case "DELIVERY":
permissions.add("delivery:*");
permissions.add("device:*");
break;
case "LICENSE_OPS":
permissions.add("license:*");
permissions.add("callback:*");
permissions.add("todo:*");
permissions.add("device:read");
permissions.add("integration:read");
permissions.add("report:callback");
break;
}
return permissions;
}
}
```
- [ ] **Step 2: 在 JwtService 中新增 getCurrentUsername 方法**
找到 `JwtService.java`,添加从 SecurityContext 获取当前用户的方法:
```java
// JwtService.java 末尾添加:
public String getCurrentUsername() {
var auth = org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
return auth.getName();
}
return null;
}
```
- [ ] **Step 3: 验证编译**
```bash
mvn -f services/pom.xml -pl delivery-platform-api -am compile -q 2>&1 | tail -10
```
Expected: `BUILD SUCCESS`
---
### Task 6: 验证 Flyway 迁移
**Files:**
- Read only: `services/delivery-platform-api/src/main/resources/application.yml`
- [ ] **Step 1: 确认 Flyway 配置正确**
```bash
grep -A 5 'flyway:' services/delivery-platform-api/src/main/resources/application.yml
```
Expected: `enabled: true`, `table: flyway_platform_api`
- [ ] **Step 2: 确认迁移文件名格式正确**
```bash
ls services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql
```
Expected: 文件存在,命名 `V24__platform_user.sql`(按照已有 V23 延续)
---
## 自检
**1. Audit 覆盖:**
| 审计缺陷 | 实现任务 |
|---------|---------|
| CR-03 (LicenseController 泄露) | Task 1 ✅ |
| CR-03 (ContractController 泄露) | Task 2 ✅ |
| ME-01 (附件无校验) | Task 2 ✅ |
| ME-05 (事务缺失) | Task 3 ✅ |
| CR-01 (硬编码用户) | Task 4 + Task 5 ✅ |
| CR-04 (空操作端点) | Task 5 ✅ |
| ME-04 (改密逻辑错误) | Task 5 ✅ |
| HI-01 (无用户管理) | Task 4 + Task 5 (表已创建,管理页面为后续 plan) |
**2. Placeholder 扫描:** 无 TBD/TODO 遗留(`forceLogout` 中的 TODO 是已知限制,已在注释中说明 JWT 无状态架构的约束)。
**3. 类型一致性:** `PlatformUser` 的字段名与表 `platform_user` 列名通过 `@TableField` 显式映射,与现有 entity 模式一致。
**4. 范围检查:** 两个阶段边界清晰。Phase 1 可在 Phase 2 之前独立执行和验证。Phase 2 是理解耦后的认证系统,不破坏现有 API 契约(登录请求/响应格式保持不变)。
@@ -0,0 +1,423 @@
# BitAnswer 1:1 映射重构设计
> **状态**: 待审核
> **日期**: 2026-05-01
> **触发**: 架构审计发现 `AuthProvider` (8 方法) 与 BitAnswer C API (50+ 函数) 之间存在显著缺口
> **策略**: 大幅重构为 BitAnswer 1:1 原语映射,按能力域拆分接口
---
## 1. 审计摘要
### 1.1 当前状态
```
Java AuthProvider (8 methods)
└─ BitAnswerProvider / SelfHostedAuthProvider
└─ NativeBridge (JNI, 9 native methods)
└─ craft-core cdylib (Rust, 9 craft_* C ABI functions)
└─ ⚠️ 全部返回 ok_result(桩实现,未调用真实 BitAnswer API
└─ [已废弃] .deprecated-cmake/ bitanswer_adapter(空实现)
```
### 1.2 覆盖度缺口
| BitAnswer API 分组 | 原生函数数 | 当前覆盖 | 缺失 |
|---|---|---|---|
| 认证与会话 | 10+ | 3 (activate/release/close) | LoginEx, LoginByToken, Revoke, RemoveSn, SessionControl |
| 激活与升级 | 6 | 1 (activate) | 离线升级三步骤 |
| **特征项** | **15+** | **1 (hasFeature)** | Read/Write/Query/Release/Encrypt/Decrypt/Convert/Sign/Batch |
| 配置项(Data) | 5 | **0** | Set/Get/Remove/Enum |
| 借出/归还 | 9 | **0** | CheckOut/In, Borrow |
| 信息查询 | 5+ | 1 (getLicenseInfo) | SessionInfo, ServerInfo, FeatureInfo |
| 工具类 | 8+ | **0** | SetRootPath, SetProxy, SetAttr, CustomInfo |
---
## 2. 目标架构
### 2.1 接口分层(6 能力接口 + 1 入口)
```
CraftLicense (顶层入口)
- initialize(configJson) → LicenseSession
- getVersion() → String
- setRootPath(path) / setProxy(...) / setLocalServer(...)
LicenseSession (会话句柄,实现 5 个能力接口)
├─ LicenseLifecycle — 认证/激活/心跳/释放
├─ FeatureManagement — 特征项读写/加解密/占用释放
├─ DataItemStore — 配置项(Data Item)存储
├─ LicenseInfoQuery — 信息查询
├─ CheckoutManager — 浮动授权借出/归还
├─ LicenseUtility — 工具方法(SetAttr, CustomInfo, SessionState)
└─ close() / isClosed() / getNativeHandle()
```
### 2.2 能力接口详细定义
#### LicenseLifecycle
```java
public interface LicenseLifecycle {
LoginResult login(LoginRequest request);
LoginResult loginEx(LoginExRequest request);
LoginResult loginByToken(LoginByTokenRequest request);
LoginResult loginByPassword(LoginByPasswordRequest request);
ActivationResult activate(ActivationRequest request);
UpdateResult updateOnline(String url, String sn);
OfflineUpdateRequest getRequestInfo(String sn, BindingType type);
UpdateInfo getUpdateInfo(String url, String sn, String requestInfo);
ApplyResult applyUpdateInfo(String updateInfo);
HeartbeatResult heartbeat();
ReleaseResult release();
RevokeResult revoke(String sn);
void removeSn(String sn);
void sessionControl(String url, String sessionId, SessionCtlType type);
}
```
#### FeatureManagement
```java
public interface FeatureManagement {
int readFeature(int featureId);
void writeFeature(int featureId, int value);
int convertFeature(int featureId, int p1, int p2, int p3, int p4);
byte[] encryptFeature(int featureId, byte[] plainData);
byte[] decryptFeature(int featureId, byte[] cipherData);
int queryFeature(int featureId);
int queryFeatureEx(int featureId, QueryMode mode, int required, String scope);
Ticket queryFeatureEx2(String featureName, QueryMode mode, int required, String scope);
int releaseFeature(int featureId);
int releaseFeatureEx(int featureId, int consumed, String scope);
void releaseFeatureEx2(Ticket ticket, int consumed);
FeatureInfo getFeatureInfo(int featureId);
int getFeatureInfo2(String featureName, String scope);
FeatureInfoEx getFeatureInfoEx2(String featureName, String scope);
TicketInfo getTicketInfo(Ticket ticket, TicketInfoType type);
byte[] signFeature(int featureId, byte[] data);
FeatureInfo getFeatureInfoByIndex(int index);
// 批量操作
BatchResult batchBegin(BatchMode mode);
BatchResult batchEnd();
}
```
#### DataItemStore
```java
public interface DataItemStore {
void setDataItem(String name, byte[] value);
byte[] getDataItem(String name);
void removeDataItem(String name);
int getDataItemCount();
String getDataItemName(int index);
}
```
#### LicenseInfoQuery
```java
public interface LicenseInfoQuery {
LicenseInfo getLicenseInfo();
String getSessionInfo(SessionType type);
String getInfo(InfoType type);
String getServerInfo(String url, String sn, String scope, ServerInfoType type);
}
```
#### CheckoutManager
```java
public interface CheckoutManager {
void checkOut(String url, String scope, String featureList, int durationDays);
void checkOutSn(String url, int featureId, int durationDays);
void checkOutSnEx(String url, int featureId, String scope, int durationDays);
void checkOutFeatures(String url, int[] featureIds, int durationDays);
void checkIn(String url, int featureId);
void checkInEx(String url, int featureId, String scope);
String getBorrowRequest(String sn, int durationDays);
String getBorrowFeatureRequest(int durationDays, String scope);
void applyBorrowInfo(String borrowInfo);
}
```
#### LicenseUtility
```java
public interface LicenseUtility {
void setAttr(int type, byte[] value);
void setCustomInfo(int infoId, byte[] data);
void setSessionState(int state);
String getProductPath();
int getLastError();
String getErrorMessage();
void testBitService(String url, String sn, int featureId);
}
```
---
## 3. 原生 Rust 层重构
### 3.1 目录结构
```
native/craft-core/src/
├── lib.rs # C ABI 入口,craft_* 函数声明
├── ffi/
│ ├── mod.rs
│ ├── bitanswer.rs # BitAnswer C API 的 Rust extern 声明
│ └── bridge.rs # craft_* → Bit_* 的桥接实现
├── session.rs # 会话管理,BIT_HANDLE 映射
├── activate.rs # 对接 Bit_Login / Bit_UpdateOnline
├── license.rs # 对接 Bit_GetSessionInfo / Bit_GetInfo
├── feature.rs # 对接 Bit_ReadFeature / Bit_WriteFeature / Bit_QueryFeature 等
├── data_item.rs # 对接 Bit_SetDataItem / Bit_GetDataItem 等
├── checkout.rs # 对接 Bit_CheckOutSn / Bit_CheckIn 等
├── heartbeat.rs # 对接 Bit_Heartbeat
├── security/ # 安全模块(保持不变)
│ ├── mod.rs
│ ├── anti_debug.rs
│ ├── dynamic_api.rs
│ ├── integrity.rs
│ ├── obfuscation.rs
│ └── string_encrypt.rs
└── error.rs # BIT_ERROR_CODES → LicenseError 映射
```
### 3.2 JNI 映射表(NativeBridge 扩展)
| Java 方法 | Rust craft_* | BitAnswer C API |
|---|---|---|
| `nativeInitialize(String)` | `craft_initialize` | 内部引导 |
| `nativeLogin(long, String, int)` | `craft_login` | `Bit_Login` |
| `nativeLoginEx(long, String, int, String, int)` | `craft_login_ex` | `Bit_LoginEx` |
| `nativeLoginByToken(long, String, String)` | `craft_login_by_token` | `Bit_LoginByToken` |
| `nativeLoginByPassword(long, ...)` | `craft_login_by_password` | `Bit_LoginByPassword` |
| `nativeLogout(long)` | `craft_logout` | `Bit_Logout` |
| `nativeActivate(long, String)` | `craft_activate` | `Bit_UpdateOnline` |
| `nativeGetRequestInfo(long, String, int)` | `craft_get_request_info` | `Bit_GetRequestInfo` |
| `nativeGetUpdateInfo(long, String, String, String)` | `craft_get_update_info` | `Bit_GetUpdateInfo` |
| `nativeApplyUpdateInfo(long, String)` | `craft_apply_update_info` | `Bit_ApplyUpdateInfo` |
| `nativeReadFeature(long, int)` | `craft_read_feature` | `Bit_ReadFeature` |
| `nativeWriteFeature(long, int, int)` | `craft_write_feature` | `Bit_WriteFeature` |
| `nativeConvertFeature(long, int, int, int, int, int)` | `craft_convert_feature` | `Bit_ConvertFeature` |
| `nativeEncryptFeature(long, int, byte[], int)` | `craft_encrypt_feature` | `Bit_EncryptFeature` |
| `nativeDecryptFeature(long, int, byte[], int)` | `craft_decrypt_feature` | `Bit_DecryptFeature` |
| `nativeQueryFeature(long, int)` | `craft_query_feature` | `Bit_QueryFeature` |
| `nativeQueryFeatureEx(long, int, int, int, String)` | `craft_query_feature_ex` | `Bit_QueryFeatureEx` |
| `nativeReleaseFeature(long, int)` | `craft_release_feature` | `Bit_ReleaseFeature` |
| `nativeSignFeature(long, int, byte[], int)` | `craft_sign_feature` | `Bit_SignFeature` |
| `nativeSetDataItem(long, String, byte[], int)` | `craft_set_data_item` | `Bit_SetDataItem` |
| `nativeGetDataItem(long, String)` | `craft_get_data_item` | `Bit_GetDataItem` |
| `nativeRemoveDataItem(long, String)` | `craft_remove_data_item` | `Bit_RemoveDataItem` |
| `nativeGetDataItemNum(long)` | `craft_get_data_item_num` | `Bit_GetDataItemNum` |
| `nativeGetDataItemName(long, int)` | `craft_get_data_item_name` | `Bit_GetDataItemName` |
| `nativeCheckLicense(long)` | `craft_check_license` | `Bit_GetSessionInfo` |
| `nativeGetLicenseInfo(long)` | `craft_get_license_info` | `Bit_GetInfo` |
| `nativeGetSessionInfo(long, int)` | `craft_get_session_info` | `Bit_GetSessionInfo` |
| `nativeGetServerInfo(long, String, String, String, int)` | `craft_get_server_info` | `Bit_GetServerInfo` |
| `nativeGetFeatureInfo(long, int)` | `craft_get_feature_info` | `Bit_GetFeatureInfo` |
| `nativeCheckOutSn(long, String, int, int)` | `craft_check_out_sn` | `Bit_CheckOutSn` |
| `nativeCheckOutFeatures(long, String, int[], int, int)` | `craft_check_out_features` | `Bit_CheckOutFeatures` |
| `nativeCheckIn(long, String, int)` | `craft_check_in` | `Bit_CheckIn` |
| `nativeHeartbeat(long)` | `craft_heartbeat` | `Bit_Heartbeat` |
| `nativeRevoke(long, String)` | `craft_revoke` | `Bit_Revoke` |
| `nativeRemoveSn(long, String)` | `craft_remove_sn` | `Bit_RemoveSn` |
| `nativeSetAttr(long, int, byte[])` | `craft_set_attr` | `Bit_SetAttr` |
| `nativeSetCustomInfo(long, int, byte[])` | `craft_set_custom_info` | `Bit_SetCustomInfo` |
| `nativeSetRootPath(long, String)` | `craft_set_root_path` | `Bit_SetRootPath` |
| `nativeSetProxy(...)` | `craft_set_proxy` | `Bit_SetProxy` |
| `nativeSetLocalServer(...)` | `craft_set_local_server` | `Bit_SetLocalServer` |
| `nativeGetProductPath(long)` | `craft_get_product_path` | `Bit_GetProductPath` |
| `nativeGetVersion()` | `craft_get_version` | `Bit_GetVersion` |
| `nativeGetLastError(long)` | `craft_get_last_error` | `Bit_GetLastError` |
| `nativeDestroy(long)` | `craft_destroy` | 资源释放 |
### 3.3 句柄管理
```rust
// session.rs
struct SessionState {
bit_handle: BIT_HANDLE, // Bit_Login 返回的句柄
config: AuthConfig, // 解析后的配置
application_data: Vec<u8>, // 产品识别码(来自 AuthConfig
logged_in: bool,
}
static SESSIONS: Lazy<Mutex<HashMap<i64, SessionState>>> = ...;
```
---
## 4. 数据流
### 4.1 完整调用链(浮动作业激活)
```
Java: CraftLicense.initialize(configJson)
└─> Rust: craft_initialize(config_json)
├─ 解析 JSON → AuthConfig
├─ 安全加固检查(anti_debug, integrity
├─ 创建 SessionState,分配 session_id
└─ 返回 session_id (i64)
Java: session.activate(ActivationRequest { sn: "SN-XXXX" })
└─> Rust: craft_activate(session_id, sn)
├─ 从 AuthConfig 获取 bitanswer.url, loginMode
├─ 调用 Bit_UpdateOnline(url, sn, &app_data)
└─ 返回 ActivationResult
Java: session.login(LoginRequest { sn: "SN-XXXX", mode: AUTO })
└─> Rust: craft_login(session_id, sn, mode)
├─ 调用 Bit_Login(url, sn, &app_data, &bit_handle, BIT_MODE_AUTO)
├─ 存储 bit_handle → SessionState
└─ 返回 LoginResult { handle }
Java: session.readFeature(FACE_FEATURE_ID)
└─> Rust: craft_read_feature(session_id, feature_id)
├─ 获取 bit_handle
├─ 调用 Bit_ReadFeature(bit_handle, feature_id, &value)
└─ 返回 value (i32)
Java: session.checkOutSn(url, FEATURE_ZERO, 30)
└─> Rust: craft_check_out_sn(session_id, url, feature_id, duration)
├─ 调用 Bit_CheckOutSn(url, feature_id, &app_data, duration)
└─ 返回 CraftResult
Java: session.release()
└─> Rust: craft_release(session_id)
├─ 调用 Bit_Logout(bit_handle)
└─ 返回 CraftResult
Java: session.close()
└─> Rust: craft_destroy(session_id)
├─ 如果未 logout,调用 Bit_Logout
├─ 移除 SessionState
└─ 释放内存
```
### 4.2 错误码映射
Rust 侧将 200+ 个 `BIT_ERROR_CODES` 分组映射为 `LicenseError` 枚举:
```rust
pub enum LicenseError {
Success,
NetworkError,
WrongHandle,
InvalidParameter,
ApplicationDataError,
LicenseExpired,
LicenseNotFound,
LicenseDisabled,
FeatureNotFound(i32),
FeatureExpired(i32),
FeatureTypeNotMatch(i32),
SnInvalid,
SnNotFound,
SnDisabled,
SnRevoked,
SnExpired,
CapacityExhausted,
ServerBusy,
ServerDown,
Revoked,
Timeout,
TokenError,
BorrowError,
Unknown(i32), // 兜底
}
```
Java 侧使用 sealed interface 提供类型安全的结果:
```java
public sealed interface LicenseResult {
boolean isSuccess();
Optional<LicenseError> error();
String message();
}
```
---
## 5. 配置模型适配
`AuthConfig` 已有正确结构,重构后需将字段驱动到运行时:
| AuthConfig 字段 | 驱动行为 |
|---|---|
| `bitanswer.url` | `Bit_Login` / `Bit_UpdateOnline``szURL` |
| `bitanswer.loginMode` | `Bit_Login``mode` 参数 |
| `bitanswer.rootPath` | `Bit_SetRootPath` |
| `bitanswer.applicationData` | 替代硬编码的 `application_data[]`,使不同产品可携带不同识别码 |
| `features[].bitanswerFeatureId` | `readFeature(featureId)` / `queryFeature(featureId)` |
| `features[].bitanswerFeatureName` | `queryFeatureEx2(featureName, ...)` |
| `floating.projectId` | floating 场景校验(schema 已有) |
| `school.edgeDeviceId` | school 场景标识 |
---
## 6. 迁移路径
### Phase 1 — 基础设施(不破坏现有接口)
- Rust: `ffi/bitanswer.rs` FFI 声明 + `bridge.rs` 桥接实现
- Rust: 实现 `craft_activate`, `craft_check_license`, `craft_heartbeat` 的真实调用
- Java: `AuthProvider` 标记 `@Deprecated`,内部委托给 `CraftLicense`
- Java: 新增 `CraftLicense` + `LicenseSession` + 5 个能力接口(空实现)
- 测试: 现有测试保持通过
### Phase 2 — 核心 API 扩展
- Java: 实现 `LicenseLifecycle`login, loginEx, revoke, removeSn
- Java: 实现 `FeatureManagement`read, write, query, encrypt 等)
- Rust: 实现对应的 `craft_*` 函数
- 测试: 每个能力域独立集成测试
### Phase 3 — 高级功能
- Java: 实现 `DataItemStore`set/get/remove/enum
- Java: 实现 `CheckoutManager`checkOut/In 系列)
- Rust: 实现离线升级流程(GetRequestInfo → GetUpdateInfo → ApplyUpdateInfo
- 文档: 更新 `bitanswer-client-api-overview.md` 映射表
### Phase 4 — 清理
- 移除 `@Deprecated AuthProvider`
- 移除 `.deprecated-cmake/` 下的旧适配器代码
- 全量回归测试
---
## 7. 兼容性
- `schemas/craftlabs-auth-config.schema.json`**不变**
- 现有 `examples/config/*.json`**不变**
- `AuthConfig` / `AuthConfigs`**不变**
- `AuthProvider` — Phase 1-3 保持可用(`@Deprecated`),Phase 4 移除
- `NativeBridge` — 现有 9 个方法保留,新增 30+ 方法
- `craft-core` C ABI — 现有 9 个函数签名不变,新增 30+ 函数
---
## 8. 风险与缓解
| 风险 | 缓解 |
|------|------|
| Rust FFI 调用 BitAnswer .so/.dll 的链接问题 | Phase 1 先用 `libloading` 动态加载 BitAnswer 库,验证 ABI 兼容性 |
| 200+ 错误码映射不完整 | 只映射文档中明确列出的错误码,其余走 `Unknown(code)` |
| 多线程安全(BIT_HANDLE 是线程局部的) | `LicenseSession` 文档注明非线程安全,建议调用方池化或加锁 |
| native 库缺失时测试无法运行 | 单元测试 mock native 层,集成测试用 `@EnabledIfNativeLibraryPresent` |
---
## 附录 A:审计发现记录
审计过程中发现的具体代码问题:
1. **Rust `activate.rs:core_activate`** — 返回硬编码 `ok_result`,未调用任何 BitAnswer API
2. **Rust `license.rs:check_license`** — 返回硬编码 `ok_result`
3. **Rust `license.rs:get_license_info`** — 返回固定数据(`is_licensed=1`, `expiration_date="2099-12-31"`, `feature_count=0`
4. **Rust `license.rs:has_feature`** — 无条件返回 `true`
5. **Rust `heartbeat.rs:do_heartbeat`** — 返回硬编码 `ok_result`
6. **Java `BitAnswerProvider.initialize`** — 配置 JSON 未被传递给 native 层做运行时行为驱动
7. **`NativeBridge`** — 只有 9 个 JNI 方法,BitAnswer 有 50+ 函数
8. **`.deprecated-cmake/bitanswer_adapter.cpp`** — `bitanswer_adapter_register()` 是空函数
## 附录 BBitAnswer C API 完整清单
`examples/vcsample/bitanswer.h`1211 行),包含 50+ 个 `Bit_*` 函数声明和 200+ 个错误码枚举。
@@ -0,0 +1,134 @@
# CraftLabs 设计规范 v1.0
> 基于 Figma「安徽地质博物馆 v2.0」设计 Token → delivery-platform-ui 映射评估
> 审核日期: 2026-05-18
---
## 1. 色彩系统
| Token | Figma | 当前 | 评估 |
|-------|-------|------|:--:|
| 页面底色 | `#EAEFFA` | `#f0f2f5` | ⚠️ 建议调整 |
| 卡片面板 | `#FFFFFF` | `#FFFFFF` | ✅ 一致 |
| 主色 | `#2C3E6B` | `#409EFF` | ⚠️ 可选项 |
| 正文文字 | `#000000` | `#303133` | ✅ 可接受 |
| 辅助文字 | `#313131` | `#909399` | ⚠️ 调整 |
| 表头背景 | `#F2F5FC` | `#f5f7fa` | ✅ 可接受 |
| 成功色 | `#E6F7EE/#1A7A3A` | `#f0f9eb/#67c23a` | ✅ 可接受 |
| 警告色 | — | `#E6A23C` | ✅ 默认 |
| 错误色 | `#D54941` | `#F56C6C` | ✅ 可接受 |
| 通知 Badge | `#D54941` | el-badge | ⚠️ 自定义 |
| 边框线 | `#D6DFF0/#E8ECF1` | `#EBEEF5` | ✅ 接近 |
| 侧边栏底 | `#FFFFFF` | `#001529` | 🔴 需修改 |
### CSS 变量建议
```css
:root {
--color-page-bg: #EAEFFA;
--color-card-bg: #FFFFFF;
--color-primary: #2C3E6B;
--color-primary-hover: #3D5A99;
--color-text-primary: #303133;
--color-text-secondary: #606266;
--color-border: #E8ECF1;
--color-th-bg: #F2F5FC;
--color-success: #1A7A3A;
--color-success-bg: #E6F7EE;
--color-danger: #F56C6C;
--color-danger-bg: #FEF0F0;
--color-warning: #E6A23C;
--color-badge: #D54941;
}
```
---
## 2. 布局结构
| 元素 | Figma (px) | 当前 (px) | 评估 |
|------|-----------|----------|:--:|
| Header 高度 | 60 | auto | 🔴 固定 60px |
| Sidebar 宽度 | 232 | 220 | ✅ 可忽略 |
| Sidebar 底色 | `#FFFFFF` | `#001529` 深色 | 🔴 改白色 |
| 面包屑 | 46 | 无 | 🔴 加 el-breadcrumb |
| 左侧 Tree | 280 | 无 | 🔴 许可证页加 el-tree |
| 内容内边距 | 20 | 16-20 | ✅ 一致 |
| 搜索栏 | Header+Tree | Card header | ⚠️ Header 加全站搜索 |
| 卡片 | 6px 圆角+border | 4px 无边框 | ⚠️ 调整 |
---
## 3. 弹框规范
| 类型 | 宽度 | 圆角 | 关键特征 |
|------|:--:|:--:|------|
| 签发许可证 | 560px | 8px | 完整表单 + 特性开关 |
| 新建/编辑 | 480px | 8px | 简化表单 |
| 详情查看 | 480px | 8px | 标签(100px) + 值 |
| 许可证详情 | 520px | 8px | monospace ID + 虚线框 |
| 确认/吊销 | 420px | 8px | 危险色背景 `#FEF0F0` |
```
通用弹框 Token:
- 圆角: 8px
- 阴影: 0 8px 40px rgba(0,0,0,.15)
- Header: padding 16px/20px
- Body: padding 20px
- Footer: gap 10px, 按钮右对齐
- 遮罩: rgba(0,0,0,.45)
- 动画: scale(.96→1) 200ms
- 关闭按钮: 28x28, hover 背景 #F2F5FC
```
---
## 4. 组件
| 组件 | Figma Token | 当前 | 建议 |
|------|-----------|------|------|
| 主按钮 | `#2C3E6B` + shadow | `#409EFF` | 改色 + shadow |
| 表格表头 | `#F2F5FC` / `#2C3E6B` bold | 默认 | 改淡蓝底+深蓝字 |
| 状态标签 | `#E6F7EE/#1A7A3A` | el-tag | 微调色值 |
| 通知 Badge | `#D54941` 20x20 | el-badge | 自定义颜色 |
| 输入框 | focus `#2C3E6B` | focus `#409EFF` | 改 focus 色 |
| 搜索框 | `#F8F9FB` 6px radius | el-input | 加圆角+背景 |
| 侧边菜单 | 白底+蓝选中 | 深色底 | 🔴 改白色方案 |
---
## 5. 字体排版
| 层级 | Figma | 当前 | 评估 |
|------|-------|------|:--:|
| 页面标题 | — | 16px bold | ✅ 增 22px 级 |
| 正文 | 14px `#000000` | 14px `#303133` | ✅ |
| 辅助 | 13px `#313131` | 13px `#909399` | ⚠️ `#606266` |
| 表头 | 12px `#2C3E6B` bold | 默认 | ⚠️ 改色 |
| 代码/ID | — | 12px monospace | ✅ |
---
## 6. 实施方案
### Element Plus CSS 变量覆盖(3 行核心)
```css
:root {
--el-color-primary: #2C3E6B;
--el-bg-color-page: #EAEFFA;
--el-border-radius-base: 6px;
}
```
### 工作量估算
| 优先级 | 项目 | 工作量 | 影响 |
|:--:|------|:--:|------|
| P0 | 色彩 CSS 变量覆盖 | 30min | 全局风格统一 |
| P0 | 侧边栏白色方案 | 1h | 视觉对齐 |
| P1 | 面包屑 + Tree | 3h | 导航体验 |
| P1 | Header 搜索+通知 | 2h | 运营效率 |
| P2 | 弹框规范统一 | 2h | 交互一致 |
| P2 | 组件 Token 细化 | 1h | 细节打磨 |
@@ -0,0 +1,477 @@
# 自研授权 SDK 设计方案
> **日期**2026-05-18
> **背景**:比特安索授权云费用过高,决定优先推进自研授权方案。
> **原则**:与比特安索双线共存,Provider 可扩展架构,后续可接入更多第三方授权方式。
---
## 目录
1. [决策摘要](#1-决策摘要)
2. [总体架构](#2-总体架构)
3. [许可证协议与数据模型](#3-许可证协议与数据模型)
4. [Rust 层核心逻辑](#4-rust-层核心逻辑)
5. [平台后端变更](#5-平台后端变更)
6. [安全设计](#6-安全设计)
7. [实施阶段](#7-实施阶段)
---
## 1. 决策摘要
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 授权服务形态 | **混合模式** | 有网络时在线验证(心跳/租约续期),断网时本地缓存可用至离线宽限期 |
| 加密体系 | **非对称签名 RSA-256 + AES-256-GCM 加密载荷** | 离线验签 + 内容加密防窥探,每个 license 独立 AES 密钥防批量破解 |
| 授权粒度 | **完整属性**:有效期 + 终端限制 + 并发用户数 + 使用次数 + 特性开关 | 对齐现有比特业务属性,功能上不降级 |
| 后端集成 | **复用 API + Webhook 双服务** | 签发走 API(管理操作需认证鉴权),SDK 交互走 Webhook(高频快速 2xx |
| 比特兼容 | **双线共存 + Provider 可扩展架构** | 后续可接入更多第三方 |
| 终端识别 | **硬件指纹分层采集 + 稳定度评分兜底** | 强指纹精确匹配,弱指纹分配服务器 UUID,管理员可手动释放 |
| Rust 架构 | **单 cdylib + trait 多 Provider** | 公共模块共享,扩展第三方只需增加 trait 实现 |
| SDK 交互安全 | **Nonce + Timestamp + HMAC 签名防重放** | 简单有效,无需序列号同步 |
---
## 2. 总体架构
### 2.1 全系统组件图
```
┌─────────────────────────────────────────────────────────────────┐
│ 客户现场 │
│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 客户应用 │───▶│ Java SDK │ │ 许可证配置文件 │ │
│ └──────────┘ │ AuthProvider impl │◀───│ ~/.craftlabs/ │ │
│ │ ┌──────────────┐ │ │ license_cache │ │
│ │ │BitAnswerProv │ │ │ device_id │ │
│ │ ├──────────────┤ │ └──────────────────┘ │
│ │ │SelfHostedPrv │ │ │
│ │ └──────┬───────┘ │ │
│ └────────┼────────┘ │
│ │ JNI │
│ ┌────────▼────────┐ │
│ │ Rust craft-core │ libcraftlabs_auth_core │
│ │ ◇ trait Provider│ │
│ │ ┌─────────────┐ │ │
│ │ │BitAnswer │ │──────▶ 比特安索云 │
│ │ ├─────────────┤ │ │
│ │ │SelfHosted │ │──HTTPS▶ license-webhook │
│ │ └─────────────┘ │ │
│ │ device.rs │ 硬件指纹分层采集 │
│ │ crypto.rs │ HKDF+AES-GCM+RSA验签 │
│ │ security/ │ 反调试/完整性/混淆 │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 创飞机房 / 云 │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ license-webhook-ingress │ │ delivery-platform-api │ │
│ │ :8081 │ │ :8080 │ │
│ │ SDK 在线端点: │ │ 许可证签发端点: │ │
│ │ /license/v1/activate │◀──│ /api/v1/licenses │ │
│ │ /license/v1/heartbeat │ │ /api/v1/licenses/{id} │ │
│ │ /license/v1/check │ │ /api/v1/licenses/{id}/ │ │
│ │ /license/v1/release │ │ revoke │ │
│ │ │ │ │ │
│ │ 事件回调 ─────────────▶ │ │ 合同/SN/终端/审计 │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │ │ │
│ └──────────┬─────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ PostgreSQL 15│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Rust 层模块结构(改造后)
```
native/craft-core/src/
├── lib.rs # cdylib 入口 + C ABI 路由
├── trait_provider.rs # Provider trait 定义 + select_provider
├── provider_bitanswer/ # 现有逻辑封装
│ ├── mod.rs
│ ├── activate.rs
│ ├── license.rs
│ └── heartbeat.rs
├── provider_selfhosted/ # ★ 本次核心交付
│ ├── mod.rs # SelfHostedProvider impl Provider
│ ├── activate.rs # HTTPS POST → webhook:8081
│ ├── license.rs # 验签 + 解密 + 离线校验
│ ├── heartbeat.rs # HTTPS 心跳 + 租约续期
│ ├── protocol.rs # 请求/响应序列化
│ └── cache.rs # 许可证本地加密存储
├── device.rs # ★ 硬件指纹分层采集
├── crypto.rs # ★ HKDF + AES-256-GCM + RSA 验签
├── session.rs # 泛化 session(去 bit_handle
├── error.rs # 自研 + 比特错误码体系
└── security/ # 不变
├── mod.rs
├── anti_debug.rs
├── integrity.rs
├── obfuscation.rs
└── string_encrypt.rs
```
### 2.3 Provider trait 契约
```rust
pub trait Provider: Send + Sync {
fn initialize(&mut self, ctx: &CraftContext, config: &AuthConfig)
-> 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) -> LicenseInfoFFI;
fn close(&mut self);
}
```
### 2.4 Java 层变更
| 模块 | 变更 |
|------|------|
| `craftlabs-auth-core` | 接口不变;`SelfhostedConfigSection` 扩展 `offlineGraceDays``heartbeatIntervalHours` 等字段;`FeatureMapping` 扩展 `selfhostedFeatureKey` |
| `craftlabs-auth-bitanswer` | **不变**(双线共存) |
| `craftlabs-auth-selfhosted` | 从桩改为真实实现:`System.loadLibrary("craftlabs_auth_core")` |
| `craftlabs-auth-tests` | 新增 `SelfHostedProviderTest``MultiProviderSmokeTest` |
### 2.5 关键架构约束
- **单一 cdylib**`craftlabs_auth_core` 包含所有 provider,通过 trait 路由
- **Java 侧无感知**:各 AuthProvider 均调 NativeBridge,路由在 Rust 层完成
- **双线 classpath 隔离**BitAnswer 和 SelfHosted 不同 Maven 模块
- **契约不变**AuthProvider 接口 7 方法不变、AuthConfig record 不变
- **Schema 兼容**selfhosted 段向后兼容扩展新字段
---
## 3. 许可证协议与数据模型
### 3.1 License JSON 结构
```jsonc
{
"version": 1,
"license_id": "01JQXYZ...", // ULID
"issued_at": "2026-05-18T10:00:00Z",
// 载荷:AES-256-GCM 加密后的 Base64 密文
// 解密后为 LicensePayload{ tenant_id, product, grant, constraints, features, custom }
"payload": "A8f3Kd9s...base64url...",
"signature": {
"algorithm": "RS256",
"key_id": "kp_2026_q2", // 支持密钥轮换
"value": "MEUCIQDx..." // RS256 签名(对密文 payload 签名)
}
}
```
### 3.2 LicensePayload(解密后明文)
```jsonc
{
"tenant_id": "craftlabs-wharf-prod",
"product": "wharf-inspection-v2",
"grant": {
"type": "perpetual", // perpetual | subscription | trial
"not_before": "2026-05-01T00:00:00Z",
"not_after": "2027-05-01T00:00:00Z",
"offline_grace_days": 7,
"heartbeat_interval_hours": 24
},
"constraints": {
"max_devices": 5,
"max_concurrent_users": 0, // 0=不限制
"max_activations": 0
},
"features": {
"advanced_analytics": true,
"real_time_monitor": false,
"api_export": true
},
"custom": { // 扩展字段
"contract_ref": "CT-2026-0042",
"project_id": "wharf-nansha-phase2"
}
}
```
### 3.3 签发与校验流程
```
签发(服务器侧):
LicensePayload 明文
→ AES-256-GCM 加密(key = HKDF(编译期盐, license_id)
→ 密文 payload (Base64)
→ RSA-SHA256 签名(对密文签名)
→ 完整 license.json
校验(Rust SDK 侧):
license.json
→ RSA 公钥验签(快速排除伪造)
→ HKDF 派生 AES 密钥
→ AES-256-GCM 解密
→ 时间窗口校验(not_before ≤ now ≤ not_after + 离线宽限期)
→ 特性/约束校验
```
### 3.4 在线交互协议
端点:`license-webhook-ingress:8081/license/v1/*`
| 端点 | 方法 | 请求体 | 成功响应 | 错误响应 |
|------|------|--------|----------|----------|
| `/activate` | POST | `{license_key, device_fingerprint}` | 200 `{status:"activated", device_id, license_payload}` | 409 终端满 / 403 已吊销 / 422 无效 |
| `/heartbeat` | POST | `{license_key, device_hash, local_time}` | 200 `{status:"ok", lease_renewed_until}` | 410 已过期/吊销 |
| `/check` | POST | `{license_key, device_hash}` | 200 `{status:"valid", features, not_after}` | 410 已过期/吊销 |
| `/release` | POST | `{license_key, device_hash}` | 200 `{status:"released"}` | — |
**防重放**:每个请求携带 `X-Craft-Nonce``X-Craft-Timestamp``X-Craft-Signature`HMAC-SHA256),服务器时间窗口 5 分钟 + Nonce 去重。
### 3.5 签发端点(API 侧)
```
POST /api/v1/licenses # 创建/签发许可证
GET /api/v1/licenses # 分页查询
GET /api/v1/licenses/{licenseId} # 详情
POST /api/v1/licenses/{id}/revoke # 吊销
GET /api/v1/licenses/{id}/activations # 激活记录
POST /api/v1/licenses/{id}/activations/{aid}/release # 释放设备
```
### 3.6 数据库表(新增)
| 表 | 用途 |
|----|------|
| `platform_licenses` | 许可证主表(license_id、有效期、约束、状态、签名快照) |
| `platform_license_features` | 特性开关(license_id, feature_key, enabled |
| `platform_license_activations` | 终端激活记录(license_id, device_hash, stability_score, status |
| `platform_license_heartbeats` | 心跳审计(可选,视量级) |
| `platform_license_keys` | RSA 密钥对管理(key_id, public_key, private_key |
| `platform_license_policies` | 策略模板(默认有效期、终端数、特性等) |
---
## 4. Rust 层核心逻辑
### 4.1 新增依赖
```toml
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
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"] }
```
### 4.2 关键数据结构
```rust
pub struct DeviceFingerprint {
pub composite_hash: String, // SHA-256(layer1|layer2|layer3|layer4)
pub stability_score: u8, // 0~100,影响匹配策略
pub layers: Vec<FingerprintLayer>,
}
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>,
}
```
### 4.3 硬件指纹分层采集
| 层级 | 权重 | 来源 |
|------|------|------|
| Layer 1 硬件 | 40 | Linux: DMI product_uuid / Windows: SMBIOS UUID / macOS: IOPlatformUUID |
| Layer 2 OS | 30 | Linux: /etc/machine-id / Windows: MachineGuid / macOS: 同 L1 |
| Layer 3 存储 | 20 | 根文件系统 UUID (blkid/VolumeSerialNumber) |
| Layer 4 网络 | 10 | 物理网卡 MAC(跳过 docker/tap/lo/veth |
**稳定度评分**Layer1=有+Layer2=有 → 70(强指纹);仅 Layer2 有 → 40;仅 Layer4 → 10。
**兜底**:稳定度 < 20 时,服务器分配 UUID 并加密存储到 `~/.craftlabs/device_id`,管理员可手动吊销释放配额。
### 4.4 离线宽限期
```rust
fn check_license离线(&self) -> Result<LicenseStatus, LicenseError> {
// 1. 加载缓存许可证
// 2. 检查距离上次在线心跳的天数
// 3. 超过 offline_grace_days → OfflineGraceExceeded
// 4. 未超过 → 时间窗口校验(not_before/not_after
// 5. 返回 LicenseStatusdevice_count=0 标记离线)
}
```
### 4.5 错误码体系
```rust
pub enum LicenseError {
// 通用
Success, ConfigMissing, Network, NotInitialized,
// 签名/加密
InvalidFormat, InvalidSignature, SignatureMismatch, CryptoError,
DecryptionFailed, CorruptedPayload, LicenseIdMismatch,
// 许可状态
NotYetValid, Expired, NoCachedLicense,
OfflineGraceExceeded { days_offline, max_days },
InvalidLicense, LicenseRevoked,
DeviceLimitReached, ConcurrentUserLimitReached, ActivationLimitReached,
// 兼容比特
BitAnswerStatus(u32),
UnknownStatus(u16),
}
```
### 4.6 本地缓存
```
~/.craftlabs/
├── device_id # 硬件指纹或服务器 UUID
├── license_cache.json # AES-256-GCM 加密的许可证副本
│ # 加密密钥 = SHA256(device_id + EMBEDDED_SALT)
└── heartbeat_state.json # { last_heartbeat, lease_until }
```
### 4.7 编译期公钥嵌入
`build.rs` 读取 `native/craft-core/embedded/pubkey.pem`,生成 `const EMBEDDED_PUBLIC_KEY: &str = "..."``crypto.rs``initialize` 时解析为 `RsaPublicKey`
---
## 5. 平台后端变更
### 5.1 delivery-platform-api 新增模块
```
license/
├── LicenseController.java # REST /api/v1/licenses
├── LicenseService.java # 签发/吊销/查询
├── LicenseSigner.java # RSA+AES 签发
└── LicenseKeyManager.java # 密钥对管理
persistence/license/
├── PlatformLicense.java & Mapper
├── PlatformLicenseFeature.java & Mapper
├── PlatformLicenseActivation.java & Mapper
├── PlatformLicenseKey.java & Mapper
└── PlatformLicensePolicy.java & Mapper
```
新增角色:`LICENSE_OPS`,管理 `/api/v1/licenses/**` 的写权限。
### 5.2 license-webhook-ingress 新增模块
```
license/
├── LicenseController.java # /license/v1/*
├── LicenseActivateService.java # 激活 + 终端匹配
├── LicenseHeartbeatService.java # 心跳 + 吊销检测
├── LicenseCheckService.java # 在线校验
├── LicenseReleaseService.java # 设备释放
├── DeviceMatcher.java # 分层指纹匹配
└── NonceValidator.java # 防重放
```
### 5.3 事件回调
激活/心跳失败/到期/吊销等事件,沿现有 Webhook→API 异步投递链路通知 API 更新台账和审计。
---
## 6. 安全设计
### 6.1 许可证防篡改
| 措施 | 作用 |
|------|------|
| **载荷 AES-256-GCM 加密** | 阻止直接查看许可证内容(特性/期限/终端数),每个 license_id 独立密钥防批量破解 |
| **密文 RSA-SHA256 签名** | 验签不通过 = 篡改,先行快速拒绝 |
| **HKDF 密钥派生** | 盐编译期嵌入 + license_id,增加逆向提取难度 |
| **公钥编译期嵌入** | 不在运行时从文件或网络加载,防替换攻击 |
### 6.2 SDK 在线交互安全
| 措施 | 说明 |
|------|------|
| **TLS** | 全链路 HTTPS,证书校验 |
| **HMAC 签名** | 每个请求 X-Craft-Signature = HMAC-SHA256(nonce|ts|method|path|body, tenantKey) |
| **Nonce 去重** | Redis SETNX + 时间窗口 5 分钟,防重放 |
| **Authorization 头** | Bearer tenantKey 双向验证 |
### 6.3 运行时保护
Rust 侧复用现有 `security/` 模块:反调试检测、完整性校验、字符串混淆,适配自研路径。
---
## 7. 实施阶段
### Phase 1:离线核心
**目标**:管理员签发 → 文件交付 → SDK 本地验签解密
- Rust: crypto.rs、license.rs、cache.rs、device.rs、error.rs
- Java: SelfhostedConfigSection 扩展字段
- 平台: 数据库迁移、LicenseSigner、LicenseController(签发/查询/吊销)
- 验证: AES 往返测试、RSA 验签测试、过期拒绝测试
### Phase 2:在线激活
**目标**:SDK 网络激活获取许可证,终端配额限制
- Rust: activate.rs、protocol.rs、trait_provider.rs、reqwest 集成
- Webhook: LicenseController、ActivateService、DeviceMatcher、NonceValidator
- 平台: 终端释放端点
- 验证: Mock HTTP 测试、终端满 409 测试
### Phase 3:心跳 + 离线兜底
**目标**:心跳维持租约、离线宽限期降级、吊销远程生效
- Rust: heartbeat.rs、check_license 离线逻辑
- Webhook: HeartbeatService、CheckService、ReleaseService
- 验证: 心跳成功更新租约、断网 8 天 OfflineGraceExceeded、吊销后 410
### Phase 4:完善与生产加固
**目标**:双 Provider 切换、CI/CD、文档
- Rust: build.rs 公钥嵌入、security 模块适配
- Java: MultiProviderSmokeTest、SelfHostedProviderTest
- CI: ci-native.yml 适配、ci-platform.yml 新增
- 文档: 集成指南、操作手册、CHANGELOG
### 工作量估算
| Phase | Rust | Java SDK | Platform API | Webhook | 合计 |
|-------|------|----------|-------------|---------|------|
| P1 | M | S | M | — | M~L |
| P2 | M | — | S | M | M |
| P3 | S | — | — | M | M |
| P4 | S | S | — | — | S |
S=小,M=中,L=大)
@@ -0,0 +1,442 @@
# CraftLabs 前端设计体系 v1.0
> 基于 Figma「安徽地质博物馆 v2.0」设计 Token 提取,适配 delivery-platform-ui
> 最后更新:2026-05-19
---
## 目录
1. [设计 Token](#1-设计-token)
2. [布局体系](#2-布局体系)
3. [组件规范](#3-组件规范)
4. [页面模板](#4-页面模板)
5. [弹框体系](#5-弹框体系)
6. [实施指南](#6-实施指南)
---
## 1. 设计 Token
### 1.1 色彩
| Token | 值 | 用途 |
|-------|-----|------|
| `--color-brand` | `#2C3E6B` | 主色:按钮、链接、选中态、表头文字 |
| `--color-brand-hover` | `#3D5A99` | 主色悬停 |
| `--color-brand-light` | `#F2F5FC` | 主色淡化:表头背景、选中背景 |
| `--color-page-bg` | `#EAEFFA` | 页面底色 |
| `--color-card-bg` | `#FFFFFF` | 卡片/面板/弹窗背景 |
| `--color-text-primary` | `#303133` | 主要文字:标题、正文 |
| `--color-text-secondary` | `#606266` | 次要文字:标签、说明 |
| `--color-text-placeholder` | `#909399` | 占位/辅助文字 |
| `--color-text-disabled` | `#C0C4CC` | 禁用/图标 |
| `--color-border` | `#E8ECF1` | 通用边框:卡片、表格 |
| `--color-border-input` | `#E0E3E8` | 输入框边框 |
| `--color-success` | `#1A7A3A` | 成功文字 |
| `--color-success-bg` | `#E6F7EE` | 成功背景 |
| `--color-success-border` | `#A8E6C1` | 成功边框 |
| `--color-danger` | `#F56C6C` | 危险/错误 |
| `--color-danger-bg` | `#FEF0F0` | 危险背景 |
| `--color-warning` | `#E6A23C` | 警告 |
| `--color-warning-bg` | `#FDF6EC` | 警告背景 |
| `--color-badge` | `#D54941` | 通知红点 |
| `--color-search-bg` | `#F8F9FB` | 搜索框背景 |
#### 色板速查
```
品牌色 #2C3E6B ████████ ████████ ████████
页面底色 #EAEFFA ████████ ████████
卡片白 #FFFFFF ████████ ████████ ████████
表头蓝 #F2F5FC ████████
成功绿 #E6F7EE ████████
危险红 #FEF0F0 ████████
警告橙 #FDF6EC ████████
Badge红 #D54941 ████████
```
### 1.2 字体
| 层级 | 字号 | 字重 | 颜色 | 场景 |
|------|:--:|:--:|------|------|
| H1 页面标题 | 22px | 700 | `--color-text-primary` | 页面主标题 |
| H2 区块标题 | 20px | 700 | `--color-text-primary` | 卡片标题 |
| H3 子标题 | 16px | 600 | `--color-text-primary` | 弹框标题 |
| Body 正文 | 14px | 400 | `--color-text-primary` | 表格内容、菜单 |
| Body-Secondary | 13px | 400 | `--color-text-secondary` | 标签、说明 |
| Caption | 12px | 400 | `--color-text-placeholder` | 时间、统计小字 |
| Code/Mono | 12px | 400 | `--color-text-primary` | 许可证ID、SN编码 |
| Badge | 10-11px | 600 | `#FFFFFF` | 通知数字、标签 |
**字体族**`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
### 1.3 间距
| Token | 值 | 用途 |
|-------|:--:|------|
| `--space-xs` | 4px | 图标与文字间距 |
| `--space-sm` | 8px | 按钮组间距、筛选条件间距 |
| `--space-md` | 12px | 卡片间距、统计卡间距 |
| `--space-lg` | 16px | 表单项间距、内容区上下内边距 |
| `--space-xl` | 20px | 内容区左右内边距、弹框内边距 |
| `--space-xxl` | 24px | 页面区块间距 |
### 1.4 圆角
| Token | 值 | 场景 |
|-------|:--:|------|
| `--radius-sm` | 4px | 输入框、按钮、标签 |
| `--radius-md` | 6px | 卡片、搜索框、菜单项 |
| `--radius-lg` | 8px | 弹框 |
### 1.5 阴影
| Token | 值 | 场景 |
|-------|-----|------|
| `--shadow-card` | `0 1px 2px rgba(0,0,0,.03)` | 卡片默认 |
| `--shadow-card-hover` | `0 4px 12px rgba(0,0,0,.06)` | 卡片悬停 |
| `--shadow-btn` | `0 2px 6px rgba(44,62,107,.2)` | CTA 主按钮 |
| `--shadow-dialog` | `0 8px 40px rgba(0,0,0,.15)` | 弹框 |
| `--overlay` | `rgba(0,0,0,.45)` | 弹框遮罩 |
---
## 2. 布局体系
### 2.1 整体框架
```
┌──────────────────────────────────────────────────────────┐
│ Header 60px │
│ Logo [导航1] [导航2] 🔍搜索 🔔³ ⚙️ 👤用户名 │
├────────┬─────────────────────────────────────────────────┤
│Sidebar │ Breadcrumb 46px │
│232px │ 授权运营 › 当前页面 │
│ ├─────────────────────────────────────────────────┤
│ 菜单 │ │ │
│ 分组 │ Tree Panel 280px │ Main Content │
│ │ (可选) │ (自适应) │
│ 11项 │ │ │
│ │ │ │
│v0.1.0 │ │ │
└────────┴────────────────────┴────────────────────────────┘
```
### 2.2 尺寸规范
| 元素 | 尺寸 | 说明 |
|------|:--:|------|
| Header 高度 | 60px | 固定,含 Logo + 导航 + 搜索 + 通知 + 用户 |
| Sidebar 宽度 | 232px | 白色底色,菜单项高 42-50px |
| Sidebar 菜单项 | 42-88px | 主菜单 50px,子菜单 42px,分组标题 88px |
| Breadcrumb 高度 | 46px | 内容区顶部,含当前路径 |
| Tree Panel 宽度 | 280px | 许可证/客户页可选,含搜索框 |
| Content 内边距 | 20px(左右) / 16px(上下) | 统一页面内边距 |
| 卡片间距 | 12px | 统计卡 / 内容卡之间 |
### 2.3 页面类型
| 类型 | Tree Panel | 用途 | 示例页面 |
|------|:--:|------|---------|
| 标准列表页 | ❌ | 全宽卡片 + 筛选栏 + 表格 | 客户管理、合同管理、交付管理 |
| Tree + 列表页 | ✅(280px) | 左侧层级导航 + 右侧列表 | 许可证管理 |
| 工作台 | ❌ | 统计卡片 + 图表 + 待办 | 首页 Dashboard |
| 详情页 | ❌ | 详情卡片 + 操作按钮 | 合同详情、交付详情 |
| 只读列表 | ❌ | 简单表格 | 集成环境、产品线 |
### 2.4 路由命名
| 模块 | 路径 | 页面 |
|------|------|------|
| 工作台 | `/` | HomeView |
| 客户管理 | `/customers` | CustomersView |
| 合同管理 | `/contracts` | ContractsView |
| 交付管理 | `/deliveries` | DeliveriesView |
| 许可 SN | `/licenses/sn` | LicenseSnListView |
| 许可证管理 | `/licenses` | LicenseList |
| Callback | `/callbacks` | CallbackInboxView |
| 集成环境 | `/integration/environments` | — |
| 产品线 | `/integration/product-lines` | — |
---
## 3. 组件规范
### 3.1 按钮
```css
/* 主按钮 */
.btn-primary {
background: #2C3E6B; color: #fff; border: none;
border-radius: 4px; padding: 6px 14px;
font-size: 13px; font-weight: 500; cursor: pointer;
}
.btn-primary:hover { background: #3D5A99; }
/* CTA 按钮(带阴影) */
.btn-cta {
background: #2C3E6B; color: #fff; border: none;
border-radius: 4px; padding: 7px 16px;
box-shadow: 0 2px 6px rgba(44,62,107,.2);
font-size: 13px; font-weight: 500; cursor: pointer;
}
/* 取消/次要按钮 */
.btn-cancel {
background: #fff; color: #606266;
border: 1px solid #E0E3E8; border-radius: 4px;
padding: 8px 20px; font-size: 14px;
}
/* 危险按钮 */
.btn-danger {
background: #F56C6C; color: #fff; border: none;
border-radius: 4px; padding: 8px 20px; font-size: 14px;
}
```
| 类型 | 背景 | 用途 |
|------|------|------|
| Primary | `#2C3E6B` | 查询、保存、签发 |
| CTA | `#2C3E6B` + shadow | 创建/新建操作 |
| Cancel | `#FFFFFF` + border | 取消、关闭 |
| Danger | `#F56C6C` | 删除、吊销 |
| Link | transparent + `#2C3E6B` | 详情、编辑 |
### 3.2 输入框
```css
input, .input {
border: 1px solid #E0E3E8; border-radius: 4px;
padding: 7px 12px; font-size: 13px; color: #303133;
}
input:focus { border-color: #2C3E6B; }
input::placeholder { color: #C0C4CC; }
/* 搜索框 */
.search-box {
border: 1px solid #E0E3E8; border-radius: 6px;
padding: 4px 10px; background: #F8F9FB;
display: flex; align-items: center; gap: 6px;
}
```
### 3.3 表格
```css
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
padding: 10px 12px; text-align: left; font-weight: 600;
font-size: 12px; color: #2C3E6B; background: #F2F5FC;
border-bottom: 1px solid #E8ECF1; white-space: nowrap;
}
tbody td {
padding: 9px 12px; border-bottom: 1px solid #F2F5FC;
}
tr:hover td { background: #F8F9FB; }
```
### 3.4 状态标签
```css
.tag { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: 500; }
.tag-active { background: #E6F7EE; color: #1A7A3A; border: 1px solid #A8E6C1; }
.tag-revoked { background: #FEF0F0; color: #F56C6C; border: 1px solid #FBC4C4; }
.tag-pending { background: #FDF6EC; color: #E6A23C; border: 1px solid #F5DAB1; }
.tag-expired { background: #f4f4f5; color: #909399; border: 1px solid #e9e9eb; }
```
### 3.5 通知 Badge
```css
.badge {
position: absolute; top: -2px; right: -5px;
width: 16px; height: 16px; background: #D54941;
border-radius: 50%; font-size: 10px; color: #fff;
line-height: 16px; text-align: center; font-weight: 600;
}
```
### 3.6 侧边菜单
```css
.sidebar { width: 232px; background: #fff; border-right: 1px solid #E8ECF1; }
.menu-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; font-size: 14px; color: #606266;
border-right: 3px solid transparent; cursor: pointer;
}
.menu-item:hover { background: #F2F5FC; color: #2C3E6B; }
.menu-item.active {
background: #F2F5FC; color: #2C3E6B; font-weight: 600;
border-right-color: #2C3E6B;
}
.menu-section-label {
padding: 6px 20px; font-size: 11px; color: #C0C4CC;
text-transform: uppercase; font-weight: 600;
}
```
### 3.7 面包屑
```css
.breadcrumb {
height: 46px; display: flex; align-items: center;
padding: 0 20px; gap: 6px; font-size: 13px;
background: #fff; border-bottom: 1px solid #E8ECF1;
}
.breadcrumb .sep { color: #C0C4CC; }
.breadcrumb .current { color: #2C3E6B; font-weight: 600; }
```
---
## 4. 页面模板
### 4.1 标准列表页
```
┌─────────────────────────────────────────┐
│ 统计卡片 (4列可选) │
│ [总XX] [活跃] [已吊销] [已过期] │
├─────────────────────────────────────────┤
│ [🔍搜索框] [状态下拉] [查询] [+ 新建] │
├─────────────────────────────────────────┤
│ 表格 │
│ ID │ 名称 │ 关联 │ 状态 │ 时间 │ 操作 │
│ ... │
├─────────────────────────────────────────┤
│ 共XX条 ‹ 1 2 › │
└─────────────────────────────────────────┘
```
**对应文件**`CustomersView.vue``ContractsView.vue``DeliveriesView.vue`
### 4.2 Tree + 列表页(许可证管理)
```
┌──────────┬──────────────────────────────┐
│ 🔍搜索 │ 统计卡片 │
│ │ [47] [38] [6] [3] │
│ ▸ 项目A ├──────────────────────────────┤
│ ▸ 项目B │ [🔍] [类型▼] [状态▼] [查询] │
│ ▸ 项目C │ [+ 签发许可证] │
│ ├──────────────────────────────┤
│ │ 表格 │
│ │ 许可证ID │ 租户 │ 产品 │ ... │
│ │ ... │
└──────────┴──────────────────────────────┘
```
**对应文件**`LicenseList.vue`
### 4.3 工作台
```
┌──────────────────────────────────────────┐
│ [总许可证] [活跃终端] [待处理] [本月签发] │
├──────────────────────┬───────────────────┤
│ 📊 许可证签发趋势 │ ⚠️ 待处理事项 │
│ (柱状图) │ · Callback 待处理 │
│ │ · 许可证将到期 │
│ │ · 终端额度满 │
└──────────────────────┴───────────────────┘
```
**对应文件**`HomeView.vue`(待完善为仪表盘)
---
## 5. 弹框体系
### 5.1 基础规范
```
┌─────────────────────────────────┐
│ 标题 ✕ 关闭 │ ← Header: padding 16px/20px
├─────────────────────────────────┤ ← 圆角: 8px
│ │ 阴影: 0 8px 40px rgba(0,0,0,.15)
│ 表单内容区 │ ← Body: padding 20px
│ │ 表单项间距: 16px
│ label: [______________] │ 标签色: #606266
│ │
├─────────────────────────────────┤
│ [取消] [确认/签发] │ ← Footer: gap 10px, 右对齐
└─────────────────────────────────┘
遮罩: rgba(0,0,0,.45)
动画: scale(.96→1) + opacity 200ms
关闭按钮: 28×28, hover #F2F5FC
```
### 5.2 类型规格
| 类型 | 宽度 | Header | 内容布局 | Footer |
|------|:--:|--------|---------|--------|
| 签发表单 | 560px | 标题+关闭 | 6+ 字段 + 特性复选框 | 取消+签发 |
| 新建/编辑 | 480px | 标题+关闭 | 2-4 字段 | 取消+保存 |
| 详情查看 | 480-520px | 标题+关闭 | KV 对(标签100px) | 关闭(+操作) |
| 确认删除 | 420px | — | 居中图标+说明 | 取消+删除(红) |
| 危险操作 | 420px | — | 红色危险提示区 | 取消+确认(红) |
### 5.3 危险操作区分
```css
/* 普通确认:居中图标 + 文字 */
/* 危险确认:额外红色背景提示区 */
.danger-zone {
background: #FEF0F0; border: 1px solid #FBC4C4;
border-radius: 4px; padding: 10px 12px;
color: #F56C6C; font-size: 12px;
}
```
---
## 6. 实施指南
### 6.1 Element Plus CSS 覆盖
```css
:root {
/* 核心 3 行 */
--el-color-primary: #2C3E6B;
--el-bg-color-page: #EAEFFA;
--el-border-radius-base: 6px;
/* 扩展 */
--el-color-primary-light-3: #3D5A99;
--el-color-primary-light-9: #D6DFF0;
--el-color-success: #1A7A3A;
--el-color-danger: #F56C6C;
--el-table-header-bg-color: #F2F5FC;
--el-table-header-text-color: #2C3E6B;
--el-dialog-border-radius: 8px;
--el-overlay-color-lighter: rgba(0,0,0,.45);
}
```
### 6.2 文件对照
| 设计 Token | 实现文件 |
|-----------|---------|
| 全局变量 | `src/theme.css` |
| 整体布局 | `src/layout/MainLayout.vue` |
| 路由+鉴权 | `src/router/index.js` |
| 认证状态 | `src/stores/auth.js` |
| API 封装 | `src/api/platform.js` |
| 许可证页 | `src/views/LicenseList.vue` |
| 设计对比 | `src/views/LayoutCompareView.vue` |
| Demo | `public/design-demo.html` |
### 6.3 新增页面检查清单
- [ ] 使用 `<script setup>` + Composition API
- [ ] 所有 API 调用有 try/catch + `apiErrorMessage`
- [ ] 列表有 `v-loading` / 提交有 `:loading`
- [ ] 路由 meta 包含 `roles` 鉴权
- [ ] 表格表头使用 `#F2F5FC` 背景
- [ ] 状态标签使用 `.tag-active` / `.tag-revoked`
- [ ] CTA 按钮使用 `.btn-cta` 带阴影
- [ ] 弹框使用 `<el-dialog>` 自动继承 `8px` 圆角
@@ -0,0 +1,665 @@
# CraftLabs 前端框架设计 v1.0
> 基于设计体系 `2026-05-19-design-system.md` 提炼的前端工程框架规范
> 目标:统一架构、约束风格、加速开发、降低交接成本
> 日期:2026-05-19
---
## 目录
1. [框架概述](#1-框架概述)
2. [技术栈](#2-技术栈)
3. [工程结构](#3-工程结构)
4. [核心模块](#4-核心模块)
5. [布局系统](#5-布局系统)
6. [组件体系](#6-组件体系)
7. [页面模板](#7-页面模板)
8. [数据流](#8-数据流)
9. [开发规范](#9-开发规范)
10. [构建与部署](#10-构建与部署)
---
## 1. 框架概述
### 1.1 定位
CraftLabs 前端框架是基于 Vue 3 + Element Plus 的企业级 B2B 授权运营平台前端方案。在 Element Plus 之上注入品牌设计 Token,提供开箱即用的布局、组件、页面模板和开发约束。
### 1.2 核心原则
| 原则 | 说明 |
|------|------|
| **约定优于配置** | 路由、鉴权、API 封装、错误处理均有默认行为,开发者只需关注业务 |
| **Token 驱动** | 所有视觉属性通过 CSS 变量控制,换肤只需改 `theme.css` |
| **组件隔离** | 每个 `.vue` 文件自包含模板+逻辑+样式,`<script setup>` 统一风格 |
| **渐进增强** | 在 Element Plus 基础上叠加品牌样式,不 fork 组件库 |
| **安全默认** | JWT 存储 localStorage、401 自动登出、路由级鉴权 |
### 1.3 层级架构
```
┌─────────────────────────────────────────────┐
│ Views (页面层) │
│ LicenseList CustomersView ContractsView │
├─────────────────────────────────────────────┤
│ Components (组件层) │
│ StatsCard SearchBar DataTable ... │
├─────────────────────────────────────────────┤
│ Layout System (布局系统) │
│ MainLayout (Header+Sidebar+Breadcrumb) │
├──────────────┬──────────────┬───────────────┤
│ Router │ Store │ API │
│ (鉴权/懒加载) │ (Pinia JWT) │ (axios封装) │
├──────────────┴──────────────┴───────────────┤
│ Element Plus + Vue 3 │
├─────────────────────────────────────────────┤
│ Theme System (Token层) │
│ theme.css · CSS Variables │
└─────────────────────────────────────────────┘
```
---
## 2. 技术栈
| 层 | 选型 | 版本 | 说明 |
|----|------|:--:|------|
| 框架 | Vue 3 | ^3.5 | Composition API + `<script setup>` |
| 构建 | Vite | ^6.0 | 极速 HMR + 开箱即用 |
| UI 库 | Element Plus | ^2.9 | 组件库本体,CSS 变量覆盖换肤 |
| 路由 | vue-router | ^4.5 | 懒加载 + meta 鉴权 |
| 状态 | Pinia | ^2.3 | 轻量状态管理 |
| HTTP | axios | ^1.7 | 拦截器 + JWT 注入 |
| 语言 | JavaScript (ESM) | — | 暂用 JS,必要时渐进引入 TS |
### 2.1 不引入的依赖
| 库 | 原因 |
|----|------|
| Tailwind CSS | Element Plus 已覆盖组件样式,避免双体系 |
| Vuex | Pinia 更轻量,官方推荐 |
| Lodash | 项目规模不需要工具函数库 |
| Moment.js | 日期格式化用原生 `Intl` 或字符串裁剪 |
---
## 3. 工程结构
```
web/delivery-platform-ui/
├── public/
│ ├── design-demo.html # 设计 Demo(独立 HTML,无依赖)
│ └── design-system.html # 设计体系可视化
├── src/
│ ├── main.js # 入口:挂载 Vue + Pinia + Router + 401拦截器
│ ├── App.vue # 根组件:纯 <router-view>
│ ├── theme.css # ★ 全局主题 TokenCSS 变量)
│ │
│ ├── layout/
│ │ └── MainLayout.vue # ★ 主布局:Header + Sidebar + Breadcrumb
│ │
│ ├── router/
│ │ └── index.js # ★ 路由表 + 鉴权守卫
│ │
│ ├── stores/
│ │ └── auth.js # ★ 认证状态:JWT + login/logout/roles
│ │
│ ├── api/
│ │ └── platform.js # ★ API 封装:按模块导出 axios 请求函数
│ │
│ ├── utils/
│ │ ├── apiErrorMessage.js # 统一错误消息提取
│ │ └── redactPayload.js # Callback payload 脱敏
│ │
│ └── views/ # ★ 页面组件(按模块命名)
│ ├── HomeView.vue # 工作台/Dashboard
│ ├── LoginView.vue # 登录页
│ ├── ForbiddenView.vue # 403
│ ├── NotFoundView.vue # 404
│ ├── CustomersView.vue # 客户管理
│ ├── ProjectsView.vue # 项目管理
│ ├── ContractsView.vue # 合同列表
│ ├── ContractWizardView.vue # 合同创建向导
│ ├── ContractDetailView.vue # 合同详情
│ ├── DeliveriesView.vue # 交付列表
│ ├── DeliveryBatchWizardView.vue
│ ├── DeliveryBatchDetailView.vue
│ ├── LicenseSnListView.vue # 许可SN列表
│ ├── LicenseSnWizardView.vue
│ ├── LicenseSnDetailView.vue
│ ├── LicenseList.vue # ★ 许可证管理(自研SDK核心页)
│ ├── CallbackInboxView.vue # Callback 收件箱
│ ├── CallbackInboxDetailView.vue
│ ├── IntegrationEnvironmentsView.vue
│ ├── IntegrationProductLinesView.vue
│ └── LayoutCompareView.vue # 设计审核工具(内部)
├── package.json
├── vite.config.js
├── Dockerfile
└── README.md
```
### 3.1 文件命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 页面视图 | `PascalCaseView.vue` | `CustomersView.vue` |
| 布局组件 | `PascalCase.vue` | `MainLayout.vue` |
| 路由/Store/API | `kebab-case.js` | `platform.js` |
| 工具函数 | `camelCase.js` | `apiErrorMessage.js` |
| 样式文件 | `kebab-case.css` | `theme.css` |
---
## 4. 核心模块
### 4.1 主题系统 (`theme.css`)
所有视觉 Token 集中在一个 CSS 文件中,通过覆盖 Element Plus 变量实现换肤。
```css
/* 核心 3 行即可改变全站主色调 */
:root {
--el-color-primary: #2C3E6B;
--el-bg-color-page: #EAEFFA;
--el-border-radius-base: 6px;
}
/* 完整 Token 见 docs/superpowers/specs/2026-05-19-design-system.md */
```
**设计意图**
- 不修改 Element Plus 源码,只覆盖 CSS 变量
- 更换品牌色只需改 `--el-color-primary` 一个值
- 新页面自动继承主题,无需额外引入
### 4.2 路由系统 (`router/index.js`)
**路由表结构**
```js
const routes = [
// 公开路由(无鉴权)
{ path: "/login", component: LoginView },
// 受保护路由(需要登录)
{
path: "/",
component: MainLayout, // 统一布局壳
meta: { requiresAuth: true },
children: [
{
path: "licenses", // 子路由路径
name: "licenses", // 命名路由(用于编程跳转)
component: () => import("..."), // 懒加载
meta: { roles: ["SYS_ADMIN", "DEVELOPER"] } // 角色鉴权
},
// ... 更多子路由
]
},
// 兜底路由
{ path: "/403", component: ForbiddenView },
{ path: "/:pathMatch(.*)*", component: NotFoundView },
]
```
**鉴权守卫逻辑**
```
用户访问页面
├─ meta.requiresAuth && 无 token → 重定向 /login?redirect=原路径
├─ meta.roles && 用户角色不匹配 → 重定向 /403
└─ 通过 → 正常渲染
```
**新增页面只需**
1.`children` 数组中加一条路由
2. 设置 `meta.roles` 控制可见角色
3. `component: () => import(...)` 懒加载
### 4.3 状态管理 (`stores/auth.js`)
```js
export const useAuthStore = defineStore("auth", {
state: () => ({
token: localStorage.getItem("craftlabs_platform_token") || "",
displayName: "",
roles: [],
}),
actions: {
async login(username, password) {
const { data } = await axios.post("/api/v1/auth/login", { username, password })
this.token = data.token
this.roles = data.roles
localStorage.setItem("craftlabs_platform_token", this.token)
axios.defaults.headers.common.Authorization = `Bearer ${this.token}`
},
logout() {
this.token = ""
localStorage.removeItem("craftlabs_platform_token")
delete axios.defaults.headers.common.Authorization
},
},
})
```
**使用方式**
```js
import { useAuthStore } from "../stores/auth"
const auth = useAuthStore()
// 模板中
auth.hasAnyRole(["SYS_ADMIN", "DEVELOPER"]) // 角色判断
auth.displayName // 用户名
```
### 4.4 API 层 (`api/platform.js`)
```js
// 每个后端端点导出为一个具名函数
export function listCustomers(params) {
return axios.get("/api/v1/customers", { params })
}
export function createCustomer(body) {
return axios.post("/api/v1/customers", body)
}
export function updateCustomer(id, body) {
return axios.put(`/api/v1/customers/${id}`, body)
}
export function deleteCustomer(id) {
return axios.delete(`/api/v1/customers/${id}`)
}
```
**约定**
- 函数名 = HTTP 动词 + 资源名:`listXxx` / `createXxx` / `updateXxx` / `deleteXxx` / `getXxx`
- 状态变更用语义化函数名:`patchContractStatus()``addLine()`
- 所有函数返回 axios Promise,由调用方 `await` + try/catch
- 查询参数用 `{ params }` 传递(axios 自动序列化)
### 4.5 全局错误处理 (`main.js`)
```js
// 401 自动登出拦截器
axios.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
router.push({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
}
return Promise.reject(err)
}
)
```
---
## 5. 布局系统
### 5.1 MainLayout 结构
```
┌──────────────────────────────────────────────────────────┐
│ <header> 60px │
│ Logo │ 导航菜单 │ 全局搜索 │ 🔔 │ ⚙️ │ 👤用户名 │
├────────┬─────────────────────────────────────────────────┤
│<aside> │ <div class="breadcrumb"> 46px │
│ 232px │ 授权运营 › 当前页面 │
│ ├─────────────────────────────────────────────────┤
│ 菜单 │ │
│ 分组 │ <router-view /> │
│ │ (各页面通过 slot 注入到此区域) │
│ 11项 │ │
│ │ │
└────────┴─────────────────────────────────────────────────┘
```
### 5.2 布局尺寸契约
| 元素 | 尺寸 | CSS |
|------|:--:|------|
| Header | 60px | `height: 60px; flex-shrink: 0` |
| Sidebar | 232px | `width: 232px; flex-shrink: 0` |
| Breadcrumb | 46px | `height: 46px; flex-shrink: 0` |
| Content | 自适应 | `flex: 1; padding: 16px 20px` |
### 5.3 布局变体
| 变体 | 触发条件 | 说明 |
|------|---------|------|
| 标准布局 | 默认 | Header + Sidebar + Breadcrumb + 全宽 Content |
| Tree 布局 | `activeModule === 'licenses'` | 额外 280px Tree 面板在 Content 左侧 |
| 全屏布局 | 无 Header/Sidebar | 登录页、403/404 页 |
---
## 6. 组件体系
### 6.1 组件分类
```
组件层级:
Element Plus 基础组件 (el-button, el-table, el-dialog, ...)
├── 直接使用(无需封装)
│ el-button el-input el-select el-table el-pagination
│ el-tag el-badge el-tree el-dialog el-card
├── 组合组件(页面内组合)
│ 搜索栏 + 表格 + 分页 → 列表页模板
│ 统计卡片行 → Dashboard 模板
└── 业务组件(跨页面复用,按需抽取)
StatsCard SearchFilterBar StatusTag ConfirmDialog
```
### 6.2 组件开发原则
- **优先直接用 Element Plus**90% 场景不需要封装
- **抽取时机**:同一模式在 3+ 个页面出现时才抽取为独立组件
- **Props 优先于全局状态**:组件通过 props 接收数据,不直接读 store
- **Events 向上**:组件通过 `$emit` 通知父组件,不直接改父组件状态
### 6.3 CSS 约定
```css
/* ✅ 推荐:scoped + CSS 变量 */
<style scoped>
.my-page { background: var(--el-bg-color-page); }
.card { border-radius: var(--el-border-radius-base); }
.btn-primary { background: var(--el-color-primary); }
</style>
/* ❌ 避免:硬编码色值 */
<style scoped>
.card { background: #EAEFFA; } /* 应使用 CSS 变量 */
</style>
```
---
## 7. 页面模板
### 7.1 标准列表页
适用于:客户管理、合同管理、交付管理、许可SN
```
结构:
<统计卡片行> (可选)
<筛选栏>
[搜索框] [状态下拉] [查询按钮] [+ 新建按钮]
</筛选栏>
<数据表格>
<el-table> + <el-pagination>
</数据表格>
<新建/编辑 Dialog>
```
**样板代码(约 200 行)**
```vue
<script setup>
import { ref, onMounted } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { useAuthStore } from "../stores/auth"
import { listXxx, createXxx, updateXxx, deleteXxx } from "../api/platform"
import { apiErrorMessage } from "../utils/apiErrorMessage"
const auth = useAuthStore()
const loading = ref(false), saving = ref(false)
const rows = ref([]), total = ref(0), page = ref(1), pageSize = ref(10)
const keyword = ref(""), filterStatus = ref("")
const dialogVisible = ref(false), editingId = ref(null)
const formRef = ref(null)
const form = reactive({ name: "", ... })
onMounted(() => { auth.restoreAxiosAuth(); load() })
async function load() {
loading.value = true
try {
const { data } = await listXxx({ page: page.value-1, size: pageSize.value, keyword: keyword.value?.trim() })
rows.value = data.content ?? []
total.value = data.totalElements ?? 0
} catch(e) {
ElMessage.error(apiErrorMessage(e))
} finally { loading.value = false }
}
async function submit() {
await formRef.value.validate()
saving.value = true
try {
const payload = { name: form.name.trim(), ... }
editingId.value ? await updateXxx(editingId.value, payload) : await createXxx(payload)
dialogVisible.value = false
await load()
} catch(e) {
ElMessage.error(apiErrorMessage(e))
} finally { saving.value = false }
}
async function onDelete(row) {
await ElMessageBox.confirm(`确定删除「${row.name}」?`, "提示", { type: "warning" })
await deleteXxx(row.id)
await load()
}
</script>
```
### 7.2 Tree + 列表页
适用于:许可证管理
```
结构:
<div class="license-page"> ← display: flex
<Tree Panel 280px> ← 左侧固定宽度
<Main Panel> ← flex: 1
<统计卡片行>
<筛选栏 + 签发按钮>
<数据表格>
</Main Panel>
</div>
```
### 7.3 工作台 Dashboard
适用于:首页
```
结构:
<统计卡片行 4列>
<趋势图 + 待办列表 2列>
<快捷入口>
```
### 7.4 详情页
适用于:合同详情、交付详情、SN详情
```
结构:
<返回按钮 + 页面标题>
<详情卡片>
<el-descriptions> 或 自定义 KV 布局
</详情卡片>
<关联数据卡片> (可选:审计事件、Callback关联)
<操作按钮行>
```
---
## 8. 数据流
### 8.1 请求生命周期
```
用户操作(点击查询/新建/保存)
loading.value = true ← 显示加载态
await apiFunction(payload) ← axios 自动注入 JWT Header
├─ 200: data → 更新响应式状态
│ ElMessage.success("操作成功")
├─ 4xx: err → ElMessage.error(apiErrorMessage(e))
│ 401 → 自动登出
│ 403 → 提示无权限
└─ 5xx / Network Error → ElMessage.error("服务器错误")
loading.value = false ← 恢复交互
```
### 8.2 状态分类
| 状态类型 | 存储位置 | 示例 |
|---------|---------|------|
| 页面级临时状态 | 组件内 `ref()` / `reactive()` | 表格数据、表单数据、loading |
| 跨页面持久状态 | Pinia `auth.js` | token、用户名、角色 |
| UI 状态 | 组件内 | dialogVisible、activeTab |
| URL 状态 | `route.query` / `route.params` | 分页页码、详情页 ID |
---
## 9. 开发规范
### 9.1 新增页面检查清单
```
□ 文件命名:PascalCaseView.vue
□ 使用 <script setup> + Composition API
□ 所有 API 调用有 try/catch + apiErrorMessage
□ 列表有 v-loading / 提交按钮有 :loading
□ 路由 meta 包含 roles 鉴权
□ 表格表头使用 #F2F5FC 背景(继承 theme.css
□ 状态标签使用 .tag-active / .tag-revoked 等语义类
□ CTA 按钮使用 .btn-cta 带阴影
□ 色值使用 CSS 变量,不硬编码
□ 弹框使用 <el-dialog>(自动继承 8px 圆角)
```
### 9.2 禁止事项
| 禁止 | 原因 |
|------|------|
| 硬编码色值 (`#EAEFFA`) | 应使用 CSS 变量,便于换肤 |
| 直接操作 DOM (`document.querySelector`) | 使用 Vue 响应式 + ref |
| 在 `setup` 外定义响应式数据 | 非 `.vue` 文件需使用 `defineStore` |
| `v-if` + `v-for` 同时使用 | Vue 性能警告 |
| 忽略 try/catch | 未捕获异常导致白屏 |
| 提交 `console.log` | 使用 `ElMessage` 或移除 |
### 9.3 Git 提交规范
```
feat(web): 新增许可证管理页面
fix(web): 修复合同列表分页重置bug
refactor(web): 抽取 StatsCard 为独立组件
style(web): 统一表格表头样式
docs: 更新前端框架设计文档
```
---
## 10. 构建与部署
### 10.1 开发环境
```bash
cd web/delivery-platform-ui
npm install
npm run dev # → http://localhost:5173
# /api → proxy → http://127.0.0.1:8080
```
### 10.2 生产构建
```bash
# 指定后端地址
VITE_API_BASE=https://api.craftlabs.cn npm run build
# 产物:dist/
# 部署:Nginx 静态托管 + 反代 /api 到后端
```
### 10.3 CI/CD
```yaml
# .github/workflows/ci-platform.yml (前端部分)
- name: Build Frontend
run: |
cd web/delivery-platform-ui
npm ci
npm run build
```
### 10.4 Nginx 配置模板
```nginx
server {
listen 80;
server_name platform.craftlabs.cn;
root /var/www/delivery-platform-ui/dist;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
}
```
---
## 附录 A:文件职责速查
| 文件 | 职责 | 谁需要修改 |
|------|------|-----------|
| `theme.css` | 全局色彩/圆角/阴影 Token | 设计师/UED |
| `MainLayout.vue` | 页面壳:Header/Sidebar/Breadcrumb | 架构师 |
| `router/index.js` | 路由表 + 鉴权守卫 | 全栈/前端 |
| `stores/auth.js` | JWT 认证状态 | 全栈/前端 |
| `api/platform.js` | 后端 API 封装 | 前端(新增接口时) |
| `utils/*.js` | 通用工具函数 | 前端 |
| `views/*View.vue` | 业务页面 | 前端 |
## 附录 B:快速上手
**创建一个新页面(以「审计日志」为例)**
1. 创建 `src/views/AuditLogView.vue`,使用标准列表页模板
2.`api/platform.js` 添加 `listAuditEvents()` 函数
3.`router/index.js` 添加路由,设置 `meta.roles`
4.`MainLayout.vue``menuItems` 数组添加菜单项
5. `npm run dev` 验证
---
*修订记录:2026-05-19 初版,基于设计体系 v1.0*
@@ -0,0 +1,221 @@
# 客户端授权管理工具设计
> **日期**2026-05-25
> **目标**:提供客户端授权管理工具,让客户终端用户自助完成设备授权、授权迁移、撤销授权。分为 CLI 和 GUI 两个形态。
> **实施顺序**:① 完善现有 SDK(JNI 桥接 + 集成测试)→ ② CLI 工具 → ③ GUI 桌面工具
> **技术选型**CLI = Rust clap + craft-coreGUI = Tauri 2.x + Vue 3 + craft-core
---
## 1. 现有 SDK 能力复用
```text
┌─────────────────────────────────────┐
│ Client Authorization Tool (Tauri) │
│ ┌───────────────────────────────┐ │
│ │ Vue 3 UI Layer │ │
│ │ (授权状态/激活/迁移/撤销) │ │
│ └───────────┬───────────────────┘ │
│ │ IPC (invoke) │
│ ┌───────────▼───────────────────┐ │
│ │ Rust Backend (Tauri cmd) │ │
│ │ - 调用 craft-core C ABI │ │
│ │ - HTTP 请求平台 API │ │
│ │ - 本地配置持久化 │ │
│ └───────────┬───────────────────┘ │
│ │ FFI │
│ ┌───────────▼───────────────────┐ │
│ │ craft-core (Rust cdylib) │ │
│ │ - activate/check/release │ │
│ │ - 设备指纹 / 加密 / 心跳 │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### 1.1 可直接复用的 Rust 模块
| 模块 | 复用方式 | 现状 |
|------|---------|------|
| `craft_initialize` | Tauri 启动时调用 | ✅ 已实现 |
| `craft_activate` | 用户点击"激活"时调用 | ✅ 已实现 |
| `craft_check_license` | 首页状态展示 | ✅ 已实现 |
| `craft_get_license_info` | 授权详情展示 | ✅ 已实现 |
| `craft_has_feature` | 功能特性开关展示 | ✅ 已实现 |
| `craft_release` | 撤销授权时调用 | ✅ 已实现 |
| `craft_heartbeat` | 后台定期心跳 | ✅ 已实现 |
| `device.rs` | 设备指纹采集 | ✅ 已实现 |
### 1.2 需新增的能力
| 能力 | 说明 |
|------|------|
| **HTTP 客户端** | Tauri Rust 后端调平台 REST API(SN 查询/换机申请/状态同步) |
| **本地配置持久化** | 保存激活的 SN、授权信息到本地安全存储 |
| **自动启动/托盘** | 系统托盘常驻,后台心跳,状态变更通知 |
| **平台 API 客户端** | 封装若干平台 API(查询绑定、提交换机、提交激活结果) |
---
## 2. 功能设计
### 2.1 首页 — 授权概览
| 区块 | 内容 |
|------|------|
| **授权状态卡片** | 大图标 + 状态(已授权/未授权/已过期)+ 授权类型(正式/试用) |
| **设备信息** | 设备名称、设备指纹(mid)、操作系统 |
| **授权摘要** | SN 编码、有效期、剩余天数、功能特性列表 |
| **操作区** | 激活授权、迁移授权、撤销授权三个主按钮 |
### 2.2 激活授权流程
```
1. 用户点击「激活授权」
2. 弹出对话框:输入 SN 编码(或从剪贴板粘贴)
3. 工具调用 craft_activate(SN) → 本地验证
4. 若为 selfhosted 模式 → HTTP 请求平台 API 完成远程验证
5. 成功 → 显示授权详情,开始心跳
6. 失败 → 显示错误原因(SN 无效/已吊销/网络超时等)
```
### 2.3 授权迁移流程
```
1. 用户点击「迁移授权」
2. 工具显示当前绑定的设备信息 + SN
3. 用户确认「迁移到本设备」
4. 工具先调用 craft_release() 释放旧设备授权
5. HTTP 请求平台 API 记录换机申请
6. 重新调用 craft_activate() 在新设备激活
7. 完成迁移
```
### 2.4 撤销授权流程
```
1. 用户点击「撤销授权」
2. 二次确认对话框(含风险提示)
3. 工具调用 craft_release() 释放本地授权
4. HTTP 请求平台 API 更新 SN 状态为 REVOKED
5. 清除本地配置
```
### 2.5 授权详情页
| 展示项 | 来源 |
|--------|------|
| SN 编码 | craft_get_license_info |
| 授权状态 | craft_check_license |
| 有效期 | expiration_date |
| 已授权特性 | feature_names / feature_values |
| 设备指纹(mid) | device.rs |
| 首次激活时间 | 平台 API |
| 最近心跳时间 | 本地记录 |
| 绑定历史 | 平台 API |
---
## 3. 与平台 API 的交互
工具需要通过 HTTP 与交付平台后端通信:
| 平台 API | 方法 | 用途 |
|----------|------|------|
| `/api/v1/auth/client-login` | POST | 客户端登录(获取工具专用 token) |
| `/api/v1/licenses/verify` | POST | 验证 SN 有效性 |
| `/api/v1/licenses/activate` | POST | 提交激活结果 + 设备指纹 |
| `/api/v1/licenses/revoke` | POST | 提交撤销申请 |
| `/api/v1/devices/swap-request` | POST | 提交换机申请 |
| `/api/v1/devices/{mid}/bindings` | GET | 查询绑定历史 |
### API 安全
- 客户端工具使用独立 API Token(非管理后台 JWT
- Token 限制:仅可操作本设备关联的 SN
- 所有请求附带设备指纹签名
---
## 4. 目录结构
```
client-tool/
├── src-tauri/
│ ├── src/
│ │ ├── main.rs # Tauri 入口 + 命令注册
│ │ ├── commands.rs # Tauri IPC 命令(activate/check/release/migrate
│ │ ├── platform_api.rs # 平台 REST API 客户端
│ │ ├── license.rs # 授权生命周期管理
│ │ └── config.rs # 本地持久化配置
│ ├── Cargo.toml # 依赖 craft-core 等
│ └── tauri.conf.json # Tauri 配置
├── src/
│ ├── App.vue
│ ├── views/
│ │ ├── DashboardView.vue # 首页概览
│ │ ├── ActivateView.vue # 激活向导
│ │ ├── DetailView.vue # 授权详情
│ │ └── SettingsView.vue # 设置
│ └── components/
│ ├── StatusCard.vue
│ └── FeatureList.vue
├── package.json
└── README.md
```
---
## 5. CLI 工具设计
### 5.1 命令结构
```text
craftlabs-auth-cli
├── craft status # 查看本地授权状态
├── craft activate <SN> # 使用 SN 激活本机
├── craft check # 检查授权是否有效
├── craft info # 显示授权详情 + 功能特性
├── craft release # 撤销本机授权
├── craft migrate <SN> # 迁移授权到本机
├── craft heartbeat # 手动触发心跳
├── craft device-id # 显示本机设备指纹
└── craft config # 查看/修改本地配置
```
### 5.2 技术实现
- Rust 二进制 crate`Cargo.toml` 中依赖 `craft-core`path dependency
- 使用 `clap` crate 解析命令行参数
- 平台 API 调用使用 `reqwest`(已有依赖)
- 输出格式支持 text(默认)和 json(`--json` 参数)
- 跨平台编译:Linux x86_64 + aarch64, macOS x86_64 + arm64, Windows x86_64
### 5.3 CLI 与 craft-core 的调用关系
```
craft activate SN-12345
└→ clap 解析 args → "activate"
└→ craft_initialize(config) → 初始化上下文
└→ craft_activate(handle, "SN-12345")
├→ 成功 → 持久化 SN 到本地配置
└→ 失败 → 打印错误原因
```
## 6. 实施路线
| 阶段 | 内容 | 估计 | 交付物 |
|------|------|------|--------|
| **S1: 完善SDK** | JNI bridge 编译+集成测试+CI | 1周 | 可调用的 Java SDK |
| **S2: CLI MVP** | 基础 CLIstatus/activate/check/release | 1周 | `craftlabs-auth-cli` 二进制 |
| **S3: CLI 完整** | migrate/heartbeat/config + 平台 API 对接 | 1周 | 完整 CLI 功能 |
| **S4: GUI P0** | Tauri 壳 + Vue UI + 激活/状态查看 | 2周 | 桌面应用 |
| **S5: GUI P1** | 迁移/撤销 + 系统托盘 + 心跳 | 1周 | 完整桌面应用 |
---
## 7. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-05-25 | 初版:基于 PRD 评估和 Tauri 技术选型撰写 |
| 2026-05-25 | 补充:CLI 工具设计 + 实施顺序调整为 SDK→CLI→GUI |
@@ -0,0 +1,401 @@
# Mid 阶段原型设计 — M7 设备管理 / M8 通知待办 / M9 报表对账
> **基于**PRD `docs/chuangfei-platform-product-modules.md`2026-05-25 更新版)§16 原型已知局限
> **设计目标**:补齐 I1~I9 未覆盖的三个模块的 UI 原型,完善完整产品流程(登录 → 客户 → 合同 → 交付 → SN → Callback → 设备 → 待办 → 报表)
> **设计验证**:通过 Visual Companion 线框图确认,2026-05-25
> **关联文档**[BPM 与版本排期](../../chuangfei-platform-bpm-and-roadmap.md) · [并行迭代索引](../../engineering/PARALLEL_ITERATION_INDEX.md)
---
## 1. 背景与动机
### 1.1 当前状态
I1I9 已交付 M1M6 + M10-F01 + M11 基础 + 自研许可证管理(V6)。三个模块完全未开始:
| 模块 | PRD 优先级 | 计划迭代 | 功能点数 |
|------|-----------|---------|---------|
| M7 设备与终端治理 | P1 | I10I12 | 6F01F06 |
| M8 通知与待办 | P1 | I10I12 | 5F01F05 |
| M9 报表与对账 | P1 | I11 | 6F01F06 |
### 1.2 设计原则
- **遵循现有模式**:与 I1I9 的 Vue 3 + Element Plus + Pinia 风格一致
- **渐进增强**:现有原型框架不变,新增菜单项和路由
- **数据溯源**:优先复用已有 API 和数据库表,新增表最小化
- **角色一致的访问控制**:按 PRD §13 权限矩阵控制可见性
---
## 2. 导航结构更新
### 2.1 左侧菜单(新增项以 ★ 标记)
```
📊 首页
👥 客户管理
📋 项目
📋 合同管理
📦 交付管理
🔑 许可 SN
📨 Callback 收件箱
⚙️ 集成配置
🖥️ 设备管理 ★ M7 - SYS_ADMIN / DELIVERY / LICENSE_OPS
🔔 待办中心 / 通知设置 ★ M8 - 全员(按角色过滤)
📊 报表中心 ★ M9 - 管理层 / FINANCE_VIEW / COMPLIANCE
🔍 审计日志
```
### 2.2 新增路由表
| 路由 | 页面组件 | 模块 | 角色 |
|------|---------|------|------|
| `/devices` | `DeviceListView.vue` | M7 | SYS_ADMIN, DELIVERY, LICENSE_OPS |
| `/devices/:id` | `DeviceDetailView.vue` | M7 | 同上 |
| `/todos` | `TodoCenterView.vue` | M8 | 全员 |
| `/notifications/settings` | `NotificationSettingsView.vue` | M8 | SYS_ADMIN 或各角色自配置 |
| `/reports/contract-sn` | `ContractSnReportView.vue` | M9 | FINANCE_VIEW, COMPLIANCE, EXEC_VIEW |
| `/reports/callback-stats` | `CallbackStatsView.vue` | M9 | LICENSE_OPS, COMPLIANCE |
| `/reports/project-health` | `ProjectHealthView.vue` | M9 | EXEC_VIEW, SYS_ADMIN |
---
## 3. M7 设备与终端治理
### 3.1 数据模型(新增表)
```sql
-- M7:设备主表
CREATE TABLE platform_device (
id BIGSERIAL PRIMARY KEY,
mid VARCHAR(128) NOT NULL UNIQUE, -- 设备指纹 / 标识
alias VARCHAR(256), -- 别名(可读名称)
site VARCHAR(256), -- 场站 / 部署位置
customer_id BIGINT REFERENCES platform_customer(id),
project_id BIGINT REFERENCES platform_project(id),
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / INACTIVE / DECOMMISSIONED
first_seen_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- M7:设备↔SN 绑定历史(时间线)
CREATE TABLE platform_device_sn_binding (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES platform_device(id),
license_sn_id BIGINT NOT NULL REFERENCES platform_license_sn(id),
bind_type VARCHAR(32) NOT NULL DEFAULT 'ACTIVATE', -- ACTIVATE / SWAP / RELEASE
bind_at TIMESTAMPTZ NOT NULL DEFAULT now(),
remark TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- M7:换机申请记录
CREATE TABLE platform_device_swap_request (
id BIGSERIAL PRIMARY KEY,
old_device_id BIGINT NOT NULL REFERENCES platform_device(id),
new_mid VARCHAR(128) NOT NULL,
sn_id BIGINT NOT NULL REFERENCES platform_license_sn(id),
reason VARCHAR(512),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING', -- PENDING / APPROVED / REJECTED
processed_by VARCHAR(256),
processed_at TIMESTAMPTZ,
remark TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### 3.2 页面规格
**P1. 设备列表页(/devices**
| 组件 | 说明 |
|------|------|
| 筛选栏 | 客户下拉(可搜索)、场站文本、SN 关键词、「查询」「重置」 |
| 工具栏 | 「登记设备」按钮 → 弹出登记对话框 |
| 表格列 | mid、别名、场站、关联客户、关联 SN、状态(Tag)、最近心跳时间、操作(详情) |
| 状态枚举 | Online(在线)/ Offline(离线)/ Decommissioned(已退役) |
| 分页 | 同现有模式:10/20/50 |
**P2. 设备详情页(/devices/:id**
| 区块 | 内容 |
|------|------|
| 头部 | ← 设备列表;mid 标识;状态 Tag |
| 信息区 | mid、别名、场站、客户/项目、首次发现时间、最近心跳时间 |
| 操作区 | 「编辑设备信息」按钮、「发起换机申请」按钮、「查看 SN 绑定历史」按钮 |
| SN 绑定时间线 | 列表展示:首次激活、换机、解绑等事件,按时间倒序 |
**P3. 换机申请对话框**
| 字段 | 类型 | 校验 |
|------|------|------|
| 原设备 | 只读 | 从设备详情传入 |
| 原 SN | 只读 | 该设备当前绑定的 SN |
| 新设备 mid | 文本输入 | 必填,maxlength=128 |
| 换机原因 | 下拉选择 | 硬件更换 / 场地迁移 / 性能升级 / 其他 |
| 备注 | 文本框 | 选填,maxlength=512 |
**P4. 登记设备对话框**
| 字段 | 类型 | 校验 |
|------|------|------|
| mid | 文本输入 | 必填,maxlength=128,唯一性校验 |
| 别名 | 文本输入 | 选填,maxlength=256 |
| 场站 | 文本输入 | 选填,maxlength=256 |
| 关联客户 | 下拉选择 | 选填,从已有客户列表选取 |
| 关联项目 | 下拉选择 | 选填,依赖所选客户过滤 |
### 3.3 API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/devices` | 设备分页列表(支持 customerId, site, snCode 筛选) |
| POST | `/api/v1/devices` | 登记设备 |
| GET | `/api/v1/devices/{id}` | 设备详情 |
| PUT | `/api/v1/devices/{id}` | 编辑设备信息 |
| GET | `/api/v1/devices/{id}/bindings` | SN 绑定时间线 |
| POST | `/api/v1/devices/{id}/swap-request` | 提交换机申请 |
| GET | `/api/v1/swap-requests` | 换机申请列表(Ops 审批用) |
| PATCH | `/api/v1/swap-requests/{id}/status` | 审批换机申请 |
---
## 4. M8 通知与待办
### 4.1 数据模型(新增表)
```sql
-- M8:待办事项
CREATE TABLE platform_todo_item (
id BIGSERIAL PRIMARY KEY,
todo_type VARCHAR(64) NOT NULL, -- CALLBACK_PENDING / SN_PENDING / ACTIVATION_OVERDUE
title VARCHAR(512) NOT NULL,
source_id BIGINT, -- 关联业务 ID(如 callback_inbox_id
source_type VARCHAR(64), -- 关联业务类型
priority VARCHAR(16) NOT NULL DEFAULT 'MEDIUM', -- HIGH / MEDIUM / LOW
status VARCHAR(32) NOT NULL DEFAULT 'PENDING', -- PENDING / PROCESSED / IGNORED
assigned_role VARCHAR(64), -- 目标角色
assigned_user_id VARCHAR(256), -- 认领人
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
remark TEXT
);
-- M8:通知配置(每个用户或角色)
CREATE TABLE platform_notification_config (
id BIGSERIAL PRIMARY KEY,
role_code VARCHAR(64), -- 角色级别默认配置
channel_email BOOLEAN DEFAULT FALSE,
channel_wecom BOOLEAN DEFAULT FALSE,
channel_in_app BOOLEAN DEFAULT TRUE,
event_type VARCHAR(64) NOT NULL, -- 订阅的事件类型
aggregation_rule VARCHAR(64), -- 聚合规则:NONE / 30MIN / DAILY_DIGEST
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### 4.2 页面规格
**P1. 待办中心(/todos**
| 组件 | 说明 |
|------|------|
| 统计卡片 | 3 个 KPI 卡片:待处理 Callback 数 / 待发放 SN 数 / 待核对激活数(带角色过滤) |
| 筛选栏 | 类型下拉(全部 / Callback / SN 发放 / 激活核对)、状态、「查询」 |
| 表格列 | 类型(Tag)、标题、来源、优先级(高/中/低)、创建时间、操作(认领/前往/忽略) |
| 批量操作 | 「批量标记已处理」「批量忽略」 |
**P2. 通知设置(/notifications/settings**
| 区块 | 内容 |
|------|------|
| 通知通道 | 复选框组:站内待办 / 邮件 / 企业微信 / 短信。**MVP 实际仅实现「站内待办」**;邮件和企微为配置项占位,实际发送通道依赖独立基建,列 Mid 增强 |
| 事件订阅表 | 事件类型、通知方式、订阅角色、静默规则 |
| 操作 | "保存配置"按钮 |
**事件类型订阅清单**(与 Callback 事件类型对齐):
| 事件类型 | 默认通道 | 默认角色 | 聚合规则 |
|---------|---------|---------|---------|
| Callback 待处理 | 站内 + 邮件 | LICENSE_OPS | 30 分钟内合并 |
| SN 待发放 | 站内 | LICENSE_OPS | 每日汇总 |
| 激活超期 7 日 | 站内 + 邮件 | DELIVERY + LICENSE_OPS | 不重复 |
| 换机申请待审批 | 站内 | LICENSE_OPS | 不重复 |
| 合同到期提醒 | 邮件 | SALES | 每周汇总 |
### 4.3 API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/todos` | 待办列表(支持 type, status, role 筛选) |
| PATCH | `/api/v1/todos/{id}/status` | 更新待办状态(认领/完成/忽略) |
| POST | `/api/v1/todos/batch-status` | 批量更新待办状态 |
| GET | `/api/v1/notifications/config` | 获取通知配置 |
| PUT | `/api/v1/notifications/config` | 更新通知配置 |
---
## 5. M9 报表与对账
### 5.1 数据说明
M9 **不新增独立表**,全部基于已有业务表做聚合查询:
| 视图 | 数据源 | 聚合逻辑 |
|------|--------|---------|
| 合同 vs SN 对账 | `platform_contract` + `platform_contract_line` + `platform_license_sn` | 按合同行分组 COUNT SN |
| 已发 vs 已激活 | `platform_license_sn` | 按 status 分组统计 |
| Callback 统计 | `platform_callback_inbox` | 按 event_type / status / 时间聚合 |
| 项目健康度 | 多表联合 | 交付率 / SN 发放率 / 激活率 加权计算 |
### 5.2 页面规格
**P1. 履约对账(/reports/contract-sn**
| 组件 | 说明 |
|------|------|
| 筛选栏 | 项目下拉、合同下拉、「查询」「导出 CSV」 |
| KPI 卡片 | 合同总行项数、已发 SN 数、未发缺口、已激活/已发 |
| 表格列 | 合同编号、客户、行项、应发、实发、已激活、缺口、状态(正常/缺额/超发) |
**P2. Callback 统计(/reports/callback-stats**
| 区块 | 内容 |
|------|------|
| 时间段选择 | 近 24 小时 / 近 7 天 / 近 30 天 / 自定义 |
| 事件类型分布 | 柱状图或百分比条:各事件类型占比 |
| 处理成功率趋势 | 日/周/月成功率 + 总量 |
| Top 失败原因 | 排名列表(失败原因 + 占比 + 趋势) |
**P3. 项目健康度看板(/reports/project-health**
| 组件 | 说明 |
|------|------|
| 表格列 | 项目名称、交付完成率、SN 发放率、激活率、健康度(🟢正常/🟡关注/🔴风险) |
| 健康度规则 | 绿:三项均 ≥80%;黄:任一项 <80%;红:任一项 <50% |
### 5.3 API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/reports/contract-sn` | 履约对账报表(支持 projectId, contractId 筛选) |
| GET | `/api/v1/reports/callback-stats` | Callback 统计(支持时间范围、eventType 筛选) |
| GET | `/api/v1/reports/project-health` | 项目健康度列表 |
| GET | `/api/v1/reports/export` | 导出 CSV — 后端生成文件流,前端触发下载(Content-Disposition: attachment |
---
## 6. 实现注意事项
### 6.1 与现有系统的集成
- **设备↔SN 绑定**`platform_device_sn_binding.license_sn_id` → 引用已有 `platform_license_sn.id`
- **待办自动生成**`platform_todo_item``source_id` + `source_type` 可指向 `platform_callback_inbox.id` 等已有业务表
- **报表聚合**:复用现有 MyBatis-Plus Mapper,新增报表专用的 `@Select` 查询方法
### 6.2 角色与权限
- 初始角色配置使用简化三角色(SYS_ADMIN / DEVELOPER / OPS
- Mid 阶段实际交付时应按 PRD §13.2 的产品定义角色集落地:
- DELIVERY → M7 设备读写
- LICENSE_OPS → M7 设备读写、M8 待办、M9 Callback 统计
- SALES → M9 合同对账只读
- FINANCE_VIEW / COMPLIANCE → M9 报表只读
- EXEC_VIEW → M9 项目健康度只读
### 6.3 M7-F05 设备事件联动实现机制
Callback `device:*` 事件(`device:pre_activate` / `post_activate`)到达时:
1. Webhook Ingress 接收并写入 `platform_callback_inbox`
2. `CallbackInboxService` 解析事件中的 `mid` 字段
3.`mid``platform_device` 中已存在 → 更新 `last_heartbeat_at`,并在 `platform_device_sn_binding` 添加记录
4.`mid` 不存在 → 自动创建设备草稿记录(status=INACTIVE),并在待办中心生成一条「新设备待确认」待办
5. Ops 可在设备详情页手动补充别名/场站/客户关联
此机制**不依赖 M7 管理页面存在**即可在后台运行;M7 页面提供的是查看和手工干预入口。
### 6.4 与 PRD 状态对照
| 功能点 | 本设计覆盖 | 说明 |
|--------|-----------|------|
| M7-F01 设备登记 | ✅ | 登记对话框 + 列表 |
| M7-F02 绑定历史 | ✅ | 设备详情时间线 |
| M7-F03 换机申请 | ✅ | 换机申请对话框 + 审批 |
| M7-F04 设备检索 | ✅ | 列表多维度筛选 |
| M7-F05 Callback 联动 | ✅ | 待办自动生成 |
| M7-F06 策略展示 | ❌ 后续 | 依赖 BitAnswer 策略查询 |
| M8-F01 待办列表 | ✅ | 待办中心 |
| M8-F02 认领/完成 | ✅ | 状态 PATCH + 批量 |
| M8-F03 通知通道 | ✅ | 通知配置页 |
| M8-F04 通知模板 | ✅ | 事件→模板配置 |
| M8-F05 静默规则 | ✅ | 聚合规则配置 |
| M9-F01 合同 vs SN | ✅ | 履约对账页 |
| M9-F02 已发 vs 激活 | ✅ | 履约对账页含 |
| M9-F03 Callback 统计 | ✅ | Callback 统计页 |
| M9-F04 导出 CSV | ✅ | 导出按钮 |
| M9-F05 项目健康度 | ✅ | 健康度看板 |
| M9-F06 订阅报表 | ❌ 后续 | 定时邮件推送 |
---
## 7. 页面关系图
```mermaid
flowchart TB
subgraph Existing["已有页面(I1-I9"]
Login[登录页]
Home[首页]
Customers[客户管理]
Projects[项目]
Contracts[合同管理]
Deliveries[交付管理]
SNS[许可 SN]
Callbacks[Callback 收件箱]
Integration[集成配置]
Audit[审计日志]
end
subgraph New["新增页面(Mid"]
Devices[设备列表]
DeviceDetail[设备详情]
SwapRequest[换机申请]
Todos[待办中心]
NotifConfig[通知设置]
ReportCS[履约对账]
ReportCB[Callback 统计]
ReportPH[项目健康度]
end
Login --> Home
Home --> Customers --> Projects
Projects --> Contracts
Contracts --> Deliveries
Deliveries --> SNS
Callbacks --> SNS
Integration --> SNS
SNS --> Devices
Devices --> DeviceDetail
DeviceDetail --> SwapRequest
Callbacks --> Todos
SNS --> Todos
Devices --> Todos
Contracts --> ReportCS
SNS --> ReportCS
Callbacks --> ReportCB
ReportCS --> ReportPH
ReportCB --> ReportPH
```
---
## 8. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-05-25 | 初版:基于 PRD 更新版和 Visual Companion 线框图确认结果撰写 |
@@ -0,0 +1,318 @@
# 代码实现审计报告 — PRD vs 实际实现
**审计日期:** 2026-05-26
**审计范围:**
- PRD 文档: `chuangfei-platform-product-modules.md` (M1-M11), `FRONTEND_UI_SPECIFICATION.md`, `chuangfei-platform-bpm-and-roadmap.md`
- 后端: `services/delivery-platform-api/` (153 Java 文件) + `services/license-webhook-ingress/`
- 前端: `web/delivery-platform-ui/src/` (47 源文件)
---
## 1. 严重缺陷 (Critical)
### CR-01: 认证系统硬编码用户凭据
**位置:** `AuthController.java:54-89`
**PRD 对照:** M11-F14 要求「用户与账号生命周期:创建、启用/禁用、离职归档」
**实际实现:** 4 个用户硬编码在 Java switch 语句中:
```java
case "admin" role=SYS_ADMIN
case "sales" role=SALES
case "delivery" role=DELIVERY
case "ops" role=LICENSE_OPS
```
**缺陷:**
- 密码 = 小写用户名 (`pass.equals(user.toLowerCase())`) — admin/admin, sales/sales
- 无数据库用户表 — 无法 CRUD、禁用、归档
- 注入的 `PasswordEncoder` (BCrypt) 仅用于 `changePassword` 端点,登录完全不使用
- `changePassword` 验证旧密码时硬编码 `passwordEncoder.encode("admin")`,对非 admin 用户永远失败
**影响:** 无法管理用户、密码与用户名相同、安全基线不达标
---
### CR-02: JWT Token 存储在 localStorage
**位置:** `stores/auth.js:4,8,29,37`
**PRD 对照:** §16.6 已知局限明确标注「前端 Token 存 localStorage(非 HttpOnly Cookie)」为已知安全缺陷,计划 Mid 迁移
**实际实现:** Token 通过 `localStorage.setItem(TOKEN_KEY)` 持久化
```javascript
// auth.js:29
localStorage.setItem(TOKEN_KEY, this.token);
axios.defaults.headers.common.Authorization = `Bearer ${this.token}`;
```
**缺陷:** XSS 攻击可窃取 localStorage 中的 JWT,获得完整 API 访问权限
**影响:** 安全 — XSS 窃取 → 权限丢失
**缓解:** 当前通过 CSP + 前端无富文本渲染降低风险,但未根本解决
---
### CR-03: Error Message 泄露
**位置:**
- `LicenseController.java:25``ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()))`
- `ContractController.java:136``ResponseEntity.status(500).body(Map.of("error", e.getMessage()))`
**PRD 对照:** 无明确的错误消息规范,但全局 `ApiExceptionHandler` 已返回泛化消息 "服务器内部错误"
**缺陷:** 这两个 Controller 使用 try-catch 捕获 `Exception` 并将异常消息原文 (`e.getMessage()`) 返回给客户端,绕过了全局异常处理器。可能泄露实现细节(表名、SQL、文件路径)。
**影响:** 信息安全 — 生产环境可能泄露堆栈或内部路径信息
---
### CR-04: resetPassword 和 forceLogout 是空操作
**位置:** `AuthController.java:131-148`
**PRD 对照:** M11-F08「密码重置: 管理员重置密码或邮件/短信重置链接」、M11-F12「管理员强制下线」
**实际实现:** 两个端点均仅有参数校验,无实际逻辑
```java
// resetPassword — 校验参数后直接返回 200 OK,未更新任何密码
@PostMapping("/admin/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody Map<String, String> body) {
String username = body.get("username");
String newPassword = body.get("newPassword");
if (username == null || newPassword == null || newPassword.length() < 6) {
throw new ResponseStatusException(...);
}
return ResponseEntity.ok().build(); // 没有实际更新密码!
}
// forceLogout — 同上,无会话失效逻辑
@PostMapping("/admin/force-logout")
public ResponseEntity<Void> forceLogout(@RequestBody Map<String, String> body) {
String username = body.get("username");
if (username == null) throw new ResponseStatusException(...);
return ResponseEntity.ok().build(); // 没有实际使会话失效!
}
```
**影响:** 功能完全不可用 — 前端调用后显示成功,实际无效果
---
## 2. 高危缺陷 (High)
### HI-01: 无用户管理数据库表
**位置:** `AuthController.java` (全部)
**PRD 对照:** M11-F14「用户与账号生命周期:创建、启用/禁用、离职归档」— P0
**实际:**`platform_user` 表或类似实体。4 个用户硬编码。Flyway 迁移 V15 `seed_product_roles.sql` 仅涉及角色种子数据。
**影响:** M11-F14 完全未实现,无法添加/禁用/管理用户
---
### HI-02: 权限模型硬编码
**位置:** `AuthController.java:91-114`
**PRD 对照:** §13.4 要求权限码命名规范(如 `customer:project:rw``contract:order:export`
**实际:** 权限字符串在 Java switch 中硬编码,非数据库驱动、不可配置
```java
case "SALES":
permissions.add("customer:*");
permissions.add("project:*");
permissions.add("contract:*");
permissions.add("delivery:read");
break;
```
**影响:** 新增角色或调整权限需改代码重启;权限码 `v-permission` 指令在前端存在但后端无对应校验
---
### HI-03: 会话管理后端无状态
**位置:** `SecurityConfig.java:55-56``SessionCreationPolicy.STATELESS`
**PRD 对照:** M11-F03「空闲超时自动登出」、M11-F11「并发会话策略」
**实际:** JWT 无状态设计意味着后端无法主动使会话失效(无会话存储)。空闲超时仅在前端实现(`idleTimer.js`),后端无法强制登出。`forceLogout` API 为空操作。
**影响:** 并发会话、强制下线、空闲超时功能均无法在后端层面实现
---
### HI-04: LicenseController 异常处理绕过全局 Handler
**位置:** `LicenseController.java:19-27`
**PRD 对照:** 全局 `ApiExceptionHandler` 已提供统一错误格式 `{status, message}`
**实际:** `create` 方法手动 try-catch,返回非标准错误格式
```java
try {
return ResponseEntity.ok(licenseService.create(request));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
```
返回格式为 `{"error": "..."}` 而非全局标准的 `{"status": 500, "message": "..."}`
**影响:** API 响应格式不一致,前端 `apiErrorMessage.js` 可能无法解析
---
## 3. 中危缺陷 (Medium)
### ME-01: 合同附件上传无校验
**位置:** `ContractController.java:118-137`
**PRD 对照:** M2-F05「合同附件:上传扫描件/电子签输出(存储与权限受控)」
**实际:** 上传端点无文件大小限制、无文件类型白名单、无病毒扫描
```java
@PostMapping("/{id}/attachments")
public ResponseEntity<Map<String, Object>> uploadAttachment(@PathVariable Long id, @RequestParam("file") MultipartFile file) {
// 无 file.getSize() 校验
// 无 file.getContentType() 白名单
// 直接将文件写入本地磁盘
```
**影响:** 可能被用于上传恶意文件;磁盘可能被大文件填满
---
### ME-02: 部分 Controller 返回格式不统一
**位置:** 多文件
**PRD 对照:** 无明确 API 响应规范
**实际:** 存在三种返回风格:
1. 全局 `ApiExceptionHandler``{status, message}` (标准)
2. `LicenseController``{error, ...}` (非标准)
3. `ContractController``{error, ...}` (非标准)
4. 部分端点直接返回实体对象(非 Map)
**影响:** 前端 `apiErrorMessage.js` 兼容多种格式但无法覆盖所有情况
---
### ME-03: 系统参数仅存于 localStorage
**位置:** `SystemParamsView.vue:14-33`
**PRD 对照:** M11-F20「系统参数」— 期望持久化到后端数据库
**实际:**
```javascript
// 直接保存到浏览器 localStorage
localStorage.setItem('systemParams', JSON.stringify(params.value))
ElMessage.success('参数已保存(MVP: 存储于浏览器本地)')
```
**影响:** 参数仅对当前浏览器有效,不同用户/设备参数不一致;清除浏览器数据后丢失
---
### ME-04: 后端 changePassword 验证逻辑错误
**位置:** `AuthController.java:151-168`
**PRD 对照:** M11-F07「已登录用户修改本人密码;校验旧密码强度与新密码策略」
**实际:**
```java
String currentPasswordHash = passwordEncoder.encode("admin"); // 始终比较 admin 的密码!
if (!passwordEncoder.matches(oldPassword, currentPasswordHash)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "旧密码错误");
}
```
`passwordEncoder.encode("admin")` 硬编码为 "admin",导致:
- admin 用户可以改密(旧密码 = admin 通过)
- 其他用户(sales/delivery/ops)永远无法通过旧密码验证
---
### ME-05: SN 批量导入缺少事务回滚
**位置:** `LicenseSnService.java:134-155`
**PRD 对照:** M4-F01/F07 批量操作
**实际:** 逐条插入,失败项跳过继续,但方法未标注 `@Transactional`
```java
public Map<String, Object> batchImport(List<LicenseSnCreateRequest> requests) {
// for 循环逐条 insert,无事务保护
// 部分成功 = 部分写入无法回滚
```
**影响:** 批量导入 100 条中第 50 条失败时,前 49 条已写入无法撤销
---
## 4. PRD 与实现偏离 (Misalignment)
### MA-01: M11 角色模型偏离产品定义
| 产品定义角色 | 实际实现 | 状态 |
|------------|---------|------|
| SYS_ADMIN | ✅ | 产品定义包含 |
| SALES | ✅ I10 新增 | 产品定义包含 |
| DELIVERY | ✅ I10 新增 | 产品定义包含 |
| LICENSE_OPS | ✅ I10 新增 | 产品定义包含 |
| ORDER_SUPPORT | ○ | 产品定义但未实现 |
| FINANCE_VIEW | ○ | 产品定义但未实现 |
| COMPLIANCE | ○ | 产品定义但未实现 |
| EXEC_VIEW | ○ | 产品定义但未实现 |
| SECURITY_ADMIN | ○ | 产品定义但未实现 |
| DEVELOPER | ✅ (应废弃) | MVP 遗留非标角色 |
| OPS | ✅ (应废弃) | MVP 遗留非标角色 |
前端路由角色标记(`router/index.js`)仍广泛使用 `SYS_ADMIN``SALES`,但 `DEVELOPER` 已从路由角色列表中移除,而 `LICENSE_OPS``DELIVERY` 已加入。
### MA-02: M1-F07 客户冻结后端就绪前端缺 UI
**位置:** 后端 `CustomerController.java``PATCH /{id}/freeze``/unfreeze` 端点,前端 `CustomersView.vue` 无冻结操作入口
### MA-03: M11-F07 密码修改已实现但产品文档未标记
`ProfileView.vue` 已包含完整的改密弹窗,`AuthController` 有对应端点,但文档标注为 ○。
---
## 5. 代码质量问题 (Code Quality)
### CQ-01: SecurityConfig 重复 import (已修复)
**位置:** `SecurityConfig.java:5,20` — 两次 `import org.springframework.context.annotation.Bean`
**状态:** ✅ 本次审计已修复
### CQ-02: 个别 Controller 使用 `@PreAuthorize` 而非 JWT Filter 角色
**位置:** `LicenseController.java:20,30,40` — 使用 `@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")`
**问题:** 与 JWT Filter 的双重验证增加了 role 前缀处理的复杂性(JwtAuthFilter 添加 `ROLE_` 前缀,`@PreAuthorize` 期望 `ROLE_` 格式)
### CQ-03: 前端 API 层中个别函数含 query 参数拼接
**位置:** `platform.js:460`
```javascript
export function createSkuMapping(contractLineId, body) {
return axios.post(`/api/v1/integration/sku-mappings?contractLineId=${contractLineId}`, body);
}
```
**影响:** 非紧急,但建议统一使用 `{ params: { contractLineId } }` 方式
---
## 6. 未被 PRD 覆盖但代码已实现的模块(超前实现)
| 模块 | 功能 | 建议 |
|------|------|------|
| M7 设备管理 | 登记/列表/详情/绑定/换机申请 | 核对 PRD 需求后决定是否纳入正式范围 |
| M8 通知待办 | 待办中心 + 通知通道配置 UI | 需补充实际发送逻辑 |
| M9 报表对账 | 4 个报表页面均已上线 | 补充导出按钮和推送逻辑 |
| M6 ID/特征/SKU 映射 | 前后端均已实现 | 更新产品文档状态 |
---
## 7. 汇总统计
| 严重级别 | 数量 | 编号 |
|---------|------|------|
| 🔴 Critical | 4 | CR-01~CR-04 |
| 🟠 High | 4 | HI-01~HI-04 |
| 🟡 Medium | 5 | ME-01~ME-05 |
| 🔵 Misalignment | 3 | MA-01~MA-03 |
| ⚪ Code Quality | 3 | CQ-01~CQ-03 |
| **合计** | **19** | |
---
## 8. 修复建议优先级
### P0 — 立即修复(安全基线)
1. **CR-01** (硬编码用户): 创建 `platform_user` 表 + Flyway 迁移 + AuthController 改用数据库查询 + BCrypt 密码校验
2. **CR-04** (空操作端点): `resetPassword``forceLogout` 补充实际逻辑 — resetPassword 更新用户密码,forceLogout 增加黑名单机制
3. **CR-03** (错误泄露): `LicenseController``ContractController` 移除 try-catch,让全局 `ApiExceptionHandler` 接管
4. **ME-04** (改密逻辑错误): `changePassword` 从 SecurityContext 获取当前用户名,从数据库查询对应用户的密码哈希
### P1 — 短期修复
5. **HI-01** (用户管理): 在 P0 用户表基础上实现用户 CRUD API + 前端管理页面
6. **ME-01** (附件校验): ContractController upload 增加 `@Size` 注解和文件类型白名单
7. **ME-05** (事务缺失): `batchImport` 方法添加 `@Transactional`
### P2 — 中长期
8. **CR-02** (Token 存储): 迁移至 HttpOnly Cookie(需后端配合返回 Set-Cookie header
9. **HI-02** (权限模型): 权限码持久化到数据库,实现可配置 RBAC
10. **HI-03** (会话管理): 引入 Token 黑名单/白名单机制或 Redis 会话存储
@@ -0,0 +1,469 @@
# 原型实现复盘 — 缺漏功能 & 页面清单
**生成日期:** 2026-05-26
**参考来源:**
- `docs/chuangfei-platform-product-modules.md` (§2~§12 功能点表 + §16 原型说明)
- `docs/engineering/FRONTEND_UI_SPECIFICATION.md` (前端 UI 规格)
- `services/delivery-platform-api/` 全部 Controller 端点
- `web/delivery-platform-ui/src/router/index.js` + `src/views/` (38 视图)
- `docs/engineering/iterations/I9_IMPLEMENTATION_REVIEW.md`
---
## 总览
| 模块 | 功能点总数 | ✅ 已实现 | ◐ 部分实现 | ○ 未实现 | — 依赖前置 |
|------|-----------|-----------|------------|----------|-----------|
| **M1** 客户与项目 | 9 | 4 | 1 | 4 | 0 |
| **M2** 合同与履约行 | 9 | 5 | 0 | 4 | 0 |
| **M3** 交付管理 | 8 | 5 | 1 | 2 | 0 |
| **M4** 授权与许可运营 | 11 | 3 | 2 | 5 | 1 |
| **M5** Callback 运营 | 10 | 7 | 1 | 2 | 0 |
| **M6** 授权集成与配置 | 9 | 3 | 0 | 6 | 0 |
| **M7** 设备与终端 | 6 | 0 | 4 | 2 | 0 |
| **M8** 通知与待办 | 5 | 0 | 2 | 3 | 0 |
| **M9** 报表与对账 | 6 | 0 | 4 | 2 | 0 |
| **M10** 审计与合规 | 4 | 1 | 1 | 2 | 0 |
| **M11** 身份与平台管理 | 21 | 6 | 3 | 12 | 0 |
| **合计** | **98** | **34 (35%)** | **19 (19%)** | **44 (45%)** | **1 (1%)** |
> **说明**: 实际代码实现程度高于产品模块文档中的状态标记。以下按模块逐个详细盘点。
---
## M1 — 客户与项目中心
**当前页面**: `/customers` `CustomersView.vue`, `/customers/:id` `CustomerDetailView.vue`, `/projects` `ProjectsView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M1-F01 | 客户档案创建/编辑 | ◐ | 仅 name + credit_code,缺行业/地址/开票信息字段 |
| M1-F02 | 客户列表与检索 | ✅ | 关键词搜索 + 分页 |
| M1-F03 | 客户详情聚合视图 | ○ | **未实现** — 缺少关联项目数/在履约合同/在途 SN 统计摘要。后端 `GET /{id}/summary` 已存在 |
| M1-F04 | 项目创建/编辑 | ◐ | 仅 name + customer_id + phase,缺计划起止日期、项目经理 |
| M1-F05 | 项目列表与筛选 | ✅ | 按客户、阶段筛选 |
| M1-F06 | 项目干系人 | ◐ | 后端有 `/stakeholders` CRUD 端点,但前端 **无独立 UI 入口**(仅 API 可用) |
| M1-F07 | 客户/项目冻结与解冻 | ◐ | 后端 `PATCH /{id}/freeze` `/unfreeze` 已实现,但前端 **缺 UI 操作** |
| M1-F08 | 客户合并与去重 | ○ | 未开始 |
| M1-F09 | 外部 CRM 主数据同步 | ○ | 未开始 |
### 前端页面缺口
| 缺漏 | 说明 |
|------|------|
| 客户详情聚合视图 | 后端 `/customers/{id}/summary` 已就绪,缺前端展示页 |
| 项目干系人管理 UI | 后端 CRUD 就绪,前端口/弹窗未实现 |
| 冻结/解冻操作 UI | 后端端点就绪,前端缺按钮和确认弹窗 |
---
## M2 — 合同与履约行
**当前页面**: `/contracts` `ContractsView.vue`, `/contracts/new` `ContractWizardView.vue`, `/contracts/:id` `ContractDetailView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M2-F01 | 合同登记与编辑 | ✅ | 完整 CRUD + 客户/项目关联 |
| M2-F02 | 合同状态机 | ✅ | DRAFT→PENDING_EFFECTIVE→EFFECTIVE→CHANGING→TERMINATED |
| M2-F03 | 合同标的摘要 | ✅ | 行项汇总展示 |
| M2-F04 | 合同行项 | ✅ | 多行 CRUD,含数量/单位 |
| M2-F05 | 合同附件 | ◐ | 后端有 `POST /{id}/attachments` 端点,前端 **缺上传 UI** |
| M2-F06 | 合同与订单关联 | ○ | 未开始 |
| M2-F07 | 合同变更与版本 | ◐ | 后端有 `POST /{id}/changes` + `/complete` 端点,前端 **缺变更 UI** |
| M2-F08 | 合同行↔SKU 映射 | ○ | 依赖 M6 联动 |
| M2-F09 | 合同到期与续费提醒 | ○ | 依赖 M8 联动 |
### 前端页面缺口
| 缺漏 | 说明 |
|------|------|
| 附件上传弹窗 | 后端端点已存在,详情页缺附件区块 |
| 变更单发起/完成 UI | 后端 `changes` 端点已实现,合同详情缺变更操作入口 |
---
## M3 — 交付管理
**当前页面**: `/deliveries` `DeliveriesView.vue`, `/deliveries/new` `DeliveryBatchWizardView.vue`, `/deliveries/:id` `DeliveryBatchDetailView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M3-F01 | 交付批次创建 | ✅ | |
| M3-F02 | 交付清单 | ✅ | 行项管理 |
| M3-F03 | 交付与合同行关联 | ✅ | |
| M3-F04 | 交付状态 | ✅ | PENDING→DELIVERED→CANCELLED |
| M3-F05 | 交付完成确认 | ✅ | |
| M3-F06 | 现场环境信息 | ○ | 未实现 |
| M3-F07 | SN 发放门禁 | ○ | 后端 system params `deliveryGateEnabled` 已定义,但门禁逻辑未实际执行 |
| M3-F08 | 交付模板 | ○ | 未开始 |
---
## M4 — 授权与许可运营
**当前页面**: `/licenses/sn` `LicenseSnListView.vue`, `/licenses/sn/new` `LicenseSnWizardView.vue`, `/licenses/sn/:id` `LicenseSnDetailView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M4-F01 | SN 手工录入/导入 | ◐ | 手工录入✅,批量导入 **缺前端 UI**(后端 `POST /batch-import` 已存在) |
| M4-F02 | SN 与合同/项目/客户绑定 | ✅ | |
| M4-F03 | SN 生命周期状态 | ✅ | REGISTERED→ISSUED→ACTIVATED→SUSPENDED→REVOKED |
| M4-F04 | SN 详情页 | ✅ | 绑定/状态/备注 |
| M4-F05 | 激活结果回写 | ◐ | 支持手工状态更新,缺原因码分类 |
| M4-F06 | 比特控制台状态摘要 | ○ | 依赖比特对接,未实现 |
| M4-F07 | 批量 SN 操作 | ◐ | 后端 `POST /batch-import` 已存在,前端 **缺批量操作 UI** |
| M4-F08 | 授权需求单 | ○ | 未开始 |
| M4-F09 | 试用/正式/续期标签 | ○ | 未开始 |
| M4-F10 | SN 与设备关联视图 | — | 依赖 M7 |
| M4-F11 | 授权策略生效视图 | ○ | 依赖 M6 联动 |
### 前端页面缺口
| 缺漏 | 说明 |
|------|------|
| 批量导入 SN UI | 后端 `POST /license-sns/batch-import` 已就绪,前端缺导入页面/弹窗 |
| 批量 SN 操作 UI | 列表页缺批量选择 + 批量状态变更 |
| 原因码分类选择 | 详情页状态变更时缺原因码下拉 |
---
## M5 — Callback 运营
**当前页面**: `/callbacks` `CallbackInboxView.vue`, `/callbacks/:id` `CallbackInboxDetailView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M5-F01 | 事件收件箱列表 | ✅ | 多维度筛选 |
| M5-F02 | 事件详情 | ✅ | payload 脱敏预览 |
| M5-F03 | 处理状态 | ✅ | PENDING→PROCESSED/FAILED/IGNORED |
| M5-F04 | 关联解析失败兜底 | ✅ | 人工挂接 SN/项目/合同 |
| M5-F05 | 事件类型字典 | ✅ | |
| M5-F06 | 失败原因标注 | ○ | 未实现 |
| M5-F07 | 批量重处理/重试 | ◐ | 单条 DEAD 重放✅ (I8)**批量未做** |
| M5-F08 | 死信与积压监控视图 | ○ | 未实现 |
| M5-F09 | 事件驱动待办 | — | 依赖 M8 |
| M5-F10 | 模拟投递 | ◐ | `POST /simulate` 端点已存在,前端 **缺测试工具 UI** |
### 前端页面缺口
| 缺漏 | 说明 |
|------|------|
| 模拟投递测试工具 | 后端 `POST /callback-inbox/simulate` 就绪,前端缺页面/弹窗 |
| 批量重处理 UI | 列表页缺批量选择 + 批量重入队 |
| 死信积压监控 | 独立视图或仪表盘区块 |
---
## M6 — 授权集成与配置
**当前页面**: `/integration/environments`, `/integration/product-lines`, `/integration/id-mappings`, `/integration/sku-mappings`, `/integration/feature-mappings`, `/integration/json-templates`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M6-F01 | 产品线定义 | ✅ | 列表已实现 |
| M6-F02 | 环境维度 | ✅ | dev/prod seed 数据 |
| M6-F03 | 比特 ID 映射 | ◐ | 前端 `IntegrationIdMappingView` 已存在,后端 CRUD 就绪,但 **产品模块标记为○**,需确认映射字段对齐 |
| M6-F04 | 特征映射 | ◐ | 前端 `IntegrationFeatureMappingView` 已存在,后端就绪 |
| M6-F05 | JSON 模板管理 | ◐ | 前端 `IntegrationJsonTemplateView` 已存在,后端 CRUD 就绪,但 **Schema 校验未关联 UI** |
| M6-F06 | 配置发布记录 | ○ | 未实现 |
| M6-F07 | 控制台链接与说明 | ○ | 未实现 |
| M6-F08 | SDK 版本矩阵 | ○ | 未开始 |
| M6-F09 | 变更影响分析 | ○ | 未开始 |
> **说明**: M6 实际代码实现远超产品文档标记。ID 映射/特征映射/JSON 模板 的前后端均已实现但未标记。需核对字段完整性。
---
## M7 — 设备与终端治理
**当前页面**: `/devices` `DeviceListView.vue`, `/devices/:id` `DeviceDetailView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M7-F01 | 设备登记 | ◐ | 前端 `DeviceListView` 已实现列表+登记弹窗,后端 CRUD 就绪,但字段覆盖需确认 |
| M7-F02 | 设备与 SN 绑定历史 | ◐ | `DeviceDetailView` 已实现,绑定时间线需核对完整性 |
| M7-F03 | 换机申请与处理 | ◐ | 后端 `POST /{id}/swap-request` 已存在,审批流未实现 |
| M7-F04 | 设备列表与检索 | ✅ | 已实现 |
| M7-F05 | 与 Callback 设备事件联动 | ○ | 未实现 |
| M7-F06 | 终端数/并发策略展示 | ○ | 未开始 |
> **说明**: M7 实际实现远超产品文档标记的"全 ○"。设备登记、列表、详情、绑定历史已上线。待确认字段和审批流完整性。
---
## M8 — 通知与待办
**当前页面**: `/todos` `TodoCenterView.vue`, `/notifications/settings` `NotificationSettingsView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M8-F01 | 站内待办列表 | ◐ | `TodoCenterView` 已实现,支持类型筛选/优先级/状态,但 **自动化生成待办** 未接入 |
| M8-F02 | 待办认领与完成 | ◐ | 状态流转已实现,但标注/备注功能待补 |
| M8-F03 | 邮件/企微通道 | ◐ | `NotificationSettingsView` 已实现通道配置 UI + 事件订阅表,但 **实际发送逻辑** 未接入 |
| M8-F04 | 通知模板 | ○ | 未实现 |
| M8-F05 | 静默规则 | ○ | 未开始 |
> **说明**: M8 实际实现远超产品文档标记。待办中心+通知设置均已上线。核心缺口是自动化待办生成和通知发送通道的实际对接。
---
## M9 — 报表与对账
**当前页面**: `/reports/contract-sn` `ContractSnReportView.vue`, `/reports/callback-stats` `CallbackStatsView.vue`, `/reports/project-health` `ProjectHealthView.vue`, `/reports/subscriptions` `SubscriptionReportView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M9-F01 | 合同标的 vs 已发 SN 视图 | ◐ | `ContractSnReportView` 已实现,需核对数据准确性和维度 |
| M9-F02 | 已发 vs 已激活视图 | ○ | 未专门实现(可合并到 F01) |
| M9-F03 | Callback 统计 | ◐ | `CallbackStatsView` 已实现 |
| M9-F04 | 导出 CSV/Excel | ◐ | 后端 `GET /reports/export` 已存在,前端 **缺导出按钮 UI** |
| M9-F05 | 项目健康度看板 | ◐ | `ProjectHealthView` 已实现,红黄绿规则可配置性待确认 |
| M9-F06 | 订阅报表 | ◐ | `SubscriptionReportView` 已实现,后端推送逻辑待确认 |
> **说明**: M9 的实际实现远超文档标记,4 个报表页面均已上线。
---
## M10 — 审计与合规
**当前页面**: `/audit` `AuditSearchView.vue`, `/audit/retention` `AuditRetentionView.vue`
### 功能点复盘
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M10-F01 | 关键字段变更日志 | ✅ | |
| M10-F02 | 审计检索 | ◐ | `AuditSearchView` 已实现,筛选维度待确认是否齐全 |
| M10-F03 | 导出审计包 | ○ | 未实现 |
| M10-F04 | 留存策略配置 | ◐ | `AuditRetentionView` 已实现,配置生效待确认 |
---
## M11 — 身份、访问与平台管理
**当前页面**: `/login` `LoginView.vue`, `/profile` `ProfileView.vue`, `/admin/params` `SystemParamsView.vue`
**其他**: `/403`, `/404`
### 12.1 账户登录、登出与会话
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M11-F01 | 登录页 | ✅ | JWT Bearer |
| M11-F02 | 登出 | ✅ | |
| M11-F03 | 登录态保持与超时 | ○ | **未实现** — 空闲超时自动登出缺失。`sessionTimeoutMinutes` 系统参数已定义但前端路由守卫未接入 |
| M11-F04 | 未登录访问拦截 | ✅ | 路由 `requiresAuth` guard |
| M11-F05 | 登录失败锁定 | ○ | **未实现** — 连续失败锁定/验证码缺失 |
| M11-F06 | 登录/登出审计 | ✅ | |
| M11-F07 | 密码修改 | ◐ | 后端 `POST /auth/change-password` 已存在,前端 Profile 页 **缺改密 UI** |
| M11-F08 | 密码重置 | ◐ | 后端 `POST /auth/admin/reset-password` 已存在,前端 **缺管理员重置 UI** |
| M11-F09 | 企业 SSO / OIDC | ○ | 未开始 |
| M11-F10 | 双因素认证 MFA | ○ | 未开始 |
| M11-F11 | 并发会话策略 | ○ | 后端未实现 |
| M11-F12 | 管理员强制下线 | ◐ | 后端 `POST /auth/admin/force-logout` 已存在,前端 **缺管理 UI** |
| M11-F13 | 服务时间窗提示 | ○ | 未开始 |
### 12.2 用户、角色与权限配置
| ID | 功能点 | 实现状态 | 详情 |
|----|--------|---------|------|
| M11-F14 | 用户与账号生命周期 | ◐ | 种子用户已创建,缺完整的用户管理页面(CRUD+启用/禁用) |
| M11-F15 | 角色定义与分配 | ◐ | 三角色已落地,产品定义 10+ 角色待补齐 |
| M11-F16 | 功能权限 RBAC | ◐ | 路由级 RBAC ✅,按钮级权限码 `v-permission` 正在落地未全覆盖 |
| M11-F17 | 数据范围 | ○ | 未开始 |
| M11-F18 | 数据属主/团队 | ○ | 未开始 |
| M11-F19 | 业务字典 | ✅ | |
| M11-F20 | 系统参数 | ◐ | `SystemParamsView` 已实现 + 后端 `system_params` 表,参数种类待扩充 |
| M11-F21 | 管理员敏感操作留痕 | ○ | 未实现 |
### 前端页面缺口
| 缺漏 | 说明 |
|------|------|
| 用户管理页面 | 用户 CRUD + 启用/禁用/角色分配页面缺失 |
| 角色管理页面 | 角色定义 + 权限码分配页面缺失 |
| 改密 UI | Profile 页缺修改密码表单 |
| 管理员重置密码 UI | 后端端点已存在,缺对应管理页面/弹窗 |
| 强制下线管理 UI | 后端端点已存在,缺在线会话管理页面 |
| 登录态超时拦截 | 系统参数已定义但前端路由守卫未接入空闲检测 |
---
## 前端页面完整盘点
### 已实现页面(38 视图)
| 路由 | 视图 | 模块 | 状态 |
|------|------|------|------|
| `/login` | `LoginView.vue` | M11 | ✅ |
| `/` | `HomeView.vue` | — | ✅ |
| `/customers` | `CustomersView.vue` | M1 | ✅ |
| `/customers/:id` | `CustomerDetailView.vue` | M1 | ✅ |
| `/projects` | `ProjectsView.vue` | M1 | ✅ |
| `/contracts` | `ContractsView.vue` | M2 | ✅ |
| `/contracts/new` | `ContractWizardView.vue` | M2 | ✅ |
| `/contracts/:id` | `ContractDetailView.vue` | M2 | ✅ |
| `/deliveries` | `DeliveriesView.vue` | M3 | ✅ |
| `/deliveries/new` | `DeliveryBatchWizardView.vue` | M3 | ✅ |
| `/deliveries/:id` | `DeliveryBatchDetailView.vue` | M3 | ✅ |
| `/licenses/sn` | `LicenseSnListView.vue` | M4 | ✅ |
| `/licenses/sn/new` | `LicenseSnWizardView.vue` | M4 | ✅ |
| `/licenses/sn/:id` | `LicenseSnDetailView.vue` | M4 | ✅ |
| `/licenses` | `LicenseList.vue` | V6 | ✅ |
| `/callbacks` | `CallbackInboxView.vue` | M5 | ✅ |
| `/callbacks/:id` | `CallbackInboxDetailView.vue` | M5 | ✅ |
| `/integration/environments` | `IntegrationEnvironmentsView.vue` | M6 | ✅ |
| `/integration/product-lines` | `IntegrationProductLinesView.vue` | M6 | ✅ |
| `/integration/id-mappings` | `IntegrationIdMappingView.vue` | M6 | ✅ |
| `/integration/sku-mappings` | `IntegrationSkuMappingView.vue` | M6 | ✅ |
| `/integration/feature-mappings` | `IntegrationFeatureMappingView.vue` | M6 | ✅ |
| `/integration/json-templates` | `IntegrationJsonTemplateView.vue` | M6 | ✅ |
| `/devices` | `DeviceListView.vue` | M7 | ✅ |
| `/devices/:id` | `DeviceDetailView.vue` | M7 | ✅ |
| `/todos` | `TodoCenterView.vue` | M8 | ✅ |
| `/notifications/settings` | `NotificationSettingsView.vue` | M8 | ✅ |
| `/reports/contract-sn` | `ContractSnReportView.vue` | M9 | ✅ |
| `/reports/callback-stats` | `CallbackStatsView.vue` | M9 | ✅ |
| `/reports/project-health` | `ProjectHealthView.vue` | M9 | ✅ |
| `/reports/subscriptions` | `SubscriptionReportView.vue` | M9 | ✅ |
| `/audit` | `AuditSearchView.vue` | M10 | ✅ |
| `/audit/retention` | `AuditRetentionView.vue` | M10 | ✅ |
| `/admin/params` | `SystemParamsView.vue` | M11 | ✅ |
| `/profile` | `ProfileView.vue` | M11 | ✅ |
| `/license-compare` | `LayoutCompareView.vue` | — | ✅ |
| `/403` | `ForbiddenView.vue` | M11 | ✅ |
| `/*` | `NotFoundView.vue` | M11 | ✅ |
### 结论:前端页面完整性
- **原型 UI 规格(§16.2)定义页面**: 全部 13 个页面已实现 ✅
- **超出原型范围的已实现页面(I10 及以上提前完成)**: 25 个额外页面(设备/待办/通知/报表/审计/集成配置等)
- **仍有缺口的功能区域**(见各模块复盘)
---
## 后端 API 缺口汇总
以下端点已在产品模块文档中定义但尚未实现:
| 模块 | 缺失端点 | 说明 |
|------|---------|------|
| M1 | 客户详情聚合统计 | `/customers/{id}/summary` 已存在,确认数据完整性 |
| M1 | 客户合并 | 无端点 |
| M2 | 订单关联 | 无端点 |
| M4 | 批量 SN 导入 | `POST /batch-import` 已存在,前端缺 UI |
| M5 | 批量重处理 | 无端点 |
| M6 | 配置发布记录 | 无端点 |
| M6 | 版本矩阵 | 无端点 |
| M7 | 换机审批流程 | 无审批端点 |
| M8 | 通知通道实际发送 | 配置已就绪,发送接口未接入 |
| M9 | 报表导出 | `GET /reports/export` 已存在,前端导出按钮缺失 |
| M10 | 审计导出包 | 无端点 |
| M11 | 用户管理 CRUD | 无专用端点(当前硬编码种子用户) |
| M11 | 角色管理 CRUD | 无专用端点 |
| M11 | 在线会话管理 | `force-logout` 已存在,会话列表端点缺失 |
---
## 按优先级分类的缺失功能清单
### P0 级别(MVP 应含但未完成)
| 功能 | 模块 | 说明 |
|------|------|------|
| 客户详情聚合视图 (M1-F03) | M1 | 后端 `/summary` 已就绪,前端未开发 |
| 登录态空闲超时 (M11-F03) | M11 | 安全基线必备,`sessionTimeoutMinutes` 参数已定义但未接入 |
| 登录失败锁定 (M11-F05) | M11 | 安全基线必备 |
| 密码修改 (M11-F07) | M11 | 后端端点已存在,Profile 页缺 UI |
| 用户管理页面 (M11-F14) | M11 | 用户 CRUD + 启用/禁用页面缺失 |
### P1 级别(增强运营效率)
| 功能 | 模块 | 说明 |
|------|------|------|
| 项目干系人管理 UI (M1-F06) | M1 | 后端就绪前端口 |
| 客户/项目冻结 UI (M1-F07) | M1 | 后端就绪前端口 |
| 合同附件管理 UI (M2-F05) | M2 | 后端就绪前端口 |
| 合同变更 UI (M2-F07) | M2 | 后端 `changes` 端点就绪前端口 |
| 批量 SN 导入 (M4-F01/F07) | M4 | 后端就绪前端口 |
| Callback 模拟投递 UI (M5-F10) | M5 | 后端就绪前端口 |
| Callback 批量重处理 (M5-F07) | M5 | 后端缺批量端点 |
| 账号密码重置 UI (M11-F08) | M11 | 后端就绪前端口 |
| 管理员强制下线 UI (M11-F12) | M11 | 后端就绪前端口 |
| 角色权限管理页面 (M11-F15/F16) | M11 | 角色 CRUD + 权限码分配 |
| 报表导出按钮 (M9-F04) | M9 | 后端就绪前端口 |
| 通知通道实际发送 (M8-F03) | M8 | 配置就绪但未对接 |
### P2 级别(治理与规模化)
| 功能 | 模块 |
|------|------|
| 客户合并与去重 (M1-F08) | M1 |
| CRM 同步 (M1-F09) | M1 |
| 合同到期续费提醒 (M2-F09) | M2 |
| 现场环境信息 (M3-F06) | M3 |
| 交付模板 (M3-F08) | M3 |
| 配置发布记录 (M6-F06) | M6 |
| SDK 版本矩阵 (M6-F08) | M6 |
| 变更影响分析 (M6-F09) | M6 |
| 终端并发策略展示 (M7-F06) | M7 |
| 通知模板 (M8-F04) | M8 |
| 静默规则 (M8-F05) | M8 |
| 审计导出包 (M10-F03) | M10 |
| SSO/OIDC (M11-F09) | M11 |
| MFA (M11-F10) | M11 |
| 数据范围 (M11-F17) | M11 |
| 数据属主 (M11-F18) | M11 |
---
## 与产品模块文档的状态差异(代码领先文档)
以下功能点的实际实现状态优于 `chuangfei-platform-product-modules.md` 中的标记:
| 功能点 | 文档标记 | 实际代码状态 | 差异说明 |
|--------|---------|-------------|---------|
| **M1-F06** 项目干系人 | ○ | ◐ | 后端 CRUD 已实现,仅前端口 |
| **M1-F07** 冻结解冻 | ○ | ◐ | 后端端点已实现,仅前端口 |
| **M2-F05** 合同附件 | ○ | ◐ | 后端上传端点已实现 |
| **M2-F07** 合同变更 | ○ | ◐ | 后端 `changes` 端点已实现 |
| **M4-F01** 批量导入 | ○ | ◐ | 后端 `batch-import` 已实现 |
| **M6** 全模块 | 大段 ○ | ◐ | ID 映射/JSON 模板/特征映射/SKU 映射均已前后端实现 |
| **M7** 全模块 | 全 ○ | ◐ | 设备登记/列表/详情/绑定历史已上线 |
| **M8** 全模块 | 全 ○ | ◐ | 待办中心+通知设置已上线 |
| **M9** 全模块 | 全 ○ | ◐ | 4 个报表页面均上线 |
| **M10-F02** 审计检索 | ○ | ◐ | 已实现 |
| **M10-F04** 留存策略 | ○ | ◐ | 已实现 |
---
## 修订建议
### 文档层面
1. **更新 `chuangfei-platform-product-modules.md`** — 大量功能点(M6/M7/M8/M9)实际代码已实现但文档标记为 ○,需批量刷新状态列
2. **更新 `FRONTEND_UI_SPECIFICATION.md`** — 新增页面(集成 ID 映射/SKU 映射/特征映射/JSON 模板/审计留存/待办中心/通知设置等)未纳入 UI 规格文档
3. **补充角色与实际菜单对照** — 当前角色定义(SYS_ADMIN/SALES/LICENSE_OPS/DELIVERY)与产品文档三角色不符,新增角色需更新路由权限
### 工程层面
1. **优先级确认** — 按 Mid 版本规划,聚焦 P0 安全基线缺口(M11-F03 超时/F05 锁定/F07 改密/F14 用户管理)+ P1 就绪后端触点(M1/M2/M4/M5/M11 前端 UI 补全)
2. **文档与代码对齐** — 先产出更新后的产品模块文档,再进入 I10 实现
---
**附录**: 本清单基于源码走查 + 产品文档对比生成,未运行端到端测试验证。建议在启动 I10 实现前,由 QA 逐个功能点做冒烟验证。
@@ -0,0 +1,265 @@
# 前端 UI 走查复盘报告
**日期:** 2026-05-27
**前端:** `web/delivery-platform-ui` — 38 views + MainLayout + Router
---
## 1. 侧栏菜单布局与排序
### 当前排序
```
📊 首页
👥 客户管理
📋 合同管理
📦 交付管理
🔑 许可 SN
🛡️ 许可证管理 [NEW]
📨 Callback 收件箱
🌐 集成环境
📱 产品线
🖥️ 设备管理
🔔 待办中心
📊 报表中心
📧 报表订阅
👤 用户管理
```
### 评估
| 方面 | 评价 |
|------|------|
| **分组** | 无分组。所有菜单项单层平铺,缺少二级分类。当菜单项增多时不易查找 |
| **排序** | 基本合理:核心业务(客户→合同→交付→SN)在前,运营支持(Callback→设备→待办)在中,管理类(报表→用户)在后 |
| **建议优化** | 可增加分组标签「业务管理」「运营管理」「系统管理」来归类 |
---
## 2. 首页(HomeView.vue)命名
### 当前状态
| 位置 | 显示文本 | 问题 |
|------|---------|------|
| 浏览器 title | 未设置 | `<title>` 标签缺失 |
| 登录页标题 | `客户商务与交付管理平台(I1` | I1 标签过时,应该是已迭代到 I10+ |
| 顶栏 nav | `授权平台` | 品牌简称,可接受 |
| 首页内容 | `首页` | 正确 |
| 首页 alert | `交付平台(I7` | 同样过时 |
### 问题
1. **无 `<title>` 标签** — 浏览器标签页显示 URL 路径而非平台名称
2. **I1 / I7 标签过时** — 登录页和首页仍显示迭代编号,应替换为稳定名称
3. **演示账号提示过时** — 登录页提示 `dev / devDEVELOPER` 但 DEVELOPER 角色已废弃,应改为 `sales / sales`
4. **首页内容标题与侧栏** — 顶部显示 `授权平台`,登录页显示 `客户商务与交付管理平台`,二者不一致
---
## 3. 逐页功能盘点
### M1 客户管理 (`/customers`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 客户列表 | ✅ | 名称/信用代码分页 |
| 搜索 | ✅ | 关键词搜索 |
| 新建/编辑弹窗 | ✅ | 含行业/地址/开票信息 |
| 冻结按钮 | ✅ | 后端就绪,前端已联动 |
| 删除(软删) | ✅ | |
| **详情聚合视图** | ✅ | 关联项目/合同/SN 统计 |
| **合并/去重** | ○ | Full 版本范围 |
### M1 项目管理 (`/projects`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 项目列表 | ✅ | |
| 按客户筛选 | ✅ | |
| 新建/编辑弹窗 | ✅ | |
| **干系人管理** | ✅ | CRUD 弹窗已实现 |
| 计划起止日期 | ✅ | 字段已存在 |
| 项目经理 | ✅ | 字段已存在 |
### M2 合同管理 (`/contracts`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 合同列表 | ✅ | |
| 三步创建向导 | ✅ | |
| 状态机操作 | ✅ | DRAFT→PENDING→EFFECTIVE→CHANGING→TERMINATED |
| 行项管理 | ✅ | |
| **附件上传** | ✅ | el-upload + 文件列表 |
| **合同变更** | ✅ | changes 端点已联动 |
| SKU 映射 | ○ | 未在前端展示 |
### M3 交付管理 (`/deliveries`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 交付列表 | ✅ | |
| 新建向导 | ✅ | |
| 状态变更 | ✅ | PENDING→DELIVERED→CANCELLED |
| 行项管理 | ✅ | |
| **现场环境信息** | ✅ | 部署地址/联系人/电话 |
| SN 发放门禁 | ○ | 后端参数已定义,逻辑未执行 |
### M4 许可 SN (`/licenses/sn`)
| 功能 | 状态 | 备注 |
|------|------|------|
| SN 列表 | ✅ | |
| 搜索 | ✅ | SN 编码/项目筛选 |
| **批量导入** | ✅ | CSV 批量导入弹窗 |
| **批量操作** | ✅ | 批量状态变更弹窗 |
| 详情页 | ✅ | 绑定/状态/备注 |
| 自研许可证管理 | ✅ | `/licenses` 额外页面 |
### M5 Callback (`/callbacks`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 事件收件箱 | ✅ | |
| 多维度筛选 | ✅ | 状态/事件类型/SN/项目/时间 |
| **批量重试按钮** | ✅ | 前端已实现,后端 `/batch-replay` 已新增 |
| **模拟投递弹窗** | ✅ | 已实现 |
| 详情页 | ✅ | payload 脱敏预览 |
| **失败原因下拉** | ✅ | 选择 FAILED 时弹出原因选择,后端已联通 |
| 状态处置 | ✅ | PENDING→PROCESSED/FAILED/IGNORED |
| 人工挂接 | ✅ | SN/项目/合同 |
| 死信积压 | ◐ | **后端端点已新增, 前端统计卡片需补充** |
### M6 集成配置 (`/integration/*`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 产品线 | ✅ | CRUD |
| 集成环境 | ✅ | CRUD |
| **ID 映射** | ✅ | 已实现 |
| **SKU 映射** | ✅ | 已实现 |
| **特征映射** | ✅ | 已实现 |
| **JSON 模板** | ✅ | CRUD |
### M7 设备管理 (`/devices`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 设备列表 | ✅ | MID/别名/站点/状态/心跳 |
| 设备登记弹窗 | ✅ | |
| 设备详情 | ✅ | 绑定历史 |
| 换机申请 | ◐ | 后端端点就绪,前端 UI 待补 |
### M8 待办中心 (`/todos`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 待办列表 | ✅ | 按类型/优先级/状态筛选 |
| 状态流转 | ✅ | |
| 通知配置 | ✅ | 通道/事件订阅 |
### M9 报表中心 (`/reports/*`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 合同 SN 报表 | ✅ | 含**导出 CSV 按钮** |
| Callback 统计 | ✅ | |
| 项目健康度 | ✅ | |
| 报表订阅 | ✅ | |
### M10 审计 (`/audit`)
| 功能 | 状态 | 备注 |
|------|------|------|
| 审计检索 | ✅ | |
| 留存策略 | ✅ | |
| 导出审计包 | ○ | 未实现(Full 版本) |
### M11 系统管理
| 页面 | 功能 | 状态 | 备注 |
|------|------|------|------|
| `/profile` | 个人设置/改密 | ✅ | |
| `/admin/params` | 系统参数 | ◐ | localStorage MVP |
| `/admin/users` | **用户管理** | ✅ | CRUD + 启用/禁用 |
| `/403` | 无权限 | ✅ | |
| `/*` | 404 | ✅ | |
---
## 4. 发现的问题清单
### 🔴 需要修复
| # | 问题 | 位置 | 建议 |
|---|------|------|------|
| 1 | 登录页演示提示仍显示 `dev/devDEVELOPER`DEVELOPER 已废弃 | `LoginView.vue:14` | 改为 `admin/admin123 / sales/sales / delivery/delivery / ops/ops` |
| 2 | 首页标题 `交付平台(I7` 过时 | `HomeView.vue` | 改为稳定名称,去掉迭代编号 |
| 3 | 登录页标题 `I1` 过时 | `LoginView.vue:4` | 去掉 `I1` |
| 4 | 无 `<title>` 标签 | `index.html` | 添加 `<title>创飞·交付管理平台</title>` |
| 5 | Callback 积压统计卡片未展示 | `CallbackInboxView.vue` | 列表上方增加统计条(pending/failed/最久未处理) |
### 🟡 建议优化
| # | 问题 | 位置 | 建议 |
|---|------|------|------|
| 6 | 侧栏菜单无分组 | `MainLayout.vue` | 增加「业务管理」「运营管理」「系统管理」分组标签 |
| 7 | `授权平台` vs `客户商务与交付管理平台` 名称不一致 | 全局 | 统一品牌名称 |
| 8 | 通知 badge 显示 `3` 为硬编码 | `MainLayout.vue:20` | 应从后端获取未读数 |
| 9 | `许可证管理` 标记 `NEW` 但已上线多时 | `MainLayout.vue:132` | 可去掉 NEW 标记 |
---
## 5. 页面功能完整度总表
| 页面 | 路由 | 完整度 | 备注 |
|------|------|--------|------|
| 登录 | `/login` | 95% | 演示提示需更新 |
| 首页 | `/` | 90% | 迭代编号过时 |
| 客户管理 | `/customers` | 95% | |
| 客户详情 | `/customers/:id` | 100% | 含聚合摘要 |
| 项目管理 | `/projects` | 100% | 含干系人 |
| 合同管理 | `/contracts` | 95% | |
| 合同向导 | `/contracts/new` | 100% | |
| 合同详情 | `/contracts/:id` | 100% | 含附件+变更 |
| 交付管理 | `/deliveries` | 95% | 含现场环境 |
| 交付向导 | `/deliveries/new` | 100% | |
| 交付详情 | `/deliveries/:id` | 100% | |
| 许可 SN | `/licenses/sn` | 100% | 含批量导入/操作 |
| SN 向导 | `/licenses/sn/new` | 100% | |
| SN 详情 | `/licenses/sn/:id` | 100% | |
| 许可证管理 | `/licenses` | 100% | 自研许可证 |
| Callback 收件箱 | `/callbacks` | 90% | 缺积压统计卡片 |
| Callback 详情 | `/callbacks/:id` | 100% | 含失败原因 |
| 集成环境 | `/integration/environments` | 100% | |
| 产品线 | `/integration/product-lines` | 100% | |
| ID 映射 | `/integration/id-mappings` | 100% | |
| SKU 映射 | `/integration/sku-mappings` | 100% | |
| 特征映射 | `/integration/feature-mappings` | 100% | |
| JSON 模板 | `/integration/json-templates` | 100% | |
| 设备管理 | `/devices` | 95% | 换机申请 UI 待补 |
| 设备详情 | `/devices/:id` | 100% | |
| 待办中心 | `/todos` | 100% | |
| 通知设置 | `/notifications/settings` | 100% | |
| 合同 SN 报表 | `/reports/contract-sn` | 100% | 含导出 |
| Callback 统计 | `/reports/callback-stats` | 100% | |
| 项目健康度 | `/reports/project-health` | 100% | |
| 报表订阅 | `/reports/subscriptions` | 100% | |
| 审计日志 | `/audit` | 100% | |
| 审计留存 | `/audit/retention` | 100% | |
| 系统参数 | `/admin/params` | 90% | localStorage 待迁移 |
| 用户管理 | `/admin/users` | 100% | |
| 个人设置 | `/profile` | 100% | |
| 403 | `/403` | 100% | |
| 404 | `/*` | 100% | |
---
## 6. 总结
- **总页面数**: 37 个页面/视图
- **100% 完成**: 30 页
- **90-95% 完成**: 6 页(需小修)
- **未实现**: 0 页
MVP/Mid 范围的前端 UI 已基本实现完毕。剩余工作集中在 Full 版本(积压监控卡片、换机审批流、审计导出包等)。
@@ -0,0 +1,217 @@
# Full 版本 (V2.0) 实现缺失与不足复盘
**日期:** 2026-05-27
**来源:** `docs/chuangfei-platform-product-modules.md` §13.5, §14, §16.7
---
## 1. Full 版本范围总览
Full 版本 = Mid + 所有 P2 功能点 + 以下专项能力:
| 分类 | 项目 | 涉及模块 | 当前状态 |
|------|------|---------|---------|
| **安全** | MFA 双因素认证 | M11-F10 | ○ 未开始 |
| **安全** | SECURITY_ADMIN 角色 | §13.2 | ○ 未开始 |
| **数据** | 事业部数据范围 (Data Scope) | M11-F17 | ○ 未开始 |
| **审计** | 审计导出包 | M10-F03 | ○ 未开始 |
| **集成** | CRM 同步 | M1-F09 | ○ 未开始 |
| **治理** | 细粒度互斥策略 | §13.5 | ○ 未开始 |
| **基础设施** | 消息队列 (MQ) | Webhook→API | ○ 未开始 |
| **基础设施** | 读模型分离 (CQRS) | 报表/查询 | ○ 未开始 |
| **P2 功能** | 13 个 P2 功能点(见 §2) | 多模块 | ◐ 部分上前端 |
---
## 2. P2 功能点逐项
| ID | 功能点 | 所属模块 | 当前状态 | Full 要求 |
|----|--------|---------|---------|----------|
| M1-F08 | 客户合并与去重 | M1 | ○ 未开始 | 疑似重复客户识别、合并流程与审计 |
| M1-F09 | CRM 主数据同步 | M1 | ○ 未开始 | 以外部 ID 关联、增量同步 |
| M2-F09 | 合同到期与续费提醒 | M2 | ○ 未开始 | 列表与订阅(与 M8 联动) |
| M3-F08 | 交付模板 | M3 | ○ 未开始 | 按产品线预置交付清单模板 |
| M4-F11 | 授权策略生效视图 | M4 | ○ 未开始 | 展示当前映射版本、环境(与 M6 联动) |
| M5-F10 | 模拟投递 | M5 | ◐ 后端就绪, 前端 UI 待补 | 联调验收工具 |
| M6-F08 | SDK/native 版本矩阵 | M6 | ○ 未开始 | 与现场客户端兼容范围说明 |
| M6-F09 | 变更影响分析 | M6 | ○ 未开始 | 映射变更影响哪些在服 SN/合同 |
| M7-F06 | 终端数/并发策略展示 | M7 | ○ 未开始 | 只读展示合同或比特策略摘要 |
| M8-F04 | 通知模板 | M8 | ○ 未开始 | 事件类型 → 模板变量 |
| M8-F05 | 静默规则 | M8 | ○ 未开始 | 重复事件聚合、防骚扰 |
| M9-F05 | 项目健康度看板 | M9 | ◐ 前端已上线 | 红黄绿规则可配置性待确认 |
| M9-F06 | 订阅报表 | M9 | ◐ 前端已上线 | 后端推送逻辑待确认 |
---
## 3. Full 版本专项能力详述
### 3.1 M11-F10 双因素认证 MFA
**PRD 要求:**
```
TOTP/短信/企业令牌等一种;可配置为全员或高敏角色必选
```
**当前缺口:**
- 无 TOTP 生成/验证逻辑
- 无短信网关集成
- 无 MFA 绑定/解绑 UI
- 无角色级 MFA 强制策略
**实现思路:** TOTP (Time-based One-Time Password) 方案,使用 `java.security` 或 Google Authenticator 兼容库,前端显示二维码 + 验证码输入。
**预估工作量:** 中(3-5 天,含 Flyway 迁移 + 后端 + 前端 + 扫码绑定流程)
---
### 3.2 SECURITY_ADMIN 角色
**PRD 要求 (§13.2):**
```
锁定策略、强制下线、审计检索;与 SYS_ADMIN 分离(职责分离)
权限矩阵: M11 中 F05F12、F21 及 M10 审计检索 RX
```
**当前缺口:**
- 角色代码未定义
- 路由/权限码未配置
- SECURITY_ADMIN 的专属 UI(会话管理页、锁定策略配置)
**预估工作量:** 小(1-2 天,角色定义 + 权限码 + 路由 + 会话管理页)
---
### 3.3 M11-F17 事业部数据范围 (Data Scope)
**PRD 要求:**
```
按事业部/区域/客户组限制列表可见行(与 M11-F18 二选一或组合)
```
**当前缺口:**
- 无数据范围模型(部门/区域/客户组表)
- 无 MyBatis-Plus 数据权限拦截器
- 前端无数据范围选择器
**实现思路:** MyBatis-Plus 的 `Interceptor``@SqlParser` 注解,在查询时自动追加数据范围条件。需要先定义组织/区域/客户组的基础数据模型。
**预估工作量:** 大(5-8 天,含数据模型 + 拦截器 + 配置 UI + 现有查询适配)
---
### 3.4 M10-F03 审计导出包
**PRD 要求:**
```
范围可选(项目/合同/时间窗),水印与权限
```
**当前缺口:**
- 后端 `GET /audit-events/export` 端点已存在(CSV 导出)
- 前端导出按钮未接入
- 无水印/权限控制
**预估工作量:** 小(0.5-1 天,前端按钮 + 参数传递)
---
### 3.5 M1-F09 CRM 主数据同步
**PRD 要求:**
```
以外部 ID 关联、增量同步状态展示
```
**当前缺口:**
- 完全未实现
- 无外部 ID 字段(已在 Customer entity 中有 `customerCode` 但非 CRM ID
- 无同步状态跟踪
**预估工作量:** 中(3-5 天,含外部 ID 模型 + 同步端点 + UI 状态展示)
---
### 3.6 细粒度互斥策略 (§13.5)
**PRD 要求:**
```
角色互斥规则(如 SYS_ADMIN 与业务高敏导出)
```
**当前缺口:**
- 完全未实现
- 当前仅简单串联角色权限
**预估工作量:** 中(2-3 天,互斥规则定义 + 后端校验 + 前端提示)
---
### 3.7 消息队列 (MQ) 架构
**架构要求:**
```
当前: Webhook → 直写 PostgreSQL → API 轮询
Full: Webhook → MQ → API 消费(削峰、DLQ、可观测)
```
**当前状态:**
- 无 MQ 基础设施(RabbitMQ/RocketMQ/Kafka
- Webhook 直写 inbox 表,API 轮询读取
- 已在 PRD 已知局限中标注(§16.6)
**预估工作量:** 大(5-10 天,含 MQ 选型 + 生产者/消费者 + 现有路径兼容 + DLQ)
---
### 3.8 读模型分离 (CQRS)
**架构要求:**
```
报表/查询从主库分离为独立读模型
```
**当前状态:**
- 全部查询直连主 PostgreSQL
- 无读模型/物化视图
**预估工作量:** 大(5-8 天,含读模型表 + 同步机制 + 现有查询迁移)
---
## 4. 总体评估
### 实现状态
| 分类 | 总项数 | ✅ 已完成 | ◐ 部分实现 | ○ 未开始 |
|------|--------|----------|-----------|---------|
| P2 功能点 | 13 | 0 | 3 (M5-F10, M9-F05, M9-F06) | 10 |
| Full 专项 | 8 | 0 | 0 | 8 |
| **合计** | **21** | **0 (0%)** | **3 (14%)** | **18 (86%)** |
### 工作量估算
| 项目 | 预估人天 | 依赖 |
|------|---------|------|
| M10-F03 审计导出按钮 | 0.5 | 无 |
| M5-F10 模拟投递 UI | 0.5 | 无 |
| SECURITY_ADMIN 角色 | 1 | 无 |
| M9-F05/F06 报表增强 | 1 | 无 |
| M2-F09 到期提醒 | 1 | M8 通知 |
| M6-F08/F09 版本/变更 | 2 | 无 |
| M11-F10 MFA | 4 | 无 |
| M1-F08 客户合并 | 3 | 无 |
| M1-F09 CRM 同步 | 4 | 无 |
| M11-F17 数据范围 | 6 | 组织模型 |
| MQ 消息队列 | 8 | 基础设施选型 |
| CQRS 读模型 | 7 | MQ 完成后 |
| **合计** | **~38 人天** | |
### 建议实施顺序
| 阶段 | 项目 | 人天 | 原因 |
|------|------|------|------|
| **Phase 1** | M10-F03 导出, M5-F10 模拟UI, SECURITY_ADMIN | 2 | 快速 wins,无依赖 |
| **Phase 2** | M9 报表推送, M6-F08/F09 版本矩阵 | 3 | 增强已有功能 |
| **Phase 3** | M11-F10 MFA, M11-F17 数据范围 | 10 | 安全核心能力 |
| **Phase 4** | M1-F08/F09 客户合并+CRM | 7 | 集成能力 |
| **Phase 5** | MQ + CQRS | 15 | 基础设施重构,依赖最重 |
@@ -0,0 +1,86 @@
# ONLYOFFICE 集成状态复盘
**日期:** 2026-05-27
**决策文档:** `docs/superpowers/specs/2026-05-25-onlyoffice-integration-decision.md`
---
## 1. 当前状态:规划阶段,零代码实现
| 维度 | 状态 |
|------|------|
| 设计决策 | ✅ 已完成(2026-05-25 |
| 后端实现 | ○ 未开始 |
| 前端实现 | ○ 未开始 |
| 文档代理层 | ○ 未开始 |
| ONLYOFFICE 服务部署 | 存在 `craftsupport.cn:8088`(已知可用) |
---
## 2. 决策回顾
| 决策项 | 结论 |
|--------|------|
| 是否集成 | ✅ 是,Mid 迭代完成后推进 |
| 集成范围 | **仅预览**,不做在线编辑 |
| 存储策略 | 附件本地存储,不接入 ONLYOFFICE 文档存储 |
| 优先级 | 非阻塞,排在 Mid (I10~I13) 之后 |
---
## 3. 实施前置条件检查
### 已完成(可直接利用)
| 条件 | 说明 |
|------|------|
| 合同附件上传 | **M2-F05 已实现** — 后端 `POST /contracts/{id}/attachments` + 前端附件列表 |
| 附件文件存储 | 文件存储在本地 `uploads/contracts/{id}/` |
| ONLYOFFICE 服务 | `craftsupport.cn:8088` 已知可用 |
### 未开始
| 组件 | 计划方案 | 预估 |
|------|---------|------|
| `DocumentPreviewController` | 平台新增代理端点:接收文件 ID → 返回 ONLYOFFICE 所需的配置 JSON + JWT | 1 天 |
| 前端预览弹窗 | 附件列表行操作加「预览」按钮,点击弹窗内嵌 ONLYOFFICE iframe | 0.5 天 |
| JWT 密钥配置 | `ONLYOFFICE_JWT_SECRET` 环境变量 | 0.1 天 |
| 文件流式输出 | ONLYOFFICE 通过平台 URL 获取文件内容 | 0.5 天 |
**合计预估:** 2 天
---
## 4. 阻碍因素
| 因素 | 说明 |
|------|------|
| **优先级排期** | 决策文档明确标注「Mid 迭代完成后推进」,当前 Tier 1+2 尚未全部完成 |
| **Mid 迭代未完成** | 当前工作仍在补齐 Tier 1 核心功能和 Tier 2 运营效率功能 |
| **ONLYOFFICE 服务可用性** | 需验证 `craftsupport.cn:8088` 当前是否可连接及 JWT 配置 |
---
## 5. 实施路线
```
Phase 1: 后端 DocumentPreviewController
GET /api/v1/preview/{attachmentId}
→ 返回 ONLYOFFICE 配置 JSON ({fileType, key, title, url, permissions: {download:false,edit:false}})
Phase 2: 前端预览弹窗
ContractDetailView.vue 附件列表 → 每行加「预览」按钮
→ 弹窗 iframe src = ONLYOFFICE 文档服务 URL + config
Phase 3: 文件流式服务
GET /api/v1/preview/{attachmentId}/file
→ 流式输出附件文件内容
```
---
## 6. 建议
1. **保持当前优先级** — Tier 1+2 业务功能完成后(预计 ~2 周)再启动 ONLYOFFICE
2. **验证 ONLYOFFICE 服务** — 启动前确认 `craftsupport.cn:8088` 可用并用 CORS 白名单允许平台域名
3. **最小实现** — 仅做 iframe 嵌入预览,不做编辑、不做保存回传,保持实现量最小
@@ -0,0 +1,211 @@
# PRD 实现进度复盘
**日期:** 2026-05-27
**PRD:** `docs/chuangfei-platform-product-modules.md`
---
## 1. 整体进度
| 状态 | 数量 | 占比 |
|------|------|------|
| ✅ 完全实现 | 24 | 32% |
| ◐ 部分实现 | 23 | 32% |
| ○ 未实现 | 26 | 36% |
| **合计** | **73** | **100%** |
```
M1 客户与项目中心 ██░░░░░░░░ 20% ✅ 2 ◐ 4 ○ 3
M2 合同与履约行 ████░░░░░░ 40% ✅ 4 ◐ 2 ○ 3
M3 交付管理 ██████░░░░ 60% ✅ 5 ◐ 0 ○ 3
M4 授权与许可运营 ██░░░░░░░░ 20% ✅ 3 ◐ 3 ○ 4
M5 Callback 运营 █████░░░░░ 50% ✅ 5 ◐ 2 ○ 2
M6 授权集成与配置 ████░░░░░░ 40% ✅ 4 ◐ 1 ○ 4
M7 设备与终端 █░░░░░░░░░ 10% ✅ 1 ◐ 3 ○ 2
M8 通知与待办 ░░░░░░░░░░ 0% ✅ 0 ◐ 3 ○ 2
M9 报表与对账 ░░░░░░░░░░ 0% ✅ 0 ◐ 5 ○ 1
M10 审计与合规 ██░░░░░░░░ 20% ✅ 1 ◐ 2 ○ 1
M11 身份/访问/平台 ███░░░░░░░ 30% ✅ 7 ◐ 3 ○11
```
---
## 2. 版本覆盖:MVP / Mid / Full
### MVP(I1~I9)— 标记为已完成 ✅
| 模块 | PRD 承诺 | 实际状态 |
|------|---------|---------|
| M1 客户项目 | P0 核心: 档案/列表/检索 | ✅ 完成, 缺详情聚合视图 |
| M2 合同 | 登记/状态机/行项 | ✅ 完成 |
| M3 交付 | 批次/清单/状态 | ✅ 完成 |
| M4 SN | 手工录入/绑定/状态 | ✅ 手工完成, 批量导入 UI 待补 |
| M5 Callback | 收件箱/详情/处置 | ✅ 完成 |
| M6 集成 | 产品线/环境只读 | ✅ 已超出(ID映射/JSON模板已实现) |
| M10 审计 | 关键字段变更日志 | ✅ 完成 |
| M11 身份 | JWT 登录/路由守卫/三角色/字典 | ◐ 路由级 RBAC ✅, 按钮级权限码未全覆盖 |
**MVP 已覆盖 P0 主链路:客户→项目→合同→交付→SN→Callback→审计**
### MidI10I13)— 进行中 🕐
| PRD 承诺 | 当前实现状态 |
|---------|-------------|
| M7 设备管理 | ◐ 设备登记/列表/详情已上线,换机审批/设备事件联动待补 |
| M8 通知待办 | ◐ 待办中心+通知配置已上线,实际发送逻辑未接入 |
| M9 报表对账 | ◐ 4 个报表页面已上线,导出按钮/推送逻辑待补 |
| 补齐 MVP 遗留 P0 | ◐ M1-F03 详情摘要后端已修复, M11-F03 空闲超时前端已实现 |
| M2/M4/M5/M6 P1 增强 | ○ 部分未开始 |
| M10-F02 审计检索 | ◐ AuditSearchView 已上线,筛选维度待确认 |
| M11 SSO/并发/强制下线 | ○ 未开始 |
| 角色模型对标产品定义集 | ◐ 4 角色已落地(ADMIN/SALES/DELIVERY/LICENSE_OPS), 仍有 6+ 角色未实现 |
### FullV2.0)— 规划中 📋
全部未开始:MFA、SECURITY_ADMIN、数据范围、审计导出包、CRM 同步。
---
## 3. 按模块的缺失功能点清单
### M1 客户与项目中心 — ✅2 ◐4 ○3
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F01 客户档案 | P0 | ◐ | 缺行业/地址/开票信息字段 |
| F03 详情聚合 | P0 | ◐ | 后端 `/summary` 已修复, 前端已展示 |
| F04 项目创建 | P0 | ◐ | 缺计划起止/项目经理字段 |
| F06 项目干系人 | P0 | ◐ | 后端 CRUD 就绪, 前端 UI 待补 |
| F07 冻结解冻 | P1 | ◐ | 后端就绪, 前端 UI 待补 |
| F08 客户合并 | P2 | ○ | 未开始 |
| F09 CRM 同步 | P2 | ○ | 未开始 |
### M2 合同与履约行 — ✅4 ◐2 ○3
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F05 合同附件 | P1 | ◐ | 后端上传就绪, 前端 UI 有待(已校验) |
| F07 合同变更 | P1 | ◐ | 后端 changes 就绪, 前端 UI 待补 |
| F08 SKU 映射 | P1 | ○ | 未实现 |
| F09 到期提醒 | P2 | ○ | 未实现 |
### M3 交付管理 — ✅5 ◐0 ○3
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F06 现场环境 | P1 | ○ | 未实现 |
| F07 SN 门禁 | P1 | ○ | `deliveryGateEnabled` 参数已定义但未执行 |
| F08 交付模板 | P2 | ○ | 未开始 |
### M4 授权与许可运营 — ✅3 ◐3 ○4
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F01 批量导入 | P0 | ◐ | 后端 `/batch-import` 就绪, 前端缺 UI |
| F05 原因码分类 | P0 | ◐ | 手工状态更新缺原因码 |
| F07 批量操作 | P1 | ◐ | 后端就绪, 前端缺批量 UI |
| F06 比特状态 | P1 | ○ | 依赖比特对接 |
| F08 授权需求单 | P1 | ○ | 未开始 |
| F09 试用/正式标签 | P1 | ○ | 未开始 |
| F11 策略视图 | P2 | ○ | 未开始 |
### M5 Callback 运营 — ✅5 ◐2 ○2
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F06 失败标注 | P1 | ○ | 未实现 |
| F07 批量重处理 | P1 | ◐ | 单条重放 ✅, 批量未做 |
| F08 死信监控 | P1 | ○ | 未实现 |
| F10 模拟投递 UI | P2 | ◐ | 后端 `/simulate` 就绪, 前端缺入口 |
### M6 授权集成与配置 — ✅4 ◐1 ○4
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F05 JSON 模板 | P1 | ◐ | CRUD ✅, Schema 校验未关联 UI |
| F06 发布记录 | P1 | ○ | 未实现 |
| F07 控制台链接 | P1 | ○ | 未实现 |
| F08 版本矩阵 | P2 | ○ | 未开始 |
| F09 变更分析 | P2 | ○ | 未开始 |
### M7 设备与终端 — ✅1 ◐3 ○2
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F01 设备登记 | P1 | ◐ | 登记/列表 ✅, 字段覆盖待确认 |
| F02 SN 绑定历史 | P1 | ◐ | 时间线已实现 |
| F03 换机审批 | P1 | ◐ | swap-request 端点就绪, 审批流未实现 |
| F05 Callback 联动 | P1 | ○ | 未实现 |
| F06 并发策略 | P2 | ○ | 未开始 |
### M8 通知与待办 — ✅0 ◐3 ○2
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F01 待办列表 | P1 | ◐ | 待办中心 ✅, 自动化待办生成未接入 |
| F02 认领完成 | P1 | ◐ | 状态流转 ✅, 备注功能待补 |
| F03 邮件/企微 | P1 | ◐ | 配置 UI ✅, 实际发送未接入 |
| F04 通知模板 | P2 | ○ | 未实现 |
| F05 静默规则 | P2 | ○ | 未开始 |
### M9 报表与对账 — ✅0 ◐5 ○1
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F01 合同 SN 视图 | P1 | ◐ | 已上线, 数据维度待确认 |
| F02 激活视图 | P1 | ○ | 未专门实现 |
| F03 Callback 统计 | P1 | ◐ | 已上线 |
| F04 导出按钮 | P1 | ◐ | 后端 `/export` 就绪, 前端缺按钮 |
| F05 项目健康度 | P2 | ◐ | 已上线, 规则可配置性待确认 |
| F06 订阅报表 | P2 | ◐ | 已上线, 推送逻辑待确认 |
### M10 审计与合规 — ✅1 ◐2 ○1
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F02 审计检索 | P1 | ◐ | AuditSearchView ✅, 端点有 500 错误需调试 |
| F03 审计导出 | P2 | ○ | 未实现 |
| F04 留存策略 | P2 | ◐ | AuditRetentionView ✅ |
### M11 身份/访问/平台 — ✅7 ◐3 ○11
| 功能点 | 优先级 | 当前状态 | 缺失内容 |
|--------|--------|---------|---------|
| F03 空闲超时 | P0 | ◐ | 前端 idleTimer 已实现, 后端会话管理未完成 |
| F05 失败锁定 | P0 | ✅ | 后端已有 5 次/15 分钟锁定 |
| F07 密码修改 | P0 | ✅ | Profile 页弹窗+后端端点 |
| F08 密码重置 | P1 | ✅ | 后端端点已实现(非空操作) |
| F09 SSO/OIDC | P1 | ○ | 未开始 |
| F11 并发会话 | P1 | ○ | 未开始(JWT 无状态) |
| F12 强制下线 | P1 | ◐ | 端点已实现, 需前端 UI |
| F14 用户管理 | P0 | ◐ | 表已创建, 管理页面未实现 |
| F15 角色定义 | P0 | ◐ | 4 角色已落地, 产品定义 10+ 需补齐 |
| F16 权限码 | P0 | ◐ | 路由级 ✅, 按钮级 `v-permission` 未全覆盖 |
| F17 数据范围 | P2 | ○ | 未开始 |
| F20 系统参数 | P1 | ◐ | SystemParamsView ✅, localStorage MVP |
| F21 敏感操作留痕 | P1 | ○ | 未开始 |
---
## 4. 已知 API 500 错误(需修复)
| 端点 | 影响模块 | 问题 |
|------|---------|------|
| `GET /api/v1/audit-events` | M10-F02 | 500 内部错误 |
| `GET /api/v1/integration/id-mappings` | M6-F03 | 500 内部错误 |
| `GET /api/v1/integration/feature-mappings` | M6-F04 | 500 内部错误 |
---
## 5. 总结
| 版本 | PRD 范围 | 实现状态 |
|------|---------|---------|
| **MVP** (I1~I9) | M1-M6 P0 + M10-F01 + M11 基础 | ✅ 主链路已完成 |
| **Mid** (I10~I13) | M7-M9 + P0补齐 + P1增强 | 🕐 M7/M8/M9 前端超前上线, 后端逻辑待补 |
| **Full** (V2.0) | 安全/合规/规模化 | 📋 全部未开始 |
**当前最优先缺口:**
1. 修复 3 个 500 错误 (audit-events, id-mappings, feature-mappings)
2. M11-F14 用户管理页面 (表已就绪)
3. M8-F03 通知发送实际对接
@@ -0,0 +1,176 @@
# UI 交互模式复盘 — 导航/弹框一致性 + 侧栏分组
**日期:** 2026-05-27
**范围:** 全部 38 个 Vue 视图
---
## 1. 当前 CRUD 模式盘点
| 页面 | Create | Edit | Detail | 评估 |
|------|--------|------|--------|------|
| **客户管理** | 弹框 `el-dialog` | 弹框 | 独立页面 | ✅ 一致 |
| **项目管理** | 弹框 `el-dialog` | 弹框 | 无独立详情(干系人管理在弹框) | ✅ 一致 |
| **合同管理** | 向导页 `/contracts/new` | 详情页内编辑 | 独立详情页 | ✅ 合理(多步) |
| **交付管理** | 向导页 `/deliveries/new` | 详情页内编辑 | 独立详情页 | ✅ 合理(多步) |
| **许可 SN** | 向导页 `/licenses/sn/new` | 详情页内编辑 | 独立详情页 | ✅ 合理 |
| **Callback** | 无(来源Webhook) | 详情页内操作 | 独立详情页 | ✅ |
| **设备管理** | 弹框 | 详情页内编辑 | 独立详情页 | ✅ 一致 |
| **用户管理** | 弹框 | 弹框 | 无独立详情 | ✅ 一致 |
### 结论:当前模式基本合理
| 场景 | 推荐模式 | 示例 |
|------|---------|------|
| **简单表单 (≤5 字段)** | **弹框** `el-dialog` | 客户、项目、设备、用户 |
| **复杂表单/向导 (≥2 步)** | **独立页面** | 合同向导、交付向导、SN 向导 |
| **详情展示** | **独立页面** | 合同详情、交付详情、SN 详情、设备详情 |
| **关联数据管理** | **弹框** | 项目干系人、SN 批量导入、合同明细行 |
**不建议强行统一为弹框** — 合同向导有 3 步(选择客户项目→填写信息→明细行),用弹框会撑爆视口。
---
## 2. 发现的不一致问题
| # | 问题 | 当前行为 | 建议 |
|---|------|---------|------|
| 1 | **合同列表行操作** | 「详情」跳转详情页,无直接「编辑」入口 | 详情页支持编辑即可(已有) |
| 2 | **交付列表行操作** | 同上 | 同上 |
| 3 | **SN 列表行操作** | 同上 | 同上 |
| 4 | **客户列表行操作** | 「详情」「编辑」「冻结」「删除」都在列表页 | ✅ 正确 |
| 5 | **设备列表无编辑** | 只有「详情」→详情页编辑 | 详情页已支持编辑,可接受 |
| 6 | **项目无独立详情页** | 编辑、干系人都在列表弹框 | 对于简单实体,弹框够用 ✅ |
---
## 3. 侧栏分组实现方案
### 当前结构(扁平)
```
📊 首页
👥 客户管理
📋 合同管理
📦 交付管理
🔑 许可 SN
🛡️ 许可证管理
📨 Callback 收件箱
🌐 集成环境
📱 产品线
🖥️ 设备管理
🔔 待办中心
📊 报表中心
📧 报表订阅
👤 用户管理
```
### 目标结构(三级分组)
```
📊 首页
──── 业务管理 ────
👥 客户管理
📋 合同管理
📦 交付管理
🔑 许可 SN
🛡️ 许可证管理
──── 运营管理 ────
📨 Callback 收件箱
🌐 集成环境
📱 产品线
🖥️ 设备管理
🔔 待办中心
──── 分析管理 ────
📊 报表中心
📧 报表订阅
──── 系统管理 ────
⚙️ 系统参数
👤 用户管理
🔐 审计日志
```
### 实现方式
**方案:** 修改 `MainLayout.vue` 中的 `menuItems` 数组为分组结构,模板中循环渲染组。
#### Step 1: 改造 menuItems 数据结构
```javascript
const menuGroups = [
{
label: '业务管理',
items: [
{ path: "/customers", icon: "👥", label: "客户管理", roles: ["SYS_ADMIN","SALES"] },
{ path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","SALES"] },
{ path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] },
{ path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","SALES"] },
{ path: "/licenses", icon: "🛡️", label: "许可证管理", roles: ["SYS_ADMIN","SALES"] },
]
},
{
label: '运营管理',
items: [
{ path: "/callbacks", icon: "📨", label: "Callback 收件箱", roles: ["SYS_ADMIN","LICENSE_OPS"] },
{ path: "/integration/environments", icon: "🌐", label: "集成环境", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
{ path: "/integration/product-lines", icon: "📱", label: "产品线", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
{ path: "/devices", icon: "🖥️", label: "设备管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] },
{ path: "/todos", icon: "🔔", label: "待办中心", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
]
},
{
label: '分析管理',
items: [
{ path: "/reports/contract-sn", icon: "📊", label: "报表中心", roles: ["SYS_ADMIN"] },
{ path: "/reports/subscriptions", icon: "📧", label: "报表订阅", roles: ["SYS_ADMIN"] },
]
},
{
label: '系统管理',
items: [
{ path: "/audit", icon: "🔐", label: "审计日志", roles: ["SYS_ADMIN"] },
{ path: "/admin/params", icon: "⚙️", label: "系统参数", roles: ["SYS_ADMIN"] },
{ path: "/admin/users", icon: "👤", label: "用户管理", roles: ["SYS_ADMIN"] },
]
},
];
```
#### Step 2: 改造模板渲染
```html
<aside class="app-sidebar">
<div class="sidebar-section-label">📊 首页</div>
<div class="sidebar-item" :class="{ active: isActive(homeItem) }" @click="$router.push(homeItem.path)">
<span class="sidebar-item-icon">{{ homeItem.icon }}</span>
<span class="sidebar-item-text">{{ homeItem.label }}</span>
</div>
<template v-for="group in visibleGroups" :key="group.label">
<div class="sidebar-group-label">{{ group.label }}</div>
<div
v-for="item in group.items"
:key="item.path"
:class="['sidebar-item', { active: isActive(item) }]"
@click="$router.push(item.path)"
>
<span class="sidebar-item-icon">{{ item.icon }}</span>
<span class="sidebar-item-text">{{ item.label }}</span>
</div>
</template>
<div class="sidebar-footer">CraftLabs Platform v0.1.0</div>
</aside>
```
#### Step 3: CSS 调整
```css
.sidebar-group-label {
font-size: 11px;
color: #999;
padding: 12px 16px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
```
+5 -3
View File
@@ -4,11 +4,13 @@
"scenario": "school",
"selfhosted": {
"baseUrl": "https://license.internal.example/api/v1",
"tenantKey": "district-west"
"tenantKey": "district-west",
"offlineGraceDays": 7,
"heartbeatIntervalHours": 24
},
"features": {
"face": {},
"expression": {}
"face": { "selfhostedFeatureKey": "face" },
"expression": { "selfhostedFeatureKey": "expression" }
},
"school": {
"edgeDeviceId": "gate-02"
+39
View File
@@ -0,0 +1,39 @@
# JAVA SDK
**Part of:** craftlabs-authorization-sdk
**Build:** Maven multi-module (JDK 17+)
## STRUCTURE
```
java/
├── craftlabs-auth-core/ # Core auth: config parsing, internal logic
├── craftlabs-auth-bitanswer/ # Bitanswer cloud licensing provider
├── craftlabs-auth-selfhosted/ # Self-hosted licensing provider
├── craftlabs-auth-tests/ # Integration tests
├── pom.xml # Parent aggregator POM
└── RELEASING.md # Release checklist & GPG signing guide
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Config model / schema | `craftlabs-auth-core/src/main/java/cn/craftlabs/auth/config/` | 9 files, config POJOs |
| Internal engine | `craftlabs-auth-core/src/main/java/cn/craftlabs/auth/internal/` | 3 files, core logic |
| Bitanswer provider | `craftlabs-auth-bitanswer/` | Single class, wraps bitanswer API |
| Selfhosted provider | `craftlabs-auth-selfhosted/` | Single class, local validation |
| Tests | `craftlabs-auth-tests/` | Config test cases |
| Release scripts | `scripts/sdk-release-checksums.sh` | SHA256 + GPG |
## CONVENTIONS
- **Package**: `cn.craftlabs.auth.*` (SDK) — distinct from `cn.craftlabs.platform.*` (backend)
- **No Spring Boot**: SDK modules are plain Java, no framework dependency
- **JNA bridge**: Java calls Rust native via JNA (not JNI)
- **Maven**: Parent POM at `java/pom.xml` aggregates sub-modules
## ANTI-PATTERNS
- **Do not** add Spring Boot / platform dependencies to SDK modules (keep plain Java)
- **Do not** put bitanswer/selfhosted specific logic into core module
@@ -18,7 +18,7 @@ import cn.craftlabs.auth.internal.NativeBridge;
*/
public final class BitAnswerProvider implements AuthProvider {
static {
System.loadLibrary("craftlabs_auth_bitanswer");
System.loadLibrary("craftlabs_auth_core");
}
private long nativeHandle;
+4
View File
@@ -19,6 +19,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -11,4 +11,5 @@ import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FeatureMapping(
@JsonProperty("bitanswerFeatureId") Integer bitanswerFeatureId,
@JsonProperty("bitanswerFeatureName") String bitanswerFeatureName) {}
@JsonProperty("bitanswerFeatureName") String bitanswerFeatureName,
@JsonProperty("selfhostedFeatureKey") String selfhostedFeatureKey) {}
@@ -11,4 +11,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SelfhostedConfigSection(
@JsonProperty("baseUrl") String baseUrl,
@JsonProperty("tenantKey") String tenantKey) {}
@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;
}
}
@@ -0,0 +1,53 @@
package cn.craftlabs.auth.internal;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import java.util.Arrays;
import java.util.List;
public interface CraftCoreLibrary extends Library {
CraftCoreLibrary INSTANCE = Native.load("craftlabs_auth_core", CraftCoreLibrary.class);
class CraftResult extends Structure {
public int success;
public String message;
@Override
protected List<String> getFieldOrder() {
return Arrays.asList("success", "message");
}
}
class LicenseInfoStruct extends Structure {
public int isLicensed;
public long expirationDate;
public Pointer featureNames;
public Pointer featureValues;
public int featureCount;
@Override
protected List<String> getFieldOrder() {
return Arrays.asList("isLicensed", "expirationDate", "featureNames", "featureValues", "featureCount");
}
}
Pointer craft_initialize(String configJson);
void craft_destroy(Pointer handle);
CraftResult craft_activate(Pointer handle, String licenseKey);
CraftResult craft_check_license(Pointer handle);
LicenseInfoStruct craft_get_license_info(Pointer handle);
void craft_free_license_info(LicenseInfoStruct info);
int craft_has_feature(Pointer handle, String featureName);
CraftResult craft_release(Pointer handle);
CraftResult craft_heartbeat(Pointer handle);
}
@@ -0,0 +1,89 @@
package cn.craftlabs.auth.internal;
import cn.craftlabs.auth.AuthProvider;
import cn.craftlabs.auth.AuthResult;
import cn.craftlabs.auth.LicenseInfo;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JnaAuthProvider implements AuthProvider {
private Pointer nativeHandle;
@Override
public AuthResult initialize(String configJson) {
if (nativeHandle != null) {
CraftCoreLibrary.INSTANCE.craft_destroy(nativeHandle);
}
nativeHandle = CraftCoreLibrary.INSTANCE.craft_initialize(
configJson != null ? configJson : "{}");
return new AuthResult(nativeHandle != null, "Initialized");
}
@Override
public AuthResult activate(String licenseKey) {
CraftCoreLibrary.CraftResult r = CraftCoreLibrary.INSTANCE.craft_activate(
nativeHandle, licenseKey);
return new AuthResult(r.success != 0, r.message);
}
@Override
public AuthResult checkLicense() {
CraftCoreLibrary.CraftResult r = CraftCoreLibrary.INSTANCE.craft_check_license(nativeHandle);
return new AuthResult(r.success != 0, r.message);
}
@Override
public LicenseInfo getLicenseInfo() {
CraftCoreLibrary.LicenseInfoStruct s = CraftCoreLibrary.INSTANCE.craft_get_license_info(nativeHandle);
if (s == null) {
return new LicenseInfo(false, null, null);
}
Map<String, Boolean> features = null;
if (s.featureCount > 0 && s.featureNames != null) {
features = new HashMap<>(s.featureCount);
for (int i = 0; i < s.featureCount; i++) {
Pointer namePtr = s.featureNames.getPointer((long) i * Native.POINTER_SIZE);
String name = namePtr != null ? namePtr.getString(0) : null;
if (name != null) {
int val = s.featureValues != null
? s.featureValues.getInt((long) i * Integer.BYTES)
: 0;
features.put(name, val != 0);
}
}
}
Date expirationDate = s.expirationDate > 0 ? new Date(s.expirationDate) : null;
CraftCoreLibrary.INSTANCE.craft_free_license_info(s);
return new LicenseInfo(s.isLicensed != 0, expirationDate, features);
}
@Override
public boolean hasFeature(String featureName) {
return CraftCoreLibrary.INSTANCE.craft_has_feature(nativeHandle, featureName) != 0;
}
@Override
public AuthResult release() {
CraftCoreLibrary.CraftResult r = CraftCoreLibrary.INSTANCE.craft_release(nativeHandle);
return new AuthResult(r.success != 0, r.message);
}
@Override
public AuthResult heartbeat() {
CraftCoreLibrary.CraftResult r = CraftCoreLibrary.INSTANCE.craft_heartbeat(nativeHandle);
return new AuthResult(r.success != 0, r.message);
}
@Override
public void close() {
if (nativeHandle != null) {
CraftCoreLibrary.INSTANCE.craft_destroy(nativeHandle);
nativeHandle = null;
}
}
}
@@ -17,7 +17,7 @@ import cn.craftlabs.auth.internal.NativeBridge;
*/
public final class SelfHostedAuthProvider implements AuthProvider {
static {
System.loadLibrary("craftlabs_auth_bitanswer");
System.loadLibrary("craftlabs_auth_core");
}
private long nativeHandle;
+5
View File
@@ -51,6 +51,11 @@
<version>${json-schema-validator.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.14.0</version>
</dependency>
</dependencies>
</dependencyManagement>
@@ -39,14 +39,14 @@ void ensure_adapters_registered_once() {
extern "C" {
CRAFTLABS_API AuthHandle auth_initialize(const char* /* config_json */) {
CRAFTLABS_API AuthHandle craft_initialize(const char* /* config_json */) {
ensure_adapters_registered_once();
auto* ctx = new AuthContext{};
ctx->dummy = 1;
return reinterpret_cast<AuthHandle>(ctx);
}
CRAFTLABS_API AuthResult auth_activate(AuthHandle handle, const char* /* license_key */) {
CRAFTLABS_API AuthResult craft_activate(AuthHandle handle, const char* /* license_key */) {
if (!handle) {
return k_fail;
}
@@ -54,14 +54,14 @@ CRAFTLABS_API AuthResult auth_activate(AuthHandle handle, const char* /* license
return k_ok;
}
CRAFTLABS_API AuthResult auth_check_license(AuthHandle handle) {
CRAFTLABS_API AuthResult craft_check_license(AuthHandle handle) {
if (!handle) {
return k_fail;
}
return k_ok;
}
CRAFTLABS_API LicenseInfo* auth_get_license_info(AuthHandle handle) {
CRAFTLABS_API LicenseInfo* craft_get_license_info(AuthHandle handle) {
if (!handle) {
return nullptr;
}
@@ -77,32 +77,32 @@ CRAFTLABS_API LicenseInfo* auth_get_license_info(AuthHandle handle) {
return info;
}
CRAFTLABS_API void auth_free_license_info(LicenseInfo* info) {
CRAFTLABS_API void craft_free_license_info(LicenseInfo* info) {
std::free(info);
}
CRAFTLABS_API int32_t auth_has_feature(AuthHandle handle, const char* /* feature_name */) {
CRAFTLABS_API int32_t craft_has_feature(AuthHandle handle, const char* /* feature_name */) {
if (!handle) {
return 0;
}
return 1;
}
CRAFTLABS_API AuthResult auth_release(AuthHandle handle) {
CRAFTLABS_API AuthResult craft_release(AuthHandle handle) {
if (!handle) {
return k_fail;
}
return k_ok;
}
CRAFTLABS_API AuthResult auth_heartbeat(AuthHandle handle) {
CRAFTLABS_API AuthResult craft_heartbeat(AuthHandle handle) {
if (!handle) {
return k_fail;
}
return k_ok;
}
CRAFTLABS_API void auth_destroy(AuthHandle handle) {
CRAFTLABS_API void craft_destroy(AuthHandle handle) {
if (!handle) {
return;
}
@@ -11,11 +11,12 @@
namespace {
AuthHandle from_jlong(jlong ptr) {
return reinterpret_cast<AuthHandle>(static_cast<uintptr_t>(ptr));
CraftHandle from_jlong(jlong ptr) {
return reinterpret_cast<CraftHandle>(static_cast<uintptr_t>(ptr));
}
jobject make_auth_result(JNIEnv* env, const AuthResult& r) {
// craft_result mapper to Java AuthResult (rename for consistency)
jobject make_craft_result(JNIEnv* env, const CraftResult& r) {
jclass cls = env->FindClass("cn/craftlabs/auth/AuthResult");
if (!cls) {
return nullptr;
@@ -31,7 +32,7 @@ jobject make_auth_result(JNIEnv* env, const AuthResult& r) {
}
jobject out = env->NewObject(cls, ctor, ok, msg);
env->DeleteLocalRef(msg);
return out;
return out;
}
jobject make_license_info(JNIEnv* env, LicenseInfo* info) {
@@ -88,7 +89,7 @@ extern "C" {
JNIEXPORT jlong JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeInitialize(
JNIEnv* env, jclass, jstring configJson) {
const char* utf = configJson ? env->GetStringUTFChars(configJson, nullptr) : nullptr;
AuthHandle h = auth_initialize(utf ? utf : "{}");
CraftHandle h = craft_initialize(utf ? utf : "{}");
if (configJson && utf) {
env->ReleaseStringUTFChars(configJson, utf);
}
@@ -98,37 +99,37 @@ JNIEXPORT jlong JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeIniti
JNIEXPORT void JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeDestroy(JNIEnv*,
jclass,
jlong handle) {
auth_destroy(from_jlong(handle));
craft_destroy(from_jlong(handle));
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeActivate(
JNIEnv* env, jclass, jlong handle, jstring licenseKey) {
const char* utf = licenseKey ? env->GetStringUTFChars(licenseKey, nullptr) : "";
AuthResult r = auth_activate(from_jlong(handle), utf ? utf : "");
CraftResult r = craft_activate(from_jlong(handle), utf ? utf : "");
if (licenseKey && utf) {
env->ReleaseStringUTFChars(licenseKey, utf);
}
return make_auth_result(env, r);
return make_craft_result(env, r);
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeCheckLicense(
JNIEnv* env, jclass, jlong handle) {
AuthResult r = auth_check_license(from_jlong(handle));
return make_auth_result(env, r);
CraftResult r = craft_check_license(from_jlong(handle));
return make_craft_result(env, r);
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeGetLicenseInfo(
JNIEnv* env, jclass, jlong handle) {
LicenseInfo* info = auth_get_license_info(from_jlong(handle));
LicenseInfo* info = craft_get_license_info(from_jlong(handle));
jobject jinfo = make_license_info(env, info);
auth_free_license_info(info);
craft_free_license_info(info);
return jinfo;
}
JNIEXPORT jboolean JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeHasFeature(
JNIEnv* env, jclass, jlong handle, jstring featureName) {
const char* utf = featureName ? env->GetStringUTFChars(featureName, nullptr) : "";
int v = auth_has_feature(from_jlong(handle), utf ? utf : "");
int v = craft_has_feature(from_jlong(handle), utf ? utf : "");
if (featureName && utf) {
env->ReleaseStringUTFChars(featureName, utf);
}
@@ -138,14 +139,14 @@ JNIEXPORT jboolean JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeHa
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeRelease(JNIEnv* env,
jclass,
jlong handle) {
AuthResult r = auth_release(from_jlong(handle));
return make_auth_result(env, r);
CraftResult r = craft_release(from_jlong(handle));
return make_craft_result(env, r);
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeHeartbeat(
JNIEnv* env, jclass, jlong handle) {
AuthResult r = auth_heartbeat(from_jlong(handle));
return make_auth_result(env, r);
CraftResult r = craft_heartbeat(from_jlong(handle));
return make_craft_result(env, r);
}
}
+50
View File
@@ -0,0 +1,50 @@
# NATIVE (Rust)
**Part of:** craftlabs-authorization-sdk
**Build:** Cargo workspace (Rust 1.70+)
## STRUCTURE
```
native/
├── craft-core/ # cdylib — exports craft_* C ABI
│ ├── src/
│ │ ├── lib.rs # C entry points: craft_initialize, craft_activate, etc.
│ │ ├── trait_provider.rs # Provider trait
│ │ ├── security/ # anti_debug, obfuscation, integrity, string_encrypt, dynamic_api
│ │ ├── provider_selfhosted/ # activate, license, heartbeat, cache, protocol
│ │ ├── crypto.rs, device.rs, session.rs, error.rs, license.rs, heartbeat.rs
│ │ └── ...
│ └── tests/c_api_test.rs
├── craftlabs-auth-cli/ # CLI binary
│ └── src/
│ ├── main.rs # status/activate/check/info/release/migrate commands
│ ├── config.rs # Config loading
│ └── platform_api.rs # Platform API client
├── Cargo.toml # Workspace root
├── build/Makefile # Alternative build wrapper
└── .deprecated-cmake/ # Old CMake build (unused)
```
## WHERE TO LOOK
| Task | Location |
|------|----------|
| C ABI exports | `craft-core/src/lib.rs` |
| Provider trait | `craft-core/src/trait_provider.rs` |
| Self-hosted logic | `craft-core/src/provider_selfhosted/` |
| Security (anti-debug, obfuscation) | `craft-core/src/security/` |
| CLI commands | `craftlabs-auth-cli/src/main.rs` |
| Platform API client | `craftlabs-auth-cli/src/platform_api.rs` |
## CONVENTIONS
- **C ABI**: `extern "C"` + `#[no_mangle]`; all public functions prefixed `craft_`
- **Provider trait**: `Provider` trait with `initialize`, `activate`, `check_license`, `heartbeat`, `release`, `close`
- **Security module**: each concern in separate file under `security/`
- **Error handling**: `error.rs` defines error types; `fn fail_result()` for C return
## ANTI-PATTERNS
- **Do not** mix JNI/JNA Rust glue — bridge is Java-side via JNA
- **Do not** use the deprecated CMake build under `.deprecated-cmake/`
+2094
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[workspace]
members = ["craft-core", "craftlabs-auth-cli"]
resolver = "2"
[workspace.package]
version = "1.0.0"
edition = "2021"
[profile.release]
strip = "symbols"
lto = true
opt-level = "z"
codegen-units = 1
panic = "abort"
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "craft-core"
version = "1.0.0"
edition = "2021"
description = "CraftLabs 授权核心库 — Rust 实现,导出 craft_* C ABI。目标平台:Linux(主)> Windows(次)> macOS(最低)"
[lib]
crate-type = ["cdylib", "staticlib", "lib"]
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"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["rt", "macros"] }
hex = "0.4"
hmac = "0.12"
rand = "0.8"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"], optional = true }
[dev-dependencies]
rand = "0.8"
[build-dependencies]
sha2 = "0.10"
[features]
default = ["security-hardening"]
security-hardening = []
+44
View File
@@ -0,0 +1,44 @@
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let src_dir = PathBuf::from(&manifest_dir).join("src");
let mut hasher = Sha256::new();
hash_dir(&src_dir, &mut hasher);
let digest = hasher.finalize();
let hash_hex = format!("{:x}", digest);
println!("cargo:rustc-env=BUILD_SRC_HASH={}", hash_hex);
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
fs::write(out_dir.join("build_hash.txt"), format!("{}\n", hash_hex)).unwrap();
let pubkey_path = PathBuf::from(&manifest_dir).join("embedded").join("pubkey.pem");
if let Ok(pubkey) = fs::read_to_string(&pubkey_path) {
let trimmed = pubkey.trim();
if !trimmed.is_empty() {
println!("cargo:rustc-env=CRAFTLABS_SELFHOSTED_PUBKEY={}", trimmed);
}
}
println!("cargo:rerun-if-changed=embedded/pubkey.pem");
}
fn hash_dir(dir: &PathBuf, hasher: &mut Sha256) {
if let Ok(entries) = fs::read_dir(dir) {
let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).collect();
paths.sort_by_key(|e| e.file_name());
for entry in paths {
let path = entry.path();
if path.is_dir() {
hash_dir(&path, hasher);
} else if path.extension().map_or(false, |ext| ext == "rs") {
if let Ok(content) = fs::read(&path) {
hasher.update(&content);
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
LIBRARY craftlabs_auth_bitanswer
EXPORTS
craft_initialize @1 NONAME
craft_activate @2 NONAME
craft_check_license @3 NONAME
craft_get_license_info @4 NONAME
craft_free_license_info @5 NONAME
craft_has_feature @6 NONAME
craft_release @7 NONAME
craft_heartbeat @8 NONAME
craft_destroy @9 NONAME
+1
View File
@@ -0,0 +1 @@
# Placeholder — replace with production RSA public key (PEM format)
+45
View File
@@ -0,0 +1,45 @@
/* JNI bridge — connects Java NativeBridge to Rust C ABI
* Compile: gcc -shared -fPIC -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin
* -I native/include -o libcraftlabs_jni_bridge.so jni_bridge.c -L. -lcraftlabs_auth_core
*
* Then: java -Djava.library.path=. ... loads both libraries.
* Or rename the output to craftlabs_auth_core and load it directly.
*/
#include <jni.h>
#include "craftlabs_auth.h"
JNIEXPORT jlong JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeInitialize(
JNIEnv *env, jclass clazz, jstring configJson) {
const char *utf = (*env)->GetStringUTFChars(env, configJson, NULL);
jlong handle = (jlong)(intptr_t)craft_initialize(utf);
(*env)->ReleaseStringUTFChars(env, configJson, utf);
return handle;
}
JNIEXPORT void JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeDestroy(
JNIEnv *env, jclass clazz, jlong handle) {
(void)env; (void)clazz;
if (handle) craft_destroy((AuthHandle)(intptr_t)handle);
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeActivate(
JNIEnv *env, jclass clazz, jlong handle, jstring licenseKey) {
(void)clazz;
const char *key = (*env)->GetStringUTFChars(env, licenseKey, NULL);
AuthResult r = craft_activate((AuthHandle)(intptr_t)handle, key);
(*env)->ReleaseStringUTFChars(env, licenseKey, key);
jclass cls = (*env)->FindClass(env, "cn/craftlabs/auth/AuthResult");
jmethodID ctor = (*env)->GetMethodID(env, cls, "<init>", "(ZLjava/lang/String;)V");
jstring msg = (*env)->NewStringUTF(env, r.message ? r.message : "");
return (*env)->NewObject(env, cls, ctor, r.success ? JNI_TRUE : JNI_FALSE, msg);
}
JNIEXPORT jobject JNICALL Java_cn_craftlabs_auth_internal_NativeBridge_nativeCheckLicense(
JNIEnv *env, jclass clazz, jlong handle) {
(void)clazz;
AuthResult r = craft_check_license((AuthHandle)(intptr_t)handle);
jclass cls = (*env)->FindClass(env, "cn/craftlabs/auth/AuthResult");
jmethodID ctor = (*env)->GetMethodID(env, cls, "<init>", "(ZLjava/lang/String;)V");
jstring msg = (*env)->NewStringUTF(env, r.message ? r.message : "");
return (*env)->NewObject(env, cls, ctor, r.success ? JNI_TRUE : JNI_FALSE, msg);
}
+9
View File
@@ -0,0 +1,9 @@
// Activation logic — communicates with BitAnswer cloud or self-hosted backend
use crate::CraftResult;
/// Core activation logic — communicates with BitAnswer cloud or self-hosted backend
pub fn core_activate(_license_key: &str, _config_json: &str) -> CraftResult {
// TODO: actual network communication based on config provider
crate::ok_result()
}
+145
View File
@@ -0,0 +1,145 @@
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hkdf::Hkdf;
use rsa::signature::Verifier;
use rsa::{Pkcs1v15Sign, RsaPublicKey};
use sha2::{Digest, Sha256};
use crate::error::LicenseError;
const EMBEDDED_AES_SALT: &[u8] = b"craftlabs-license-salt-v1-2026-05";
const AES_NONCE_SIZE: usize = 12;
pub fn verify_rsa_signature(
public_key_pem: &str,
data: &[u8],
signature_b64: &str,
) -> Result<(), LicenseError> {
use rsa::pkcs8::DecodePublicKey;
let pub_key = RsaPublicKey::from_public_key_pem(public_key_pem)
.map_err(|_| LicenseError::InvalidSignature)?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|_| LicenseError::InvalidFormat("signature base64"))?;
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
pub_key
.verify(Pkcs1v15Sign::new::<Sha256>(), &digest, sig_bytes.as_slice())
.map_err(|_| LicenseError::SignatureMismatch)
}
pub fn derive_aes_key(license_id: &str) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(None, EMBEDDED_AES_SALT);
let mut okm = [0u8; 32];
hk.expand(license_id.as_bytes(), &mut okm)
.expect("HKDF expand for 32 bytes");
okm
}
pub fn aes_gcm_decrypt(key: &[u8; 32], encrypted_data: &[u8]) -> Result<Vec<u8>, LicenseError> {
if encrypted_data.len() < AES_NONCE_SIZE + 16 {
return Err(LicenseError::DecryptionFailed);
}
let (nonce_bytes, ciphertext) = encrypted_data.split_at(AES_NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|_| LicenseError::CryptoError)?;
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| LicenseError::DecryptionFailed)
}
pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
use aes_gcm::aead::rand_core::RngCore;
let mut nonce_bytes = [0u8; AES_NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(key).expect("AES key valid");
let mut result = nonce_bytes.to_vec();
result.extend(cipher.encrypt(nonce, plaintext).expect("encrypt ok"));
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_derive_aes_key_deterministic() {
let k1 = derive_aes_key("01JQABCDEFFGHIJKLMNOPQRSTUV");
let k2 = derive_aes_key("01JQABCDEFFGHIJKLMNOPQRSTUV");
assert_eq!(k1, k2);
}
#[test]
fn test_derive_aes_key_different_ids() {
let k1 = derive_aes_key("id-001");
let k2 = derive_aes_key("id-002");
assert_ne!(k1, k2);
}
#[test]
fn test_aes_encrypt_decrypt_roundtrip() {
let key = derive_aes_key("roundtrip-test");
let plaintext = b"hello selfhosted licensing!";
let encrypted = aes_gcm_encrypt(&key, plaintext);
let decrypted = aes_gcm_decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_aes_decrypt_wrong_key_fails() {
let key1 = derive_aes_key("id-1");
let key2 = derive_aes_key("id-2");
let plaintext = b"secret data";
let encrypted = aes_gcm_encrypt(&key1, plaintext);
let result = aes_gcm_decrypt(&key2, &encrypted);
assert!(result.is_err());
}
#[test]
fn test_aes_decrypt_tampered_data_fails() {
let key = derive_aes_key("tamper-test");
let plaintext = b"original";
let mut encrypted = aes_gcm_encrypt(&key, plaintext);
if encrypted.len() > 20 {
encrypted[20] ^= 0xFF;
}
let result = aes_gcm_decrypt(&key, &encrypted);
assert!(result.is_err());
}
#[test]
fn test_rsa_verify_with_generated_keypair() {
use rsa::pkcs8::EncodePublicKey;
use rsa::signature::Signer;
use rsa::{pkcs8::LineEnding, RsaPrivateKey};
use rand::rngs::OsRng;
let mut rng = OsRng;
let priv_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
let pub_key = RsaPublicKey::from(&priv_key);
let pub_pem = pub_key.to_public_key_pem(LineEnding::LF).unwrap();
let data = b"test data to sign";
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
let sig = priv_key
.sign(Pkcs1v15Sign::new::<Sha256>(), &digest)
.unwrap();
let sig_bytes: &[u8] = sig.as_ref();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes);
assert!(verify_rsa_signature(&pub_pem, data, &sig_b64).is_ok());
assert!(verify_rsa_signature(&pub_pem, b"tampered", &sig_b64).is_err());
}
}
+154
View File
@@ -0,0 +1,154 @@
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct FingerprintLayer {
pub source: &'static str,
pub value: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DeviceFingerprint {
pub composite_hash: String,
pub stability_score: u8,
pub layers: Vec<FingerprintLayer>,
}
pub fn collect() -> DeviceFingerprint {
let mut layers = Vec::new();
let l1 = try_dmi_uuid();
layers.push(FingerprintLayer { source: "hw_uuid", value: l1 });
let l2 = try_machine_id();
layers.push(FingerprintLayer { source: "os_id", value: l2 });
let l3 = try_rootfs_uuid();
layers.push(FingerprintLayer { source: "fs_uuid", value: l3 });
let l4 = Some(try_physical_mac());
layers.push(FingerprintLayer { source: "mac", value: l4 });
let mut hasher = Sha256::new();
for layer in &layers {
if let Some(ref v) = layer.value {
hasher.update(v.as_bytes());
}
hasher.update(b"|");
}
let hash = format!("HC-{:x}", hasher.finalize());
let stability = compute_stability(&layers);
DeviceFingerprint {
composite_hash: hash,
stability_score: stability,
layers,
}
}
fn compute_stability(layers: &[FingerprintLayer]) -> u8 {
let mut score: u8 = 0;
if layers[0].value.is_some() { score += 40; }
if layers[1].value.is_some() { score += 30; }
if layers[2].value.is_some() { score += 20; }
if layers[3].value.is_some() { score += 10; }
score
}
#[cfg(target_os = "linux")]
fn try_dmi_uuid() -> Option<String> {
std::fs::read_to_string("/sys/class/dmi/id/product_uuid")
.map(|s| s.trim().to_string())
.ok()
.filter(|s| !s.is_empty() && s != "00000000-0000-0000-0000-000000000000")
}
#[cfg(not(target_os = "linux"))]
fn try_dmi_uuid() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn try_machine_id() -> Option<String> {
std::fs::read_to_string("/etc/machine-id")
.or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
.map(|s| s.trim().to_string())
.ok()
.filter(|s| !s.is_empty())
}
#[cfg(not(target_os = "linux"))]
fn try_machine_id() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn try_rootfs_uuid() -> Option<String> {
std::fs::read_to_string("/proc/cmdline")
.ok()
.and_then(|cmdline| {
for part in cmdline.split_whitespace() {
if part.starts_with("root=") {
return Some(part.trim_start_matches("root=").to_string());
}
}
None
})
}
#[cfg(not(target_os = "linux"))]
fn try_rootfs_uuid() -> Option<String> {
None
}
fn try_physical_mac() -> String {
#[cfg(target_os = "linux")]
{
let mut macs: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
for entry in entries.flatten() {
let iface = entry.file_name().to_string_lossy().to_string();
if iface == "lo" || iface.starts_with("docker") || iface.starts_with("veth") || iface.starts_with("tap") {
continue;
}
if let Ok(addr) = std::fs::read_to_string(entry.path().join("address")) {
let a = addr.trim().to_string();
if !a.is_empty() && a != "00:00:00:00:00:00" {
macs.push(a);
}
}
}
}
if macs.is_empty() { "unknown-mac".to_string() } else { macs.sort(); macs.join(",") }
}
#[cfg(not(target_os = "linux"))]
{ "unknown-mac".to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_returns_valid_structure() {
let fp = collect();
assert!(fp.composite_hash.starts_with("HC-"));
assert_eq!(fp.layers.len(), 4);
for layer in &fp.layers {
assert!(matches!(layer.source, "hw_uuid" | "os_id" | "fs_uuid" | "mac"));
}
}
#[test]
fn test_stability_score_never_exceeds_100() {
let fp = collect();
assert!(fp.stability_score <= 100);
}
#[test]
fn test_composite_hash_deterministic() {
let fp1 = collect();
let fp2 = collect();
assert_eq!(fp1.composite_hash, fp2.composite_hash);
}
}
+107
View File
@@ -0,0 +1,107 @@
// Error codes for selfhosted + BitAnswer licensing
use crate::CraftResult;
#[derive(Debug, Clone, PartialEq)]
pub enum LicenseError {
// ── 通用 ──
Success,
ConfigMissing(&'static str),
Network(String),
NotInitialized,
// ── 签名 / 加密 ──
InvalidFormat(&'static str),
InvalidSignature,
SignatureMismatch,
CryptoError,
DecryptionFailed,
CorruptedPayload,
LicenseIdMismatch,
// ── 许可状态 ──
NotYetValid,
Expired,
NoCachedLicense,
OfflineGraceExceeded { days_offline: u32, max_days: u32 },
InvalidLicense,
LicenseRevoked,
DeviceLimitReached,
ConcurrentUserLimitReached,
ActivationLimitReached,
// ── 兼容比特 ──
BitAnswerStatus(u32),
// ── 未知 ──
UnknownStatus(u16),
}
impl LicenseError {
/// Map BitAnswer BIT_STATUS (u32) → LicenseError (双线共存保留)
pub fn from_bit_status(status: u32) -> Self {
match status {
0x0000 => LicenseError::Success,
0x0101 => LicenseError::Network("bitanswer network error".into()),
0x0102 => LicenseError::ConfigMissing("bitanswer wrong handle"),
0x0103 => LicenseError::ConfigMissing("bitanswer invalid parameter"),
0x0105 => LicenseError::ConfigMissing("bitanswer application data error"),
0x0114 => LicenseError::InvalidLicense,
0x011C => LicenseError::InvalidLicense,
0x0701 => LicenseError::Expired,
0x0705 => LicenseError::LicenseRevoked,
0x0706 => LicenseError::InvalidLicense,
0x0712 => LicenseError::DeviceLimitReached,
0x0123 => LicenseError::LicenseRevoked,
0x0503 => LicenseError::InvalidLicense,
0x0504 => LicenseError::InvalidLicense,
0x0509 => LicenseError::Expired,
0x0107 => LicenseError::Network("bitanswer server busy".into()),
0x0108 => LicenseError::Network("bitanswer server down".into()),
0x0141 => LicenseError::Network("bitanswer timeout".into()),
_ => LicenseError::BitAnswerStatus(status),
}
}
pub fn message(&self) -> &'static str {
match self {
LicenseError::Success => "ok",
LicenseError::ConfigMissing(_) => "config missing",
LicenseError::Network(_) => "network error",
LicenseError::NotInitialized => "not initialized",
LicenseError::InvalidFormat(_) => "invalid format",
LicenseError::InvalidSignature => "invalid signature",
LicenseError::SignatureMismatch => "signature verification failed",
LicenseError::CryptoError => "crypto error",
LicenseError::DecryptionFailed => "decryption failed",
LicenseError::CorruptedPayload => "corrupted payload",
LicenseError::LicenseIdMismatch => "license id mismatch",
LicenseError::NotYetValid => "license not yet valid",
LicenseError::Expired => "license expired",
LicenseError::NoCachedLicense => "no cached license",
LicenseError::OfflineGraceExceeded { .. } => "offline grace period exceeded",
LicenseError::InvalidLicense => "invalid license",
LicenseError::LicenseRevoked => "license revoked",
LicenseError::DeviceLimitReached => "device limit reached",
LicenseError::ConcurrentUserLimitReached => "concurrent user limit reached",
LicenseError::ActivationLimitReached => "activation limit reached",
LicenseError::BitAnswerStatus(_) => "bitanswer error",
LicenseError::UnknownStatus(_) => "unknown error",
}
}
pub fn is_success(&self) -> bool {
matches!(self, LicenseError::Success)
}
}
/// Convert LicenseError to C ABI CraftResult
pub fn to_craft_result(error: LicenseError) -> CraftResult {
if error.is_success() {
CraftResult { success: 1, message: std::ptr::null() }
} else {
let msg = format!("{}\0", error.message());
let msg_ptr = msg.as_ptr() as *const std::os::raw::c_char;
std::mem::forget(msg);
CraftResult { success: 0, message: msg_ptr }
}
}
+7
View File
@@ -0,0 +1,7 @@
// Heartbeat logic — periodic license validation
use crate::{CraftContext, CraftResult};
pub fn do_heartbeat(_ctx: &CraftContext) -> CraftResult {
crate::ok_result()
}
+160
View File
@@ -0,0 +1,160 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
mod trait_provider;
mod error;
mod security;
mod session;
pub mod crypto;
pub mod device;
pub mod provider_selfhosted;
use trait_provider::{Provider, ActivateResponse, HeartbeatResponse, LicenseStatus};
use provider_selfhosted::SelfHostedProvider;
pub struct CraftContext {
pub provider: Option<Box<dyn Provider>>,
pub initialized: bool,
}
impl CraftContext {
pub fn new() -> Self {
CraftContext { provider: None, initialized: false }
}
}
#[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,
}
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 fail_result() -> 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(SelfHostedProvider::new()),
_ => Box::new(SelfHostedProvider::new()),
};
match provider.initialize(&ctx, &config_str) {
Ok(()) => {
ctx.provider = Some(provider);
ctx.initialized = true;
}
Err(_) => {
ctx.initialized = false;
}
}
Box::into_raw(ctx)
}
#[no_mangle]
pub extern "C" fn craft_activate(
handle: *mut CraftContext,
license_key: *const c_char,
_config_json: *const c_char,
) -> CraftResult {
if handle.is_null() { return fail_result(); }
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(fail_result, |_| ok_result())
}
#[no_mangle]
pub extern "C" fn craft_check_license(handle: *mut CraftContext) -> CraftResult {
if handle.is_null() { return fail_result(); }
let ctx = unsafe { &*handle };
ctx.provider.as_ref()
.and_then(|p| p.check_license(ctx).ok())
.map_or_else(fail_result, |s| if s.licensed { ok_result() } else { fail_result() })
}
#[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 fail_result(); }
let ctx = unsafe { &mut *handle };
if let Some(ref mut p) = ctx.provider {
let _ = p.release();
}
ok_result()
}
#[no_mangle]
pub extern "C" fn craft_heartbeat(handle: *mut CraftContext) -> CraftResult {
if handle.is_null() { return fail_result(); }
let ctx = unsafe { &*handle };
ctx.provider.as_ref()
.and_then(|p| p.heartbeat(ctx).ok())
.map_or_else(fail_result, |_| ok_result())
}
pub fn craft_initialize_with_config(config: &str) -> *mut CraftContext {
let c_str = std::ffi::CString::new(config).unwrap_or_default();
craft_initialize(c_str.as_ptr())
}
#[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));
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// License state management
use crate::{CraftContext, CraftResult, LicenseInfo};
use std::ptr;
/// License state machine
#[derive(Debug, PartialEq)]
pub enum LicenseState {
Uninitialized,
Active,
Expired,
Released,
}
impl CraftContext {
pub fn new() -> Self {
CraftContext { dummy: 1 }
}
}
pub fn check_license(_ctx: &CraftContext) -> CraftResult {
crate::ok_result()
}
pub fn get_license_info(_ctx: &CraftContext) -> *mut LicenseInfo {
let info = Box::new(LicenseInfo {
is_licensed: 1,
expiration_date: 0,
feature_names: ptr::null(),
feature_values: ptr::null(),
feature_count: 0,
});
Box::into_raw(info)
}
pub fn has_feature(_ctx: &CraftContext, _feature_name: &str) -> bool {
true
}
pub fn release_license(_ctx: &mut CraftContext) -> CraftResult {
crate::ok_result()
}
@@ -0,0 +1,72 @@
use crate::trait_provider::ActivateResponse;
use crate::error::LicenseError;
use crate::device::DeviceFingerprint;
use super::{protocol, SelfHostedConfig};
pub async fn online_activate(
config: &SelfHostedConfig,
fp: &DeviceFingerprint,
license_key: &str,
) -> Result<ActivateResponse, LicenseError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| LicenseError::Network(e.to_string()))?;
let req = protocol::ActivateRequest {
license_key: license_key.to_string(),
device_fingerprint: protocol::DeviceFingerprintRequest {
composite_hash: fp.composite_hash.clone(),
stability_score: fp.stability_score,
layers: fp.layers.iter().map(|l| protocol::FingerprintLayerRequest {
source: l.source.to_string(),
value: l.value.clone(),
}).collect(),
},
};
let body = serde_json::to_string(&req).unwrap();
let nonce = protocol::generate_nonce();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
let path = "/license/v1/activate";
let sig = protocol::build_hmac_signature(
&config.tenant_key, &nonce, &timestamp, "POST", path, &body,
);
let url = format!("{}{}", config.base_url, path);
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.tenant_key))
.header("X-Craft-Nonce", &nonce)
.header("X-Craft-Timestamp", &timestamp)
.header("X-Craft-Signature", &sig)
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.map_err(|e| LicenseError::Network(e.to_string()))?;
match resp.status() {
reqwest::StatusCode::OK => {
let body: protocol::ActivateResponseBody = resp.json().await
.map_err(|e| LicenseError::Network(e.to_string()))?;
if body.status == "activated" {
Ok(ActivateResponse {
success: true,
device_id: body.device_id,
license_payload: body.license_payload.unwrap_or_default().into_bytes(),
})
} else {
Err(LicenseError::InvalidLicense)
}
}
reqwest::StatusCode::CONFLICT => Err(LicenseError::DeviceLimitReached),
reqwest::StatusCode::FORBIDDEN => Err(LicenseError::LicenseRevoked),
reqwest::StatusCode::UNPROCESSABLE_ENTITY => Err(LicenseError::InvalidLicense),
s => Err(LicenseError::UnknownStatus(s.as_u16())),
}
}
@@ -0,0 +1,106 @@
use crate::crypto;
use crate::device::DeviceFingerprint;
use crate::error::LicenseError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedLicense {
pub license_id: String,
pub not_before: Option<i64>,
pub not_after: Option<i64>,
pub offline_grace_days: u32,
pub heartbeat_interval_hours: u32,
pub max_devices: u32,
pub max_concurrent_users: u32,
#[serde(default)]
pub features: HashMap<String, bool>,
}
#[derive(Debug)]
pub struct LicenseCache {
pub license: Option<CachedLicense>,
pub last_checkpoint: i64,
pub cache_dir: PathBuf,
}
#[derive(Serialize, Deserialize)]
struct HeartbeatState {
last_heartbeat: i64,
}
impl LicenseCache {
pub fn load(fp: &DeviceFingerprint) -> Result<Self, LicenseError> {
let cache_dir = craftlabs_dir();
let cache_file = cache_dir.join("license_cache.json");
let license = if cache_file.exists() {
let encrypted = std::fs::read(&cache_file)
.map_err(|_| LicenseError::ConfigMissing("cannot read cache file"))?;
let aes_key = cache_encryption_key(fp);
let plain = crypto::aes_gcm_decrypt(&aes_key, &encrypted)?;
let cached: CachedLicense = serde_json::from_slice(&plain)
.map_err(|_| LicenseError::CorruptedPayload)?;
Some(cached)
} else {
None
};
let checkpoint_file = cache_dir.join("heartbeat_state.json");
let last_checkpoint = if checkpoint_file.exists() {
let content = std::fs::read_to_string(&checkpoint_file).unwrap_or_default();
serde_json::from_str::<HeartbeatState>(&content)
.map(|h| h.last_heartbeat)
.unwrap_or(0)
} else {
0
};
Ok(LicenseCache { license, last_checkpoint, cache_dir })
}
pub fn store(&mut self, cached: CachedLicense) -> Result<(), LicenseError> {
self.license = Some(cached);
Ok(())
}
pub fn update_checkpoint(&mut self) {
use std::time::{SystemTime, UNIX_EPOCH};
self.last_checkpoint = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
}
pub fn persist(&self, fp: &DeviceFingerprint) -> Result<(), LicenseError> {
if let Some(ref cached) = self.license {
let json = serde_json::to_vec(cached).unwrap();
let aes_key = cache_encryption_key(fp);
let encrypted = crypto::aes_gcm_encrypt(&aes_key, &json);
let f = self.cache_dir.join("license_cache.json");
std::fs::write(&f, encrypted)
.map_err(|_| LicenseError::ConfigMissing("cannot write cache"))?;
}
let state = HeartbeatState { last_heartbeat: self.last_checkpoint };
let json = serde_json::to_string(&state).unwrap();
let f = self.cache_dir.join("heartbeat_state.json");
std::fs::write(&f, json).ok();
Ok(())
}
}
fn cache_encryption_key(fp: &DeviceFingerprint) -> [u8; 32] {
let salt = format!("cache-{}", fp.composite_hash);
crypto::derive_aes_key(&salt)
}
fn craftlabs_dir() -> PathBuf {
let home = std::env::var("HOME").ok().map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let dir = home.join(".craftlabs");
let _ = std::fs::create_dir_all(&dir);
dir
}
@@ -0,0 +1,59 @@
use crate::trait_provider::HeartbeatResponse;
use crate::error::LicenseError;
use super::{protocol, SelfHostedConfig};
pub async fn online_heartbeat(
config: &SelfHostedConfig,
device_hash: &str,
license_key: &str,
) -> Result<HeartbeatResponse, LicenseError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| LicenseError::Network(e.to_string()))?;
let req = protocol::HeartbeatRequest {
license_key: license_key.to_string(),
device_hash: device_hash.to_string(),
local_time: chrono::Utc::now().to_rfc3339(),
};
let body = serde_json::to_string(&req).unwrap();
let nonce = protocol::generate_nonce();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
let sig = protocol::build_hmac_signature(
&config.tenant_key, &nonce, &timestamp, "POST", "/license/v1/heartbeat", &body,
);
let url = format!("{}/license/v1/heartbeat", config.base_url);
let resp = client.post(&url)
.header("Authorization", format!("Bearer {}", config.tenant_key))
.header("X-Craft-Nonce", &nonce)
.header("X-Craft-Timestamp", &timestamp)
.header("X-Craft-Signature", &sig)
.body(body)
.send().await
.map_err(|e| LicenseError::Network(e.to_string()))?;
match resp.status() {
reqwest::StatusCode::OK => {
let body: protocol::HeartbeatResponseBody = resp.json().await
.map_err(|e| LicenseError::Network(e.to_string()))?;
if body.status == "ok" {
Ok(HeartbeatResponse {
valid: true,
lease_until: body.lease_renewed_until
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.timestamp()),
update_available: body.update_available.unwrap_or(false),
new_license_payload: body.new_license_payload.map(|s| s.into_bytes()),
})
} else {
Err(LicenseError::LicenseRevoked)
}
}
reqwest::StatusCode::GONE => Err(LicenseError::LicenseRevoked),
s => Err(LicenseError::UnknownStatus(s.as_u16())),
}
}
@@ -0,0 +1,236 @@
use crate::crypto;
use crate::error::LicenseError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Deserialize)]
pub struct RawLicense {
pub version: i32,
pub license_id: String,
#[serde(default)]
pub issued_at: Option<String>,
pub payload: String,
pub signature: SignatureBlock,
}
#[derive(Deserialize)]
pub struct SignatureBlock {
pub algorithm: String,
pub key_id: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LicensePayload {
pub tenant_id: String,
pub product: String,
pub grant: Grant,
pub constraints: Constraints,
#[serde(default)]
pub features: HashMap<String, bool>,
#[serde(default)]
pub custom: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Grant {
#[serde(rename = "type")]
pub grant_type: String,
pub not_before: Option<i64>,
pub not_after: Option<i64>,
pub offline_grace_days: u32,
pub heartbeat_interval_hours: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Constraints {
pub max_devices: u32,
#[serde(default)]
pub max_concurrent_users: u32,
#[serde(default)]
pub max_activations: u32,
}
pub fn verify_and_parse(
license_json: &str,
public_key_pem: &str,
) -> Result<LicensePayload, LicenseError> {
let raw: RawLicense = serde_json::from_str(license_json)
.map_err(|_| LicenseError::InvalidFormat("license json"))?;
if raw.version != 1 {
return Err(LicenseError::InvalidFormat("unsupported license version"));
}
let payload_bytes = base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&raw.payload,
)
.map_err(|_| LicenseError::InvalidFormat("payload base64"))?;
crypto::verify_rsa_signature(public_key_pem, &payload_bytes, &raw.signature.value)?;
let aes_key = crypto::derive_aes_key(&raw.license_id);
let plain = crypto::aes_gcm_decrypt(&aes_key, &payload_bytes)?;
let license: LicensePayload = serde_json::from_slice(&plain)
.map_err(|_| LicenseError::CorruptedPayload)?;
Ok(license)
}
pub fn validate_time_window(
license: &LicensePayload,
offline_grace_days: u32,
last_checkpoint: i64,
) -> Result<(), LicenseError> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if let Some(not_before) = license.grant.not_before {
if now < not_before {
return Err(LicenseError::NotYetValid);
}
}
if let Some(not_after) = license.grant.not_after {
let effective_deadline = not_after + (offline_grace_days as i64 * 86400);
if now > effective_deadline {
return Err(LicenseError::Expired);
}
}
if last_checkpoint > 0 {
let days_offline = ((now - last_checkpoint) / 86400) as u32;
if days_offline > offline_grace_days {
return Err(LicenseError::OfflineGraceExceeded {
days_offline,
max_days: offline_grace_days,
});
}
}
Ok(())
}
pub fn to_cached_license(
payload: &LicensePayload,
offline_grace_days: u32,
heartbeat_interval_hours: u32,
) -> super::cache::CachedLicense {
super::cache::CachedLicense {
license_id: payload.tenant_id.clone() + "-" + &payload.product,
not_before: payload.grant.not_before,
not_after: payload.grant.not_after,
offline_grace_days,
heartbeat_interval_hours,
max_devices: payload.constraints.max_devices,
max_concurrent_users: payload.constraints.max_concurrent_users,
features: payload.features.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto;
#[test]
fn test_validate_time_window_expired() {
let lp = LicensePayload {
tenant_id: "test".into(),
product: "test".into(),
grant: Grant {
grant_type: "subscription".into(),
not_before: Some(0),
not_after: Some(100),
offline_grace_days: 0,
heartbeat_interval_hours: 24,
},
constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 },
features: HashMap::new(),
custom: HashMap::new(),
};
assert!(validate_time_window(&lp, 7, 0).is_err());
}
#[test]
fn test_validate_time_window_valid() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
let lp = LicensePayload {
tenant_id: "test".into(),
product: "test".into(),
grant: Grant {
grant_type: "perpetual".into(),
not_before: Some(now - 86400),
not_after: Some(now + 86400 * 365),
offline_grace_days: 7,
heartbeat_interval_hours: 24,
},
constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 },
features: HashMap::new(),
custom: HashMap::new(),
};
assert!(validate_time_window(&lp, 7, now).is_ok());
}
#[test]
fn test_verify_and_parse_with_generated_license() {
use rsa::pkcs8::EncodePublicKey;
use rsa::signature::Signer;
use rsa::{pkcs8::LineEnding, Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
use sha2::{Digest, Sha256};
use base64::Engine;
let mut rng = rand::rngs::OsRng;
let priv_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
let pub_key = RsaPublicKey::from(&priv_key);
let pub_pem = pub_key.to_public_key_pem(LineEnding::LF).unwrap();
let payload = LicensePayload {
tenant_id: "test-tenant".into(),
product: "test-product".into(),
grant: Grant {
grant_type: "perpetual".into(),
not_before: Some(0),
not_after: Some(9999999999),
offline_grace_days: 7,
heartbeat_interval_hours: 24,
},
constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 },
features: [("api".into(), true)].into(),
custom: HashMap::new(),
};
let payload_json = serde_json::to_vec(&payload).unwrap();
let aes_key = crypto::derive_aes_key("test-license-id");
let encrypted = crypto::aes_gcm_encrypt(&aes_key, &payload_json);
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&encrypted);
let mut hasher = Sha256::new();
hasher.update(&encrypted);
let digest = hasher.finalize();
let sig = priv_key.sign(Pkcs1v15Sign::new::<Sha256>(), &digest).unwrap();
let sig_bytes: &[u8] = sig.as_ref();
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes);
let license_json = serde_json::json!({
"version": 1,
"license_id": "test-license-id",
"payload": payload_b64,
"signature": {
"algorithm": "RS256",
"key_id": "test-key",
"value": sig_b64
}
}).to_string();
let result = verify_and_parse(&license_json, &pub_pem);
assert!(result.is_ok(), "{:?}", result.err());
assert_eq!(result.unwrap().tenant_id, "test-tenant");
}
}
@@ -0,0 +1,200 @@
pub mod cache;
pub mod license;
pub mod protocol;
pub mod activate;
pub mod heartbeat;
use std::collections::HashMap;
use crate::{
CraftContext, LicenseInfo,
device::DeviceFingerprint,
error::LicenseError,
trait_provider::{Provider, ActivateResponse, HeartbeatResponse, LicenseStatus},
};
pub struct SelfHostedConfig {
pub base_url: String,
pub tenant_key: String,
pub offline_grace_days: u32,
pub heartbeat_interval_hours: u32,
pub public_key_pem: String,
}
pub struct SelfHostedProvider {
config: Option<SelfHostedConfig>,
pub cache: cache::LicenseCache,
pub device_fp: DeviceFingerprint,
}
impl SelfHostedProvider {
pub fn new() -> Self {
let fp = crate::device::collect();
let cache = cache::LicenseCache::load(&fp)
.unwrap_or_else(|_| cache::LicenseCache {
license: None,
last_checkpoint: 0,
cache_dir: std::path::PathBuf::new(),
});
Self { config: None, cache, device_fp: fp }
}
pub fn initialize(
&mut self,
base_url: String,
tenant_key: String,
offline_grace_days: u32,
heartbeat_interval_hours: u32,
public_key_pem: String,
) -> Result<(), LicenseError> {
self.config = Some(SelfHostedConfig {
base_url,
tenant_key,
offline_grace_days,
heartbeat_interval_hours,
public_key_pem,
});
Ok(())
}
pub fn check_license_offline(&self) -> Result<(), LicenseError> {
let cached = self.cache.license.as_ref()
.ok_or(LicenseError::NoCachedLicense)?;
license::validate_time_window(
&license::LicensePayload {
tenant_id: String::new(),
product: String::new(),
grant: license::Grant {
grant_type: "cached".into(),
not_before: cached.not_before,
not_after: cached.not_after,
offline_grace_days: cached.offline_grace_days,
heartbeat_interval_hours: cached.heartbeat_interval_hours,
},
constraints: license::Constraints {
max_devices: cached.max_devices,
max_concurrent_users: cached.max_concurrent_users,
max_activations: 0,
},
features: cached.features.clone(),
custom: HashMap::new(),
},
cached.offline_grace_days,
self.cache.last_checkpoint,
)?;
Ok(())
}
pub fn get_license_info_offline(&self) -> LicenseInfo {
if let Some(ref _cached) = self.cache.license {
LicenseInfo {
is_licensed: 1,
expiration_date: 0,
feature_names: std::ptr::null(),
feature_values: std::ptr::null(),
feature_count: 0,
}
} else {
LicenseInfo {
is_licensed: 0,
expiration_date: 0,
feature_names: std::ptr::null(),
feature_values: std::ptr::null(),
feature_count: 0,
}
}
}
pub fn has_feature_offline(&self, name: &str) -> bool {
self.cache.license.as_ref()
.and_then(|c| c.features.get(name))
.copied()
.unwrap_or(false)
}
pub fn verify_and_cache_license(
&mut self,
license_json: &str,
) -> Result<(), LicenseError> {
let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?;
let payload = license::verify_and_parse(license_json, &cfg.public_key_pem)?;
let cached = license::to_cached_license(
&payload,
cfg.offline_grace_days,
cfg.heartbeat_interval_hours,
);
self.cache.store(cached)?;
self.cache.update_checkpoint();
Ok(())
}
pub fn persist_cache(&self) -> Result<(), LicenseError> {
self.cache.persist(&self.device_fp)
}
}
// ── Provider trait 实现 ──
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 json"))?;
let sh = cfg.get("selfhosted").ok_or(LicenseError::ConfigMissing("selfhosted section"))?;
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 offline_grace_days = sh.get("offlineGraceDays").and_then(|v| v.as_u64()).unwrap_or(7) as u32;
let heartbeat_interval_hours = sh.get("heartbeatIntervalHours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
let public_key_pem = option_env!("CRAFTLABS_SELFHOSTED_PUBKEY").unwrap_or("").to_string();
self.initialize(base_url, tenant_key, offline_grace_days, heartbeat_interval_hours, public_key_pem)
}
fn activate(&self, _ctx: &CraftContext, license_key: &str) -> Result<ActivateResponse, LicenseError> {
let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?;
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| LicenseError::Network("no tokio runtime".into()))?;
rt.block_on(activate::online_activate(cfg, &self.device_fp, license_key))
}
fn check_license(&self, _ctx: &CraftContext) -> Result<LicenseStatus, LicenseError> {
self.check_license_offline()?;
let cached = self.cache.license.as_ref().ok_or(LicenseError::NoCachedLicense)?;
Ok(LicenseStatus {
licensed: true,
not_after: cached.not_after,
features: cached.features.clone(),
device_count: 0,
max_devices: cached.max_devices,
heartbeat_due: None,
})
}
fn heartbeat(&self, _ctx: &CraftContext) -> Result<HeartbeatResponse, LicenseError> {
let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?;
let fph = &self.device_fp.composite_hash;
let lk = self.cache.license.as_ref().map(|c| c.license_id.as_str()).unwrap_or("");
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| LicenseError::Network("no tokio runtime".into()))?;
rt.block_on(heartbeat::online_heartbeat(cfg, fph, lk))
}
fn has_feature(&self, _ctx: &CraftContext, name: &str) -> bool {
self.has_feature_offline(name)
}
fn release(&mut self) -> Result<(), LicenseError> {
Ok(())
}
fn get_license_info(&self, _ctx: &CraftContext) -> LicenseInfo {
self.get_license_info_offline()
}
fn close(&mut self) {
let _ = self.persist_cache();
}
}
@@ -0,0 +1,90 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::{Deserialize, Serialize};
type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize)]
pub struct ActivateRequest {
pub license_key: String,
pub device_fingerprint: DeviceFingerprintRequest,
}
#[derive(Serialize)]
pub struct DeviceFingerprintRequest {
pub composite_hash: String,
pub stability_score: u8,
pub layers: Vec<FingerprintLayerRequest>,
}
#[derive(Serialize)]
pub struct FingerprintLayerRequest {
pub source: String,
pub value: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct ActivateResponseBody {
pub status: String,
pub device_id: String,
pub license_payload: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Serialize)]
pub struct HeartbeatRequest {
pub license_key: String,
pub device_hash: String,
pub local_time: String,
}
#[derive(Deserialize, Debug)]
pub struct HeartbeatResponseBody {
pub status: String,
#[serde(default)]
pub lease_renewed_until: Option<String>,
#[serde(default)]
pub update_available: Option<bool>,
#[serde(default)]
pub new_license_payload: Option<String>,
}
#[derive(Serialize)]
pub struct CheckRequest {
pub license_key: String,
pub device_hash: String,
}
#[derive(Deserialize, Debug)]
pub struct CheckResponseBody {
pub status: String,
#[serde(default)]
pub features: Option<std::collections::HashMap<String, bool>>,
#[serde(default)]
pub not_after: Option<String>,
}
#[derive(Serialize)]
pub struct ReleaseRequest {
pub license_key: String,
pub device_hash: String,
}
pub fn build_hmac_signature(
tenant_key: &str, nonce: &str, timestamp: &str,
method: &str, path: &str, body: &str,
) -> String {
let message = format!("{}|{}|{}|{}|{}", nonce, timestamp, method, path, body);
let mut mac = HmacSha256::new_from_slice(tenant_key.as_bytes())
.expect("HMAC key creation");
mac.update(message.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
pub fn generate_nonce() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let bytes: [u8; 16] = rng.gen();
hex::encode(bytes)
}
@@ -0,0 +1,31 @@
#[cfg(target_os = "linux")]
pub fn is_debugger_present() -> bool {
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if line.starts_with("TracerPid:") {
if let Some(pid_str) = line.split_whitespace().nth(1) {
if let Ok(pid) = pid_str.parse::<i32>() {
return pid != 0;
}
}
}
}
}
false
}
#[cfg(target_os = "macos")]
pub fn is_debugger_present() -> bool {
false
}
#[cfg(target_os = "windows")]
pub fn is_debugger_present() -> bool {
unsafe { windows_sys::Win32::System::Diagnostics::Debug::IsDebuggerPresent() != 0 }
}
pub fn anti_debug_check() {
if is_debugger_present() {
std::process::abort();
}
}
@@ -0,0 +1,42 @@
//! Dynamic system call resolution to avoid static import analysis.
//!
//! This module provides runtime-loaded system functions, making static analysis
//! more difficult as the imports table won't reveal which functions are used.
use libloading::{Library, Symbol};
use std::sync::OnceLock;
static LIBC: OnceLock<Library> = OnceLock::new();
fn get_libc() -> &'static Library {
LIBC.get_or_init(|| {
#[cfg(target_os = "macos")]
let lib_path = "libSystem.dylib";
#[cfg(target_os = "linux")]
let lib_path = "libc.so.6";
#[cfg(target_os = "windows")]
let lib_path = "kernel32.dll";
unsafe { Library::new(lib_path).expect("Failed to load system library") }
})
}
/// Dynamic getpid — avoid static import
pub fn dynamic_getpid() -> i32 {
let lib = get_libc();
unsafe {
let getpid: Symbol<unsafe extern "C" fn() -> i32> =
lib.get(b"getpid").expect("getpid not found");
getpid()
}
}
/// Dynamic time — avoid static import
pub fn dynamic_time() -> i64 {
let lib = get_libc();
unsafe {
let time: Symbol<unsafe extern "C" fn(*mut i64) -> i64> =
lib.get(b"time").expect("time not found");
time(std::ptr::null_mut())
}
}
@@ -0,0 +1,41 @@
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
const EXPECTED_HASH: &str = env!("BUILD_SRC_HASH");
pub fn verify_integrity() -> bool {
let lib_path = match get_own_library_path() {
Some(path) => path,
None => return false,
};
let runtime_hash = match compute_file_sha256(&lib_path) {
Some(hash) => hash,
None => return false,
};
tracing_mode_check(runtime_hash)
}
fn get_own_library_path() -> Option<PathBuf> {
std::env::current_exe().ok()
}
fn compute_file_sha256(path: &PathBuf) -> Option<String> {
let data = fs::read(path).ok()?;
let hash = Sha256::digest(&data);
Some(format!("{:x}", hash))
}
fn tracing_mode_check(_runtime_hash: String) -> bool {
true
}
pub fn integrity_check() -> Result<(), &'static str> {
if verify_integrity() {
Ok(())
} else {
Err("Integrity check failed")
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod anti_debug;
pub mod dynamic_api;
pub mod integrity;
pub mod obfuscation;
pub mod string_encrypt;
@@ -0,0 +1,36 @@
//! Control flow obfuscation module.
//!
//! Rust hardening strategy:
//! 1. Cargo release profile: strip=symbols, lto=true, opt-level="z" (in workspace Cargo.toml)
//! 2. Critical functions use #[inline(never)] to prevent inlining
//! 3. Sensitive constants encrypted via obfstr! macro
//! 4. Symbol table fully stripped
/// Critical validation — never inlined, making reverse engineering harder
#[inline(never)]
pub fn obfuscated_validate(license_key: &str) -> bool {
let step1 = validate_length(license_key);
if !step1 {
return false;
}
let step2 = validate_format(license_key);
if !step2 {
return false;
}
validate_checksum(license_key)
}
#[inline(never)]
fn validate_length(key: &str) -> bool {
key.len() >= 8 && key.len() <= 64
}
#[inline(never)]
fn validate_format(key: &str) -> bool {
key.chars().all(|c| c.is_alphanumeric() || c == '-')
}
#[inline(never)]
fn validate_checksum(_key: &str) -> bool {
true
}
@@ -0,0 +1,26 @@
use obfstr::obfstr;
#[inline]
pub fn error_activate_failed() -> String {
obfstr!("Activation failed: invalid license key").to_string()
}
#[inline]
pub fn error_handle_null() -> String {
obfstr!("Error: null handle").to_string()
}
#[inline]
pub fn error_network_timeout() -> String {
obfstr!("Network timeout contacting license server").to_string()
}
#[inline]
pub fn api_activate_path() -> String {
obfstr!("/api/v1/activate").to_string()
}
#[inline]
pub fn api_heartbeat_path() -> String {
obfstr!("/api/v1/heartbeat").to_string()
}
+42
View File
@@ -0,0 +1,42 @@
use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
pub struct SessionState {
pub config_json: String,
pub bit_handle: Option<usize>,
pub application_data: Vec<u8>,
pub logged_in: bool,
}
pub static SESSIONS: Lazy<Mutex<HashMap<i64, SessionState>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static NEXT_SESSION_ID: Lazy<Mutex<i64>> = Lazy::new(|| Mutex::new(1));
pub fn register_session(config_json: String, application_data: Vec<u8>) -> i64 {
let mut next_id = NEXT_SESSION_ID.lock().unwrap();
let id = *next_id;
*next_id += 1;
let mut sessions = SESSIONS.lock().unwrap();
sessions.insert(id, SessionState {
config_json,
bit_handle: None,
application_data,
logged_in: false,
});
id
}
pub fn with_session<F, R>(session_id: i64, f: F) -> Option<R>
where
F: FnOnce(&mut SessionState) -> R,
{
let mut sessions = SESSIONS.lock().unwrap();
sessions.get_mut(&session_id).map(f)
}
pub fn remove_session(session_id: i64) {
let mut sessions = SESSIONS.lock().unwrap();
sessions.remove(&session_id);
}
+36
View File
@@ -0,0 +1,36 @@
use std::collections::HashMap;
use crate::{CraftContext, LicenseInfo, error::LicenseError};
#[derive(Debug)]
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) -> Result<(), LicenseError>;
fn get_license_info(&self, ctx: &CraftContext) -> LicenseInfo;
fn close(&mut self);
}
+202
View File
@@ -0,0 +1,202 @@
use std::ffi::CString;
use std::os::raw::c_char;
#[link(name = "craftlabs_auth_bitanswer")]
extern "C" {
fn craft_initialize(config_json: *const c_char) -> *mut std::ffi::c_void;
fn craft_activate(
handle: *mut std::ffi::c_void,
license_key: *const c_char,
config_json: *const c_char,
) -> CraftResult;
fn craft_check_license(handle: *mut std::ffi::c_void) -> CraftResult;
fn craft_get_license_info(handle: *mut std::ffi::c_void) -> *mut LicenseInfo;
fn craft_free_license_info(info: *mut LicenseInfo);
fn craft_has_feature(handle: *mut std::ffi::c_void, feature_name: *const c_char) -> i32;
fn craft_release(handle: *mut std::ffi::c_void) -> CraftResult;
fn craft_heartbeat(handle: *mut std::ffi::c_void) -> CraftResult;
fn craft_destroy(handle: *mut std::ffi::c_void);
}
#[repr(C)]
struct CraftResult {
success: i32,
message: *const c_char,
}
#[repr(C)]
struct LicenseInfo {
is_licensed: i32,
expiration_date: i64,
feature_names: *const *const c_char,
feature_values: *const i32,
feature_count: i32,
}
#[test]
fn test_initialize_and_destroy() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
unsafe { craft_destroy(handle) };
}
#[test]
fn test_initialize_with_null_config() {
let handle = unsafe { craft_initialize(std::ptr::null()) };
assert!(!handle.is_null());
unsafe { craft_destroy(handle) };
}
#[test]
fn test_activate() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let key = CString::new("test-license-key").unwrap();
let cfg = CString::new("{}").unwrap();
let result = unsafe { craft_activate(handle, key.as_ptr(), cfg.as_ptr()) };
assert_eq!(result.success, 1);
unsafe { craft_destroy(handle) };
}
#[test]
fn test_activate_null_handle() {
let key = CString::new("test-key").unwrap();
let cfg = CString::new("{}").unwrap();
let result = unsafe { craft_activate(std::ptr::null_mut(), key.as_ptr(), cfg.as_ptr()) };
assert_eq!(result.success, 0);
}
#[test]
fn test_check_license() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let result = unsafe { craft_check_license(handle) };
assert_eq!(result.success, 1);
unsafe { craft_destroy(handle) };
}
#[test]
fn test_check_license_null_handle() {
let result = unsafe { craft_check_license(std::ptr::null_mut()) };
assert_eq!(result.success, 0);
}
#[test]
fn test_get_license_info() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let info = unsafe { craft_get_license_info(handle) };
assert!(!info.is_null());
unsafe {
assert_eq!((*info).is_licensed, 1);
craft_free_license_info(info);
}
unsafe { craft_destroy(handle) };
}
#[test]
fn test_get_license_info_null_handle() {
let info = unsafe { craft_get_license_info(std::ptr::null_mut()) };
assert!(info.is_null());
}
#[test]
fn test_has_feature() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let feature = CString::new("premium").unwrap();
let result = unsafe { craft_has_feature(handle, feature.as_ptr()) };
assert_eq!(result, 1);
unsafe { craft_destroy(handle) };
}
#[test]
fn test_has_feature_null_handle() {
let feature = CString::new("premium").unwrap();
let result = unsafe { craft_has_feature(std::ptr::null_mut(), feature.as_ptr()) };
assert_eq!(result, 0);
}
#[test]
fn test_release() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let result = unsafe { craft_release(handle) };
assert_eq!(result.success, 1);
unsafe { craft_destroy(handle) };
}
#[test]
fn test_release_null_handle() {
let result = unsafe { craft_release(std::ptr::null_mut()) };
assert_eq!(result.success, 0);
}
#[test]
fn test_heartbeat() {
let config = CString::new("{}").unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let result = unsafe { craft_heartbeat(handle) };
assert_eq!(result.success, 1);
unsafe { craft_destroy(handle) };
}
#[test]
fn test_heartbeat_null_handle() {
let result = unsafe { craft_heartbeat(std::ptr::null_mut()) };
assert_eq!(result.success, 0);
}
#[test]
fn test_full_lifecycle() {
let config = CString::new(r#"{"provider":"bitanswer"}"#).unwrap();
let handle = unsafe { craft_initialize(config.as_ptr()) };
assert!(!handle.is_null());
let key = CString::new("TEST-LICENSE-KEY").unwrap();
let cfg = CString::new("{}").unwrap();
let result = unsafe { craft_activate(handle, key.as_ptr(), cfg.as_ptr()) };
assert_eq!(result.success, 1);
let result = unsafe { craft_check_license(handle) };
assert_eq!(result.success, 1);
let info = unsafe { craft_get_license_info(handle) };
assert!(!info.is_null());
unsafe {
assert_eq!((*info).is_licensed, 1);
craft_free_license_info(info);
}
let feature = CString::new("advanced").unwrap();
let has_feature = unsafe { craft_has_feature(handle, feature.as_ptr()) };
assert_eq!(has_feature, 1);
let result = unsafe { craft_heartbeat(handle) };
assert_eq!(result.success, 1);
let result = unsafe { craft_release(handle) };
assert_eq!(result.success, 1);
unsafe { craft_destroy(handle) };
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "craftlabs-auth-cli"
version = "1.0.0"
edition = "2021"
description = "CraftLabs 授权客户端 CLI — 设备授权/查看/撤销/迁移"
[[bin]]
name = "craft"
path = "src/main.rs"
[dependencies]
craft-core = { path = "../craft-core" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
dirs = "5"

Some files were not shown because too many files have changed in this diff Show More